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.
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:
- Generating an EC key pair for DPoP proof
- Creating a DPoP proof header for the token request
- Handling DPoP nonce rotation (401 with 'use_dpop_nonce' error)
- 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,
);
}