callback method

Future<OAuthSession> callback(
  1. String callback,
  2. OAuthContext context, {
  3. String? issuer,
})

Processes the OAuth 2.0 authorization callback with DPoP (Demonstrating Proof of Possession) support.

This method handles the callback from the OAuth authorization server, validates the response, exchanges the authorization code for tokens using DPoP-bound tokens, and manages DPoP nonce rotation.

callback The full callback URL received from the OAuth authorization server. Must be a valid URI containing the necessary OAuth parameters including state and code.

issuer Optional expected issuer identifier for iss validation (RFC 9207). When omitted, the expected issuer defaults to the issuer discovered during authorize (carried in context) or the service origin. The callback must contain an iss parameter that exactly matches the expected issuer; a missing or mismatched iss throws an OAuthException. atproto requires authorization_response_iss_parameter_supported=true, so every conforming authorization server always includes iss — including on error responses.

Returns a Future<OAuthSession> containing the access token, refresh token, and associated metadata including DPoP-specific information.

Throws:

  • ArgumentError if the callback parameter is empty
  • ArgumentError if the callback is not a valid URI
  • OAuthException in the following cases:
    • Missing or invalid state parameter
    • Missing or mismatched iss parameter (see issuer)
    • Presence of error parameter in the callback
    • Missing authorization code
    • Invalid token exchange response
    • Missing or non-DID sub in the token response

Example:

final session = await oauth.callback(
  'https://example.com/callback?iss=https://bsky.social&state=abc&code=xyz',
  context,
);

The method implements DPoP by:

  1. Generating an EC key pair for DPoP proof
  2. Creating a DPoP proof header for the token request
  3. Handling DPoP nonce rotation (use_dpop_nonce errors), bounded to a small number of retries
  4. Storing DPoP-related information in the resulting session

The returned OAuthSession includes DPoP-specific fields:

  • $dPoPNonce: The latest DPoP nonce from the server, if any
  • $publicKey: The encoded public key used for DPoP
  • $privateKey: The encoded private key used for DPoP

Implementation

Future<OAuthSession> callback(
  final String callback,
  final OAuthContext context, {
  final String? issuer,
}) async {
  if (callback.isEmpty) {
    throw ArgumentError.value(callback, 'callback', 'must not be empty');
  }

  final callbackUri = Uri.tryParse(callback);
  if (callbackUri == null) {
    throw ArgumentError.value(callback, 'callback', 'must be a valid URI');
  }

  final params = callbackUri.queryParameters;

  final stateParam = params['state'];
  if (stateParam == null) throw OAuthException('Missing "state" parameter');
  if (!_secureEquals(context.state, stateParam)) {
    throw OAuthException('Unknown authorization session "$stateParam"');
  }

  // RFC 9207: validate the `iss` authorization response parameter to
  // detect authorization server mix-up attacks. This is required by the
  // atproto OAuth profile.
  final expectedIssuer = _normalizeIssuer(
    issuer ?? context.issuer ?? _origin,
  );
  final issParam = params['iss'];
  if (issParam == null) {
    // atproto requires authorization servers to support RFC 9207
    // (`authorization_response_iss_parameter_supported=true`), so a
    // conforming server always includes `iss` — its absence indicates a
    // non-conforming or hostile party.
    throw OAuthException(
      'Missing "iss" parameter (RFC 9207): expected "$expectedIssuer"',
    );
  }
  if (_normalizeIssuer(issParam) != expectedIssuer) {
    throw OAuthException(
      'Issuer mismatch: expected "$expectedIssuer" but the callback '
      'was issued by "$issParam"',
    );
  }

  final errorParam = params['error'];
  if (errorParam != null) {
    final description = params['error_description'];
    throw OAuthException(
      description == null ? errorParam : '$errorParam: $description',
    );
  }

  final codeParam = params['code'];
  if (codeParam == null) throw OAuthException('Missing "code" query param');

  final endpoint = Uri.parse(context.tokenEndpoint ?? '$_origin/oauth/token');

  // Reuse the DPoP key pair generated during [authorize]: the pushed
  // authorization request was bound to that key's thumbprint, so the
  // token request must be signed with the same key. Contexts serialized
  // by older versions of this library have no key material; generate a
  // fresh pair in that case for backward compatibility.
  final String publicKey;
  final String privateKey;
  final contextPublicKey = context.dpopPublicKey;
  final contextPrivateKey = context.dpopPrivateKey;
  if (contextPublicKey != null && contextPrivateKey != null) {
    publicKey = contextPublicKey;
    privateKey = contextPrivateKey;
  } else {
    final keyPair = getKeyPair();
    publicKey = encodePublicKey(keyPair.publicKey as ECPublicKey);
    privateKey = encodePrivateKey(keyPair.privateKey as ECPrivateKey);
  }

  final result = await _postTokenRequest(
    endpoint: endpoint,
    initialDPoPNonce: context.dpopNonce,
    publicKey: publicKey,
    privateKey: privateKey,
    bodyParams: {
      'client_id': metadata.clientId,
      'grant_type': 'authorization_code',
      'code': codeParam,
      'redirect_uri': metadata.redirectUris.first,
      'code_verifier': context.codeVerifier,
    },
  );

  return _buildSession(
    result: result,
    publicKey: publicKey,
    privateKey: privateKey,
    expectedSub: expectedSub,
  );
}