@atproto/oauth-client
Version:
OAuth client for ATPROTO PDS. This package serves as common base for environment-specific implementations (NodeJS, Browser, React-Native).
171 lines (151 loc) • 5.08 kB
text/typescript
import {
IdentityResolver,
ResolvedIdentity,
ResolveIdentityOptions,
} from '@atproto-labs/identity-resolver'
import {
OAuthAuthorizationServerMetadata,
oauthIssuerIdentifierSchema,
} from '@atproto/oauth-types'
import {
GetCachedOptions,
OAuthAuthorizationServerMetadataResolver,
} from './oauth-authorization-server-metadata-resolver.js'
import { OAuthProtectedResourceMetadataResolver } from './oauth-protected-resource-metadata-resolver.js'
import { OAuthResolverError } from './oauth-resolver-error.js'
export type { GetCachedOptions }
export type ResolveOAuthOptions = GetCachedOptions & ResolveIdentityOptions
export class OAuthResolver {
constructor(
readonly identityResolver: IdentityResolver,
readonly protectedResourceMetadataResolver: OAuthProtectedResourceMetadataResolver,
readonly authorizationServerMetadataResolver: OAuthAuthorizationServerMetadataResolver,
) {}
/**
* @param input - A handle, DID, PDS URL or Entryway URL
*/
public async resolve(
input: string,
options?: ResolveOAuthOptions,
): Promise<{
identity?: ResolvedIdentity
metadata: OAuthAuthorizationServerMetadata
}> {
// Allow using an entryway, or PDS url, directly as login input (e.g.
// when the user forgot their handle, or when the handle does not
// resolve to a DID)
return /^https?:\/\//.test(input)
? this.resolveFromService(input, options)
: this.resolveFromIdentity(input, options)
}
/**
* @note this method can be used to verify if a particular uri supports OAuth
* based sign-in (for compatibility with legacy implementation).
*/
public async resolveFromService(
input: string,
options?: ResolveOAuthOptions,
): Promise<{
metadata: OAuthAuthorizationServerMetadata
}> {
try {
// Assume first that input is a PDS URL (as required by ATPROTO)
const metadata = await this.getResourceServerMetadata(input, options)
return { metadata }
} catch (err) {
if (!options?.signal?.aborted && err instanceof OAuthResolverError) {
try {
// Fallback to trying to fetch as an issuer (Entryway)
const result = oauthIssuerIdentifierSchema.safeParse(input)
if (result.success) {
const metadata = await this.getAuthorizationServerMetadata(
result.data,
options,
)
return { metadata }
}
} catch {
// Fallback failed, throw original error
}
}
throw err
}
}
public async resolveFromIdentity(
input: string,
options?: ResolveOAuthOptions,
): Promise<{
identity: ResolvedIdentity
metadata: OAuthAuthorizationServerMetadata
}> {
const identity = await this.resolveIdentity(input, options)
options?.signal?.throwIfAborted()
const metadata = await this.getResourceServerMetadata(identity.pds, options)
return { identity, metadata }
}
public async resolveIdentity(
input: string,
options?: ResolveIdentityOptions,
): Promise<ResolvedIdentity> {
try {
return await this.identityResolver.resolve(input, options)
} catch (cause) {
throw OAuthResolverError.from(
cause,
`Failed to resolve identity: ${input}`,
)
}
}
public async getAuthorizationServerMetadata(
issuer: string,
options?: GetCachedOptions,
): Promise<OAuthAuthorizationServerMetadata> {
try {
return await this.authorizationServerMetadataResolver.get(issuer, options)
} catch (cause) {
throw OAuthResolverError.from(
cause,
`Failed to resolve OAuth server metadata for issuer: ${issuer}`,
)
}
}
public async getResourceServerMetadata(
pdsUrl: string | URL,
options?: GetCachedOptions,
) {
try {
const rsMetadata = await this.protectedResourceMetadataResolver.get(
pdsUrl,
options,
)
// ATPROTO requires one, and only one, authorization server entry
if (rsMetadata.authorization_servers?.length !== 1) {
throw new OAuthResolverError(
rsMetadata.authorization_servers?.length
? `Unable to determine authorization server for PDS: ${pdsUrl}`
: `No authorization servers found for PDS: ${pdsUrl}`,
)
}
const issuer = rsMetadata.authorization_servers![0]!
options?.signal?.throwIfAborted()
const asMetadata = await this.getAuthorizationServerMetadata(
issuer,
options,
)
// https://datatracker.ietf.org/doc/html/draft-ietf-oauth-resource-metadata-05#section-4
if (asMetadata.protected_resources) {
if (!asMetadata.protected_resources.includes(rsMetadata.resource)) {
throw new OAuthResolverError(
`PDS "${pdsUrl}" not protected by issuer "${issuer}"`,
)
}
}
return asMetadata
} catch (cause) {
throw OAuthResolverError.from(
cause,
`Failed to resolve OAuth server metadata for resource: ${pdsUrl}`,
)
}
}
}