@atproto/oauth-client
Version:
OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).
118 lines (100 loc) • 3.31 kB
text/typescript
import {
Fetch,
FetchResponseError,
bindFetch,
cancelBody,
} from '@atproto-labs/fetch'
import {
CachedGetter,
GetCachedOptions,
SimpleStore,
} from '@atproto-labs/simple-store'
import {
OAuthProtectedResourceMetadata,
oauthProtectedResourceMetadataSchema,
} from '@atproto/oauth-types'
import { contentMime } from './util.js'
export type { GetCachedOptions, OAuthProtectedResourceMetadata }
export type ProtectedResourceMetadataCache = SimpleStore<
string,
OAuthProtectedResourceMetadata
>
export type OAuthProtectedResourceMetadataResolverConfig = {
allowHttpResource?: boolean
}
/**
* @see {@link https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05}
*/
export class OAuthProtectedResourceMetadataResolver extends CachedGetter<
string,
OAuthProtectedResourceMetadata
> {
private readonly fetch: Fetch<unknown>
private readonly allowHttpResource: boolean
constructor(
cache: ProtectedResourceMetadataCache,
fetch: Fetch = globalThis.fetch,
config?: OAuthProtectedResourceMetadataResolverConfig,
) {
super(async (origin, options) => this.fetchMetadata(origin, options), cache)
this.fetch = bindFetch(fetch)
this.allowHttpResource = config?.allowHttpResource === true
}
async get(
resource: string | URL,
options?: GetCachedOptions,
): Promise<OAuthProtectedResourceMetadata> {
const { protocol, origin } = new URL(resource)
if (protocol !== 'https:' && protocol !== 'http:') {
throw new TypeError(
`Invalid protected resource metadata URL protocol: ${protocol}`,
)
}
if (protocol === 'http:' && !this.allowHttpResource) {
throw new TypeError(
`Unsecure resource metadata URL (${protocol}) only allowed in development and test environments`,
)
}
return super.get(origin, options)
}
private async fetchMetadata(
origin: string,
options?: GetCachedOptions,
): Promise<OAuthProtectedResourceMetadata> {
const url = new URL(`/.well-known/oauth-protected-resource`, origin)
const request = new Request(url, {
signal: options?.signal,
headers: { accept: 'application/json' },
cache: options?.noCache ? 'no-cache' : undefined,
redirect: 'manual', // response must be 200 OK
})
const response = await this.fetch(request)
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#section-3.2
if (response.status !== 200) {
await cancelBody(response, 'log')
throw await FetchResponseError.from(
response,
`Unexpected status code ${response.status} for "${url}"`,
undefined,
{ cause: request },
)
}
if (contentMime(response.headers) !== 'application/json') {
await cancelBody(response, 'log')
throw await FetchResponseError.from(
response,
`Unexpected content type for "${url}"`,
undefined,
{ cause: request },
)
}
const metadata = oauthProtectedResourceMetadataSchema.parse(
await response.json(),
)
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#section-3.3
if (metadata.resource !== origin) {
throw new TypeError(`Invalid issuer ${metadata.resource}`)
}
return metadata
}
}