UNPKG

@atproto/oauth-client

Version:

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

189 lines 7.74 kB
import { atprotoOAuthTokenResponseSchema, oauthParResponseSchema, } from '@atproto/oauth-types'; import { bindFetch, fetchJsonProcessor, } from '@atproto-labs/fetch'; import { TokenRefreshError } from './errors/token-refresh-error.js'; import { dpopFetchWrapper } from './fetch-dpop.js'; import { createClientCredentialsFactory, } from './oauth-client-auth.js'; import { OAuthResponseError } from './oauth-response-error.js'; import { timeoutSignal } from './util.js'; export class OAuthServerAgent { /** * @throws see {@link createClientCredentialsFactory} */ constructor(authMethod, dpopKey, serverMetadata, clientMetadata, dpopNonces, oauthResolver, runtime, keyset, fetch) { this.authMethod = authMethod; this.dpopKey = dpopKey; this.serverMetadata = serverMetadata; this.clientMetadata = clientMetadata; this.dpopNonces = dpopNonces; this.oauthResolver = oauthResolver; this.runtime = runtime; this.keyset = keyset; this.clientCredentialsFactory = createClientCredentialsFactory(authMethod, serverMetadata, clientMetadata, runtime, keyset); this.dpopFetch = dpopFetchWrapper({ fetch: bindFetch(fetch), 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) { try { await this.request('revocation', { token }); } catch { // Don't care } } async exchangeCode(code, codeVerifier, redirectUri) { const now = Date.now(); const tokenResponse = await this.request('token', { grant_type: 'authorization_code', // redirectUri should always be passed by the calling code, but if it is // not, default to the first redirect_uri registered for the client: redirect_uri: redirectUri ?? 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) { 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) */ async verifyIssuer(sub) { const resolved = await this.oauthResolver.resolveFromIdentity(sub, { noCache: true, allowStale: false, signal: timeoutSignal(10e3), }); 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.pds.href; } async request(endpoint, payload) { const url = this.serverMetadata[`${endpoint}_endpoint`]; if (!url) throw new Error(`No ${endpoint} endpoint available`); const auth = await this.clientCredentialsFactory(); // https://datatracker.ietf.org/doc/html/draft-ietf-oauth-v2-1-13#section-3.2.2 // https://datatracker.ietf.org/doc/html/rfc7009#section-2.1 // https://datatracker.ietf.org/doc/html/rfc7662#section-2.1 // https://datatracker.ietf.org/doc/html/rfc9126#section-2 const { response, json } = await this.dpopFetch(url, { method: 'POST', headers: { ...auth.headers, 'Content-Type': 'application/x-www-form-urlencoded', }, body: wwwFormUrlEncode({ ...payload, ...auth.payload }), }).then(fetchJsonProcessor()); if (response.ok) { switch (endpoint) { case 'token': return atprotoOAuthTokenResponseSchema.parse(json); case 'pushed_authorization_request': return oauthParResponseSchema.parse(json); default: return json; } } else { throw new OAuthResponseError(response, json); } } } function wwwFormUrlEncode(payload) { return new URLSearchParams(Object.entries(payload) .filter(entryHasDefinedValue) .map(stringifyEntryValue)).toString(); } function entryHasDefinedValue(entry) { return entry[1] !== undefined; } function stringifyEntryValue(entry) { const name = entry[0]; const value = entry[1]; switch (typeof value) { case 'string': return [name, value]; case 'number': case 'boolean': return [name, String(value)]; default: { const enc = JSON.stringify(value); if (enc === undefined) { throw new Error(`Unsupported value type for ${name}: ${String(value)}`); } return [name, enc]; } } } //# sourceMappingURL=oauth-server-agent.js.map