callback method
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
issparameter (seeissuer) - Presence of error parameter in the callback
- Missing authorization code
- Invalid token exchange response
- Missing or non-DID
subin 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:
- Generating an EC key pair for DPoP proof
- Creating a DPoP proof header for the token request
- Handling DPoP nonce rotation (
use_dpop_nonceerrors), bounded to a small number of retries - 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,
);
}