diff --git a/Makefile b/Makefile index 1f87915..02c5fb6 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,7 @@ # rookery Makefile # Cloudflare Worker — multi-tenant PDS for AI agents on AT Protocol -.PHONY: install test dev deploy build clean typecheck +.PHONY: install test dev deploy build clean typecheck ci install: npm install @@ -21,5 +21,7 @@ build: typecheck: npx tsc --noEmit +ci: typecheck test + clean: rm -rf node_modules/ dist/ .wrangler/ diff --git a/src/oauth/dpop.ts b/src/oauth/dpop.ts index a4380d5..7d04001 100644 --- a/src/oauth/dpop.ts +++ b/src/oauth/dpop.ts @@ -20,7 +20,7 @@ export interface OAuthDpopPayload { htu: string; iat: number; ath?: string; - nonce: string; + nonce?: string; [key: string]: unknown; } @@ -34,6 +34,7 @@ export interface ValidateOauthDpopProofOptions { db: D1Database; nonceSecret: string; now: number; + requireNonce?: boolean; } type ParsedOAuthDpopPayload = { @@ -129,16 +130,18 @@ export async function validateOauthDpopProof( } const dpopPayload = assertDpopPayload(payload); - if (typeof dpopPayload.nonce !== "string" || dpopPayload.nonce.length === 0) { - throw new UseDpopNonceError(await deriveDpopNonce(options.nonceSecret, options.now)); - } - const nonceValid = await isValidDpopNonce( - options.nonceSecret, - dpopPayload.nonce, - options.now, - ); - if (!nonceValid) { - throw new UseDpopNonceError(await deriveDpopNonce(options.nonceSecret, options.now)); + if (options.requireNonce !== false) { + if (typeof dpopPayload.nonce !== "string" || dpopPayload.nonce.length === 0) { + throw new UseDpopNonceError(await deriveDpopNonce(options.nonceSecret, options.now)); + } + const nonceValid = await isValidDpopNonce( + options.nonceSecret, + dpopPayload.nonce, + options.now, + ); + if (!nonceValid) { + throw new UseDpopNonceError(await deriveDpopNonce(options.nonceSecret, options.now)); + } } const key = await importEcVerifyKey(header.jwk); diff --git a/src/oauth/resource-server.ts b/src/oauth/resource-server.ts new file mode 100644 index 0000000..8a7f6f0 --- /dev/null +++ b/src/oauth/resource-server.ts @@ -0,0 +1,92 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 sol pbc + +import { initDirectory, RepoNotFoundError, resolveRepo } from "../directory"; +import { sha256Base64url } from "../auth"; +import type { Env } from "../types"; +import { validateOauthDpopProof } from "./dpop"; +import { getOAuthTokenWithSession, initOAuth } from "./store"; + +const INVALID_OAUTH_TOKEN_MESSAGE = "Invalid OAuth access token"; + +export class InvalidOauthTokenError extends Error { + constructor() { + super(INVALID_OAUTH_TOKEN_MESSAGE); + this.name = "InvalidOauthTokenError"; + } +} + +export async function resolveOAuthAccessToken( + accessToken: string, + dpopJwt: string, + method: string, + url: string, + env: Env, + now: number, +): Promise<{ did: string; doId: string; scope: string }> { + await initOAuth(env.DIRECTORY); + await initDirectory(env.DIRECTORY); + + let proof; + try { + proof = await validateOauthDpopProof(dpopJwt, method, url, accessToken, { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + requireNonce: false, + }); + } catch { + throw new InvalidOauthTokenError(); + } + + const accessTokenHash = await sha256Base64url(accessToken); + const joined = await getOAuthTokenWithSession(env.DIRECTORY, accessTokenHash); + if (!joined) { + throw new InvalidOauthTokenError(); + } + + const { token, sessionExp } = joined; + if (token.exp <= now || sessionExp <= now) { + throw new InvalidOauthTokenError(); + } + if (token.dpopJkt !== proof.thumbprint) { + throw new InvalidOauthTokenError(); + } + + let resolved; + try { + resolved = await resolveRepo(token.did, env); + } catch (err) { + if (err instanceof RepoNotFoundError) { + throw new InvalidOauthTokenError(); + } + throw err; + } + + return { did: token.did, doId: resolved.doId, scope: token.scope }; +} + +export function enforceOAuthScope( + scope: string, + op: "write" | "uploadBlob", + collections: string[], +): string | null { + const tokens = scope.split(/\s+/).filter(Boolean); + const hasTransition = tokens.includes("transition:generic"); + const repoNsids = new Set( + tokens + .filter((token) => token.startsWith("repo:")) + .map((token) => token.slice("repo:".length)), + ); + + if (op === "uploadBlob") { + return hasTransition ? null : "blob"; + } + + for (const collection of collections) { + if (!hasTransition && !repoNsids.has(collection)) { + return collection; + } + } + return null; +} diff --git a/src/oauth/store.ts b/src/oauth/store.ts index db25cb7..12c758a 100644 --- a/src/oauth/store.ts +++ b/src/oauth/store.ts @@ -142,6 +142,10 @@ type OAuthTokenRow = { exp: number; }; +type OAuthTokenWithSessionRow = OAuthTokenRow & { + session_exp: number; +}; + export async function initOAuth(db: D1Database): Promise { await db.batch([ db.prepare(PAR_REQUESTS_SCHEMA), @@ -290,6 +294,16 @@ export async function getOAuthCode( return row ? mapCode(row) : null; } +export async function consumeOAuthCode( + db: D1Database, + codeHash: string, +): Promise { + const row = await db.prepare( + "DELETE FROM oauth_codes WHERE code_hash = ? RETURNING *", + ).bind(codeHash).first(); + return row ? mapCode(row) : null; +} + export async function deleteOAuthCode( db: D1Database, codeHash: string, @@ -345,6 +359,18 @@ export async function getOAuthSessionById( return row ? mapSession(row) : null; } +export async function rotateOAuthSessionRefresh( + db: D1Database, + sessionId: string, + presentedRefreshHash: string, + newRefreshHash: string, +): Promise { + const res = await db.prepare( + "UPDATE oauth_sessions SET refresh_token_hash = ? WHERE session_id = ? AND refresh_token_hash = ?", + ).bind(newRefreshHash, sessionId, presentedRefreshHash).run(); + return res.meta.changes === 1; +} + export async function deleteOAuthSessionByRefreshTokenHash( db: D1Database, refreshTokenHash: string, @@ -399,6 +425,19 @@ export async function getOAuthTokenByAccessTokenHash( return row ? mapToken(row) : null; } +export async function getOAuthTokenWithSession( + db: D1Database, + accessTokenHash: string, +): Promise<{ token: OAuthToken; sessionExp: number } | null> { + const row = await db.prepare( + `SELECT t.*, s.exp AS session_exp + FROM oauth_tokens t + JOIN oauth_sessions s ON t.session_id = s.session_id + WHERE t.access_token_hash = ?`, + ).bind(accessTokenHash).first(); + return row ? { token: mapToken(row), sessionExp: row.session_exp } : null; +} + export async function deleteOAuthTokenByAccessTokenHash( db: D1Database, accessTokenHash: string, diff --git a/src/worker.ts b/src/worker.ts index 2f64d3d..58364f2 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -27,6 +27,8 @@ import { buildProtectedResourceMetadata, OAUTH_AUTHORIZE_PATH, OAUTH_PAR_PATH, + OAUTH_REVOKE_PATH, + OAUTH_TOKEN_PATH, } from "./oauth/metadata"; import { ClientAuthError, @@ -38,14 +40,27 @@ import { import { UseDpopNonceError, validateOauthDpopProof } from "./oauth/dpop"; import { deriveDpopNonce } from "./oauth/nonce"; import { + consumeOAuthCode, consumeOAuthParRequest, deleteOAuthParRequest, + deleteOAuthSessionById, + deleteOAuthTokensBySessionId, getOAuthParRequest, + getOAuthSessionByRefreshTokenHash, + getOAuthTokenByAccessTokenHash, initOAuth, insertOAuthCode, insertOAuthDpopJti, insertOAuthParRequest, + insertOAuthSession, + insertOAuthToken, + rotateOAuthSessionRefresh, } from "./oauth/store"; +import { + enforceOAuthScope, + InvalidOauthTokenError, + resolveOAuthAccessToken, +} from "./oauth/resource-server"; import { base64urlDecode, base64urlEncode, @@ -371,6 +386,14 @@ function stringFormField(value: unknown): string | undefined { return typeof value === "string" ? value : undefined; } +function randomBase64url(bytes = 32): string { + return base64urlEncode(crypto.getRandomValues(new Uint8Array(bytes))); +} + +function randomOauthToken(prefix: "rkat_" | "rkrt_"): string { + return `${prefix}${randomBase64url()}`; +} + function appendOAuthClientDisplay( target: Partial>, source: Record, @@ -402,6 +425,13 @@ async function isValidOAuthScope(scope: string): Promise { let hasRequestedCrawl = false; +type DpopAuthResult = { + did: string; + doId: string; + authKind: "wm" | "oauth"; + scope?: string; +}; + /** Validate DPoP proof and resolve caller's Account DO ID. Throws on failure. */ async function resolveDpopAuth( authHeader: string | undefined, @@ -409,15 +439,21 @@ async function resolveDpopAuth( method: string, url: string, env: Env, -): Promise<{ did: string; doId: string }> { +): Promise { const accessToken = extractBearerToken(authHeader ?? null); if (!accessToken) throw new Error("Missing DPoP authorization"); if (!dpopHeader) throw new Error("Missing DPoP proof"); + if (accessToken.startsWith("rkat_")) { + const now = Math.floor(Date.now() / 1000); + const resolved = await resolveOAuthAccessToken(accessToken, dpopHeader, method, url, env, now); + return { ...resolved, authKind: "oauth" }; + } const { key, thumbprint } = await validateDpopProof(dpopHeader, method, url, accessToken); const serviceOrigin = `https://${env.ROOKERY_HOSTNAME}`; await validateAccessToken(accessToken, key, serviceOrigin, thumbprint, getTosText(env)); await initDirectory(env.DIRECTORY); - return resolveByThumbprint(env.DIRECTORY, thumbprint); + const resolved = await resolveByThumbprint(env.DIRECTORY, thumbprint); + return { ...resolved, authKind: "wm" }; } async function requestCrawl(env: Env): Promise { @@ -650,8 +686,9 @@ app.get(OAUTH_AUTHORIZE_PATH, async (c) => { const deny = c.req.query("deny") === "1"; const dpopHeader = c.req.header("dpop"); let did: string; + let authKind: "wm" | "oauth"; try { - ({ did } = await resolveDpopAuth(authHeader, dpopHeader, "GET", c.req.url, c.env)); + ({ did, authKind } = await resolveDpopAuth(authHeader, dpopHeader, "GET", c.req.url, c.env)); } catch (err) { if (err instanceof RepoNotFoundError) { return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); @@ -663,6 +700,9 @@ app.get(OAUTH_AUTHORIZE_PATH, async (c) => { const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; return c.json({ error: code, message }, 401); } + if (authKind !== "wm") { + return c.json({ error: "AuthFailed", message: "OAuth access tokens cannot grant consent" }, 401); + } const { payload } = parseJwt(dpopHeader ?? ""); const jti = payload.jti; @@ -711,6 +751,239 @@ app.get(OAUTH_AUTHORIZE_PATH, async (c) => { return c.redirect(loc.toString(), 302); }); +app.post(OAUTH_TOKEN_PATH, async (c) => { + const now = Math.floor(Date.now() / 1000); + await initOAuth(c.env.DIRECTORY); + const nonce = await deriveDpopNonce(c.env.OAUTH_NONCE_SECRET ?? "", now); + const issuer = `https://${c.env.ROOKERY_HOSTNAME}`; + + const oauthError = ( + error: string, + status: 400 | 401, + errorDescription?: string, + ) => { + const body: { error: string; error_description?: string } = { error }; + if (errorDescription !== undefined) { + body.error_description = errorDescription; + } + c.header("DPoP-Nonce", nonce); + return c.json(body, status); + }; + const invalidGrant = () => oauthError("invalid_grant", 400); + + const dpopHeader = c.req.header("dpop"); + if (!dpopHeader) { + return oauthError("invalid_request", 400, "DPoP proof required"); + } + + let proof; + try { + proof = await validateOauthDpopProof(dpopHeader, "POST", c.req.url, null, { + db: c.env.DIRECTORY, + nonceSecret: c.env.OAUTH_NONCE_SECRET ?? "", + now, + }); + } catch (err) { + if (err instanceof UseDpopNonceError) { + c.header("DPoP-Nonce", err.nonce); + return c.json({ error: "use_dpop_nonce", error_description: err.message }, 400); + } + return oauthError("invalid_dpop_proof", 400, (err as Error).message); + } + + let form: Awaited>; + try { + form = await c.req.parseBody(); + } catch { + return oauthError("invalid_request", 400, "Invalid form body"); + } + + const grantType = stringFormField(form.grant_type); + if (!grantType) { + return oauthError("invalid_request", 400); + } + if (grantType !== "authorization_code" && grantType !== "refresh_token") { + return oauthError("unsupported_grant_type", 400); + } + + const clientId = stringFormField(form.client_id); + const clientAssertion = stringFormField(form.client_assertion); + const clientAssertionType = stringFormField(form.client_assertion_type); + if (!clientId) { + return oauthError("invalid_request", 400, "client_id required"); + } + + let metadata: Awaited>; + try { + metadata = await fetchClientMetadata(clientId, c.env); + } catch (err) { + if (!(err instanceof ClientMetadataError)) throw err; + return oauthError("invalid_client", 401, "client metadata invalid"); + } + + let authenticatedClientId: string; + try { + authenticatedClientId = (await verifyClientAuth( + metadata, + { clientAssertionType, clientAssertion }, + issuer, + c.env.DIRECTORY, + now, + )).clientId; + } catch (err) { + if (!(err instanceof ClientAuthError)) throw err; + return oauthError("invalid_client", 401, "client authentication failed"); + } + + if (grantType === "authorization_code") { + const code = stringFormField(form.code); + if (!code) { + return invalidGrant(); + } + const codeRow = await consumeOAuthCode(c.env.DIRECTORY, await sha256Base64url(code)); + if (!codeRow) { + return invalidGrant(); + } + + const redirectUri = stringFormField(form.redirect_uri); + const codeVerifier = stringFormField(form.code_verifier); + if ( + codeRow.exp <= now || + codeRow.clientId !== authenticatedClientId || + codeRow.redirectUri !== redirectUri || + codeRow.dpopJkt !== proof.thumbprint || + !codeVerifier || + await sha256Base64url(codeVerifier) !== codeRow.codeChallenge + ) { + return invalidGrant(); + } + + const sessionId = randomBase64url(); + const refreshToken = randomOauthToken("rkrt_"); + const accessToken = randomOauthToken("rkat_"); + const sessionExp = now + 1_209_600; + const accessExp = now + Math.min(900, sessionExp - now); + await insertOAuthSession(c.env.DIRECTORY, { + sessionId, + refreshTokenHash: await sha256Base64url(refreshToken), + clientId: codeRow.clientId, + did: codeRow.did, + scope: codeRow.scope, + dpopJkt: codeRow.dpopJkt, + exp: sessionExp, + }, now); + await insertOAuthToken(c.env.DIRECTORY, { + accessTokenHash: await sha256Base64url(accessToken), + sessionId, + clientId: codeRow.clientId, + did: codeRow.did, + scope: codeRow.scope, + dpopJkt: codeRow.dpopJkt, + exp: accessExp, + }, now); + + c.header("DPoP-Nonce", nonce); + return c.json({ + access_token: accessToken, + token_type: "DPoP", + expires_in: accessExp - now, + refresh_token: refreshToken, + scope: codeRow.scope, + sub: codeRow.did, + }); + } + + const refreshToken = stringFormField(form.refresh_token); + if (!refreshToken) { + return invalidGrant(); + } + const presentedRefreshHash = await sha256Base64url(refreshToken); + const session = await getOAuthSessionByRefreshTokenHash(c.env.DIRECTORY, presentedRefreshHash); + if (!session) { + return invalidGrant(); + } + try { + await resolveRepo(session.did, c.env); + } catch (err) { + if (err instanceof RepoNotFoundError) { + return invalidGrant(); + } + throw err; + } + if ( + session.exp <= now || + session.clientId !== authenticatedClientId || + session.dpopJkt !== proof.thumbprint + ) { + return invalidGrant(); + } + + const newRefreshToken = randomOauthToken("rkrt_"); + const rotated = await rotateOAuthSessionRefresh( + c.env.DIRECTORY, + session.sessionId, + presentedRefreshHash, + await sha256Base64url(newRefreshToken), + ); + if (!rotated) { + await deleteOAuthTokensBySessionId(c.env.DIRECTORY, session.sessionId); + await deleteOAuthSessionById(c.env.DIRECTORY, session.sessionId); + return invalidGrant(); + } + + const accessToken = randomOauthToken("rkat_"); + const accessExp = now + Math.min(900, session.exp - now); + await insertOAuthToken(c.env.DIRECTORY, { + accessTokenHash: await sha256Base64url(accessToken), + sessionId: session.sessionId, + clientId: session.clientId, + did: session.did, + scope: session.scope, + dpopJkt: session.dpopJkt, + exp: accessExp, + }, now); + + c.header("DPoP-Nonce", nonce); + return c.json({ + access_token: accessToken, + token_type: "DPoP", + expires_in: accessExp - now, + refresh_token: newRefreshToken, + scope: session.scope, + sub: session.did, + }); +}); + +app.post(OAUTH_REVOKE_PATH, async (c) => { + await initOAuth(c.env.DIRECTORY); + let form: Awaited>; + try { + form = await c.req.parseBody(); + } catch { + return c.body(null, 200); + } + + const token = stringFormField(form.token); + if (!token) { + return c.body(null, 200); + } + + const tokenHash = await sha256Base64url(token); + const accessToken = await getOAuthTokenByAccessTokenHash(c.env.DIRECTORY, tokenHash); + if (accessToken) { + await deleteOAuthTokensBySessionId(c.env.DIRECTORY, accessToken.sessionId); + await deleteOAuthSessionById(c.env.DIRECTORY, accessToken.sessionId); + return c.body(null, 200); + } + + const session = await getOAuthSessionByRefreshTokenHash(c.env.DIRECTORY, tokenHash); + if (session) { + await deleteOAuthTokensBySessionId(c.env.DIRECTORY, session.sessionId); + await deleteOAuthSessionById(c.env.DIRECTORY, session.sessionId); + } + return c.body(null, 200); +}); + app.get("/tos", (c) => { return c.text(getTosText(c.env), 200, { "content-type": "text/plain; charset=utf-8", @@ -743,8 +1016,9 @@ app.post("/api/invites", async (c) => { let did: string; let doId: string; + let authKind: "wm" | "oauth"; try { - ({ did, doId } = await resolveDpopAuth( + ({ did, doId, authKind } = await resolveDpopAuth( c.req.header("authorization"), c.req.header("dpop"), "POST", @@ -762,6 +1036,9 @@ app.post("/api/invites", async (c) => { const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; return c.json({ error: code, message }, 401); } + if (authKind !== "wm") { + return c.json({ error: "AuthFailed", message: "OAuth access tokens cannot mint invites" }, 401); + } const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); if (!(await stub.rpcHasPublished())) { @@ -1253,38 +1530,37 @@ app.post("/xrpc/com.atproto.repo.uploadBlob", async (c) => { return c.json({ error: "BlobTooLarge", message: "Blob exceeds 60MB limit" }, 400); } - const accessToken = extractBearerToken(c.req.header("authorization") ?? null); - if (!accessToken) { - return c.json({ error: "AuthRequired", message: "Missing DPoP authorization" }, 401); - } - - const dpopJwt = c.req.header("dpop"); - if (!dpopJwt) { - return c.json({ error: "AuthRequired", message: "Missing DPoP proof" }, 401); - } - - let thumbprint: string; + let doId: string; + let authKind: "wm" | "oauth"; + let scope: string | undefined; try { - const result = await validateDpopProof(dpopJwt, "POST", c.req.url, accessToken); - thumbprint = result.thumbprint; - const serviceOrigin = `https://${c.env.ROOKERY_HOSTNAME}`; - await validateAccessToken(accessToken, result.key, serviceOrigin, thumbprint, getTosText(c.env)); + ({ doId, authKind, scope } = await resolveDpopAuth( + c.req.header("authorization"), + c.req.header("dpop"), + "POST", + c.req.url, + c.env, + )); } catch (err) { + if (err instanceof InvalidOauthTokenError) { + return c.json({ error: "InvalidToken", message: (err as Error).message }, 401); + } + if (err instanceof RepoNotFoundError) { + return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); + } const message = (err as Error).message; if (message.includes("tos_hash does not match")) { return c.json({ error: "tos_changed", message: "Terms of service have changed. Re-consent required." }, 401); } - return c.json({ error: "AuthFailed", message }, 401); + const code = message.startsWith("Missing") ? "AuthRequired" : "AuthFailed"; + return c.json({ error: code, message }, 401); } - await initDirectory(c.env.DIRECTORY); - - let doId: string; - try { - const resolved = await resolveByThumbprint(c.env.DIRECTORY, thumbprint); - doId = resolved.doId; - } catch { - return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); + if (authKind === "oauth") { + const denied = enforceOAuthScope(scope!, "uploadBlob", []); + if (denied) { + return c.json({ error: "InsufficientScope", message: `Scope does not permit ${denied}` }, 403); + } } const bytes = new Uint8Array(await c.req.arrayBuffer()); @@ -1302,8 +1578,10 @@ app.post("/xrpc/com.atproto.repo.uploadBlob", async (c) => { app.post("/xrpc/com.atproto.repo.createRecord", async (c) => { let did: string; let doId: string; + let authKind: "wm" | "oauth"; + let scope: string | undefined; try { - ({ did, doId } = await resolveDpopAuth( + ({ did, doId, authKind, scope } = await resolveDpopAuth( c.req.header("authorization"), c.req.header("dpop"), "POST", @@ -1311,6 +1589,9 @@ app.post("/xrpc/com.atproto.repo.createRecord", async (c) => { c.env, )); } catch (err) { + if (err instanceof InvalidOauthTokenError) { + return c.json({ error: "InvalidToken", message: (err as Error).message }, 401); + } if (err instanceof RepoNotFoundError) { return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); } @@ -1340,6 +1621,13 @@ app.post("/xrpc/com.atproto.repo.createRecord", async (c) => { return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); } + if (authKind === "oauth") { + const denied = enforceOAuthScope(scope!, "write", [body.collection]); + if (denied) { + return c.json({ error: "InsufficientScope", message: `Scope does not permit ${denied}` }, 403); + } + } + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); return c.json(await stub.rpcCreateRecord(body.collection, body.rkey, body.record)); }); @@ -1348,8 +1636,10 @@ app.post("/xrpc/com.atproto.repo.createRecord", async (c) => { app.post("/xrpc/com.atproto.repo.putRecord", async (c) => { let did: string; let doId: string; + let authKind: "wm" | "oauth"; + let scope: string | undefined; try { - ({ did, doId } = await resolveDpopAuth( + ({ did, doId, authKind, scope } = await resolveDpopAuth( c.req.header("authorization"), c.req.header("dpop"), "POST", @@ -1357,6 +1647,9 @@ app.post("/xrpc/com.atproto.repo.putRecord", async (c) => { c.env, )); } catch (err) { + if (err instanceof InvalidOauthTokenError) { + return c.json({ error: "InvalidToken", message: (err as Error).message }, 401); + } if (err instanceof RepoNotFoundError) { return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); } @@ -1386,6 +1679,13 @@ app.post("/xrpc/com.atproto.repo.putRecord", async (c) => { return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); } + if (authKind === "oauth") { + const denied = enforceOAuthScope(scope!, "write", [body.collection]); + if (denied) { + return c.json({ error: "InsufficientScope", message: `Scope does not permit ${denied}` }, 403); + } + } + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); return c.json(await stub.rpcPutRecord(body.collection, body.rkey, body.record)); }); @@ -1394,8 +1694,10 @@ app.post("/xrpc/com.atproto.repo.putRecord", async (c) => { app.post("/xrpc/com.atproto.repo.deleteRecord", async (c) => { let did: string; let doId: string; + let authKind: "wm" | "oauth"; + let scope: string | undefined; try { - ({ did, doId } = await resolveDpopAuth( + ({ did, doId, authKind, scope } = await resolveDpopAuth( c.req.header("authorization"), c.req.header("dpop"), "POST", @@ -1403,6 +1705,9 @@ app.post("/xrpc/com.atproto.repo.deleteRecord", async (c) => { c.env, )); } catch (err) { + if (err instanceof InvalidOauthTokenError) { + return c.json({ error: "InvalidToken", message: (err as Error).message }, 401); + } if (err instanceof RepoNotFoundError) { return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); } @@ -1432,6 +1737,13 @@ app.post("/xrpc/com.atproto.repo.deleteRecord", async (c) => { return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); } + if (authKind === "oauth") { + const denied = enforceOAuthScope(scope!, "write", [body.collection]); + if (denied) { + return c.json({ error: "InsufficientScope", message: `Scope does not permit ${denied}` }, 403); + } + } + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); return c.json(await stub.rpcDeleteRecord(body.collection, body.rkey)); }); @@ -1440,8 +1752,10 @@ app.post("/xrpc/com.atproto.repo.deleteRecord", async (c) => { app.post("/xrpc/com.atproto.repo.applyWrites", async (c) => { let did: string; let doId: string; + let authKind: "wm" | "oauth"; + let scope: string | undefined; try { - ({ did, doId } = await resolveDpopAuth( + ({ did, doId, authKind, scope } = await resolveDpopAuth( c.req.header("authorization"), c.req.header("dpop"), "POST", @@ -1449,6 +1763,9 @@ app.post("/xrpc/com.atproto.repo.applyWrites", async (c) => { c.env, )); } catch (err) { + if (err instanceof InvalidOauthTokenError) { + return c.json({ error: "InvalidToken", message: (err as Error).message }, 401); + } if (err instanceof RepoNotFoundError) { return c.json({ error: "AccountNotFound", message: "No account for this key" }, 401); } @@ -1478,6 +1795,13 @@ app.post("/xrpc/com.atproto.repo.applyWrites", async (c) => { return c.json({ error: "InvalidRequest", message: "Repo DID does not match authenticated account" }, 400); } + if (authKind === "oauth") { + const denied = enforceOAuthScope(scope!, "write", body.writes.map((write) => write.collection)); + if (denied) { + return c.json({ error: "InsufficientScope", message: `Scope does not permit ${denied}` }, 403); + } + } + const stub = c.env.ACCOUNT.get(c.env.ACCOUNT.idFromString(doId)); return c.json(await stub.rpcApplyWrites(body.writes)); }); diff --git a/test/helpers.ts b/test/helpers.ts index 1874436..428b989 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -91,6 +91,11 @@ export async function generateEcKeys(): Promise<{ return { ecKeys, publicJwk, thumbprint }; } +export async function createPkcePair(): Promise<{ verifier: string; challenge: string }> { + const verifier = base64urlEncode(crypto.getRandomValues(new Uint8Array(32))); + return { verifier, challenge: await sha256Base64url(verifier) }; +} + export async function createOauthDpopJwt( ecKeys: CryptoKeyPair, publicJwk: JsonWebKey, diff --git a/test/integration.test.ts b/test/integration.test.ts index d2e0668..1a2dc26 100644 --- a/test/integration.test.ts +++ b/test/integration.test.ts @@ -3,6 +3,7 @@ import { buildAccessToken, createDpopJwt, createOauthDpopJwt, + createPkcePair, env, generateAuthKeys, generateEcKeys, @@ -735,13 +736,18 @@ describe("Integration", () => { describe("OAuth authorize/PAR", () => { const PAR_URL = "http://localhost/oauth/par"; const AUTHORIZE_URL = "http://localhost/oauth/authorize"; + const TOKEN_URL = "http://localhost/oauth/token"; + const REVOKE_URL = "http://localhost/oauth/revoke"; const CLIENT_ASSERTION_TYPE = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"; type ParSuccess = { requestUri: string; + ecKeys: CryptoKeyPair; + publicJwk: JsonWebKey; ecThumbprint: string; clientId: string; redirectUri: string; + codeVerifier: string; state?: string; }; @@ -752,6 +758,15 @@ describe("Integration", () => { did: string; }; + type TokenSuccess = { + access_token: string; + token_type: "DPoP"; + expires_in: number; + refresh_token: string; + scope: string; + sub: string; + }; + beforeAll(async () => { await initOAuth(env.DIRECTORY); }); @@ -801,14 +816,15 @@ describe("Integration", () => { }; } - function buildParForm( + async function buildParForm( clientId: string, overrides: Record = {}, - ): Record { + ): Promise<{ formValues: Record; codeVerifier: string }> { + const pkce = await createPkcePair(); const values: Record = { client_id: clientId, response_type: "code", - code_challenge: `challenge-${crypto.randomUUID()}`, + code_challenge: pkce.challenge, code_challenge_method: "S256", redirect_uri: "http://127.0.0.1/callback", scope: "atproto", @@ -821,12 +837,17 @@ describe("Integration", () => { values[key] = value; } } - return values; + return { formValues: values, codeVerifier: pkce.verifier }; } async function postPar( formValues: Record, - ): Promise<{ response: Response; ecThumbprint: string }> { + ): Promise<{ + response: Response; + ecKeys: CryptoKeyPair; + publicJwk: JsonWebKey; + ecThumbprint: string; + }> { const { ecKeys, publicJwk, thumbprint } = await generateEcKeys(); const missingNonceDpop = await createOauthDpopJwt( ecKeys, @@ -864,7 +885,7 @@ describe("Integration", () => { }), ); expect(response.headers.get("DPoP-Nonce")).toBeTruthy(); - return { response, ecThumbprint: thumbprint }; + return { response, ecKeys, publicJwk, ecThumbprint: thumbprint }; } async function doPar(opts: { @@ -874,20 +895,23 @@ describe("Integration", () => { } = {}): Promise { const clientId = opts.clientId ?? `https://client.example/${uniqueId("client")}.json`; const redirectUri = opts.redirectUri ?? "http://127.0.0.1/callback"; - const formValues = buildParForm(clientId, { + const { formValues, codeVerifier } = await buildParForm(clientId, { redirect_uri: redirectUri, ...opts.formOverrides, }); - const { response, ecThumbprint } = await postPar(formValues); + const { response, ecKeys, publicJwk, ecThumbprint } = await postPar(formValues); expect(response.status).toBe(201); const body = await response.json() as { request_uri: string; expires_in: number }; expect(body.request_uri).toMatch(/^urn:ietf:params:oauth:request_uri:/); expect(body.expires_in).toBe(300); return { requestUri: body.request_uri, + ecKeys, + publicJwk, ecThumbprint, clientId, redirectUri, + codeVerifier, state: formValues.state, }; } @@ -959,6 +983,210 @@ describe("Integration", () => { ).bind(redirectUri).first<{ code_hash: string }>(); } + async function authorizeCode(par: ParSuccess, account: ConsentAccount): Promise { + const response = await worker.fetch( + new Request(authorizeUrl(par), { + headers: await consentHeaders(account), + redirect: "manual", + }), + ); + expect(response.status).toBe(302); + const loc = new URL(response.headers.get("location")!); + const code = loc.searchParams.get("code"); + expect(code).toBeTruthy(); + return code!; + } + + async function postToken( + formValues: Record, + ecKeys: CryptoKeyPair, + publicJwk: JsonWebKey, + ): Promise { + const missingNonceDpop = await createOauthDpopJwt( + ecKeys, + publicJwk, + "POST", + TOKEN_URL, + null, + ); + const missingNonceResponse = await worker.fetch( + new Request(TOKEN_URL, { + method: "POST", + headers: { + dpop: missingNonceDpop, + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(formValues), + }), + ); + expect(missingNonceResponse.status).toBe(400); + const nonce = missingNonceResponse.headers.get("DPoP-Nonce"); + expect(nonce).toBeTruthy(); + await expect(missingNonceResponse.json()).resolves.toMatchObject({ + error: "use_dpop_nonce", + }); + + const dpop = await createOauthDpopJwt(ecKeys, publicJwk, "POST", TOKEN_URL, null, nonce!); + const response = await worker.fetch( + new Request(TOKEN_URL, { + method: "POST", + headers: { + dpop, + "content-type": "application/x-www-form-urlencoded", + }, + body: new URLSearchParams(formValues), + }), + ); + expect(response.headers.get("DPoP-Nonce")).toBeTruthy(); + return response; + } + + function codeTokenForm( + par: ParSuccess, + code: string, + overrides: Record = {}, + ): Record { + const values: Record = { + grant_type: "authorization_code", + client_id: par.clientId, + code, + redirect_uri: par.redirectUri, + code_verifier: par.codeVerifier, + }; + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) { + delete values[key]; + } else { + values[key] = value; + } + } + return values; + } + + async function exchangeCode( + par: ParSuccess, + code: string, + overrides: Record = {}, + ): Promise { + return postToken(codeTokenForm(par, code, overrides), par.ecKeys, par.publicJwk); + } + + async function refreshToken( + par: ParSuccess, + refresh: string, + overrides: Record = {}, + ): Promise { + const values: Record = { + grant_type: "refresh_token", + client_id: par.clientId, + refresh_token: refresh, + }; + for (const [key, value] of Object.entries(overrides)) { + if (value === undefined) { + delete values[key]; + } else { + values[key] = value; + } + } + return postToken(values, par.ecKeys, par.publicJwk); + } + + async function expectTokenSuccess(response: Response): Promise { + expect(response.status).toBe(200); + const body = await response.json() as TokenSuccess; + expect(Object.keys(body).sort()).toEqual([ + "access_token", + "expires_in", + "refresh_token", + "scope", + "sub", + "token_type", + ]); + expect(body.access_token.startsWith("rkat_")).toBe(true); + expect(body.refresh_token.startsWith("rkrt_")).toBe(true); + expect(body.token_type).toBe("DPoP"); + return body; + } + + async function oauthHeaders( + par: Pick, + url: string, + accessToken: string, + ): Promise<{ authorization: string; dpop: string }> { + return { + authorization: `DPoP ${accessToken}`, + dpop: await createOauthDpopJwt(par.ecKeys, par.publicJwk, "POST", url, accessToken), + }; + } + + async function issueOauthToken( + scope = "atproto repo:com.example.test", + ): Promise<{ + account: ConsentAccount; + par: ParSuccess; + token: TokenSuccess; + fetchSpy: ReturnType; + }> { + const account = await createConsentAccount("oauth-token"); + const clientId = `https://client.example/${uniqueId("token")}.json`; + const redirectUri = `http://127.0.0.1/callback?case=${uniqueId("token")}`; + const fetchSpy = stubOauthFetch({ + [clientId]: parClientMetadata(clientId, { redirect_uris: [redirectUri] }), + }); + const par = await doPar({ clientId, redirectUri, formOverrides: { scope } }); + const code = await authorizeCode(par, account); + const token = await expectTokenSuccess(await exchangeCode(par, code)); + return { account, par, token, fetchSpy }; + } + + async function createRecordWithOauth( + par: Pick, + token: TokenSuccess, + collection: string, + rkey: string, + dpopOverride?: string, + ): Promise { + const url = "http://localhost/xrpc/com.atproto.repo.createRecord"; + const headers = await oauthHeaders(par, url, token.access_token); + if (dpopOverride !== undefined) { + headers.dpop = dpopOverride; + } + return worker.fetch( + new Request(url, { + method: "POST", + headers: { + ...headers, + "content-type": "application/json", + }, + body: JSON.stringify({ + repo: token.sub, + collection, + rkey, + record: { text: rkey, createdAt: new Date().toISOString() }, + }), + }), + ); + } + + async function uploadBlobWithOauth( + par: Pick, + accessToken: string, + body: Uint8Array, + ): Promise { + const url = "http://localhost/xrpc/com.atproto.repo.uploadBlob"; + return worker.fetch( + new Request(url, { + method: "POST", + headers: { + ...await oauthHeaders(par, url, accessToken), + "content-type": "text/plain", + "content-length": String(body.byteLength), + }, + body, + }), + ); + } + it("runs the happy path end-to-end", async () => { const account = await createConsentAccount("oauth-happy"); const clientId = `https://client.example/${uniqueId("happy")}.json`; @@ -1121,14 +1349,391 @@ describe("Integration", () => { } }); + describe("token, revoke, and resource-server access", () => { + it("exchanges a code, stores only hashes, and writes with the OAuth access token", async () => { + const issued = await issueOauthToken("atproto repo:com.example.test"); + try { + expect(issued.token.scope).toBe("atproto repo:com.example.test"); + expect(issued.token.sub).toBe(issued.account.did); + expect(issued.token.expires_in).toBe(900); + await expect( + env.DIRECTORY.prepare( + "SELECT 1 FROM oauth_tokens WHERE access_token_hash = ?", + ).bind(issued.token.access_token).first(), + ).resolves.toBeNull(); + await expect( + env.DIRECTORY.prepare( + "SELECT 1 FROM oauth_sessions WHERE refresh_token_hash = ?", + ).bind(issued.token.refresh_token).first(), + ).resolves.toBeNull(); + await expect( + env.DIRECTORY.prepare( + "SELECT 1 FROM oauth_tokens WHERE access_token_hash = ?", + ).bind(await sha256Base64url(issued.token.access_token)).first(), + ).resolves.toBeTruthy(); + await expect( + env.DIRECTORY.prepare( + "SELECT 1 FROM oauth_sessions WHERE refresh_token_hash = ?", + ).bind(await sha256Base64url(issued.token.refresh_token)).first(), + ).resolves.toBeTruthy(); + + const write = await createRecordWithOauth( + issued.par, + issued.token, + "com.example.test", + uniqueId("oauth-write"), + ); + expect(write.status).toBe(200); + } finally { + issued.fetchSpy.mockRestore(); + } + }); + + it("consumes codes on PKCE failure and enforces code bindings", async () => { + const account = await createConsentAccount("oauth-bindings"); + const clientId = `https://client.example/${uniqueId("bindings")}.json`; + const otherClientId = `https://client.example/${uniqueId("other-bindings")}.json`; + const redirectUri = `http://127.0.0.1/callback?case=${uniqueId("bindings")}`; + const fetchSpy = stubOauthFetch({ + [clientId]: parClientMetadata(clientId, { redirect_uris: [redirectUri] }), + [otherClientId]: parClientMetadata(otherClientId, { redirect_uris: [redirectUri] }), + }); + try { + let par = await doPar({ clientId, redirectUri }); + let code = await authorizeCode(par, account); + let response = await exchangeCode(par, code, { code_verifier: "wrong-verifier" }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_grant" }); + response = await exchangeCode(par, code); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_grant" }); + + par = await doPar({ clientId, redirectUri }); + code = await authorizeCode(par, account); + await expectTokenSuccess(await exchangeCode(par, code)); + response = await exchangeCode(par, code); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_grant" }); + + par = await doPar({ clientId, redirectUri }); + code = await authorizeCode(par, account); + response = await exchangeCode(par, code, { client_id: otherClientId }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_grant" }); + + par = await doPar({ clientId, redirectUri }); + code = await authorizeCode(par, account); + response = await exchangeCode(par, code, { redirect_uri: "http://127.0.0.1/callback?case=wrong" }); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_grant" }); + + par = await doPar({ clientId, redirectUri }); + code = await authorizeCode(par, account); + const otherKeys = await generateEcKeys(); + response = await postToken(codeTokenForm(par, code), otherKeys.ecKeys, otherKeys.publicJwk); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_grant" }); + } finally { + fetchSpy.mockRestore(); + } + }); + + it("rotates refresh tokens, keeps fixed session expiry, and enforces refresh jkt binding", async () => { + const issued = await issueOauthToken("atproto repo:com.example.test"); + try { + const otherKeys = await generateEcKeys(); + let response = await postToken( + { + grant_type: "refresh_token", + client_id: issued.par.clientId, + refresh_token: issued.token.refresh_token, + }, + otherKeys.ecKeys, + otherKeys.publicJwk, + ); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_grant" }); + + const sessionExp = Math.floor(Date.now() / 1000) + 120; + await env.DIRECTORY.prepare( + "UPDATE oauth_sessions SET exp = ? WHERE refresh_token_hash = ?", + ).bind(sessionExp, await sha256Base64url(issued.token.refresh_token)).run(); + const before = Math.floor(Date.now() / 1000); + const refreshed = await expectTokenSuccess(await refreshToken(issued.par, issued.token.refresh_token)); + const after = Math.floor(Date.now() / 1000); + expect(refreshed.expires_in).toBeLessThanOrEqual(sessionExp - before); + expect(refreshed.expires_in).toBeGreaterThanOrEqual(sessionExp - after); + + response = await refreshToken(issued.par, issued.token.refresh_token); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_grant" }); + + const refreshedAgain = await expectTokenSuccess(await refreshToken(issued.par, refreshed.refresh_token)); + expect(refreshedAgain.refresh_token).not.toBe(refreshed.refresh_token); + } finally { + issued.fetchSpy.mockRestore(); + } + }); + + it("rejects resource access when the owning session is expired", async () => { + const issued = await issueOauthToken("atproto repo:com.example.test"); + try { + await env.DIRECTORY.prepare( + "UPDATE oauth_sessions SET exp = 0 WHERE refresh_token_hash = ?", + ).bind(await sha256Base64url(issued.token.refresh_token)).run(); + const tokenRow = await env.DIRECTORY.prepare( + "SELECT exp FROM oauth_tokens WHERE access_token_hash = ?", + ).bind(await sha256Base64url(issued.token.access_token)).first<{ exp: number }>(); + expect(tokenRow!.exp).toBeGreaterThan(Math.floor(Date.now() / 1000)); + const response = await createRecordWithOauth( + issued.par, + issued.token, + "com.example.test", + uniqueId("expired-session"), + ); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: "InvalidToken" }); + } finally { + issued.fetchSpy.mockRestore(); + } + }); + + it("revokes access tokens, refresh tokens, and ignores garbage", async () => { + let issued = await issueOauthToken("atproto repo:com.example.test"); + try { + const accessRevoke = await worker.fetch( + new Request(REVOKE_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: issued.token.access_token }), + }), + ); + expect(accessRevoke.status).toBe(200); + let response = await createRecordWithOauth( + issued.par, + issued.token, + "com.example.test", + uniqueId("revoked-access"), + ); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: "InvalidToken" }); + response = await refreshToken(issued.par, issued.token.refresh_token); + expect(response.status).toBe(400); + await expect(response.json()).resolves.toMatchObject({ error: "invalid_grant" }); + } finally { + issued.fetchSpy.mockRestore(); + } + + issued = await issueOauthToken("atproto repo:com.example.test"); + try { + const refreshRevoke = await worker.fetch( + new Request(REVOKE_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: issued.token.refresh_token }), + }), + ); + expect(refreshRevoke.status).toBe(200); + const response = await createRecordWithOauth( + issued.par, + issued.token, + "com.example.test", + uniqueId("revoked-refresh"), + ); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: "InvalidToken" }); + } finally { + issued.fetchSpy.mockRestore(); + } + + const garbage = await worker.fetch( + new Request(REVOKE_URL, { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: new URLSearchParams({ token: "garbage" }), + }), + ); + expect(garbage.status).toBe(200); + }); + + it("rejects forged resource proofs and replayed resource proof jtis", async () => { + const issued = await issueOauthToken("atproto repo:com.example.test"); + try { + const url = "http://localhost/xrpc/com.atproto.repo.createRecord"; + const dpop = await createOauthDpopJwt( + issued.par.ecKeys, + issued.par.publicJwk, + "POST", + url, + issued.token.access_token, + ); + const parts = dpop.split("."); + parts[2] = `${parts[2][0] === "A" ? "B" : "A"}${parts[2].slice(1)}`; + let response = await createRecordWithOauth( + issued.par, + issued.token, + "com.example.test", + uniqueId("forged"), + parts.join("."), + ); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: "InvalidToken" }); + + const replayDpop = await createOauthDpopJwt( + issued.par.ecKeys, + issued.par.publicJwk, + "POST", + url, + issued.token.access_token, + ); + response = await createRecordWithOauth( + issued.par, + issued.token, + "com.example.test", + uniqueId("replay-one"), + replayDpop, + ); + expect(response.status).toBe(200); + response = await createRecordWithOauth( + issued.par, + issued.token, + "com.example.test", + uniqueId("replay-two"), + replayDpop, + ); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: "InvalidToken" }); + } finally { + issued.fetchSpy.mockRestore(); + } + }); + + it("enforces OAuth scopes for writes and blob uploads", async () => { + let issued = await issueOauthToken("atproto repo:com.example.case"); + try { + let response = await createRecordWithOauth( + issued.par, + issued.token, + "com.example.Case", + uniqueId("case-mismatch"), + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "InsufficientScope" }); + } finally { + issued.fetchSpy.mockRestore(); + } + + issued = await issueOauthToken("atproto repo:com.example.allowed"); + try { + const url = "http://localhost/xrpc/com.atproto.repo.applyWrites"; + const response = await worker.fetch( + new Request(url, { + method: "POST", + headers: { + ...await oauthHeaders(issued.par, url, issued.token.access_token), + "content-type": "application/json", + }, + body: JSON.stringify({ + repo: issued.token.sub, + writes: [ + { + $type: "com.atproto.repo.applyWrites#create", + collection: "com.example.allowed", + rkey: "allowed-create", + record: { text: "allowed", createdAt: new Date().toISOString() }, + }, + { + $type: "com.atproto.repo.applyWrites#delete", + collection: "com.example.denied", + rkey: "denied-delete", + }, + ], + }), + }), + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "InsufficientScope" }); + const get = await worker.fetch( + `http://localhost/xrpc/com.atproto.repo.getRecord?repo=${encodeURIComponent(issued.token.sub)}&collection=com.example.allowed&rkey=allowed-create`, + ); + expect(get.status).toBe(404); + } finally { + issued.fetchSpy.mockRestore(); + } + + issued = await issueOauthToken("atproto repo:com.example.test"); + try { + const response = await uploadBlobWithOauth( + issued.par, + issued.token.access_token, + new TextEncoder().encode("repo only blob"), + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "InsufficientScope" }); + } finally { + issued.fetchSpy.mockRestore(); + } + + issued = await issueOauthToken("atproto transition:generic"); + try { + const response = await uploadBlobWithOauth( + issued.par, + issued.token.access_token, + new TextEncoder().encode("transition blob"), + ); + expect(response.status).toBe(200); + } finally { + issued.fetchSpy.mockRestore(); + } + + issued = await issueOauthToken("atproto"); + try { + const response = await createRecordWithOauth( + issued.par, + issued.token, + "com.example.test", + uniqueId("atproto-only"), + ); + expect(response.status).toBe(403); + await expect(response.json()).resolves.toMatchObject({ error: "InsufficientScope" }); + } finally { + issued.fetchSpy.mockRestore(); + } + }); + + it("rejects deactivated accounts for resource access and refresh", async () => { + const issued = await issueOauthToken("atproto repo:com.example.test"); + try { + await env.DIRECTORY.prepare( + "UPDATE accounts SET active = 0 WHERE did = ?", + ).bind(issued.token.sub).run(); + const response = await createRecordWithOauth( + issued.par, + issued.token, + "com.example.test", + uniqueId("deactivated-rs"), + ); + expect(response.status).toBe(401); + await expect(response.json()).resolves.toMatchObject({ error: "InvalidToken" }); + + const refresh = await refreshToken(issued.par, issued.token.refresh_token); + expect(refresh.status).toBe(400); + await expect(refresh.json()).resolves.toMatchObject({ error: "invalid_grant" }); + } finally { + issued.fetchSpy.mockRestore(); + } + }); + }); + describe("PAR validation", () => { it("rejects unregistered redirect_uri", async () => { const clientId = `https://client.example/${uniqueId("bad-redirect")}.json`; const fetchSpy = stubOauthFetch({ [clientId]: parClientMetadata(clientId) }); try { - const { response } = await postPar(buildParForm(clientId, { + const { formValues } = await buildParForm(clientId, { redirect_uri: "http://127.0.0.1/other", - })); + }); + const { response } = await postPar(formValues); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: "invalid_request" }); } finally { @@ -1140,9 +1745,10 @@ describe("Integration", () => { const clientId = `https://client.example/${uniqueId("plain")}.json`; const fetchSpy = stubOauthFetch({ [clientId]: parClientMetadata(clientId) }); try { - const { response } = await postPar(buildParForm(clientId, { + const { formValues } = await buildParForm(clientId, { code_challenge_method: "plain", - })); + }); + const { response } = await postPar(formValues); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: "invalid_request" }); } finally { @@ -1154,9 +1760,10 @@ describe("Integration", () => { const clientId = `https://client.example/${uniqueId("scope-missing")}.json`; const fetchSpy = stubOauthFetch({ [clientId]: parClientMetadata(clientId) }); try { - const { response } = await postPar(buildParForm(clientId, { + const { formValues } = await buildParForm(clientId, { scope: "transition:generic", - })); + }); + const { response } = await postPar(formValues); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: "invalid_scope" }); } finally { @@ -1168,9 +1775,10 @@ describe("Integration", () => { const clientId = `https://client.example/${uniqueId("include")}.json`; const fetchSpy = stubOauthFetch({ [clientId]: parClientMetadata(clientId) }); try { - const { response } = await postPar(buildParForm(clientId, { + const { formValues } = await buildParForm(clientId, { scope: "atproto include:app.bsky", - })); + }); + const { response } = await postPar(formValues); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: "invalid_scope" }); } finally { @@ -1182,9 +1790,10 @@ describe("Integration", () => { const clientId = `https://client.example/${uniqueId("bad-nsid")}.json`; const fetchSpy = stubOauthFetch({ [clientId]: parClientMetadata(clientId) }); try { - const { response } = await postPar(buildParForm(clientId, { + const { formValues } = await buildParForm(clientId, { scope: "atproto repo:not a nsid", - })); + }); + const { response } = await postPar(formValues); expect(response.status).toBe(400); await expect(response.json()).resolves.toMatchObject({ error: "invalid_scope" }); } finally { @@ -1212,7 +1821,8 @@ describe("Integration", () => { [absentClient]: parClientMetadata(absentClient), }); try { - const fragment = await postPar(buildParForm(fragmentClient, { response_mode: "fragment" })); + const { formValues: fragmentForm } = await buildParForm(fragmentClient, { response_mode: "fragment" }); + const fragment = await postPar(fragmentForm); expect(fragment.response.status).toBe(400); await expect(fragment.response.json()).resolves.toMatchObject({ error: "invalid_request" }); @@ -1309,10 +1919,11 @@ describe("Integration", () => { }, ecKeys.privateKey, ); - const { response } = await postPar(buildParForm(clientId, { + const { formValues } = await buildParForm(clientId, { client_assertion_type: CLIENT_ASSERTION_TYPE, client_assertion: assertion, - })); + }); + const { response } = await postPar(formValues); expect(response.status).toBe(401); await expect(response.json()).resolves.toMatchObject({ error: "invalid_client" }); } finally { @@ -1326,7 +1937,8 @@ describe("Integration", () => { [clientId]: parClientMetadata("https://other.example/client.json"), }); try { - const { response } = await postPar(buildParForm(clientId)); + const { formValues } = await buildParForm(clientId); + const { response } = await postPar(formValues); expect(response.status).toBe(401); await expect(response.json()).resolves.toMatchObject({ error: "invalid_client" }); } finally { diff --git a/test/oauth-dpop.test.ts b/test/oauth-dpop.test.ts index 0b73709..b903c46 100644 --- a/test/oauth-dpop.test.ts +++ b/test/oauth-dpop.test.ts @@ -174,6 +174,57 @@ describe("OAuth DPoP proof validation", () => { ).rejects.toBeInstanceOf(UseDpopNonceError); }); + it("accepts absent nonce when nonce is not required and still checks ath", async () => { + const accessToken = "resource-access-token"; + const { ecKeys, publicJwk } = await generateEcKeys(); + const now = Math.floor(Date.now() / 1000); + const htu = "https://server.example/xrpc/com.atproto.repo.createRecord"; + const jwt = await createOauthDpopJwt(ecKeys, publicJwk, "POST", htu, accessToken); + + await expect( + validateOauthDpopProof(jwt, "POST", htu, accessToken, { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + requireNonce: false, + }), + ).resolves.toBeTruthy(); + }); + + it("rejects wrong ath even when nonce is not required", async () => { + const { ecKeys, publicJwk } = await generateEcKeys(); + const now = Math.floor(Date.now() / 1000); + const htu = "https://server.example/xrpc/com.atproto.repo.createRecord"; + const jwt = await createOauthDpopJwt(ecKeys, publicJwk, "POST", htu, "resource-access-token"); + + await expect( + validateOauthDpopProof(jwt, "POST", htu, "other-access-token", { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + requireNonce: false, + }), + ).rejects.toThrow("ath does not match"); + }); + + it("rejects replayed jti even when nonce is not required", async () => { + const { ecKeys, publicJwk } = await generateEcKeys(); + const now = Math.floor(Date.now() / 1000); + const htu = "https://server.example/xrpc/com.atproto.repo.createRecord"; + const jwt = await createOauthDpopJwt(ecKeys, publicJwk, "POST", htu, "resource-access-token"); + const options = { + db: env.DIRECTORY, + nonceSecret: env.OAUTH_NONCE_SECRET ?? "", + now, + requireNonce: false, + }; + + await expect(validateOauthDpopProof(jwt, "POST", htu, "resource-access-token", options)).resolves.toBeTruthy(); + await expect(validateOauthDpopProof(jwt, "POST", htu, "resource-access-token", options)).rejects.toThrow( + "replayed jti", + ); + }); + it("throws UseDpopNonceError for fabricated nonce", async () => { const { now, ecKeys, publicJwk, htu } = await buildValidProof(); const jwt = await createOauthDpopJwt(ecKeys, publicJwk, "POST", htu, null, "fabricated"); diff --git a/test/oauth-store.test.ts b/test/oauth-store.test.ts index dbbe362..bd88151 100644 --- a/test/oauth-store.test.ts +++ b/test/oauth-store.test.ts @@ -1,15 +1,18 @@ import { env } from "./helpers"; import { + consumeOAuthCode, getOAuthCode, getOAuthParRequest, getOAuthSessionByRefreshTokenHash, getOAuthTokenByAccessTokenHash, + getOAuthTokenWithSession, initOAuth, insertOAuthCode, insertOAuthDpopJti, insertOAuthParRequest, insertOAuthSession, insertOAuthToken, + rotateOAuthSessionRefresh, } from "../src/oauth/store"; describe("oauth store", () => { @@ -114,4 +117,90 @@ describe("oauth store", () => { await expect(insertOAuthDpopJti(env.DIRECTORY, jtiHash, now + 300, now)).resolves.toBe(true); await expect(insertOAuthDpopJti(env.DIRECTORY, jtiHash, now + 300, now)).resolves.toBe(false); }); + + it("consumes OAuth codes atomically", async () => { + const suffix = crypto.randomUUID(); + const now = 1_700_000_000; + const code = { + codeHash: `consume-code-${suffix}`, + clientId: `https://client.example/${suffix}`, + redirectUri: `https://client.example/cb/${suffix}`, + codeChallenge: `challenge-${suffix}`, + scope: "atproto", + did: `did:plc:${suffix.replace(/-/g, "")}`, + dpopJkt: `jkt-${suffix}`, + exp: now + 120, + }; + await insertOAuthCode(env.DIRECTORY, code, now); + + await expect(consumeOAuthCode(env.DIRECTORY, code.codeHash)).resolves.toEqual(code); + await expect(getOAuthCode(env.DIRECTORY, code.codeHash)).resolves.toBeNull(); + await expect(consumeOAuthCode(env.DIRECTORY, code.codeHash)).resolves.toBeNull(); + }); + + it("rotates refresh token hashes with a guarded update", async () => { + const suffix = crypto.randomUUID(); + const now = 1_700_000_000; + const session = { + sessionId: `session-rotate-${suffix}`, + refreshTokenHash: `refresh-old-${suffix}`, + clientId: "https://client.example", + did: "did:plc:rotate", + scope: "atproto", + dpopJkt: "jkt", + exp: now + 3600, + }; + await insertOAuthSession(env.DIRECTORY, session, now); + + await expect( + rotateOAuthSessionRefresh( + env.DIRECTORY, + session.sessionId, + session.refreshTokenHash, + `refresh-new-${suffix}`, + ), + ).resolves.toBe(true); + await expect( + rotateOAuthSessionRefresh( + env.DIRECTORY, + session.sessionId, + session.refreshTokenHash, + `refresh-never-${suffix}`, + ), + ).resolves.toBe(false); + }); + + it("reads OAuth tokens through their owning sessions", async () => { + const suffix = crypto.randomUUID(); + const now = 1_700_000_000; + const session = { + sessionId: `session-join-${suffix}`, + refreshTokenHash: `refresh-join-${suffix}`, + clientId: "https://client.example", + did: "did:plc:join", + scope: "atproto repo:org.example.thing", + dpopJkt: "jkt", + exp: now + 3600, + }; + await insertOAuthSession(env.DIRECTORY, session, now); + const token = { + accessTokenHash: `access-join-${suffix}`, + sessionId: session.sessionId, + clientId: session.clientId, + did: session.did, + scope: session.scope, + dpopJkt: session.dpopJkt, + exp: now + 300, + }; + await insertOAuthToken(env.DIRECTORY, token, now); + + await expect(getOAuthTokenWithSession(env.DIRECTORY, token.accessTokenHash)).resolves.toEqual({ + token, + sessionExp: session.exp, + }); + await env.DIRECTORY.prepare("DELETE FROM oauth_sessions WHERE session_id = ?") + .bind(session.sessionId) + .run(); + await expect(getOAuthTokenWithSession(env.DIRECTORY, token.accessTokenHash)).resolves.toBeNull(); + }); });