import { randomBytes } from "node:crypto"; import { httpLogger } from "@atproto/pds"; export const PREAUTHORIZE_PATH = "/_preauthorize"; export const MAX_ACCOUNT_AGE_MS = 5 * 60 * 1000; const MAX_BODY_BYTES = 8 * 1024; class Refusal extends Error { constructor(status, error, message) { super(message); this.status = status; this.error = error; } } const refuse = (message) => new Refusal(403, "PreauthorizeRefused", message); export const clientsFromEnv = (raw = process.env.TANGLED_PREAUTHORIZE_CLIENTS) => (raw ?? "") .split(",") .map((client) => client.trim()) .filter(Boolean); // everything the route calls. a pds that dropped one leaves the route off and logs it const requiredApi = (pds) => { const provider = pds.ctx?.oauthProvider; return { "app.post": pds.app?.post, "ctx.authVerifier.adminToken": pds.ctx?.authVerifier?.adminToken, "ctx.accountManager.getAccount": pds.ctx?.accountManager?.getAccount, "oauthProvider.clientManager.getClient": provider?.clientManager?.getClient, "oauthProvider.accountManager.getAccount": provider?.accountManager?.getAccount, "oauthProvider.accountManager.setAuthorizedClient": provider?.accountManager?.setAuthorizedClient, "oauthProvider.requestManager.peekClientId": provider?.requestManager?.peekClientId, "oauthProvider.requestManager.get": provider?.requestManager?.get, "oauthProvider.requestManager.setAuthorized": provider?.requestManager?.setAuthorized, "oauthProvider.requestManager.delete": provider?.requestManager?.delete, "oauthProvider.tokenManager.listAccountTokens": provider?.tokenManager?.listAccountTokens }; }; export const missingApi = (pds) => { const missing = Object.entries(requiredApi(pds)) .filter(([, value]) => typeof value !== "function") .map(([name]) => name); if (typeof pds.ctx?.oauthProvider?.issuer !== "string") missing.push("oauthProvider.issuer"); return missing; }; const readJson = async (req) => { let size = 0; const chunks = []; for await (const chunk of req) { size += chunk.length; if (size > MAX_BODY_BYTES) throw new Refusal(413, "PayloadTooLarge", "body too large"); chunks.push(chunk); } try { return JSON.parse(Buffer.concat(chunks).toString("utf8")); } catch { throw new Refusal(400, "InvalidRequest", "body is not json"); } }; export const mountPreauthorize = (pds, clients = clientsFromEnv(), now = Date.now) => { const missing = missingApi(pds); if (missing.length > 0) { httpLogger.error({ missing }, "tangled preauthorize is off, the pds lacks what it needs"); return; } const { authVerifier, accountManager: actors, oauthProvider: provider } = pds.ctx; const authorize = async (req) => { const { did, requestUri } = (await readJson(req)) ?? {}; if (typeof did !== "string" || typeof requestUri !== "string") { throw new Refusal(400, "InvalidRequest", "did and requestUri are required"); } const clientId = await provider.requestManager.peekClientId(requestUri); if (!clientId) throw refuse("unknown request"); if (!clients.includes(clientId)) throw refuse("client is not allowed"); const actor = await actors.getAccount(did); if (!actor) throw refuse("unknown account"); if (now() - Date.parse(actor.createdAt) > MAX_ACCOUNT_AGE_MS) throw refuse("account is not new"); const { account, authorizedClients } = await provider.accountManager.getAccount(did); if (authorizedClients.size > 0) throw refuse("account already authorized a client"); if ((await provider.tokenManager.listAccountTokens(did)).length > 0) { throw refuse("account already has oauth tokens"); } const client = await provider.clientManager.getClient(clientId); const deviceId = `dev-${randomBytes(16).toString("hex")}`; const { parameters } = await provider.requestManager.get(requestUri, deviceId, clientId); const hint = parameters.login_hint; if (hint && hint !== account.did && hint !== account.handle) { await provider.requestManager.delete(requestUri); throw refuse("request is for another account"); } const metadata = { userAgent: req.headers["user-agent"] ?? null, ipAddress: req.ip ?? req.socket.remoteAddress ?? "", lastSeenAt: new Date(now()) }; const code = await provider.requestManager.setAuthorized( requestUri, client, account, deviceId, metadata ); await provider.accountManager.setAuthorizedClient(account, client, { authorizedScopes: parameters.scope?.split(" ") ?? [] }); return { code, state: parameters.state, iss: provider.issuer }; }; pds.app.post(PREAUTHORIZE_PATH, async (req, res) => { try { await authVerifier.adminToken({ req, res }); } catch { return res .status(401) .json({ error: "AuthenticationRequired", message: "admin auth required" }); } try { res.json(await authorize(req)); } catch (err) { if (err instanceof Refusal) return res.status(err.status).json({ error: err.error, message: err.message }); if (err?.status < 500 && typeof err.error === "string") { return res .status(403) .json({ error: "PreauthorizeRefused", message: err.error_description ?? err.error }); } httpLogger.error({ err }, "tangled preauthorize failed"); res.status(500).json({ error: "InternalServerError", message: "Internal Server Error" }); } }); };