authorize method

Future<Uri> authorize(
  1. String identity
)

Initiates an OAuth 2.0 authorization request for identity using Pushed Authorization Requests (PAR) with PKCE and DPoP.

identity is a handle (alice.example, optionally @/at:// prefixed) or a DID (did:plc: / did:web:). It is resolved to the account's PDS and authorization server, and used as the login_hint.

The generated per-authorization state (PKCE verifier, state, DPoP key pair, resolved issuer/PDS/DID) is persisted in the OAuthStateStore keyed by state; callback reads it back to complete the exchange.

Returns the authorization Uri the user should be redirected to.

Throws an OAuthException when the client metadata declares no redirect URI, when identity resolution fails, when the PAR request fails, or when the discovered issuer does not match the authorization server origin.

Implementation

Future<Uri> 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 ResolvedIdentity resolved;
  try {
    resolved = await _identityResolver.resolve(identity);
  } on IdentityException catch (e) {
    throw OAuthException(e.message);
  }
  final pdsOrigin = _normalizeHttpOrigin(resolved.pds, what: 'PDS URL');
  final authServerUri = await _resolveAuthorizationServer(
    pdsOrigin,
    _httpClient,
  );
  final authority = authServerUri.authority;

  final meta = await _discoverServerMetadata(authority);
  final origin = 'https://$authority';
  final parEndpoint = Uri.parse(
    meta.pushedAuthorizationRequestEndpoint ?? '$origin/oauth/par',
  );
  final authorizationEndpoint = Uri.parse(
    meta.authorizationEndpoint ?? '$origin/oauth/authorize',
  );
  final tokenEndpoint = meta.tokenEndpoint ?? '$origin/oauth/token';
  final issuer = _normalizeIssuer(meta.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,
  };

  // Use the resolved, normalized handle/DID as the login_hint (never the raw
  // input, which may carry `@`/`at://` prefixes or non-canonical casing).
  final loginHint = resolved.handle ?? resolved.did;
  if (loginHint.isNotEmpty) {
    bodyParams['login_hint'] = loginHint;
  }

  // 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 carried in the context and
  // reused for the token request in [callback].
  final keyPair = await _signer.generateKeyPair();

  final result = await _postWithDPoPProof(
    endpoint: parEndpoint,
    keyPair: keyPair,
    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}',
    );
  }

  await _stateStore.set(
    state,
    OAuthContext(
      codeVerifier: codeVerifier,
      state: state,
      issuer: issuer,
      tokenEndpoint: tokenEndpoint,
      dpopPublicKey: keyPair.publicKey,
      dpopPrivateKey: keyPair.privateKey,
      pds: pdsOrigin,
      expectedSub: resolved.did,
    ),
  );

  return authorizationEndpoint.replace(
    queryParameters: {
      ...authorizationEndpoint.queryParameters,
      'client_id': metadata.clientId,
      'request_uri': requestUri,
    },
  );
}