import { KeyObject, createPublicKey, createSecretKey } from 'node:crypto' import { IncomingMessage, ServerResponse } from 'node:http' import * as jose from 'jose' import KeyEncoder from 'key-encoder' import { getVerificationMaterial } from '@atproto/common' import { IdResolver, getDidKeyFromMultibase } from '@atproto/identity' import { AtIdentifierString, DidString, isDidString } from '@atproto/lex' import { OAuthError, OAuthVerifier, VerifyTokenPayloadOptions, WWWAuthenticateError, } from '@atproto/oauth-provider' import { ScopePermissions, ScopePermissionsTransition, } from '@atproto/oauth-scopes' import { AuthRequiredError, Awaitable, ForbiddenError, InvalidRequestError, MethodAuthContext, MethodAuthVerifier, Params, XRPCError, parseReqNsid, verifyJwt as verifyServiceJwt, } from '@atproto/xrpc-server' import { AccountManager } from './account-manager/account-manager' import { ActorAccount } from './account-manager/helpers/account' import { AccessOutput, AdminTokenOutput, ModServiceOutput, OAuthOutput, RefreshOutput, UnauthenticatedOutput, UserServiceAuthOutput, } from './auth-output' import { ACCESS_STANDARD, AuthScope, isAuthScope } from './auth-scope' import { softDeleted } from './db' import { appendVary } from './util/http' import { WithRequired } from './util/types' export type VerifiedOptions = { checkTakedown?: boolean checkDeactivated?: boolean } export type ScopedOptions = { scopes?: readonly S[] } export type ExtraScopedOptions = { additional?: readonly S[] } export type AuthorizedOptions

