From 0c8b281472c0860cfffd5f063397df486e9accf6 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 10 Sep 2026 22:45:48 +0600 Subject: [PATCH 1/8] auth: add optional AT Protocol service authentication --- .changeset/optional-service-auth.md | 9 + .env.example | 3 + AGENTS.md | 10 +- README.md | 25 ++- lexicons/org/hypercerts/feed/defs.json | 5 +- lexicons/org/hypercerts/feed/getFeed.json | 2 +- .../org/hypercerts/feed/getFeedSkeleton.json | 2 +- package-lock.json | 58 ++++++ package.json | 2 + src/api/get-feed-skeleton.ts | 24 ++- src/api/get-feed.ts | 24 ++- src/app.ts | 15 +- src/auth/input.ts | 51 ++++++ src/auth/service-auth.ts | 83 +++++++++ src/config.ts | 33 ++++ src/feed/query.ts | 2 +- src/feed/types.ts | 2 +- src/feed/validation.ts | 5 +- src/server.ts | 3 + test/app.unit.test.ts | 166 +++++++++++++++++- test/auth.unit.test.ts | 121 +++++++++++++ test/config.unit.test.ts | 30 +++- test/database.unit.test.ts | 1 + test/feed.integration.test.ts | 1 + test/lexicon.unit.test.ts | 7 + test/query.unit.test.ts | 2 +- 26 files changed, 640 insertions(+), 46 deletions(-) create mode 100644 .changeset/optional-service-auth.md create mode 100644 src/auth/input.ts create mode 100644 src/auth/service-auth.ts create mode 100644 test/auth.unit.test.ts diff --git a/.changeset/optional-service-auth.md b/.changeset/optional-service-auth.md new file mode 100644 index 0000000..aa97744 --- /dev/null +++ b/.changeset/optional-service-auth.md @@ -0,0 +1,9 @@ +--- +"@hypercerts/hypercerts-feed-service": minor +--- + +Feed requests can now use AT Protocol service authentication. If a request includes a valid token, the token's issuer becomes the feed viewer. Requests without a token still need `params.viewerDid`. + +Before upgrading, set `SERVICE_DID` to this service's DID, even if you do not use authentication. It must be a bare DID, without `#serviceId`. + +The `did#serviceId` format from AT Protocol Proposal 0014 is not supported yet because `@atproto/lex-server` does not support it. diff --git a/.env.example b/.env.example index 4e33146..35f491e 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,7 @@ DATABASE_URL=postgres://hypercerts_feed_reader:change-me@postgres:5432/hyperindex +SERVICE_DID=did:web:feed.example +SERVICE_AUTH_MAX_AGE_SECONDS=300 +DID_RESOLUTION_TIMEOUT_MS=2000 PORT=3000 HOST=0.0.0.0 METRICS_HOST=0.0.0.0 diff --git a/AGENTS.md b/AGENTS.md index f087806..8dac13f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,14 +2,14 @@ ## Read this first -This repository is a standalone, read-only TypeScript service exposing two unauthenticated POST procedures: +This repository is a standalone, read-only TypeScript service exposing two POST procedures with optional AT Protocol service authentication: ```text org.hypercerts.feed.getFeedSkeleton org.hypercerts.feed.getFeed ``` -The skeleton returns URI-only generic feed subjects. The hydrated endpoint returns generic feed entries with validated feed-specific views and actor summaries. Hyperindex is the only supported database owner. The service reads its current PostgreSQL state directly; it does not ingest, authenticate, write records, own migrations, cache across requests, call Hyperindex/PDS/AppView APIs, download blobs, hydrate target records, or provide immutable history. +The skeleton returns URI-only generic feed subjects. The hydrated endpoint returns generic feed entries with validated feed-specific views and actor summaries. Hyperindex is the only supported database owner. The service reads its current PostgreSQL state directly; it does not ingest, write records, own migrations, cache across requests, call Hyperindex/PDS/AppView APIs, download blobs, hydrate target records, or provide immutable history. Optional service auth verifies the issuer's `#atproto` signing key, audience, expiry, endpoint binding, and DID document over a bounded secure resolver; the verified issuer, never the body, supplies an authenticated viewer. Use **npm**, not pnpm. `package-lock.json` is authoritative. Node.js 22.13+ is supported; CI and Docker use Node.js 24. PostgreSQL 16+ is required. @@ -89,7 +89,7 @@ src/server.ts Ownership: -- `src/server.ts` is the composition root. It creates the registered Hypercerts feed, one shared registry, separate endpoint services, and one identity adapter; it also owns listener settings, initial readiness, and graceful shutdown. +- `src/server.ts` is the composition root. It creates the registered Hypercerts feed, one shared registry, separate endpoint services, one identity adapter, and the configured optional service-auth verifier; it also owns listener settings, initial readiness, and graceful shutdown. - `src/app.ts` is the fetch-compatible boundary. It owns fixed route metadata, POST enforcement, the 64 KiB body limit, malformed JSON, routed validation messages, and bounded request metrics. - `src/api/get-feed-skeleton.ts` and `src/api/get-feed.ts` register the procedures, run generated output validation inside the error boundary, and translate expected `FeedError` values. - `src/feed/service.ts` projects registry-selected metadata rows into the public skeleton. It does not own dispatch, cursor, or pagination policy. @@ -141,7 +141,7 @@ A request, response, event kind, view, or public-error change normally requires Preserve these unless the public contract is intentionally revised and documented: - Both procedures accept the same `{ feedId, params?, limit?, cursor? }` wrapper. `limit` and `cursor` are generic top-level pagination controls; `params` contains only algorithm-specific values and may be omitted for feeds that declare no params contract. The public params union remains open for future feeds. Runtime dispatch rejects an unregistered `feedId` with `UnsupportedFeed`, while the current Hypercerts feed rejects missing params or a mismatched params discriminator with `InvalidRequest` before querying. -- The base scope always resolves from the viewer's current Certified follows. There is no caller-supplied author override. +- The base scope always resolves from the normalized viewer's current Certified follows. Anonymous requests require `params.viewerDid`; authenticated requests may omit it, but a supplied value must exactly match the verified service-auth issuer. There is no caller-supplied author override or body-based override of authenticated identity. - Deduplicate request lists before enforcing semantic limits: 64 evaluators, 16 kinds, and 1–50 page items. - Evaluator endorsement subjects are unioned after base-author resolution. Remove the viewer and deduplicate candidates. Do not query actor status: Hyperindex purges source records for explicitly deleted, deactivated, suspended, or taken-down identities, and actors absent from `actor` remain eligible. - Omitted or empty `kinds` means all supported kinds. Unknown kinds fail with the generic `InvalidRequest` error and an actionable Hypercerts-parameter message. @@ -181,7 +181,7 @@ Rate limiting belongs at the gateway. Keep `/health` and `/ready` private. ## MVP exclusions -Do not add ingestion, writes, authentication, cross-request caching, immutable history, Hyperindex/PDS/AppView API calls, blob downloads/proxying, target-record reads, target previews, recursive/detail hydration, activity-label hydration, preference persistence, or migrations/indexes. +Do not add ingestion, writes, stronger authentication modes, cross-request caching, immutable history, Hyperindex/PDS/AppView API calls, blob downloads/proxying, target-record reads, target previews, recursive/detail hydration, activity-label hydration, preference persistence, or migrations/indexes. Service-auth verification is limited to the maintained AT Protocol verifier and the bounded DID-resolution boundary described above; never log authorization credentials, JWTs, or claims. ## Change checklist diff --git a/README.md b/README.md index 17f23d3..3493b9f 100644 --- a/README.md +++ b/README.md @@ -3,13 +3,13 @@ A standalone, read-only TypeScript service. It reads the current PostgreSQL data owned by Hyperindex and serves ordered Hypercerts feeds over XRPC. -Hyperindex is the only supported owner of the database. The service provides a URI-only skeleton and a hydrated feed with generic entries and feed-specific views. It does not ingest or change indexed data, import Hyperindex code, call the Hyperindex GraphQL API, authenticate callers, fetch blob bytes, or provide an unchangeable event history. +Hyperindex is the only supported owner of the database. The service provides a URI-only skeleton and a hydrated feed with generic entries and feed-specific views. It does not ingest or change indexed data, import Hyperindex code, call the Hyperindex GraphQL API, write records, fetch blob bytes, or provide an unchangeable event history. Feed authentication is optional AT Protocol service authentication; it never trusts a body viewer over the verified issuer. ## Endpoints `GET /` returns a small JSON description of the service and lists its public XRPC procedures. It does not query the database or report service readiness. -Both feed endpoints are unauthenticated POST procedures. They use the same `{ feedId, params?, limit?, cursor? }` request wrapper, feed-scoped cursor contract, and stable public errors. `params` contains only algorithm-specific values; pagination is generic and top-level. The registered Hypercerts feed requires `params.viewerDid` to choose the viewer scope. The service does not check it against an authenticated caller: +Both feed endpoints are POST procedures with optional AT Protocol service authentication. They use the same `{ feedId, params?, limit?, cursor? }` request wrapper, feed-scoped cursor contract, and stable public errors. `params` contains only algorithm-specific values; pagination is generic and top-level. The registered Hypercerts feed requires the Hypercerts params object and discriminator. Anonymous requests must include `params.viewerDid`; authenticated callers may omit it, and the verified JWT issuer supplies the viewer. A supplied viewer DID must match the issuer: ```text POST /xrpc/org.hypercerts.feed.getFeedSkeleton @@ -41,7 +41,7 @@ curl -sS http://localhost:3000/xrpc/org.hypercerts.feed.getFeed \ }' ``` -Use the same body with `getFeedSkeleton` when another data system needs only ordered source AT-URIs. The public params union is open for future feed algorithms, including algorithms that accept no params. This service currently registers only `org.hypercerts.feed.defs#hypercertsFeed` and requires `org.hypercerts.feed.defs#hypercertsFeedParams`; missing params or a mismatched `$type` returns `InvalidRequest`, while an unknown `feedId` returns `UnsupportedFeed`. +Use the same body with `getFeedSkeleton` when another data system needs only ordered source AT-URIs. A service caller may add `-H 'authorization: Bearer '` and omit `viewerDid`; the token must target the bare DID configured as `SERVICE_DID`, carry the exact endpoint NSID in `lxm`, and be signed by the issuer's `#atproto` key. Invalid supplied authorization returns 401 and is never treated as anonymous. The public params union is open for future feed algorithms, including algorithms that accept no params. This service currently registers only `org.hypercerts.feed.defs#hypercertsFeed` and requires `org.hypercerts.feed.defs#hypercertsFeedParams`; missing params or a mismatched `$type` returns `InvalidRequest`, while an unknown `feedId` returns `UnsupportedFeed`. ### Skeleton response @@ -123,9 +123,10 @@ sequenceDiagram participant Identity as Identity Reader participant DB as Hyperindex PostgreSQL - Client->>HTTP: POST feed procedure + Client->>HTTP: POST feed procedure + optional Bearer JWT HTTP->>XRPC: Bounded, validated JSON - XRPC->>Service: Endpoint input + XRPC->>XRPC: Verify audience, expiry, lxm, #atproto signature + XRPC->>Service: Endpoint input + trusted viewer DID Service->>Registry: Load metadata or source-aware page Registry->>Feed: Dispatch feedId, optional params, and pagination Feed->>DB: One normalized Hyperindex selection statement @@ -148,7 +149,9 @@ sequenceDiagram ## Request behavior -- Malformed JSON, missing required params, an invalid nested `viewerDid`, params that do not match the selected feed, or structurally invalid top-level pagination return HTTP 400 with `InvalidRequest`. Semantically invalid selected-feed parameters or pagination beyond that feed's supported range return HTTP 422 with the same generic error name and an actionable message. An unregistered `feedId` returns `UnsupportedFeed`. These never become internal server errors. +- Malformed JSON, missing required params, anonymous requests without `params.viewerDid`, an authenticated supplied viewer mismatch, an invalid nested `viewerDid`, params that do not match the selected feed, or structurally invalid top-level pagination return HTTP 400 with `InvalidRequest`. Semantically invalid selected-feed parameters or pagination beyond that feed's supported range return HTTP 422 with the same generic error name and an actionable message. An unregistered `feedId` returns `UnsupportedFeed`. These never become internal server errors. +- A present `Authorization` header is always verified. Malformed, expired, not-yet-valid, wrong-audience, wrong-endpoint, unresolved-issuer, or incorrectly signed service JWTs return HTTP 401; the request is never retried anonymously. DID documents are resolved over bounded HTTPS-only requests with direct public-address pinning; `did:web` redirects and private, loopback, link-local, reserved, and special-use destinations are rejected. +- Service-auth JWTs include a signed `jti` identifier, which the AT Protocol specification recommends receivers use when replay prevention matters. This service deliberately accepts but does not track or otherwise act on `jti`; it keeps no replay cache because the endpoints are public and read-only, anonymous callers can request the same feed by supplying `params.viewerDid`, and repeated-request abuse belongs at the gateway. A valid token can therefore be reused until it expires. - The base scope always comes from the viewer's current `app.certified.graph.follow` records. The service ignores malformed follow subjects. - `trustedEvaluators` adds the subjects of every current, active endorsement award from each evaluator. - An endorsement definition with no `allowedIssuers` allows any issuer. When it is present, only its listed issuer DIDs qualify. An empty or malformed value allows no issuers. @@ -216,6 +219,9 @@ For local development, copy `.env.example` to `.env`. For deployment, copy its v | Variable | Required | Default | Purpose | |---|---:|---:|---| | `DATABASE_URL` | yes | | Dedicated read-only Postgres URL for the Hyperindex database | +| `SERVICE_DID` | yes | | DID identifying this service and required as the service-auth JWT audience | +| `SERVICE_AUTH_MAX_AGE_SECONDS` | no | `300` | Maximum service-auth JWT age; bounded from 1 through 3600 seconds | +| `DID_RESOLUTION_TIMEOUT_MS` | no | `2000` | Total timeout for one HTTPS DID-document resolution; bounded from 100 through 60000 ms | | `PORT` | no | `3000` | HTTP listen port | | `HOST` | no | `0.0.0.0` | Public HTTP listen interface | | `METRICS_HOST` | no | `0.0.0.0` | Private metrics listen interface; used only when `METRICS_PORT` is set | @@ -229,6 +235,8 @@ For local development, copy `.env.example` to `.env`. For deployment, copy its v | `GRACEFUL_SHUTDOWN_MS` | no | `10000` | Shutdown drain timeout | | `TRUSTED_QUALITY_LABELER_DIDS` | no | empty | Comma-separated Orglabeler trust roots | +Service-auth audience verification currently accepts only the bare `SERVICE_DID`. It does not yet accept the combined `did#serviceId` audience proposed by [AT Protocol Proposal 0014](https://github.com/bluesky-social/proposals/blob/main/0014-service-auth-revised/README.md) because `@atproto/lex-server` does not support that audience form. Clients may still use a combined service reference for PDS proxy routing while the reference PDS emits a bare-DID service-auth audience. The verifier should be updated to accept an explicit allowlist of both forms when upstream support becomes available. + If there are no configured trusted labelers, known organizations count as unrated whenever the request includes an organization-quality policy. ## Database access @@ -310,6 +318,7 @@ See [`docs/RELEASING.md`](docs/RELEASING.md) for the contributor and maintainer docker build -t hypercerts-feed-service . docker run --rm -p 3000:3000 \ -e DATABASE_URL='postgresql://...' \ + -e SERVICE_DID='did:web:feed.example' \ -e TRUSTED_QUALITY_LABELER_DIDS='did:plc:ar7c4by46qjdydhdevvrndac' \ hypercerts-feed-service ``` @@ -327,7 +336,7 @@ Configure a compatible Prometheus collector to read `http://=22" } }, + "node_modules/@atproto-labs/fetch-node": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/@atproto-labs/fetch-node/-/fetch-node-0.3.7.tgz", + "integrity": "sha512-WZvjXIeEHGeOPmeckyGiSGeoHhkvzwnKtSrRSxgnHakkNqlKNLmPbUIYOIiJgiYgBSOCbse5NpPLRu3Zdw2+gA==", + "license": "MIT", + "dependencies": { + "@atproto-labs/fetch": "^0.3.5", + "@atproto-labs/pipe": "^0.2.4", + "ipaddr.js": "^2.1.0", + "undici_v6": "npm:undici@^6.x", + "undici_v7": "npm:undici@^7.x", + "undici_v8": "npm:undici@^8.x" + }, + "engines": { + "node": ">=22" + } + }, "node_modules/@atproto-labs/pipe": { "version": "0.2.4", "resolved": "https://registry.npmjs.org/@atproto-labs/pipe/-/pipe-0.2.4.tgz", @@ -2297,6 +2316,15 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/ipaddr.js": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz", + "integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==", + "license": "MIT", + "engines": { + "node": ">= 10" + } + }, "node_modules/iso-datestring-validator": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/iso-datestring-validator/-/iso-datestring-validator-2.2.2.tgz", @@ -3316,6 +3344,36 @@ "multiformats": "^13.0.0" } }, + "node_modules/undici_v6": { + "name": "undici", + "version": "6.28.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.1.tgz", + "integrity": "sha512-zWpdTVD54H48CIybL0rWQ3ukpb9d23wM7eH5RtfdmeP70cWHNjtfo7P4vZX+5CoDcO53J4Pu5uXp7lNfjc6DRA==", + "license": "MIT", + "engines": { + "node": ">=18.17" + } + }, + "node_modules/undici_v7": { + "name": "undici", + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.1.tgz", + "integrity": "sha512-RYONW2MeafgYlkVOKYKkA/Ag7BmXqgIWCa8t1m0JcxrQg9pI9lEqRhAOruOBCbAohOa/gkCF+iPi9hrgvTzu6Q==", + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici_v8": { + "name": "undici", + "version": "8.10.2", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.2.tgz", + "integrity": "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/undici-types": { "version": "7.18.2", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", diff --git a/package.json b/package.json index 83baf04..3d1da6e 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "start": "node dist/server.js" }, "dependencies": { + "@atproto-labs/did-resolver": "^0.3.7", + "@atproto-labs/fetch-node": "^0.3.7", "@atproto/lex": "^0.3.8", "@atproto/lex-server": "^0.1.9", "@atproto/lexicon": "0.6.2", diff --git a/src/api/get-feed-skeleton.ts b/src/api/get-feed-skeleton.ts index fff2ec8..b64e1c9 100644 --- a/src/api/get-feed-skeleton.ts +++ b/src/api/get-feed-skeleton.ts @@ -1,6 +1,8 @@ import { LexRouter, LexServerError } from '@atproto/lex-server' import type { Logger } from 'pino' +import { applyAuthenticatedViewer } from '../auth/input.js' +import type { OptionalServiceAuth, ServiceAuthCredentials } from '../auth/service-auth.js' import { FeedError, FeedErrorCode } from '../feed/errors.js' import type { FeedSkeletonReader } from '../feed/service.js' import type { GetFeedSkeletonInput } from '../feed/types.js' @@ -13,11 +15,15 @@ export const registerGetFeedSkeleton = ( router: LexRouter, feedService: FeedSkeletonReader, logger: Logger, + auth?: OptionalServiceAuth, ): void => { - router.add(getFeedSkeleton, async ({ input }) => { + const handler = async ( + input: GetFeedSkeletonInput, + credentials: ServiceAuthCredentials | undefined, + ) => { try { const output = await feedService.getFeedSkeleton( - input.body as GetFeedSkeletonInput, + applyAuthenticatedViewer(input, credentials), ) return { body: $output.schema.$parse(output) } } catch (cause) { @@ -42,5 +48,17 @@ export const registerGetFeedSkeleton = ( { cause }, ) } - }) + } + + if (auth === undefined) { + router.add(getFeedSkeleton, async ({ input }) => + handler(input.body as GetFeedSkeletonInput, undefined), + ) + } else { + router.add(getFeedSkeleton, { + auth, + handler: async ({ input, credentials }) => + handler(input.body as GetFeedSkeletonInput, credentials), + }) + } } diff --git a/src/api/get-feed.ts b/src/api/get-feed.ts index 728f636..bfbfa2d 100644 --- a/src/api/get-feed.ts +++ b/src/api/get-feed.ts @@ -1,6 +1,8 @@ import { LexRouter, LexServerError } from '@atproto/lex-server' import type { Logger } from 'pino' +import { applyAuthenticatedViewer } from '../auth/input.js' +import type { OptionalServiceAuth, ServiceAuthCredentials } from '../auth/service-auth.js' import { FeedError, FeedErrorCode } from '../feed/errors.js' import type { GetFeedSkeletonInput } from '../feed/types.js' import type { HydratedFeedReader } from '../hydration/service.js' @@ -13,11 +15,15 @@ export const registerGetFeed = ( router: LexRouter, feedService: HydratedFeedReader, logger: Logger, + auth?: OptionalServiceAuth, ): void => { - router.add(getFeed, async ({ input }) => { + const handler = async ( + input: GetFeedSkeletonInput, + credentials: ServiceAuthCredentials | undefined, + ) => { try { const output = await feedService.getFeed( - input.body as GetFeedSkeletonInput, + applyAuthenticatedViewer(input, credentials), ) return { body: $output.schema.$parse(output) } } catch (cause) { @@ -42,5 +48,17 @@ export const registerGetFeed = ( { cause }, ) } - }) + } + + if (auth === undefined) { + router.add(getFeed, async ({ input }) => + handler(input.body as GetFeedSkeletonInput, undefined), + ) + } else { + router.add(getFeed, { + auth, + handler: async ({ input, credentials }) => + handler(input.body as GetFeedSkeletonInput, credentials), + }) + } } diff --git a/src/app.ts b/src/app.ts index 4fd87af..208d29a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -5,6 +5,7 @@ import type { Logger } from 'pino' import { registerGetFeedSkeleton } from './api/get-feed-skeleton.js' import { registerGetFeed } from './api/get-feed.js' +import type { OptionalServiceAuth } from './auth/service-auth.js' import type { DatabaseCompatibilityChecker } from './database.js' import { FeedErrorCode } from './feed/errors.js' import type { FeedSkeletonReader } from './feed/service.js' @@ -93,12 +94,15 @@ const corsPreflightResponse = (request: Request): Response => { const unsupportedHeader = requestedHeaders ?.split(',') .map((value) => value.trim().toLowerCase()) - .find((value) => value !== '' && value !== 'content-type') + .find( + (value) => + value !== '' && value !== 'content-type' && value !== 'authorization', + ) if (unsupportedHeader !== undefined) { return jsonResponse( { error: FeedErrorCode.InvalidRequest, - message: `This feed preflight requests unsupported header ${JSON.stringify(unsupportedHeader)}; retry with content-type only.`, + message: `This feed preflight requests unsupported header ${JSON.stringify(unsupportedHeader)}; retry with content-type and authorization only.`, }, 400, ) @@ -108,7 +112,7 @@ const corsPreflightResponse = (request: Request): Response => { status: 204, headers: { 'access-control-allow-methods': 'POST', - 'access-control-allow-headers': 'content-type', + 'access-control-allow-headers': 'content-type, authorization', 'access-control-max-age': '600', }, }) @@ -331,6 +335,7 @@ export const createApp = ( services: AppFeedServices, metrics: Metrics, logger: Logger, + auth?: OptionalServiceAuth, ): { fetch: FetchHandler } => { const router = new LexRouter({ onHandlerError: ({ error, method }) => { @@ -338,8 +343,8 @@ export const createApp = ( logger.error({ err: error, nsid: method.nsid }, 'unexpected XRPC handler error') }, }) - registerGetFeedSkeleton(router, services.skeleton, logger) - registerGetFeed(router, services.hydrated, logger) + registerGetFeedSkeleton(router, services.skeleton, logger, auth) + registerGetFeed(router, services.hydrated, logger, auth) const fetch: FetchHandler = async (request) => { const startedAt = performance.now() diff --git a/src/auth/input.ts b/src/auth/input.ts new file mode 100644 index 0000000..67650d0 --- /dev/null +++ b/src/auth/input.ts @@ -0,0 +1,51 @@ +import { FeedError, FeedErrorCode } from '../feed/errors.js' +import { + HYPERCERTS_FEED_PARAMS_TYPE, + type HypercertsFeedParams, + type GetFeedSkeletonInput, +} from '../feed/types.js' +import type { ServiceAuthCredentials } from './service-auth.js' + +/** + * Applies the verified caller DID to the internal feed request. The body may + * omit viewerDid only for authenticated requests; a supplied value is checked + * before it is replaced by the verified identity. + */ +export const applyAuthenticatedViewer = ( + input: GetFeedSkeletonInput, + credentials: ServiceAuthCredentials | undefined, +): GetFeedSkeletonInput => { + const params = input.params + if (params?.$type !== HYPERCERTS_FEED_PARAMS_TYPE) { + return input + } + const hypercertsParams = params as HypercertsFeedParams + + if (credentials === undefined) { + if (hypercertsParams.viewerDid === undefined) { + throw new FeedError( + FeedErrorCode.InvalidRequest, + 'params.viewerDid is required for anonymous requests; provide the viewer DID or send a valid service-auth bearer token and retry.', + ) + } + return input + } + + if ( + hypercertsParams.viewerDid !== undefined && + hypercertsParams.viewerDid !== credentials.did + ) { + throw new FeedError( + FeedErrorCode.InvalidRequest, + 'params.viewerDid does not match the authenticated service caller; omit viewerDid or use the authenticated viewer DID and retry.', + ) + } + + return { + ...input, + params: { + ...hypercertsParams, + viewerDid: credentials.did, + }, + } +} diff --git a/src/auth/service-auth.ts b/src/auth/service-auth.ts new file mode 100644 index 0000000..a68fd83 --- /dev/null +++ b/src/auth/service-auth.ts @@ -0,0 +1,83 @@ +import { + LexServerAuthError, + serviceAuth, + type LexRouterAuth, + type ServiceAuthCredentials, + type ServiceAuthOptions, +} from '@atproto/lex-server' +import { createDidResolver } from '@atproto-labs/did-resolver' +import { safeFetchWrap } from '@atproto-labs/fetch-node' +import type { DidString } from '@atproto/syntax' + +import type { Config } from '../config.js' + +export type OptionalServiceAuth = LexRouterAuth< + ServiceAuthCredentials | undefined +> + +type OptionalServiceAuthOptions = Omit +const authChallenge = { + Bearer: { error: 'BadJwtLexiconMethod' }, +} as const + +/** + * Makes AT Protocol service authentication optional without treating malformed + * credentials as anonymous requests. The built-in verifier handles signature, + * audience, expiry, DID resolution, and key rotation. + */ +export const createOptionalServiceAuth = ( + options: OptionalServiceAuthOptions, +): OptionalServiceAuth => { + // The upstream callback checks a non-canonical nonce before signature + // verification. This public, read-only service deliberately keeps no replay + // state; authenticated and anonymous callers can request the same feed data. + const verify = serviceAuth({ ...options, unique: async () => true }) + + return async (context) => { + if (context.request.headers.get('authorization') === null) { + return undefined + } + + const credentials = await verify(context) + if (credentials.jwt.payload.lxm !== context.method.nsid) { + throw new LexServerAuthError( + 'AuthenticationRequired', + 'JWT lexicon method is missing or does not match this endpoint; request a token with the exact endpoint NSID in lxm.', + authChallenge, + ) + } + return credentials + } +} + +/** Builds the production auth verifier and its bounded DID resolver. */ +export const createConfiguredServiceAuth = ( + config: Pick< + Config, + 'serviceDid' | 'serviceAuthMaxAgeSeconds' | 'didResolutionTimeoutMs' + >, +): OptionalServiceAuth => { + const didResolver = createDidResolver({ + allowHttp: false, + fetch: safeFetchWrap({ + responseMaxSize: 128 * 1024, + ssrfProtection: true, + allowHttp: false, + allowCustomPort: false, + allowIpHost: false, + allowPrivateIps: false, + timeout: config.didResolutionTimeoutMs, + allowImplicitRedirect: false, + }), + }) + + // lex-server currently rejects Proposal 0014 did#serviceId audiences. + // Keep the bare-DID audience until upstream can accept both forms explicitly. + return createOptionalServiceAuth({ + audience: config.serviceDid as DidString, + maxAge: config.serviceAuthMaxAgeSeconds, + didResolver, + }) +} + +export type { ServiceAuthCredentials } diff --git a/src/config.ts b/src/config.ts index a4771df..cf68cd5 100644 --- a/src/config.ts +++ b/src/config.ts @@ -24,6 +24,12 @@ export interface Config { readonly requestTimeoutMs: number /** Maximum time graceful shutdown waits for active requests. */ readonly gracefulShutdownMs: number + /** DID of this service; required as the service-auth JWT audience. */ + readonly serviceDid: string + /** Maximum accepted age of a service-auth JWT, in seconds. */ + readonly serviceAuthMaxAgeSeconds: number + /** Maximum time allowed for one DID document resolution. */ + readonly didResolutionTimeoutMs: number /** Orglabeler DIDs trusted to provide account-quality labels. */ readonly trustedQualityLabelerDids: readonly string[] /** Pino logging threshold. */ @@ -93,6 +99,18 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => { ) } + const serviceDid = env.SERVICE_DID + if (!serviceDid) { + throw new Error( + 'SERVICE_DID is required; set it to the valid DID identifying this service for service-auth JWT audience verification.', + ) + } + if (!isValidDid(serviceDid)) { + throw new Error( + `SERVICE_DID ${JSON.stringify(serviceDid)} is not a valid DID; set it to the DID identifying this service.`, + ) + } + const trustedQualityLabelerDids = [ ...new Set( (env.TRUSTED_QUALITY_LABELER_DIDS ?? '') @@ -119,6 +137,21 @@ export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => { return { host: env.HOST || '0.0.0.0', port, + serviceDid, + serviceAuthMaxAgeSeconds: integerEnv( + env, + 'SERVICE_AUTH_MAX_AGE_SECONDS', + 300, + 1, + 3_600, + ), + didResolutionTimeoutMs: integerEnv( + env, + 'DID_RESOLUTION_TIMEOUT_MS', + 2_000, + 100, + 60_000, + ), metricsHost: env.METRICS_HOST || '0.0.0.0', metricsPort, databaseUrl, diff --git a/src/feed/query.ts b/src/feed/query.ts index 2eab71d..f055d14 100644 --- a/src/feed/query.ts +++ b/src/feed/query.ts @@ -73,7 +73,7 @@ const parseHypercertsFeedParams = ( return { $type: HYPERCERTS_FEED_PARAMS_TYPE, - viewerDid: parsed.viewerDid, + ...(parsed.viewerDid === undefined ? {} : { viewerDid: parsed.viewerDid }), ...(parsed.trustedEvaluators === undefined ? {} : { trustedEvaluators: parsed.trustedEvaluators }), diff --git a/src/feed/types.ts b/src/feed/types.ts index 6b8a551..eaa68cc 100644 --- a/src/feed/types.ts +++ b/src/feed/types.ts @@ -61,7 +61,7 @@ export interface OrganizationQualityPolicyInput { export interface HypercertsFeedParams { readonly $type: typeof HYPERCERTS_FEED_PARAMS_TYPE /** Viewer whose current Certified outbound follows supply the base scope. */ - readonly viewerDid: string + readonly viewerDid?: string /** Evaluators whose active endorsement subjects are added to the base scope. */ readonly trustedEvaluators?: readonly string[] /** Optional organization-quality membership policy. */ diff --git a/src/feed/validation.ts b/src/feed/validation.ts index f38f022..0d17d61 100644 --- a/src/feed/validation.ts +++ b/src/feed/validation.ts @@ -39,7 +39,8 @@ export const normalizeFeedRequest = ( input: HypercertsFeedParams, pagination: FeedPagination = {}, ): NormalizedFeedRequest => { - if (!isValidDid(input.viewerDid)) { + const viewerDid = input.viewerDid + if (typeof viewerDid !== 'string' || !isValidDid(viewerDid)) { throw invalidFeedParams( 'viewerDid is not a valid DID; provide the viewer account as a syntactically valid DID.', ) @@ -90,7 +91,7 @@ export const normalizeFeedRequest = ( } return { - viewerDid: input.viewerDid, + viewerDid, trustedEvaluators, ...(organizationQuality ? { organizationQuality } : {}), limit, diff --git a/src/server.ts b/src/server.ts index 69b0737..556783b 100644 --- a/src/server.ts +++ b/src/server.ts @@ -2,6 +2,7 @@ import { createServer } from '@atproto/lex-server/nodejs' import pino from 'pino' import { createApp } from './app.js' +import { createConfiguredServiceAuth } from './auth/service-auth.js' import { loadConfig } from './config.js' import { Database } from './database.js' import { createHypercertsFeed } from './feed/query.js' @@ -30,11 +31,13 @@ const feeds = new FeedRegistry([hypercertsFeed]) const feedService = new FeedService(feeds) const identities = new PostgresIdentityReader(database) const hydratedFeed = new HydratedFeedService(feeds, identities) +const auth = createConfiguredServiceAuth(config) const app = createApp( database, { skeleton: feedService, hydrated: hydratedFeed }, metrics, logger, + auth, ) metrics.setReady(false) diff --git a/test/app.unit.test.ts b/test/app.unit.test.ts index ee24841..f8f079f 100644 --- a/test/app.unit.test.ts +++ b/test/app.unit.test.ts @@ -1,8 +1,10 @@ import { jsonToLex, type BlobRef } from '@atproto/lex' +import { LexServerAuthError } from '@atproto/lex-server' import pino from 'pino' import { describe, expect, it, vi } from 'vitest' import { createApp, type AppFeedServices } from '../src/app.js' +import type { ServiceAuthCredentials, OptionalServiceAuth } from '../src/auth/service-auth.js' import type { DatabaseCompatibilityChecker } from '../src/database.js' import { FeedError, FeedErrorCode } from '../src/feed/errors.js' import type { FeedSkeletonReader } from '../src/feed/service.js' @@ -55,13 +57,35 @@ const feedRequest = (viewerDid = viewer): GetFeedSkeletonInput => ({ params: { $type: HYPERCERTS_FEED_PARAMS_TYPE, viewerDid }, }) -const post = (url: string, body: string): Request => +const post = ( + url: string, + body: string, + extraHeaders: HeadersInit = {}, +): Request => new Request(url, { method: 'POST', - headers: { 'content-type': 'application/json' }, + headers: { 'content-type': 'application/json', ...extraHeaders }, body, }) +const trustedCredentials = (did: string): ServiceAuthCredentials => + ({ did } as ServiceAuthCredentials) + +const authFor = ( + credentials: ServiceAuthCredentials | undefined, + invalidToken = false, +): OptionalServiceAuth => + vi.fn(async ({ request }) => { + if (invalidToken && request.headers.has('authorization')) { + throw new LexServerAuthError( + 'AuthenticationRequired', + 'Invalid bearer token', + { Bearer: { error: 'InvalidToken' } }, + ) + } + return credentials + }) + describe('HTTP application', () => { it('describes the service at the root route', async () => { const metrics = new Metrics() @@ -134,6 +158,140 @@ describe('HTTP application', () => { }) }) + it.each([ + ['skeleton', skeletonPath], + ['hydrated', hydratedPath], + ] as const)('binds authenticated omitted viewerDid to the %s service', async (_label, url) => { + let received: GetFeedSkeletonInput | undefined + const skeleton: FeedSkeletonReader = { + getFeedSkeleton: vi.fn(async (input) => { + received = input + return { feed: [] } + }), + } + const hydrated: HydratedFeedReader = { + getFeed: vi.fn(async (input) => { + received = input + return { feed: [] } + }), + } + const app = createApp( + compatibleDatabase, + appServices(skeleton, hydrated), + new Metrics(), + logger, + authFor(trustedCredentials(viewer)), + ) + + const response = await app.fetch( + post( + url, + JSON.stringify({ + feedId: HYPERCERTS_FEED_ID, + params: { $type: HYPERCERTS_FEED_PARAMS_TYPE }, + }), + { authorization: 'Bearer trusted-token' }, + ), + ) + + expect(response.status).toBe(200) + expect(received).toMatchObject({ + feedId: HYPERCERTS_FEED_ID, + params: { $type: HYPERCERTS_FEED_PARAMS_TYPE, viewerDid: viewer }, + }) + }) + + it.each([ + ['skeleton', skeletonPath], + ['hydrated', hydratedPath], + ] as const)('rejects an authenticated supplied viewer mismatch on the %s route', async (_label, url) => { + const getFeedSkeleton = vi.fn() + const getFeed = vi.fn() + const app = createApp( + compatibleDatabase, + appServices({ getFeedSkeleton }, { getFeed }), + new Metrics(), + logger, + authFor(trustedCredentials(viewer)), + ) + + const response = await app.fetch( + post( + url, + JSON.stringify(feedRequest(actor)), + { authorization: 'Bearer trusted-token' }, + ), + ) + + expect(response.status).toBe(400) + expect(getFeedSkeleton).not.toHaveBeenCalled() + expect(getFeed).not.toHaveBeenCalled() + await expect(response.json()).resolves.toMatchObject({ + error: 'InvalidRequest', + message: expect.stringContaining('does not match the authenticated'), + }) + }) + + it.each([ + ['skeleton', skeletonPath], + ['hydrated', hydratedPath], + ] as const)('requires viewerDid for anonymous requests on the %s route', async (_label, url) => { + const getFeedSkeleton = vi.fn() + const getFeed = vi.fn() + const app = createApp( + compatibleDatabase, + appServices({ getFeedSkeleton }, { getFeed }), + new Metrics(), + logger, + authFor(undefined), + ) + + const response = await app.fetch( + post( + url, + JSON.stringify({ + feedId: HYPERCERTS_FEED_ID, + params: { $type: HYPERCERTS_FEED_PARAMS_TYPE }, + }), + ), + ) + + expect(response.status).toBe(400) + expect(getFeedSkeleton).not.toHaveBeenCalled() + expect(getFeed).not.toHaveBeenCalled() + await expect(response.json()).resolves.toMatchObject({ + error: 'InvalidRequest', + message: expect.stringContaining('required for anonymous requests'), + }) + }) + + it.each([ + ['skeleton', skeletonPath], + ['hydrated', hydratedPath], + ] as const)('does not fall back to anonymous on invalid auth for the %s route', async (_label, url) => { + const getFeedSkeleton = vi.fn() + const getFeed = vi.fn() + const app = createApp( + compatibleDatabase, + appServices({ getFeedSkeleton }, { getFeed }), + new Metrics(), + logger, + authFor(undefined, true), + ) + const token = 'Bearer invalid-token' + + const response = await app.fetch( + post(url, JSON.stringify(feedRequest()), { authorization: token }), + ) + const body = await response.text() + + expect(response.status).toBe(401) + expect(body).not.toContain(token) + expect(body).not.toContain(viewer) + expect(getFeedSkeleton).not.toHaveBeenCalled() + expect(getFeed).not.toHaveBeenCalled() + }) + it('serves the unauthenticated hydrated POST procedure', async () => { let received: GetFeedSkeletonInput | undefined const hydrated: HydratedFeedReader = { @@ -225,7 +383,7 @@ describe('HTTP application', () => { headers: { origin, 'access-control-request-method': 'POST', - 'access-control-request-headers': 'content-type', + 'access-control-request-headers': 'content-type, authorization', }, }), ) @@ -234,7 +392,7 @@ describe('HTTP application', () => { expect(response.headers.get('access-control-allow-origin')).toBe('*') expect(response.headers.get('access-control-allow-methods')).toBe('POST') expect(response.headers.get('access-control-allow-headers')).toBe( - 'content-type', + 'content-type, authorization', ) expect(response.headers.has('vary')).toBe(false) } diff --git a/test/auth.unit.test.ts b/test/auth.unit.test.ts new file mode 100644 index 0000000..65b42aa --- /dev/null +++ b/test/auth.unit.test.ts @@ -0,0 +1,121 @@ +import { P256Keypair, bytesToMultibase } from '@atproto/crypto' +import { LexServerAuthError } from '@atproto/lex-server' +import type { DidResolver } from '@atproto-labs/did-resolver' +import { describe, expect, it } from 'vitest' + +import { createOptionalServiceAuth } from '../src/auth/service-auth.js' + +const issuerDid = 'did:plc:ar7c4by46qjdydhdevvrndac' +const serviceDid = 'did:web:feed.example' +const skeletonNsid = 'org.hypercerts.feed.getFeedSkeleton' +const hydratedNsid = 'org.hypercerts.feed.getFeed' + +const now = (): number => Math.floor(Date.now() / 1_000) + +const encode = (value: unknown): string => + Buffer.from(JSON.stringify(value)).toString('base64url') + +const signedJwt = async ( + keypair: P256Keypair, + overrides: Record = {}, +): Promise => { + const payload = { + iss: issuerDid, + aud: serviceDid, + iat: now(), + exp: now() + 60, + lxm: skeletonNsid, + ...overrides, + } + const message = `${encode({ alg: keypair.jwtAlg, typ: 'JWT' })}.${encode(payload)}` + const signature = await keypair.sign(new TextEncoder().encode(message)) + return `${message}.${Buffer.from(signature).toString('base64url')}` +} + +const resolverFor = (keypair: P256Keypair): DidResolver => ({ + resolve: async () => ({ + id: issuerDid, + verificationMethod: [ + { + id: `${issuerDid}#atproto`, + type: 'EcdsaSecp256r1VerificationKey2019', + controller: issuerDid, + publicKeyMultibase: bytesToMultibase( + keypair.publicKeyBytes(), + 'base58btc', + ), + }, + ], + }), +}) as unknown as DidResolver + +const authRequest = (authorization: string | undefined): Request => + new Request('http://localhost/xrpc/test', { + headers: authorization === undefined ? {} : { authorization }, + }) + +const authContext = ( + authorization: string | undefined, + nsid = skeletonNsid, +) => ({ + request: authRequest(authorization), + method: { nsid } as any, + params: {}, +}) + +describe('optional AT Protocol service authentication', () => { + it('returns anonymous credentials only when Authorization is absent', async () => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + + await expect(auth(authContext(undefined))).resolves.toBeUndefined() + }) + + it('verifies a signed #atproto JWT and binds it to the endpoint', async () => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const token = await signedJwt(keypair) + + await expect( + auth(authContext(`Bearer ${token}`)), + ).resolves.toMatchObject({ did: issuerDid }) + }) + + it.each([ + ['wrong signature', async () => signedJwt(await P256Keypair.create())], + ['expired token', async (keypair: P256Keypair) => signedJwt(keypair, { exp: now() - 1 })], + ['not-yet-valid token', async (keypair: P256Keypair) => signedJwt(keypair, { nbf: now() + 60 })], + ['wrong audience', async (keypair: P256Keypair) => signedJwt(keypair, { aud: 'did:web:other.example' })], + ['missing lxm', async (keypair: P256Keypair) => signedJwt(keypair, { lxm: undefined })], + ['wrong lxm', async (keypair: P256Keypair) => signedJwt(keypair, { lxm: hydratedNsid })], + ])('rejects %s without anonymous fallback', async (label, makeToken) => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const token = await makeToken(keypair) + + await expect(auth(authContext(`Bearer ${token}`))).rejects.toBeInstanceOf( + LexServerAuthError, + ) + }) + + it('rejects a present malformed Authorization header', async () => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + + await expect(auth(authContext('Basic credentials'))).rejects.toBeInstanceOf( + LexServerAuthError, + ) + }) +}) diff --git a/test/config.unit.test.ts b/test/config.unit.test.ts index dc27f95..36341a7 100644 --- a/test/config.unit.test.ts +++ b/test/config.unit.test.ts @@ -3,15 +3,20 @@ import { describe, expect, it } from 'vitest' import { loadConfig } from '../src/config.js' const databaseUrl = 'postgres://feed:secret@localhost:5432/indexer' +const serviceDid = 'did:web:feed.example' +const validEnvironment = { DATABASE_URL: databaseUrl, SERVICE_DID: serviceDid } describe('loadConfig', () => { it('loads defaults and deduplicates trusted labelers', () => { const config = loadConfig({ - DATABASE_URL: databaseUrl, + ...validEnvironment, TRUSTED_QUALITY_LABELER_DIDS: 'did:plc:ar7c4by46qjdydhdevvrndac,did:plc:ar7c4by46qjdydhdevvrndac', }) + expect(config.serviceDid).toBe(serviceDid) + expect(config.serviceAuthMaxAgeSeconds).toBe(300) + expect(config.didResolutionTimeoutMs).toBe(2_000) expect(config.databaseMaxConnections).toBe(5) expect(config.databaseIdleTimeoutMs).toBe(60_000) expect(config.metricsHost).toBe('0.0.0.0') @@ -23,7 +28,7 @@ describe('loadConfig', () => { it('loads configured metrics listener settings', () => { const config = loadConfig({ - DATABASE_URL: databaseUrl, + ...validEnvironment, METRICS_HOST: '127.0.0.1', METRICS_PORT: '3001', }) @@ -34,7 +39,7 @@ describe('loadConfig', () => { it('disables metrics when the metrics port is empty', () => { const config = loadConfig({ - DATABASE_URL: databaseUrl, + ...validEnvironment, METRICS_PORT: '', }) @@ -44,7 +49,7 @@ describe('loadConfig', () => { it('rejects a metrics port that conflicts with the public port', () => { expect(() => loadConfig({ - DATABASE_URL: databaseUrl, + ...validEnvironment, PORT: '3001', METRICS_PORT: '3001', }), @@ -55,28 +60,37 @@ describe('loadConfig', () => { it('loads a configured database idle timeout', () => { const config = loadConfig({ - DATABASE_URL: databaseUrl, + ...validEnvironment, DATABASE_IDLE_TIMEOUT_MS: '120000', }) expect(config.databaseIdleTimeoutMs).toBe(120_000) }) + it('requires and validates the service DID', () => { + expect(() => loadConfig({ DATABASE_URL: databaseUrl })).toThrow( + 'SERVICE_DID is required', + ) + expect(() => + loadConfig({ ...validEnvironment, SERVICE_DID: 'not-a-did' }), + ).toThrow('SERVICE_DID') + }) + it('explains how to fix missing and malformed values', () => { expect(() => loadConfig({})).toThrow('DATABASE_URL is required') expect(() => - loadConfig({ DATABASE_URL: databaseUrl, PORT: '70000' }), + loadConfig({ ...validEnvironment, PORT: '70000' }), ).toThrow('PORT must be an integer from 1 through 65535') expect(() => loadConfig({ - DATABASE_URL: databaseUrl, + ...validEnvironment, DATABASE_IDLE_TIMEOUT_MS: '500', }), ).toThrow( 'DATABASE_IDLE_TIMEOUT_MS must be an integer from 1000 through 3600000', ) expect(() => - loadConfig({ DATABASE_URL: databaseUrl, METRICS_PORT: '0' }), + loadConfig({ ...validEnvironment, METRICS_PORT: '0' }), ).toThrow('METRICS_PORT must be an integer from 1 through 65535') }) }) diff --git a/test/database.unit.test.ts b/test/database.unit.test.ts index c8743fa..5ce4c04 100644 --- a/test/database.unit.test.ts +++ b/test/database.unit.test.ts @@ -25,6 +25,7 @@ describe('Database', () => { it('keeps one connection warm and drains excess idle connections', () => { const config = loadConfig({ DATABASE_URL: 'postgres://feed:secret@localhost:5432/indexer', + SERVICE_DID: 'did:web:feed.example', DATABASE_MAX_CONNECTIONS: '5', DATABASE_IDLE_TIMEOUT_MS: '120000', }) diff --git a/test/feed.integration.test.ts b/test/feed.integration.test.ts index 7c706ad..5a352d6 100644 --- a/test/feed.integration.test.ts +++ b/test/feed.integration.test.ts @@ -103,6 +103,7 @@ describe('Hypercerts feed definition against Postgres', () => { const config = loadConfig({ DATABASE_URL: TEST_DATABASE_URL, + SERVICE_DID: 'did:web:feed.example', DATABASE_MAX_CONNECTIONS: '3', DATABASE_STATEMENT_TIMEOUT_MS: '10000', }) diff --git a/test/lexicon.unit.test.ts b/test/lexicon.unit.test.ts index 5db54de..05fef56 100644 --- a/test/lexicon.unit.test.ts +++ b/test/lexicon.unit.test.ts @@ -173,6 +173,7 @@ describe('feed Lexicon contract', () => { closed: false, refs: [paramsType], }) + expect(defs.hypercertsFeedParams.required ?? []).not.toContain('viewerDid') expect(hydratedMain.input.schema.properties.limit).toMatchObject({ type: 'integer', minimum: 1, @@ -254,6 +255,12 @@ describe('feed Lexicon contract', () => { }, }), ).not.toThrow() + expect(() => + skeletonInput.schema.$parse({ + feedId, + params: { $type: paramsType }, + }), + ).not.toThrow() expect(() => skeletonOutput.schema.$parse({ feed: [{ subject: uri }] }), ).not.toThrow() diff --git a/test/query.unit.test.ts b/test/query.unit.test.ts index 8d5c8b5..587d4ec 100644 --- a/test/query.unit.test.ts +++ b/test/query.unit.test.ts @@ -109,7 +109,7 @@ describe('Hypercerts SQL feed definition', () => { feed.loadPage({ $type: paramsType }, {}, 'metadata'), ).rejects.toMatchObject({ code: 'InvalidRequest', - message: expect.stringContaining('Missing required key "viewerDid"'), + message: expect.stringContaining('viewerDid is not a valid DID'), }) await expect( feed.loadPage( From af1703377c593eb4e7dec5b2f068f30dbd0f6c25 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 15 Sep 2026 18:09:10 +0600 Subject: [PATCH 2/8] api: extract shared feed procedure registration --- src/api/feed-procedure.ts | 108 +++++++++++++++++++++++++++++++++++ src/api/get-feed-skeleton.ts | 62 +++++--------------- src/api/get-feed.ts | 62 +++++--------------- 3 files changed, 134 insertions(+), 98 deletions(-) create mode 100644 src/api/feed-procedure.ts diff --git a/src/api/feed-procedure.ts b/src/api/feed-procedure.ts new file mode 100644 index 0000000..6a53a39 --- /dev/null +++ b/src/api/feed-procedure.ts @@ -0,0 +1,108 @@ +import type { + InferMethodOutputBody, + LexValue, + Main, + NsidString, + ParamsSchema, + Payload, + Procedure, + Schema, +} from '@atproto/lex-schema' +import { + LexServerError, + type LexRouter, + type LexRouterMethodHandler, +} from '@atproto/lex-server' +import type { Logger } from 'pino' + +import { applyAuthenticatedViewer } from '../auth/input.js' +import type { + OptionalServiceAuth, + ServiceAuthCredentials, +} from '../auth/service-auth.js' +import { FeedError, FeedErrorCode } from '../feed/errors.js' +import type { GetFeedSkeletonInput } from '../feed/types.js' + +type JsonProcedure = Procedure< + NsidString, + ParamsSchema, + Payload<'application/json', Schema>, + Payload<'application/json', Schema>, + readonly string[] | undefined +> + +type FeedProcedureOutputParser = ( + output: Output, +) => InferMethodOutputBody + +interface RegisterFeedProcedureOptions { + readonly router: LexRouter + readonly method: Main + readonly logger: Logger + readonly auth: OptionalServiceAuth | undefined + readonly execute: (input: GetFeedSkeletonInput) => Promise + readonly parseOutput: FeedProcedureOutputParser + readonly errorLog: string +} + +/** Registers a feed procedure with shared viewer binding, output validation, and error handling. */ +export const registerFeedProcedure = ({ + router, + method, + logger, + auth, + execute, + parseOutput, + errorLog, +}: RegisterFeedProcedureOptions): void => { + const handler = async ( + input: GetFeedSkeletonInput, + credentials: ServiceAuthCredentials | undefined, + ) => { + try { + const output = await execute(applyAuthenticatedViewer(input, credentials)) + return { body: parseOutput(output) } + } catch (cause) { + if (cause instanceof FeedError) { + throw new LexServerError( + cause.status, + { error: cause.code, message: cause.message }, + undefined, + { cause }, + ) + } + + logger.error({ err: cause }, errorLog) + throw new LexServerError( + 500, + { + error: FeedErrorCode.InternalError, + message: + 'Feed generation failed because of an internal service error; retry the request, then contact the operator if it continues.', + }, + undefined, + { cause }, + ) + } + } + + if (auth === undefined) { + const unauthenticatedHandler: LexRouterMethodHandler = + async ({ input }) => + handler( + (input as unknown as { body: GetFeedSkeletonInput }).body, + undefined, + ) + router.add(method, unauthenticatedHandler) + } else { + const authenticatedHandler: LexRouterMethodHandler< + JsonProcedure, + ServiceAuthCredentials | undefined + > = async ({ input, credentials }) => + handler( + (input as unknown as { body: GetFeedSkeletonInput }).body, + credentials, + ) + router.add(method, { auth, handler: authenticatedHandler }) + } +} diff --git a/src/api/get-feed-skeleton.ts b/src/api/get-feed-skeleton.ts index b64e1c9..f165ecc 100644 --- a/src/api/get-feed-skeleton.ts +++ b/src/api/get-feed-skeleton.ts @@ -1,14 +1,13 @@ -import { LexRouter, LexServerError } from '@atproto/lex-server' +import type { LexRouter } from '@atproto/lex-server' import type { Logger } from 'pino' -import { applyAuthenticatedViewer } from '../auth/input.js' -import type { OptionalServiceAuth, ServiceAuthCredentials } from '../auth/service-auth.js' -import { FeedError, FeedErrorCode } from '../feed/errors.js' +import type { OptionalServiceAuth } from '../auth/service-auth.js' import type { FeedSkeletonReader } from '../feed/service.js' -import type { GetFeedSkeletonInput } from '../feed/types.js' +import type { GetFeedSkeletonOutput } from '../feed/types.js' import getFeedSkeleton, { $output, } from '../lexicons/org/hypercerts/feed/getFeedSkeleton.js' +import { registerFeedProcedure } from './feed-procedure.js' /** Registers the public generic feed-skeleton procedure on a LexRouter instance. */ export const registerGetFeedSkeleton = ( @@ -17,48 +16,13 @@ export const registerGetFeedSkeleton = ( logger: Logger, auth?: OptionalServiceAuth, ): void => { - const handler = async ( - input: GetFeedSkeletonInput, - credentials: ServiceAuthCredentials | undefined, - ) => { - try { - const output = await feedService.getFeedSkeleton( - applyAuthenticatedViewer(input, credentials), - ) - return { body: $output.schema.$parse(output) } - } catch (cause) { - if (cause instanceof FeedError) { - throw new LexServerError( - cause.status, - { error: cause.code, message: cause.message }, - undefined, - { cause }, - ) - } - - logger.error({ err: cause }, 'feed skeleton generation failed') - throw new LexServerError( - 500, - { - error: FeedErrorCode.InternalError, - message: - 'Feed generation failed because of an internal service error; retry the request, then contact the operator if it continues.', - }, - undefined, - { cause }, - ) - } - } - - if (auth === undefined) { - router.add(getFeedSkeleton, async ({ input }) => - handler(input.body as GetFeedSkeletonInput, undefined), - ) - } else { - router.add(getFeedSkeleton, { - auth, - handler: async ({ input, credentials }) => - handler(input.body as GetFeedSkeletonInput, credentials), - }) - } + registerFeedProcedure({ + router, + method: getFeedSkeleton, + logger, + auth, + execute: (input) => feedService.getFeedSkeleton(input), + parseOutput: (output: GetFeedSkeletonOutput) => $output.schema.$parse(output), + errorLog: 'feed skeleton generation failed', + }) } diff --git a/src/api/get-feed.ts b/src/api/get-feed.ts index bfbfa2d..94ac7f4 100644 --- a/src/api/get-feed.ts +++ b/src/api/get-feed.ts @@ -1,14 +1,13 @@ -import { LexRouter, LexServerError } from '@atproto/lex-server' +import type { LexRouter } from '@atproto/lex-server' import type { Logger } from 'pino' -import { applyAuthenticatedViewer } from '../auth/input.js' -import type { OptionalServiceAuth, ServiceAuthCredentials } from '../auth/service-auth.js' -import { FeedError, FeedErrorCode } from '../feed/errors.js' -import type { GetFeedSkeletonInput } from '../feed/types.js' +import type { OptionalServiceAuth } from '../auth/service-auth.js' import type { HydratedFeedReader } from '../hydration/service.js' +import type { GetHydratedFeedOutput } from '../hydration/types.js' import getFeed, { $output, } from '../lexicons/org/hypercerts/feed/getFeed.js' +import { registerFeedProcedure } from './feed-procedure.js' /** Registers the public view-only hydrated feed procedure on a LexRouter instance. */ export const registerGetFeed = ( @@ -17,48 +16,13 @@ export const registerGetFeed = ( logger: Logger, auth?: OptionalServiceAuth, ): void => { - const handler = async ( - input: GetFeedSkeletonInput, - credentials: ServiceAuthCredentials | undefined, - ) => { - try { - const output = await feedService.getFeed( - applyAuthenticatedViewer(input, credentials), - ) - return { body: $output.schema.$parse(output) } - } catch (cause) { - if (cause instanceof FeedError) { - throw new LexServerError( - cause.status, - { error: cause.code, message: cause.message }, - undefined, - { cause }, - ) - } - - logger.error({ err: cause }, 'hydrated feed generation failed') - throw new LexServerError( - 500, - { - error: FeedErrorCode.InternalError, - message: - 'Feed generation failed because of an internal service error; retry the request, then contact the operator if it continues.', - }, - undefined, - { cause }, - ) - } - } - - if (auth === undefined) { - router.add(getFeed, async ({ input }) => - handler(input.body as GetFeedSkeletonInput, undefined), - ) - } else { - router.add(getFeed, { - auth, - handler: async ({ input, credentials }) => - handler(input.body as GetFeedSkeletonInput, credentials), - }) - } + registerFeedProcedure({ + router, + method: getFeed, + logger, + auth, + execute: (input) => feedService.getFeed(input), + parseOutput: (output: GetHydratedFeedOutput) => $output.schema.$parse(output), + errorLog: 'hydrated feed generation failed', + }) } From 754cecc7065478c9db5e97e869ace5b652286c8b Mon Sep 17 00:00:00 2001 From: kzoeps Date: Tue, 15 Sep 2026 21:10:21 +0600 Subject: [PATCH 3/8] auth: add bounded per-issuer replay quotas --- .changeset/optional-service-auth.md | 4 +- AGENTS.md | 7 +- README.md | 7 +- src/auth/service-auth.ts | 124 +++++++- test/auth.unit.test.ts | 464 +++++++++++++++++++++++++++- 5 files changed, 579 insertions(+), 27 deletions(-) diff --git a/.changeset/optional-service-auth.md b/.changeset/optional-service-auth.md index aa97744..ea91c81 100644 --- a/.changeset/optional-service-auth.md +++ b/.changeset/optional-service-auth.md @@ -2,7 +2,9 @@ "@hypercerts/hypercerts-feed-service": minor --- -Feed requests can now use AT Protocol service authentication. If a request includes a valid token, the token's issuer becomes the feed viewer. Requests without a token still need `params.viewerDid`. +Feed requests can now use AT Protocol service authentication. If a request includes a valid token, the token's issuer becomes the feed viewer. Requests without a token still need `params.viewerDid`. Authenticated tokens must include a non-empty `jti` of no more than 256 UTF-8 bytes and can be used only once per running process; retries require a fresh token, including after downstream failures. + +Replay protection allows up to 512 live entries per verified issuer and 4,096 live entries per process. It is not shared across replicas or restarts. A full live issuer quota or replay store returns HTTP 503 until entries expire, preserving capacity for other issuers until the global bound is reached. Before upgrading, set `SERVICE_DID` to this service's DID, even if you do not use authentication. It must be a bare DID, without `#serviceId`. diff --git a/AGENTS.md b/AGENTS.md index 8dac13f..b3394ad 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ org.hypercerts.feed.getFeedSkeleton org.hypercerts.feed.getFeed ``` -The skeleton returns URI-only generic feed subjects. The hydrated endpoint returns generic feed entries with validated feed-specific views and actor summaries. Hyperindex is the only supported database owner. The service reads its current PostgreSQL state directly; it does not ingest, write records, own migrations, cache across requests, call Hyperindex/PDS/AppView APIs, download blobs, hydrate target records, or provide immutable history. Optional service auth verifies the issuer's `#atproto` signing key, audience, expiry, endpoint binding, and DID document over a bounded secure resolver; the verified issuer, never the body, supplies an authenticated viewer. +The skeleton returns URI-only generic feed subjects. The hydrated endpoint returns generic feed entries with validated feed-specific views and actor summaries. Hyperindex is the only supported database owner. The service reads its current PostgreSQL state directly; it does not ingest, write records, own migrations, cache feed results across requests, call Hyperindex/PDS/AppView APIs, download blobs, hydrate target records, or provide immutable history. Optional service auth verifies the issuer's `#atproto` signing key, audience, expiry, endpoint binding, and DID document over a bounded secure resolver; the verified issuer, never the body, supplies an authenticated viewer. Verified service-auth `jti` values are consumed once in a bounded replay map local to the auth instance and process. Use **npm**, not pnpm. `package-lock.json` is authoritative. Node.js 22.13+ is supported; CI and Docker use Node.js 24. PostgreSQL 16+ is required. @@ -89,7 +89,7 @@ src/server.ts Ownership: -- `src/server.ts` is the composition root. It creates the registered Hypercerts feed, one shared registry, separate endpoint services, one identity adapter, and the configured optional service-auth verifier; it also owns listener settings, initial readiness, and graceful shutdown. +- `src/server.ts` is the composition root. It creates the registered Hypercerts feed, one shared registry, separate endpoint services, one identity adapter, and the configured optional service-auth verifier; that verifier owns one replay map shared by both endpoints; it also owns listener settings, initial readiness, and graceful shutdown. - `src/app.ts` is the fetch-compatible boundary. It owns fixed route metadata, POST enforcement, the 64 KiB body limit, malformed JSON, routed validation messages, and bounded request metrics. - `src/api/get-feed-skeleton.ts` and `src/api/get-feed.ts` register the procedures, run generated output validation inside the error boundary, and translate expected `FeedError` values. - `src/feed/service.ts` projects registry-selected metadata rows into the public skeleton. It does not own dispatch, cursor, or pagination policy. @@ -142,6 +142,7 @@ Preserve these unless the public contract is intentionally revised and documente - Both procedures accept the same `{ feedId, params?, limit?, cursor? }` wrapper. `limit` and `cursor` are generic top-level pagination controls; `params` contains only algorithm-specific values and may be omitted for feeds that declare no params contract. The public params union remains open for future feeds. Runtime dispatch rejects an unregistered `feedId` with `UnsupportedFeed`, while the current Hypercerts feed rejects missing params or a mismatched params discriminator with `InvalidRequest` before querying. - The base scope always resolves from the normalized viewer's current Certified follows. Anonymous requests require `params.viewerDid`; authenticated requests may omit it, but a supplied value must exactly match the verified service-auth issuer. There is no caller-supplied author override or body-based override of authenticated identity. +- Authenticated tokens must carry a non-empty string `jti` of 1–256 UTF-8 bytes. After successful signature and exact `lxm` verification, each issuer-and-`jti` pair is consumed once in the shared process-local replay map, with a maximum of 512 live entries per verified issuer and 4,096 live entries total. Expired entries are purged without evicting live entries; a live full issuer quota or global map fails closed with HTTP 503. The map is cleared on restart and is not replica-global. Downstream failures do not restore a consumed token. - Deduplicate request lists before enforcing semantic limits: 64 evaluators, 16 kinds, and 1–50 page items. - Evaluator endorsement subjects are unioned after base-author resolution. Remove the viewer and deduplicate candidates. Do not query actor status: Hyperindex purges source records for explicitly deleted, deactivated, suspended, or taken-down identities, and actors absent from `actor` remain eligible. - Omitted or empty `kinds` means all supported kinds. Unknown kinds fail with the generic `InvalidRequest` error and an actionable Hypercerts-parameter message. @@ -181,7 +182,7 @@ Rate limiting belongs at the gateway. Keep `/health` and `/ready` private. ## MVP exclusions -Do not add ingestion, writes, stronger authentication modes, cross-request caching, immutable history, Hyperindex/PDS/AppView API calls, blob downloads/proxying, target-record reads, target previews, recursive/detail hydration, activity-label hydration, preference persistence, or migrations/indexes. Service-auth verification is limited to the maintained AT Protocol verifier and the bounded DID-resolution boundary described above; never log authorization credentials, JWTs, or claims. +Do not add ingestion, writes, stronger authentication modes, feed-result caching across requests, immutable history, Hyperindex/PDS/AppView API calls, blob downloads/proxying, target-record reads, target previews, recursive/detail hydration, activity-label hydration, preference persistence, or migrations/indexes. Service-auth verification is limited to the maintained AT Protocol verifier and the bounded DID-resolution boundary described above; keep replay protection bounded and process-local, and never log authorization credentials, JWTs, or claims. ## Change checklist diff --git a/README.md b/README.md index 3493b9f..4e31bce 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ sequenceDiagram Client->>HTTP: POST feed procedure + optional Bearer JWT HTTP->>XRPC: Bounded, validated JSON XRPC->>XRPC: Verify audience, expiry, lxm, #atproto signature + XRPC->>XRPC: Validate and consume issuer-scoped jti XRPC->>Service: Endpoint input + trusted viewer DID Service->>Registry: Load metadata or source-aware page Registry->>Feed: Dispatch feedId, optional params, and pagination @@ -151,7 +152,7 @@ sequenceDiagram - Malformed JSON, missing required params, anonymous requests without `params.viewerDid`, an authenticated supplied viewer mismatch, an invalid nested `viewerDid`, params that do not match the selected feed, or structurally invalid top-level pagination return HTTP 400 with `InvalidRequest`. Semantically invalid selected-feed parameters or pagination beyond that feed's supported range return HTTP 422 with the same generic error name and an actionable message. An unregistered `feedId` returns `UnsupportedFeed`. These never become internal server errors. - A present `Authorization` header is always verified. Malformed, expired, not-yet-valid, wrong-audience, wrong-endpoint, unresolved-issuer, or incorrectly signed service JWTs return HTTP 401; the request is never retried anonymously. DID documents are resolved over bounded HTTPS-only requests with direct public-address pinning; `did:web` redirects and private, loopback, link-local, reserved, and special-use destinations are rejected. -- Service-auth JWTs include a signed `jti` identifier, which the AT Protocol specification recommends receivers use when replay prevention matters. This service deliberately accepts but does not track or otherwise act on `jti`; it keeps no replay cache because the endpoints are public and read-only, anonymous callers can request the same feed by supplying `params.viewerDid`, and repeated-request abuse belongs at the gateway. A valid token can therefore be reused until it expires. +- Service-auth JWTs must include a signed, non-empty `jti` no larger than 256 UTF-8 bytes. After signature, audience, expiry, and exact endpoint checks succeed, the service accepts each issuer-and-`jti` pair once. A token is consumed before the downstream feed request runs, so retries require a fresh token even when feed generation fails. Replay state is bounded to 512 live entries per verified issuer and 4,096 live entries total, local to the running process, cleared on restart, and not shared between replicas; a full live issuer quota or global store returns HTTP 503 until entries expire. Anonymous requests remain unaffected because they do not use service-auth replay state. - The base scope always comes from the viewer's current `app.certified.graph.follow` records. The service ignores malformed follow subjects. - `trustedEvaluators` adds the subjects of every current, active endorsement award from each evaluator. - An endorsement definition with no `allowedIssuers` allows any issuer. When it is present, only its listed issuer DIDs qualify. An empty or malformed value allows no issuers. @@ -336,7 +337,7 @@ Configure a compatible Prometheus collector to read `http:// type OptionalServiceAuthOptions = Omit +const MAX_REPLAY_ENTRIES = 4_096 +const MAX_REPLAY_ENTRIES_PER_ISSUER = 512 const authChallenge = { Bearer: { error: 'BadJwtLexiconMethod' }, } as const +const badJwtChallenge = { + Bearer: { error: 'BadJwt' }, +} as const +const replayChallenge = { + Bearer: { error: 'NonceNotUnique' }, +} as const +const textEncoder = new TextEncoder() + +const expiredTokenError = (): LexServerAuthError => + new LexServerAuthError( + 'AuthenticationRequired', + 'JWT token expired', + { Bearer: { error: 'JwtExpired' } }, + ) /** * Makes AT Protocol service authentication optional without treating malformed * credentials as anonymous requests. The built-in verifier handles signature, - * audience, expiry, DID resolution, and key rotation. + * audience, expiry, DID resolution, and key rotation. Verified tokens are + * replay-protected in a bounded cache local to this auth instance and process. */ export const createOptionalServiceAuth = ( options: OptionalServiceAuthOptions, ): OptionalServiceAuth => { // The upstream callback checks a non-canonical nonce before signature - // verification. This public, read-only service deliberately keeps no replay - // state; authenticated and anonymous callers can request the same feed data. + // verification. Keep it side-effect-free; canonical jti state is recorded + // only after the built-in verifier and the exact endpoint check succeed. const verify = serviceAuth({ ...options, unique: async () => true }) + const replayEntriesByIssuer = new Map>() + let replayEntryCount = 0 + let lastObservedWallMs = 0 + + const removeReplayEntry = ( + issuerDid: string, + jti: string, + issuerEntries: Map, + ): void => { + if (!issuerEntries.delete(jti)) return + replayEntryCount -= 1 + if (issuerEntries.size === 0) { + replayEntriesByIssuer.delete(issuerDid) + } + } + + const purgeExpiredIssuerEntries = ( + issuerDid: string, + issuerEntries: Map, + ): void => { + for (const [storedJti, expiry] of issuerEntries) { + if (expiry <= lastObservedWallMs) { + removeReplayEntry(issuerDid, storedJti, issuerEntries) + } + } + } + + const purgeExpiredEntries = (): void => { + for (const [issuerDid, issuerEntries] of replayEntriesByIssuer) { + purgeExpiredIssuerEntries(issuerDid, issuerEntries) + } + } return async (context) => { if (context.request.headers.get('authorization') === null) { @@ -46,6 +96,74 @@ export const createOptionalServiceAuth = ( authChallenge, ) } + + const payload = credentials.jwt.payload as typeof credentials.jwt.payload & { + readonly jti?: unknown + } + const jti = payload.jti + if ( + typeof jti !== 'string' || + jti.length === 0 || + textEncoder.encode(jti).byteLength > 256 + ) { + throw new LexServerAuthError( + 'AuthenticationRequired', + 'JWT jti must be a non-empty string no longer than 256 UTF-8 bytes; request a token with a valid jti.', + badJwtChallenge, + ) + } + + lastObservedWallMs = Math.max(Date.now(), lastObservedWallMs) + const expiresAt = (payload.exp + 1) * 1_000 + if (expiresAt <= lastObservedWallMs) { + throw expiredTokenError() + } + + const issuerDid = credentials.did + let issuerEntries = replayEntriesByIssuer.get(issuerDid) + const storedExpiry = issuerEntries?.get(jti) + if (storedExpiry !== undefined) { + if (storedExpiry > lastObservedWallMs) { + throw new LexServerAuthError( + 'AuthenticationRequired', + 'Replay attack detected: token is not unique; request a fresh token.', + replayChallenge, + ) + } + removeReplayEntry(issuerDid, jti, issuerEntries!) + issuerEntries = replayEntriesByIssuer.get(issuerDid) + } + + if (issuerEntries !== undefined && issuerEntries.size >= MAX_REPLAY_ENTRIES_PER_ISSUER) { + purgeExpiredIssuerEntries(issuerDid, issuerEntries) + issuerEntries = replayEntriesByIssuer.get(issuerDid) + } + if ( + issuerEntries !== undefined && + issuerEntries.size >= MAX_REPLAY_ENTRIES_PER_ISSUER + ) { + throw new LexServerError(503, { + error: 'InternalError', + message: + 'Authentication replay protection reached the per-issuer limit; retry later with a fresh token.', + }) + } + + if (replayEntryCount >= MAX_REPLAY_ENTRIES) { + purgeExpiredEntries() + } + if (replayEntryCount >= MAX_REPLAY_ENTRIES) { + throw new LexServerError(503, { + error: 'InternalError', + message: + 'Authentication replay protection is temporarily at capacity; retry later with a fresh token.', + }) + } + + issuerEntries ??= new Map() + replayEntriesByIssuer.set(issuerDid, issuerEntries) + issuerEntries.set(jti, expiresAt) + replayEntryCount += 1 return credentials } } diff --git a/test/auth.unit.test.ts b/test/auth.unit.test.ts index 65b42aa..c949a4e 100644 --- a/test/auth.unit.test.ts +++ b/test/auth.unit.test.ts @@ -1,17 +1,26 @@ import { P256Keypair, bytesToMultibase } from '@atproto/crypto' import { LexServerAuthError } from '@atproto/lex-server' import type { DidResolver } from '@atproto-labs/did-resolver' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' -import { createOptionalServiceAuth } from '../src/auth/service-auth.js' +import { + createOptionalServiceAuth, + type ServiceAuthCredentials, +} from '../src/auth/service-auth.js' const issuerDid = 'did:plc:ar7c4by46qjdydhdevvrndac' +const otherIssuerDid = 'did:plc:ewvi7nxzyoun6zhxrhs64oiz' const serviceDid = 'did:web:feed.example' const skeletonNsid = 'org.hypercerts.feed.getFeedSkeleton' const hydratedNsid = 'org.hypercerts.feed.getFeed' +const baseTimeMs = 1_700_000_000_000 + +let nextJti = 0 const now = (): number => Math.floor(Date.now() / 1_000) +const freshJti = (): string => `test-jti-${++nextJti}` + const encode = (value: unknown): string => Buffer.from(JSON.stringify(value)).toString('base64url') @@ -25,6 +34,7 @@ const signedJwt = async ( iat: now(), exp: now() + 60, lxm: skeletonNsid, + jti: freshJti(), ...overrides, } const message = `${encode({ alg: keypair.jwtAlg, typ: 'JWT' })}.${encode(payload)}` @@ -32,23 +42,36 @@ const signedJwt = async ( return `${message}.${Buffer.from(signature).toString('base64url')}` } -const resolverFor = (keypair: P256Keypair): DidResolver => ({ - resolve: async () => ({ - id: issuerDid, - verificationMethod: [ - { - id: `${issuerDid}#atproto`, - type: 'EcdsaSecp256r1VerificationKey2019', - controller: issuerDid, - publicKeyMultibase: bytesToMultibase( - keypair.publicKeyBytes(), - 'base58btc', - ), - }, - ], - }), +const resolverForMany = ( + ...entries: readonly (readonly [string, P256Keypair])[] +): DidResolver => ({ + resolve: async (did: string) => { + const entry = entries.find(([entryDid]) => entryDid === did) + if (entry === undefined) throw new Error('unknown test issuer') + + const [resolvedDid, keypair] = entry + return { + id: resolvedDid, + verificationMethod: [ + { + id: `${resolvedDid}#atproto`, + type: 'EcdsaSecp256r1VerificationKey2019', + controller: resolvedDid, + publicKeyMultibase: bytesToMultibase( + keypair.publicKeyBytes(), + 'base58btc', + ), + }, + ], + } + }, }) as unknown as DidResolver +const resolverFor = ( + keypair: P256Keypair, + did = issuerDid, +): DidResolver => resolverForMany([did, keypair]) + const authRequest = (authorization: string | undefined): Request => new Request('http://localhost/xrpc/test', { headers: authorization === undefined ? {} : { authorization }, @@ -118,4 +141,411 @@ describe('optional AT Protocol service authentication', () => { LexServerAuthError, ) }) + + it.each([ + ['missing', undefined], + ['empty', ''], + ['wrong type number', 123], + ['wrong type boolean', true], + ['wrong type object', {}], + ['257 UTF-8 bytes', `${'é'.repeat(128)}a`], + ])('rejects a %s jti', async (_label, jti) => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const token = await signedJwt(keypair, { jti }) + + await expect(auth(authContext(`Bearer ${token}`))).rejects.toMatchObject({ + error: 'AuthenticationRequired', + status: 401, + wwwAuthenticate: { Bearer: { error: 'BadJwt' } }, + }) + }) + + it('accepts a 256-byte Unicode jti', async () => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const token = await signedJwt(keypair, { jti: 'é'.repeat(128) }) + + await expect(auth(authContext(`Bearer ${token}`))).resolves.toMatchObject({ + did: issuerDid, + }) + }) + + it('does not trim a nonempty whitespace jti', async () => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const token = await signedJwt(keypair, { jti: ' ' }) + + await expect(auth(authContext(`Bearer ${token}`))).resolves.toMatchObject({ + did: issuerDid, + }) + }) + + it('rejects a duplicate issuer and jti with the upstream replay challenge', async () => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const token = await signedJwt(keypair, { jti: 'duplicate-jti' }) + + await expect(auth(authContext(`Bearer ${token}`))).resolves.toBeDefined() + await expect(auth(authContext(`Bearer ${token}`))).rejects.toMatchObject({ + error: 'AuthenticationRequired', + status: 401, + wwwAuthenticate: { Bearer: { error: 'NonceNotUnique' } }, + }) + }) + + it('isolates the same jti between verified issuers', async () => { + const firstKeypair = await P256Keypair.create() + const secondKeypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverForMany( + [issuerDid, firstKeypair], + [otherIssuerDid, secondKeypair], + ), + }) + const firstToken = await signedJwt(firstKeypair, { jti: 'shared-jti' }) + const secondToken = await signedJwt(secondKeypair, { + iss: otherIssuerDid, + jti: 'shared-jti', + }) + + await expect(auth(authContext(`Bearer ${firstToken}`))).resolves.toMatchObject({ + did: issuerDid, + }) + await expect(auth(authContext(`Bearer ${secondToken}`))).resolves.toMatchObject({ + did: otherIssuerDid, + }) + await expect(auth(authContext(`Bearer ${firstToken}`))).rejects.toMatchObject({ + wwwAuthenticate: { Bearer: { error: 'NonceNotUnique' } }, + }) + }) + + it('caps each verified issuer at 512 entries without consuming another issuer quota', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(baseTimeMs) + const firstKeypair = await P256Keypair.create() + const secondKeypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverForMany( + [issuerDid, firstKeypair], + [otherIssuerDid, secondKeypair], + ), + }) + const firstTokens = await Promise.all( + Array.from({ length: 513 }, (_, index) => + signedJwt(firstKeypair, { + exp: now() + 1, + jti: `first-issuer-jti-${index}`, + }), + ), + ) + + for (const token of firstTokens.slice(0, 512)) { + await expect(auth(authContext(`Bearer ${token}`))).resolves.toMatchObject({ + did: issuerDid, + }) + } + await expect( + auth(authContext(`Bearer ${firstTokens[512]}`)), + ).rejects.toMatchObject({ + error: 'InternalError', + status: 503, + message: expect.stringContaining('issuer'), + }) + + const secondTokens = await Promise.all( + Array.from({ length: 512 }, (_, index) => + signedJwt(secondKeypair, { + iss: otherIssuerDid, + jti: `second-issuer-jti-${index}`, + }), + ), + ) + for (const token of secondTokens) { + await expect(auth(authContext(`Bearer ${token}`))).resolves.toMatchObject({ + did: otherIssuerDid, + }) + } + + vi.setSystemTime(baseTimeMs + 2_000) + const afterExpiryToken = await signedJwt(firstKeypair, { + jti: 'first-issuer-after-expiry', + }) + await expect( + auth(authContext(`Bearer ${afterExpiryToken}`)), + ).resolves.toMatchObject({ did: issuerDid }) + } finally { + vi.useRealTimers() + } + }, 30_000) + + it('does not let a forged signature consume a valid token jti', async () => { + const keypair = await P256Keypair.create() + const forgedKeypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const forgedToken = await signedJwt(forgedKeypair, { jti: 'forged-jti' }) + const validToken = await signedJwt(keypair, { jti: 'forged-jti' }) + + await expect(auth(authContext(`Bearer ${forgedToken}`))).rejects.toMatchObject({ + wwwAuthenticate: { Bearer: { error: 'BadJwtSignature' } }, + }) + await expect(auth(authContext(`Bearer ${validToken}`))).resolves.toMatchObject({ + did: issuerDid, + }) + }) + + it.each([ + ['missing', undefined], + ['wrong', hydratedNsid], + ])('does not let a %s lxm consume a valid token jti', async (_label, lxm) => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const invalidToken = await signedJwt(keypair, { + jti: 'endpoint-jti', + lxm, + }) + const validToken = await signedJwt(keypair, { jti: 'endpoint-jti' }) + + await expect(auth(authContext(`Bearer ${invalidToken}`))).rejects.toBeInstanceOf( + LexServerAuthError, + ) + await expect(auth(authContext(`Bearer ${validToken}`))).resolves.toMatchObject({ + did: issuerDid, + }) + }) + + it('shares replay state between the skeleton and hydrated endpoints', async () => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const skeletonToken = await signedJwt(keypair, { + jti: 'both-endpoints-jti', + lxm: skeletonNsid, + }) + const hydratedToken = await signedJwt(keypair, { + jti: 'both-endpoints-jti', + lxm: hydratedNsid, + }) + + await expect( + auth(authContext(`Bearer ${skeletonToken}`, skeletonNsid)), + ).resolves.toBeDefined() + await expect( + auth(authContext(`Bearer ${hydratedToken}`, hydratedNsid)), + ).rejects.toMatchObject({ + wwwAuthenticate: { Bearer: { error: 'NonceNotUnique' } }, + }) + }) + + it('allows only one concurrent authentication for a token', async () => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const token = await signedJwt(keypair, { jti: 'concurrent-jti' }) + const results = await Promise.allSettled([ + auth(authContext(`Bearer ${token}`)), + auth(authContext(`Bearer ${token}`)), + ]) + const fulfilled = results.filter( + (result): result is PromiseFulfilledResult => + result.status === 'fulfilled', + ) + const rejected = results.filter( + (result): result is PromiseRejectedResult => result.status === 'rejected', + ) + + expect(fulfilled).toHaveLength(1) + expect(rejected).toHaveLength(1) + expect(rejected[0]?.reason).toMatchObject({ + wwwAuthenticate: { Bearer: { error: 'NonceNotUnique' } }, + }) + }) + + it('consumes a token even when the downstream request fails', async () => { + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const token = await signedJwt(keypair, { jti: 'downstream-failure-jti' }) + const request = authContext(`Bearer ${token}`) + + await expect( + (async () => { + await auth(request) + throw new Error('downstream failure') + })(), + ).rejects.toThrow('downstream failure') + await expect(auth(request)).rejects.toMatchObject({ + wwwAuthenticate: { Bearer: { error: 'NonceNotUnique' } }, + }) + }) + + it('rejects a token that expires during asynchronous verification', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(baseTimeMs) + const keypair = await P256Keypair.create() + let releaseResolution!: () => void + let markResolutionStarted!: () => void + const resolutionStarted = new Promise((resolve) => { + markResolutionStarted = resolve + }) + const resolutionReleased = new Promise((resolve) => { + releaseResolution = resolve + }) + const resolver = { + resolve: async () => { + markResolutionStarted() + await resolutionReleased + return resolverFor(keypair).resolve(issuerDid) + }, + } as unknown as DidResolver + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolver, + }) + const token = await signedJwt(keypair, { + exp: now() + 1, + jti: 'delayed-expiry-jti', + }) + const pending = auth(authContext(`Bearer ${token}`)) + + await resolutionStarted + vi.setSystemTime(baseTimeMs + 2_000) + releaseResolution() + + await expect(pending).rejects.toMatchObject({ + error: 'AuthenticationRequired', + message: 'JWT token expired', + wwwAuthenticate: { Bearer: { error: 'JwtExpired' } }, + }) + } finally { + vi.useRealTimers() + } + }) + + it('retains a token through the complete exp second', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(baseTimeMs) + const keypair = await P256Keypair.create() + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverFor(keypair), + }) + const token = await signedJwt(keypair, { + exp: now() + 1, + jti: 'expiry-boundary-jti', + }) + + await expect(auth(authContext(`Bearer ${token}`))).resolves.toBeDefined() + vi.setSystemTime(baseTimeMs + 1_999) + await expect(auth(authContext(`Bearer ${token}`))).rejects.toMatchObject({ + wwwAuthenticate: { Bearer: { error: 'NonceNotUnique' } }, + }) + } finally { + vi.useRealTimers() + } + }) + + it('purges expired entries at capacity, fails closed for live entries, and resists clock rollback', async () => { + vi.useFakeTimers() + try { + vi.setSystemTime(baseTimeMs) + const capacityIssuerDids = [ + issuerDid, + otherIssuerDid, + ...Array.from( + { length: 7 }, + (_, index) => `did:plc:${'a'.repeat(23)}${String.fromCharCode(98 + index)}`, + ), + ] + const capacityKeypairs = await Promise.all( + Array.from({ length: capacityIssuerDids.length }, () => + P256Keypair.create(), + ), + ) + const auth = createOptionalServiceAuth({ + audience: serviceDid, + didResolver: resolverForMany( + ...capacityIssuerDids.map( + (did, index) => [did, capacityKeypairs[index]!] as const, + ), + ), + }) + const tokens = await Promise.all( + Array.from({ length: 4_096 }, (_, index) => { + const issuerIndex = Math.floor(index / 512) + return signedJwt(capacityKeypairs[issuerIndex]!, { + iss: capacityIssuerDids[issuerIndex], + exp: now() + 60, + jti: `capacity-jti-${index}`, + }) + }), + ) + + for (const token of tokens) { + await auth(authContext(`Bearer ${token}`)) + } + + const overflowToken = await signedJwt(capacityKeypairs[8]!, { + iss: capacityIssuerDids[8], + jti: 'overflow-jti', + }) + await expect( + auth(authContext(`Bearer ${overflowToken}`)), + ).rejects.toMatchObject({ + error: 'InternalError', + status: 503, + message: expect.stringContaining('at capacity'), + }) + await expect(auth(authContext(`Bearer ${tokens[0]}`))).rejects.toMatchObject({ + wwwAuthenticate: { Bearer: { error: 'NonceNotUnique' } }, + }) + + vi.setSystemTime(baseTimeMs + 61_000) + const freshToken = await signedJwt(capacityKeypairs[0]!, { + exp: now() + 60, + jti: 'after-purge-jti', + }) + await expect(auth(authContext(`Bearer ${freshToken}`))).resolves.toBeDefined() + + vi.setSystemTime(baseTimeMs + 60_500) + await expect(auth(authContext(`Bearer ${tokens[0]}`))).rejects.toMatchObject({ + error: 'AuthenticationRequired', + message: 'JWT token expired', + wwwAuthenticate: { Bearer: { error: 'JwtExpired' } }, + }) + } finally { + vi.useRealTimers() + } + // Use real signed tokens here so the production verifier and hard cap are + // exercised together; the explicit timeout covers 4,096 signatures. + }, 30_000) }) From d09d80afbf154480a5fcf0dcb42d7c02cd61dfe4 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Wed, 16 Sep 2026 13:22:13 +0600 Subject: [PATCH 4/8] docs: clarify jti replay and service audiences --- AGENTS.md | 4 +- README.md | 4 +- src/auth/service-auth.ts | 188 ++++++++++++++++++++++++++------------- 3 files changed, 132 insertions(+), 64 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index b3394ad..34dcf37 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -9,7 +9,7 @@ org.hypercerts.feed.getFeedSkeleton org.hypercerts.feed.getFeed ``` -The skeleton returns URI-only generic feed subjects. The hydrated endpoint returns generic feed entries with validated feed-specific views and actor summaries. Hyperindex is the only supported database owner. The service reads its current PostgreSQL state directly; it does not ingest, write records, own migrations, cache feed results across requests, call Hyperindex/PDS/AppView APIs, download blobs, hydrate target records, or provide immutable history. Optional service auth verifies the issuer's `#atproto` signing key, audience, expiry, endpoint binding, and DID document over a bounded secure resolver; the verified issuer, never the body, supplies an authenticated viewer. Verified service-auth `jti` values are consumed once in a bounded replay map local to the auth instance and process. +The skeleton returns URI-only generic feed subjects. The hydrated endpoint returns generic feed entries with validated feed-specific views and actor summaries. Hyperindex is the only supported database owner. The service reads its current PostgreSQL state directly; it does not ingest, write records, own migrations, cache feed results across requests, call Hyperindex/PDS/AppView APIs, download blobs, hydrate target records, or provide immutable history. Optional service auth verifies the issuer's `#atproto` signing key, audience, expiry, endpoint binding, and DID document over a bounded secure resolver; the verified issuer, never the body, supplies an authenticated viewer. It retains only bounded, process-local replay-protection state through the JWT `jti`; that state is not a feed-result cache. Use **npm**, not pnpm. `package-lock.json` is authoritative. Node.js 22.13+ is supported; CI and Docker use Node.js 24. PostgreSQL 16+ is required. @@ -182,7 +182,7 @@ Rate limiting belongs at the gateway. Keep `/health` and `/ready` private. ## MVP exclusions -Do not add ingestion, writes, stronger authentication modes, feed-result caching across requests, immutable history, Hyperindex/PDS/AppView API calls, blob downloads/proxying, target-record reads, target previews, recursive/detail hydration, activity-label hydration, preference persistence, or migrations/indexes. Service-auth verification is limited to the maintained AT Protocol verifier and the bounded DID-resolution boundary described above; keep replay protection bounded and process-local, and never log authorization credentials, JWTs, or claims. +Do not add ingestion, writes, stronger authentication modes, feed-result caching across requests, immutable history, Hyperindex/PDS/AppView API calls, blob downloads/proxying, target-record reads, target previews, recursive/detail hydration, activity-label hydration, preference persistence, or migrations/indexes. Service-auth verification is limited to the maintained AT Protocol verifier and the bounded DID-resolution boundary described above; retain only bounded, process-local replay-protection state through the JWT `jti`, and never log authorization credentials, JWTs, or claims. ## Change checklist diff --git a/README.md b/README.md index 4e31bce..bfb34be 100644 --- a/README.md +++ b/README.md @@ -236,7 +236,7 @@ For local development, copy `.env.example` to `.env`. For deployment, copy its v | `GRACEFUL_SHUTDOWN_MS` | no | `10000` | Shutdown drain timeout | | `TRUSTED_QUALITY_LABELER_DIDS` | no | empty | Comma-separated Orglabeler trust roots | -Service-auth audience verification currently accepts only the bare `SERVICE_DID`. It does not yet accept the combined `did#serviceId` audience proposed by [AT Protocol Proposal 0014](https://github.com/bluesky-social/proposals/blob/main/0014-service-auth-revised/README.md) because `@atproto/lex-server` does not support that audience form. Clients may still use a combined service reference for PDS proxy routing while the reference PDS emits a bare-DID service-auth audience. The verifier should be updated to accept an explicit allowlist of both forms when upstream support becomes available. +Service-auth audience verification currently accepts only the bare `SERVICE_DID`. [AT Protocol Proposal 0014](https://github.com/bluesky-social/proposals/blob/main/0014-service-auth-revised/README.md) defines the combined `did#serviceId` service reference, but the reference PDS currently emits the bare-DID service-auth audience and `@atproto/lex-server` does not yet support the combined audience form. This is separate from JWT `jti` replay protection, which this service now validates and consumes once per issuer in its bounded process-local replay state. Clients may still use a combined service reference for PDS proxy routing. When upstream support and PDS behavior are ready, update the verifier to accept an explicit allowlist of both audience forms. If there are no configured trusted labelers, known organizations count as unrated whenever the request includes an organization-quality policy. @@ -361,4 +361,4 @@ Public errors never show SQL, database credentials, table contents, internal cau ## MVP boundaries -The service does not ingest data, write records, manage migrations, cache feed results across requests, call Hyperindex/PDS/AppView APIs, download blobs, hydrate target records, build target previews, recursively hydrate linked records, save preferences, or provide an unchangeable event history. Its bounded service-auth replay state is process-local and is not a feed-result cache. Results show Hyperindex's current, changeable data and its current freshness. +The service does not ingest data, write records, manage migrations, cache feed results across requests, call Hyperindex/PDS/AppView APIs, download blobs, hydrate target records, build target previews, recursively hydrate linked records, save preferences, or provide an unchangeable event history. It retains only bounded, process-local replay-protection state through the JWT `jti`; that state is not a feed-result cache. Results show Hyperindex's current, changeable data and its current freshness. diff --git a/src/auth/service-auth.ts b/src/auth/service-auth.ts index 48aa1b5..254ed21 100644 --- a/src/auth/service-auth.ts +++ b/src/auth/service-auth.ts @@ -37,19 +37,44 @@ const expiredTokenError = (): LexServerAuthError => { Bearer: { error: 'JwtExpired' } }, ) -/** - * Makes AT Protocol service authentication optional without treating malformed - * credentials as anonymous requests. The built-in verifier handles signature, - * audience, expiry, DID resolution, and key rotation. Verified tokens are - * replay-protected in a bounded cache local to this auth instance and process. - */ -export const createOptionalServiceAuth = ( - options: OptionalServiceAuthOptions, -): OptionalServiceAuth => { - // The upstream callback checks a non-canonical nonce before signature - // verification. Keep it side-effect-free; canonical jti state is recorded - // only after the built-in verifier and the exact endpoint check succeed. - const verify = serviceAuth({ ...options, unique: async () => true }) +type VerifiedServiceAuthPayload = ServiceAuthCredentials['jwt']['payload'] & { + readonly jti?: unknown +} + +type ReplayProtector = { + consume: (issuerDid: string, jti: string, exp: number) => void +} + +const assertEndpointBinding = ( + payload: ServiceAuthCredentials['jwt']['payload'], + methodNsid: string, +): void => { + if (payload.lxm !== methodNsid) { + throw new LexServerAuthError( + 'AuthenticationRequired', + 'JWT lexicon method is missing or does not match this endpoint; request a token with the exact endpoint NSID in lxm.', + authChallenge, + ) + } +} + +const validateJti = (payload: VerifiedServiceAuthPayload): string => { + const jti = payload.jti + if ( + typeof jti !== 'string' || + jti.length === 0 || + textEncoder.encode(jti).byteLength > 256 + ) { + throw new LexServerAuthError( + 'AuthenticationRequired', + 'JWT jti must be a non-empty string no longer than 256 UTF-8 bytes; request a token with a valid jti.', + badJwtChallenge, + ) + } + return jti +} + +const createReplayProtector = (): ReplayProtector => { const replayEntriesByIssuer = new Map>() let replayEntryCount = 0 let lastObservedWallMs = 0 @@ -83,61 +108,52 @@ export const createOptionalServiceAuth = ( } } - return async (context) => { - if (context.request.headers.get('authorization') === null) { - return undefined - } + const getCurrentTimeMs = (): number => { + lastObservedWallMs = Math.max(Date.now(), lastObservedWallMs) + return lastObservedWallMs + } - const credentials = await verify(context) - if (credentials.jwt.payload.lxm !== context.method.nsid) { - throw new LexServerAuthError( - 'AuthenticationRequired', - 'JWT lexicon method is missing or does not match this endpoint; request a token with the exact endpoint NSID in lxm.', - authChallenge, - ) + const assertTokenIsLive = (expiresAt: number, nowMs: number): void => { + if (expiresAt <= nowMs) { + throw expiredTokenError() } + } - const payload = credentials.jwt.payload as typeof credentials.jwt.payload & { - readonly jti?: unknown - } - const jti = payload.jti - if ( - typeof jti !== 'string' || - jti.length === 0 || - textEncoder.encode(jti).byteLength > 256 - ) { + const rejectReplayAndGetEntries = ( + issuerDid: string, + jti: string, + nowMs: number, + ): Map | undefined => { + const issuerEntries = replayEntriesByIssuer.get(issuerDid) + if (issuerEntries === undefined) return undefined + + const storedExpiry = issuerEntries.get(jti) + if (storedExpiry === undefined) return issuerEntries + if (storedExpiry > nowMs) { throw new LexServerAuthError( 'AuthenticationRequired', - 'JWT jti must be a non-empty string no longer than 256 UTF-8 bytes; request a token with a valid jti.', - badJwtChallenge, + 'Replay attack detected: token is not unique; request a fresh token.', + replayChallenge, ) } - lastObservedWallMs = Math.max(Date.now(), lastObservedWallMs) - const expiresAt = (payload.exp + 1) * 1_000 - if (expiresAt <= lastObservedWallMs) { - throw expiredTokenError() - } + removeReplayEntry(issuerDid, jti, issuerEntries) + return replayEntriesByIssuer.get(issuerDid) + } - const issuerDid = credentials.did - let issuerEntries = replayEntriesByIssuer.get(issuerDid) - const storedExpiry = issuerEntries?.get(jti) - if (storedExpiry !== undefined) { - if (storedExpiry > lastObservedWallMs) { - throw new LexServerAuthError( - 'AuthenticationRequired', - 'Replay attack detected: token is not unique; request a fresh token.', - replayChallenge, - ) - } - removeReplayEntry(issuerDid, jti, issuerEntries!) - issuerEntries = replayEntriesByIssuer.get(issuerDid) + const ensureIssuerCapacity = ( + issuerDid: string, + issuerEntries: Map | undefined, + ): Map | undefined => { + if ( + issuerEntries === undefined || + issuerEntries.size < MAX_REPLAY_ENTRIES_PER_ISSUER + ) { + return issuerEntries } - if (issuerEntries !== undefined && issuerEntries.size >= MAX_REPLAY_ENTRIES_PER_ISSUER) { - purgeExpiredIssuerEntries(issuerDid, issuerEntries) - issuerEntries = replayEntriesByIssuer.get(issuerDid) - } + purgeExpiredIssuerEntries(issuerDid, issuerEntries) + issuerEntries = replayEntriesByIssuer.get(issuerDid) if ( issuerEntries !== undefined && issuerEntries.size >= MAX_REPLAY_ENTRIES_PER_ISSUER @@ -148,10 +164,13 @@ export const createOptionalServiceAuth = ( 'Authentication replay protection reached the per-issuer limit; retry later with a fresh token.', }) } + return issuerEntries + } - if (replayEntryCount >= MAX_REPLAY_ENTRIES) { - purgeExpiredEntries() - } + const ensureGlobalCapacity = (): void => { + if (replayEntryCount < MAX_REPLAY_ENTRIES) return + + purgeExpiredEntries() if (replayEntryCount >= MAX_REPLAY_ENTRIES) { throw new LexServerError(503, { error: 'InternalError', @@ -159,11 +178,60 @@ export const createOptionalServiceAuth = ( 'Authentication replay protection is temporarily at capacity; retry later with a fresh token.', }) } + } + const storeReplayEntry = ( + issuerDid: string, + jti: string, + expiresAt: number, + issuerEntries: Map | undefined, + ): void => { issuerEntries ??= new Map() replayEntriesByIssuer.set(issuerDid, issuerEntries) issuerEntries.set(jti, expiresAt) replayEntryCount += 1 + } + + const consume = (issuerDid: string, jti: string, exp: number): void => { + const nowMs = getCurrentTimeMs() + const expiresAt = (exp + 1) * 1_000 + assertTokenIsLive(expiresAt, nowMs) + + let issuerEntries = rejectReplayAndGetEntries(issuerDid, jti, nowMs) + issuerEntries = ensureIssuerCapacity(issuerDid, issuerEntries) + ensureGlobalCapacity() + storeReplayEntry(issuerDid, jti, expiresAt, issuerEntries) + } + + return { consume } +} + +/** + * Makes AT Protocol service authentication optional without treating malformed + * credentials as anonymous requests. The built-in verifier handles signature, + * audience, expiry, DID resolution, and key rotation. Verified tokens are + * replay-protected in a bounded cache local to this auth instance and process. + */ +export const createOptionalServiceAuth = ( + options: OptionalServiceAuthOptions, +): OptionalServiceAuth => { + // The upstream callback checks a non-canonical nonce before signature + // verification. Keep it side-effect-free; canonical jti state is recorded + // only after the built-in verifier and the exact endpoint check succeed. + const verify = serviceAuth({ ...options, unique: async () => true }) + const replayProtector = createReplayProtector() + + return async (context) => { + if (context.request.headers.get('authorization') === null) { + return undefined + } + + const credentials = await verify(context) + assertEndpointBinding(credentials.jwt.payload, context.method.nsid) + + const payload = credentials.jwt.payload as VerifiedServiceAuthPayload + const jti = validateJti(payload) + replayProtector.consume(credentials.did, jti, payload.exp) return credentials } } From 3c1528314fa1ab7afede701fa868b32795cba886 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 24 Sep 2026 11:34:42 +0600 Subject: [PATCH 5/8] auth: publish feed service did document --- .changeset/optional-service-auth.md | 2 +- README.md | 5 ++- src/app.ts | 29 +++++++++++++ src/server.ts | 1 + test/app.unit.test.ts | 65 +++++++++++++++++++++++++++++ 5 files changed, 100 insertions(+), 2 deletions(-) diff --git a/.changeset/optional-service-auth.md b/.changeset/optional-service-auth.md index ea91c81..2b6392d 100644 --- a/.changeset/optional-service-auth.md +++ b/.changeset/optional-service-auth.md @@ -6,6 +6,6 @@ Feed requests can now use AT Protocol service authentication. If a request inclu Replay protection allows up to 512 live entries per verified issuer and 4,096 live entries per process. It is not shared across replicas or restarts. A full live issuer quota or replay store returns HTTP 503 until entries expire, preserving capacity for other issuers until the global bound is reached. -Before upgrading, set `SERVICE_DID` to this service's DID, even if you do not use authentication. It must be a bare DID, without `#serviceId`. +Before upgrading, set `SERVICE_DID` to this service's DID, even if you do not use authentication. It must be a bare DID, without `#serviceId`. If it is a hostname-level `did:web` and the matching hostname points to this service, `GET /.well-known/did.json` now publishes a `#hypercerts_feed` service entry at that hostname for discovery. The `did#serviceId` format from AT Protocol Proposal 0014 is not supported yet because `@atproto/lex-server` does not support it. diff --git a/README.md b/README.md index bfb34be..a92d501 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Hyperindex is the only supported owner of the database. The service provides a U ## Endpoints -`GET /` returns a small JSON description of the service and lists its public XRPC procedures. It does not query the database or report service readiness. +`GET /` returns a small JSON description of the service and lists its public XRPC procedures. It does not query the database or report service readiness. For a hostname-level `did:web` configured as `SERVICE_DID`, `GET /.well-known/did.json` publishes a DID document on that hostname with a `#hypercerts_feed` service entry (`HypercertsFeedService`) pointing to its HTTPS origin. Other hostnames and non-`did:web` service DIDs do not publish a document here. Both feed endpoints are POST procedures with optional AT Protocol service authentication. They use the same `{ feedId, params?, limit?, cursor? }` request wrapper, feed-scoped cursor contract, and stable public errors. `params` contains only algorithm-specific values; pagination is generic and top-level. The registered Hypercerts feed requires the Hypercerts params object and discriminator. Anonymous requests must include `params.viewerDid`; authenticated callers may omit it, and the verified JWT issuer supplies the viewer. A supplied viewer DID must match the issuer: @@ -236,6 +236,8 @@ For local development, copy `.env.example` to `.env`. For deployment, copy its v | `GRACEFUL_SHUTDOWN_MS` | no | `10000` | Shutdown drain timeout | | `TRUSTED_QUALITY_LABELER_DIDS` | no | empty | Comma-separated Orglabeler trust roots | +For example, set `SERVICE_DID=did:web:dev.feed.hypercerts.dev` only when `https://dev.feed.hypercerts.dev` routes to this service; clients can discover its XRPC endpoint using `did:web:dev.feed.hypercerts.dev#hypercerts_feed`. This service does not publish a signing key: it verifies callers using their own DID documents. The DID service entry is for discovery and does not change JWT audience validation. + Service-auth audience verification currently accepts only the bare `SERVICE_DID`. [AT Protocol Proposal 0014](https://github.com/bluesky-social/proposals/blob/main/0014-service-auth-revised/README.md) defines the combined `did#serviceId` service reference, but the reference PDS currently emits the bare-DID service-auth audience and `@atproto/lex-server` does not yet support the combined audience form. This is separate from JWT `jti` replay protection, which this service now validates and consumes once per issuer in its bounded process-local replay state. Clients may still use a combined service reference for PDS proxy routing. When upstream support and PDS behavior are ready, update the verifier to accept an explicit allowlist of both audience forms. If there are no configured trusted labelers, known organizations count as unrated whenever the request includes an organization-quality policy. @@ -341,6 +343,7 @@ The process limits request body size, HTTP request receive time, pool size, conn ## Operations +- `GET /.well-known/did.json`: serves the configured hostname-level `did:web` identity and `#hypercerts_feed` service entry only on that DID's hostname. - `GET /health`: checks only whether the process is alive. - `GET /ready`: checks current database support and read-only state; the runtime schema contract is documented separately. - Private metrics listener: when `METRICS_PORT` is set, `GET /metrics` exposes this replica's metrics in Prometheus-compatible exposition format on `METRICS_HOST:METRICS_PORT`; all other paths are rejected. diff --git a/src/app.ts b/src/app.ts index 208d29a..8da1d92 100644 --- a/src/app.ts +++ b/src/app.ts @@ -64,6 +64,7 @@ const routeLabel = (pathname: string): string => { if (pathname === '/') return 'root' if (pathname === '/health') return 'health' if (pathname === '/ready') return 'ready' + if (pathname === '/.well-known/did.json') return 'did_document' return feedRoute(pathname)?.label ?? 'other' } @@ -249,6 +250,29 @@ const handleHealthRequest = (request: Request): Response => ? jsonResponse({ status: 'ok' }) : methodNotAllowed('GET') +const handleDidDocumentRequest = ( + request: Request, + serviceDid: string | undefined, +): Response => { + const hostname = new URL(request.url).hostname + if (serviceDid !== `did:web:${hostname}`) { + return new Response(null, { status: 404 }) + } + if (request.method !== 'GET') return methodNotAllowed('GET') + + return jsonResponse({ + '@context': ['https://www.w3.org/ns/did/v1'], + id: serviceDid, + service: [ + { + id: '#hypercerts_feed', + type: 'HypercertsFeedService', + serviceEndpoint: `https://${hostname}`, + }, + ], + }) +} + const handleReadyRequest = async ( request: Request, database: DatabaseCompatibilityChecker, @@ -304,6 +328,7 @@ const handleRequest = async ( database: DatabaseCompatibilityChecker, router: LexRouter, metrics: Metrics, + serviceDid: string | undefined, ): Promise => { const matchedFeedRoute = feedRoute(pathname) @@ -316,6 +341,8 @@ const handleRequest = async ( response = handleHealthRequest(request) } else if (pathname === '/ready') { response = await handleReadyRequest(request, database, metrics) + } else if (pathname === '/.well-known/did.json') { + response = handleDidDocumentRequest(request, serviceDid) } else { response = await handleFeedRequest(request, matchedFeedRoute, router) } @@ -336,6 +363,7 @@ export const createApp = ( metrics: Metrics, logger: Logger, auth?: OptionalServiceAuth, + serviceDid?: string, ): { fetch: FetchHandler } => { const router = new LexRouter({ onHandlerError: ({ error, method }) => { @@ -359,6 +387,7 @@ export const createApp = ( database, router, metrics, + serviceDid, ) status = response.status if (matchedFeedRoute !== undefined) { diff --git a/src/server.ts b/src/server.ts index 556783b..2ea07aa 100644 --- a/src/server.ts +++ b/src/server.ts @@ -38,6 +38,7 @@ const app = createApp( metrics, logger, auth, + config.serviceDid, ) metrics.setReady(false) diff --git a/test/app.unit.test.ts b/test/app.unit.test.ts index f8f079f..8baaefa 100644 --- a/test/app.unit.test.ts +++ b/test/app.unit.test.ts @@ -112,6 +112,71 @@ describe('HTTP application', () => { expect(metricText).toContain('route="root"') }) + it('publishes the configured did:web feed service at its canonical domain', async () => { + const app = createApp( + compatibleDatabase, + appServices(), + new Metrics(), + logger, + undefined, + 'did:web:dev.feed.hypercerts.dev', + ) + + const response = await app.fetch( + new Request('https://dev.feed.hypercerts.dev/.well-known/did.json'), + ) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('application/json') + await expect(response.json()).resolves.toEqual({ + '@context': ['https://www.w3.org/ns/did/v1'], + id: 'did:web:dev.feed.hypercerts.dev', + service: [ + { + id: '#hypercerts_feed', + type: 'HypercertsFeedService', + serviceEndpoint: 'https://dev.feed.hypercerts.dev', + }, + ], + }) + }) + + it('does not claim a different hostname or a non-web DID', async () => { + for (const [serviceDid, hostname] of [ + ['did:web:dev.feed.hypercerts.dev', 'other.example'], + ['did:plc:ar7c4by46qjdydhdevvrndac', 'dev.feed.hypercerts.dev'], + ]) { + const app = createApp( + compatibleDatabase, + appServices(), + new Metrics(), + logger, + undefined, + serviceDid, + ) + const response = await app.fetch( + new Request(`https://${hostname}/.well-known/did.json`), + ) + expect(response.status).toBe(404) + } + }) + + it('rejects non-GET requests to the DID document', async () => { + const app = createApp( + compatibleDatabase, + appServices(), + new Metrics(), + logger, + undefined, + 'did:web:dev.feed.hypercerts.dev', + ) + const response = await app.fetch( + post('https://dev.feed.hypercerts.dev/.well-known/did.json', '{}'), + ) + expect(response.status).toBe(405) + expect(response.headers.get('allow')).toBe('GET') + }) + it('rejects non-GET requests to the root route', async () => { const app = createApp( compatibleDatabase, From 3ecee4de5323cf67bab2dbc8e5c52a639e5ada40 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 24 Sep 2026 11:45:20 +0600 Subject: [PATCH 6/8] auth: derive service endpoint from configured did --- README.md | 4 +-- src/app.ts | 4 +-- test/app.unit.test.ts | 59 ++++++++++++++++++++++++++++++------------- 3 files changed, 45 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index a92d501..a8bef91 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ Hyperindex is the only supported owner of the database. The service provides a U ## Endpoints -`GET /` returns a small JSON description of the service and lists its public XRPC procedures. It does not query the database or report service readiness. For a hostname-level `did:web` configured as `SERVICE_DID`, `GET /.well-known/did.json` publishes a DID document on that hostname with a `#hypercerts_feed` service entry (`HypercertsFeedService`) pointing to its HTTPS origin. Other hostnames and non-`did:web` service DIDs do not publish a document here. +`GET /` returns a small JSON description of the service and lists its public XRPC procedures. It does not query the database or report service readiness. For a hostname-level `did:web` configured as `SERVICE_DID`, `GET /.well-known/did.json` publishes a DID document with a `#hypercerts_feed` service entry (`HypercertsFeedService`) pointing to the HTTPS origin derived from that DID. The route uses the configured DID, not the request hostname. Non-`did:web` service DIDs do not publish a document here. Both feed endpoints are POST procedures with optional AT Protocol service authentication. They use the same `{ feedId, params?, limit?, cursor? }` request wrapper, feed-scoped cursor contract, and stable public errors. `params` contains only algorithm-specific values; pagination is generic and top-level. The registered Hypercerts feed requires the Hypercerts params object and discriminator. Anonymous requests must include `params.viewerDid`; authenticated callers may omit it, and the verified JWT issuer supplies the viewer. A supplied viewer DID must match the issuer: @@ -343,7 +343,7 @@ The process limits request body size, HTTP request receive time, pool size, conn ## Operations -- `GET /.well-known/did.json`: serves the configured hostname-level `did:web` identity and `#hypercerts_feed` service entry only on that DID's hostname. +- `GET /.well-known/did.json`: serves the configured hostname-level `did:web` identity and `#hypercerts_feed` service entry; its endpoint comes from `SERVICE_DID`, not the request hostname. - `GET /health`: checks only whether the process is alive. - `GET /ready`: checks current database support and read-only state; the runtime schema contract is documented separately. - Private metrics listener: when `METRICS_PORT` is set, `GET /metrics` exposes this replica's metrics in Prometheus-compatible exposition format on `METRICS_HOST:METRICS_PORT`; all other paths are rejected. diff --git a/src/app.ts b/src/app.ts index 8da1d92..b5f5eae 100644 --- a/src/app.ts +++ b/src/app.ts @@ -254,8 +254,8 @@ const handleDidDocumentRequest = ( request: Request, serviceDid: string | undefined, ): Response => { - const hostname = new URL(request.url).hostname - if (serviceDid !== `did:web:${hostname}`) { + const hostname = serviceDid?.match(/^did:web:([a-z0-9.-]+)$/)?.[1] + if (hostname === undefined) { return new Response(null, { status: 404 }) } if (request.method !== 'GET') return methodNotAllowed('GET') diff --git a/test/app.unit.test.ts b/test/app.unit.test.ts index 8baaefa..a94d38a 100644 --- a/test/app.unit.test.ts +++ b/test/app.unit.test.ts @@ -141,24 +141,47 @@ describe('HTTP application', () => { }) }) - it('does not claim a different hostname or a non-web DID', async () => { - for (const [serviceDid, hostname] of [ - ['did:web:dev.feed.hypercerts.dev', 'other.example'], - ['did:plc:ar7c4by46qjdydhdevvrndac', 'dev.feed.hypercerts.dev'], - ]) { - const app = createApp( - compatibleDatabase, - appServices(), - new Metrics(), - logger, - undefined, - serviceDid, - ) - const response = await app.fetch( - new Request(`https://${hostname}/.well-known/did.json`), - ) - expect(response.status).toBe(404) - } + it('uses the configured production DID rather than the request hostname', async () => { + const app = createApp( + compatibleDatabase, + appServices(), + new Metrics(), + logger, + undefined, + 'did:web:feed.hypercerts.dev', + ) + + const response = await app.fetch( + new Request('https://other.example/.well-known/did.json'), + ) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + '@context': ['https://www.w3.org/ns/did/v1'], + id: 'did:web:feed.hypercerts.dev', + service: [ + { + id: '#hypercerts_feed', + type: 'HypercertsFeedService', + serviceEndpoint: 'https://feed.hypercerts.dev', + }, + ], + }) + }) + + it('does not publish a did:web document for a non-web DID', async () => { + const app = createApp( + compatibleDatabase, + appServices(), + new Metrics(), + logger, + undefined, + 'did:plc:ar7c4by46qjdydhdevvrndac', + ) + const response = await app.fetch( + new Request('https://dev.feed.hypercerts.dev/.well-known/did.json'), + ) + expect(response.status).toBe(404) }) it('rejects non-GET requests to the DID document', async () => { From 60dec4da7d91f5839f5f4c236d5fb899fa4ec67e Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 24 Sep 2026 12:14:07 +0600 Subject: [PATCH 7/8] auth: preserve signed POST verification after body read --- .changeset/optional-service-auth.md | 2 +- README.md | 2 +- package-lock.json | 74 ++++++++++++++--------------- package.json | 2 +- src/app.ts | 5 +- test/app.unit.test.ts | 51 ++++++++++++++++++++ 6 files changed, 95 insertions(+), 41 deletions(-) diff --git a/.changeset/optional-service-auth.md b/.changeset/optional-service-auth.md index 2b6392d..90c941c 100644 --- a/.changeset/optional-service-auth.md +++ b/.changeset/optional-service-auth.md @@ -8,4 +8,4 @@ Replay protection allows up to 512 live entries per verified issuer and 4,096 li Before upgrading, set `SERVICE_DID` to this service's DID, even if you do not use authentication. It must be a bare DID, without `#serviceId`. If it is a hostname-level `did:web` and the matching hostname points to this service, `GET /.well-known/did.json` now publishes a `#hypercerts_feed` service entry at that hostname for discovery. -The `did#serviceId` format from AT Protocol Proposal 0014 is not supported yet because `@atproto/lex-server` does not support it. +Node HTTP requests now retain working service authentication after their POST body finishes. The service upgrades `@atproto/lex-server` to 0.1.19 and avoids passing its prematurely aborted request signal to DID resolution. The `did#serviceId` format from AT Protocol Proposal 0014 is not supported yet because `@atproto/lex-server` does not support it. diff --git a/README.md b/README.md index a8bef91..40a5f8a 100644 --- a/README.md +++ b/README.md @@ -339,7 +339,7 @@ Configure a compatible Prometheus collector to read `http:// { + it('keeps authentication usable after a real HTTP POST body has been read', async () => { + const auth: OptionalServiceAuth = async ({ request }) => { + request.signal.throwIfAborted() + return trustedCredentials(viewer) + } + const app = createApp( + compatibleDatabase, + appServices(), + new Metrics(), + logger, + auth, + ) + const server = createServer(app) + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', resolve) + }) + + try { + const address = server.address() as AddressInfo + for (const method of [ + 'org.hypercerts.feed.getFeedSkeleton', + 'org.hypercerts.feed.getFeed', + ]) { + const response = await fetch( + `http://127.0.0.1:${address.port}/xrpc/${method}`, + { + method: 'POST', + headers: { + 'content-type': 'application/json', + authorization: 'Bearer test-token', + }, + body: JSON.stringify({ + feedId: HYPERCERTS_FEED_ID, + params: { $type: HYPERCERTS_FEED_PARAMS_TYPE }, + limit: 1, + }), + }, + ) + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ feed: [] }) + } + } finally { + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())) + }) + } + }) + it('describes the service at the root route', async () => { const metrics = new Metrics() const app = createApp( From 643a01241c422e6734467ce55247d1f9a879e915 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Thu, 24 Sep 2026 14:23:20 +0600 Subject: [PATCH 8/8] auth: declare crypto test dependency and verify HTTP bearer header --- package-lock.json | 1 + package.json | 1 + test/app.unit.test.ts | 1 + 3 files changed, 3 insertions(+) diff --git a/package-lock.json b/package-lock.json index ff3ea34..d2d4de8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,6 +21,7 @@ "prom-client": "^15.1.3" }, "devDependencies": { + "@atproto/crypto": "^0.5.5", "@changesets/changelog-github": "^1.0.1", "@changesets/cli": "^3.0.2", "@types/node": "^24.10.0", diff --git a/package.json b/package.json index b8f1405..e5e7602 100644 --- a/package.json +++ b/package.json @@ -40,6 +40,7 @@ "prom-client": "^15.1.3" }, "devDependencies": { + "@atproto/crypto": "^0.5.5", "@changesets/changelog-github": "^1.0.1", "@changesets/cli": "^3.0.2", "@types/node": "^24.10.0", diff --git a/test/app.unit.test.ts b/test/app.unit.test.ts index 72c3b18..3d4e8f7 100644 --- a/test/app.unit.test.ts +++ b/test/app.unit.test.ts @@ -92,6 +92,7 @@ describe('HTTP application', () => { it('keeps authentication usable after a real HTTP POST body has been read', async () => { const auth: OptionalServiceAuth = async ({ request }) => { request.signal.throwIfAborted() + expect(request.headers.get('authorization')).toBe('Bearer test-token') return trustedCredentials(viewer) } const app = createApp(