@atproto/oauth-client
Version:
OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).
531 lines (466 loc) • 15.8 kB
text/typescript
import {
assertAtprotoDid,
AtprotoDid,
DidCache,
DidResolverCached,
DidResolverCommon,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
type DidResolverCommonOptions,
} from '@atproto-labs/did-resolver'
import { Fetch } from '@atproto-labs/fetch'
import {
AppViewHandleResolver,
CachedHandleResolver,
HandleCache,
HandleResolver,
} from '@atproto-labs/handle-resolver'
import { IdentityResolver } from '@atproto-labs/identity-resolver'
import { SimpleStoreMemory } from '@atproto-labs/simple-store-memory'
import { Key, Keyset } from '@atproto/jwk'
import {
OAuthAuthorizationRequestParameters,
OAuthClientIdDiscoverable,
OAuthClientMetadata,
OAuthClientMetadataInput,
oauthClientMetadataSchema,
OAuthResponseMode,
} from '@atproto/oauth-types'
import { FALLBACK_ALG } from './constants.js'
import { TokenRevokedError } from './errors/token-revoked-error.js'
import {
AuthorizationServerMetadataCache,
OAuthAuthorizationServerMetadataResolver,
} from './oauth-authorization-server-metadata-resolver.js'
import { OAuthCallbackError } from './oauth-callback-error.js'
import {
OAuthProtectedResourceMetadataResolver,
ProtectedResourceMetadataCache,
} from './oauth-protected-resource-metadata-resolver.js'
import { OAuthResolver } from './oauth-resolver.js'
import { DpopNonceCache, OAuthServerAgent } from './oauth-server-agent.js'
import { OAuthServerFactory } from './oauth-server-factory.js'
import { OAuthSession } from './oauth-session.js'
import { RuntimeImplementation } from './runtime-implementation.js'
import { Runtime } from './runtime.js'
import {
SessionEventMap,
SessionGetter,
SessionStore,
} from './session-getter.js'
import { InternalStateData, StateStore } from './state-store.js'
import { AuthorizeOptions, ClientMetadata } from './types.js'
import { CustomEventTarget } from './util.js'
import { validateClientMetadata } from './validate-client-metadata.js'
// Export all types needed to construct OAuthClientOptions
export type {
AuthorizationServerMetadataCache,
DidCache,
DpopNonceCache,
Fetch,
HandleCache,
HandleResolver,
InternalStateData,
Key,
Keyset,
OAuthClientMetadata,
OAuthClientMetadataInput,
OAuthResponseMode,
ProtectedResourceMetadataCache,
RuntimeImplementation,
SessionStore,
StateStore,
}
export type OAuthClientOptions = {
// Config
responseMode: OAuthResponseMode
clientMetadata: Readonly<OAuthClientMetadataInput>
keyset?: Keyset | Iterable<Key | undefined | null | false>
/**
* Determines if the client will allow communicating with the OAuth Servers
* (Authorization & Resource), or to retrieve "did:web" documents, over
* unsafe HTTP connections. It is recommended to set this to `true` only for
* development purposes.
*
* @note This does not affect the identity resolution mechanism, which will
* allow HTTP connections to the PLC Directory (if the provided directory url
* is "http:" based).
* @default false
* @see {@link OAuthProtectedResourceMetadataResolver.allowHttpResource}
* @see {@link OAuthAuthorizationServerMetadataResolver.allowHttpIssuer}
* @see {@link DidResolverCommonOptions.allowHttp}
*/
allowHttp?: boolean
// Stores
stateStore: StateStore
sessionStore: SessionStore
didCache?: DidCache
handleCache?: HandleCache
authorizationServerMetadataCache?: AuthorizationServerMetadataCache
protectedResourceMetadataCache?: ProtectedResourceMetadataCache
dpopNonceCache?: DpopNonceCache
// Services
handleResolver: HandleResolver | URL | string
plcDirectoryUrl?: URL | string
runtimeImplementation: RuntimeImplementation
fetch?: Fetch
}
export type OAuthClientEventMap = SessionEventMap
export type OAuthClientFetchMetadataOptions = {
clientId: OAuthClientIdDiscoverable
fetch?: Fetch
signal?: AbortSignal
}
export class OAuthClient extends CustomEventTarget<OAuthClientEventMap> {
static async fetchMetadata({
clientId,
fetch = globalThis.fetch,
signal,
}: OAuthClientFetchMetadataOptions) {
signal?.throwIfAborted()
const request = new Request(clientId, {
redirect: 'error',
signal: signal,
})
const response = await fetch(request)
if (response.status !== 200) {
response.body?.cancel?.()
throw new TypeError(`Failed to fetch client metadata: ${response.status}`)
}
// https://drafts.aaronpk.com/draft-parecki-oauth-client-id-metadata-document/draft-parecki-oauth-client-id-metadata-document.html#section-4.1
const mime = response.headers.get('content-type')?.split(';')[0].trim()
if (mime !== 'application/json') {
response.body?.cancel?.()
throw new TypeError(`Invalid client metadata content type: ${mime}`)
}
const json: unknown = await response.json()
signal?.throwIfAborted()
return oauthClientMetadataSchema.parse(json)
}
// Config
readonly clientMetadata: ClientMetadata
readonly responseMode: OAuthResponseMode
readonly keyset?: Keyset
// Services
readonly runtime: Runtime
readonly fetch: Fetch
readonly oauthResolver: OAuthResolver
readonly serverFactory: OAuthServerFactory
// Stores
protected readonly sessionGetter: SessionGetter
protected readonly stateStore: StateStore
constructor({
fetch = globalThis.fetch,
allowHttp = false,
stateStore,
sessionStore,
didCache = undefined,
dpopNonceCache = new SimpleStoreMemory({ ttl: 60e3, max: 100 }),
handleCache = undefined,
authorizationServerMetadataCache = new SimpleStoreMemory({
ttl: 60e3,
max: 100,
}),
protectedResourceMetadataCache = new SimpleStoreMemory({
ttl: 60e3,
max: 100,
}),
responseMode,
clientMetadata,
handleResolver,
plcDirectoryUrl,
runtimeImplementation,
keyset,
}: OAuthClientOptions) {
super()
this.keyset = keyset
? keyset instanceof Keyset
? keyset
: new Keyset(keyset)
: undefined
this.clientMetadata = validateClientMetadata(clientMetadata, this.keyset)
this.responseMode = responseMode
this.runtime = new Runtime(runtimeImplementation)
this.fetch = fetch
this.oauthResolver = new OAuthResolver(
new IdentityResolver(
new DidResolverCached(
new DidResolverCommon({ fetch, plcDirectoryUrl, allowHttp }),
didCache,
),
new CachedHandleResolver(
AppViewHandleResolver.from(handleResolver, { fetch }),
handleCache,
),
),
new OAuthProtectedResourceMetadataResolver(
protectedResourceMetadataCache,
fetch,
{ allowHttpResource: allowHttp },
),
new OAuthAuthorizationServerMetadataResolver(
authorizationServerMetadataCache,
fetch,
{ allowHttpIssuer: allowHttp },
),
)
this.serverFactory = new OAuthServerFactory(
this.clientMetadata,
this.runtime,
this.oauthResolver,
this.fetch,
this.keyset,
dpopNonceCache,
)
this.sessionGetter = new SessionGetter(
sessionStore,
this.serverFactory,
this.runtime,
)
this.stateStore = stateStore
// Proxy sessionGetter events
for (const type of ['deleted', 'updated'] as const) {
this.sessionGetter.addEventListener(type, (event) => {
if (!this.dispatchCustomEvent(type, event.detail)) {
event.preventDefault()
}
})
}
}
// Exposed as public API for convenience
get identityResolver() {
return this.oauthResolver.identityResolver
}
// Exposed as public API for convenience
get didResolver() {
return this.identityResolver.didResolver
}
// Exposed as public API for convenience
get handleResolver() {
return this.identityResolver.handleResolver
}
get jwks() {
return this.keyset?.publicJwks ?? ({ keys: [] as const } as const)
}
async authorize(
input: string,
{ signal, ...options }: AuthorizeOptions = {},
): Promise<URL> {
const redirectUri =
options?.redirect_uri ?? this.clientMetadata.redirect_uris[0]
if (!this.clientMetadata.redirect_uris.includes(redirectUri)) {
// The server will enforce this, but let's catch it early
throw new TypeError('Invalid redirect_uri')
}
const { identity, metadata } = await this.oauthResolver.resolve(input, {
signal,
})
const pkce = await this.runtime.generatePKCE()
const dpopKey = await this.runtime.generateKey(
metadata.dpop_signing_alg_values_supported || [FALLBACK_ALG],
)
const state = await this.runtime.generateNonce()
await this.stateStore.set(state, {
iss: metadata.issuer,
dpopKey,
verifier: pkce.verifier,
appState: options?.state,
})
const parameters: OAuthAuthorizationRequestParameters = {
...options,
client_id: this.clientMetadata.client_id,
redirect_uri: redirectUri,
code_challenge: pkce.challenge,
code_challenge_method: pkce.method,
state,
login_hint: identity
? input // If input is a handle or a DID, use it as a login_hint
: undefined,
response_mode: this.responseMode,
response_type: 'code' as const,
scope: options?.scope ?? this.clientMetadata.scope,
}
const authorizationUrl = new URL(metadata.authorization_endpoint)
// Since the user will be redirected to the authorization_endpoint url using
// a browser, we need to make sure that the url is valid.
if (
authorizationUrl.protocol !== 'https:' &&
authorizationUrl.protocol !== 'http:'
) {
throw new TypeError(
`Invalid authorization endpoint protocol: ${authorizationUrl.protocol}`,
)
}
if (metadata.pushed_authorization_request_endpoint) {
const server = await this.serverFactory.fromMetadata(metadata, dpopKey)
const parResponse = await server.request(
'pushed_authorization_request',
parameters,
)
authorizationUrl.searchParams.set(
'client_id',
this.clientMetadata.client_id,
)
authorizationUrl.searchParams.set('request_uri', parResponse.request_uri)
return authorizationUrl
} else if (metadata.require_pushed_authorization_requests) {
throw new Error(
'Server requires pushed authorization requests (PAR) but no PAR endpoint is available',
)
} else {
for (const [key, value] of Object.entries(parameters)) {
if (value) authorizationUrl.searchParams.set(key, String(value))
}
// Length of the URL that will be sent to the server
const urlLength =
authorizationUrl.pathname.length + authorizationUrl.search.length
if (urlLength < 2048) {
return authorizationUrl
} else if (!metadata.pushed_authorization_request_endpoint) {
throw new Error('Login URL too long')
}
}
throw new Error(
'Server does not support pushed authorization requests (PAR)',
)
}
/**
* This method allows the client to proactively revoke the request_uri it
* created through PAR.
*/
async abortRequest(authorizeUrl: URL) {
const requestUri = authorizeUrl.searchParams.get('request_uri')
if (!requestUri) return
// @NOTE This is not implemented here because, 1) the request server should
// invalidate the request_uri after some delay anyways, and 2) I am not sure
// that the revocation endpoint is even supposed to support this (and I
// don't want to spend the time checking now).
// @TODO investigate actual necessity & feasibility of this feature
}
async callback(params: URLSearchParams): Promise<{
session: OAuthSession
state: string | null
}> {
const responseJwt = params.get('response')
if (responseJwt != null) {
// https://openid.net/specs/oauth-v2-jarm.html
throw new OAuthCallbackError(params, 'JARM not supported')
}
const issuerParam = params.get('iss')
const stateParam = params.get('state')
const errorParam = params.get('error')
const codeParam = params.get('code')
if (!stateParam) {
throw new OAuthCallbackError(params, 'Missing "state" parameter')
}
const stateData = await this.stateStore.get(stateParam)
if (stateData) {
// Prevent any kind of replay
await this.stateStore.del(stateParam)
} else {
throw new OAuthCallbackError(
params,
`Unknown authorization session "${stateParam}"`,
)
}
try {
if (errorParam != null) {
throw new OAuthCallbackError(params, undefined, stateData.appState)
}
if (!codeParam) {
throw new OAuthCallbackError(
params,
'Missing "code" query param',
stateData.appState,
)
}
const server = await this.serverFactory.fromIssuer(
stateData.iss,
stateData.dpopKey,
)
if (issuerParam != null) {
if (!server.issuer) {
throw new OAuthCallbackError(
params,
'Issuer not found in metadata',
stateData.appState,
)
}
if (server.issuer !== issuerParam) {
throw new OAuthCallbackError(
params,
'Issuer mismatch',
stateData.appState,
)
}
} else if (
server.serverMetadata.authorization_response_iss_parameter_supported
) {
throw new OAuthCallbackError(
params,
'iss missing from the response',
stateData.appState,
)
}
const tokenSet = await server.exchangeCode(codeParam, stateData.verifier)
try {
await this.sessionGetter.setStored(tokenSet.sub, {
dpopKey: stateData.dpopKey,
tokenSet,
})
const session = this.createSession(server, tokenSet.sub)
return { session, state: stateData.appState ?? null }
} catch (err) {
await server.revoke(tokenSet.refresh_token || tokenSet.access_token)
throw err
}
} catch (err) {
// Make sure, whatever the underlying error, that the appState is
// available in the calling code
throw OAuthCallbackError.from(err, params, stateData.appState)
}
}
/**
* Load a stored session. This will refresh the token only if needed (about to
* expire) by default.
*
* @param refresh See {@link SessionGetter.getSession}
*/
async restore(
sub: string,
refresh: boolean | 'auto' = 'auto',
): Promise<OAuthSession> {
// sub arg is lightly typed for convenience of library user
assertAtprotoDid(sub)
const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, {
noCache: refresh === true,
allowStale: refresh === false,
})
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey, {
noCache: refresh === true,
allowStale: refresh === false,
})
return this.createSession(server, sub)
}
async revoke(sub: string) {
// sub arg is lightly typed for convenience of library user
assertAtprotoDid(sub)
const { dpopKey, tokenSet } = await this.sessionGetter.get(sub, {
allowStale: true,
})
// NOT using `;(await this.restore(sub, false)).signOut()` because we want
// the tokens to be deleted even if it was not possible to fetch the issuer
// data.
try {
const server = await this.serverFactory.fromIssuer(tokenSet.iss, dpopKey)
await server.revoke(tokenSet.access_token)
} finally {
await this.sessionGetter.delStored(sub, new TokenRevokedError(sub))
}
}
protected createSession(
server: OAuthServerAgent,
sub: AtprotoDid,
): OAuthSession {
return new OAuthSession(server, sub, this.sessionGetter, this.fetch)
}
}