diff --git a/packages/pds/example.env b/packages/pds/example.env index fc3c3520eb0..d314e170af7 100644 --- a/packages/pds/example.env +++ b/packages/pds/example.env @@ -14,6 +14,7 @@ PDS_REPO_SIGNING_KEY_K256_PRIVATE_KEY_HEX="3ee68..." PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX="e049f..." # Secrets - update to secure high-entropy strings +PDS_DPOP_SECRET="32-random-bytes-hex-encoded" PDS_JWT_SECRET="jwt-secret" PDS_ADMIN_PASSWORD="admin-pass" @@ -21,4 +22,4 @@ PDS_ADMIN_PASSWORD="admin-pass" PDS_DID_PLC_URL="https://plc.bsky-sandbox.dev" PDS_BSKY_APP_VIEW_ENDPOINT="https://api.bsky-sandbox.dev" PDS_BSKY_APP_VIEW_DID="did:web:api.bsky-sandbox.dev" -PDS_CRAWLERS="https://bgs.bsky-sandbox.dev" \ No newline at end of file +PDS_CRAWLERS="https://bgs.bsky-sandbox.dev" diff --git a/packages/pds/src/account-manager/helpers/used-refresh-token.ts b/packages/pds/src/account-manager/helpers/used-refresh-token.ts index 518ee2aed5f..bf5ff896005 100644 --- a/packages/pds/src/account-manager/helpers/used-refresh-token.ts +++ b/packages/pds/src/account-manager/helpers/used-refresh-token.ts @@ -6,11 +6,12 @@ export const insert = async ( id: number, usedRefreshToken: RefreshToken, ) => { - await db.db - .insertInto('used_refresh_token') - .values({ id, usedRefreshToken }) - .onConflict((oc) => oc.columns(['id', 'usedRefreshToken']).doNothing()) - .execute() + await db.executeWithRetry( + db.db + .insertInto('used_refresh_token') + .values({ id, usedRefreshToken }) + .onConflict((oc) => oc.columns(['id', 'usedRefreshToken']).doNothing()), + ) } export const findByToken = (db: AccountDb, usedRefreshToken: RefreshToken) => { diff --git a/packages/pds/src/account-manager/index.ts b/packages/pds/src/account-manager/index.ts index 560e84b780e..8b00f1cd13b 100644 --- a/packages/pds/src/account-manager/index.ts +++ b/packages/pds/src/account-manager/index.ts @@ -51,7 +51,6 @@ export class AccountManager dbLocation: string, private jwtKey: KeyObject, private serviceDid: string, - readonly publicUrl: string, disableWalAutoCheckpoint = false, ) { this.db = getDb(dbLocation, disableWalAutoCheckpoint) diff --git a/packages/pds/src/api/proxy.ts b/packages/pds/src/api/proxy.ts index 554898dbbaf..ad16a337a14 100644 --- a/packages/pds/src/api/proxy.ts +++ b/packages/pds/src/api/proxy.ts @@ -1,4 +1,5 @@ import { Headers } from '@atproto/xrpc' +import { InvalidRequestError } from '@atproto/xrpc-server' import { IncomingMessage } from 'node:http' export const resultPassthru = (result: { headers: Headers; data: T }) => { @@ -24,9 +25,24 @@ export function authPassthru( | undefined export function authPassthru(req: IncomingMessage, withEncoding?: boolean) { - if (req.headers.authorization) { + const { authorization } = req.headers + + if (authorization) { + // DPoP requests are bound to the endpoint being called. Allowing them to be + // proxied would require that the receiving end allows DPoP proof not + // created for him. Since proxying is mainly there to support legacy + // clients, and DPoP is a new feature, we don't support DPoP requests + // through the proxy. + + // This is fine since app views are usually called using the requester's + // credentials when "auth.credentials.type === 'access'", which is the only + // case were DPoP is used. + if (authorization.startsWith('DPoP ') || req.headers['dpop']) { + throw new InvalidRequestError('DPoP requests cannot be proxied') + } + return { - headers: { authorization: req.headers.authorization }, + headers: { authorization }, encoding: withEncoding ? 'application/json' : undefined, } } diff --git a/packages/pds/src/auth-verifier.ts b/packages/pds/src/auth-verifier.ts index 23426d98bcf..58d0480819c 100644 --- a/packages/pds/src/auth-verifier.ts +++ b/packages/pds/src/auth-verifier.ts @@ -1,15 +1,17 @@ -import { KeyObject, createPublicKey, createSecretKey } from 'node:crypto' +import { IdResolver } from '@atproto/identity' +import { Keyset } from '@atproto/jwk' +import { DpopProvider } from '@atproto/oauth-provider' import { AuthRequiredError, ForbiddenError, InvalidRequestError, verifyJwt as verifyServiceJwt, } from '@atproto/xrpc-server' -import { IdResolver } from '@atproto/identity' -import * as ui8 from 'uint8arrays' import express from 'express' import * as jose from 'jose' import KeyEncoder from 'key-encoder' +import { KeyObject, createPublicKey, createSecretKey } from 'node:crypto' +import * as ui8 from 'uint8arrays' import { AccountManager } from './account-manager' import { softDeleted } from './db' @@ -94,6 +96,9 @@ type ValidatedRefreshBearer = ValidatedBearer & { } export type AuthVerifierOpts = { + dpopProvider: DpopProvider + issuer: string + keyset: Keyset jwtKey: KeyObject adminPass: string moderatorPass: string @@ -106,6 +111,9 @@ export type AuthVerifierOpts = { } export class AuthVerifier { + private _dpopProvider: DpopProvider + private _issuer: string + private _keyset: Keyset private _jwtKey: KeyObject private _adminPass: string private _moderatorPass: string @@ -117,6 +125,9 @@ export class AuthVerifier { public idResolver: IdResolver, opts: AuthVerifierOpts, ) { + this._dpopProvider = opts.dpopProvider + this._issuer = opts.issuer + this._keyset = opts.keyset this._jwtKey = opts.jwtKey this._adminPass = opts.adminPass this._moderatorPass = opts.moderatorPass @@ -321,7 +332,15 @@ export class AuthVerifier { throw new AuthRequiredError(undefined, 'AuthMissing') } - const { payload } = await this.jwtVerify(token, verifyOptions) + const { payload, protectedHeader } = await this.jwtVerify( + token, + verifyOptions, + ) + + if (protectedHeader.typ) { + // Only OAuth Provider sets this claim + throw new InvalidRequestError('Malformed token', 'InvalidToken') + } const { sub, aud, scope } = payload if (typeof sub !== 'string' || !sub.startsWith('did:')) { @@ -357,6 +376,8 @@ export class AuthVerifier { switch (type) { case BEARER: return this.validateBearerAccessToken(req, scopes) + case DPOP: + return this.validateDpopAccessToken(req, scopes) case null: throw new AuthRequiredError(undefined, 'AuthMissing') default: @@ -367,6 +388,64 @@ export class AuthVerifier { } } + async validateDpopAccessToken( + req: express.Request, + scopes: AuthScope[], + ): Promise { + const [tokenType, token] = parseAuthorizationHeader( + req.headers.authorization, + ) + if (tokenType !== DPOP) { + throw new InvalidRequestError( + 'Unexpected authorization type', + 'InvalidToken', + ) + } + + const url = new URL(req.url, this._issuer) + + const dpopJkt = await this._dpopProvider.dpopCheck( + req.headers.dpop, + req.method, + url.href, + token, + ) + + if (!dpopJkt) { + throw new InvalidRequestError('DPop proof required', 'InvalidToken') + } + + const { payload } = await this._keyset.verify<{ + aud: string + sub: `did:${string}` + }>(token as any, { + requiredClaims: ['aud', 'sub', 'iss', 'client_id'], + issuer: this._issuer, + audience: this.dids.pds, + typ: 'at+jwt', + }) + + const { sub } = payload + if (typeof sub !== 'string' || !sub.startsWith('did:')) { + throw new InvalidRequestError('Malformed token', 'InvalidToken') + } + + if (!dpopJkt || (payload.cnf as any)?.jkt !== dpopJkt) { + // TODO add a specific error code + throw new InvalidRequestError('Invalid DPop key', 'InvalidToken') + } + + return { + credentials: { + type: 'access', + did: sub, + scope: AuthScope.Access, + audience: payload.aud, + }, + artifacts: token, + } + } + async validateBearerAccessToken( req: express.Request, scopes: AuthScope[], @@ -464,6 +543,7 @@ export class AuthVerifier { const BASIC = 'Basic' const BEARER = 'Bearer' +const DPOP = 'DPoP' export const parseAuthorizationHeader = (authorization?: string) => { const result = authorization?.split(' ', 2) @@ -473,7 +553,7 @@ export const parseAuthorizationHeader = (authorization?: string) => { const isAccessToken = (req: express.Request): boolean => { const [type] = parseAuthorizationHeader(req.headers.authorization) - return type === BEARER + return type === BEARER || type === DPOP } const isBearerToken = (req: express.Request): boolean => { diff --git a/packages/pds/src/config/config.ts b/packages/pds/src/config/config.ts index 8ca807d39b5..a94fd8016e2 100644 --- a/packages/pds/src/config/config.ts +++ b/packages/pds/src/config/config.ts @@ -231,6 +231,12 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { const crawlersCfg: ServerConfig['crawlers'] = env.crawlers ?? [] + const oauthProviderCfg: ServerConfig['oauthProvider'] = entrywayCfg + ? null + : { + unsafeFetch: !!env.oauthUnsafeFetch, + } + return { service: serviceCfg, db: dbCfg, @@ -248,6 +254,7 @@ export const envToCfg = (env: ServerEnvironment): ServerConfig => { redis: redisCfg, rateLimits: rateLimitsCfg, crawlers: crawlersCfg, + oauthProvider: oauthProviderCfg, } } @@ -268,6 +275,7 @@ export type ServerConfig = { redis: RedisScratchConfig | null rateLimits: RateLimitsConfig crawlers: string[] + oauthProvider: OAuthProviderConfig | null } export type ServiceConfig = { @@ -331,6 +339,10 @@ export type EntrywayConfig = { plcRotationKey: string } +export type OAuthProviderConfig = { + unsafeFetch: boolean +} + export type InvitesConfig = | { required: true diff --git a/packages/pds/src/config/env.ts b/packages/pds/src/config/env.ts index fb5aed8232f..6e969b2f29c 100644 --- a/packages/pds/src/config/env.ts +++ b/packages/pds/src/config/env.ts @@ -94,6 +94,7 @@ export const readEnv = (): ServerEnvironment => { crawlers: envList('PDS_CRAWLERS'), // secrets + dpopSecret: envStr('PDS_DPOP_SECRET'), jwtSecret: envStr('PDS_JWT_SECRET'), adminPassword: envStr('PDS_ADMIN_PASSWORD'), moderatorPassword: envStr('PDS_MODERATOR_PASSWORD'), @@ -105,6 +106,9 @@ export const readEnv = (): ServerEnvironment => { plcRotationKeyK256PrivateKeyHex: envStr( 'PDS_PLC_ROTATION_KEY_K256_PRIVATE_KEY_HEX', ), + + // oauth + oauthUnsafeFetch: envBool('PDS_OAUTH_UNSAFE_FETCH') ?? false, } } @@ -199,6 +203,7 @@ export type ServerEnvironment = { crawlers?: string[] // secrets + dpopSecret?: string jwtSecret?: string adminPassword?: string moderatorPassword?: string @@ -207,4 +212,7 @@ export type ServerEnvironment = { // keys plcRotationKeyKmsKeyId?: string plcRotationKeyK256PrivateKeyHex?: string + + // oauth + oauthUnsafeFetch: boolean } diff --git a/packages/pds/src/config/secrets.ts b/packages/pds/src/config/secrets.ts index 8e18cd830f7..17300abb752 100644 --- a/packages/pds/src/config/secrets.ts +++ b/packages/pds/src/config/secrets.ts @@ -18,6 +18,10 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { throw new Error('Must configure plc rotation key') } + if (!env.dpopSecret) { + throw new Error('Must provide a DPoP secret') + } + if (!env.jwtSecret) { throw new Error('Must provide a JWT secret') } @@ -27,6 +31,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { } return { + dpopSecret: env.dpopSecret, jwtSecret: env.jwtSecret, adminPassword: env.adminPassword, moderatorPassword: env.moderatorPassword ?? env.adminPassword, @@ -37,6 +42,7 @@ export const envToSecrets = (env: ServerEnvironment): ServerSecrets => { } export type ServerSecrets = { + dpopSecret: string jwtSecret: string adminPassword: string moderatorPassword: string diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index e1303a21081..72fa07646f7 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -26,7 +26,15 @@ import { DiskBlobStore } from './disk-blobstore' import { getRedisClient } from './redis' import { ActorStore, ActorStoreReader } from './actor-store' import { LocalViewer } from './read-after-write/viewer' -import { OAuthManager } from './oauth-provider/oauth-manager' +import { + DpopNonce, + DpopProvider, + OAuthProvider, + ReplayStore, +} from '@atproto/oauth-provider' +import { OAuthReplayStoreRedis } from '@atproto/oauth-provider-replay-redis' +import { OAuthReplayStoreMemory } from '@atproto/oauth-provider-replay-memory' +import { OauthClientStore } from './oauth-provider/oauth-client-store' export type AppContextOptions = { actorStore: ActorStore @@ -49,7 +57,7 @@ export type AppContextOptions = { moderationAgent?: AtpAgent reportingAgent?: AtpAgent entrywayAgent?: AtpAgent - oauthManager?: OAuthManager + oauthProvider?: OAuthProvider authVerifier: AuthVerifier plcRotationKey: crypto.Keypair cfg: ServerConfig @@ -77,7 +85,7 @@ export class AppContext { public reportingAgent: AtpAgent | undefined public entrywayAgent: AtpAgent | undefined public authVerifier: AuthVerifier - public oauthManager?: OAuthManager + public oauthProvider?: OAuthProvider public plcRotationKey: crypto.Keypair public cfg: ServerConfig @@ -100,7 +108,7 @@ export class AppContext { this.reportingAgent = opts.reportingAgent this.entrywayAgent = opts.entrywayAgent this.authVerifier = opts.authVerifier - this.oauthManager = opts.oauthManager + this.oauthProvider = opts.oauthProvider this.plcRotationKey = opts.plcRotationKey this.cfg = opts.cfg } @@ -188,7 +196,6 @@ export class AppContext { cfg.db.accountDbLoc, jwtSecretKey, cfg.service.did, - cfg.service.publicUrl, cfg.db.disableWalAutoCheckpoint, ) await accountManager.migrateOrThrow() @@ -197,7 +204,54 @@ export class AppContext { ? createPublicKeyObject(cfg.entryway.jwtPublicKeyHex) : jwtSecretKey + const keyset = await NodeKeyset.fromImportables({ + // @TODO: load keys from config + ['s0']: jwtKey, + ['kid-1']: + '-----BEGIN PRIVATE KEY-----\n' + + 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4D4H8/CFAVuKMgQD\n' + + 'BIK9m53AEUrCxQKrgtMNSTNV9A2hRANCAARAwyllCZOflLEQM0MaYujz7ITxqczZ\n' + + '6Vxhj4urrdXUN3MEliQcc14ImTWHt7h7+xbxIXETLj0kTzctAxSbtwZf\n' + + '-----END PRIVATE KEY-----\n', + }) + + const dpopNonce = new DpopNonce(Buffer.from(secrets.dpopSecret, 'hex')) + const replayStore: ReplayStore = redisScratch + ? new OAuthReplayStoreRedis(redisScratch) + : new OAuthReplayStoreMemory() + + const oauthProvider = cfg.oauthProvider + ? new OAuthProvider({ + issuer: cfg.service.publicUrl, + keyset, + dpopNonce, + + accountStore: accountManager, + requestStore: accountManager, + sessionStore: accountManager, + tokenStore: accountManager, + replayStore, + clientStore: new OauthClientStore({ + unsafeFetch: cfg.oauthProvider.unsafeFetch, + }), + + onTokenResponse: (tokenResponse, { account }) => { + // ATPROTO extension: add the sub claim to the token response to allow + // clients to resolve the PDS url (audience) using the did resolution + // mechanism. + tokenResponse['sub'] = account.sub + }, + }) + : undefined + const authVerifier = new AuthVerifier(accountManager, idResolver, { + issuer: new URL(cfg.service.publicUrl).href, + keyset, + dpopProvider: + // Using the oauthProvider as dpop provider allows clients to use the + // same nonce when authenticating and making requests, this will avoid + // un-necessary "invalid_nonce" errors. + oauthProvider ?? new DpopProvider({ dpopNonce, replayStore }), jwtKey, adminPass: secrets.adminPassword, moderatorPass: secrets.moderatorPassword, @@ -209,26 +263,6 @@ export class AppContext { }, }) - const oauthManager = cfg.entryway - ? undefined - : new OAuthManager( - cfg.service.publicUrl, - // @TODO: load more keys from config - await NodeKeyset.fromImportables({ - ['kid-0']: - '-----BEGIN PRIVATE KEY-----\n' + - 'MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg4D4H8/CFAVuKMgQD\n' + - 'BIK9m53AEUrCxQKrgtMNSTNV9A2hRANCAARAwyllCZOflLEQM0MaYujz7ITxqczZ\n' + - '6Vxhj4urrdXUN3MEliQcc14ImTWHt7h7+xbxIXETLj0kTzctAxSbtwZf\n' + - '-----END PRIVATE KEY-----\n', - }), - accountManager, - redisScratch, - // TODO: from config - process.env.NODE_ENV === 'development' || - process.env.NODE_ENV === 'test', - ) - const plcRotationKey = secrets.plcRotationKey.provider === 'kms' ? await KmsKeypair.load({ @@ -270,7 +304,7 @@ export class AppContext { reportingAgent, entrywayAgent, authVerifier, - oauthManager, + oauthProvider, plcRotationKey, cfg, ...(overrides ?? {}), diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index 7939a3f92c4..3b7e06339fd 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -19,7 +19,7 @@ import API from './api' import * as basicRoutes from './basic-routes' import * as wellKnown from './well-known' import * as error from './error' -import { loggerMiddleware } from './logger' +import { loggerMiddleware, oauthProviderLogger } from './logger' import { ServerConfig, ServerSecrets } from './config' import { createServer } from './lexicon' import { createHttpTerminator, HttpTerminator } from 'http-terminator' @@ -122,7 +122,16 @@ export class PDS { server = API(server, ctx) - if (ctx.oauthManager) app.use(ctx.oauthManager.router) + if (ctx.oauthProvider) { + app.use( + ctx.oauthProvider.httpHandler({ + // Log oauth provider errors using our own logger + onError: (req, res, err) => { + oauthProviderLogger.error({ err }, 'oauth-provider error') + }, + }), + ) + } app.use(basicRoutes.createRouter(ctx)) app.use(wellKnown.createRouter(ctx)) diff --git a/packages/pds/src/logger.ts b/packages/pds/src/logger.ts index 713f661b8d6..9a1ef4a2c7c 100644 --- a/packages/pds/src/logger.ts +++ b/packages/pds/src/logger.ts @@ -45,6 +45,15 @@ export const loggerMiddleware = pinoHttp({ auth = 'Basic ' + parsed.username } } + if (authHeader.startsWith('DPoP ')) { + const token = authHeader.slice('DPoP '.length) + const { iss } = jose.decodeJwt(token) + if (iss) { + auth = 'DPoP ' + iss + } else { + auth = 'DPoP Invalid' + } + } return { ...serialized, headers: { diff --git a/packages/pds/src/oauth-provider/oauth-client-store.ts b/packages/pds/src/oauth-provider/oauth-client-store.ts new file mode 100644 index 00000000000..85738d22d12 --- /dev/null +++ b/packages/pds/src/oauth-provider/oauth-client-store.ts @@ -0,0 +1,129 @@ +import { isAbsolute, relative } from 'node:path' +import { + ClientId, + ClientMetadata, + InvalidClientMetadataError, + InvalidRedirectUriError, + parseRedirectUri, +} from '@atproto/oauth-provider' +import { OAuthClientUriStore } from '@atproto/oauth-provider-client-uri' + +import { oauthProviderLogger } from '../logger.js' + +export class OauthClientStore extends OAuthClientUriStore { + constructor({ + /** + * In dev, it can be useful to disable SSRF & other protections. + */ + unsafeFetch = false, + } = {}) { + super({ unsafeFetch, fetch, loopbackMetadata, validateMetadata }) + } +} + +/** + * Wrap the client's fetch function to log the requests + */ +function fetch(request: Request) { + oauthProviderLogger.debug({ uri: request.url }, 'client metadata fetch') + return globalThis.fetch(request) +} + +/** + * Allow "loopback" clients using the following client metadata (as defined in + * the ATPROTO spec). + */ +function loopbackMetadata({ href }: URL): Partial { + return { + client_name: 'Loopback ATPROTO client', + client_uri: href, + response_types: ['code', 'code id_token'], + grant_types: ['authorization_code', 'refresh_token'], + scope: 'profile offline_access', + redirect_uris: ['127.0.0.1', '[::1]'].map( + (ip) => Object.assign(new URL(href), { hostname: ip }).href, + ) as [string, string], + token_endpoint_auth_method: 'none', + application_type: 'native', + dpop_bound_access_tokens: true, + } +} + +/** + * Make sure that fetched metadata are spec compliant + */ +function validateMetadata( + clientId: ClientId, + clientUrl: URL, + metadata: ClientMetadata, +) { + // ATPROTO spec requires the use of DPoP (default is false) + if (metadata.dpop_bound_access_tokens !== true) { + throw new InvalidClientMetadataError( + '"dpop_bound_access_tokens" must be true', + ) + } + + // ATPROTO spec requires the use of PKCE + if (metadata.response_types.some((rt) => rt.split(' ').includes('token'))) { + throw new InvalidClientMetadataError( + '"token" response type is not compatible with PKCE (use "code" instead)', + ) + } + + for (const redirectUri of metadata.redirect_uris) { + const uri = parseRedirectUri(redirectUri) + + switch (true) { + case uri.protocol === 'http:': + // Only loopback redirect URIs are allowed to use HTTP + switch (uri.hostname) { + // ATPROTO spec requires that the IP is used in case of loopback redirect URIs + case '127.0.0.1': + case '[::1]': + continue + + // ATPROTO spec forbids use of localhost as redirect URI hostname + case 'localhost': + throw new InvalidRedirectUriError( + `Loopback redirect URI ${uri} is not allowed (use explicit IPs instead)`, + ) + } + + // ATPROTO spec forbids http redirects (except for loopback, covered before) + throw new InvalidRedirectUriError(`Redirect URI ${uri} must use HTTPS`) + + // ATPROTO spec requires that the redirect URI is a sub-url of the client URL + case uri.protocol === 'https:': + if (!isSubUrl(clientUrl, uri)) { + throw new InvalidRedirectUriError( + `Redirect URI ${uri} must be a sub-url of ${clientUrl}`, + ) + } + continue + + // Custom URI schemes are allowed by ATPROTO, following the rules + // defined in the spec & current best practices. These are already + // enforced by the @atproto/oauth-provider & + // @atproto/oauth-provider-client-uri packages. + default: + continue + } + } +} + +function isSubUrl(reference: URL, url: URL): boolean { + if (url.origin !== reference.origin) return false + if (url.username !== reference.username) return false + if (url.password !== reference.password) return false + + return ( + reference.pathname === url.pathname || + isSubPath(reference.pathname, url.pathname) + ) +} + +function isSubPath(reference: string, path: string): boolean { + const rel = relative(reference, path) + return !rel.startsWith('..') && !isAbsolute(rel) +} diff --git a/packages/pds/src/oauth-provider/oauth-manager.ts b/packages/pds/src/oauth-provider/oauth-manager.ts deleted file mode 100644 index cb9e183b4f1..00000000000 --- a/packages/pds/src/oauth-provider/oauth-manager.ts +++ /dev/null @@ -1,174 +0,0 @@ -import { isAbsolute, relative } from 'node:path' - -import { Keyset } from '@atproto/jwk' -import { - InvalidClientMetadataError, - InvalidRedirectUriError, - OAuthProvider, - parseRedirectUri, -} from '@atproto/oauth-provider' -import { OAuthClientUriStore } from '@atproto/oauth-provider-client-uri' -import { OAuthReplayStoreMemory } from '@atproto/oauth-provider-replay-memory' -import { - OAuthReplayStoreRedis, - type OAuthReplayStoreRedisOptions, -} from '@atproto/oauth-provider-replay-redis' - -import { AccountManager } from '../account-manager/index.js' -import { oauthProviderLogger } from '../logger.js' - -export class OAuthManager { - private readonly provider: OAuthProvider - - constructor( - issuer: string | URL, - keyset: Keyset, - accountManager: AccountManager, - redis?: OAuthReplayStoreRedisOptions, - unsafeFetch = process.env.NODE_ENV === 'development', - ) { - this.provider = new OAuthProvider({ - issuer, - keyset, - - accountStore: accountManager, - requestStore: accountManager, - sessionStore: accountManager, - tokenStore: accountManager, - - clientStore: new OAuthClientUriStore({ - // In dev, it can be useful to disable SSRF & other protections. - unsafeFetch, - // Wrap the client's fetch function to log the requests - fetch: async (request) => { - oauthProviderLogger.debug( - { uri: request.url }, - 'client metadata fetch', - ) - return globalThis.fetch(request) - }, - // Allow "loopback" clients using the following client metadata (as - // defined in the ATPROTO spec). - loopbackMetadata: ({ href }) => ({ - client_name: 'Loopback ATPROTO client', - client_uri: href, - response_types: ['code', 'code id_token'], - grant_types: ['authorization_code', 'refresh_token'], - scope: 'profile offline_access', - redirect_uris: ['127.0.0.1', '[::1]'].map( - (ip) => Object.assign(new URL(href), { hostname: ip }).href, - ) as [string, string], - token_endpoint_auth_method: 'none', - application_type: 'native', - dpop_bound_access_tokens: true, - }), - }), - replayStore: redis - ? new OAuthReplayStoreRedis(redis) - : new OAuthReplayStoreMemory(), - - onTokenResponse: (tokenResponse, { account }) => { - // ATPROTO extension: add the sub claim to the token response to allow - // clients to resolve the PDS url (audience) using the did resolution - // mechanism. - tokenResponse['sub'] = account.sub - }, - - // Make sure that client metadata are spec compliant - onClientData: (clientId, { metadata }) => { - let clientUrl: URL - try { - clientUrl = new URL(clientId) - } catch (cause) { - throw new InvalidClientMetadataError( - '"client_id" must be a URL', - cause, - ) - } - - // ATPROTO spec requires the use of DPoP (default is false) - if (metadata.dpop_bound_access_tokens !== true) { - throw new InvalidClientMetadataError( - '"dpop_bound_access_tokens" must be true', - ) - } - - // ATPROTO spec requires the use of PKCE - if ( - metadata.response_types.some((rt) => rt.split(' ').includes('token')) - ) { - throw new InvalidClientMetadataError( - '"token" response type is not compatible with PKCE (use "code" instead)', - ) - } - - for (const redirectUri of metadata.redirect_uris) { - const uri = parseRedirectUri(redirectUri) - - switch (true) { - case uri.protocol === 'http:': - // Only loopback redirect URIs are allowed to use HTTP - switch (uri.hostname) { - // ATPROTO spec requires that the IP is used in case of loopback redirect URIs - case '127.0.0.1': - case '[::1]': - continue - - // ATPROTO spec forbids use of localhost as redirect URI hostname - case 'localhost': - throw new InvalidRedirectUriError( - `Loopback redirect URI ${uri} is not allowed (use explicit IPs instead)`, - ) - } - - // ATPROTO spec forbids http redirects (except for loopback, covered before) - throw new InvalidRedirectUriError( - `Redirect URI ${uri} must use HTTPS`, - ) - - // ATPROTO spec requires that the redirect URI is a sub-url of the client URL - case uri.protocol === 'https:': - if (!isSubUrl(clientUrl, uri)) { - throw new InvalidRedirectUriError( - `Redirect URI ${uri} must be a sub-url of ${clientUrl}`, - ) - } - continue - - // Custom URI schemes are allowed by ATPROTO, following the rules - // defined in the spec & current best practices. These are already - // enforced by the @atproto/oauth-provider & - // @atproto/oauth-provider-client-uri packages. - default: - continue - } - } - }, - }) - } - - get router() { - return this.provider.httpHandler({ - // Log oauth provider errors using our own logger - onError: (req, res, err) => { - oauthProviderLogger.error({ err }, 'oauth-provider error') - }, - }) - } -} - -function isSubPath(reference: string, path: string): boolean { - const rel = relative(reference, path) - return !rel.startsWith('..') && !isAbsolute(rel) -} - -function isSubUrl(reference: URL, url: URL): boolean { - if (url.origin !== reference.origin) return false - if (url.username !== reference.username) return false - if (url.password !== reference.password) return false - - return ( - reference.pathname === url.pathname || - isSubPath(reference.pathname, url.pathname) - ) -} diff --git a/packages/pds/src/oauth-provider/stores/oauth-replay-store-memory.ts b/packages/pds/src/oauth-provider/stores/oauth-replay-store-memory.ts deleted file mode 100644 index 8fa9b737afe..00000000000 --- a/packages/pds/src/oauth-provider/stores/oauth-replay-store-memory.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { ReplayStore } from '@atproto/oauth-provider' - -export class OAuthReplayStoreMemory implements ReplayStore { - private lastCleanup = Date.now() - private nonces = new Map() - - /** - * Returns true if the nonce is unique within the given time frame. - */ - async unique( - namespace: string, - nonce: string, - timeFrame: number, - ): Promise { - this.cleanup() - const key = `${namespace}:${nonce}` - - const now = Date.now() - - const exp = this.nonces.get(key) - this.nonces.set(key, now + timeFrame) - - return exp == null || exp < now - } - - private cleanup() { - const now = Date.now() - - if (this.lastCleanup < now - 60_000) { - for (const [key, expires] of this.nonces) { - if (expires < now) this.nonces.delete(key) - } - this.lastCleanup = now - } - } -}