From 420d370528ef71cc91f4fde8a6f30de6066ff376 Mon Sep 17 00:00:00 2001 From: Tim Disney Date: Fri, 26 Jun 2026 16:56:08 -0700 Subject: [PATCH] at intents with xrpc and jwt auth --- at-intent/capabilities.json | 54 +++++ backend/package-lock.json | 38 ++++ backend/package.json | 2 + backend/src/config/identity.ts | 6 + backend/src/config/scopes.ts | 10 + backend/src/index.ts | 13 ++ backend/src/routes/auth.ts | 5 + backend/src/routes/xrpc.ts | 240 ++++++++++++++++++++ backend/src/services/at-intent-usage.ts | 77 +++++++ backend/src/services/oauth.ts | 14 ++ backend/src/services/rate-limit.ts | 5 + backend/src/services/service-auth.ts | 142 ++++++++++++ backend/src/utils/did-resolver.ts | 104 +++++++-- backend/test/at-intent-usage.spec.ts | 89 ++++++++ backend/test/did-resolver.spec.ts | 67 ++++++ backend/test/helpers/jwt.ts | 60 +++++ backend/test/service-auth.spec.ts | 116 ++++++++++ backend/test/xrpc.spec.ts | 281 ++++++++++++++++++++++++ 18 files changed, 1302 insertions(+), 21 deletions(-) create mode 100644 at-intent/capabilities.json create mode 100644 backend/src/config/identity.ts create mode 100644 backend/src/routes/xrpc.ts create mode 100644 backend/src/services/at-intent-usage.ts create mode 100644 backend/src/services/service-auth.ts create mode 100644 backend/test/at-intent-usage.spec.ts create mode 100644 backend/test/did-resolver.spec.ts create mode 100644 backend/test/helpers/jwt.ts create mode 100644 backend/test/service-auth.spec.ts create mode 100644 backend/test/xrpc.spec.ts diff --git a/at-intent/capabilities.json b/at-intent/capabilities.json new file mode 100644 index 00000000..2acc91c6 --- /dev/null +++ b/at-intent/capabilities.json @@ -0,0 +1,54 @@ +{ + "capabilities": [ + { + "name": "Save to Skyreader", + "icon": "https://skyreader.app/icons/icon-512.png", + "description": "Save an article to your Skyreader reading list (read-it-later). Accepts any web URL. Not for following a source over time; use subscribe for that.", + "verb": "save", + "subject": [{ "kind": "uri" }], + "input": [{ "name": "title", "kind": "string", "required": false }], + "delivery": "service", + "endpoint": "https://api.skyreader.app/xrpc/app.skyreader.feed.save", + "serviceId": "skyreader_api" + }, + { + "name": "Subscribe to feed in Skyreader", + "icon": "https://skyreader.app/icons/icon-512.png", + "description": "Subscribe to an RSS/Atom feed by URL so new items appear in your Skyreader reading list. For following an atproto account or publication, this maps to the account-follow capability instead.", + "verb": "subscribe", + "subject": [{ "kind": "uri" }], + "input": [{ "name": "category", "kind": "string", "required": false }], + "delivery": "service", + "endpoint": "https://api.skyreader.app/xrpc/app.skyreader.feed.subscribe", + "serviceId": "skyreader_api" + }, + { + "name": "Follow in Skyreader", + "icon": "https://skyreader.app/icons/icon-512.png", + "description": "Follow an atproto account or standard.site publication so its new posts appear in Skyreader. Requires Atmospheric sync to be enabled on the account.", + "verb": "subscribe", + "subject": [ + { "kind": "did", "as": "account" }, + { "kind": "at-uri", "of": "site.standard.publication" } + ], + "produces": ["site.standard.graph.subscription"], + "delivery": "repo", + "scope": "repo:site.standard.graph.subscription" + }, + { + "name": "Share to Skyreader linkblog", + "icon": "https://skyreader.app/icons/icon-512.png", + "description": "Share a link to your public linkblog with an optional note. The post appears in your portable standard.site linkblog, visible across the Atmosphere.", + "verb": "share", + "subject": [{ "kind": "uri" }], + "input": [ + { "name": "title", "kind": "string", "required": false }, + { "name": "note", "kind": "string", "required": false }, + { "name": "tags", "kind": "string", "required": false } + ], + "delivery": "service", + "endpoint": "https://api.skyreader.app/xrpc/app.skyreader.linkblog.share", + "serviceId": "skyreader_api" + } + ] +} diff --git a/backend/package-lock.json b/backend/package-lock.json index 456c9ea4..72a761a8 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -9,6 +9,8 @@ "version": "0.0.0", "license": "GPL-3.0", "dependencies": { + "@noble/curves": "^2.2.0", + "@scure/base": "^2.2.0", "fast-xml-parser": "^5.2.0" }, "devDependencies": { @@ -1663,6 +1665,33 @@ "@jridgewell/sourcemap-codec": "^1.4.10" } }, + "node_modules/@noble/curves": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/curves/-/curves-2.2.0.tgz", + "integrity": "sha512-T/BoHgFXirb0ENSPBquzX0rcjXeM6Lo892a2jlYJkqk83LqZx0l1Of7DzlKJ6jkpvMrkHSnAcgb5JegL8SeIkQ==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0" + }, + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/@noble/hashes": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@noble/hashes/-/hashes-2.2.0.tgz", + "integrity": "sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==", + "license": "MIT", + "engines": { + "node": ">= 20.19.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@poppinss/colors": { "version": "4.1.6", "resolved": "https://registry.npmjs.org/@poppinss/colors/-/colors-4.1.6.tgz", @@ -2042,6 +2071,15 @@ "win32" ] }, + "node_modules/@scure/base": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/base/-/base-2.2.0.tgz", + "integrity": "sha512-b8XEupJibegiXV+tDUseI8oLQc8ei3d/4Jkb2RpbHh3MfE054ov3uIz2dhFkB3FI8iwYkEh0gGCApkrYggkPNg==", + "license": "MIT", + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sindresorhus/is": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz", diff --git a/backend/package.json b/backend/package.json index 40c64cde..66474715 100644 --- a/backend/package.json +++ b/backend/package.json @@ -16,6 +16,8 @@ "format:check": "prettier --check ." }, "dependencies": { + "@noble/curves": "^2.2.0", + "@scure/base": "^2.2.0", "fast-xml-parser": "^5.2.0" }, "devDependencies": { diff --git a/backend/src/config/identity.ts b/backend/src/config/identity.ts new file mode 100644 index 00000000..c021c569 --- /dev/null +++ b/backend/src/config/identity.ts @@ -0,0 +1,6 @@ +// Skyreader's own atproto identity (handle skyreader.app). This is the repo where +// Skyreader publishes its dev.at-intent.capability records, the `app` value written into +// users' dev.at-intent.usage footprints, AND the expected `aud` of an atproto service-auth +// JWT presented to Skyreader's XRPC endpoints. Resolve with: +// curl https://skyreader.app/.well-known/atproto-did +export const SKYREADER_APP_DID = 'did:plc:ra4jsemddo2ii4pn5jaf6x4v'; diff --git a/backend/src/config/scopes.ts b/backend/src/config/scopes.ts index dec97119..74ecab7e 100644 --- a/backend/src/config/scopes.ts +++ b/backend/src/config/scopes.ts @@ -38,6 +38,15 @@ export const LINKBLOG_SCOPES = ['repo:site.standard.publication', 'repo:site.sta // check for sharing) so adding it doesn't retroactively over-restrict shares. export const ATMOSPHERE_SCOPES = ['repo:site.standard.graph.subscription']; +// AT Intents discovery footprint — lets Skyreader write a dev.at-intent.usage record +// into the user's OWN repo so other Atmosphere apps/agents can discover that the user +// uses Skyreader and resolve the capabilities it publishes. Deliberately kept OUT of +// GRANULAR_SCOPES (it's not required for any core Skyreader action), so requesting it +// doesn't push existing users through a hasRequiredScopes re-auth. It's only added to +// ALL_POSSIBLE_SCOPES, which is what the login/callback flow actually requests, so new +// logins pick it up and the usage write is skipped for sessions that lack it. +export const AT_INTENT_SCOPES = ['repo:dev.at-intent.usage']; + // All possible scopes (base + all integrations) — used in client metadata export const ALL_POSSIBLE_SCOPES = [ GRANULAR_SCOPES, @@ -45,4 +54,5 @@ export const ALL_POSSIBLE_SCOPES = [ ...MARGIN_SCOPES, ...LINKBLOG_SCOPES, ...ATMOSPHERE_SCOPES, + ...AT_INTENT_SCOPES, ].join(' '); diff --git a/backend/src/index.ts b/backend/src/index.ts index 7caffd46..18a233ec 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -51,6 +51,7 @@ import { handleDeleteLabel, handleBulkAddLabels, } from './routes/labels'; +import { handleXrpcSave, handleXrpcSubscribe, handleXrpcLinkblogShare } from './routes/xrpc'; import { handleCreateSaved, handleGetSaved, @@ -527,6 +528,18 @@ export default { response = await handleSyncStatus(request, env); break; + // AT Intents service endpoints (XRPC procedures). No top-level session guard: + // the handlers return XRPC-shaped `{ error, message }` auth errors themselves. + case url.pathname === '/xrpc/app.skyreader.feed.save': + response = await handleXrpcSave(request, env, ctx); + break; + case url.pathname === '/xrpc/app.skyreader.feed.subscribe': + response = await handleXrpcSubscribe(request, env, ctx); + break; + case url.pathname === '/xrpc/app.skyreader.linkblog.share': + response = await handleXrpcLinkblogShare(request, env); + break; + default: response = new Response(JSON.stringify({ error: 'Not found' }), { status: 404, diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 1f4e6c12..7d6faa66 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -29,6 +29,7 @@ import { ALL_POSSIBLE_SCOPES, } from '../config/scopes'; import { getUserTier } from '../services/user-tier'; +import { writeUsageRecord } from '../services/at-intent-usage'; import { getLimitsForTier } from '../config/tier-limits'; import { buildSetCookieHeader, @@ -749,6 +750,10 @@ export async function handleAuthCallback( // Now store session (after user exists in DB due to FK constraint) await storeSession(env, sessionId, session); + // Write the AT Intents discovery footprint into the user's repo (best-effort, + // skipped if the usage scope wasn't granted). Never block the login redirect on it. + ctx.waitUntil(writeUsageRecord(session)); + // Build the session cookie to set during redirect const cookieDomain = getCookieDomain(env, request); const isSecure = isSecureContext(request); diff --git a/backend/src/routes/xrpc.ts b/backend/src/routes/xrpc.ts new file mode 100644 index 00000000..1e0a2eb3 --- /dev/null +++ b/backend/src/routes/xrpc.ts @@ -0,0 +1,240 @@ +import type { Env } from '../types'; +import { handleCreateSaved } from './saved'; +import { handleCreateSubscription } from './subscriptions'; +import { handleCreateLinkblogShare } from './linkblog'; +import { generateTid } from '../utils/tid'; +import { verifyServiceAuth } from '../services/service-auth'; +import { findSessionIdForDid } from '../services/oauth'; +import { checkRateLimit } from '../services/rate-limit'; + +// Bucket key for the unauthenticated service-auth throttle (see rate-limit.ts). +const SERVICE_AUTH_RL_KEY = '/xrpc:service-auth'; + +// AT Intents service endpoints. +// +// These expose existing Skyreader actions as XRPC procedures (`/xrpc/`) so AT +// Intents consumers can route to them per the service wire contract: the matched +// subject and inputs arrive as query-string params, and errors come back in the XRPC +// shape `{ error: , message: }`. Both verbs here are procedures (POST); +// only `open` would be a GET query. +// +// Auth — two accepted credentials: +// 1. A Skyreader session (a `session_id` cookie or `Authorization: Bearer `), +// same as the rest of the API. For first-party callers that already have a session. +// 2. An atproto inter-service JWT (`Authorization: Bearer `), the AT Intents path: +// any consumer holding the user's session mints one via the user's PDS, scoped to +// this XRPC method. We verify it (services/service-auth.ts) to get the user's DID, +// then map that DID to a stored Skyreader session to act — because the actual work +// (D1 writes, and especially the linkblog PDS write) needs Skyreader's own tokens, +// which a bare identity proof doesn't provide. So service auth works only for users +// who have signed in to Skyreader and granted the needed scopes. +// +// Implementation: rather than duplicate the validation/dedup/tier logic, each wrapper +// resolves the credential to a session, builds a synthetic JSON-body POST request bearing +// that session, delegates to the existing `/api/*` handler, then reshapes the result for +// XRPC. + +const JSON_HEADERS = { 'Content-Type': 'application/json' }; + +function xrpcError(error: string, message: string, status: number): Response { + return new Response(JSON.stringify({ error, message }), { status, headers: JSON_HEADERS }); +} + +// XRPC error names don't have a fixed registry; we derive a stable name from the HTTP +// status of the delegated handler's response. +function errorNameForStatus(status: number): string { + switch (status) { + case 400: + return 'InvalidRequest'; + case 401: + return 'AuthenticationRequired'; + case 403: + return 'Forbidden'; + case 405: + return 'MethodNotImplemented'; + case 429: + return 'RateLimitExceeded'; + default: + return status >= 500 ? 'InternalServerError' : 'RequestFailed'; + } +} + +// Pass successful responses through untouched; remap the internal `{ error, message? }` +// error shape onto the XRPC `{ error: , message }` shape keyed off the status. +async function toXrpcResponse(res: Response): Promise { + if (res.ok) return res; + + let payload: { error?: string; message?: string } = {}; + try { + payload = (await res.clone().json()) as { error?: string; message?: string }; + } catch { + // Non-JSON body — fall back to the status-derived name as the message. + } + const name = errorNameForStatus(res.status); + const message = payload.message || payload.error || name; + return xrpcError(name, message, res.status); +} + +// Resolve the request's credential into the session to act as. +// - returns { sessionId: null } to pass the original auth headers through unchanged +// (first-party cookie / Bearer session_id path), +// - returns { sessionId } when an atproto service-auth JWT verified and mapped to a +// stored Skyreader session (delegate as that session), +// - returns { error } as a ready XRPC error response otherwise. +// Detection: a service-auth JWT is a Bearer token with three dot-separated segments; a +// Skyreader session_id has none, so it falls through to passthrough. +async function resolveDelegationAuth( + request: Request, + env: Env, + lxm: string +): Promise<{ sessionId: string | null } | { error: Response }> { + const authz = request.headers.get('Authorization') || ''; + const token = authz.startsWith('Bearer ') ? authz.slice('Bearer '.length) : ''; + if (token.split('.').length !== 3) { + return { sessionId: null }; // not a JWT — first-party session passthrough + } + + // Throttle the JWT path by client IP BEFORE verifying: verification triggers an + // outbound DID-resolution fetch, and this path has no session to key the normal + // per-user rate limiter on. Fails open on DB error (see checkRateLimit). + const clientIp = request.headers.get('CF-Connecting-IP') || 'unknown'; + const rl = await checkRateLimit(env, `ip:${clientIp}`, SERVICE_AUTH_RL_KEY); + if (!rl.allowed) { + return { + error: xrpcError( + 'RateLimitExceeded', + 'Too many service-auth requests. Slow down and retry shortly.', + 429 + ), + }; + } + + const verified = await verifyServiceAuth(token, { lxm }); + if (!verified.ok) { + return { error: xrpcError(verified.error, verified.message, 401) }; + } + + const sessionId = await findSessionIdForDid(env, verified.did); + if (!sessionId) { + return { + error: xrpcError( + 'AuthenticationRequired', + `No active Skyreader session for ${verified.did}. The user must sign in to Skyreader (granting the needed permissions) before this action can be performed on their behalf.`, + 401 + ), + }; + } + return { sessionId }; +} + +// Build a synthetic JSON-body POST request bearing the resolved session, or return a +// ready XRPC error if auth failed. For the service-auth path we swap in the resolved +// session id as a Bearer token (and drop any Cookie) so the delegated handler resolves +// exactly that session; for passthrough we keep the original auth headers as-is. +async function buildDelegatedRequest( + request: Request, + env: Env, + lxm: string, + body: unknown +): Promise<{ req: Request } | { error: Response }> { + const auth = await resolveDelegationAuth(request, env, lxm); + if ('error' in auth) return { error: auth.error }; + + const headers = new Headers(request.headers); + headers.set('Content-Type', 'application/json'); + if (auth.sessionId) { + headers.set('Authorization', `Bearer ${auth.sessionId}`); + headers.delete('Cookie'); + } + return { + req: new Request(request.url, { method: 'POST', headers, body: JSON.stringify(body) }), + }; +} + +// POST /xrpc/app.skyreader.feed.save — save a web article to the reading list. +// subject = the article URL; optional input `title`. +export async function handleXrpcSave( + request: Request, + env: Env, + ctx: ExecutionContext +): Promise { + if (request.method !== 'POST') { + return xrpcError('MethodNotImplemented', 'Use POST for this procedure.', 405); + } + + const params = new URL(request.url).searchParams; + const subject = params.get('subject'); + if (!subject) { + return xrpcError('InvalidRequest', 'Missing required param: subject (the article URL).', 400); + } + + const body = { + url: subject, + rkey: generateTid(), + source: 'url', + title: params.get('title') ?? undefined, + }; + + const built = await buildDelegatedRequest(request, env, 'app.skyreader.feed.save', body); + if ('error' in built) return built.error; + return toXrpcResponse(await handleCreateSaved(built.req, env, ctx)); +} + +// POST /xrpc/app.skyreader.feed.subscribe — subscribe to an RSS/Atom feed by URL. +// subject = the feed URL; optional input `category`. +export async function handleXrpcSubscribe( + request: Request, + env: Env, + ctx: ExecutionContext +): Promise { + if (request.method !== 'POST') { + return xrpcError('MethodNotImplemented', 'Use POST for this procedure.', 405); + } + + const params = new URL(request.url).searchParams; + const subject = params.get('subject'); + if (!subject) { + return xrpcError('InvalidRequest', 'Missing required param: subject (the feed URL).', 400); + } + + const body = { + rkey: generateTid(), + feedUrl: subject, + category: params.get('category') ?? undefined, + }; + + const built = await buildDelegatedRequest(request, env, 'app.skyreader.feed.subscribe', body); + if ('error' in built) return built.error; + return toXrpcResponse(await handleCreateSubscription(built.req, env, ctx)); +} + +// POST /xrpc/app.skyreader.linkblog.share — share a link to the user's linkblog. +// subject = the article URL; optional inputs `title`, `note`, repeatable `tags`. +// Delivered as a service (not pds) because the underlying write is app logic: it lazily +// creates the user's site.standard.publication, builds the leaflet-block document body, +// and links the document to that publication — none of which a generic consumer could +// construct from a raw PDS write. +export async function handleXrpcLinkblogShare(request: Request, env: Env): Promise { + if (request.method !== 'POST') { + return xrpcError('MethodNotImplemented', 'Use POST for this procedure.', 405); + } + + const params = new URL(request.url).searchParams; + const subject = params.get('subject'); + if (!subject) { + return xrpcError('InvalidRequest', 'Missing required param: subject (the article URL).', 400); + } + + const tags = params.getAll('tags'); + const body = { + rkey: generateTid(), + articleUrl: subject, + articleTitle: params.get('title') ?? undefined, + note: params.get('note') ?? undefined, + tags: tags.length ? tags : undefined, + }; + + const built = await buildDelegatedRequest(request, env, 'app.skyreader.linkblog.share', body); + if ('error' in built) return built.error; + return toXrpcResponse(await handleCreateLinkblogShare(built.req, env)); +} diff --git a/backend/src/services/at-intent-usage.ts b/backend/src/services/at-intent-usage.ts new file mode 100644 index 00000000..09707d44 --- /dev/null +++ b/backend/src/services/at-intent-usage.ts @@ -0,0 +1,77 @@ +import type { Session } from '../types'; +import { createPDSClient } from './pds-client'; +import { generateTid } from '../utils/tid'; +import { AT_INTENT_SCOPES } from '../config/scopes'; +import { SKYREADER_APP_DID } from '../config/identity'; + +// AT Intents discovery footprint. +// +// When a person uses Skyreader, we write a `dev.at-intent.usage` record into THEIR repo +// that points back at Skyreader's app DID. That's the signal other Atmosphere apps and +// agents scan for to discover that this user uses Skyreader and resolve the +// `dev.at-intent.capability` records Skyreader publishes (see at-intent/capabilities.json). +// +// Self-asserted and best-effort: only an app the user has already granted repo write +// access can create one, and a failure here must never break the login flow — always +// call this fire-and-forget (ctx.waitUntil). + +// Skyreader's atproto identity (the `app` value consumers resolve back from a usage +// record). Shared with the service-auth verifier — see config/identity.ts. +const APP_DID = SKYREADER_APP_DID; + +const USAGE_COLLECTION = 'dev.at-intent.usage'; +const USAGE_SCOPE = AT_INTENT_SCOPES[0]; // 'repo:dev.at-intent.usage' + +interface UsageRecord { + $type: string; + app: string; + lastSeenAt: string; + createdAt: string; +} + +function rkeyFromUri(uri: string): string { + return uri.split('/').pop() || ''; +} + +// Write or refresh the usage footprint in the user's repo. Idempotent and deduped by +// `app` per the lexicon (which uses tid record keys, so we can't address the record by a +// fixed key): we list existing usage records, reuse the rkey of any that already points +// at our app DID (preserving its createdAt and only bumping lastSeenAt), and otherwise +// mint a fresh TID. Consumers age handlers out by lastSeenAt, so refreshing it on each +// login is the point. +export async function writeUsageRecord(session: Session): Promise { + // Skip silently if the user hasn't granted the usage write scope (e.g. a session that + // predates AT_INTENT_SCOPES, or a PDS that didn't grant it). + if (!session.grantedScopes?.split(' ').includes(USAGE_SCOPE)) { + return; + } + + const pds = createPDSClient(session); + const now = new Date().toISOString(); + + // Find an existing usage record for our app, if any. + let rkey: string | null = null; + let createdAt = now; + const existing = await pds.listRecords(USAGE_COLLECTION); + if (existing.success) { + const mine = existing.data.records.find((r) => r.value?.app === APP_DID); + if (mine) { + rkey = rkeyFromUri(mine.uri); + createdAt = mine.value.createdAt || now; + } + } + // listRecords failing (e.g. the collection doesn't exist yet) just means "no record" — + // fall through to a fresh create. + + const record: UsageRecord = { + $type: USAGE_COLLECTION, + app: APP_DID, + lastSeenAt: now, + createdAt, + }; + + const result = await pds.putRecord(USAGE_COLLECTION, rkey ?? generateTid(), record); + if (!result.success) { + console.error(`[at-intent] usage write failed for ${session.did}: ${result.error}`); + } +} diff --git a/backend/src/services/oauth.ts b/backend/src/services/oauth.ts index 6ba7c187..8a401e33 100644 --- a/backend/src/services/oauth.ts +++ b/backend/src/services/oauth.ts @@ -826,6 +826,20 @@ export async function getSessionFromRequest(request: Request, env: Env): Promise return session; } +// Find the most recent stored session id for a DID. Used by the AT Intents service-auth +// path: a verified inter-service JWT proves the user's identity, but acting on their +// behalf (especially PDS writes) still needs Skyreader's stored OAuth tokens, so we map +// the DID to a session the user established by signing in. Returns null if there is none. +// The caller delegates with this session id, so the normal auto-refresh still applies. +export async function findSessionIdForDid(env: Env, did: string): Promise { + const row = await env.DB.prepare( + 'SELECT session_id FROM sessions WHERE did = ? ORDER BY expires_at DESC LIMIT 1' + ) + .bind(did) + .first<{ session_id: string }>(); + return row?.session_id ?? null; +} + // Lock timeout in milliseconds - if a refresh takes longer than this, the lock is considered stale const REFRESH_LOCK_TIMEOUT_MS = 30 * 1000; // 30 seconds diff --git a/backend/src/services/rate-limit.ts b/backend/src/services/rate-limit.ts index bfe707f7..e4f75950 100644 --- a/backend/src/services/rate-limit.ts +++ b/backend/src/services/rate-limit.ts @@ -28,6 +28,11 @@ const RATE_LIMITS: Record = { '/api/leaflet/resolve': EXPENSIVE_LIMIT, '/api/extract': EXPENSIVE_LIMIT, + // AT Intents service-auth pre-verification. Keyed by client IP (not did) and checked + // BEFORE the signature, since verifying a service-auth JWT triggers an outbound DID + // resolution fetch that an unauthenticated caller could otherwise spam. + '/xrpc:service-auth': EXPENSIVE_LIMIT, + // PDS sync operations (allow retries for hasMore batching) '/api/sync/full': { limit: 20, windowMs: 300000 }, // Individual sync operations (6 per hour) diff --git a/backend/src/services/service-auth.ts b/backend/src/services/service-auth.ts new file mode 100644 index 00000000..2cd7cb48 --- /dev/null +++ b/backend/src/services/service-auth.ts @@ -0,0 +1,142 @@ +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { p256 } from '@noble/curves/nist.js'; +import { base58, base64urlnopad } from '@scure/base'; +import { SKYREADER_APP_DID } from '../config/identity'; +import { resolveAtprotoSigningKey } from '../utils/did-resolver'; + +// atproto inter-service authentication ("service auth"). +// +// A consumer that holds a user's session mints a short-lived JWT via the user's PDS +// (com.atproto.server.getServiceAuth), signed by the USER's atproto signing key, with: +// iss = the user's DID, aud = the service's DID, lxm = the XRPC method, exp = soon. +// We verify it by resolving the issuer's signing key from their DID document and checking +// the signature + claims. A valid token proves "this request is user "; it does NOT +// grant us access to their PDS — the caller maps the verified DID to a stored Skyreader +// session to actually act. +// +// Workers' WebCrypto can't verify secp256k1 (ES256K), which is the atproto default, so we +// use @noble/curves for both ES256K (secp256k1) and ES256 (P-256). + +const CLOCK_SKEW_SEC = 30; + +// Upper bound on a token's remaining lifetime. atproto service-auth tokens are meant to +// be short-lived (the convention is ~60s); we hold close to that, allowing only modest +// slack for slow consumers (plus CLOCK_SKEW_SEC on top at the check site). A short window +// is the primary control against replay: these JWTs carry no jti/nonce and bind only the +// method (lxm), NOT the call's parameters, so a leaked token can be replayed with +// attacker-chosen params (e.g. an arbitrary public linkblog post) until it expires. The +// tighter this is, the smaller that window. +const MAX_LIFETIME_SEC = 2 * 60; + +export type VerifyResult = + | { ok: true; did: string } + | { ok: false; error: string; message: string }; + +function fail(message: string): VerifyResult { + return { ok: false, error: 'AuthenticationRequired', message }; +} + +function decodeJsonSegment(segment: string): unknown { + return JSON.parse(new TextDecoder().decode(base64urlnopad.decode(segment))); +} + +// Decode a Multikey publicKeyMultibase (base58btc, multicodec-prefixed) into a curve + +// compressed public key. secp256k1-pub = multicodec 0xe7 (varint [0xe7,0x01]); p256-pub = +// 0x1200 (varint [0x80,0x24]). Both prefixes are 2 bytes, followed by the 33-byte key. +function decodeMultikey(multibase: string): { curve: 'k256' | 'p256'; key: Uint8Array } | null { + if (!multibase.startsWith('z')) return null; // 'z' = base58btc multibase prefix + let bytes: Uint8Array; + try { + bytes = base58.decode(multibase.slice(1)); + } catch { + return null; + } + if (bytes.length < 3) return null; + if (bytes[0] === 0xe7 && bytes[1] === 0x01) return { curve: 'k256', key: bytes.slice(2) }; + if (bytes[0] === 0x80 && bytes[1] === 0x24) return { curve: 'p256', key: bytes.slice(2) }; + return null; +} + +interface JwtPayload { + iss?: string; + aud?: string; + exp?: number; + lxm?: string; +} + +/** + * Verify an atproto service-auth JWT bound to a specific XRPC method (`lxm`). On success + * returns the verified issuer DID. Every failure is non-fatal and returns a reason; the + * caller surfaces it as an XRPC AuthenticationRequired error. + */ +export async function verifyServiceAuth( + token: string, + opts: { lxm: string; now?: number } +): Promise { + const parts = token.split('.'); + if (parts.length !== 3) return fail('Malformed service-auth token.'); + + let header: { alg?: string }; + let payload: JwtPayload; + try { + header = decodeJsonSegment(parts[0]) as { alg?: string }; + payload = decodeJsonSegment(parts[1]) as JwtPayload; + } catch { + return fail('Service-auth token is not a valid JWT.'); + } + + const alg = header?.alg; + if (alg !== 'ES256K' && alg !== 'ES256') return fail(`Unsupported JWT alg: ${alg}.`); + + // Claim checks first (cheap) before resolving the key (a network fetch). + const now = opts.now ?? Math.floor(Date.now() / 1000); + if (typeof payload?.exp !== 'number' || now > payload.exp + CLOCK_SKEW_SEC) { + return fail('Service-auth token has expired.'); + } + if (payload.exp - now > MAX_LIFETIME_SEC + CLOCK_SKEW_SEC) { + return fail('Service-auth token lifetime exceeds the allowed maximum.'); + } + if (payload?.aud !== SKYREADER_APP_DID) { + return fail('Service-auth token audience does not match Skyreader.'); + } + if (payload?.lxm !== opts.lxm) { + return fail(`Service-auth token is not scoped to this method (${opts.lxm}).`); + } + const iss = payload?.iss; + if (typeof iss !== 'string' || !iss.startsWith('did:')) { + return fail('Service-auth token has no valid issuer DID.'); + } + + const multibase = await resolveAtprotoSigningKey(iss); + if (!multibase) return fail(`Could not resolve a signing key for ${iss}.`); + const decoded = decodeMultikey(multibase); + if (!decoded) return fail('Issuer signing key uses an unsupported type.'); + + // The JWT alg must match the resolved key's curve (block curve-confusion). + const algIsK256 = alg === 'ES256K'; + if (algIsK256 !== (decoded.curve === 'k256')) { + return fail('JWT alg does not match the issuer signing key curve.'); + } + + let sig: Uint8Array; + try { + sig = base64urlnopad.decode(parts[2]); + } catch { + return fail('Service-auth token signature is not valid base64url.'); + } + if (sig.length !== 64) return fail('Service-auth token signature has an unexpected length.'); + + const signingInput = new TextEncoder().encode(`${parts[0]}.${parts[1]}`); + const hash = new Uint8Array(await crypto.subtle.digest('SHA-256', signingInput)); + const curve = decoded.curve === 'k256' ? secp256k1 : p256; + + let valid = false; + try { + valid = curve.verify(sig, hash, decoded.key, { prehash: false }); + } catch { + valid = false; + } + if (!valid) return fail('Service-auth token signature is invalid.'); + + return { ok: true, did: iss }; +} diff --git a/backend/src/utils/did-resolver.ts b/backend/src/utils/did-resolver.ts index 9b83ff84..d7a72e40 100644 --- a/backend/src/utils/did-resolver.ts +++ b/backend/src/utils/did-resolver.ts @@ -1,5 +1,5 @@ /** - * Utility functions for resolving DIDs to PDS URLs and other AT Protocol operations + * Utility functions for resolving DIDs to PDS URLs and atproto signing keys. */ // PLC directory for DID resolution @@ -8,6 +8,67 @@ const PLC_DIRECTORY = 'https://plc.directory'; interface DidDocument { id: string; service?: Array<{ id: string; type: string; serviceEndpoint: string }>; + verificationMethod?: Array<{ + id: string; + type: string; + controller?: string; + publicKeyMultibase?: string; + }>; +} + +/** + * Resolve a `did:web` identifier to the `https` URL of its DID document, or null if the + * host isn't a safe, public domain. This is an SSRF guard: the host comes straight from an + * attacker-controllable DID (e.g. the issuer of a service-auth JWT), so we must not let it + * point fetch() at internal infrastructure. + * + * atproto `did:web` identities are bare, public domains. We therefore reject: + * - path-form did:web (extra `:`-separated segments) — unused by atproto identities, + * - ports / IPv6 literals (anything with a `:` after percent-decoding), + * - IPv4 literals (e.g. 169.254.169.254, 127.0.0.1, 10.x — the classic SSRF targets), + * - localhost and internal TLDs (.local / .internal / .lan), + * - anything without a dot (not a real public domain). + */ +function didWebToDocUrl(did: string): string | null { + const raw = did.slice('did:web:'.length); + // A literal ':' here is a path-form separator (a port would be percent-encoded as %3A). + if (!raw || raw.includes(':')) return null; + + let host: string; + try { + host = decodeURIComponent(raw).toLowerCase(); + } catch { + return null; // malformed percent-encoding + } + + if (!host || host.includes(':')) return null; // empty, ported, or IPv6 literal + if (host === 'localhost' || host.endsWith('.localhost')) return null; + if (host.endsWith('.local') || host.endsWith('.internal') || host.endsWith('.lan')) return null; + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return null; // IPv4 literal + if (!host.includes('.')) return null; // not a public domain + + return `https://${host}/.well-known/did.json`; +} + +/** + * Fetch and parse a DID document for did:plc or did:web. + */ +async function fetchDidDocument(did: string): Promise { + if (did.startsWith('did:plc:')) { + const response = await fetch(`${PLC_DIRECTORY}/${did}`); + if (!response.ok) return null; + return (await response.json()) as DidDocument; + } else if (did.startsWith('did:web:')) { + const docUrl = didWebToDocUrl(did); + if (!docUrl) return null; + // Don't follow redirects: didWebToDocUrl only vetted the literal host, so a redirect + // could send us to an internal address (e.g. 169.254.169.254) and defeat the SSRF + // guard. Treat any 3xx as a resolution failure. + const response = await fetch(docUrl, { redirect: 'manual' }); + if (!response.ok) return null; + return (await response.json()) as DidDocument; + } + return null; } /** @@ -15,28 +76,29 @@ interface DidDocument { */ export async function resolvePdsUrl(did: string): Promise { try { - if (did.startsWith('did:plc:')) { - const response = await fetch(`${PLC_DIRECTORY}/${did}`); - if (!response.ok) return null; - const doc = (await response.json()) as DidDocument; - const pdsService = doc.service?.find( - (s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' - ); - return pdsService?.serviceEndpoint || null; - } else if (did.startsWith('did:web:')) { - // For did:web, the PDS URL is derived from the DID - const domain = did.replace('did:web:', ''); - const response = await fetch(`https://${domain}/.well-known/did.json`); - if (!response.ok) return null; - const doc = (await response.json()) as DidDocument; - const pdsService = doc.service?.find( - (s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' - ); - return pdsService?.serviceEndpoint || null; - } - return null; + const doc = await fetchDidDocument(did); + const pdsService = doc?.service?.find( + (s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer' + ); + return pdsService?.serviceEndpoint || null; } catch (error) { console.error(`[did-resolver] Failed to resolve PDS URL for ${did}:`, error); return null; } } + +/** + * Resolve a DID to its atproto signing key (the `#atproto` verification method's + * publicKeyMultibase — a Multikey/base58btc-encoded compressed public key). Used to + * verify atproto service-auth JWTs. Returns null if the DID or key can't be resolved. + */ +export async function resolveAtprotoSigningKey(did: string): Promise { + try { + const doc = await fetchDidDocument(did); + const vm = doc?.verificationMethod?.find((v) => v.id.endsWith('#atproto')); + return vm?.publicKeyMultibase || null; + } catch (error) { + console.error(`[did-resolver] Failed to resolve signing key for ${did}:`, error); + return null; + } +} diff --git a/backend/test/at-intent-usage.spec.ts b/backend/test/at-intent-usage.spec.ts new file mode 100644 index 00000000..cafd909b --- /dev/null +++ b/backend/test/at-intent-usage.spec.ts @@ -0,0 +1,89 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as pdsClient from '../src/services/pds-client'; +import { writeUsageRecord } from '../src/services/at-intent-usage'; +import { AT_INTENT_SCOPES } from '../src/config/scopes'; +import type { Session } from '../src/types'; + +const APP_DID = 'did:plc:ra4jsemddo2ii4pn5jaf6x4v'; +const USAGE_COLLECTION = 'dev.at-intent.usage'; + +function session(grantedScopes: string): Session { + return { + did: 'did:plc:user', + handle: 'user.bsky.social', + pdsUrl: 'https://pds.test', + accessToken: 'tok', + refreshToken: 'rtok', + dpopPrivateKey: JSON.stringify({ kty: 'EC' }), + expiresAt: Date.now() + 3_600_000, + grantedScopes, + } as Session; +} + +// A fake PDSClient exposing only the methods writeUsageRecord touches. +function fakeClient(records: Array<{ uri: string; value: unknown }>) { + const putRecord = vi.fn().mockResolvedValue({ success: true, data: { uri: 'at://x', cid: 'c' } }); + const listRecords = vi.fn().mockResolvedValue({ success: true, data: { records } }); + return { client: { listRecords, putRecord } as any, putRecord, listRecords }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('writeUsageRecord', () => { + it('skips entirely when the usage scope was not granted', async () => { + const spy = vi.spyOn(pdsClient, 'createPDSClient'); + await writeUsageRecord(session('atproto repo:app.skyreader.feed.subscription')); + expect(spy).not.toHaveBeenCalled(); + }); + + it('creates a new record (fresh TID rkey) pointing at the app DID when none exists', async () => { + const { client, putRecord, listRecords } = fakeClient([]); + vi.spyOn(pdsClient, 'createPDSClient').mockReturnValue(client); + + await writeUsageRecord(session(AT_INTENT_SCOPES[0])); + + expect(listRecords).toHaveBeenCalledWith(USAGE_COLLECTION); + expect(putRecord).toHaveBeenCalledTimes(1); + const [collection, rkey, record] = putRecord.mock.calls[0]; + expect(collection).toBe(USAGE_COLLECTION); + expect(rkey).toMatch(/^[a-z0-9]{13,}$/); // a freshly minted TID + expect(record.app).toBe(APP_DID); + expect(record.$type).toBe(USAGE_COLLECTION); + expect(record.createdAt).toBe(record.lastSeenAt); // first write: created == lastSeen + }); + + it('reuses the existing rkey and preserves createdAt, bumping only lastSeenAt', async () => { + const existingCreatedAt = '2026-01-01T00:00:00.000Z'; + const { client, putRecord } = fakeClient([ + { + uri: `at://did:plc:user/${USAGE_COLLECTION}/existingrkey01`, + value: { app: APP_DID, createdAt: existingCreatedAt, lastSeenAt: existingCreatedAt }, + }, + ]); + vi.spyOn(pdsClient, 'createPDSClient').mockReturnValue(client); + + await writeUsageRecord(session(AT_INTENT_SCOPES[0])); + + const [, rkey, record] = putRecord.mock.calls[0]; + expect(rkey).toBe('existingrkey01'); + expect(record.createdAt).toBe(existingCreatedAt); + expect(record.lastSeenAt).not.toBe(existingCreatedAt); // bumped to now + }); + + it('ignores usage records for other apps when deduping', async () => { + const { client, putRecord } = fakeClient([ + { + uri: `at://did:plc:user/${USAGE_COLLECTION}/otherapp01`, + value: { app: 'did:web:someother.app', createdAt: '2026-01-01T00:00:00.000Z' }, + }, + ]); + vi.spyOn(pdsClient, 'createPDSClient').mockReturnValue(client); + + await writeUsageRecord(session(AT_INTENT_SCOPES[0])); + + const [, rkey, record] = putRecord.mock.calls[0]; + expect(rkey).not.toBe('otherapp01'); // minted a fresh one, didn't clobber the other app + expect(rkey).toMatch(/^[a-z0-9]{13,}$/); + expect(record.app).toBe(APP_DID); + }); +}); diff --git a/backend/test/did-resolver.spec.ts b/backend/test/did-resolver.spec.ts new file mode 100644 index 00000000..38615299 --- /dev/null +++ b/backend/test/did-resolver.spec.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { resolveAtprotoSigningKey } from '../src/utils/did-resolver'; + +// A minimal DID document carrying an #atproto verification method. +function didDoc(multibase: string) { + return { + ok: true, + json: async () => ({ + id: 'did:web:example.com', + verificationMethod: [{ id: 'did:web:example.com#atproto', publicKeyMultibase: multibase }], + }), + } as Response; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('resolveAtprotoSigningKey did:web host validation (SSRF guard)', () => { + // Hosts that must be rejected BEFORE any network fetch happens. + const forbidden = [ + 'did:web:localhost', + 'did:web:127.0.0.1', + 'did:web:169.254.169.254', // cloud metadata endpoint + 'did:web:10.0.0.1', + 'did:web:192.168.1.1', + 'did:web:example.com%3A8080', // percent-encoded port + 'did:web:db.internal', + 'did:web:host.local', + 'did:web:example.com:user:alice', // path-form did:web + 'did:web:nodot', + ]; + + for (const did of forbidden) { + it(`rejects ${did} without fetching`, async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch'); + const key = await resolveAtprotoSigningKey(did); + expect(key).toBeNull(); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + } + + it('resolves a bare public domain and fetches its well-known did.json', async () => { + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue(didDoc('zPublicKeyMultibase')); + + const key = await resolveAtprotoSigningKey('did:web:example.com'); + + expect(key).toBe('zPublicKeyMultibase'); + // Must not follow redirects — the host was vetted, but a redirect could escape it. + expect(fetchSpy).toHaveBeenCalledWith('https://example.com/.well-known/did.json', { + redirect: 'manual', + }); + }); + + it('does not follow a redirect to an internal address (SSRF guard)', async () => { + // A vetted public host whose did.json 302s toward an internal target. With + // redirect:'manual' the 3xx surfaces as a non-ok response, so resolution fails + // rather than chasing the redirect. + vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok: false, + status: 302, + json: async () => ({}), + } as Response); + + const key = await resolveAtprotoSigningKey('did:web:example.com'); + + expect(key).toBeNull(); + }); +}); diff --git a/backend/test/helpers/jwt.ts b/backend/test/helpers/jwt.ts new file mode 100644 index 00000000..6b156b36 --- /dev/null +++ b/backend/test/helpers/jwt.ts @@ -0,0 +1,60 @@ +import { secp256k1 } from '@noble/curves/secp256k1.js'; +import { p256 } from '@noble/curves/nist.js'; +import { base58, base64urlnopad } from '@scure/base'; + +// Test helpers for minting real atproto service-auth JWTs and the matching Multikey, +// so service-auth verification is exercised against genuine signatures (not mocks). + +type CurveName = 'k256' | 'p256'; + +function curveFor(name: CurveName) { + return name === 'k256' ? secp256k1 : p256; +} + +function b64urlJson(obj: unknown): string { + return base64urlnopad.encode(new TextEncoder().encode(JSON.stringify(obj))); +} + +// Encode a compressed public key as a did:key/Multikey publicKeyMultibase string. +export function multikeyFor(curve: CurveName, pubCompressed: Uint8Array): string { + const prefix = curve === 'k256' ? new Uint8Array([0xe7, 0x01]) : new Uint8Array([0x80, 0x24]); + const bytes = new Uint8Array(prefix.length + pubCompressed.length); + bytes.set(prefix); + bytes.set(pubCompressed, prefix.length); + return 'z' + base58.encode(bytes); +} + +export function genKeypair(curve: CurveName = 'k256') { + const c = curveFor(curve); + const sk = c.utils.randomSecretKey(); + const pub = c.getPublicKey(sk, true); + return { curve, sk, pub, multibase: multikeyFor(curve, pub) }; +} + +export async function mintServiceJwt(opts: { + curve?: CurveName; + privateKey: Uint8Array; + claims: Record; + alg?: string; // override the header alg (to test alg/curve mismatch) +}): Promise { + const curve = opts.curve ?? 'k256'; + const c = curveFor(curve); + const alg = opts.alg ?? (curve === 'k256' ? 'ES256K' : 'ES256'); + const header = b64urlJson({ typ: 'JWT', alg }); + const payload = b64urlJson(opts.claims); + const signingInput = `${header}.${payload}`; + const hash = new Uint8Array( + await crypto.subtle.digest('SHA-256', new TextEncoder().encode(signingInput)) + ); + const sig = c.sign(hash, opts.privateKey, { prehash: false, format: 'compact' }); + return `${signingInput}.${base64urlnopad.encode(sig)}`; +} + +// Corrupt the signature segment of a JWT (flip a byte) without changing its length, to +// produce a structurally valid but cryptographically invalid token. +export function corruptSignature(jwt: string): string { + const [h, p, s] = jwt.split('.'); + const sig = base64urlnopad.decode(s); + sig[sig.length - 1] ^= 0xff; + return `${h}.${p}.${base64urlnopad.encode(sig)}`; +} diff --git a/backend/test/service-auth.spec.ts b/backend/test/service-auth.spec.ts new file mode 100644 index 00000000..0f591c12 --- /dev/null +++ b/backend/test/service-auth.spec.ts @@ -0,0 +1,116 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; +import * as didResolver from '../src/utils/did-resolver'; +import { verifyServiceAuth } from '../src/services/service-auth'; +import { SKYREADER_APP_DID } from '../src/config/identity'; +import { genKeypair, mintServiceJwt, corruptSignature } from './helpers/jwt'; + +const USER_DID = 'did:plc:serviceauthuser'; +const LXM = 'app.skyreader.feed.save'; +const NOW = 1_900_000_000; // fixed reference time (seconds) + +function claims(over: Record = {}) { + return { iss: USER_DID, aud: SKYREADER_APP_DID, lxm: LXM, exp: NOW + 60, ...over }; +} + +afterEach(() => vi.restoreAllMocks()); + +describe('verifyServiceAuth', () => { + it('accepts a valid ES256K (secp256k1) token and returns the issuer DID', async () => { + const kp = genKeypair('k256'); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(kp.multibase); + const jwt = await mintServiceJwt({ curve: 'k256', privateKey: kp.sk, claims: claims() }); + + const r = await verifyServiceAuth(jwt, { lxm: LXM, now: NOW }); + expect(r.ok).toBe(true); + if (r.ok) expect(r.did).toBe(USER_DID); + }); + + it('accepts a valid ES256 (P-256) token', async () => { + const kp = genKeypair('p256'); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(kp.multibase); + const jwt = await mintServiceJwt({ curve: 'p256', privateKey: kp.sk, claims: claims() }); + + const r = await verifyServiceAuth(jwt, { lxm: LXM, now: NOW }); + expect(r.ok).toBe(true); + }); + + it('rejects a token whose audience is not Skyreader', async () => { + const kp = genKeypair(); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(kp.multibase); + const jwt = await mintServiceJwt({ + privateKey: kp.sk, + claims: claims({ aud: 'did:plc:someoneelse' }), + }); + + const r = await verifyServiceAuth(jwt, { lxm: LXM, now: NOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toMatch(/audience/i); + }); + + it('rejects an expired token', async () => { + const kp = genKeypair(); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(kp.multibase); + const jwt = await mintServiceJwt({ privateKey: kp.sk, claims: claims({ exp: NOW - 120 }) }); + + const r = await verifyServiceAuth(jwt, { lxm: LXM, now: NOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toMatch(/expired/i); + }); + + it('rejects a token whose lifetime exceeds the allowed maximum', async () => { + const kp = genKeypair(); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(kp.multibase); + // exp far in the future (1 hour out) — valid signature, but too long-lived. + const jwt = await mintServiceJwt({ privateKey: kp.sk, claims: claims({ exp: NOW + 3600 }) }); + + const r = await verifyServiceAuth(jwt, { lxm: LXM, now: NOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toMatch(/lifetime/i); + }); + + it('rejects a token minted for a different method (lxm)', async () => { + const kp = genKeypair(); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(kp.multibase); + const jwt = await mintServiceJwt({ privateKey: kp.sk, claims: claims() }); // lxm = save + + const r = await verifyServiceAuth(jwt, { lxm: 'app.skyreader.linkblog.share', now: NOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toMatch(/method/i); + }); + + it('rejects a token with a corrupted signature', async () => { + const kp = genKeypair(); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(kp.multibase); + const jwt = corruptSignature(await mintServiceJwt({ privateKey: kp.sk, claims: claims() })); + + const r = await verifyServiceAuth(jwt, { lxm: LXM, now: NOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toMatch(/signature/i); + }); + + it('rejects when the header alg does not match the signing key curve', async () => { + const kp = genKeypair('k256'); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(kp.multibase); + // Signed with a secp256k1 key but the header claims ES256 (P-256). + const jwt = await mintServiceJwt({ + curve: 'k256', + privateKey: kp.sk, + claims: claims(), + alg: 'ES256', + }); + + const r = await verifyServiceAuth(jwt, { lxm: LXM, now: NOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toMatch(/curve/i); + }); + + it('rejects when the issuer signing key cannot be resolved', async () => { + const kp = genKeypair(); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(null); + const jwt = await mintServiceJwt({ privateKey: kp.sk, claims: claims() }); + + const r = await verifyServiceAuth(jwt, { lxm: LXM, now: NOW }); + expect(r.ok).toBe(false); + if (!r.ok) expect(r.message).toMatch(/signing key/i); + }); +}); diff --git a/backend/test/xrpc.spec.ts b/backend/test/xrpc.spec.ts new file mode 100644 index 00000000..cefe73ef --- /dev/null +++ b/backend/test/xrpc.spec.ts @@ -0,0 +1,281 @@ +import { env, createExecutionContext, waitOnExecutionContext } from 'cloudflare:test'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import worker from '../src/index'; +import * as linkblogSync from '../src/services/linkblog-sync'; +import * as didResolver from '../src/utils/did-resolver'; +import { GRANULAR_SCOPES, LINKBLOG_SCOPES } from '../src/config/scopes'; +import { SKYREADER_APP_DID } from '../src/config/identity'; +import { genKeypair, mintServiceJwt } from './helpers/jwt'; + +// Route-level coverage for the AT Intents XRPC service endpoints +// (/xrpc/app.skyreader.feed.save, /xrpc/app.skyreader.feed.subscribe). These pin the +// XRPC wire contract: subject + inputs arrive as query-string params, the same Skyreader +// session auth is reused, and errors come back as { error: , message }. + +const IncomingRequest = Request; + +const DID = 'did:plc:xrpctest'; +const SESSION = 'sess-xrpc'; + +async function setupUser(grantedScopes: string) { + await env.DB.prepare( + `INSERT INTO users (did, handle, pds_url, tier, created_at) VALUES (?, 'xr.bsky.social', 'https://pds.test', 'free', unixepoch()) + ON CONFLICT(did) DO NOTHING` + ) + .bind(DID) + .run(); + await env.DB.prepare( + `INSERT INTO sessions (session_id, did, handle, pds_url, access_token, refresh_token, dpop_private_key, expires_at, granted_scopes) + VALUES (?, ?, 'xr.bsky.social', 'https://pds.test', 'tok', 'rtok', ?, ?, ?)` + ) + .bind(SESSION, DID, JSON.stringify({ kty: 'EC' }), Date.now() + 3_600_000, grantedScopes) + .run(); +} + +async function reset(grantedScopes = GRANULAR_SCOPES) { + await env.DB.prepare('DELETE FROM saved_articles WHERE user_did = ?').bind(DID).run(); + await env.DB.prepare('DELETE FROM subscriptions_cache WHERE user_did = ?').bind(DID).run(); + await env.DB.prepare('DELETE FROM sessions WHERE did = ?').bind(DID).run(); + await env.DB.prepare('DELETE FROM user_settings WHERE user_did = ?').bind(DID).run(); + await env.DB.prepare('DELETE FROM users WHERE did = ?').bind(DID).run(); + await setupUser(grantedScopes); +} + +// XRPC procedures carry subject + inputs in the query string, not a body. +function xrpc( + path: string, + params: Record, + opts: { auth?: boolean; method?: string; bearer?: string } = {} +) { + const { auth = true, method = 'POST', bearer } = opts; + const qs = new URLSearchParams(params).toString(); + const headers: Record = { Origin: env.FRONTEND_URL }; + if (bearer) headers.Authorization = `Bearer ${bearer}`; + else if (auth) headers.Cookie = `session_id=${SESSION}`; + return new IncomingRequest(`http://localhost${path}?${qs}`, { method, headers }); +} + +function serviceJwt(opts: { + privateKey: Uint8Array; + iss: string; + lxm: string; + aud?: string; + expInSec?: number; +}) { + return mintServiceJwt({ + curve: 'k256', + privateKey: opts.privateKey, + claims: { + iss: opts.iss, + aud: opts.aud ?? SKYREADER_APP_DID, + lxm: opts.lxm, + exp: Math.floor(Date.now() / 1000) + (opts.expInSec ?? 60), + }, + }); +} + +async function call(req: Request): Promise<{ status: number; body: any }> { + const ctx = createExecutionContext(); + const res = await worker.fetch(req, env, ctx); + await waitOnExecutionContext(ctx); + const text = await res.text(); + return { status: res.status, body: text ? JSON.parse(text) : null }; +} + +describe('XRPC /xrpc/app.skyreader.feed.save', () => { + beforeEach(() => reset()); + + it('saves an article from the subject URL and returns success', async () => { + const { status } = await call( + xrpc('/xrpc/app.skyreader.feed.save', { + subject: 'https://example.com/post', + title: 'Hello', + }) + ); + expect(status).toBeLessThan(300); + + const row = await env.DB.prepare('SELECT url, title FROM saved_articles WHERE user_did = ?') + .bind(DID) + .first<{ url: string; title: string | null }>(); + expect(row).not.toBeNull(); + expect(row!.url).toBe('https://example.com/post'); + expect(row!.title).toBe('Hello'); + }); + + it('rejects a missing subject with XRPC InvalidRequest (400)', async () => { + const { status, body } = await call(xrpc('/xrpc/app.skyreader.feed.save', {})); + expect(status).toBe(400); + expect(body.error).toBe('InvalidRequest'); + expect(typeof body.message).toBe('string'); + }); + + it('rejects an unauthenticated call with XRPC AuthenticationRequired (401)', async () => { + const { status, body } = await call( + xrpc( + '/xrpc/app.skyreader.feed.save', + { subject: 'https://example.com/post' }, + { auth: false } + ) + ); + expect(status).toBe(401); + expect(body.error).toBe('AuthenticationRequired'); + }); + + it('rejects GET (this is a POST procedure) with MethodNotImplemented (405)', async () => { + const { status, body } = await call( + xrpc( + '/xrpc/app.skyreader.feed.save', + { subject: 'https://example.com/post' }, + { method: 'GET' } + ) + ); + expect(status).toBe(405); + expect(body.error).toBe('MethodNotImplemented'); + }); +}); + +describe('XRPC /xrpc/app.skyreader.feed.subscribe', () => { + beforeEach(() => reset()); + + it('subscribes to the subject feed URL and stores it in D1', async () => { + const { status } = await call( + xrpc('/xrpc/app.skyreader.feed.subscribe', { + subject: 'https://example.com/feed.xml', + category: 'tech', + }) + ); + expect(status).toBeLessThan(300); + + const row = await env.DB.prepare( + 'SELECT feed_url, category FROM subscriptions_cache WHERE user_did = ?' + ) + .bind(DID) + .first<{ feed_url: string; category: string | null }>(); + expect(row).not.toBeNull(); + expect(row!.feed_url).toBe('https://example.com/feed.xml'); + expect(row!.category).toBe('tech'); + }); + + it('rejects a missing subject with XRPC InvalidRequest (400)', async () => { + const { status, body } = await call(xrpc('/xrpc/app.skyreader.feed.subscribe', {})); + expect(status).toBe(400); + expect(body.error).toBe('InvalidRequest'); + }); +}); + +describe('XRPC /xrpc/app.skyreader.linkblog.share', () => { + const LINKBLOG_SESSION_SCOPES = `${GRANULAR_SCOPES} ${LINKBLOG_SCOPES.join(' ')}`; + + beforeEach(() => reset(LINKBLOG_SESSION_SCOPES)); + afterEach(() => vi.restoreAllMocks()); + + it('shares the subject URL via the linkblog (publication-linking handled server-side)', async () => { + // The underlying write touches the PDS — stub it so the test stays hermetic. The + // point is that the XRPC wrapper delegates with the right inputs, not the PDS write. + const spy = vi + .spyOn(linkblogSync, 'writeLinkblogShare') + .mockResolvedValue({ success: true, data: { uri: 'at://x/doc', cid: 'cid1' } } as any); + + const { status } = await call( + xrpc('/xrpc/app.skyreader.linkblog.share', { + subject: 'https://example.com/post', + title: 'A title', + note: 'worth a read', + }) + ); + expect(status).toBeLessThan(300); + + expect(spy).toHaveBeenCalledTimes(1); + const [, , rkey, input] = spy.mock.calls[0]; + expect(rkey).toMatch(/^[a-z0-9]{13,}$/); // freshly minted TID + expect(input.articleUrl).toBe('https://example.com/post'); + expect(input.articleTitle).toBe('A title'); + expect(input.note).toBe('worth a read'); + }); + + it('rejects a missing subject with XRPC InvalidRequest (400)', async () => { + const { status, body } = await call(xrpc('/xrpc/app.skyreader.linkblog.share', {})); + expect(status).toBe(400); + expect(body.error).toBe('InvalidRequest'); + }); + + it('rejects an unauthenticated call with XRPC AuthenticationRequired (401)', async () => { + const { status, body } = await call( + xrpc( + '/xrpc/app.skyreader.linkblog.share', + { subject: 'https://example.com/post' }, + { auth: false } + ) + ); + expect(status).toBe(401); + expect(body.error).toBe('AuthenticationRequired'); + }); +}); + +describe('XRPC service auth (atproto inter-service JWT)', () => { + beforeEach(() => reset()); // user + stored session for DID, GRANULAR scopes + afterEach(() => vi.restoreAllMocks()); + + it('verifies a service-auth JWT, maps the DID to the stored session, and saves', async () => { + const kp = genKeypair('k256'); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(kp.multibase); + const jwt = await serviceJwt({ privateKey: kp.sk, iss: DID, lxm: 'app.skyreader.feed.save' }); + + const { status } = await call( + xrpc( + '/xrpc/app.skyreader.feed.save', + { subject: 'https://svc.example/post' }, + { auth: false, bearer: jwt } + ) + ); + expect(status).toBeLessThan(300); + + const row = await env.DB.prepare('SELECT url FROM saved_articles WHERE user_did = ?') + .bind(DID) + .first<{ url: string }>(); + expect(row?.url).toBe('https://svc.example/post'); + }); + + it('rejects a valid JWT when the user has no stored Skyreader session (401)', async () => { + const kp = genKeypair('k256'); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(kp.multibase); + const jwt = await serviceJwt({ + privateKey: kp.sk, + iss: 'did:plc:nosessionuser', + lxm: 'app.skyreader.feed.save', + }); + + const { status, body } = await call( + xrpc( + '/xrpc/app.skyreader.feed.save', + { subject: 'https://svc.example/post' }, + { auth: false, bearer: jwt } + ) + ); + expect(status).toBe(401); + expect(body.error).toBe('AuthenticationRequired'); + expect(body.message).toMatch(/no active skyreader session/i); + }); + + it('rejects a JWT scoped to a different method (lxm mismatch)', async () => { + const kp = genKeypair('k256'); + vi.spyOn(didResolver, 'resolveAtprotoSigningKey').mockResolvedValue(kp.multibase); + // A token minted for linkblog.share presented to the save endpoint. + const jwt = await serviceJwt({ + privateKey: kp.sk, + iss: DID, + lxm: 'app.skyreader.linkblog.share', + }); + + const { status, body } = await call( + xrpc( + '/xrpc/app.skyreader.feed.save', + { subject: 'https://svc.example/post' }, + { auth: false, bearer: jwt } + ) + ); + expect(status).toBe(401); + expect(body.error).toBe('AuthenticationRequired'); + expect(body.message).toMatch(/method/i); + }); +});