authorize method

Future<(Uri, OAuthContext)> authorize([
  1. String? identity
])

Initiates an OAuth 2.0 authorization request using Pushed Authorization Requests (PAR) with PKCE (Proof Key for Code Exchange) and DPoP (Demonstrating Proof of Possession).

This method implements the following OAuth 2.0 security features:

  • RFC 8414 authorization server metadata discovery for endpoint resolution (with a fallback to the conventional atproto paths when the metadata document is unavailable)
  • PAR (RFC 9126) for secure authorization request transmission
  • PKCE (RFC 7636) to prevent authorization code interception
  • DPoP (RFC 9449) for proof-of-possession tokens

The flow consists of two steps:

  1. Pushes the authorization request parameters to the authorization server
  2. Returns the authorization URL with the obtained request URI

Example:

// With login hint
final authUrl = await authorize('shinyakato.dev');
// Without login hint
final authUrl = await authorize();
// Redirect user to authUrl for authentication

Parameters:

  • identity: Optional user identifier (typically handle or email) used as login_hint. If not provided, the login_hint parameter will not be included in the request.

Security measures implemented:

  • Generates cryptographically secure random values for PKCE and state
  • Uses SHA-256 for PKCE code challenge
  • Sends a DPoP proof with the PAR request (RFC 9449); the same key pair is carried in the returned OAuthContext and reused for the token request, since the authorization server binds the pushed request to the DPoP key
  • Absorbs a use_dpop_nonce challenge on the PAR request so the nonce is pre-acquired for the token request
  • Validates server response status (201 Created)

Throws:

  • OAuthException: If the client metadata declares no redirect URI, if the PAR request fails or returns an unexpected status code, or if the discovered issuer does not match service

Returns:

  • Uri: The authorization URL where the user should be redirected to complete authentication

Implementation

Future<(Uri, OAuthContext)> authorize([final String? identity]) async {
  final redirectUri = metadata.redirectUris.firstOrNull;
  if (redirectUri == null || redirectUri.isEmpty) {
    throw OAuthException(
      'Client metadata must declare at least one "redirect_uris" entry',
    );
  }

  final serverMetadata = await _resolveServerMetadata();
  final parEndpoint = Uri.parse(
    serverMetadata.pushedAuthorizationRequestEndpoint ?? '$_origin/oauth/par',
  );
  final authorizationEndpoint = Uri.parse(
    serverMetadata.authorizationEndpoint ?? '$_origin/oauth/authorize',
  );
  final tokenEndpoint =
      serverMetadata.tokenEndpoint ?? '$_origin/oauth/token';
  final issuer = _normalizeIssuer(serverMetadata.issuer ?? _origin);

  final codeVerifier = random(46);
  final codeChallenge = hashS256(codeVerifier);
  final state = random(64);

  final bodyParams = <String, String>{
    'client_id': metadata.clientId,
    'redirect_uri': redirectUri,
    'state': state,
    'code_challenge': codeChallenge,
    'code_challenge_method': 'S256',
    'response_type': 'code',
    'scope': metadata.scope,
  };

  // Only include login_hint if identity is provided
  if (identity != null && identity.isNotEmpty) {
    bodyParams['login_hint'] = identity;
  }

  // The DPoP key pair is generated up front so the PAR request can carry
  // a DPoP proof (the atproto authorization server binds the pushed
  // request to this key's thumbprint); the same pair is then carried in
  // the context and reused for the token request in [callback].
  final keyPair = getKeyPair();
  final publicKey = encodePublicKey(keyPair.publicKey as ECPublicKey);
  final privateKey = encodePrivateKey(keyPair.privateKey as ECPrivateKey);

  final result = await _postWithDPoPProof(
    endpoint: parEndpoint,
    initialDPoPNonce: null,
    publicKey: publicKey,
    privateKey: privateKey,
    bodyParams: bodyParams,
  );
  final response = result.response;

  if (response.statusCode != 201) {
    throw OAuthException(
      'Pushed authorization request failed '
      '(status ${response.statusCode}): ${response.body}',
    );
  }

  final requestUri = result.body?['request_uri'];
  if (requestUri is! String || requestUri.isEmpty) {
    throw OAuthException(
      'Pushed authorization response is missing "request_uri": '
      '${response.body}',
    );
  }

  return (
    authorizationEndpoint.replace(
      queryParameters: {
        ...authorizationEndpoint.queryParameters,
        'client_id': metadata.clientId,
        'request_uri': requestUri,
      },
    ),
    OAuthContext(
      codeVerifier: codeVerifier,
      state: state,
      // The `dpop-nonce` header is optional (RFC 9449); its absence is a
      // perfectly normal response.
      dpopNonce: result.dPoPNonce,
      issuer: issuer,
      tokenEndpoint: tokenEndpoint,
      dpopPublicKey: publicKey,
      dpopPrivateKey: privateKey,
    ),
  );
}