UNPKG

@atproto/oauth-client

Version:

OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).

298 lines (258 loc) 8.98 kB
import { Fetch, Json, bindFetch, fetchJsonProcessor } from '@atproto-labs/fetch' import { SimpleStore } from '@atproto-labs/simple-store' import { AtprotoDid } from '@atproto/did' import { Key, Keyset } from '@atproto/jwk' import { CLIENT_ASSERTION_TYPE_JWT_BEARER, OAuthAuthorizationRequestPar, OAuthAuthorizationServerMetadata, OAuthClientCredentials, OAuthEndpointName, OAuthParResponse, OAuthTokenRequest, oauthParResponseSchema, } from '@atproto/oauth-types' import { AtprotoScope, AtprotoTokenResponse, atprotoTokenResponseSchema, } from './atproto-token-response.js' import { FALLBACK_ALG } from './constants.js' import { TokenRefreshError } from './errors/token-refresh-error.js' import { dpopFetchWrapper } from './fetch-dpop.js' import { OAuthResolver } from './oauth-resolver.js' import { OAuthResponseError } from './oauth-response-error.js' import { Runtime } from './runtime.js' import { ClientMetadata } from './types.js' import { timeoutSignal } from './util.js' export type TokenSet = { iss: string sub: AtprotoDid aud: string scope: AtprotoScope refresh_token?: string access_token: string token_type: 'DPoP' /** ISO Date */ expires_at?: string } export type DpopNonceCache = SimpleStore<string, string> export class OAuthServerAgent { protected dpopFetch: Fetch<unknown> constructor( readonly dpopKey: Key, readonly serverMetadata: OAuthAuthorizationServerMetadata, readonly clientMetadata: ClientMetadata, readonly dpopNonces: DpopNonceCache, readonly oauthResolver: OAuthResolver, readonly runtime: Runtime, readonly keyset?: Keyset, fetch?: Fetch, ) { this.dpopFetch = dpopFetchWrapper<void>({ fetch: bindFetch(fetch), iss: clientMetadata.client_id, key: dpopKey, supportedAlgs: serverMetadata.dpop_signing_alg_values_supported, sha256: async (v) => runtime.sha256(v), nonces: dpopNonces, isAuthServer: true, }) } get issuer() { return this.serverMetadata.issuer } async revoke(token: string) { try { await this.request('revocation', { token }) } catch { // Don't care } } async exchangeCode(code: string, codeVerifier?: string): Promise<TokenSet> { const now = Date.now() const tokenResponse = await this.request('token', { grant_type: 'authorization_code', redirect_uri: this.clientMetadata.redirect_uris[0]!, code, code_verifier: codeVerifier, }) try { // /!\ IMPORTANT /!\ // // The tokenResponse MUST always be valid before the "sub" it contains // can be trusted (see Atproto's OAuth spec for details). const aud = await this.verifyIssuer(tokenResponse.sub) return { aud, sub: tokenResponse.sub, iss: this.issuer, scope: tokenResponse.scope, refresh_token: tokenResponse.refresh_token, access_token: tokenResponse.access_token, token_type: tokenResponse.token_type, expires_at: typeof tokenResponse.expires_in === 'number' ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined, } } catch (err) { await this.revoke(tokenResponse.access_token) throw err } } async refresh(tokenSet: TokenSet): Promise<TokenSet> { if (!tokenSet.refresh_token) { throw new TokenRefreshError(tokenSet.sub, 'No refresh token available') } // /!\ IMPORTANT /!\ // // The "sub" MUST be a DID, whose issuer authority is indeed the server we // are trying to obtain credentials from. Note that we are doing this // *before* we actually try to refresh the token: // 1) To avoid unnecessary refresh // 2) So that the refresh is the last async operation, ensuring as few // async operations happen before the result gets a chance to be stored. const aud = await this.verifyIssuer(tokenSet.sub) const now = Date.now() const tokenResponse = await this.request('token', { grant_type: 'refresh_token', refresh_token: tokenSet.refresh_token, }) return { aud, sub: tokenSet.sub, iss: this.issuer, scope: tokenResponse.scope, refresh_token: tokenResponse.refresh_token, access_token: tokenResponse.access_token, token_type: tokenResponse.token_type, expires_at: typeof tokenResponse.expires_in === 'number' ? new Date(now + tokenResponse.expires_in * 1000).toISOString() : undefined, } } /** * VERY IMPORTANT ! Always call this to process token responses. * * Whenever an OAuth token response is received, we **MUST** verify that the * "sub" is a DID, whose issuer authority is indeed the server we just * obtained credentials from. This check is a critical step to actually be * able to use the "sub" (DID) as being the actual user's identifier. * * @returns The user's PDS URL (the resource server for the user) */ protected async verifyIssuer(sub: AtprotoDid) { using signal = timeoutSignal(10e3) const resolved = await this.oauthResolver.resolveFromIdentity(sub, { noCache: true, allowStale: false, signal, }) if (this.issuer !== resolved.metadata.issuer) { // Best case scenario; the user switched PDS. Worst case scenario; a bad // actor is trying to impersonate a user. In any case, we must not allow // this token to be used. throw new TypeError('Issuer mismatch') } return resolved.identity.pds.href } async request<Endpoint extends OAuthEndpointName>( endpoint: Endpoint, payload: Endpoint extends 'token' ? OAuthTokenRequest : Endpoint extends 'pushed_authorization_request' ? OAuthAuthorizationRequestPar : Record<string, unknown>, ): Promise< Endpoint extends 'token' ? AtprotoTokenResponse : Endpoint extends 'pushed_authorization_request' ? OAuthParResponse : Json > async request( endpoint: OAuthEndpointName, payload: Record<string, unknown>, ): Promise<unknown> { const url = this.serverMetadata[`${endpoint}_endpoint`] if (!url) throw new Error(`No ${endpoint} endpoint available`) const auth = await this.buildClientAuth(endpoint) const { response, json } = await this.dpopFetch(url, { method: 'POST', headers: { ...auth.headers, 'Content-Type': 'application/json' }, body: JSON.stringify({ ...payload, ...auth.payload }), }).then(fetchJsonProcessor()) if (response.ok) { switch (endpoint) { case 'token': return atprotoTokenResponseSchema.parse(json) case 'pushed_authorization_request': return oauthParResponseSchema.parse(json) default: return json } } else { throw new OAuthResponseError(response, json) } } async buildClientAuth(endpoint: OAuthEndpointName): Promise<{ headers?: Record<string, string> payload: OAuthClientCredentials }> { const methodSupported = this.serverMetadata[`token_endpoint_auth_methods_supported`] const method = this.clientMetadata[`token_endpoint_auth_method`] if ( method === 'private_key_jwt' || (this.keyset && !method && (methodSupported?.includes('private_key_jwt') ?? false)) ) { if (!this.keyset) throw new Error('No keyset available') try { const alg = this.serverMetadata[ `token_endpoint_auth_signing_alg_values_supported` ] ?? FALLBACK_ALG // If jwks is defined, make sure to only sign using a key that exists in // the jwks. If jwks_uri is defined, we can't be sure that the key we're // looking for is in there so we will just assume it is. const kid = this.clientMetadata.jwks?.keys .map(({ kid }) => kid) .filter((v): v is string => typeof v === 'string') return { payload: { client_id: this.clientMetadata.client_id, client_assertion_type: CLIENT_ASSERTION_TYPE_JWT_BEARER, client_assertion: await this.keyset.createJwt( { alg, kid }, { iss: this.clientMetadata.client_id, sub: this.clientMetadata.client_id, aud: this.serverMetadata.issuer, jti: await this.runtime.generateNonce(), iat: Math.floor(Date.now() / 1000), }, ), }, } } catch (err) { if (method === 'private_key_jwt') throw err // Else try next method } } if ( method === 'none' || (!method && (methodSupported?.includes('none') ?? true)) ) { return { payload: { client_id: this.clientMetadata.client_id, }, } } throw new Error(`Unsupported ${endpoint} authentication method`) } }