@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
text/typescript
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`)
}
}