callback method

Future<OAuthSession> callback(
  1. String callback,
  2. OAuthContext context
)

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.

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

Throws:

  • ArgumentError if the callback parameter is null or empty
  • ArgumentError if the callback is not a valid URI
  • OAuthException in the following cases:
    • Missing or invalid state parameter
    • Presence of error parameter in the callback
    • Missing authorization code
    • Invalid token exchange response

Example:

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

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 (401 with 'use_dpop_nonce' error)
  4. Storing DPoP-related information in the resulting session

The returned OAuthSession includes DPoP-specific fields:

  • $dPoPNonce: The latest DPoP nonce from the server
  • $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,
) async {
  if (callback.isEmpty) throw ArgumentError.notNull(callback);
  if (Uri.tryParse(callback) == null) throw ArgumentError.value(callback);

  final params = Uri.parse(callback).queryParameters;

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

  final errorParam = params['error'];
  if (errorParam != null) throw OAuthException(errorParam);

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

  final keyPair = getKeyPair();
  final endpoint = Uri.https(service, '/oauth/token');

  final publicKey = encodePublicKey(keyPair.publicKey as ECPublicKey);
  final privateKey = encodePrivateKey(keyPair.privateKey as ECPrivateKey);

  final dPoPHeader = getDPoPHeader(
    clientId: metadata.clientId,
    endpoint: endpoint.toString(),
    method: 'POST',
    dPoPNonce: context.dpopNonce,
    publicKey: publicKey,
    privateKey: privateKey,
  );

  final response = await http.post(
    endpoint,
    headers: {
      'DPoP': dPoPHeader,
    },
    body: {
      'client_id': metadata.clientId,
      'grant_type': 'authorization_code',
      'code': codeParam,
      'redirect_uri': metadata.redirectUris.firstOrNull,
      'code_verifier': context.codeVerifier,
    },
  );

  final body = jsonDecode(response.body);

  if (response.statusCode == 401 &&
      body['error'] == 'use_dpop_nonce' &&
      response.headers.containsKey('dpop-nonce')) {
    // Retry with next DPoP nonce
    return await this.callback(
      callback,
      context.copyWith(dpopNonce: response.headers['dpop-nonce']!),
    );
  }

  if (response.statusCode != 200) {
    throw OAuthException(response.body);
  }

  return OAuthSession(
    accessToken: body['access_token'],
    refreshToken: body['refresh_token'],
    tokenType: body['token_type'],
    scope: body['scope'],
    expiresAt:
        DateTime.now().toUtc().add(Duration(seconds: body['expires_in'])),
    sub: body['sub'],
    $dPoPNonce: response.headers['dpop-nonce']!,
    $publicKey: publicKey,
    $privateKey: privateKey,
  );
}