callback method

Future<OAuthSession> callback(
  1. String callbackUrl
)

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:

  • state must be known (present in the store) and match in constant time (CSRF protection).
  • RFC 9207: the iss parameter must be present and match the issuer discovered during authorize. atproto requires authorization_response_iss_parameter_supported=true, so a missing iss indicates a non-conforming or hostile party.
  • Any error parameter is surfaced; a missing code is rejected.
  • The token response sub must 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 callbackUrl is 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);
  }
}