= { authorize: ( permissions: ScopePermissions, ctx: MethodAuthContext

, ) => Awaitable } export type AuthVerifierOpts = { publicUrl: string jwtKey: KeyObject adminPass: string dids: { pds: string entryway?: string modService?: string } } export type VerifyBearerJwtOptions = WithRequired< Omit & { scopes: readonly S[] }, 'audience' | 'typ' > export type VerifyBearerJwtResult = { sub: DidString aud: string jti: string | undefined scope: S } export class AuthVerifier { private _publicUrl: string private _jwtKey: KeyObject private _adminPass: string public dids: AuthVerifierOpts['dids'] constructor( public accountManager: AccountManager, public idResolver: IdResolver, public oauthVerifier: OAuthVerifier, opts: AuthVerifierOpts, ) { this._publicUrl = opts.publicUrl this._jwtKey = opts.jwtKey this._adminPass = opts.adminPass this.dids = opts.dids } // verifiers (arrow fns to preserve scope) public unauthenticated: MethodAuthVerifier = (ctx) => { setAuthHeaders(ctx.res) // @NOTE this auth method is typically used as fallback when no other auth // method is applicable. This means that the presence of an "authorization" // header means that that header is invalid (as it did not match any of the // other auth methods). if (ctx.req.headers['authorization']) { throw new AuthRequiredError('Invalid authorization header') } return { credentials: null, } } public adminToken: MethodAuthVerifier = async (ctx) => { setAuthHeaders(ctx.res) const parsed = parseBasicAuth(ctx.req) if (!parsed) { throw new AuthRequiredError() } const { username, password } = parsed if (username !== 'admin' || password !== this._adminPass) { throw new AuthRequiredError() } return { credentials: { type: 'admin_token' } } } public modService: MethodAuthVerifier = async (ctx) => { setAuthHeaders(ctx.res) if (!this.dids.modService) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } const payload = await this.verifyServiceJwt(ctx.req, { iss: [this.dids.modService, `${this.dids.modService}#atproto_labeler`], }) return { credentials: { type: 'mod_service', did: payload.iss, }, } } public moderator: MethodAuthVerifier = async (ctx) => { const type = extractAuthType(ctx.req) if (type === AuthType.BEARER) { return this.modService(ctx) } else { return this.adminToken(ctx) } } protected access( options: VerifiedOptions & Required>, ): MethodAuthVerifier> { const { scopes, ...statusOptions } = options const verifyJwtOptions: VerifyBearerJwtOptions = { audience: this.dids.pds, typ: 'at+jwt', scopes: // @NOTE We can reject taken down credentials based on the scope if // "checkTakedown" is set. statusOptions.checkTakedown && scopes.includes(AuthScope.Takendown as S) ? scopes.filter((s) => s !== AuthScope.Takendown) : scopes, } return async (ctx) => { setAuthHeaders(ctx.res) const { sub: did, scope } = await this.verifyBearerJwt( ctx.req, verifyJwtOptions, ) await this.verifyStatus(did, statusOptions) return { credentials: { type: 'access', did, scope }, } } } public refresh(options?: { allowExpired?: boolean }): MethodAuthVerifier { const verifyOptions: VerifyBearerJwtOptions = { clockTolerance: options?.allowExpired ? Infinity : undefined, typ: 'refresh+jwt', // when using entryway, proxying refresh credentials audience: this.dids.entryway ? this.dids.entryway : this.dids.pds, scopes: [AuthScope.Refresh], } return async (ctx) => { setAuthHeaders(ctx.res) const result = await this.verifyBearerJwt(ctx.req, verifyOptions) const tokenId = result.jti if (!tokenId) { throw new AuthRequiredError( 'Unexpected missing refresh token id', 'MissingTokenId', ) } return { credentials: { type: 'refresh', did: result.sub, scope: result.scope, tokenId, }, } } } public authorization

({ scopes = ACCESS_STANDARD, additional = [], ...options }: VerifiedOptions & ScopedOptions & ExtraScopedOptions & AuthorizedOptions

): MethodAuthVerifier { const access = this.access({ ...options, scopes: [...scopes, ...additional], }) const oauth = this.oauth(options) return async (ctx) => { const type = extractAuthType(ctx.req) if (type === AuthType.BEARER) { return access(ctx) } if (type === AuthType.DPOP) { return oauth(ctx) } // Auth headers are set through the access and oauth methods so we only // need to set them here if we reach this point setAuthHeaders(ctx.res) if (type !== null) { throw new InvalidRequestError( 'Unexpected authorization type', 'InvalidToken', ) } throw new AuthRequiredError(undefined, 'AuthMissing') } } public authorizationOrAdminTokenOptional

( opts: VerifiedOptions & ExtraScopedOptions & AuthorizedOptions

, ): MethodAuthVerifier< OAuthOutput | AccessOutput | AdminTokenOutput | UnauthenticatedOutput, P > { const authorization = this.authorization(opts) return async (ctx) => { const type = extractAuthType(ctx.req) if (type === AuthType.BEARER || type === AuthType.DPOP) { return authorization(ctx) } else if (type === AuthType.BASIC) { return this.adminToken(ctx) } else { return this.unauthenticated(ctx) } } } public userServiceAuth: MethodAuthVerifier = async ( ctx, ) => { setAuthHeaders(ctx.res) const payload = await this.verifyServiceJwt(ctx.req) return { credentials: { type: 'user_service_auth', did: payload.iss, }, } } public userServiceAuthOptional: MethodAuthVerifier< UserServiceAuthOutput | UnauthenticatedOutput > = async (ctx) => { const type = extractAuthType(ctx.req) if (type === AuthType.BEARER) { return await this.userServiceAuth(ctx) } else { return this.unauthenticated(ctx) } } public authorizationOrUserServiceAuth

( options: VerifiedOptions & ScopedOptions & ExtraScopedOptions & AuthorizedOptions

, ): MethodAuthVerifier { const authorizationVerifier = this.authorization(options) return async (ctx) => { if (isDefinitelyServiceAuth(ctx.req)) { return this.userServiceAuth(ctx) } else { return authorizationVerifier(ctx) } } } protected oauth

({ authorize, ...verifyStatusOptions }: VerifiedOptions & AuthorizedOptions

): MethodAuthVerifier< OAuthOutput, P > { const verifyTokenOptions: VerifyTokenPayloadOptions = { audience: [this.dids.pds], scope: ['atproto'], } return async (ctx) => { setAuthHeaders(ctx.res) const { req, res } = ctx // https://datatracker.ietf.org/doc/html/rfc9449#section-8.2 const dpopNonce = this.oauthVerifier.nextDpopNonce() if (dpopNonce) { res.setHeader('DPoP-Nonce', dpopNonce) res.appendHeader('Access-Control-Expose-Headers', 'DPoP-Nonce') } const originalUrl = req.originalUrl || req.url || '/' const url = new URL(originalUrl, this._publicUrl) const { scope, sub: did } = await this.oauthVerifier .authenticateRequest( req.method || 'GET', url, req.headers, verifyTokenOptions, ) .catch((err) => { // Make sure to include any WWW-Authenticate header in the response // (particularly useful for DPoP's "use_dpop_nonce" error) if (err instanceof WWWAuthenticateError) { res.setHeader('WWW-Authenticate', err.wwwAuthenticateHeader) res.appendHeader( 'Access-Control-Expose-Headers', 'WWW-Authenticate', ) } if (err instanceof OAuthError) { throw new XRPCError(err.status, err.error_description, err.error) } throw err }) if (!isDidString(did)) { throw new InvalidRequestError('Malformed token', 'InvalidToken') } await this.verifyStatus(did, verifyStatusOptions) const permissions = new ScopePermissionsTransition(scope?.split(' ')) // Should never happen if (!permissions.scopes.has('atproto')) { throw new InvalidRequestError( 'OAuth token does not have "atproto" scope', 'InvalidToken', ) } await authorize(permissions, ctx) return { credentials: { type: 'oauth', did, permissions, }, } } } protected async verifyStatus( did: DidString, options: VerifiedOptions, ): Promise { if (options.checkDeactivated || options.checkTakedown) { await this.findAccount(did, options) } } /** * Finds an account by its handle or DID, returning possibly deactivated or * taken down accounts (unless `options.checkDeactivated` or * `options.checkTakedown` are set to true, respectively). */ public async findAccount( handleOrDid: AtIdentifierString, options: VerifiedOptions, ): Promise { const account = await this.accountManager.getAccount(handleOrDid, { includeDeactivated: true, includeTakenDown: true, }) if (!account) { // will be turned into ExpiredToken for the client if proxied by entryway throw new ForbiddenError('Account not found', 'AccountNotFound') } if (options.checkTakedown && softDeleted(account)) { throw new AuthRequiredError( 'Account has been taken down', 'AccountTakedown', ) } if (options.checkDeactivated && account.deactivatedAt) { throw new AuthRequiredError( 'Account is deactivated', 'AccountDeactivated', ) } return account } /** * Wraps {@link jose.jwtVerify} into a function that also validates the token * payload's type and wraps errors into {@link InvalidRequestError}. */ protected async verifyBearerJwt( req: IncomingMessage, { scopes, ...options }: VerifyBearerJwtOptions, ): Promise> { const token = bearerTokenFromReq(req) if (!token) { throw new AuthRequiredError(undefined, 'AuthMissing') } const { payload, protectedHeader } = await jose .jwtVerify(token, this._jwtKey, { ...options, typ: undefined }) .catch((cause) => { if (cause instanceof jose.errors.JWTExpired) { throw new InvalidRequestError('Token has expired', 'ExpiredToken', { cause, }) } else { throw new InvalidRequestError( 'Token could not be verified', 'InvalidToken', { cause }, ) } }) // @NOTE: the "typ" is now set in production environments, so we should be // able to safely check it through jose.jwtVerify(). However, tests depend // on @atproto/pds-entryway which does not set "typ" in the access tokens. // For that reason, we still allow it to be missing. if (protectedHeader.typ && options.typ !== protectedHeader.typ) { throw new InvalidRequestError('Invalid token type', 'InvalidToken') } const { sub, aud, scope, lxm, cnf, jti } = payload if (typeof lxm !== 'undefined') { // Service auth tokens should never make it to here. But since service // auth tokens do not have a "typ" header, the "typ" check above will not // catch them. This check here is mainly to protect against the // hypothetical case in which a PDS would issue service auth tokens using // its private key. throw new InvalidRequestError('Malformed token', 'InvalidToken') } if (typeof cnf !== 'undefined') { // Proof-of-Possession (PoP) tokens are not allowed here // https://www.rfc-editor.org/rfc/rfc7800.html throw new InvalidRequestError('Malformed token', 'InvalidToken') } if (typeof sub !== 'string' || !isDidString(sub)) { throw new InvalidRequestError('Malformed token', 'InvalidToken') } if (typeof aud !== 'string' || !aud.startsWith('did:')) { throw new InvalidRequestError('Malformed token', 'InvalidToken') } if (typeof jti !== 'string' && typeof jti !== 'undefined') { throw new InvalidRequestError('Malformed token', 'InvalidToken') } if (!isAuthScope(scope) || !scopes.includes(scope as any)) { throw new InvalidRequestError('Bad token scope', 'InvalidToken') } return { sub, aud, jti, scope: scope as S } } protected async verifyServiceJwt( req: IncomingMessage, opts?: { iss?: string[] }, ) { const jwtStr = bearerTokenFromReq(req) if (!jwtStr) { throw new AuthRequiredError('missing jwt', 'MissingJwt') } const nsid = parseReqNsid(req) const payload = await verifyServiceJwt( jwtStr, null, nsid, async (iss, forceRefresh) => { if (opts?.iss && !opts.iss.includes(iss)) { throw new AuthRequiredError('Untrusted issuer', 'UntrustedIss') } const [did, serviceId] = iss.split('#') const keyId = serviceId === 'atproto_labeler' ? 'atproto_label' : 'atproto' const didDoc = await this.idResolver.did.resolve(did, forceRefresh) if (!didDoc) { throw new AuthRequiredError('could not resolve iss did') } const parsedKey = getVerificationMaterial(didDoc, keyId) if (!parsedKey) { throw new AuthRequiredError('missing or bad key in did doc') } const didKey = getDidKeyFromMultibase(parsedKey) if (!didKey) { throw new AuthRequiredError('missing or bad key in did doc') } return didKey }, ) if ( payload.aud !== this.dids.pds && (!this.dids.entryway || payload.aud !== this.dids.entryway) ) { throw new AuthRequiredError( 'jwt audience does not match service did', 'BadJwtAudience', ) } return payload } } // HELPERS // --------- export function isUserOrAdmin( auth: AccessOutput | OAuthOutput | AdminTokenOutput | UnauthenticatedOutput, did: string, ): boolean { if (!auth.credentials) { return false } else if (auth.credentials.type === 'admin_token') { return true } else { return auth.credentials.did === did } } enum AuthType { BASIC = 'Basic', BEARER = 'Bearer', DPOP = 'DPoP', } const parseAuthorizationHeader = ( req: IncomingMessage, ): [type: null] | [type: AuthType, token: string] => { const authorization = req.headers['authorization'] if (!authorization) return [null] const result = authorization.split(' ') if (result.length !== 2) { throw new InvalidRequestError( 'Malformed authorization header', 'InvalidToken', ) } // authorization type is case-insensitive const authType = result[0].toUpperCase() const type = Object.hasOwn(AuthType, authType) ? AuthType[authType] : null if (type) return [type, result[1]] throw new InvalidRequestError( `Unsupported authorization type: ${result[0]}`, 'InvalidToken', ) } /** * @note Not all service auth tokens are guaranteed to have "lxm" claim, so this * function should not be used to verify service auth tokens. It is only used to * check if a token is definitely a service auth token. */ const isDefinitelyServiceAuth = (req: IncomingMessage): boolean => { const token = bearerTokenFromReq(req) if (!token) return false const payload = jose.decodeJwt(token) return payload['lxm'] != null } const extractAuthType = (req: IncomingMessage): AuthType | null => { const [type] = parseAuthorizationHeader(req) return type } const bearerTokenFromReq = (req: IncomingMessage) => { const [type, token] = parseAuthorizationHeader(req) return type === AuthType.BEARER ? token : null } const parseBasicAuth = ( req: IncomingMessage, ): { username: string; password: string } | null => { try { const [type, b64] = parseAuthorizationHeader(req) if (type !== AuthType.BASIC) return null const decoded = Buffer.from(b64, 'base64').toString('utf8') // We must not use split(':') because the password can contain colons const colon = decoded.indexOf(':') if (colon === -1) return null const username = decoded.slice(0, colon) const password = decoded.slice(colon + 1) return { username, password } } catch (err) { return null } } export const createSecretKeyObject = (secret: string): KeyObject => { return createSecretKey(Buffer.from(secret)) } const keyEncoder = new KeyEncoder('secp256k1') export const createPublicKeyObject = (publicKeyHex: string): KeyObject => { const key = keyEncoder.encodePublic(publicKeyHex, 'raw', 'pem') return createPublicKey({ format: 'pem', key }) } function setAuthHeaders(res: ServerResponse) { res.setHeader('Cache-Control', 'private') appendVary(res, 'Authorization') }