callback method
Processes the OAuth 2.0 authorization callback and exchanges the authorization code for a DPoP-bound OAuthSession.
callbackUrl is the full redirect URL received from the authorization
server. The state parameter is looked up in the OAuthStateStore to
recover the per-authorization context stored by authorize.
Security checks enforced:
statemust be known (present in the store) and match in constant time (CSRF protection).- RFC 9207: the
issparameter must be present and match the issuer discovered during authorize. atproto requiresauthorization_response_iss_parameter_supported=true, so a missingissindicates a non-conforming or hostile party. - Any
errorparameter is surfaced; a missingcodeis rejected. - The token response
submust be the account DID and, when known, match the identity this flow was initiated for.
On success the resulting session is stored in the OAuthSessionStore
keyed by sub, and the consumed state is deleted.
Throws:
- ArgumentError if
callbackUrlis empty or not a valid URI. - OAuthException for any of the validation failures above or a failed token exchange.
Implementation
Future<OAuthSession> callback(final String callbackUrl) async {
if (callbackUrl.isEmpty) {
throw ArgumentError.value(
callbackUrl,
'callbackUrl',
'must not be empty',
);
}
final callbackUri = Uri.tryParse(callbackUrl);
if (callbackUri == null) {
throw ArgumentError.value(
callbackUrl,
'callbackUrl',
'must be a valid URI',
);
}
final params = callbackUri.queryParameters;
final stateParam = params['state'];
if (stateParam == null) throw OAuthException('Missing "state" parameter');
final context = await _stateStore.find(stateParam);
if (context == null || !_secureEquals(context.state, stateParam)) {
throw OAuthException('Unknown authorization session "$stateParam"');
}
// The authorization `state` is strictly one-time-use: consume it on every
// outcome (success or failure) so a leaked or replayed `state` cannot be
// re-exchanged and the stored PKCE verifier + DPoP key never linger.
try {
// Reject a malformed/incompatible context up front — before the token
// exchange and before the `iss` check. An empty `issuer` would otherwise
// turn the RFC 9207 issuer comparison into a no-op.
final contextIssuer = context.issuer;
if (contextIssuer == null || contextIssuer.isEmpty) {
throw OAuthException(
'Malformed authorization context for state "$stateParam": '
'missing "issuer" (was it created by an incompatible version?)',
);
}
final contextPds = context.pds;
if (contextPds == null || contextPds.isEmpty) {
throw OAuthException(
'Malformed authorization context for state "$stateParam": '
'missing "pds" (was it created by an incompatible version?)',
);
}
// RFC 9207: validate the `iss` authorization response parameter to
// detect authorization server mix-up attacks. Required by the atproto
// OAuth profile.
final expectedIssuer = _normalizeIssuer(contextIssuer);
final issParam = params['iss'];
if (issParam == null) {
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 tokenEndpoint = context.tokenEndpoint;
final publicKey = context.dpopPublicKey;
final privateKey = context.dpopPrivateKey;
if (tokenEndpoint == null || publicKey == null || privateKey == null) {
throw OAuthException(
'Authorization session "$stateParam" is missing token endpoint or '
'DPoP key material',
);
}
final keyPair = DPoPKeyPair(publicKey: publicKey, privateKey: privateKey);
final result = await _postTokenRequest(
endpoint: Uri.parse(tokenEndpoint),
keyPair: keyPair,
bodyParams: {
'client_id': metadata.clientId,
'grant_type': 'authorization_code',
'code': codeParam,
'redirect_uri': metadata.redirectUris.first,
'code_verifier': context.codeVerifier,
},
);
final session = _buildSession(
result: result,
keyPair: keyPair,
issuer: contextIssuer,
pds: contextPds,
expectedSub: context.expectedSub,
);
await _sessionStore.set(session.sub, session);
return session;
} finally {
await _stateStore.delete(stateParam);
}
}