a collection of lightweight TypeScript packages for AT Protocol, the protocol powering Bluesky
atproto bluesky typescript npm
101
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat(oauth-browser-client)!: externalized identity resolver

Squashed commit of the following:

commit c81ecc8c56348fc5ce81cee9cf5e512804221523
Author: Mary <git@mary.my.id>
Date: Sat Oct 25 10:25:06 2025 +0700

refactor(oauth-browser-client)!: generic identity resolver

commit ace14b33476deb809d2490be9ba2ea64a4e26c37
Author: Mary <git@mary.my.id>
Date: Wed Aug 27 09:16:28 2025 +0700

feat(oauth-browser-client)!: add more authorization options

commit 4eb23375a71f45c58e0367b8c0225a1fe6497f65
Author: Mary <git@mary.my.id>
Date: Mon Aug 25 19:00:16 2025 +0700

chore: bump versions

commit 85d107d4b7ca14f7f78abb2bec22bac2a03bc11e
Author: Mary <git@mary.my.id>
Date: Mon Aug 25 18:59:23 2025 +0700

chore: enter prerelease mode

commit b823d92709cbaf1cea6e62bfaf849fba039c94e9
Author: Mary <git@mary.my.id>
Date: Sat Aug 23 21:18:20 2025 +0700

feat(oauth-browser-client): externalize handle and DID document resolution

Mary bac1b0f2 20466bc5

