@atproto/oauth-client
Version:
OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).
267 lines (234 loc) • 10.6 kB
text/typescript
import {
CachedGetter,
GetCachedOptions,
SimpleStore,
} from '@atproto-labs/simple-store'
import { AtprotoDid } from '@atproto/did'
import { Key } from '@atproto/jwk'
import { TokenInvalidError } from './errors/token-invalid-error.js'
import { TokenRefreshError } from './errors/token-refresh-error.js'
import { TokenRevokedError } from './errors/token-revoked-error.js'
import { OAuthResponseError } from './oauth-response-error.js'
import { TokenSet } from './oauth-server-agent.js'
import { OAuthServerFactory } from './oauth-server-factory.js'
import { Runtime } from './runtime.js'
import { combineSignals, CustomEventTarget, timeoutSignal } from './util.js'
export type Session = {
dpopKey: Key
tokenSet: TokenSet
}
export type SessionStore = SimpleStore<string, Session>
export type SessionEventMap = {
updated: {
sub: string
} & Session
deleted: {
sub: string
cause: TokenRefreshError | TokenRevokedError | TokenInvalidError | unknown
}
}
export type SessionEventListener<
T extends keyof SessionEventMap = keyof SessionEventMap,
> = (event: CustomEvent<SessionEventMap[T]>) => void
/**
* There are several advantages to wrapping the sessionStore in a (single)
* CachedGetter, the main of which is that the cached getter will ensure that at
* most one fresh call is ever being made. Another advantage, is that it
* contains the logic for reading from the cache which, if the cache is based on
* localStorage/indexedDB, will sync across multiple tabs (for a given sub).
*/
export class SessionGetter extends CachedGetter<AtprotoDid, Session> {
private readonly eventTarget = new CustomEventTarget<SessionEventMap>()
constructor(
sessionStore: SessionStore,
serverFactory: OAuthServerFactory,
private readonly runtime: Runtime,
) {
super(
async (sub, options, storedSession): Promise<Session> => {
// There needs to be a previous session to be able to refresh. If
// storedSession is undefined, it means that the store does not contain
// a session for the given sub.
if (storedSession === undefined) {
// Because the session is not in the store, this.delStored() method
// will not be called by the CachedGetter class (because there is
// nothing to delete). This would typically happen if there is no
// synchronization mechanism between instances of this class. Let's
// make sure an event is dispatched here if this occurs.
const msg = 'The session was deleted by another process'
const cause = new TokenRefreshError(sub, msg)
this.dispatchEvent('deleted', { sub, cause })
throw cause
}
// From this point forward, throwing a TokenRefreshError will result in
// this.delStored() being called, resulting in an event being
// dispatched, even if the session was removed from the store through a
// concurrent access (which, normally, should not happen if a proper
// runtime lock was provided).
const { dpopKey, tokenSet } = storedSession
if (sub !== tokenSet.sub) {
// Fool-proofing (e.g. against invalid session storage)
throw new TokenRefreshError(sub, 'Stored session sub mismatch')
}
if (!tokenSet.refresh_token) {
throw new TokenRefreshError(sub, 'No refresh token available')
}
// Since refresh tokens can only be used once, we might run into
// concurrency issues if multiple instances (e.g. browser tabs) are
// trying to refresh the same token simultaneously. The chances of this
// happening when multiple instances are started simultaneously is
// reduced by randomizing the expiry time (see isStale() below). The
// best solution is to use a mutex/lock to ensure that only one instance
// is refreshing the token at a time (runtime.usingLock) but that is not
// always possible. If no lock implementation is provided, we will use
// the store to check if a concurrent refresh occurred.
const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
// Because refresh tokens can only be used once, we must not use the
// "signal" to abort the refresh, or throw any abort error beyond this
// point. Any thrown error beyond this point will prevent the
// TokenGetter from obtaining, and storing, the new token set,
// effectively rendering the currently saved session unusable.
options?.signal?.throwIfAborted()
try {
const newTokenSet = await server.refresh(tokenSet)
if (sub !== newTokenSet.sub) {
// The server returned another sub. Was the tokenSet manipulated?
throw new TokenRefreshError(sub, 'Token set sub mismatch')
}
return { dpopKey, tokenSet: newTokenSet }
} catch (cause) {
// If the refresh token is invalid, let's try to recover from
// concurrency issues, or make sure the session is deleted by throwing
// a TokenRefreshError.
if (
cause instanceof OAuthResponseError &&
cause.status === 400 &&
cause.error === 'invalid_grant'
) {
// In case there is no lock implementation in the runtime, we will
// wait for a short time to give the other concurrent instances a
// chance to finish their refreshing of the token. If a concurrent
// refresh did occur, we will pretend that this one succeeded.
if (!runtime.hasImplementationLock) {
await new Promise((r) => setTimeout(r, 1000))
const stored = await this.getStored(sub)
if (stored === undefined) {
// A concurrent refresh occurred and caused the session to be
// deleted (for a reason we can't know at this point).
// Using a distinct error message mainly for debugging
// purposes. Also, throwing a TokenRefreshError to trigger
// deletion through the deleteOnError callback.
const msg = 'The session was deleted by another process'
throw new TokenRefreshError(sub, msg, { cause })
} else if (
stored.tokenSet.access_token !== tokenSet.access_token ||
stored.tokenSet.refresh_token !== tokenSet.refresh_token
) {
// A concurrent refresh occurred. Pretend this one succeeded.
return stored
} else {
// There were no concurrent refresh. The token is (likely)
// simply no longer valid.
}
}
// Make sure the session gets deleted from the store
const msg = cause.errorDescription ?? 'The session was revoked'
throw new TokenRefreshError(sub, msg, { cause })
}
throw cause
}
},
sessionStore,
{
isStale: (sub, { tokenSet }) => {
return (
tokenSet.expires_at != null &&
new Date(tokenSet.expires_at).getTime() <
Date.now() +
// Add some lee way to ensure the token is not expired when it
// reaches the server.
10e3 +
// Add some randomness to reduce the chances of multiple
// instances trying to refresh the token at the same.
30e3 * Math.random()
)
},
onStoreError: async (err, sub, { tokenSet, dpopKey }) => {
// If the token data cannot be stored, let's revoke it
const server = await serverFactory.fromIssuer(tokenSet.iss, dpopKey)
await server.revoke(tokenSet.refresh_token ?? tokenSet.access_token)
throw err
},
deleteOnError: async (err) =>
// Optimization: More likely to happen first
err instanceof TokenRefreshError ||
err instanceof TokenRevokedError ||
err instanceof TokenInvalidError,
},
)
}
addEventListener<T extends keyof SessionEventMap>(
type: T,
callback: SessionEventListener<T>,
options?: AddEventListenerOptions | boolean,
) {
this.eventTarget.addEventListener(type, callback, options)
}
removeEventListener<T extends keyof SessionEventMap>(
type: T,
callback: SessionEventListener<T>,
options?: EventListenerOptions | boolean,
) {
this.eventTarget.removeEventListener(type, callback, options)
}
dispatchEvent<T extends keyof SessionEventMap>(
type: T,
detail: SessionEventMap[T],
): boolean {
return this.eventTarget.dispatchCustomEvent(type, detail)
}
async setStored(sub: string, session: Session) {
// Prevent tampering with the stored value
if (sub !== session.tokenSet.sub) {
throw new TypeError('Token set does not match the expected sub')
}
await super.setStored(sub, session)
this.dispatchEvent('updated', { sub, ...session })
}
override async delStored(sub: AtprotoDid, cause?: unknown): Promise<void> {
await super.delStored(sub, cause)
this.dispatchEvent('deleted', { sub, cause })
}
/**
* @param refresh When `true`, the credentials will be refreshed even if they
* are not expired. When `false`, the credentials will not be refreshed even
* if they are expired. When `undefined`, the credentials will be refreshed
* if, and only if, they are (about to be) expired. Defaults to `undefined`.
*/
async getSession(sub: AtprotoDid, refresh?: boolean) {
return this.get(sub, {
noCache: refresh === true,
allowStale: refresh === false,
})
}
async get(sub: AtprotoDid, options?: GetCachedOptions): Promise<Session> {
const session = await this.runtime.usingLock(
`@atproto-oauth-client-${sub}`,
async () => {
// Make sure, even if there is no signal in the options, that the
// request will be cancelled after at most 30 seconds.
using signal = timeoutSignal(30e3, options)
using abortController = combineSignals([options?.signal, signal])
return await super.get(sub, {
...options,
signal: abortController.signal,
})
},
)
if (sub !== session.tokenSet.sub) {
// Fool-proofing (e.g. against invalid session storage)
throw new Error('Token set does not match the expected sub')
}
return session
}
}