From 4432e6b562aaebbef7662409f14967896eb3c48e Mon Sep 17 00:00:00 2001 From: Matthieu Sieben Date: Thu, 15 Feb 2024 16:31:03 +0100 Subject: [PATCH] feat(pds): create oauth-manager shell --- packages/pds/package.json | 3 + packages/pds/src/context.ts | 19 ++++++- packages/pds/src/index.ts | 2 + packages/pds/src/logger.ts | 1 + .../pds/src/oauth-provider/oauth-manager.ts | 47 +++++++++++++++ .../stores/oauth-account-store.ts | 36 ++++++++++++ .../stores/oauth-client-store.ts | 18 ++++++ .../stores/oauth-replay-store-memory.ts | 29 ++++++++++ .../stores/oauth-replay-store-redis.ts | 20 +++++++ .../stores/oauth-request-store.ts | 46 +++++++++++++++ .../stores/oauth-session-store.ts | 23 ++++++++ .../stores/oauth-token-store.ts | 36 ++++++++++++ pnpm-lock.yaml | 57 +++++++++++++++++++ 13 files changed, 336 insertions(+), 1 deletion(-) create mode 100644 packages/pds/src/oauth-provider/oauth-manager.ts create mode 100644 packages/pds/src/oauth-provider/stores/oauth-account-store.ts create mode 100644 packages/pds/src/oauth-provider/stores/oauth-client-store.ts create mode 100644 packages/pds/src/oauth-provider/stores/oauth-replay-store-memory.ts create mode 100644 packages/pds/src/oauth-provider/stores/oauth-replay-store-redis.ts create mode 100644 packages/pds/src/oauth-provider/stores/oauth-request-store.ts create mode 100644 packages/pds/src/oauth-provider/stores/oauth-session-store.ts create mode 100644 packages/pds/src/oauth-provider/stores/oauth-token-store.ts diff --git a/packages/pds/package.json b/packages/pds/package.json index c1cf433f34a..ecea0156bbd 100644 --- a/packages/pds/package.json +++ b/packages/pds/package.json @@ -34,7 +34,10 @@ "@atproto/common": "workspace:^", "@atproto/crypto": "workspace:^", "@atproto/identity": "workspace:^", + "@atproto/jwk-node": "workspace:^", "@atproto/lexicon": "workspace:^", + "@atproto/oauth-provider": "workspace:^", + "@atproto/oauth-provider-client-fqdn": "workspace:^", "@atproto/repo": "workspace:^", "@atproto/syntax": "workspace:^", "@atproto/xrpc": "workspace:^", diff --git a/packages/pds/src/context.ts b/packages/pds/src/context.ts index 6a5b2927df1..8da2e1b909e 100644 --- a/packages/pds/src/context.ts +++ b/packages/pds/src/context.ts @@ -5,6 +5,7 @@ import * as crypto from '@atproto/crypto' import { IdResolver } from '@atproto/identity' import { AtpAgent } from '@atproto/api' import { KmsKeypair, S3BlobStore } from '@atproto/aws' +import { NodeKeyset } from '@atproto/jwk-node' import { createServiceAuthHeaders } from '@atproto/xrpc-server' import { ServerConfig, ServerSecrets } from './config' import { @@ -24,6 +25,7 @@ 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' export type AppContextOptions = { actorStore: ActorStore @@ -45,6 +47,7 @@ export type AppContextOptions = { appViewAgent: AtpAgent moderationAgent: AtpAgent entrywayAgent?: AtpAgent + oauthManager?: OAuthManager authVerifier: AuthVerifier plcRotationKey: crypto.Keypair cfg: ServerConfig @@ -71,6 +74,7 @@ export class AppContext { public moderationAgent: AtpAgent public entrywayAgent: AtpAgent | undefined public authVerifier: AuthVerifier + public oauthManager?: OAuthManager public plcRotationKey: crypto.Keypair public cfg: ServerConfig @@ -92,6 +96,7 @@ export class AppContext { this.moderationAgent = opts.moderationAgent this.entrywayAgent = opts.entrywayAgent this.authVerifier = opts.authVerifier + this.oauthManager = opts.oauthManager this.plcRotationKey = opts.plcRotationKey this.cfg = opts.cfg } @@ -182,7 +187,7 @@ export class AppContext { : jwtSecretKey const authVerifier = new AuthVerifier(accountManager, idResolver, { - jwtKey, // @TODO support multiple keys? + jwtKey, adminPass: secrets.adminPassword, moderatorPass: secrets.moderatorPassword, triagePass: secrets.triagePassword, @@ -193,6 +198,17 @@ export class AppContext { }, }) + const oauthManager = cfg.entryway + ? undefined + : new OAuthManager( + cfg.service.publicUrl, + // @TODO: load more keys from config + await NodeKeyset.fromImportables({ ['kid-0']: jwtSecretKey }), + accountManager, + redisScratch, + cfg.service.hostname === 'localhost', + ) + const plcRotationKey = secrets.plcRotationKey.provider === 'kms' ? await KmsKeypair.load({ @@ -233,6 +249,7 @@ export class AppContext { moderationAgent, entrywayAgent, authVerifier, + oauthManager, plcRotationKey, cfg, ...(overrides ?? {}), diff --git a/packages/pds/src/index.ts b/packages/pds/src/index.ts index b2fbf2091d1..1e10e441aa3 100644 --- a/packages/pds/src/index.ts +++ b/packages/pds/src/index.ts @@ -122,6 +122,8 @@ export class PDS { server = API(server, ctx) + if (ctx.oauthManager) app.use(ctx.oauthManager.router) + app.use(basicRoutes.createRouter(ctx)) app.use(wellKnown.createRouter(ctx)) app.use(server.xrpc.router) diff --git a/packages/pds/src/logger.ts b/packages/pds/src/logger.ts index 717e554d00b..713f661b8d6 100644 --- a/packages/pds/src/logger.ts +++ b/packages/pds/src/logger.ts @@ -13,6 +13,7 @@ export const mailerLogger = subsystemLogger('pds:mailer') export const labelerLogger = subsystemLogger('pds:labeler') export const crawlerLogger = subsystemLogger('pds:crawler') export const httpLogger = subsystemLogger('pds') +export const oauthProviderLogger = subsystemLogger('pds:oauth-provider') export const loggerMiddleware = pinoHttp({ logger: httpLogger, diff --git a/packages/pds/src/oauth-provider/oauth-manager.ts b/packages/pds/src/oauth-provider/oauth-manager.ts new file mode 100644 index 00000000000..e4ddb77b0ec --- /dev/null +++ b/packages/pds/src/oauth-provider/oauth-manager.ts @@ -0,0 +1,47 @@ +import { Keyset } from '@atproto/jwk' +import { OAuthProvider } from '@atproto/oauth-provider' +import { Redis } from 'ioredis' + +import { AccountManager } from './../account-manager/index' + +import { OAuthAccountStore } from './stores/oauth-account-store' +import { OAuthClientStore } from './stores/oauth-client-store' +import { OAuthReplayStoreMemory } from './stores/oauth-replay-store-memory' +import { OAuthReplayStoreRedis } from './stores/oauth-replay-store-redis' +import { OAuthRequestStore } from './stores/oauth-request-store' +import { OAuthSessionStore } from './stores/oauth-session-store' +import { OAuthTokenStore } from './stores/oauth-token-store' + +export class OAuthManager { + private readonly provider: OAuthProvider + + constructor( + issuer: string | URL, + keyset: Keyset, + accountManager: AccountManager, + redis?: Redis, + disableSsrfProtection = false, + ) { + this.provider = new OAuthProvider({ + issuer, + keyset, + clientStore: new OAuthClientStore(disableSsrfProtection), + accountStore: new OAuthAccountStore(accountManager), + requestStore: new OAuthRequestStore(accountManager), + replayStore: redis + ? new OAuthReplayStoreRedis(redis) + : new OAuthReplayStoreMemory(), + sessionStore: new OAuthSessionStore(accountManager), + tokenStore: new OAuthTokenStore(accountManager), + + onTokenResponse: ({ tokenResponse, account }) => { + // ATPROTO spec extension: add the sub claim to the token response + tokenResponse['sub'] = account.sub + }, + }) + } + + get router() { + return this.provider.httpHandler() + } +} diff --git a/packages/pds/src/oauth-provider/stores/oauth-account-store.ts b/packages/pds/src/oauth-provider/stores/oauth-account-store.ts new file mode 100644 index 00000000000..1398847dd35 --- /dev/null +++ b/packages/pds/src/oauth-provider/stores/oauth-account-store.ts @@ -0,0 +1,36 @@ +import type { + Account, + AccountLoginCredentials, + AccountStore, + DeviceAccountInfo, + DeviceId, + Sub, +} from '@atproto/oauth-provider' +import { AccountManager } from '../../account-manager/index.js' + +export class OAuthAccountStore implements AccountStore { + constructor(private readonly accountManager: AccountManager) {} + + async addAccount( + deviceId: DeviceId, + credentials: AccountLoginCredentials, + ): Promise<{ account: Account; info: DeviceAccountInfo } | null> { + throw new Error('Method not implemented.') + } + async listAccounts( + deviceId: DeviceId, + sub?: Sub, + ): Promise<{ account: Account; info: DeviceAccountInfo }[]> { + throw new Error('Method not implemented.') + } + async updateAccountInfo( + deviceId: DeviceId, + sub: Sub, + info: Partial, + ): Promise { + throw new Error('Method not implemented.') + } + async removeAccount(deviceId: DeviceId, sub: Sub): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/packages/pds/src/oauth-provider/stores/oauth-client-store.ts b/packages/pds/src/oauth-provider/stores/oauth-client-store.ts new file mode 100644 index 00000000000..d9b31106730 --- /dev/null +++ b/packages/pds/src/oauth-provider/stores/oauth-client-store.ts @@ -0,0 +1,18 @@ +import { ClientStore } from '@atproto/oauth-provider' +import { OAuthClientFQDNStore } from '@atproto/oauth-provider-client-fqdn' +import { oauthProviderLogger } from '../../logger' + +export class OAuthClientStore + extends OAuthClientFQDNStore + implements ClientStore +{ + constructor(disableSsrfProtection = false) { + super({ + safeFetch: !disableSsrfProtection, + fetch: async (request) => { + oauthProviderLogger.debug({ uri: request.url }, 'client metadata fetch') + return fetch(request) + }, + }) + } +} 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 new file mode 100644 index 00000000000..d77798ba40a --- /dev/null +++ b/packages/pds/src/oauth-provider/stores/oauth-replay-store-memory.ts @@ -0,0 +1,29 @@ +import type { ReplayStore } from '@atproto/oauth-provider' + +export class OAuthReplayStoreMemory implements ReplayStore { + private lastCleanup = Date.now() + private nonces = new Map() + + async unique(nonce: string, timeframe: number): Promise { + this.cleanup() + + const now = Date.now() + + const expires = this.nonces.get(nonce) + if (expires != null && expires > now) return false + + this.nonces.set(nonce, now + timeframe) + return true + } + + private cleanup() { + const now = Date.now() + + if (this.lastCleanup < now - 60_000) { + for (const [nonce, expires] of this.nonces) { + if (expires < now) this.nonces.delete(nonce) + } + this.lastCleanup = now + } + } +} diff --git a/packages/pds/src/oauth-provider/stores/oauth-replay-store-redis.ts b/packages/pds/src/oauth-provider/stores/oauth-replay-store-redis.ts new file mode 100644 index 00000000000..55752b0de24 --- /dev/null +++ b/packages/pds/src/oauth-provider/stores/oauth-replay-store-redis.ts @@ -0,0 +1,20 @@ +import type { ReplayStore } from '@atproto/oauth-provider' +import { Redis } from 'ioredis' + +export class OAuthReplayStoreRedis implements ReplayStore { + constructor(private readonly redis: Redis) {} + + /** + * Returns true if the nonce is unique within the given timeframe. + */ + async unique(nonce: string, timeframe: number): Promise { + const key = `oauth:nonce:${nonce}` + + if ((await this.redis.exists(key)) && (await this.redis.ttl(key)) > 0) { + return false + } + + await this.redis.set(key, '1', 'EX', Math.ceil(timeframe / 1000)) + return true + } +} diff --git a/packages/pds/src/oauth-provider/stores/oauth-request-store.ts b/packages/pds/src/oauth-provider/stores/oauth-request-store.ts new file mode 100644 index 00000000000..f64af1414cd --- /dev/null +++ b/packages/pds/src/oauth-provider/stores/oauth-request-store.ts @@ -0,0 +1,46 @@ +import type { + Account, + AccountLoginCredentials, + AccountStore, + Sub, + Code, + DeviceAccountInfo, + DeviceId, + RefreshToken, + ReplayStore, + RequestData, + RequestId, + RequestStore, + SessionData, + SessionStore, + TokenData, + TokenId, + TokenInfo, + TokenStore, +} from '@atproto/oauth-provider' +import { AccountManager } from '../../account-manager/index.js' + +export class OAuthRequestStore implements RequestStore { + constructor(private readonly accountManager: AccountManager) {} + + async createRequest(id: RequestId, data: RequestData): Promise { + throw new Error('Method not implemented.') + } + async readRequest(id: RequestId): Promise { + throw new Error('Method not implemented.') + } + async updateRequest( + id: RequestId, + data: Partial, + ): Promise { + throw new Error('Method not implemented.') + } + async deleteRequest(id: RequestId): Promise { + throw new Error('Method not implemented.') + } + async findRequest( + code: Code, + ): Promise<{ id: RequestId; data: RequestData } | null> { + throw new Error('Method not implemented.') + } +} diff --git a/packages/pds/src/oauth-provider/stores/oauth-session-store.ts b/packages/pds/src/oauth-provider/stores/oauth-session-store.ts new file mode 100644 index 00000000000..77c95e0c5e7 --- /dev/null +++ b/packages/pds/src/oauth-provider/stores/oauth-session-store.ts @@ -0,0 +1,23 @@ +import type { + DeviceId, + SessionData, + SessionStore, +} from '@atproto/oauth-provider' +import { AccountManager } from '../../account-manager/index.js' + +export class OAuthSessionStore implements SessionStore { + constructor(private readonly accountManager: AccountManager) {} + + async createSession(deviceId: DeviceId, data: SessionData): Promise { + throw new Error('Method not implemented.') + } + async readSession(deviceId: DeviceId): Promise { + throw new Error('Method not implemented.') + } + async updateSession( + deviceId: DeviceId, + data: Partial, + ): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/packages/pds/src/oauth-provider/stores/oauth-token-store.ts b/packages/pds/src/oauth-provider/stores/oauth-token-store.ts new file mode 100644 index 00000000000..fd0cc1be947 --- /dev/null +++ b/packages/pds/src/oauth-provider/stores/oauth-token-store.ts @@ -0,0 +1,36 @@ +import type { + Code, + RefreshToken, + TokenData, + TokenId, + TokenInfo, + TokenStore, +} from '@atproto/oauth-provider' +import { AccountManager } from '../../account-manager/index.js' + +export class OAuthTokenStore implements TokenStore { + constructor(private readonly accountManager: AccountManager) {} + + async createToken(tokenId: TokenId, data: TokenData): Promise { + throw new Error('Method not implemented.') + } + async readToken(tokenId: TokenId): Promise { + throw new Error('Method not implemented.') + } + async updateToken( + prevTokenId: TokenId, + nextTokenId: TokenId, + data: Partial, + ): Promise { + throw new Error('Method not implemented.') + } + async deleteToken(tokenId: TokenId): Promise { + throw new Error('Method not implemented.') + } + async findToken(refreshToken: RefreshToken): Promise { + throw new Error('Method not implemented.') + } + async findTokenByCode(code: Code): Promise { + throw new Error('Method not implemented.') + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 42ec4f8fb38..d305109f66c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -787,6 +787,46 @@ importers: specifier: ^5.3.3 version: 5.3.3 + packages/oauth-provider-client-fqdn: + dependencies: + '@atproto/oauth-provider': + specifier: workspace:* + version: link:../oauth-provider + '@atproto/oauth-provider-client-uri': + specifier: workspace:* + version: link:../oauth-provider-client-uri + tslib: + specifier: ^2.6.2 + version: 2.6.2 + + packages/oauth-provider-client-uri: + dependencies: + '@atproto/fetch': + specifier: workspace:* + version: link:../fetch + '@atproto/fetch-node': + specifier: workspace:* + version: link:../fetch-node + '@atproto/jwk': + specifier: workspace:* + version: link:../jwk + '@atproto/oauth-provider': + specifier: workspace:* + version: link:../oauth-provider + '@atproto/transformer': + specifier: workspace:* + version: link:../transformer + psl: + specifier: ^1.9.0 + version: 1.9.0 + tslib: + specifier: ^2.6.2 + version: 2.6.2 + devDependencies: + '@types/psl': + specifier: ^1.1.3 + version: 1.1.3 + packages/ozone: dependencies: '@atproto/api': @@ -904,9 +944,18 @@ importers: '@atproto/identity': specifier: workspace:^ version: link:../identity + '@atproto/jwk-node': + specifier: workspace:^ + version: link:../jwk-node '@atproto/lexicon': specifier: workspace:^ version: link:../lexicon + '@atproto/oauth-provider': + specifier: workspace:^ + version: link:../oauth-provider + '@atproto/oauth-provider-client-fqdn': + specifier: workspace:^ + version: link:../oauth-provider-client-fqdn '@atproto/repo': specifier: workspace:^ version: link:../repo @@ -6527,6 +6576,10 @@ packages: resolution: {integrity: sha512-ga8y9v9uyeiLdpKddhxYQkxNDrfvuPrlFb0N1qnZZByvcElJaXthF1UhvCh9TLWJBEHeNtdnbysW7Y6Uq8CVng==} dev: true + /@types/psl@1.1.3: + resolution: {integrity: sha512-Iu174JHfLd7i/XkXY6VDrqSlPvTDQOtQI7wNAXKKOAADJ9TduRLkNdMgjGiMxSttUIZnomv81JAbAbC0DhggxA==} + dev: true + /@types/qs@6.9.7: resolution: {integrity: sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==} @@ -12053,6 +12106,10 @@ packages: resolution: {integrity: sha512-b/YwNhb8lk1Zz2+bXXpS/LK9OisiZZ1SNsSLxN1x2OXVEhW2Ckr/7mWE5vrC1ZTiJlD9g19jWszTmJsB+oEpFQ==} dev: true + /psl@1.9.0: + resolution: {integrity: sha512-E/ZsdU4HLs/68gYzgGTkMicWTLPdAftJLfJFlLUAAKZGkStNU72sZjT66SnMDVOfOWY/YAoiD7Jxa9iHvngcag==} + dev: false + /pump@3.0.0: resolution: {integrity: sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==} dependencies: