From 4f4c36c0e0a9f02ee799befdac4b4afc00c27c53 Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Thu, 12 Mar 2026 10:23:14 -0500 Subject: [PATCH] feat: add PDS write proxy for ATChess game records Adds /api/pds/write endpoint that proxies authenticated xRPC calls to users' PDS via their stored OAuth sessions. Whitelists only createRecord and putRecord for atchess.game and atchess.profile collections. Validates Bearer token and enforces repo matches DID. Co-Authored-By: Claude Opus 4.6 --- gateway/src/index.ts | 5 + gateway/src/routes/pds-proxy.ts | 157 ++++++++++++++++++++++++++++++++ gateway/src/services/oauth.ts | 20 ++++ 3 files changed, 182 insertions(+) create mode 100644 gateway/src/routes/pds-proxy.ts diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 0c8eb19..e4c03e6 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -29,6 +29,7 @@ import { createMFARouter } from './routes/mfa.js'; import { createEmailRouter } from './routes/email.js'; import { createProxyAuthRoutes } from './routes/proxy-auth.js'; import { createUserProfileRoutes } from './routes/user-profile.js'; +import { createPdsProxyRoutes } from './routes/pds-proxy.js'; import { authRateLimit, apiRateLimit, adminRateLimit } from './middleware/rateLimit.js'; import { HttpError } from './utils/errors.js'; import type { WebhookConfig } from './utils/webhook.js'; @@ -296,6 +297,10 @@ async function main(): Promise { app.use('/.well-known', wellKnownRouter); app.use('/oauth', authRateLimit, oauthRouter); console.log('OIDC routes enabled'); + + // PDS write proxy (requires OIDC for token validation) + app.use('/api/pds', apiRateLimit, createPdsProxyRoutes(oauth, oidcService)); + console.log('PDS proxy routes enabled'); } // Passkey routes (if enabled) diff --git a/gateway/src/routes/pds-proxy.ts b/gateway/src/routes/pds-proxy.ts new file mode 100644 index 0000000..0b7cff6 --- /dev/null +++ b/gateway/src/routes/pds-proxy.ts @@ -0,0 +1,157 @@ +/** + * PDS Write Proxy + * + * Allows authenticated client apps to write records to users' PDS + * using ATAuth's stored OAuth sessions. The client authenticates with + * its ATAuth access token; ATAuth restores the user's PDS session + * and proxies the write request. + * + * Supported operations: + * POST /api/pds/write — createRecord or putRecord + */ + +import { Router, Request, Response } from 'express'; +import type { OAuthService } from '../services/oauth.js'; +import type { OIDCService } from '../services/oidc/index.js'; + +// Only allow these xRPC methods through the proxy +const ALLOWED_METHODS = new Set([ + 'com.atproto.repo.createRecord', + 'com.atproto.repo.putRecord', +]); + +// Only allow writing to these collections +const ALLOWED_COLLECTIONS = new Set([ + 'xyz.arcnode.atchess.game', + 'xyz.arcnode.atchess.profile', +]); + +export function createPdsProxyRoutes( + oauthService: OAuthService, + oidcService: OIDCService, +): Router { + const router = Router(); + + /** + * POST /api/pds/write + * + * Body: { + * method: "com.atproto.repo.createRecord" | "com.atproto.repo.putRecord", + * body: { repo, collection, record, rkey? } + * } + * + * Authorization: Bearer + */ + router.post('/write', async (req: Request, res: Response) => { + try { + // 1. Validate ATAuth access token + const authHeader = req.headers.authorization; + if (!authHeader || !authHeader.startsWith('Bearer ')) { + return res.status(401).json({ + error: 'unauthorized', + message: 'Missing Authorization header', + }); + } + + const accessToken = authHeader.slice(7); + const claims = oidcService.tokenService.verifyAccessToken(accessToken); + if (!claims) { + return res.status(401).json({ + error: 'invalid_token', + message: 'Invalid or expired access token', + }); + } + + const did = claims.sub; + + // 2. Validate request body + const { method, body } = req.body; + + if (!method || !body) { + return res.status(400).json({ + error: 'invalid_request', + message: 'Missing method or body', + }); + } + + if (!ALLOWED_METHODS.has(method)) { + return res.status(403).json({ + error: 'method_not_allowed', + message: `Method ${method} is not allowed through the proxy`, + }); + } + + // 3. Validate collection + if (!body.collection || !ALLOWED_COLLECTIONS.has(body.collection)) { + return res.status(403).json({ + error: 'collection_not_allowed', + message: `Collection ${body.collection} is not allowed`, + }); + } + + // 4. Enforce that repo matches the authenticated user's DID + if (body.repo && body.repo !== did) { + return res.status(403).json({ + error: 'repo_mismatch', + message: 'Can only write to your own repository', + }); + } + + // Set repo to the authenticated user's DID + body.repo = did; + + // 5. Check for PDS session + if (!oauthService.hasPdsSession(did)) { + return res.status(409).json({ + error: 'no_pds_session', + message: 'No PDS session found. User may need to re-authenticate.', + }); + } + + // 6. Proxy the request to the user's PDS + console.log(`[PDS Proxy] ${method} for ${did} to ${body.collection}`); + + const pdsResponse = await oauthService.proxyPdsRequest( + did, + `/xrpc/${method}`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }, + ); + + const responseBody = await pdsResponse.text(); + + if (!pdsResponse.ok) { + console.error(`[PDS Proxy] PDS returned ${pdsResponse.status}:`, responseBody); + return res.status(pdsResponse.status).json({ + error: 'pds_error', + message: `PDS returned ${pdsResponse.status}`, + detail: responseBody, + }); + } + + const result = JSON.parse(responseBody); + console.log(`[PDS Proxy] Success: ${result.uri}`); + return res.json(result); + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + console.error('[PDS Proxy] Error:', message); + + if (message.includes('session') || message.includes('expired')) { + return res.status(409).json({ + error: 'session_expired', + message: 'PDS session expired. User needs to re-authenticate.', + }); + } + + return res.status(500).json({ + error: 'proxy_error', + message: 'Failed to proxy request to PDS', + }); + } + }); + + return router; +} diff --git a/gateway/src/services/oauth.ts b/gateway/src/services/oauth.ts index 058a07f..627d560 100644 --- a/gateway/src/services/oauth.ts +++ b/gateway/src/services/oauth.ts @@ -170,6 +170,26 @@ export class OAuthService { return { did, handle }; } + /** + * Restore a PDS session for a user and make an authenticated xRPC call. + * Returns the response from the user's PDS. + */ + async proxyPdsRequest(did: string, pathname: string, init?: RequestInit): Promise { + if (!this.client) { + throw new Error('OAuth client not initialized'); + } + + const session = await this.client.restore(did); + return session.fetchHandler(pathname, init); + } + + /** + * Check if a PDS session exists for a DID. + */ + hasPdsSession(did: string): boolean { + return sessionStore.has(did); + } + private async resolveDidToHandle(did: string): Promise { try { const response = await fetch( -- knotmirror 0.0.1