Skip to content

Commit

Permalink
feat(pds): create oauth-manager shell
Browse files Browse the repository at this point in the history
  • Loading branch information
matthieusieben committed Feb 19, 2024
1 parent 0d1b0a8 commit 4432e6b
Show file tree
Hide file tree
Showing 13 changed files with 336 additions and 1 deletion.
3 changes: 3 additions & 0 deletions packages/pds/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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:^",
Expand Down
19 changes: 18 additions & 1 deletion packages/pds/src/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
Expand All @@ -45,6 +47,7 @@ export type AppContextOptions = {
appViewAgent: AtpAgent
moderationAgent: AtpAgent
entrywayAgent?: AtpAgent
oauthManager?: OAuthManager
authVerifier: AuthVerifier
plcRotationKey: crypto.Keypair
cfg: ServerConfig
Expand All @@ -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

Expand All @@ -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
}
Expand Down Expand Up @@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -233,6 +249,7 @@ export class AppContext {
moderationAgent,
entrywayAgent,
authVerifier,
oauthManager,
plcRotationKey,
cfg,
...(overrides ?? {}),
Expand Down
2 changes: 2 additions & 0 deletions packages/pds/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions packages/pds/src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
47 changes: 47 additions & 0 deletions packages/pds/src/oauth-provider/oauth-manager.ts
Original file line number Diff line number Diff line change
@@ -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()
}
}
36 changes: 36 additions & 0 deletions packages/pds/src/oauth-provider/stores/oauth-account-store.ts
Original file line number Diff line number Diff line change
@@ -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<DeviceAccountInfo>,
): Promise<void> {
throw new Error('Method not implemented.')
}
async removeAccount(deviceId: DeviceId, sub: Sub): Promise<void> {
throw new Error('Method not implemented.')
}
}
18 changes: 18 additions & 0 deletions packages/pds/src/oauth-provider/stores/oauth-client-store.ts
Original file line number Diff line number Diff line change
@@ -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)
},
})
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { ReplayStore } from '@atproto/oauth-provider'

export class OAuthReplayStoreMemory implements ReplayStore {
private lastCleanup = Date.now()
private nonces = new Map<string, number>()

async unique(nonce: string, timeframe: number): Promise<boolean> {
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
}
}
}
20 changes: 20 additions & 0 deletions packages/pds/src/oauth-provider/stores/oauth-replay-store-redis.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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
}
}
46 changes: 46 additions & 0 deletions packages/pds/src/oauth-provider/stores/oauth-request-store.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
throw new Error('Method not implemented.')
}
async readRequest(id: RequestId): Promise<RequestData | null> {
throw new Error('Method not implemented.')
}
async updateRequest(
id: RequestId,
data: Partial<RequestData>,
): Promise<void> {
throw new Error('Method not implemented.')
}
async deleteRequest(id: RequestId): Promise<void> {
throw new Error('Method not implemented.')
}
async findRequest(
code: Code,
): Promise<{ id: RequestId; data: RequestData } | null> {
throw new Error('Method not implemented.')
}
}
23 changes: 23 additions & 0 deletions packages/pds/src/oauth-provider/stores/oauth-session-store.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
throw new Error('Method not implemented.')
}
async readSession(deviceId: DeviceId): Promise<SessionData | null> {
throw new Error('Method not implemented.')
}
async updateSession(
deviceId: DeviceId,
data: Partial<SessionData>,
): Promise<void> {
throw new Error('Method not implemented.')
}
}
36 changes: 36 additions & 0 deletions packages/pds/src/oauth-provider/stores/oauth-token-store.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
throw new Error('Method not implemented.')
}
async readToken(tokenId: TokenId): Promise<TokenInfo | null> {
throw new Error('Method not implemented.')
}
async updateToken(
prevTokenId: TokenId,
nextTokenId: TokenId,
data: Partial<TokenData>,
): Promise<void> {
throw new Error('Method not implemented.')
}
async deleteToken(tokenId: TokenId): Promise<void> {
throw new Error('Method not implemented.')
}
async findToken(refreshToken: RefreshToken): Promise<TokenInfo | null> {
throw new Error('Method not implemented.')
}
async findTokenByCode(code: Code): Promise<TokenInfo | null> {
throw new Error('Method not implemented.')
}
}
Loading

0 comments on commit 4432e6b

Please sign in to comment.