+373 -401
+22
.changeset/hungry-points-press.md
··· 1 + --- 2 + '@atcute/oauth-browser-client': major 3 + --- 4 + 5 + allow passing user-provided state during authorization 6 + 7 + `createAuthorizationUrl` now takes in an optional `state` property 8 + 9 + ```ts 10 + const authUrl = await createAuthorizationUrl({ 11 + // ... 12 + state: { 13 + // ... 14 + }, 15 + }); 16 + ``` 17 + 18 + `finalizeAuthorization` now returns an object containing `session` and your provided `state`. 19 + 20 + ```ts 21 + const { session, state } = await finalizeAuthorization(params); 22 + ```
+58
.changeset/puny-areas-warn.md
··· 1 + --- 2 + '@atcute/oauth-browser-client': major 3 + --- 4 + 5 + handle and DID document resolution are now externalized. 6 + 7 + although we've provided a "guide" on how to do your own handle resolution, the client itself still 8 + had to make its own resolution for post-authorization verification checks. this change finally makes 9 + it possible for you to supply a resolver for the client to use, and you're required to provide them. 10 + 11 + after upgrading, you would supply an `identityResolver` to `configureOAuth`. there is a built-in 12 + identity resolver implementation that takes in a handle and DID document resolver (which you can use 13 + `@atcute/identity-resolver` with.) 14 + 15 + ```ts 16 + import { configureOAuth, defaultIdentityResolver } from '@atcute/oauth-browser-client'; 17 + 18 + import { 19 + CompositeDidDocumentResolver, 20 + PlcDidDocumentResolver, 21 + WebDidDocumentResolver, 22 + XrpcHandleResolver, 23 + } from '@atcute/identity-resolver'; 24 + 25 + configureOAuth({ 26 + // ... existing config 27 + 28 + identityResolver: defaultIdentityResolver({ 29 + // AT Protocol handles resolve via DNS TXT record or HTTP well-known endpoints. 30 + // since web apps lack direct DNS access and face CORS restrictions, we're using 31 + // Bluesky's AppView for this example. 32 + // 33 + // NOTE: Bluesky may log handle resolutions and requester info per their privacy 34 + // policy. consider the privacy implications of this arrangement and change this 35 + // setup if unsuitable for your use case. 36 + handleResolver: new XrpcHandleResolver({ serviceUrl: 'https://public.api.bsky.app' }), 37 + 38 + didDocumentResolver: new CompositeDidDocumentResolver({ 39 + methods: { 40 + plc: new PlcDidDocumentResolver(), 41 + web: new WebDidDocumentResolver(), 42 + }, 43 + }), 44 + }), 45 + }); 46 + ``` 47 + 48 + `resolveFromIdentity` and `resolveFromService` has been removed as a result. instead, pass the 49 + target directly to `createAuthorizationUrl`. 50 + 51 + ```ts 52 + const authUrl = await createAuthorizationUrl({ 53 + target: { type: 'account', identifier: 'mary.my.id' }, 54 + // or { type: 'pds', serviceUrl: 'https://bsky.social' } 55 + 56 + // ... existing options 57 + }); 58 + ```
+14
.changeset/ready-bears-heal.md
··· 1 + --- 2 + '@atcute/oauth-browser-client': minor 3 + --- 4 + 5 + allow customizing some parts of the authorization process 6 + 7 + `createAuthorizationUrl` now takes in optional `prompt`, `display`, `locale` fields. 8 + 9 + ```ts 10 + const authUrl = createAuthorizationUrl({ 11 + // ... 12 + display: 'popup', 13 + }); 14 + ```
+63
packages/oauth/browser-client/CHANGELOG.md
··· 1 1 # @atcute/oauth-browser-client 2 2 3 + ## 2.0.0-next.0 4 + 5 + ### Major Changes 6 + 7 + - 82eb851: handle and DID document resolution are now externalized. 8 + 9 + although we've provided a "guide" on how to do your own handle resolution, the client itself still 10 + had to make its own resolution for post-authorization verification checks. this change finally 11 + makes it possible for you to supply a resolver for the client to use, and you're required to 12 + provide them. 13 + 14 + after upgrading, you would supply an `identityResolver` to `configureOAuth`. the helper 15 + `defaultIdentityResolver` composes handle and DID document resolvers if you still want the default 16 + behavior; using `@atcute/identity-resolver` implementations is recommended for this. 17 + 18 + ```ts 19 + import { configureOAuth, defaultIdentityResolver } from '@atcute/oauth-browser-client'; 20 + 21 + import { 22 + CompositeDidDocumentResolver, 23 + PlcDidDocumentResolver, 24 + WebDidDocumentResolver, 25 + XrpcHandleResolver, 26 + } from '@atcute/identity-resolver'; 27 + 28 + configureOAuth({ 29 + // ... existing config 30 + 31 + identityResolver: defaultIdentityResolver({ 32 + // AT Protocol handles resolve via DNS TXT record or HTTP well-known endpoints. 33 + // since web apps lack direct DNS access and face CORS restrictions, we're using 34 + // Bluesky's AppView for this example. 35 + // 36 + // NOTE: Bluesky may log handle resolutions and requester info per their privacy 37 + // policy. consider the privacy implications of this arrangement and change this 38 + // setup if unsuitable for your use case. 39 + handleResolver: new XrpcHandleResolver({ serviceUrl: 'https://public.api.bsky.app' }), 40 + 41 + didDocumentResolver: new CompositeDidDocumentResolver({ 42 + methods: { 43 + plc: new PlcDidDocumentResolver(), 44 + web: new WebDidDocumentResolver(), 45 + }, 46 + }), 47 + }), 48 + }); 49 + 50 + the resolved identity now includes both the did and a canonical handle (or `handle.invalid` if it 51 + can't be verified), which we use for `login_hint` to better match the reference OAuth client. 52 + ``` 53 + 54 + `resolveFromIdentity` and `resolveFromService` has been removed as a result. instead, pass the 55 + target directly to `createAuthorizationUrl`. 56 + 57 + ```ts 58 + const authUrl = await createAuthorizationUrl({ 59 + target: { type: 'account', identifier: 'mary.my.id' }, 60 + // or { type: 'pds', serviceUrl: 'https://bsky.social' } 61 + 62 + // ... existing options 63 + }); 64 + ``` 65 + 3 66 ## 1.0.27 4 67 5 68 ### Patch Changes
+43 -223
packages/oauth/browser-client/README.md
··· 18 18 19 19 ### setup 20 20 21 - initialize the client by importing and calling `configureOAuth` with the client ID and redirect URL. 22 - this call should be placed before any other calls you make with this library. 21 + initialize the client by importing and calling `configureOAuth` with the client ID and redirect URL, 22 + along with the resolvers that will be used to resolve and verify account details. this call should 23 + be placed before any other calls you make with this library. 23 24 24 25 ```ts 25 - import { configureOAuth } from '@atcute/oauth-browser-client'; 26 + import { configureOAuth, defaultIdentityResolver } from '@atcute/oauth-browser-client'; 27 + 28 + import { 29 + CompositeDidDocumentResolver, 30 + PlcDidDocumentResolver, 31 + WebDidDocumentResolver, 32 + XrpcHandleResolver, 33 + } from '@atcute/identity-resolver'; 26 34 27 35 configureOAuth({ 28 36 metadata: { 29 37 client_id: 'https://example.com/oauth-client-metadata.json', 30 38 redirect_uri: 'https://example.com/oauth/callback', 31 39 }, 40 + identityResolver: defaultIdentityResolver({ 41 + // AT Protocol handles resolve via DNS TXT record or HTTP well-known endpoints. 42 + // since web apps lack direct DNS access and face CORS restrictions, we're using 43 + // Bluesky's AppView for this example. 44 + // 45 + // NOTE: Bluesky may log handle resolutions and requester info per their privacy 46 + // policy. consider the privacy implications of this arrangement and change this 47 + // setup if unsuitable for your use case. 48 + handleResolver: new XrpcHandleResolver({ serviceUrl: 'https://public.api.bsky.app' }), 49 + 50 + didDocumentResolver: new CompositeDidDocumentResolver({ 51 + methods: { 52 + plc: new PlcDidDocumentResolver(), 53 + web: new WebDidDocumentResolver(), 54 + }, 55 + }), 56 + }), 32 57 }); 33 58 ``` 34 59 35 60 ### starting an authorization flow 36 61 37 - > [!CAUTION] 38 - > the built-in handle resolution makes use of Bluesky-hosted services to return the intended DID, 39 - > and this can mean sharing the user's IP address and the handle identifiers to Bluesky. 40 - > 41 - > while Bluesky has a declared privacy policy, both developers and users need to be informed and 42 - > aware of the privacy implications of this arrangement. read [this guide](#doing-handle-resolution) 43 - > on how you can implement your own resolution code. 44 - 45 - if your application involves asking for the user's handle or DID, you can use `resolveFromIdentity` 46 - which resolves the user's identity to get its PDS, and the metadata of its authorization server. 47 - 48 - ```ts 49 - import { resolveFromIdentity } from '@atcute/oauth-browser-client'; 50 - 51 - const { identity, metadata } = await resolveFromIdentity('mary.my.id'); 52 - ``` 53 - 54 - alternatively, if it involves asking for the user's PDS, then you can use `resolveFromService` which 55 - just grabs the authorization server metadata. 56 - 57 - ```ts 58 - import { resolveFromService } from '@atcute/oauth-browser-client'; 59 - 60 - const { metadata } = await resolveFromService('bsky.social'); 61 - ``` 62 - 63 - we can then proceed with authorization by calling `createAuthorizationUrl` with the resolved 64 - `metadata` (and `identity`, if using `resolveFromIdentity`) along with the scope of the 65 - authorization, which should either match the one in your client metadata, or a reduced set of it. 62 + we can start authorization by calling `createAuthorizationUrl` with the intended account's 63 + identifier or service along with the scope of the authorization, which should either match the one 64 + in your client metadata, or a reduced set of it. 66 65 67 66 ```ts 68 67 import { createAuthorizationUrl } from '@atcute/oauth-browser-client'; 69 68 70 - // passing `identity` is optional, 71 - // it allows for the login form to be autofilled with the user's handle or DID 72 69 const authUrl = await createAuthorizationUrl({ 73 - metadata: metadata, 74 - identity: identity, 70 + target: { type: 'account', identifier: 'mary.my.id' }, 71 + // or { type: 'pds', serviceUrl: 'https://bsky.social' } 75 72 scope: 'atproto transition:generic transition:chat.bsky', 76 73 }); 77 74 ··· 186 183 187 184 ### how it works 188 185 189 - the [client assertion backend pattern](https://github.com/bluesky-social/proposals/tree/main/0010-client-assertion-backend) 186 + the 187 + [client assertion backend pattern](https://github.com/bluesky-social/proposals/tree/main/0010-client-assertion-backend) 190 188 allows browser apps to act as confidential clients: 191 189 192 190 1. your browser app generates a DPoP key (this already happens automatically) ··· 216 214 // Call your backend endpoint (design your own API format) 217 215 const response = await fetch('https://example.com/api/client-assertion', { 218 216 method: 'POST', 219 - headers: { 'dpop': dpop }, 217 + headers: { dpop: dpop }, 220 218 body: JSON.stringify({ jkt, aud }), 221 219 }); 222 220 ··· 233 231 your infrastructure (authentication, request format, error handling, etc.). 234 232 235 233 the library will automatically: 234 + 236 235 - calculate JWK thumbprints for DPoP keys 237 236 - provide a `createDpopProof()` function for backend authentication 238 237 - request client assertions when making token requests 239 238 240 - **important**: if you configure `fetchClientAssertion`, your backend **must** be available. 241 - there is no fallback to public client mode, because your OAuth client metadata will declare you as 242 - a confidential client, and authorization servers will reject requests without client assertions. 239 + **important**: if you configure `fetchClientAssertion`, your backend **must** be available. there is 240 + no fallback to public client mode, because your OAuth client metadata will declare you as a 241 + confidential client, and authorization servers will reject requests without client assertions. 243 242 244 243 ### backend requirements 245 244 ··· 254 253 4. return `{ "client_assertion": "<signed-jwt>" }` 255 254 256 255 additionally: 256 + 257 257 - enforce CORS to only allow requests from your frontend origin 258 258 - never cache responses (client assertions should be fresh) 259 259 - optionally track devices via DPoP keys and refuse assertions for suspicious sessions ··· 372 372 client_id: import.meta.env.VITE_OAUTH_CLIENT_ID, 373 373 redirect_uri: import.meta.env.VITE_OAUTH_REDIRECT_URI, 374 374 }, 375 + // ... 375 376 }); 376 377 377 378 // ... later during sign-in process 378 379 const authUrl = await createAuthorizationUrl({ 379 - metadata: metadata, 380 - identity: identity, 380 + // ... 381 381 scope: import.meta.env.VITE_OAUTH_SCOPE, 382 382 }); 383 383 ``` 384 384 385 385 adjust the code here as necessary, the plugin adds more environment variables than what is actually 386 386 needed, you can remove them if you don't think you'd need it. 387 - 388 - ### doing handle resolution 389 - 390 - there are two ways that a handle can be verified: 391 - 392 - 1. HTTP verification: there is a file at `/.well-known/atproto-did` containing your account's DID 393 - 2. DNS verification: there is an `_atproto` TXT record containing your account's DID 394 - 395 - you'd want to resolve both of these. if both methods return a response but does not match each other 396 - then it should ideally be thrown. 397 - 398 - verify that the DID matches the intended format 399 - 400 - ```ts 401 - const isDid = (did: string): did is At.DID => { 402 - return /^did:([a-z]+):([a-zA-Z0-9._:%-]*[a-zA-Z0-9._-])$/.test(did); 403 - }; 404 - ``` 405 - 406 - pass this resolved DID to `resolveFromIdentity`, and carry on as per usual. 407 - 408 - #### HTTP handle resolution 409 - 410 - this is very straightforward, make a request to `https://<handle>/.well-known/atproto-did` without 411 - following redirects. check if the response status is 200 and trim off any excess whitespaces. 412 - 413 - some web servers might not set a permissible CORS header to access this resource, in which case 414 - there is nothing that can be done, unless you'd want to proxy the requests. 415 - 416 - ```ts 417 - const resolveHandleViaHttp = async (handle: string): Promise<At.DID> => { 418 - const url = new URL('/.well-known/atproto-did', `https://${handle}`); 419 - 420 - const response = await fetch(url, { redirect: 'error' }); 421 - if (!response.ok) { 422 - throw new ResolverError(`domain is unreachable`); 423 - } 424 - 425 - const text = await response.text(); 426 - 427 - const did = text.split('\n')[0]!.trim(); 428 - if (isDid(did)) { 429 - return did; 430 - } 431 - 432 - throw new ResolverError(`failed to resolve ${handle}`); 433 - }; 434 - ``` 435 - 436 - #### DNS handle resolution 437 - 438 - as websites can't do DNS resolution on their own, we'd have to rely on DNS-over-HTTPS (DoH) 439 - services. it should be noted that this _can_ have privacy implications of its own, please read 440 - through the privacy policy of whichever DoH service you end up using and make the user aware of it 441 - as well. 442 - 443 - for this example, we'll be using Cloudflare's DoH resolver for Firefox ([privacy 444 - policy][cf-resolver-firefox-privacy]) as it has support for `application/dns-json` format which 445 - allows us to query and see the responses in JSON. 446 - 447 - ```ts 448 - const SUBDOMAIN = '_atproto'; 449 - const PREFIX = 'did='; 450 - 451 - const resolveHandleViaDoH = async (handle: string): Promise<At.DID> => { 452 - const url = new URL('https://mozilla.cloudflare-dns.com/dns-query'); 453 - url.searchParams.set('type', 'TXT'); 454 - url.searchParams.set('name', `${SUBDOMAIN}.${handle}`); 455 - 456 - const response = await fetch(url, { 457 - method: 'GET', 458 - headers: { accept: 'application/dns-json' }, 459 - redirect: 'follow', 460 - }); 461 - 462 - const type = response.headers.get('content-type')?.trim(); 463 - if (!response.ok) { 464 - const message = type?.startsWith('text/plain') ? await response.text() : `failed to resolve ${handle}`; 465 - 466 - throw new ResolverError(message); 467 - } 468 - 469 - if (type !== 'application/dns-json') { 470 - throw new ResolverError(`unexpected response from DoH server`); 471 - } 472 - 473 - const result = asResult(await response.json()); 474 - const answers = result.Answer?.filter(isAnswerTxt).map(extractTxtData) ?? []; 475 - 476 - for (let i = 0; i < answers.length; i++) { 477 - // skip if the line does not start with "did=" 478 - if (!answers[i].startsWith(PREFIX)) { 479 - continue; 480 - } 481 - 482 - // ensure there is no other entry starting with "did=" 483 - for (let j = i + 1; j < answers.length; j++) { 484 - if (answers[j].startsWith(PREFIX)) { 485 - throw new ResolverError(`handle returned multiple did values`); 486 - } 487 - } 488 - 489 - const did = answers[i].slice(PREFIX.length); 490 - if (isDid(did)) { 491 - return did; 492 - } 493 - 494 - break; 495 - } 496 - 497 - throw new ResolverError(`failed to resolve ${handle}`); 498 - }; 499 - 500 - type Result = { Status: number; Answer?: Answer[] }; 501 - const isResult = (result: unknown): result is Result => { 502 - if (result === null || typeof result !== 'object') { 503 - return false; 504 - } 505 - 506 - return ( 507 - 'Status' in result && 508 - typeof result.Status === 'number' && 509 - (!('Answer' in result) || (Array.isArray(result.Answer) && result.Answer.every(isAnswer))) 510 - ); 511 - }; 512 - const asResult = (result: unknown): Result => { 513 - if (!isResult(result)) { 514 - throw new TypeError(`unexpected DoH response`); 515 - } 516 - 517 - return result; 518 - }; 519 - 520 - type Answer = { name: string; type: number; data: string; TTL: number }; 521 - const isAnswer = (answer: unknown): answer is Answer => { 522 - if (answer === null || typeof answer !== 'object') { 523 - return false; 524 - } 525 - 526 - return ( 527 - 'name' in answer && 528 - typeof answer.name === 'string' && 529 - 'type' in answer && 530 - typeof answer.type === 'number' && 531 - 'data' in answer && 532 - typeof answer.data === 'string' && 533 - 'TTL' in answer && 534 - typeof answer.TTL === 'number' 535 - ); 536 - }; 537 - 538 - type AnswerTxt = Answer & { type: 16 }; 539 - const isAnswerTxt = (answer: Answer): answer is AnswerTxt => { 540 - return answer.type === 16; 541 - }; 542 - 543 - const extractTxtData = (answer: AnswerTxt): string => { 544 - return answer.data.replace(/^"|"$/g, '').replace(/\\"/g, '"'); 545 - }; 546 - ``` 547 - 548 - [cf-resolver-firefox-privacy]: https://developers.cloudflare.com/1.1.1.1/privacy/cloudflare-resolver-firefox/ 549 - 550 - #### using your PDS for handle resolution 551 - 552 - alternatively, if you operate your own PDS, you can make use of it as a handle resolver. 553 - 554 - ```ts 555 - const resolveHandleViaPds = async (handle: string): Promise<At.DID> => { 556 - const rpc = new XRPC({ handler: simpleFetchHandler({ service: `https://my-pds.example.com` }) }); 557 - 558 - const { data } = await rpc.get('com.atproto.identity.resolveHandle', { 559 - params: { 560 - handle: handle, 561 - }, 562 - }); 563 - 564 - return data.did; 565 - }; 566 - ```
+52 -25
packages/oauth/browser-client/lib/agents/exchange.ts
··· 1 1 import { nanoid } from 'nanoid'; 2 2 3 + import type { ActorIdentifier } from '@atcute/lexicons'; 4 + 3 5 import { createES256Key } from '../dpop.js'; 4 6 import { CLIENT_ID, database, REDIRECT_URI } from '../environment.js'; 5 7 import { AuthorizationError, LoginError } from '../errors.js'; 6 - import type { IdentityMetadata } from '../types/identity.js'; 8 + import type { ResolvedIdentity } from '../types/identity.js'; 7 9 import type { AuthorizationServerMetadata } from '../types/server.js'; 8 10 import type { Session } from '../types/token.js'; 9 11 import { generatePKCE } from '../utils/runtime.js'; 10 12 13 + import { resolveFromIdentifier, resolveFromService } from '../resolvers.js'; 11 14 import { OAuthServerAgent } from './server-agent.js'; 12 15 import { storeSession } from './sessions.js'; 13 16 17 + export type AuthorizeTargetOptions = 18 + | { type: 'account'; identifier: ActorIdentifier } 19 + | { type: 'pds'; serviceUrl: string }; 20 + 14 21 export interface AuthorizeOptions { 15 - metadata: AuthorizationServerMetadata; 16 - identity?: IdentityMetadata; 22 + target: AuthorizeTargetOptions; 17 23 scope: string; 24 + state?: unknown; 25 + prompt?: 'none' | 'login' | 'consent' | 'select_account'; 26 + display?: 'page' | 'popup' | 'touch' | 'wap'; 27 + locale?: string; 18 28 } 19 29 20 30 /** ··· 22 32 * @param options 23 33 * @returns URL to redirect the user for authorization 24 34 */ 25 - export const createAuthorizationUrl = async ({ 26 - metadata, 27 - identity, 28 - scope, 29 - }: AuthorizeOptions): Promise<URL> => { 30 - const state = nanoid(24); 35 + export const createAuthorizationUrl = async (options: AuthorizeOptions): Promise<URL> => { 36 + const { target, scope, state = null, ...reqs } = options; 37 + 38 + let resolved: { identity?: ResolvedIdentity; metadata: AuthorizationServerMetadata }; 39 + switch (target.type) { 40 + case 'account': { 41 + resolved = await resolveFromIdentifier(target.identifier); 42 + break; 43 + } 44 + case 'pds': { 45 + resolved = await resolveFromService(target.serviceUrl); 46 + } 47 + } 48 + 49 + const { identity, metadata } = resolved; 50 + const loginHint = identity 51 + ? identity.handle !== 'handle.invalid' 52 + ? identity.handle 53 + : identity.did 54 + : undefined; 55 + 56 + const sid = nanoid(24); 31 57 32 58 const pkce = await generatePKCE(); 33 59 const dpopKey = await createES256Key(); 34 60 35 61 const params = { 62 + display: reqs.display, 63 + ui_locales: reqs.locale, 64 + prompt: reqs.prompt, 65 + 36 66 redirect_uri: REDIRECT_URI, 37 67 code_challenge: pkce.challenge, 38 68 code_challenge_method: pkce.method, 39 - state: state, 40 - login_hint: identity?.raw, 69 + state: sid, 70 + login_hint: loginHint, 41 71 response_mode: 'fragment', 42 72 response_type: 'code', 43 - display: 'page', 44 - // id_token_hint: undefined, 45 - // max_age: undefined, 46 - // prompt: undefined, 47 73 scope: scope, 48 - // ui_locales: undefined, 49 74 } satisfies Record<string, string | undefined>; 50 75 51 - database.states.set(state, { 76 + database.states.set(sid, { 52 77 dpopKey: dpopKey, 53 78 metadata: metadata, 54 79 verifier: pkce.verifier, 80 + state: state, 55 81 }); 56 82 57 83 const server = new OAuthServerAgent(metadata, dpopKey); ··· 71 97 */ 72 98 export const finalizeAuthorization = async (params: URLSearchParams) => { 73 99 const issuer = params.get('iss'); 74 - const state = params.get('state'); 100 + const sid = params.get('state'); 75 101 const code = params.get('code'); 76 102 const error = params.get('error'); 77 103 78 - if (!state || !(code || error)) { 104 + if (!sid || !(code || error)) { 79 105 throw new LoginError(`missing parameters`); 80 106 } 81 107 82 - const stored = database.states.get(state); 108 + const stored = database.states.get(sid); 83 109 if (stored) { 84 110 // Delete now that we've caught it 85 - database.states.delete(state); 111 + database.states.delete(sid); 86 112 } else { 87 113 throw new LoginError(`unknown state provided`); 88 114 } 89 115 90 - const dpopKey = stored.dpopKey; 91 - const metadata = stored.metadata; 92 - 93 116 if (error) { 94 117 throw new AuthorizationError(params.get('error_description') || error); 95 118 } 96 119 if (!code) { 97 120 throw new LoginError(`missing code parameter`); 98 121 } 122 + 123 + const dpopKey = stored.dpopKey; 124 + const metadata = stored.metadata; 125 + const state = stored.state ?? null; 99 126 100 127 if (issuer === null) { 101 128 throw new LoginError(`missing issuer parameter`); ··· 113 140 114 141 await storeSession(sub, session); 115 142 116 - return session; 143 + return { session, state }; 117 144 };
+3 -3
packages/oauth/browser-client/lib/agents/server-agent.ts
··· 3 3 import { createDPoPFetch } from '../dpop.js'; 4 4 import { CLIENT_ID, REDIRECT_URI } from '../environment.js'; 5 5 import { FetchResponseError, OAuthResponseError, TokenRefreshError } from '../errors.js'; 6 - import { resolveFromIdentity } from '../resolvers.js'; 6 + import { resolveFromIdentifier } from '../resolvers.js'; 7 7 import type { DPoPKey } from '../types/dpop.js'; 8 8 import type { OAuthParResponse } from '../types/par.js'; 9 9 import type { PersistedAuthorizationServerMetadata } from '../types/server.js'; ··· 124 124 } 125 125 126 126 const token = this.#processTokenResponse(res); 127 - const resolved = await resolveFromIdentity(sub); 127 + const resolved = await resolveFromIdentifier(sub as Did); 128 128 129 129 if (resolved.metadata.issuer !== this.#metadata.issuer) { 130 130 throw new TypeError(`issuer mismatch; got ${resolved.metadata.issuer}`); ··· 134 134 token: token, 135 135 info: { 136 136 sub: sub as Did, 137 - aud: resolved.identity.pds.href, 137 + aud: resolved.identity.pds, 138 138 server: pick(resolved.metadata, [ 139 139 'issuer', 140 140 'authorization_endpoint',
+11 -2
packages/oauth/browser-client/lib/environment.ts
··· 1 + import type { IdentityResolver } from './types/identity.js'; 2 + 1 3 import { createOAuthDatabase, type OAuthDatabase } from './store/db.js'; 2 4 3 5 export let CLIENT_ID: string; 4 6 export let REDIRECT_URI: string; 5 7 6 8 export let database: OAuthDatabase; 9 + 10 + export let identityResolver: IdentityResolver; 7 11 8 12 export interface ConfigureOAuthOptions { 13 + /** resolves actor identifiers into identity metadata */ 14 + identityResolver: IdentityResolver; 15 + 9 16 /** 10 - * Client metadata, necessary to drive the whole request 17 + * client metadata, necessary to drive the whole request 11 18 */ 12 19 metadata: { 13 20 client_id: string; ··· 15 22 }; 16 23 17 24 /** 18 - * Name that will be used as prefix for storage keys needed to persist authentication. 25 + * name that will be used as prefix for storage keys needed to persist authentication. 19 26 * @default "atcute-oauth" 20 27 */ 21 28 storageName?: string; 22 29 } 23 30 24 31 export const configureOAuth = (options: ConfigureOAuthOptions) => { 32 + ({ identityResolver } = options); 25 33 ({ client_id: CLIENT_ID, redirect_uri: REDIRECT_URI } = options.metadata); 34 + 26 35 database = createOAuthDatabase({ name: options.storageName ?? 'atcute-oauth' }); 27 36 };
+2 -1
packages/oauth/browser-client/lib/index.ts
··· 1 1 export { configureOAuth, type ConfigureOAuthOptions } from './environment.js'; 2 2 3 3 export * from './errors.js'; 4 - export * from './resolvers.js'; 5 4 6 5 export * from './agents/exchange.js'; 7 6 export * from './agents/server-agent.js'; ··· 15 14 export * from './types/server.js'; 16 15 export * from './types/store.js'; 17 16 export * from './types/token.js'; 17 + 18 + export * from './utils/identity-resolver.js';
+27 -142
packages/oauth/browser-client/lib/resolvers.ts
··· 1 - import type { ComAtprotoIdentityResolveHandle } from '@atcute/atproto'; 2 - import { type DidDocument, getPdsEndpoint } from '@atcute/identity'; 3 - import type { Did } from '@atcute/lexicons'; 4 - import { isDid } from '@atcute/lexicons/syntax'; 1 + import type { ActorIdentifier } from '@atcute/lexicons'; 5 2 6 - import { DEFAULT_APPVIEW_URL } from './constants.js'; 3 + import { identityResolver } from './environment.js'; 7 4 import { ResolverError } from './errors.js'; 8 - import type { IdentityMetadata } from './types/identity.js'; 5 + import type { ResolvedIdentity } from './types/identity.js'; 9 6 import type { AuthorizationServerMetadata, ProtectedResourceMetadata } from './types/server.js'; 10 7 import { extractContentType } from './utils/response.js'; 11 8 import { isValidUrl } from './utils/strings.js'; 12 9 13 - const DID_WEB_RE = /^([a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*(?:\.[a-zA-Z]{2,}))$/; 10 + export const resolveFromIdentifier = async ( 11 + ident: ActorIdentifier, 12 + ): Promise<{ identity: ResolvedIdentity; metadata: AuthorizationServerMetadata }> => { 13 + const identity = await identityResolver.resolve(ident); 14 14 15 - /** 16 - * Resolves domain handles into DID identifiers, by requesting Bluesky's AppView 17 - * for identity resolution. 18 - * @param handle Domain handle to resolve 19 - * @returns DID identifier resolved from the domain handle 20 - */ 21 - export const resolveHandle = async (handle: string): Promise<Did> => { 22 - const url = DEFAULT_APPVIEW_URL + `/xrpc/com.atproto.identity.resolveHandle` + `?handle=${handle}`; 23 - 24 - const response = await fetch(url); 25 - if (response.status === 400) { 26 - throw new ResolverError(`domain handle not found`); 27 - } else if (!response.ok) { 28 - throw new ResolverError(`directory is unreachable`); 29 - } 30 - 31 - const json = (await response.json()) as ComAtprotoIdentityResolveHandle.$output; 32 - 33 - return json.did; 15 + return { 16 + identity: identity, 17 + metadata: await getMetadataFromResourceServer(identity.pds), 18 + }; 34 19 }; 35 20 36 - /** 37 - * Get DID documents of did:plc (via plc.directory) and did:web identifiers 38 - * @param did DID identifier we're seeking DID doc from 39 - * @returns Retrieved DID document 40 - */ 41 - export const getDidDocument = async (did: Did): Promise<DidDocument> => { 42 - const colon_index = did.indexOf(':', 4); 43 - 44 - const type = did.slice(4, colon_index); 45 - const ident = did.slice(colon_index + 1); 46 - 47 - // 2. retrieve their DID documents 48 - let doc: DidDocument; 49 - 50 - if (type === 'plc') { 51 - const response = await fetch(`https://plc.directory/${did}`); 52 - 53 - if (response.status === 404) { 54 - throw new ResolverError(`did not found in directory`); 55 - } else if (!response.ok) { 56 - throw new ResolverError(`directory is unreachable`); 21 + export const resolveFromService = async ( 22 + host: string, 23 + ): Promise<{ metadata: AuthorizationServerMetadata }> => { 24 + try { 25 + const metadata = await getMetadataFromResourceServer(host); 26 + return { metadata }; 27 + } catch (err) { 28 + if (err instanceof ResolverError) { 29 + try { 30 + const metadata = await getAuthorizationServerMetadata(host); 31 + return { metadata }; 32 + } catch {} 57 33 } 58 34 59 - const json = await response.json(); 60 - 61 - doc = json as DidDocument; 62 - } else if (type === 'web') { 63 - if (!DID_WEB_RE.test(ident)) { 64 - throw new ResolverError(`invalid identifier`); 65 - } 66 - 67 - const response = await fetch(`https://${ident}/.well-known/did.json`); 68 - 69 - if (!response.ok) { 70 - throw new ResolverError(`did document is unreachable`); 71 - } 72 - 73 - const json = await response.json(); 74 - 75 - doc = json as DidDocument; 76 - } else { 77 - throw new ResolverError(`unsupported did method`); 35 + throw err; 78 36 } 79 - 80 - return doc; 81 37 }; 82 38 83 - /** 84 - * Get OAuth protected resource metadata from a host 85 - * @param host URL of the host 86 - * @returns Retrieved protected resource metadata 87 - */ 88 - export const getProtectedResourceMetadata = async (host: string): Promise<ProtectedResourceMetadata> => { 39 + const getProtectedResourceMetadata = async (host: string): Promise<ProtectedResourceMetadata> => { 89 40 const url = new URL(`/.well-known/oauth-protected-resource`, host); 90 41 const response = await fetch(url, { 91 42 redirect: 'manual', ··· 106 57 return metadata; 107 58 }; 108 59 109 - /** 110 - * Get OAuth authorization server metadata from a host 111 - * @param host URL of the host 112 - * @returns Retrieved authorization server metadata 113 - */ 114 - export const getAuthorizationServerMetadata = async (host: string): Promise<AuthorizationServerMetadata> => { 60 + const getAuthorizationServerMetadata = async (host: string): Promise<AuthorizationServerMetadata> => { 115 61 const url = new URL(`/.well-known/oauth-authorization-server`, host); 116 62 const response = await fetch(url, { 117 63 redirect: 'manual', ··· 146 92 return metadata; 147 93 }; 148 94 149 - /** 150 - * Resolve handle domains or DID identifiers to get their PDS and its authorization server metadata 151 - * @param ident Handle domain or DID identifier to resolve 152 - * @returns Resolved PDS and authorization server metadata 153 - */ 154 - export const resolveFromIdentity = async ( 155 - ident: string, 156 - ): Promise<{ identity: IdentityMetadata; metadata: AuthorizationServerMetadata }> => { 157 - let did: Did; 158 - if (isDid(ident)) { 159 - did = ident; 160 - } else { 161 - const resolved = await resolveHandle(ident); 162 - did = resolved; 163 - } 164 - 165 - const doc = await getDidDocument(did); 166 - const pds = getPdsEndpoint(doc); 167 - 168 - if (!pds) { 169 - throw new ResolverError(`missing pds endpoint`); 170 - } 171 - 172 - return { 173 - identity: { 174 - id: did, 175 - raw: ident, 176 - pds: new URL(pds), 177 - }, 178 - metadata: await getMetadataFromResourceServer(pds), 179 - }; 180 - }; 181 - 182 - /** 183 - * Request authorization server metadata from a PDS 184 - * @param host URL of the host 185 - * @returns Resolved authorization server metadata 186 - */ 187 - export const resolveFromService = async ( 188 - host: string, 189 - ): Promise<{ metadata: AuthorizationServerMetadata }> => { 190 - try { 191 - const metadata = await getMetadataFromResourceServer(host); 192 - return { metadata }; 193 - } catch (err) { 194 - if (err instanceof ResolverError) { 195 - try { 196 - const metadata = await getAuthorizationServerMetadata(host); 197 - return { metadata }; 198 - } catch {} 199 - } 200 - 201 - throw err; 202 - } 203 - }; 204 - 205 - /** 206 - * Request authorization server metadata from its protected resource metadata 207 - * @param input URL of the host whose authorization server is delegated 208 - * @returns Resolved authorization server metadata 209 - */ 210 - export const getMetadataFromResourceServer = async (input: string) => { 95 + const getMetadataFromResourceServer = async (input: string) => { 211 96 const rs_metadata = await getProtectedResourceMetadata(input); 212 97 213 98 if (rs_metadata.authorization_servers?.length !== 1) {
+1
packages/oauth/browser-client/lib/store/db.ts
··· 30 30 dpopKey: DPoPKey; 31 31 metadata: AuthorizationServerMetadata; 32 32 verifier?: string; 33 + state?: unknown; 33 34 }; 34 35 }; 35 36
+14 -5
packages/oauth/browser-client/lib/types/identity.ts
··· 1 - import type { Did } from '@atcute/lexicons'; 1 + import type { ActorIdentifier, Did, Handle } from '@atcute/lexicons'; 2 + 3 + export interface ResolvedIdentity { 4 + did: Did; 5 + handle: Handle; 6 + pds: string; 7 + } 8 + 9 + export interface ResolveIdentityOptions { 10 + signal?: AbortSignal; 11 + noCache?: boolean; 12 + } 2 13 3 - export interface IdentityMetadata { 4 - id: Did; 5 - raw: string; 6 - pds: URL; 14 + export interface IdentityResolver { 15 + resolve(actor: ActorIdentifier, options?: ResolveIdentityOptions): Promise<ResolvedIdentity>; 7 16 }
+59
packages/oauth/browser-client/lib/utils/identity-resolver.ts
··· 1 + import { getAtprotoHandle, getPdsEndpoint } from '@atcute/identity'; 2 + import type { DidDocumentResolver, HandleResolver } from '@atcute/identity-resolver'; 3 + import type { ActorIdentifier, Did, Handle } from '@atcute/lexicons'; 4 + import { isDid } from '@atcute/lexicons/syntax'; 5 + 6 + import { ResolverError } from '../errors.js'; 7 + import type { IdentityResolver, ResolvedIdentity, ResolveIdentityOptions } from '../types/identity.js'; 8 + 9 + export interface DefaultIdentityResolverOptions { 10 + handleResolver: HandleResolver; 11 + didDocumentResolver: DidDocumentResolver; 12 + } 13 + 14 + export const defaultIdentityResolver = ({ 15 + handleResolver, 16 + didDocumentResolver, 17 + }: DefaultIdentityResolverOptions): IdentityResolver => { 18 + return { 19 + async resolve(actor: ActorIdentifier, options?: ResolveIdentityOptions): Promise<ResolvedIdentity> { 20 + const identifierIsDid = isDid(actor); 21 + 22 + let did: Did; 23 + if (identifierIsDid) { 24 + did = actor; 25 + } else { 26 + did = await handleResolver.resolve(actor, options); 27 + } 28 + 29 + const doc = await didDocumentResolver.resolve(did, options); 30 + 31 + const pds = getPdsEndpoint(doc); 32 + if (!pds) { 33 + throw new ResolverError(`missing pds endpoint`); 34 + } 35 + 36 + let handle: Handle = 'handle.invalid'; 37 + if (identifierIsDid) { 38 + const writtenHandle = getAtprotoHandle(doc); 39 + if (writtenHandle) { 40 + try { 41 + const resolved = await handleResolver.resolve(writtenHandle, options); 42 + 43 + if (resolved === did) { 44 + handle = writtenHandle; 45 + } 46 + } catch {} 47 + } 48 + } else if (getAtprotoHandle(doc) === actor) { 49 + handle = actor; 50 + } 51 + 52 + return { 53 + did: did, 54 + handle: handle, 55 + pds: new URL(pds).href, 56 + }; 57 + }, 58 + }; 59 + };
+1
packages/oauth/browser-client/package.json
··· 27 27 "dependencies": { 28 28 "@atcute/client": "workspace:^", 29 29 "@atcute/identity": "workspace:^", 30 + "@atcute/identity-resolver": "workspace:^", 30 31 "@atcute/lexicons": "workspace:^", 31 32 "@atcute/multibase": "workspace:^", 32 33 "@atcute/uint8array": "workspace:^",
+3
pnpm-lock.yaml
··· 585 585 '@atcute/identity': 586 586 specifier: workspace:^ 587 587 version: link:../../identity/identity 588 + '@atcute/identity-resolver': 589 + specifier: workspace:^ 590 + version: link:../../identity/identity-resolver 588 591 '@atcute/lexicons': 589 592 specifier: workspace:^ 590 593 version: link:../../lexicons/lexicons