resolveFromIdentity static method

Future<OAuthClient> resolveFromIdentity(
  1. OAuthClientMetadata metadata,
  2. String identity, {
  3. String handleResolver = 'https://public.api.bsky.app',
  4. String plcDirectory = 'https://plc.directory',
  5. Client? httpClient,
})

Resolves an atproto identity (handle or DID) down to its OAuth authorization server and returns a ready-to-use OAuthClient.

Implements the atproto identity-resolution flow:

  1. When identity is a handle, it is resolved to a DID via com.atproto.identity.resolveHandle against handleResolver. A did:... identity is used as-is.
  2. The DID document is fetched — from plcDirectory for did:plc, or from /.well-known/did.json (or the path form) for did:web.
  3. When the flow started from a handle, bidirectional verification is performed: the DID document's alsoKnownAs must contain at://<handle>.
  4. The #atproto_pds service endpoint is extracted and the PDS is resolved to its authorization server exactly like resolveFromPds.

The returned client has expectedSub set to the resolved DID, so the sub of every token response is verified to match the identity this flow was initiated for.

identity may be a handle (alice.example.com, optionally prefixed with @ or at://) or a DID (did:plc:... / did:web:...).

handleResolver is the XRPC service used to resolve handles; it defaults to the public Bluesky AppView. Self-hosters can point this at their own PDS/entryway.

Throws an OAuthException when any resolution step fails, when bidirectional handle verification fails, or when the DID method is not supported (only did:plc and did:web are).

Implementation

static Future<OAuthClient> resolveFromIdentity(
  final OAuthClientMetadata metadata,
  final String identity, {
  final String handleResolver = 'https://public.api.bsky.app',
  final String plcDirectory = 'https://plc.directory',
  final http.Client? httpClient,
}) async {
  var normalized = identity.trim();
  if (normalized.startsWith('at://')) {
    normalized = normalized.substring('at://'.length);
  }
  if (normalized.startsWith('@')) {
    normalized = normalized.substring(1);
  }
  if (normalized.isEmpty) {
    throw ArgumentError.value(identity, 'identity', 'must not be empty');
  }

  final String did;
  final String? handle;
  if (normalized.startsWith('did:')) {
    did = normalized;
    handle = null;
  } else {
    // Handles are case-insensitive; normalize to lowercase.
    handle = normalized.toLowerCase();
    did = await _resolveHandle(handle, handleResolver, httpClient);
  }

  final didDocument = await _resolveDidDocument(
    did,
    plcDirectory,
    httpClient,
  );

  if (handle != null) {
    // Bidirectional verification: the DID document must claim the handle
    // back via `alsoKnownAs`, otherwise anyone could point a handle at an
    // arbitrary DID.
    final alsoKnownAs = didDocument['alsoKnownAs'];
    final claimsHandle =
        alsoKnownAs is List &&
        alsoKnownAs.whereType<String>().any(
          (final aka) => aka.toLowerCase() == 'at://$handle',
        );
    if (!claimsHandle) {
      throw OAuthException(
        'Bidirectional handle verification failed: the DID document for '
        '"$did" does not list "at://$handle" in "alsoKnownAs"',
      );
    }
  }

  final pdsOrigin = _extractPdsEndpoint(didDocument, did);
  final authorizationServer = await _resolveAuthorizationServer(
    pdsOrigin,
    httpClient,
  );

  return OAuthClient(
    metadata,
    service: authorizationServer.authority,
    pds: pdsOrigin,
    expectedSub: did,
    httpClient: httpClient,
  );
}