···11+# oauth client node npm package
22+33+atproto OAuth Client for NodeJS
44+55+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.
66+Setup
77+Client configuration
88+99+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.
1010+1111+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.
1212+From a backend service
1313+1414+The client_metadata object will typically be built by the backend at startup.
1515+1616+import { NodeOAuthClient, Session } from '@atproto/oauth-client-node'
1717+import { JoseKey } from '@atproto/jwk-jose'
1818+1919+const client = new NodeOAuthClient({
2020+// This object will be used to build the payload of the /client-metadata.json
2121+// endpoint metadata, exposing the client metadata to the OAuth server.
2222+clientMetadata: {
2323+// Must be a URL that will be exposing this metadata
2424+client_id: 'https://my-app.com/client-metadata.json',
2525+client_name: 'My App',
2626+client_uri: 'https://my-app.com',
2727+logo_uri: 'https://my-app.com/logo.png',
2828+tos_uri: 'https://my-app.com/tos',
2929+policy_uri: 'https://my-app.com/policy',
3030+redirect_uris: ['https://my-app.com/callback'],
3131+grant_types: ['authorization_code', 'refresh_token'],
3232+scope: 'atproto transition:generic',
3333+response_types: ['code'],
3434+application_type: 'web',
3535+token_endpoint_auth_method: 'private_key_jwt',
3636+token_endpoint_auth_signing_alg: 'RS256',
3737+dpop_bound_access_tokens: true,
3838+jwks_uri: 'https://my-app.com/jwks.json',
3939+},
4040+4141+// Used to authenticate the client to the token endpoint. Will be used to
4242+// build the jwks object to be exposed on the "jwks_uri" endpoint.
4343+keyset: await Promise.all([
4444+JoseKey.fromImportable(process.env.PRIVATE_KEY_1, 'key1'),
4545+JoseKey.fromImportable(process.env.PRIVATE_KEY_2, 'key2'),
4646+JoseKey.fromImportable(process.env.PRIVATE_KEY_3, 'key3'),
4747+]),
4848+4949+// Interface to store authorization state data (during authorization flows)
5050+stateStore: {
5151+async set(key: string, internalState: NodeSavedState): Promise<void> {},
5252+async get(key: string): Promise<NodeSavedState | undefined> {},
5353+async del(key: string): Promise<void> {},
5454+},
5555+5656+// Interface to store authenticated session data
5757+sessionStore: {
5858+async set(sub: string, session: Session): Promise<void> {},
5959+async get(sub: string): Promise<Session | undefined> {},
6060+async del(sub: string): Promise<void> {},
6161+},
6262+6363+// A lock to prevent concurrent access to the session store. Optional if only one instance is running.
6464+requestLock,
6565+})
6666+6767+const app = express()
6868+6969+// Expose the metadata and jwks
7070+app.get('client-metadata.json', (req, res) => res.json(client.clientMetadata))
7171+app.get('jwks.json', (req, res) => res.json(client.jwks))
7272+7373+// Create an endpoint to initiate the OAuth flow
7474+app.get('/login', async (req, res, next) => {
7575+try {
7676+const handle = 'some-handle.bsky.social' // eg. from query string
7777+const state = '434321'
7878+7979+ // Revoke any pending authentication requests if the connection is closed (optional)
8080+ const ac = new AbortController()
8181+ req.on('close', () => ac.abort())
8282+8383+ const url = await client.authorize(handle, {
8484+ signal: ac.signal,
8585+ state,
8686+ // Only supported if OAuth server is openid-compliant
8787+ ui_locales: 'fr-CA fr en',
8888+ })
8989+9090+ res.redirect(url)
9191+9292+} catch (err) {
9393+next(err)
9494+}
9595+})
9696+9797+// Create an endpoint to handle the OAuth callback
9898+app.get('/atproto-oauth-callback', async (req, res, next) => {
9999+try {
100100+const params = new URLSearchParams(req.url.split('?')[1])
101101+102102+ const { session, state } = await client.callback(params)
103103+104104+ // Process successful authentication here
105105+ console.log('authorize() was called with state:', state)
106106+107107+ console.log('User authenticated as:', session.did)
108108+109109+ const agent = new Agent(session)
110110+111111+ // Make Authenticated API calls
112112+ const profile = await agent.getProfile({ actor: agent.did })
113113+ console.log('Bsky profile:', profile.data)
114114+115115+ res.json({ ok: true })
116116+117117+} catch (err) {
118118+next(err)
119119+}
120120+})
121121+122122+// Whenever needed, restore a user's session
123123+async function worker() {
124124+const userDid = 'did:plc:123'
125125+126126+const oauthSession = await client.restore(userDid)
127127+128128+// Note: If the current access_token is expired, the session will automatically
129129+// (and transparently) refresh it. The new token set will be saved though
130130+// the client's session store.
131131+132132+const agent = new Agent(oauthSession)
133133+134134+// Make Authenticated API calls
135135+const profile = await agent.getProfile({ actor: agent.did })
136136+console.log('Bsky profile:', profile.data)
137137+}
138138+139139+From a native application
140140+141141+This applies to mobile apps, desktop apps, etc. based on NodeJS (e.g. Electron).
142142+143143+The client metadata must be hosted on an internet-accessible URL owned by you. The client metadata will typically contain:
144144+145145+{
146146+"client_id": "https://my-app.com/client-metadata.json",
147147+"client_name": "My App",
148148+"client_uri": "https://my-app.com",
149149+"logo_uri": "https://my-app.com/logo.png",
150150+"tos_uri": "https://my-app.com/tos",
151151+"policy_uri": "https://my-app.com/policy",
152152+"redirect_uris": ["https://my-app.com/atproto-oauth-callback"],
153153+"scope": "atproto",
154154+"grant_types": ["authorization_code", "refresh_token"],
155155+"response_types": ["code"],
156156+"application_type": "native",
157157+"token_endpoint_auth_method": "none",
158158+"dpop_bound_access_tokens": true
159159+}
160160+161161+Instead of hard-coding the client metadata in your app, you can fetch it when the app starts:
162162+163163+import { NodeOAuthClient } from '@atproto/oauth-client-node'
164164+165165+const client = await NodeOAuthClient.fromClientId({
166166+clientId: 'https://my-app.com/client-metadata.json',
167167+168168+stateStore: {
169169+async set(key: string, internalState: NodeSavedState): Promise<void> {},
170170+async get(key: string): Promise<NodeSavedState | undefined> {},
171171+async del(key: string): Promise<void> {},
172172+},
173173+174174+sessionStore: {
175175+async set(sub: string, session: Session): Promise<void> {},
176176+async get(sub: string): Promise<Session | undefined> {},
177177+async del(sub: string): Promise<void> {},
178178+},
179179+180180+// A lock to prevent concurrent access to the session store. Optional if only one instance is running.
181181+requestLock,
182182+})
183183+184184+ [!NOTE]
185185+186186+ 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.
187187+188188+Common configuration options
189189+190190+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.
191191+192192+For this to work, the client must be configured with the following options:
193193+sessionStore
194194+195195+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.
196196+197197+const sessionStore: NodeSavedSessionStore = {
198198+async set(sub: string, sessionData: NodeSavedSession) {
199199+// Insert or update the session data in your database
200200+await saveSessionDataToDb(sub, sessionData)
201201+},
202202+203203+async get(sub: string) {
204204+// Retrieve the session data from your database
205205+const sessionData = await getSessionDataFromDb(sub)
206206+if (!sessionData) return undefined
207207+208208+ return sessionData
209209+210210+},
211211+212212+async del(sub: string) {
213213+// Delete the session data from your database
214214+await deleteSessionDataFromDb(sub)
215215+},
216216+}
217217+218218+stateStore
219219+220220+A simple key-value store to save the state of the OAuth authorization flow. This is used to prevent CSRF attacks.
221221+222222+The implementation of the StateStore is similar to the sessionStore.
223223+224224+interface NodeSavedStateStore {
225225+set: (key: string, internalState: NodeSavedState) => Promise<void>
226226+get: (key: string) => Promise<NodeSavedState | undefined>
227227+del: (key: string) => Promise<void>
228228+}
229229+230230+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).
231231+requestLock
232232+233233+When multiple instances of the client are running, this lock will prevent concurrent refreshes of the same session.
234234+235235+Here is an example implementation based on redlock:
236236+237237+import { RuntimeLock } from '@atproto/oauth-client-node'
238238+import Redis from 'ioredis'
239239+import Redlock from 'redlock'
240240+241241+const redisClients = new Redis()
242242+const redlock = new Redlock(redisClients)
243243+244244+const requestLock: RuntimeLock = async (key, fn) => {
245245+// 30 seconds should be enough. Since we will be using one lock per user id
246246+// we can be quite liberal with the lock duration here.
247247+const lock = await redlock.lock(key, 45e3)
248248+try {
249249+return await fn()
250250+} finally {
251251+await redlock.unlock(lock)
252252+}
253253+}
254254+255255+Usage with @atproto/api
256256+257257+@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.
258258+259259+const session = await client.restore('did:plc:123')
260260+const agent = new Agent(session)
261261+262262+// Feeds and content
263263+await agent.getTimeline(params, opts)
264264+await agent.getAuthorFeed(params, opts)
265265+await agent.getPostThread(params, opts)
266266+await agent.getPost(params)
267267+await agent.getPosts(params, opts)
268268+await agent.getLikes(params, opts)
269269+await agent.getRepostedBy(params, opts)
270270+await agent.post(record)
271271+await agent.deletePost(postUri)
272272+await agent.like(uri, cid)
273273+await agent.deleteLike(likeUri)
274274+await agent.repost(uri, cid)
275275+await agent.deleteRepost(repostUri)
276276+await agent.uploadBlob(data, opts)
277277+278278+// Social graph
279279+await agent.getFollows(params, opts)
280280+await agent.getFollowers(params, opts)
281281+await agent.follow(did)
282282+await agent.deleteFollow(followUri)
283283+284284+// Actors
285285+await agent.getProfile(params, opts)
286286+await agent.upsertProfile(updateFn)
287287+await agent.getProfiles(params, opts)
288288+await agent.getSuggestions(params, opts)
289289+await agent.searchActors(params, opts)
290290+await agent.searchActorsTypeahead(params, opts)
291291+await agent.mute(did)
292292+await agent.unmute(did)
293293+await agent.muteModList(listUri)
294294+await agent.unmuteModList(listUri)
295295+await agent.blockModList(listUri)
296296+await agent.unblockModList(listUri)
297297+298298+// Notifications
299299+await agent.listNotifications(params, opts)
300300+await agent.countUnreadNotifications(params, opts)
301301+await agent.updateSeenNotifications()
302302+303303+// Identity
304304+await agent.resolveHandle(params, opts)
305305+await agent.updateHandle(params, opts)
306306+307307+// etc.
308308+309309+// Always remember to revoke the credentials when you are done
310310+await session.signOut()
311311+312312+Advances use-cases
313313+Listening for session updates and deletion
314314+315315+The OAuthClient will emit events whenever a session is updated or deleted.
316316+317317+import {
318318+Session,
319319+TokenRefreshError,
320320+TokenRevokedError,
321321+} from '@atproto/oauth-client-node'
322322+323323+client.addEventListener('updated', (event: CustomEvent<Session>) => {
324324+console.log('Refreshed tokens were saved in the store:', event.detail)
325325+})
326326+327327+client.addEventListener(
328328+'deleted',
329329+(
330330+event: CustomEvent<{
331331+sub: string
332332+cause: TokenRefreshError | TokenRevokedError | unknown
333333+}>,
334334+) => {
335335+console.log('Session was deleted from the session store:', event.detail)
336336+337337+ const { cause } = event.detail
338338+339339+ if (cause instanceof TokenRefreshError) {
340340+ // - refresh_token unavailable or expired
341341+ // - oauth response error (`cause.cause instanceof OAuthResponseError`)
342342+ // - session data does not match expected values returned by the OAuth server
343343+ } else if (cause instanceof TokenRevokedError) {
344344+ // Session was revoked through:
345345+ // - session.signOut()
346346+ // - client.revoke(sub)
347347+ } else {
348348+ // An unexpected error occurred, causing the session to be deleted
349349+ }
350350+351351+},
352352+)
353353+354354+Silent Sign-In
355355+356356+Using silent sign-in requires to handle retries on the callback endpoint.
357357+358358+app.get('/login', async (req, res) => {
359359+const handle = 'some-handle.bsky.social' // eg. from query string
360360+const user = req.user.id
361361+362362+const url = await client.authorize(handle, {
363363+// Use "prompt=none" to attempt silent sign-in
364364+prompt: 'none',
365365+366366+ // Build an internal state to map the login request to the user, and allow retries
367367+ state: JSON.stringify({
368368+ user,
369369+ handle,
370370+ }),
371371+372372+})
373373+374374+res.redirect(url)
375375+})
376376+377377+app.get('/atproto-oauth-callback', async (req, res) => {
378378+const params = new URLSearchParams(req.url.split('?')[1])
379379+try {
380380+try {
381381+const { session, state } = await client.callback(params)
382382+383383+ // Process successful authentication here. For example:
384384+385385+ const agent = new Agent(session)
386386+387387+ const profile = await agent.getProfile({ actor: agent.did })
388388+389389+ console.log('Bsky profile:', profile.data)
390390+ } catch (err) {
391391+ // Silent sign-in failed, retry without prompt=none
392392+ if (
393393+ err instanceof OAuthCallbackError &&
394394+ ['login_required', 'consent_required'].includes(err.params.get('error'))
395395+ ) {
396396+ // Parse previous state
397397+ const { user, handle } = JSON.parse(err.state)
398398+399399+ const url = await client.authorize(handle, {
400400+ // Note that we omit the prompt parameter here. Setting "prompt=none"
401401+ // here would result in an infinite redirect loop.
402402+403403+ // Build a new state (or re-use the previous one)
404404+ state: JSON.stringify({
405405+ user,
406406+ handle,
407407+ }),
408408+ })
409409+410410+ // redirect to new URL
411411+ res.redirect(url)
412412+413413+ return
414414+ }
415415+416416+ throw err
417417+ }
418418+419419+} catch (err) {
420420+next(err)
421421+}
422422+})
423423+424424+# example errors
425425+426426+ 2026-01-20 16:09:03.545
427427+428428+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
429429+2026-01-20 16:09:03.342
430430+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
431431+2026-01-20 16:09:02.817
432432+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
433433+2026-01-20 15:19:11.099
434434+[AppError]: An unexpected error occurred
435435+2026-01-20 14:09:12.990
436436+message: 'Failed to find similar URLs: Search service error: write CONNECT_TIMEOUT pgbouncer.1zqyxr78me70wp8m.flympg.net:5432',
437437+2026-01-20 14:08:44.635
438438+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
439439+2026-01-20 14:08:42.965
440440+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
441441+442442+# session getting from oauth-client-node
443443+444444+```ts
445445+import { AtprotoDid } from '@atproto/did';
446446+import { Key } from '@atproto/jwk';
447447+import {
448448+ CachedGetter,
449449+ GetCachedOptions,
450450+ SimpleStore,
451451+} from '@atproto-labs/simple-store';
452452+import { AuthMethodUnsatisfiableError } from './errors/auth-method-unsatisfiable-error.js';
453453+import { TokenInvalidError } from './errors/token-invalid-error.js';
454454+import { TokenRefreshError } from './errors/token-refresh-error.js';
455455+import { TokenRevokedError } from './errors/token-revoked-error.js';
456456+import { ClientAuthMethod } from './oauth-client-auth.js';
457457+import { OAuthResponseError } from './oauth-response-error.js';
458458+import { TokenSet } from './oauth-server-agent.js';
459459+import { OAuthServerFactory } from './oauth-server-factory.js';
460460+import { Runtime } from './runtime.js';
461461+import { CustomEventTarget, combineSignals } from './util.js';
462462+463463+export type Session = {
464464+ dpopKey: Key;
465465+ /**
466466+ * Previous implementation of this lib did not define an `authMethod`
467467+ */
468468+ authMethod?: ClientAuthMethod;
469469+ tokenSet: TokenSet;
470470+};
471471+472472+export type SessionStore = SimpleStore<string, Session>;
473473+474474+export type SessionEventMap = {
475475+ updated: {
476476+ sub: string;
477477+ } & Session;
478478+ deleted: {
479479+ sub: string;
480480+ cause: TokenRefreshError | TokenRevokedError | TokenInvalidError | unknown;
481481+ };
482482+};
483483+484484+export type SessionEventListener<
485485+ T extends keyof SessionEventMap = keyof SessionEventMap,
486486+> = (event: CustomEvent<SessionEventMap[T]>) => void;
487487+488488+/**
489489+ * There are several advantages to wrapping the sessionStore in a (single)
490490+ * CachedGetter, the main of which is that the cached getter will ensure that at
491491+ * most one fresh call is ever being made. Another advantage, is that it
492492+ * contains the logic for reading from the cache which, if the cache is based on
493493+ * localStorage/indexedDB, will sync across multiple tabs (for a given sub).
494494+ */
495495+export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
496496+ private readonly eventTarget = new CustomEventTarget<SessionEventMap>();
497497+498498+ constructor(
499499+ sessionStore: SessionStore,
500500+ serverFactory: OAuthServerFactory,
501501+ private readonly runtime: Runtime,
502502+ ) {
503503+ super(
504504+ async (sub, options, storedSession) => {
505505+ // There needs to be a previous session to be able to refresh. If
506506+ // storedSession is undefined, it means that the store does not contain
507507+ // a session for the given sub.
508508+ if (storedSession === undefined) {
509509+ // Because the session is not in the store, this.delStored() method
510510+ // will not be called by the CachedGetter class (because there is
511511+ // nothing to delete). This would typically happen if there is no
512512+ // synchronization mechanism between instances of this class. Let's
513513+ // make sure an event is dispatched here if this occurs.
514514+ const msg = 'The session was deleted by another process';
515515+ const cause = new TokenRefreshError(sub, msg);
516516+ this.dispatchEvent('deleted', { sub, cause });
517517+ throw cause;
518518+ }
519519+520520+ // From this point forward, throwing a TokenRefreshError will result in
521521+ // this.delStored() being called, resulting in an event being
522522+ // dispatched, even if the session was removed from the store through a
523523+ // concurrent access (which, normally, should not happen if a proper
524524+ // runtime lock was provided).
525525+526526+ const { dpopKey, authMethod = 'legacy', tokenSet } = storedSession;
527527+528528+ if (sub !== tokenSet.sub) {
529529+ // Fool-proofing (e.g. against invalid session storage)
530530+ throw new TokenRefreshError(sub, 'Stored session sub mismatch');
531531+ }
532532+533533+ if (!tokenSet.refresh_token) {
534534+ throw new TokenRefreshError(sub, 'No refresh token available');
535535+ }
536536+537537+ // Since refresh tokens can only be used once, we might run into
538538+ // concurrency issues if multiple instances (e.g. browser tabs) are
539539+ // trying to refresh the same token simultaneously. The chances of this
540540+ // happening when multiple instances are started simultaneously is
541541+ // reduced by randomizing the expiry time (see isStale() below). The
542542+ // best solution is to use a mutex/lock to ensure that only one instance
543543+ // is refreshing the token at a time (runtime.usingLock) but that is not
544544+ // always possible. If no lock implementation is provided, we will use
545545+ // the store to check if a concurrent refresh occurred.
546546+547547+ const server = await serverFactory.fromIssuer(
548548+ tokenSet.iss,
549549+ authMethod,
550550+ dpopKey,
551551+ );
552552+553553+ // Because refresh tokens can only be used once, we must not use the
554554+ // "signal" to abort the refresh, or throw any abort error beyond this
555555+ // point. Any thrown error beyond this point will prevent the
556556+ // TokenGetter from obtaining, and storing, the new token set,
557557+ // effectively rendering the currently saved session unusable.
558558+ options?.signal?.throwIfAborted();
559559+560560+ try {
561561+ const newTokenSet = await server.refresh(tokenSet);
562562+563563+ if (sub !== newTokenSet.sub) {
564564+ // The server returned another sub. Was the tokenSet manipulated?
565565+ throw new TokenRefreshError(sub, 'Token set sub mismatch');
566566+ }
567567+568568+ return {
569569+ dpopKey,
570570+ tokenSet: newTokenSet,
571571+ authMethod: server.authMethod,
572572+ };
573573+ } catch (cause) {
574574+ // If the refresh token is invalid, let's try to recover from
575575+ // concurrency issues, or make sure the session is deleted by throwing
576576+ // a TokenRefreshError.
577577+ if (
578578+ cause instanceof OAuthResponseError &&
579579+ cause.status === 400 &&
580580+ cause.error === 'invalid_grant'
581581+ ) {
582582+ // In case there is no lock implementation in the runtime, we will
583583+ // wait for a short time to give the other concurrent instances a
584584+ // chance to finish their refreshing of the token. If a concurrent
585585+ // refresh did occur, we will pretend that this one succeeded.
586586+ if (!runtime.hasImplementationLock) {
587587+ await new Promise((r) => setTimeout(r, 1000));
588588+589589+ const stored = await this.getStored(sub);
590590+ if (stored === undefined) {
591591+ // A concurrent refresh occurred and caused the session to be
592592+ // deleted (for a reason we can't know at this point).
593593+594594+ // Using a distinct error message mainly for debugging
595595+ // purposes. Also, throwing a TokenRefreshError to trigger
596596+ // deletion through the deleteOnError callback.
597597+ const msg = 'The session was deleted by another process';
598598+ throw new TokenRefreshError(sub, msg, { cause });
599599+ } else if (
600600+ stored.tokenSet.access_token !== tokenSet.access_token ||
601601+ stored.tokenSet.refresh_token !== tokenSet.refresh_token
602602+ ) {
603603+ // A concurrent refresh occurred. Pretend this one succeeded.
604604+ return stored;
605605+ } else {
606606+ // There were no concurrent refresh. The token is (likely)
607607+ // simply no longer valid.
608608+ }
609609+ }
610610+611611+ // Make sure the session gets deleted from the store
612612+ const msg = cause.errorDescription ?? 'The session was revoked';
613613+ throw new TokenRefreshError(sub, msg, { cause });
614614+ }
615615+616616+ throw cause;
617617+ }
618618+ },
619619+ sessionStore,
620620+ {
621621+ isStale: (sub, { tokenSet }) => {
622622+ return (
623623+ tokenSet.expires_at != null &&
624624+ new Date(tokenSet.expires_at).getTime() <
625625+ Date.now() +
626626+ // Add some lee way to ensure the token is not expired when it
627627+ // reaches the server.
628628+ 10e3 +
629629+ // Add some randomness to reduce the chances of multiple
630630+ // instances trying to refresh the token at the same.
631631+ 30e3 * Math.random()
632632+ );
633633+ },
634634+ onStoreError: async (
635635+ err,
636636+ sub,
637637+ { tokenSet, dpopKey, authMethod = 'legacy' as const },
638638+ ) => {
639639+ if (!(err instanceof AuthMethodUnsatisfiableError)) {
640640+ // If the error was an AuthMethodUnsatisfiableError, there is no
641641+ // point in trying to call `fromIssuer`.
642642+ try {
643643+ // If the token data cannot be stored, let's revoke it
644644+ const server = await serverFactory.fromIssuer(
645645+ tokenSet.iss,
646646+ authMethod,
647647+ dpopKey,
648648+ );
649649+ await server.revoke(
650650+ tokenSet.refresh_token ?? tokenSet.access_token,
651651+ );
652652+ } catch {
653653+ // Let the original error propagate
654654+ }
655655+ }
656656+657657+ throw err;
658658+ },
659659+ deleteOnError: async (err) =>
660660+ err instanceof TokenRefreshError ||
661661+ err instanceof TokenRevokedError ||
662662+ err instanceof TokenInvalidError ||
663663+ err instanceof AuthMethodUnsatisfiableError,
664664+ },
665665+ );
666666+ }
667667+668668+ addEventListener<T extends keyof SessionEventMap>(
669669+ type: T,
670670+ callback: SessionEventListener<T>,
671671+ options?: AddEventListenerOptions | boolean,
672672+ ) {
673673+ this.eventTarget.addEventListener(type, callback, options);
674674+ }
675675+676676+ removeEventListener<T extends keyof SessionEventMap>(
677677+ type: T,
678678+ callback: SessionEventListener<T>,
679679+ options?: EventListenerOptions | boolean,
680680+ ) {
681681+ this.eventTarget.removeEventListener(type, callback, options);
682682+ }
683683+684684+ dispatchEvent<T extends keyof SessionEventMap>(
685685+ type: T,
686686+ detail: SessionEventMap[T],
687687+ ): boolean {
688688+ return this.eventTarget.dispatchCustomEvent(type, detail);
689689+ }
690690+691691+ async setStored(sub: string, session: Session) {
692692+ // Prevent tampering with the stored value
693693+ if (sub !== session.tokenSet.sub) {
694694+ throw new TypeError('Token set does not match the expected sub');
695695+ }
696696+ await super.setStored(sub, session);
697697+ this.dispatchEvent('updated', { sub, ...session });
698698+ }
699699+700700+ override async delStored(sub: AtprotoDid, cause?: unknown): Promise<void> {
701701+ await super.delStored(sub, cause);
702702+ this.dispatchEvent('deleted', { sub, cause });
703703+ }
704704+705705+ /**
706706+ * @param refresh When `true`, the credentials will be refreshed even if they
707707+ * are not expired. When `false`, the credentials will not be refreshed even
708708+ * if they are expired. When `undefined`, the credentials will be refreshed
709709+ * if, and only if, they are (about to be) expired. Defaults to `undefined`.
710710+ */
711711+ async getSession(sub: AtprotoDid, refresh: boolean | 'auto' = 'auto') {
712712+ return this.get(sub, {
713713+ noCache: refresh === true,
714714+ allowStale: refresh === false,
715715+ });
716716+ }
717717+718718+ async get(sub: AtprotoDid, options?: GetCachedOptions): Promise<Session> {
719719+ const session = await this.runtime.usingLock(
720720+ `@atproto-oauth-client-${sub}`,
721721+ async () => {
722722+ // Make sure, even if there is no signal in the options, that the
723723+ // request will be cancelled after at most 30 seconds.
724724+ const signal = AbortSignal.timeout(30e3);
725725+726726+ using abortController = combineSignals([options?.signal, signal]);
727727+728728+ return await super.get(sub, {
729729+ ...options,
730730+ signal: abortController.signal,
731731+ });
732732+ },
733733+ );
734734+735735+ if (sub !== session.tokenSet.sub) {
736736+ // Fool-proofing (e.g. against invalid session storage)
737737+ throw new Error('Token set does not match the expected sub');
738738+ }
739739+740740+ return session;
741741+ }
742742+}
743743+```
744744+745745+also
746746+747747+```ts
748748+protected async validateRefreshGrant(
749749+ client: Client,
750750+ clientAuth: ClientAuth,
751751+ data: TokenData,
752752+ ): Promise<void> {
753753+ const [sessionLifetime, refreshLifetime] =
754754+ clientAuth.method !== 'none' || client.info.isFirstParty
755755+ ? [
756756+ CONFIDENTIAL_CLIENT_SESSION_LIFETIME,
757757+ CONFIDENTIAL_CLIENT_REFRESH_LIFETIME,
758758+ ]
759759+ : [PUBLIC_CLIENT_SESSION_LIFETIME, PUBLIC_CLIENT_REFRESH_LIFETIME]
760760+761761+ const sessionAge = Date.now() - data.createdAt.getTime()
762762+ if (sessionAge > sessionLifetime) {
763763+ throw new InvalidGrantError(`Session expired`)
764764+ }
765765+766766+ const refreshAge = Date.now() - data.updatedAt.getTime()
767767+ if (refreshAge > refreshLifetime) {
768768+ throw new InvalidGrantError(`Refresh token expired`)
769769+ }
770770+ }
771771+772772+protected async refreshTokenGrant(
773773+ client: Client,
774774+ clientAuth: ClientAuth,
775775+ clientMetadata: RequestMetadata,
776776+ input: OAuthRefreshTokenGrantTokenRequest,
777777+ dpopProof: null | DpopProof,
778778+ ): Promise<OAuthTokenResponse> {
779779+ const refreshToken = await refreshTokenSchema
780780+ .parseAsync(input.refresh_token, { path: ['refresh_token'] })
781781+ .catch((err) => {
782782+ const msg = formatError(err, 'Invalid refresh token')
783783+ throw new InvalidGrantError(msg, err)
784784+ })
785785+786786+ const tokenInfo = await this.tokenManager.consumeRefreshToken(refreshToken)
787787+788788+ try {
789789+ const { data } = tokenInfo
790790+ await this.compareClientAuth(client, clientAuth, dpopProof, data)
791791+ await this.validateRefreshGrant(client, clientAuth, data)
792792+793793+ return await this.tokenManager.rotateToken(
794794+ client,
795795+ clientAuth,
796796+ clientMetadata,
797797+ tokenInfo,
798798+ )
799799+ } catch (err) {
800800+ await this.tokenManager.deleteToken(tokenInfo.id)
801801+802802+ throw err
803803+ }
804804+ }
805805+806806+public async token(
807807+ clientCredentials: OAuthClientCredentials,
808808+ clientMetadata: RequestMetadata,
809809+ request: OAuthTokenRequest,
810810+ dpopProof: null | DpopProof,
811811+ ): Promise<OAuthTokenResponse> {
812812+ const { client, clientAuth } = await this.authenticateClient(
813813+ clientCredentials,
814814+ dpopProof,
815815+ )
816816+817817+ if (!this.metadata.grant_types_supported?.includes(request.grant_type)) {
818818+ throw new InvalidGrantError(
819819+ `Grant type "${request.grant_type}" is not supported by the server`,
820820+ )
821821+ }
822822+823823+ if (!client.metadata.grant_types.includes(request.grant_type)) {
824824+ throw new InvalidGrantError(
825825+ `"${request.grant_type}" grant type is not allowed for this client`,
826826+ )
827827+ }
828828+829829+ if (request.grant_type === 'authorization_code') {
830830+ return this.authorizationCodeGrant(
831831+ client,
832832+ clientAuth,
833833+ clientMetadata,
834834+ request,
835835+ dpopProof,
836836+ )
837837+ }
838838+839839+ if (request.grant_type === 'refresh_token') {
840840+ return this.refreshTokenGrant(
841841+ client,
842842+ clientAuth,
843843+ clientMetadata,
844844+ request,
845845+ dpopProof,
846846+ )
847847+ }
848848+849849+ throw new InvalidGrantError(
850850+ `Grant type "${request.grant_type}" not supported`,
851851+ )
852852+ }
853853+```