This repository has no description
0

Configure Feed

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

reference docs for auth package we use

+853
+853
.agent/logs/20260120_oauth_docs.md
··· 1 + # oauth client node npm package 2 + 3 + atproto OAuth Client for NodeJS 4 + 5 + This package implements all the OAuth features required by ATPROTO (PKCE, etc.) to run in a NodeJS based environment such as desktop apps built with Electron or traditional web app backends built with frameworks like Express. 6 + Setup 7 + Client configuration 8 + 9 + The client_id is what identifies your application to the OAuth server. It is used to fetch the client metadata, and to initiate the OAuth flow. The client_id must be a URL that points to the client metadata. 10 + 11 + Your OAuth client metadata should be hosted at a URL that corresponds to the client_id of your application. This URL should return a JSON object with the client metadata. The client metadata should be configured according to the needs of your application, and must respect the ATPROTO. 12 + From a backend service 13 + 14 + The client_metadata object will typically be built by the backend at startup. 15 + 16 + import { NodeOAuthClient, Session } from '@atproto/oauth-client-node' 17 + import { JoseKey } from '@atproto/jwk-jose' 18 + 19 + const client = new NodeOAuthClient({ 20 + // This object will be used to build the payload of the /client-metadata.json 21 + // endpoint metadata, exposing the client metadata to the OAuth server. 22 + clientMetadata: { 23 + // Must be a URL that will be exposing this metadata 24 + client_id: 'https://my-app.com/client-metadata.json', 25 + client_name: 'My App', 26 + client_uri: 'https://my-app.com', 27 + logo_uri: 'https://my-app.com/logo.png', 28 + tos_uri: 'https://my-app.com/tos', 29 + policy_uri: 'https://my-app.com/policy', 30 + redirect_uris: ['https://my-app.com/callback'], 31 + grant_types: ['authorization_code', 'refresh_token'], 32 + scope: 'atproto transition:generic', 33 + response_types: ['code'], 34 + application_type: 'web', 35 + token_endpoint_auth_method: 'private_key_jwt', 36 + token_endpoint_auth_signing_alg: 'RS256', 37 + dpop_bound_access_tokens: true, 38 + jwks_uri: 'https://my-app.com/jwks.json', 39 + }, 40 + 41 + // Used to authenticate the client to the token endpoint. Will be used to 42 + // build the jwks object to be exposed on the "jwks_uri" endpoint. 43 + keyset: await Promise.all([ 44 + JoseKey.fromImportable(process.env.PRIVATE_KEY_1, 'key1'), 45 + JoseKey.fromImportable(process.env.PRIVATE_KEY_2, 'key2'), 46 + JoseKey.fromImportable(process.env.PRIVATE_KEY_3, 'key3'), 47 + ]), 48 + 49 + // Interface to store authorization state data (during authorization flows) 50 + stateStore: { 51 + async set(key: string, internalState: NodeSavedState): Promise<void> {}, 52 + async get(key: string): Promise<NodeSavedState | undefined> {}, 53 + async del(key: string): Promise<void> {}, 54 + }, 55 + 56 + // Interface to store authenticated session data 57 + sessionStore: { 58 + async set(sub: string, session: Session): Promise<void> {}, 59 + async get(sub: string): Promise<Session | undefined> {}, 60 + async del(sub: string): Promise<void> {}, 61 + }, 62 + 63 + // A lock to prevent concurrent access to the session store. Optional if only one instance is running. 64 + requestLock, 65 + }) 66 + 67 + const app = express() 68 + 69 + // Expose the metadata and jwks 70 + app.get('client-metadata.json', (req, res) => res.json(client.clientMetadata)) 71 + app.get('jwks.json', (req, res) => res.json(client.jwks)) 72 + 73 + // Create an endpoint to initiate the OAuth flow 74 + app.get('/login', async (req, res, next) => { 75 + try { 76 + const handle = 'some-handle.bsky.social' // eg. from query string 77 + const state = '434321' 78 + 79 + // Revoke any pending authentication requests if the connection is closed (optional) 80 + const ac = new AbortController() 81 + req.on('close', () => ac.abort()) 82 + 83 + const url = await client.authorize(handle, { 84 + signal: ac.signal, 85 + state, 86 + // Only supported if OAuth server is openid-compliant 87 + ui_locales: 'fr-CA fr en', 88 + }) 89 + 90 + res.redirect(url) 91 + 92 + } catch (err) { 93 + next(err) 94 + } 95 + }) 96 + 97 + // Create an endpoint to handle the OAuth callback 98 + app.get('/atproto-oauth-callback', async (req, res, next) => { 99 + try { 100 + const params = new URLSearchParams(req.url.split('?')[1]) 101 + 102 + const { session, state } = await client.callback(params) 103 + 104 + // Process successful authentication here 105 + console.log('authorize() was called with state:', state) 106 + 107 + console.log('User authenticated as:', session.did) 108 + 109 + const agent = new Agent(session) 110 + 111 + // Make Authenticated API calls 112 + const profile = await agent.getProfile({ actor: agent.did }) 113 + console.log('Bsky profile:', profile.data) 114 + 115 + res.json({ ok: true }) 116 + 117 + } catch (err) { 118 + next(err) 119 + } 120 + }) 121 + 122 + // Whenever needed, restore a user's session 123 + async function worker() { 124 + const userDid = 'did:plc:123' 125 + 126 + const oauthSession = await client.restore(userDid) 127 + 128 + // Note: If the current access_token is expired, the session will automatically 129 + // (and transparently) refresh it. The new token set will be saved though 130 + // the client's session store. 131 + 132 + const agent = new Agent(oauthSession) 133 + 134 + // Make Authenticated API calls 135 + const profile = await agent.getProfile({ actor: agent.did }) 136 + console.log('Bsky profile:', profile.data) 137 + } 138 + 139 + From a native application 140 + 141 + This applies to mobile apps, desktop apps, etc. based on NodeJS (e.g. Electron). 142 + 143 + The client metadata must be hosted on an internet-accessible URL owned by you. The client metadata will typically contain: 144 + 145 + { 146 + "client_id": "https://my-app.com/client-metadata.json", 147 + "client_name": "My App", 148 + "client_uri": "https://my-app.com", 149 + "logo_uri": "https://my-app.com/logo.png", 150 + "tos_uri": "https://my-app.com/tos", 151 + "policy_uri": "https://my-app.com/policy", 152 + "redirect_uris": ["https://my-app.com/atproto-oauth-callback"], 153 + "scope": "atproto", 154 + "grant_types": ["authorization_code", "refresh_token"], 155 + "response_types": ["code"], 156 + "application_type": "native", 157 + "token_endpoint_auth_method": "none", 158 + "dpop_bound_access_tokens": true 159 + } 160 + 161 + Instead of hard-coding the client metadata in your app, you can fetch it when the app starts: 162 + 163 + import { NodeOAuthClient } from '@atproto/oauth-client-node' 164 + 165 + const client = await NodeOAuthClient.fromClientId({ 166 + clientId: 'https://my-app.com/client-metadata.json', 167 + 168 + stateStore: { 169 + async set(key: string, internalState: NodeSavedState): Promise<void> {}, 170 + async get(key: string): Promise<NodeSavedState | undefined> {}, 171 + async del(key: string): Promise<void> {}, 172 + }, 173 + 174 + sessionStore: { 175 + async set(sub: string, session: Session): Promise<void> {}, 176 + async get(sub: string): Promise<Session | undefined> {}, 177 + async del(sub: string): Promise<void> {}, 178 + }, 179 + 180 + // A lock to prevent concurrent access to the session store. Optional if only one instance is running. 181 + requestLock, 182 + }) 183 + 184 + [!NOTE] 185 + 186 + There is no keyset in this instance. This is due to the fact that app clients cannot safely store a private key. The token_endpoint_auth_method is set to none in the client metadata, which means that the client will not be authenticating itself to the token endpoint. This will cause sessions to have a shorter lifetime. You can circumvent this by providing a "BFF" (Backend for Frontend) that will perform an authenticated OAuth flow and use a session id based mechanism to authenticate the client. 187 + 188 + Common configuration options 189 + 190 + The OAuthClient and OAuthAgent classes will manage and refresh OAuth tokens transparently. They are also responsible to properly format the HTTP requests payload, using DPoP, and transparently retrying requests when the access token expires. 191 + 192 + For this to work, the client must be configured with the following options: 193 + sessionStore 194 + 195 + A simple key-value store to save the OAuth session data. This is used to save the access token, refresh token, and other session data. 196 + 197 + const sessionStore: NodeSavedSessionStore = { 198 + async set(sub: string, sessionData: NodeSavedSession) { 199 + // Insert or update the session data in your database 200 + await saveSessionDataToDb(sub, sessionData) 201 + }, 202 + 203 + async get(sub: string) { 204 + // Retrieve the session data from your database 205 + const sessionData = await getSessionDataFromDb(sub) 206 + if (!sessionData) return undefined 207 + 208 + return sessionData 209 + 210 + }, 211 + 212 + async del(sub: string) { 213 + // Delete the session data from your database 214 + await deleteSessionDataFromDb(sub) 215 + }, 216 + } 217 + 218 + stateStore 219 + 220 + A simple key-value store to save the state of the OAuth authorization flow. This is used to prevent CSRF attacks. 221 + 222 + The implementation of the StateStore is similar to the sessionStore. 223 + 224 + interface NodeSavedStateStore { 225 + set: (key: string, internalState: NodeSavedState) => Promise<void> 226 + get: (key: string) => Promise<NodeSavedState | undefined> 227 + del: (key: string) => Promise<void> 228 + } 229 + 230 + One notable exception is that state store items can (and should) be deleted after a short period of time (one hour should be more than enough). 231 + requestLock 232 + 233 + When multiple instances of the client are running, this lock will prevent concurrent refreshes of the same session. 234 + 235 + Here is an example implementation based on redlock: 236 + 237 + import { RuntimeLock } from '@atproto/oauth-client-node' 238 + import Redis from 'ioredis' 239 + import Redlock from 'redlock' 240 + 241 + const redisClients = new Redis() 242 + const redlock = new Redlock(redisClients) 243 + 244 + const requestLock: RuntimeLock = async (key, fn) => { 245 + // 30 seconds should be enough. Since we will be using one lock per user id 246 + // we can be quite liberal with the lock duration here. 247 + const lock = await redlock.lock(key, 45e3) 248 + try { 249 + return await fn() 250 + } finally { 251 + await redlock.unlock(lock) 252 + } 253 + } 254 + 255 + Usage with @atproto/api 256 + 257 + @atproto/oauth-client-\* packages all return an ApiClient instance upon successful authentication. This instance can be used to make authenticated requests using all the ApiClient methods defined in [API] (non exhaustive list of examples below). Any refresh of the credentials will happen under the hood, and the new tokens will be saved in the session store. 258 + 259 + const session = await client.restore('did:plc:123') 260 + const agent = new Agent(session) 261 + 262 + // Feeds and content 263 + await agent.getTimeline(params, opts) 264 + await agent.getAuthorFeed(params, opts) 265 + await agent.getPostThread(params, opts) 266 + await agent.getPost(params) 267 + await agent.getPosts(params, opts) 268 + await agent.getLikes(params, opts) 269 + await agent.getRepostedBy(params, opts) 270 + await agent.post(record) 271 + await agent.deletePost(postUri) 272 + await agent.like(uri, cid) 273 + await agent.deleteLike(likeUri) 274 + await agent.repost(uri, cid) 275 + await agent.deleteRepost(repostUri) 276 + await agent.uploadBlob(data, opts) 277 + 278 + // Social graph 279 + await agent.getFollows(params, opts) 280 + await agent.getFollowers(params, opts) 281 + await agent.follow(did) 282 + await agent.deleteFollow(followUri) 283 + 284 + // Actors 285 + await agent.getProfile(params, opts) 286 + await agent.upsertProfile(updateFn) 287 + await agent.getProfiles(params, opts) 288 + await agent.getSuggestions(params, opts) 289 + await agent.searchActors(params, opts) 290 + await agent.searchActorsTypeahead(params, opts) 291 + await agent.mute(did) 292 + await agent.unmute(did) 293 + await agent.muteModList(listUri) 294 + await agent.unmuteModList(listUri) 295 + await agent.blockModList(listUri) 296 + await agent.unblockModList(listUri) 297 + 298 + // Notifications 299 + await agent.listNotifications(params, opts) 300 + await agent.countUnreadNotifications(params, opts) 301 + await agent.updateSeenNotifications() 302 + 303 + // Identity 304 + await agent.resolveHandle(params, opts) 305 + await agent.updateHandle(params, opts) 306 + 307 + // etc. 308 + 309 + // Always remember to revoke the credentials when you are done 310 + await session.signOut() 311 + 312 + Advances use-cases 313 + Listening for session updates and deletion 314 + 315 + The OAuthClient will emit events whenever a session is updated or deleted. 316 + 317 + import { 318 + Session, 319 + TokenRefreshError, 320 + TokenRevokedError, 321 + } from '@atproto/oauth-client-node' 322 + 323 + client.addEventListener('updated', (event: CustomEvent<Session>) => { 324 + console.log('Refreshed tokens were saved in the store:', event.detail) 325 + }) 326 + 327 + client.addEventListener( 328 + 'deleted', 329 + ( 330 + event: CustomEvent<{ 331 + sub: string 332 + cause: TokenRefreshError | TokenRevokedError | unknown 333 + }>, 334 + ) => { 335 + console.log('Session was deleted from the session store:', event.detail) 336 + 337 + const { cause } = event.detail 338 + 339 + if (cause instanceof TokenRefreshError) { 340 + // - refresh_token unavailable or expired 341 + // - oauth response error (`cause.cause instanceof OAuthResponseError`) 342 + // - session data does not match expected values returned by the OAuth server 343 + } else if (cause instanceof TokenRevokedError) { 344 + // Session was revoked through: 345 + // - session.signOut() 346 + // - client.revoke(sub) 347 + } else { 348 + // An unexpected error occurred, causing the session to be deleted 349 + } 350 + 351 + }, 352 + ) 353 + 354 + Silent Sign-In 355 + 356 + Using silent sign-in requires to handle retries on the callback endpoint. 357 + 358 + app.get('/login', async (req, res) => { 359 + const handle = 'some-handle.bsky.social' // eg. from query string 360 + const user = req.user.id 361 + 362 + const url = await client.authorize(handle, { 363 + // Use "prompt=none" to attempt silent sign-in 364 + prompt: 'none', 365 + 366 + // Build an internal state to map the login request to the user, and allow retries 367 + state: JSON.stringify({ 368 + user, 369 + handle, 370 + }), 371 + 372 + }) 373 + 374 + res.redirect(url) 375 + }) 376 + 377 + app.get('/atproto-oauth-callback', async (req, res) => { 378 + const params = new URLSearchParams(req.url.split('?')[1]) 379 + try { 380 + try { 381 + const { session, state } = await client.callback(params) 382 + 383 + // Process successful authentication here. For example: 384 + 385 + const agent = new Agent(session) 386 + 387 + const profile = await agent.getProfile({ actor: agent.did }) 388 + 389 + console.log('Bsky profile:', profile.data) 390 + } catch (err) { 391 + // Silent sign-in failed, retry without prompt=none 392 + if ( 393 + err instanceof OAuthCallbackError && 394 + ['login_required', 'consent_required'].includes(err.params.get('error')) 395 + ) { 396 + // Parse previous state 397 + const { user, handle } = JSON.parse(err.state) 398 + 399 + const url = await client.authorize(handle, { 400 + // Note that we omit the prompt parameter here. Setting "prompt=none" 401 + // here would result in an infinite redirect loop. 402 + 403 + // Build a new state (or re-use the previous one) 404 + state: JSON.stringify({ 405 + user, 406 + handle, 407 + }), 408 + }) 409 + 410 + // redirect to new URL 411 + res.redirect(url) 412 + 413 + return 414 + } 415 + 416 + throw err 417 + } 418 + 419 + } catch (err) { 420 + next(err) 421 + } 422 + }) 423 + 424 + # example errors 425 + 426 + 2026-01-20 16:09:03.545 427 + 428 + Error: Failed to fetch user profile: Failed to get authenticated agent for BlueskyProfileService: Failed to authenticate: No valid OAuth or App Password session found. OAuth error: OAuth authentication failed: The session was deleted by another process. App Password error: App Password session failed: No session found for DID: did:plc:dnaonrdiuvgepft6heblocle 429 + 2026-01-20 16:09:03.342 430 + Error: Failed to fetch user profile: Failed to get authenticated agent for BlueskyProfileService: Failed to authenticate: No valid OAuth or App Password session found. OAuth error: OAuth authentication failed: The session was deleted by another process. App Password error: App Password session failed: No session found for DID: did:plc:dnaonrdiuvgepft6heblocle 431 + 2026-01-20 16:09:02.817 432 + Error: Failed to fetch user profile: Failed to get authenticated agent for BlueskyProfileService: Failed to authenticate: No valid OAuth or App Password session found. OAuth error: OAuth authentication failed: Session expired. App Password error: App Password session failed: No session found for DID: did:plc:dnaonrdiuvgepft6heblocle 433 + 2026-01-20 15:19:11.099 434 + [AppError]: An unexpected error occurred 435 + 2026-01-20 14:09:12.990 436 + message: 'Failed to find similar URLs: Search service error: write CONNECT_TIMEOUT pgbouncer.1zqyxr78me70wp8m.flympg.net:5432', 437 + 2026-01-20 14:08:44.635 438 + Error: Failed to fetch user profile: Failed to get authenticated agent for BlueskyProfileService: Failed to authenticate: No valid OAuth or App Password session found. OAuth error: OAuth authentication failed: The session was deleted by another process. App Password error: App Password session failed: No session found for DID: did:plc:j3w6epaoicv72rsppeplh2y7 439 + 2026-01-20 14:08:42.965 440 + Error: Failed to fetch user profile: Failed to get authenticated agent for BlueskyProfileService: Failed to authenticate: No valid OAuth or App Password session found. OAuth error: OAuth authentication failed: The operation was unable to achieve a quorum during its retry window.. App Password error: App Password session failed: No session found for DID: did:plc:j3w6epaoicv72rsppeplh2y7 441 + 442 + # session getting from oauth-client-node 443 + 444 + ```ts 445 + import { AtprotoDid } from '@atproto/did'; 446 + import { Key } from '@atproto/jwk'; 447 + import { 448 + CachedGetter, 449 + GetCachedOptions, 450 + SimpleStore, 451 + } from '@atproto-labs/simple-store'; 452 + import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js'; 453 + import { TokenInvalidError } from './errors/token-invalid-error.js'; 454 + import { TokenRefreshError } from './errors/token-refresh-error.js'; 455 + import { TokenRevokedError } from './errors/token-revoked-error.js'; 456 + import { ClientAuthMethod } from './oauth-client-auth.js'; 457 + import { OAuthResponseError } from './oauth-response-error.js'; 458 + import { TokenSet } from './oauth-server-agent.js'; 459 + import { OAuthServerFactory } from './oauth-server-factory.js'; 460 + import { Runtime } from './runtime.js'; 461 + import { CustomEventTarget, combineSignals } from './util.js'; 462 + 463 + export type Session = { 464 + dpopKey: Key; 465 + /** 466 + * Previous implementation of this lib did not define an `authMethod` 467 + */ 468 + authMethod?: ClientAuthMethod; 469 + tokenSet: TokenSet; 470 + }; 471 + 472 + export type SessionStore = SimpleStore<string, Session>; 473 + 474 + export type SessionEventMap = { 475 + updated: { 476 + sub: string; 477 + } & Session; 478 + deleted: { 479 + sub: string; 480 + cause: TokenRefreshError | TokenRevokedError | TokenInvalidError | unknown; 481 + }; 482 + }; 483 + 484 + export type SessionEventListener< 485 + T extends keyof SessionEventMap = keyof SessionEventMap, 486 + > = (event: CustomEvent<SessionEventMap[T]>) => void; 487 + 488 + /** 489 + * There are several advantages to wrapping the sessionStore in a (single) 490 + * CachedGetter, the main of which is that the cached getter will ensure that at 491 + * most one fresh call is ever being made. Another advantage, is that it 492 + * contains the logic for reading from the cache which, if the cache is based on 493 + * localStorage/indexedDB, will sync across multiple tabs (for a given sub). 494 + */ 495 + export class SessionGetter extends CachedGetter<AtprotoDid, Session> { 496 + private readonly eventTarget = new CustomEventTarget<SessionEventMap>(); 497 + 498 + constructor( 499 + sessionStore: SessionStore, 500 + serverFactory: OAuthServerFactory, 501 + private readonly runtime: Runtime, 502 + ) { 503 + super( 504 + async (sub, options, storedSession) => { 505 + // There needs to be a previous session to be able to refresh. If 506 + // storedSession is undefined, it means that the store does not contain 507 + // a session for the given sub. 508 + if (storedSession === undefined) { 509 + // Because the session is not in the store, this.delStored() method 510 + // will not be called by the CachedGetter class (because there is 511 + // nothing to delete). This would typically happen if there is no 512 + // synchronization mechanism between instances of this class. Let's 513 + // make sure an event is dispatched here if this occurs. 514 + const msg = 'The session was deleted by another process'; 515 + const cause = new TokenRefreshError(sub, msg); 516 + this.dispatchEvent('deleted', { sub, cause }); 517 + throw cause; 518 + } 519 + 520 + // From this point forward, throwing a TokenRefreshError will result in 521 + // this.delStored() being called, resulting in an event being 522 + // dispatched, even if the session was removed from the store through a 523 + // concurrent access (which, normally, should not happen if a proper 524 + // runtime lock was provided). 525 + 526 + const { dpopKey, authMethod = 'legacy', tokenSet } = storedSession; 527 + 528 + if (sub !== tokenSet.sub) { 529 + // Fool-proofing (e.g. against invalid session storage) 530 + throw new TokenRefreshError(sub, 'Stored session sub mismatch'); 531 + } 532 + 533 + if (!tokenSet.refresh_token) { 534 + throw new TokenRefreshError(sub, 'No refresh token available'); 535 + } 536 + 537 + // Since refresh tokens can only be used once, we might run into 538 + // concurrency issues if multiple instances (e.g. browser tabs) are 539 + // trying to refresh the same token simultaneously. The chances of this 540 + // happening when multiple instances are started simultaneously is 541 + // reduced by randomizing the expiry time (see isStale() below). The 542 + // best solution is to use a mutex/lock to ensure that only one instance 543 + // is refreshing the token at a time (runtime.usingLock) but that is not 544 + // always possible. If no lock implementation is provided, we will use 545 + // the store to check if a concurrent refresh occurred. 546 + 547 + const server = await serverFactory.fromIssuer( 548 + tokenSet.iss, 549 + authMethod, 550 + dpopKey, 551 + ); 552 + 553 + // Because refresh tokens can only be used once, we must not use the 554 + // "signal" to abort the refresh, or throw any abort error beyond this 555 + // point. Any thrown error beyond this point will prevent the 556 + // TokenGetter from obtaining, and storing, the new token set, 557 + // effectively rendering the currently saved session unusable. 558 + options?.signal?.throwIfAborted(); 559 + 560 + try { 561 + const newTokenSet = await server.refresh(tokenSet); 562 + 563 + if (sub !== newTokenSet.sub) { 564 + // The server returned another sub. Was the tokenSet manipulated? 565 + throw new TokenRefreshError(sub, 'Token set sub mismatch'); 566 + } 567 + 568 + return { 569 + dpopKey, 570 + tokenSet: newTokenSet, 571 + authMethod: server.authMethod, 572 + }; 573 + } catch (cause) { 574 + // If the refresh token is invalid, let's try to recover from 575 + // concurrency issues, or make sure the session is deleted by throwing 576 + // a TokenRefreshError. 577 + if ( 578 + cause instanceof OAuthResponseError && 579 + cause.status === 400 && 580 + cause.error === 'invalid_grant' 581 + ) { 582 + // In case there is no lock implementation in the runtime, we will 583 + // wait for a short time to give the other concurrent instances a 584 + // chance to finish their refreshing of the token. If a concurrent 585 + // refresh did occur, we will pretend that this one succeeded. 586 + if (!runtime.hasImplementationLock) { 587 + await new Promise((r) => setTimeout(r, 1000)); 588 + 589 + const stored = await this.getStored(sub); 590 + if (stored === undefined) { 591 + // A concurrent refresh occurred and caused the session to be 592 + // deleted (for a reason we can't know at this point). 593 + 594 + // Using a distinct error message mainly for debugging 595 + // purposes. Also, throwing a TokenRefreshError to trigger 596 + // deletion through the deleteOnError callback. 597 + const msg = 'The session was deleted by another process'; 598 + throw new TokenRefreshError(sub, msg, { cause }); 599 + } else if ( 600 + stored.tokenSet.access_token !== tokenSet.access_token || 601 + stored.tokenSet.refresh_token !== tokenSet.refresh_token 602 + ) { 603 + // A concurrent refresh occurred. Pretend this one succeeded. 604 + return stored; 605 + } else { 606 + // There were no concurrent refresh. The token is (likely) 607 + // simply no longer valid. 608 + } 609 + } 610 + 611 + // Make sure the session gets deleted from the store 612 + const msg = cause.errorDescription ?? 'The session was revoked'; 613 + throw new TokenRefreshError(sub, msg, { cause }); 614 + } 615 + 616 + throw cause; 617 + } 618 + }, 619 + sessionStore, 620 + { 621 + isStale: (sub, { tokenSet }) => { 622 + return ( 623 + tokenSet.expires_at != null && 624 + new Date(tokenSet.expires_at).getTime() < 625 + Date.now() + 626 + // Add some lee way to ensure the token is not expired when it 627 + // reaches the server. 628 + 10e3 + 629 + // Add some randomness to reduce the chances of multiple 630 + // instances trying to refresh the token at the same. 631 + 30e3 * Math.random() 632 + ); 633 + }, 634 + onStoreError: async ( 635 + err, 636 + sub, 637 + { tokenSet, dpopKey, authMethod = 'legacy' as const }, 638 + ) => { 639 + if (!(err instanceof AuthMethodUnsatisfiableError)) { 640 + // If the error was an AuthMethodUnsatisfiableError, there is no 641 + // point in trying to call `fromIssuer`. 642 + try { 643 + // If the token data cannot be stored, let's revoke it 644 + const server = await serverFactory.fromIssuer( 645 + tokenSet.iss, 646 + authMethod, 647 + dpopKey, 648 + ); 649 + await server.revoke( 650 + tokenSet.refresh_token ?? tokenSet.access_token, 651 + ); 652 + } catch { 653 + // Let the original error propagate 654 + } 655 + } 656 + 657 + throw err; 658 + }, 659 + deleteOnError: async (err) => 660 + err instanceof TokenRefreshError || 661 + err instanceof TokenRevokedError || 662 + err instanceof TokenInvalidError || 663 + err instanceof AuthMethodUnsatisfiableError, 664 + }, 665 + ); 666 + } 667 + 668 + addEventListener<T extends keyof SessionEventMap>( 669 + type: T, 670 + callback: SessionEventListener<T>, 671 + options?: AddEventListenerOptions | boolean, 672 + ) { 673 + this.eventTarget.addEventListener(type, callback, options); 674 + } 675 + 676 + removeEventListener<T extends keyof SessionEventMap>( 677 + type: T, 678 + callback: SessionEventListener<T>, 679 + options?: EventListenerOptions | boolean, 680 + ) { 681 + this.eventTarget.removeEventListener(type, callback, options); 682 + } 683 + 684 + dispatchEvent<T extends keyof SessionEventMap>( 685 + type: T, 686 + detail: SessionEventMap[T], 687 + ): boolean { 688 + return this.eventTarget.dispatchCustomEvent(type, detail); 689 + } 690 + 691 + async setStored(sub: string, session: Session) { 692 + // Prevent tampering with the stored value 693 + if (sub !== session.tokenSet.sub) { 694 + throw new TypeError('Token set does not match the expected sub'); 695 + } 696 + await super.setStored(sub, session); 697 + this.dispatchEvent('updated', { sub, ...session }); 698 + } 699 + 700 + override async delStored(sub: AtprotoDid, cause?: unknown): Promise<void> { 701 + await super.delStored(sub, cause); 702 + this.dispatchEvent('deleted', { sub, cause }); 703 + } 704 + 705 + /** 706 + * @param refresh When `true`, the credentials will be refreshed even if they 707 + * are not expired. When `false`, the credentials will not be refreshed even 708 + * if they are expired. When `undefined`, the credentials will be refreshed 709 + * if, and only if, they are (about to be) expired. Defaults to `undefined`. 710 + */ 711 + async getSession(sub: AtprotoDid, refresh: boolean | 'auto' = 'auto') { 712 + return this.get(sub, { 713 + noCache: refresh === true, 714 + allowStale: refresh === false, 715 + }); 716 + } 717 + 718 + async get(sub: AtprotoDid, options?: GetCachedOptions): Promise<Session> { 719 + const session = await this.runtime.usingLock( 720 + `@atproto-oauth-client-${sub}`, 721 + async () => { 722 + // Make sure, even if there is no signal in the options, that the 723 + // request will be cancelled after at most 30 seconds. 724 + const signal = AbortSignal.timeout(30e3); 725 + 726 + using abortController = combineSignals([options?.signal, signal]); 727 + 728 + return await super.get(sub, { 729 + ...options, 730 + signal: abortController.signal, 731 + }); 732 + }, 733 + ); 734 + 735 + if (sub !== session.tokenSet.sub) { 736 + // Fool-proofing (e.g. against invalid session storage) 737 + throw new Error('Token set does not match the expected sub'); 738 + } 739 + 740 + return session; 741 + } 742 + } 743 + ``` 744 + 745 + also 746 + 747 + ```ts 748 + protected async validateRefreshGrant( 749 + client: Client, 750 + clientAuth: ClientAuth, 751 + data: TokenData, 752 + ): Promise<void> { 753 + const [sessionLifetime, refreshLifetime] = 754 + clientAuth.method !== 'none' || client.info.isFirstParty 755 + ? [ 756 + CONFIDENTIAL_CLIENT_SESSION_LIFETIME, 757 + CONFIDENTIAL_CLIENT_REFRESH_LIFETIME, 758 + ] 759 + : [PUBLIC_CLIENT_SESSION_LIFETIME, PUBLIC_CLIENT_REFRESH_LIFETIME] 760 + 761 + const sessionAge = Date.now() - data.createdAt.getTime() 762 + if (sessionAge > sessionLifetime) { 763 + throw new InvalidGrantError(`Session expired`) 764 + } 765 + 766 + const refreshAge = Date.now() - data.updatedAt.getTime() 767 + if (refreshAge > refreshLifetime) { 768 + throw new InvalidGrantError(`Refresh token expired`) 769 + } 770 + } 771 + 772 + protected async refreshTokenGrant( 773 + client: Client, 774 + clientAuth: ClientAuth, 775 + clientMetadata: RequestMetadata, 776 + input: OAuthRefreshTokenGrantTokenRequest, 777 + dpopProof: null | DpopProof, 778 + ): Promise<OAuthTokenResponse> { 779 + const refreshToken = await refreshTokenSchema 780 + .parseAsync(input.refresh_token, { path: ['refresh_token'] }) 781 + .catch((err) => { 782 + const msg = formatError(err, 'Invalid refresh token') 783 + throw new InvalidGrantError(msg, err) 784 + }) 785 + 786 + const tokenInfo = await this.tokenManager.consumeRefreshToken(refreshToken) 787 + 788 + try { 789 + const { data } = tokenInfo 790 + await this.compareClientAuth(client, clientAuth, dpopProof, data) 791 + await this.validateRefreshGrant(client, clientAuth, data) 792 + 793 + return await this.tokenManager.rotateToken( 794 + client, 795 + clientAuth, 796 + clientMetadata, 797 + tokenInfo, 798 + ) 799 + } catch (err) { 800 + await this.tokenManager.deleteToken(tokenInfo.id) 801 + 802 + throw err 803 + } 804 + } 805 + 806 + public async token( 807 + clientCredentials: OAuthClientCredentials, 808 + clientMetadata: RequestMetadata, 809 + request: OAuthTokenRequest, 810 + dpopProof: null | DpopProof, 811 + ): Promise<OAuthTokenResponse> { 812 + const { client, clientAuth } = await this.authenticateClient( 813 + clientCredentials, 814 + dpopProof, 815 + ) 816 + 817 + if (!this.metadata.grant_types_supported?.includes(request.grant_type)) { 818 + throw new InvalidGrantError( 819 + `Grant type "${request.grant_type}" is not supported by the server`, 820 + ) 821 + } 822 + 823 + if (!client.metadata.grant_types.includes(request.grant_type)) { 824 + throw new InvalidGrantError( 825 + `"${request.grant_type}" grant type is not allowed for this client`, 826 + ) 827 + } 828 + 829 + if (request.grant_type === 'authorization_code') { 830 + return this.authorizationCodeGrant( 831 + client, 832 + clientAuth, 833 + clientMetadata, 834 + request, 835 + dpopProof, 836 + ) 837 + } 838 + 839 + if (request.grant_type === 'refresh_token') { 840 + return this.refreshTokenGrant( 841 + client, 842 + clientAuth, 843 + clientMetadata, 844 + request, 845 + dpopProof, 846 + ) 847 + } 848 + 849 + throw new InvalidGrantError( 850 + `Grant type "${request.grant_type}" not supported`, 851 + ) 852 + } 853 + ```