From 2e01ffec07ba3f8893da94c4403e7a72b7b29f77 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:35:41 +0200 Subject: [PATCH] fix service auth --- .changeset/exact-audiences-authorize.md | 5 + README.md | 2 +- apps/atmo-rsvp/README.md | 4 +- apps/atmo-rsvp/src/contrail.config.ts | 2 +- apps/atmo-rsvp/tests/contract.test.ts | 5 +- docs/public-services/api-atmo-rsvp.md | 10 +- docs/public-services/creating.md | 27 ++- docs/public-services/using.md | 14 +- packages/contrail/README.md | 4 +- packages/contrail/package.json | 2 + packages/contrail/src/cli/commands/connect.ts | 47 ++-- packages/contrail/src/core/router/index.ts | 32 +-- packages/contrail/src/core/service-auth.ts | 16 +- packages/contrail/src/core/types.ts | 15 +- packages/contrail/src/public-client.ts | 170 +++++++++++--- packages/contrail/src/public-service.ts | 99 ++++++-- .../contrail/src/service-auth-contract.ts | 152 +++++++++++++ packages/contrail/src/worker/index.ts | 2 + packages/contrail/tests/built-client.mjs | 12 +- packages/contrail/tests/connect.test.ts | 115 ++++++++-- packages/contrail/tests/public-client.test.ts | 145 ++++++++++-- .../contrail/tests/public-service-e2e.test.ts | 24 +- .../tests/service-auth-contract.test.ts | 215 ++++++++++++++++++ packages/contrail/tests/service-auth.test.ts | 26 ++- packages/contrail/tests/types.test.ts | 30 +++ packages/contrail/tests/worker.test.ts | 103 +++++++++ pnpm-lock.yaml | 6 + 27 files changed, 1134 insertions(+), 150 deletions(-) create mode 100644 .changeset/exact-audiences-authorize.md create mode 100644 packages/contrail/src/service-auth-contract.ts create mode 100644 packages/contrail/tests/service-auth-contract.test.ts diff --git a/.changeset/exact-audiences-authorize.md b/.changeset/exact-audiences-authorize.md new file mode 100644 index 0000000..38d486b --- /dev/null +++ b/.changeset/exact-audiences-authorize.md @@ -0,0 +1,5 @@ +--- +"@atmo-dev/contrail": minor +--- + +Correct service auth to use an exact fragmented DID service audience and deterministic least-privilege OAuth RPC scope. Discovery, provider locks, generated clients, and DID documents now distinguish the base service DID from the JWT audience and pin the protected method set. Existing consumers must reconnect, regenerate, and reauthorize; old plain-DID/wildcard OAuth grants are not compatible. diff --git a/README.md b/README.md index 75122f2..7c6c4f0 100644 --- a/README.md +++ b/README.md @@ -113,7 +113,7 @@ Consumers connect and generate Atcute types with one command: pnpx @atmo-dev/contrail connect https://api.example.com ``` -The generated client sends anonymous requests directly. Protected methods lazily discover service auth and still fail closed on endpoint or service-DID mismatches. The content-addressed Lexicon digest remains in the version-2 provider lock; the complete provider method set is not pinned at runtime, so additive deployments do not interrupt existing calls. +The generated client sends anonymous requests directly. Protected methods use a least-privilege OAuth scope with the provider's exact fragmented service audience and one `lxm` per protected method. They lazily discover service auth and fail closed on endpoint, base-DID, audience, scope, or protected-method mismatches. The content-addressed Lexicon digest remains in the version-2 provider lock; the complete provider method set is not pinned at runtime, so additive anonymous deployments do not interrupt existing calls. An application that owns the provider source can generate the same typed surface before deployment without creating a provider lock: diff --git a/apps/atmo-rsvp/README.md b/apps/atmo-rsvp/README.md index a908dc4..fbc10e8 100644 --- a/apps/atmo-rsvp/README.md +++ b/apps/atmo-rsvp/README.md @@ -29,10 +29,10 @@ rsvp.atmo.getFeed rsvp.atmo.notifyOfUpdate ``` -The service-auth audience is `did:web:api.atmo.rsvp`. A consumer can request one OAuth permission for both protected methods: +The base service DID is `did:web:api.atmo.rsvp`; the exact service-auth audience is `did:web:api.atmo.rsvp#contrail`. A consumer requests one least-privilege OAuth permission for both protected methods: ```text -rpc?lxm=*&aud=did:web:api.atmo.rsvp +rpc?aud=did:web:api.atmo.rsvp%23contrail&lxm=rsvp.atmo.getFeed&lxm=rsvp.atmo.notifyOfUpdate ``` Tokens remain method-bound. Call `com.atproto.server.getServiceAuth` with the specific `lxm` being invoked, then send its token as `Authorization: Bearer `. diff --git a/apps/atmo-rsvp/src/contrail.config.ts b/apps/atmo-rsvp/src/contrail.config.ts index 78faab4..b907d21 100644 --- a/apps/atmo-rsvp/src/contrail.config.ts +++ b/apps/atmo-rsvp/src/contrail.config.ts @@ -10,7 +10,7 @@ export const config: ContrailConfig = { }, notify: true, serviceAuth: { - audience: "did:web:api.atmo.rsvp", + audience: "did:web:api.atmo.rsvp#contrail", methods: ["getFeed", "notifyOfUpdate"], }, maintenance: { optimize: true }, diff --git a/apps/atmo-rsvp/tests/contract.test.ts b/apps/atmo-rsvp/tests/contract.test.ts index 6e9039d..bfea7e3 100644 --- a/apps/atmo-rsvp/tests/contract.test.ts +++ b/apps/atmo-rsvp/tests/contract.test.ts @@ -46,7 +46,10 @@ describe("api.atmo.rsvp public contract", () => { expect(service.manifest.methods).not.toContain("rsvp.atmo.getOverview"); expect(service.manifest.serviceAuth).toEqual({ type: "atproto-service-auth", - audience: "did:web:api.atmo.rsvp", + serviceDid: "did:web:api.atmo.rsvp", + audience: "did:web:api.atmo.rsvp#contrail", + scope: + "rpc?aud=did:web:api.atmo.rsvp%23contrail&lxm=rsvp.atmo.getFeed&lxm=rsvp.atmo.notifyOfUpdate", methods: EXPECTED_PROTECTED_METHODS, }); expect(service.lexicons.map((document) => document.id)).toEqual( diff --git a/docs/public-services/api-atmo-rsvp.md b/docs/public-services/api-atmo-rsvp.md index 5a5cc92..cfb2345 100644 --- a/docs/public-services/api-atmo-rsvp.md +++ b/docs/public-services/api-atmo-rsvp.md @@ -17,13 +17,14 @@ The XRPC namespace is DNS-authoritative: rsvp.atmo.* ``` -The service DID and service-auth audience are: +The base service DID and exact service-auth audience are: ```text -did:web:api.atmo.rsvp +service DID: did:web:api.atmo.rsvp +audience: did:web:api.atmo.rsvp#contrail ``` -The service DID identifies the API as a JWT audience. The API does not use it to sign user records and does not act as a PDS. +The fragmented service reference is the OAuth and JWT audience. The base DID identifies the DID document published by the API. The API does not use either value to sign user records and does not act as a PDS. ## Anonymous methods @@ -57,7 +58,8 @@ The module generated by `contrail connect` exposes the required OAuth permission import { contrail } from "./contrail/index.js"; export const scopes = ["atproto", contrail.scope]; -// contrail.scope is rpc?lxm=*&aud=did:web:api.atmo.rsvp +// contrail.scope is: +// rpc?aud=did:web:api.atmo.rsvp%23contrail&lxm=rsvp.atmo.getFeed&lxm=rsvp.atmo.notifyOfUpdate ``` After login, derive one client from the user's existing authenticated AT Protocol client: diff --git a/docs/public-services/creating.md b/docs/public-services/creating.md index a774789..a520a0c 100644 --- a/docs/public-services/creating.md +++ b/docs/public-services/creating.md @@ -126,7 +126,7 @@ export const config: ContrailConfig = { namespace: "events.example", notify: true, serviceAuth: { - audience: "did:web:api.example.com", + audience: "did:web:api.example.com#contrail", methods: ["getFeed", "notifyOfUpdate"], }, collections, @@ -141,7 +141,7 @@ export const config: ContrailConfig = { The provider verifies: - the JWT signature against the issuer DID's `#atproto` key; -- the exact audience DID; +- the exact fragmented service audience; - the token's exact `lxm` method claim; - expiration and maximum token age; and - the route-specific ownership rule. @@ -172,13 +172,15 @@ After deploying, `contrail connect https://api.example.com` creates the version- ### OAuth permission versus token binding -A client can request one OAuth permission for all methods at this service: +Contrail generates one least-privilege OAuth permission containing the exact fragmented audience and one sorted `lxm` parameter per protected method: ```text -rpc?lxm=*&aud=did:web:api.example.com +rpc?aud=did:web:api.example.com%23contrail&lxm=events.example.getFeed&lxm=events.example.notifyOfUpdate ``` -The wildcard belongs to the OAuth scope. It avoids asking the user for one scope per method. Each call to `com.atproto.server.getServiceAuth` should still pass the specific method NSID as `lxm`, producing a short-lived method-bound token. +Current granular OAuth RPC syntax requires the absolute DID service reference; a plain DID may be silently removed from an authorization request. The `%23` is the encoded `#` delimiter. Wildcard `lxm=*` permissions are not generated because they are broader than necessary and can trigger misleading consent descriptions. + +Each call to `com.atproto.server.getServiceAuth` still passes the specific method NSID as `lxm` and the decoded `did:web:api.example.com#contrail` audience, producing a short-lived method-bound token. For example, a feed token uses: @@ -192,22 +194,25 @@ and cannot be reused for: lxm=events.example.notifyOfUpdate ``` -### Service DID +### Service DID and audience -When the service-auth audience matches the public origin, such as: +For this configuration, the identities are distinct: ```text -did:web:api.example.com -https://api.example.com +base service DID: did:web:api.example.com +exact audience: did:web:api.example.com#contrail +public endpoint: https://api.example.com ``` -Contrail publishes the service DID document at: +Contrail publishes the base DID document at: ```text https://api.example.com/.well-known/did.json ``` -The service DID is the stable JWT audience. The AppView does not need a signing key merely to receive and verify user-issued service tokens. +The document `id` is the base DID and its Contrail service entry `id` is the exact fragmented audience. Startup fails if an automatically hosted `did:web` audience resolves to a different DID-document URL. A `did:plc` audience remains externally managed and does not create a local DID route. + +The exact service reference—not the base DID—is the JWT audience passed to `getServiceAuth` and verified by Contrail. The AppView does not need a signing key merely to receive and verify user-issued service tokens. ## Profiles and internal follow projections diff --git a/docs/public-services/using.md b/docs/public-services/using.md index 85c0332..69903ef 100644 --- a/docs/public-services/using.md +++ b/docs/public-services/using.md @@ -26,7 +26,13 @@ export default { contrail: { endpoint: "https://api.atmo.rsvp", serviceDid: "did:web:api.atmo.rsvp", - scope: "rpc?lxm=*&aud=did:web:api.atmo.rsvp", + serviceAudience: "did:web:api.atmo.rsvp#contrail", + scope: + "rpc?aud=did:web:api.atmo.rsvp%23contrail&lxm=rsvp.atmo.getFeed&lxm=rsvp.atmo.notifyOfUpdate", + protectedMethods: [ + "rsvp.atmo.getFeed", + "rsvp.atmo.notifyOfUpdate", + ], collections: [ "app.bsky.actor.profile", "app.bsky.graph.follow", @@ -99,7 +105,7 @@ The generated Lexicons provide typed method names, parameters, and responses. ## Use one authenticated client -Add the provider's generated scope to the application's OAuth scopes: +Add the provider's generated least-privilege scope to the application's OAuth scopes. It contains the encoded fragmented audience and only the protected methods advertised by this provider: ```ts import { contrail } from "./contrail/index.js"; @@ -143,13 +149,13 @@ Contrail returns the original PDS response. A notification failure is reported t ## Update the connection -Anonymous generated clients call the endpoint directly without fetching discovery first. Protected calls lazily discover service auth; transient discovery failures remain retryable and endpoint or service-DID mismatches fail closed. Adding provider methods does not interrupt methods already known by a generated client. Regenerate when application code wants the new API surface: +Anonymous generated clients call the endpoint directly without fetching discovery first. Protected calls lazily discover service auth; transient discovery failures remain retryable, while endpoint, base-DID, exact-audience, scope, or protected-method mismatches fail closed. Adding anonymous provider methods does not interrupt methods already known by a generated client. Regenerate when application code wants new API surface or the protected contract changes: ```bash pnpx @atmo-dev/contrail connect https://api.atmo.rsvp --update ``` -Review changes to `contrail.lock.json`, `lex.config.js`, and `src/contrail/`, then run the application's typecheck and tests. `--update` cannot repoint an existing lock or abandon its provider-owned Lexicon root; remove the existing connection deliberately before switching providers or output roots. Version-1 provider locks are intentionally unsupported after the clean manifest-v2 cut and must be removed before reconnecting. +Review changes to `contrail.lock.json`, `lex.config.js`, and `src/contrail/`, then run the application's typecheck and tests. A change from the old plain-DID/wildcard permission requires OAuth reauthorization; an existing grant cannot mint tokens for the corrected fragmented audience. `--update` cannot repoint an existing lock or abandon its provider-owned Lexicon root; remove the existing connection deliberately before switching providers or output roots. Version-1 provider locks are intentionally unsupported after the clean manifest-v2 cut and must be removed before reconnecting. A version-2 lock written before exact audiences existed is reported separately and needs the same removal, reconnection, and OAuth reauthorization. ## Completeness diff --git a/packages/contrail/README.md b/packages/contrail/README.md index 2509a0c..8a1e40d 100644 --- a/packages/contrail/README.md +++ b/packages/contrail/README.md @@ -185,14 +185,14 @@ Personalized feeds and `notifyOfUpdate` can instead require method-bound AT Prot const config = { notify: true, serviceAuth: { - audience: "did:web:api.example.com", + audience: "did:web:api.example.com#contrail", methods: ["getFeed", "notifyOfUpdate"], }, // ... }; ``` -The service description advertises the audience and each protected query/procedure separately from anonymous methods. `contrail connect` generates `src/contrail/index.ts`; anonymous calls go directly to the configured endpoint, while protected calls lazily discover and validate service auth. Its exported `contrail.scope` is the provider's verified OAuth permission, such as `rpc?lxm=*&aud=did:web:api.example.com`. `contrail.authenticated(authenticatedClient)` returns one Atcute client: advertised provider methods route to Contrail, ordinary methods route to the PDS, and successful tracked `createRecord`, `putRecord`, and `deleteRecord` calls automatically notify Contrail. Handle-form deletes are resolved to canonical DID URIs. Protected methods use cached exact method-bound tokens, while transient discovery failures remain retryable. Feed actors must resolve to the token issuer, and every notified AT URI must belong to the issuer. When the audience matches the public endpoint's `did:web`, the Worker publishes its service document at `/.well-known/did.json`. +The service description advertises the base service DID, exact fragmented audience, canonical OAuth scope, and each protected query/procedure separately from anonymous methods. `contrail connect` generates `src/contrail/index.ts`; anonymous calls go directly to the configured endpoint, while protected calls lazily discover and validate all service-auth fields. Its exported `contrail.scope` contains one sorted `lxm` parameter per protected method, such as `rpc?aud=did:web:api.example.com%23contrail&lxm=.getFeed&lxm=.notifyOfUpdate`. `contrail.authenticated(authenticatedClient)` returns one Atcute client: advertised provider methods route to Contrail, ordinary methods route to the PDS, and successful tracked `createRecord`, `putRecord`, and `deleteRecord` calls automatically notify Contrail. Handle-form deletes are resolved to canonical DID URIs. Protected methods use cached exact method-bound tokens, while transient discovery failures remain retryable. Feed actors must resolve to the token issuer, and every notified AT URI must belong to the issuer. When the base DID resolves to the public endpoint's `/.well-known/did.json`, the Worker publishes a DID document whose service entry ID is the exact audience. Public-service mode requires a primary ordered source so `getCursor` can expose its committed position. Existing non-public deployments without one retain the legacy ingestion-time cursor response: diff --git a/packages/contrail/package.json b/packages/contrail/package.json index 4a52b6a..c11d8ec 100644 --- a/packages/contrail/package.json +++ b/packages/contrail/package.json @@ -81,11 +81,13 @@ "@atcute/cbor": "^2.3.6", "@atcute/cid": "2.4.2", "@atcute/client": "^5.1.1", + "@atcute/identity": "^2.0.2", "@atcute/identity-resolver": "^2.0.1", "@atcute/jetstream": "^2.0.2", "@atcute/lex-cli": "^3.2.1", "@atcute/lexicon-doc": "3.0.2", "@atcute/lexicons": "^2.0.3", + "@atcute/oauth-types": "^1.0.1", "@atcute/tid": "1.1.4", "@atcute/xrpc-server": "^2.0.2", "cac": "^7.0.0", diff --git a/packages/contrail/src/cli/commands/connect.ts b/packages/contrail/src/cli/commands/connect.ts index 8c4cb11..5f92304 100644 --- a/packages/contrail/src/cli/commands/connect.ts +++ b/packages/contrail/src/cli/commands/connect.ts @@ -21,6 +21,7 @@ import type { CAC } from "cac"; import { describePublicService, digestLexiconDocuments, + isPublicServiceAuthContract, isPublicServiceManifest, normalizePublicServiceEndpoint, validateServiceManifest, @@ -104,6 +105,8 @@ async function readProviderLock(path: string): Promise { "contractDigest" in lock || typeof lock.endpoint !== "string" || typeof lock.lexiconRoot !== "string" || + !("serviceAuth" in lock) || + !isPublicServiceAuthContract(lock.serviceAuth) || (lock.allowInsecureHttp !== undefined && lock.allowInsecureHttp !== true) ) { if ((value as { version?: unknown }).version === 1) { @@ -111,6 +114,16 @@ async function readProviderLock(path: string): Promise { "existing Contrail provider lock uses unsupported version 1; remove it and reconnect", ); } + if ( + lock.format === "contrail.provider-lock" && + lock.version === 2 && + "serviceAuth" in lock && + !isPublicServiceAuthContract(lock.serviceAuth) + ) { + throw new Error( + "existing Contrail provider lock predates exact service-auth audiences; remove it, reconnect, and reauthorize OAuth", + ); + } throw new Error("existing Contrail provider lock is malformed"); } return lock as ProviderLock; @@ -185,6 +198,10 @@ async function exists(path: string): Promise { } } +function protectedMethodIds(provider: ProviderDefinition): string[] { + return (provider.serviceAuth?.methods ?? []).map(({ id }) => id).sort(); +} + function formatStringArray( values: readonly string[], indentation: number, @@ -228,9 +245,16 @@ export async function ensureConsumerLexiconConfig(options: { ); const typesRoot = relative(root, dirname(typesIndex)).replaceAll("\\", "/"); const target = options.target ?? options.api; - const serviceDid = target.serviceAuth?.audience ?? null; - const scope = serviceDid ? `rpc?lxm=*&aud=${serviceDid}` : null; - const source = `${GENERATED_LEXICON_CONFIG_HEADER}export default {\n contrail: {\n endpoint: ${JSON.stringify(target.endpoint)},\n serviceDid: ${JSON.stringify(serviceDid)},\n scope: ${JSON.stringify(scope)},\n collections: ${formatStringArray(target.collections, 4)},\n },\n generate: {\n files: [${JSON.stringify(`${patternRoot}/**/*.json`)}],\n outdir: ${JSON.stringify(`${typesRoot}/`)},\n },\n};\n`; + // Omit the service-auth keys entirely when the provider has none. This block + // is reference material consumers paste into `createPublicServiceClient`, and + // a wall of nulls reads like a broken contract rather than an anonymous one. + const serviceAuthFields = target.serviceAuth + ? `\n serviceDid: ${JSON.stringify(target.serviceAuth.serviceDid)},` + + `\n serviceAudience: ${JSON.stringify(target.serviceAuth.audience)},` + + `\n scope: ${JSON.stringify(target.serviceAuth.scope)},` + + `\n protectedMethods: ${formatStringArray(protectedMethodIds(target), 4)},` + : ""; + const source = `${GENERATED_LEXICON_CONFIG_HEADER}export default {\n contrail: {\n endpoint: ${JSON.stringify(target.endpoint)},${serviceAuthFields}\n collections: ${formatStringArray(target.collections, 4)},\n },\n generate: {\n files: [${JSON.stringify(`${patternRoot}/**/*.json`)}],\n outdir: ${JSON.stringify(`${typesRoot}/`)},\n },\n};\n`; if (await exists(path)) { const current = await readFile(path, "utf8"); @@ -313,12 +337,10 @@ export async function ensureConsumerClientModule(options: { 'import type { PublicServiceClientOptions } from "@atmo-dev/contrail/client";\n'; } const target = options.target ?? options.api; - const targetServiceDid = target.serviceAuth?.audience; - const targetScope = targetServiceDid - ? `rpc?lxm=*&aud=${targetServiceDid}` - : null; - const apiProtectedMethods = - options.api.serviceAuth?.methods.map(({ id }) => id) ?? []; + const targetServiceDid = target.serviceAuth?.serviceDid; + const targetServiceAudience = target.serviceAuth?.audience; + const targetScope = target.serviceAuth?.scope; + const apiProtectedMethods = protectedMethodIds(options.api); const configuredNotifyMethod = options.notifyMethod ?? [...options.api.methods, ...apiProtectedMethods].find( @@ -331,8 +353,7 @@ export async function ensureConsumerClientModule(options: { ...(configuredNotifyMethod ? [configuredNotifyMethod] : []), ]), ].sort(); - const targetProtectedMethods = - target.serviceAuth?.methods.map(({ id }) => id) ?? []; + const targetProtectedMethods = protectedMethodIds(target); const targetMethods = [ ...new Set([...target.methods, ...targetProtectedMethods]), ].sort(); @@ -342,10 +363,10 @@ export async function ensureConsumerClientModule(options: { ); const constAssertion = isTypeScript ? " as const" : ""; const targetType = isTypeScript - ? `\nexport type ContrailTarget = Pick<\n PublicServiceClientOptions,\n "endpoint" | "allowInsecureHttp" | "serviceDid" | "scope" | "serviceMethods" | "collections"\n> & {\n notifyMethod?: PublicServiceClientOptions["notifyMethod"] | null;\n};\n` + ? `\nexport type ContrailTarget = Pick<\n PublicServiceClientOptions,\n "endpoint" | "allowInsecureHttp" | "serviceDid" | "serviceAudience" | "scope" | "protectedMethods" | "serviceMethods" | "collections"\n> & {\n notifyMethod?: PublicServiceClientOptions["notifyMethod"] | null;\n};\n` : ""; const targetAnnotation = isTypeScript ? ": ContrailTarget" : ""; - const source = `${GENERATED_CLIENT_HEADER}import { createPublicServiceClient } from "@atmo-dev/contrail/client";\n${generatedTypeImport}${generatedImport}\nexport const contrailApi = {\n namespace: ${JSON.stringify(options.api.namespace)},\n serviceMethods: ${formatStringArray(serviceMethods, 2)},\n collections: ${formatStringArray(options.api.collections, 2)},\n notifyMethod: ${JSON.stringify(notifyMethod ?? null)},\n}${constAssertion};\n\nexport const contrailTarget = {\n endpoint: ${JSON.stringify(target.endpoint)},${target.allowInsecureHttp ? "\n allowInsecureHttp: true," : ""}${targetServiceDid ? `\n serviceDid: ${JSON.stringify(targetServiceDid)},\n scope: ${JSON.stringify(targetScope)},` : ""}\n serviceMethods: ${formatStringArray(targetMethods, 2)},\n collections: ${formatStringArray(target.collections, 2)},\n notifyMethod: ${JSON.stringify(targetNotifyMethod ?? null)},\n}${constAssertion};\n\nexport const contrailMethods = contrailTarget.serviceMethods;\n${targetType}\nexport function createContrailClient(target${targetAnnotation} = contrailTarget) {\n const { notifyMethod: targetNotifyMethod, ...runtimeTarget } = target;\n const notifyMethod =\n targetNotifyMethod === undefined\n ? contrailApi.notifyMethod\n : targetNotifyMethod;\n return createPublicServiceClient({\n ...runtimeTarget,\n serviceMethods: target.serviceMethods ?? contrailApi.serviceMethods,\n collections: target.collections ?? contrailApi.collections,\n ...(notifyMethod ? { notifyMethod } : {}),\n });\n}\n\nexport function createLocalContrailClient(\n endpoint = "http://127.0.0.1:8787",\n) {\n return createContrailClient({\n endpoint,\n allowInsecureHttp: true,\n serviceMethods: contrailApi.serviceMethods,\n collections: contrailApi.collections,\n notifyMethod: contrailApi.notifyMethod,\n });\n}\n\nexport const contrail = createContrailClient();\n`; + const source = `${GENERATED_CLIENT_HEADER}import { createPublicServiceClient } from "@atmo-dev/contrail/client";\n${generatedTypeImport}${generatedImport}\nexport const contrailApi = {\n namespace: ${JSON.stringify(options.api.namespace)},\n serviceMethods: ${formatStringArray(serviceMethods, 2)},\n protectedMethods: ${formatStringArray(apiProtectedMethods, 2)},\n collections: ${formatStringArray(options.api.collections, 2)},\n notifyMethod: ${JSON.stringify(notifyMethod ?? null)},\n}${constAssertion};\n\nexport const contrailTarget = {\n endpoint: ${JSON.stringify(target.endpoint)},${target.allowInsecureHttp ? "\n allowInsecureHttp: true," : ""}${targetServiceDid && targetServiceAudience && targetScope ? `\n serviceDid: ${JSON.stringify(targetServiceDid)},\n serviceAudience: ${JSON.stringify(targetServiceAudience)},\n scope: ${JSON.stringify(targetScope)},\n protectedMethods: ${formatStringArray(targetProtectedMethods, 2)},` : ""}\n serviceMethods: ${formatStringArray(targetMethods, 2)},\n collections: ${formatStringArray(target.collections, 2)},\n notifyMethod: ${JSON.stringify(targetNotifyMethod ?? null)},\n}${constAssertion};\n\nexport const contrailMethods = contrailTarget.serviceMethods;\n${targetType}\nexport function createContrailClient(target${targetAnnotation} = contrailTarget) {\n const { notifyMethod: targetNotifyMethod, ...runtimeTarget } = target;\n const notifyMethod =\n targetNotifyMethod === undefined\n ? contrailApi.notifyMethod\n : targetNotifyMethod;\n return createPublicServiceClient({\n ...runtimeTarget,\n serviceMethods: target.serviceMethods ?? contrailApi.serviceMethods,\n collections: target.collections ?? contrailApi.collections,\n ...(notifyMethod ? { notifyMethod } : {}),\n });\n}\n\nexport function createLocalContrailClient(\n endpoint = "http://127.0.0.1:8787",\n) {\n return createContrailClient({\n endpoint,\n allowInsecureHttp: true,\n serviceMethods: contrailApi.serviceMethods,\n collections: contrailApi.collections,\n notifyMethod: contrailApi.notifyMethod,\n });\n}\n\nexport const contrail = createContrailClient();\n`; await mkdir(dirname(path), { recursive: true }); if (await exists(path)) { diff --git a/packages/contrail/src/core/router/index.ts b/packages/contrail/src/core/router/index.ts index 3c753c3..a4e9720 100644 --- a/packages/contrail/src/core/router/index.ts +++ b/packages/contrail/src/core/router/index.ts @@ -14,8 +14,10 @@ import { resolveProfiles } from "./profiles"; import { createServiceAuthGate } from "../service-auth"; import { describePublicService, + hostsServiceDidDocument, normalizeLexiconDocuments, normalizePublicServiceEndpoint, + validatePublicServiceAuthEndpoint, validatePublicServiceLexicons, type PublicServiceOptions, } from "../../public-service"; @@ -71,11 +73,12 @@ export function createApp( ? normalizeLexiconDocuments(options.lexicons ?? []) : (options.lexicons ?? []); if (options.publicService) { - normalizePublicServiceEndpoint( + const publicEndpoint = normalizePublicServiceEndpoint( options.publicService.endpoint, options.publicService, ); validatePublicServiceLexicons(config, lexicons); + validatePublicServiceAuthEndpoint(config, options.publicService); const description = describePublicService( config, options.publicService, @@ -88,23 +91,24 @@ export function createApp( }); if ( serviceAuth && - serviceAuth.audience === - `did:web:${new URL(options.publicService.endpoint).hostname}` + hostsServiceDidDocument(serviceAuth.serviceDid, publicEndpoint) ) { app.get("/.well-known/did.json", (c) => { c.header("content-type", "application/did+ld+json; charset=UTF-8"); c.header("cache-control", "public, max-age=300"); - return c.json({ - "@context": ["https://www.w3.org/ns/did/v1"], - id: serviceAuth.audience, - service: [ - { - id: `${serviceAuth.audience}#contrail`, - type: "ContrailService", - serviceEndpoint: options.publicService!.endpoint, - }, - ], - }); + return c.body( + JSON.stringify({ + "@context": ["https://www.w3.org/ns/did/v1"], + id: serviceAuth.serviceDid, + service: [ + { + id: serviceAuth.audience, + type: "ContrailService", + serviceEndpoint: publicEndpoint, + }, + ], + }), + ); }); } app.get("/lexicons", async (c) => { diff --git a/packages/contrail/src/core/service-auth.ts b/packages/contrail/src/core/service-auth.ts index 82d9b85..7d9c7f4 100644 --- a/packages/contrail/src/core/service-auth.ts +++ b/packages/contrail/src/core/service-auth.ts @@ -4,9 +4,14 @@ import { WebDidDocumentResolver, type DidDocumentResolver, } from "@atcute/identity-resolver"; -import type { Did, Nsid } from "@atcute/lexicons/syntax"; +import type { + AtprotoAudience, + AtprotoDid, + Nsid, +} from "@atcute/lexicons/syntax"; import { ServiceJwtVerifier, type VerifiedJwt } from "@atcute/xrpc-server/auth"; import { XRPCError } from "@atcute/xrpc-server"; +import { parseServiceAudience } from "../service-auth-contract.js"; import type { AtprotoServiceAuthMethod, ContrailConfig } from "./types.js"; const AUTH_TIMEOUT_MS = 5_000; @@ -70,7 +75,8 @@ export interface ServiceAuthResult { } export interface ServiceAuthGate { - readonly audience: Did; + readonly serviceDid: AtprotoDid; + readonly audience: AtprotoAudience; protects(method: AtprotoServiceAuthMethod): boolean; authorize(request: Request, method: Nsid): Promise; } @@ -89,15 +95,14 @@ function defaultResolver(): DidDocumentResolver { } /** Create the shared verifier used by protected built-in routes. Tokens remain - * method-bound even when a client obtained permission through one wildcard - * OAuth scope (`rpc?lxm=*&aud=`). */ + * bound to the exact service audience and XRPC method. */ export function createServiceAuthGate( config: ContrailConfig, ): ServiceAuthGate | null { if (!config.serviceAuth) return null; const serviceAuth = config.serviceAuth; const protectedMethods = new Set(serviceAuth.methods); - const audience = serviceAuth.audience as Did; + const { serviceDid, audience } = parseServiceAudience(serviceAuth.audience); const verifier = new ServiceJwtVerifier({ acceptAudiences: [audience], resolver: serviceAuth.resolver ?? defaultResolver(), @@ -105,6 +110,7 @@ export function createServiceAuthGate( }); return { + serviceDid, audience, protects(method) { return protectedMethods.has(method); diff --git a/packages/contrail/src/core/types.ts b/packages/contrail/src/core/types.ts index fad25e3..c472054 100644 --- a/packages/contrail/src/core/types.ts +++ b/packages/contrail/src/core/types.ts @@ -1,4 +1,5 @@ -import { isDid } from "@atcute/lexicons/syntax"; +import type { AtprotoAudience } from "@atcute/lexicons/syntax"; +import { parseServiceAudience } from "../service-auth-contract.js"; import type { SqlDialect } from "./dialect"; // Database interface — D1 implements this natively @@ -251,8 +252,8 @@ export interface OrderedSourceConfig { export type AtprotoServiceAuthMethod = "getFeed" | "notifyOfUpdate"; export interface AtprotoServiceAuthConfig { - /** Plain service DID used as the exact JWT audience. */ - audience: string; + /** Exact fragmented service reference used as the OAuth and JWT audience. */ + audience: AtprotoAudience; /** Built-in methods that require a method-bound AT Protocol service token. */ methods: AtprotoServiceAuthMethod[]; /** Maximum accepted token lifetime and age. Default: 300 seconds. */ @@ -419,8 +420,12 @@ export function resolveConfig(config: ContrailConfig): ResolvedContrailConfig { throw new TypeError("orderedSource requires non-empty source and epoch values"); } if (config.serviceAuth) { - if (!isDid(config.serviceAuth.audience)) { - throw new TypeError("serviceAuth.audience must be a plain DID"); + try { + parseServiceAudience(config.serviceAuth.audience); + } catch (error) { + throw new TypeError( + `serviceAuth.audience must be an absolute AT Protocol DID service reference: ${(error as Error).message}`, + ); } if ( !Array.isArray(config.serviceAuth.methods) || diff --git a/packages/contrail/src/public-client.ts b/packages/contrail/src/public-client.ts index 2fc903d..b028355 100644 --- a/packages/contrail/src/public-client.ts +++ b/packages/contrail/src/public-client.ts @@ -4,12 +4,25 @@ import { simpleFetchHandler, type FetchHandler, } from "@atcute/client"; -import { isDid, type Did, type Nsid } from "@atcute/lexicons/syntax"; +import { + isDid, + type AtprotoAudience, + type AtprotoDid, + type Did, + type Nsid, +} from "@atcute/lexicons/syntax"; import { isPublicServiceManifest, normalizePublicServiceEndpoint, type PublicServiceAuthContract, } from "./public-service.js"; +import { + compareCanonical, + formatServiceOAuthScope, + parseServiceAudience, + parseServiceOAuthScope, + type ServiceOAuthScope, +} from "./service-auth-contract.js"; const DISCOVERY_TIMEOUT_MS = 15_000; const TOKEN_EXPIRY_SKEW_MS = 5_000; @@ -27,11 +40,15 @@ export interface PublicServiceClientOptions { /** Existing authenticated AT Protocol client used to mint service tokens. * Omit when the consumer only needs anonymous methods. */ authenticatedClient?: Client; - /** Optional receiving-service DID from `lex.config.js`. Supplying it also - * makes the required OAuth permission available as `client.scope`. */ - serviceDid?: Did; - /** Optional precomputed OAuth permission. Must match `serviceDid`. */ - scope?: `rpc?lxm=*&aud=${string}`; + /** Base receiving-service DID from the verified provider contract. Null and + * an omitted value both mean the provider serves no protected methods. */ + serviceDid?: AtprotoDid | null; + /** Exact fragmented OAuth and JWT audience from the provider contract. */ + serviceAudience?: AtprotoAudience | null; + /** Exact least-privilege OAuth permission from the provider contract. */ + scope?: ServiceOAuthScope | null; + /** XRPC methods granted by the exact OAuth permission. */ + protectedMethods?: readonly Nsid[] | null; /** Exact XRPC methods served by this provider. Supplying the verified list * lets authenticated clients route all other methods to the user's PDS. */ serviceMethods?: readonly Nsid[]; @@ -61,7 +78,7 @@ export type PublicServiceClient = Client & { /** Canonical public Contrail origin. */ readonly endpoint: string; /** OAuth permission required by protected methods, or null when unconfigured. */ - readonly scope: `rpc?lxm=*&aud=${string}` | null; + readonly scope: ServiceOAuthScope | null; /** Record collections whose successful writes trigger notification. */ readonly collections: readonly Nsid[]; /** Combine this provider with an authenticated PDS client. Provider methods @@ -74,9 +91,81 @@ export type PublicServiceClient = Client & { }; export function publicServiceOAuthScope( - audience: Did, -): `rpc?lxm=*&aud=${string}` { - return `rpc?lxm=*&aud=${audience}`; + audience: AtprotoAudience, + protectedMethods: readonly Nsid[], +): ServiceOAuthScope { + return formatServiceOAuthScope(audience, protectedMethods); +} + +interface ConfiguredServiceAuth { + serviceDid: AtprotoDid; + audience: AtprotoAudience; + scope: ServiceOAuthScope; + protectedMethods: Nsid[]; +} + +function sameMethods( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((method, index) => method === right[index]) + ); +} + +function configuredServiceAuth( + options: PublicServiceClientOptions, +): ConfiguredServiceAuth | null { + // A provider without protected methods is generated as absent keys, but the + // untyped `lex.config.js` block spells the same thing as nulls and an empty + // method list. Both mean anonymous-only, never a half-configured contract. + const absent = (value: unknown) => + value === undefined || + value === null || + (Array.isArray(value) && value.length === 0); + const supplied = [ + options.serviceDid, + options.serviceAudience, + options.scope, + options.protectedMethods, + ]; + if (supplied.every(absent)) return null; + if (supplied.some(absent)) { + throw new Error( + "Contrail service auth configuration is incomplete; run `contrail connect --update` and reauthorize", + ); + } + + try { + const audience = parseServiceAudience(options.serviceAudience); + const parsedScope = parseServiceOAuthScope(options.scope); + const canonicalScope = formatServiceOAuthScope( + audience.audience, + options.protectedMethods!, + ); + if (options.serviceDid !== audience.serviceDid) { + throw new Error( + `service DID ${options.serviceDid} does not match audience ${audience.audience}`, + ); + } + if (parsedScope.audience !== audience.audience) { + throw new Error("OAuth scope targets a different service audience"); + } + if (parsedScope.canonicalScope !== canonicalScope) { + throw new Error("OAuth scope does not grant the exact protected methods"); + } + return { + serviceDid: audience.serviceDid, + audience: audience.audience, + scope: canonicalScope, + protectedMethods: parsedScope.methods, + }; + } catch (error) { + throw new Error( + `Contrail service auth configuration is invalid; run \`contrail connect --update\` and reauthorize: ${(error as Error).message}`, + ); + } } function xrpcMethod(pathname: string): Nsid | null { @@ -126,6 +215,7 @@ export function publicServiceFetchHandler( options: PublicServiceClientOptions, ): FetchHandler { const endpoint = normalizePublicServiceEndpoint(options.endpoint, options); + const configuredAuth = configuredServiceAuth(options); const fetcher = options.fetch ?? fetch; const base = simpleFetchHandler({ service: endpoint, fetch: fetcher }); const tokens = new Map(); @@ -158,12 +248,40 @@ export function publicServiceFetchHandler( ); } const serviceAuth = value.serviceAuth ?? null; - if ( - options.serviceDid && - serviceAuth?.audience !== options.serviceDid - ) { + if (!serviceAuth) { + if (configuredAuth) { + throw new PublicServiceContractError( + `Contrail service DID mismatch: expected ${configuredAuth.serviceDid}, received none`, + ); + } + return null; + } + if (!configuredAuth) { + throw new PublicServiceContractError( + "Contrail protected methods are not configured; run `contrail connect --update` and reauthorize", + ); + } + if (serviceAuth.serviceDid !== configuredAuth.serviceDid) { + throw new PublicServiceContractError( + `Contrail service DID mismatch: expected ${configuredAuth.serviceDid}, received ${serviceAuth.serviceDid}`, + ); + } + if (serviceAuth.audience !== configuredAuth.audience) { + throw new PublicServiceContractError( + `Contrail service audience mismatch: expected ${configuredAuth.audience}, received ${serviceAuth.audience}`, + ); + } + if (serviceAuth.scope !== configuredAuth.scope) { throw new PublicServiceContractError( - `Contrail service DID mismatch: expected ${options.serviceDid}, received ${serviceAuth?.audience ?? "none"}`, + `Contrail OAuth scope mismatch: expected ${configuredAuth.scope}, received ${serviceAuth.scope}`, + ); + } + const discoveredMethods = serviceAuth.methods + .map((method) => method.id) + .sort(compareCanonical); + if (!sameMethods(discoveredMethods, configuredAuth.protectedMethods)) { + throw new PublicServiceContractError( + "Contrail protected-method mismatch; run `contrail connect --update` and reauthorize", ); } return serviceAuth; @@ -212,7 +330,7 @@ export function publicServiceFetchHandler( "com.atproto.server.getServiceAuth", { params: { - aud: auth.audience as Did, + aud: auth.audience, lxm: method, }, }, @@ -337,19 +455,12 @@ function createClient( authenticatedOptions: PublicServiceAuthenticatedOptions = {}, ): PublicServiceClient { const endpoint = normalizePublicServiceEndpoint(options.endpoint, options); + const serviceAuth = configuredServiceAuth(options); const authenticatedClients = new WeakMap(); const client = new Client({ handler: publicServiceFetchHandler({ ...options, endpoint }), }) as PublicServiceClient; - const expectedScope = options.serviceDid - ? publicServiceOAuthScope(options.serviceDid) - : null; - if (options.scope && options.scope !== expectedScope) { - throw new Error( - `Contrail OAuth scope mismatch: expected ${expectedScope ?? "none"}, received ${options.scope}`, - ); - } - const scope = options.scope ?? expectedScope; + const scope = serviceAuth?.scope ?? null; const collections = Object.freeze([...(options.collections ?? [])]); Object.defineProperties(client, { @@ -494,8 +605,13 @@ function createClient( /** Create a typed Atcute client for anonymous and service-auth Contrail methods. * Generated Lexicon imports still supply the method-specific TypeScript API. */ export function createPublicServiceClient( - options: PublicServiceClientOptions & { serviceDid: Did }, -): PublicServiceClient & { readonly scope: `rpc?lxm=*&aud=${string}` }; + options: PublicServiceClientOptions & { + serviceDid: AtprotoDid; + serviceAudience: AtprotoAudience; + scope: ServiceOAuthScope; + protectedMethods: readonly Nsid[]; + }, +): PublicServiceClient & { readonly scope: ServiceOAuthScope }; export function createPublicServiceClient( options: PublicServiceClientOptions, ): PublicServiceClient; diff --git a/packages/contrail/src/public-service.ts b/packages/contrail/src/public-service.ts index d07390b..27f5656 100644 --- a/packages/contrail/src/public-service.ts +++ b/packages/contrail/src/public-service.ts @@ -1,5 +1,17 @@ -import { isDid, isNsid } from "@atcute/lexicons/syntax"; +import { isAtprotoWebDid, webDidToDocumentUrl } from "@atcute/identity"; +import { + isNsid, + type AtprotoAudience, + type AtprotoDid, +} from "@atcute/lexicons/syntax"; import type { ContrailConfig } from "./core/types.js"; +import { + compareCanonical, + formatServiceOAuthScope, + parseServiceAudience, + parseServiceOAuthScope, + type ServiceOAuthScope, +} from "./service-auth-contract.js"; import { getCollectionMethods, nsidForShortName, @@ -30,7 +42,9 @@ export interface PublicServiceProtectedMethod { export interface PublicServiceAuthContract { type: "atproto-service-auth"; - audience: string; + serviceDid: AtprotoDid; + audience: AtprotoAudience; + scope: ServiceOAuthScope; methods: PublicServiceProtectedMethod[]; } @@ -92,6 +106,37 @@ export function normalizePublicServiceEndpoint( return url.origin; } +/** Whether this endpoint is the origin that hosts the base DID's document, and + * therefore whether Contrail should publish one at `/.well-known/did.json`. */ +export function hostsServiceDidDocument( + serviceDid: AtprotoDid, + endpoint: string, +): boolean { + if (!isAtprotoWebDid(serviceDid)) return false; + return ( + webDidToDocumentUrl(serviceDid).href === + new URL("/.well-known/did.json", endpoint).href + ); +} + +export function validatePublicServiceAuthEndpoint( + config: ContrailConfig, + options: PublicServiceOptions, +): void { + if (!config.serviceAuth) return; + const endpoint = normalizePublicServiceEndpoint(options.endpoint, options); + const { serviceDid, audience } = parseServiceAudience( + config.serviceAuth.audience, + ); + if (!isAtprotoWebDid(serviceDid)) return; + + if (!hostsServiceDidDocument(serviceDid, endpoint)) { + throw new Error( + `serviceAuth audience ${audience} resolves its DID document to ${webDidToDocumentUrl(serviceDid).href}, not ${new URL("/.well-known/did.json", endpoint).href}`, + ); + } +} + function normalizeJson(value: unknown): unknown { if (Array.isArray(value)) return value.map(normalizeJson); if (value && typeof value === "object") { @@ -131,7 +176,7 @@ export function normalizeLexiconDocuments( } byId.set(id, value as LexiconDocument); } - return [...byId.values()].sort((a, b) => a.id.localeCompare(b.id)); + return [...byId.values()].sort((a, b) => compareCanonical(a.id, b.id)); } function publicCollections(config: ContrailConfig): PublicServiceCollection[] { @@ -183,10 +228,18 @@ function publicServiceAuth( ? { id: `${config.namespace}.getFeed`, type: "query" } : { id: `${config.namespace}.notifyOfUpdate`, type: "procedure" }, ) - .sort((left, right) => left.id.localeCompare(right.id)); + .sort((left, right) => compareCanonical(left.id, right.id)); + const { serviceDid, audience } = parseServiceAudience( + config.serviceAuth.audience, + ); return { type: "atproto-service-auth", - audience: config.serviceAuth.audience, + serviceDid, + audience, + scope: formatServiceOAuthScope( + audience, + methods.map((method) => method.id), + ), methods, }; } @@ -370,18 +423,19 @@ export function validateServiceManifest( ); } -function isPublicServiceAuthContract( +export function isPublicServiceAuthContract( value: unknown, ): value is PublicServiceAuthContract | null | undefined { if (value === null || value === undefined) return true; if (!value || typeof value !== "object") return false; const auth = value as Partial; - return ( - auth.type === "atproto-service-auth" && - typeof auth.audience === "string" && - isDid(auth.audience) && - Array.isArray(auth.methods) && - auth.methods.every( + if ( + auth.type !== "atproto-service-auth" || + typeof auth.serviceDid !== "string" || + typeof auth.audience !== "string" || + typeof auth.scope !== "string" || + !Array.isArray(auth.methods) || + !auth.methods.every( (method) => !!method && typeof method === "object" && @@ -389,7 +443,26 @@ function isPublicServiceAuthContract( isNsid(method.id) && (method.type === "query" || method.type === "procedure"), ) - ); + ) { + return false; + } + + try { + const audience = parseServiceAudience(auth.audience); + const parsedScope = parseServiceOAuthScope(auth.scope); + const methodIds = auth.methods.map((method) => method.id); + const sortedMethodIds = [...methodIds].sort(compareCanonical); + return ( + new Set(methodIds).size === methodIds.length && + methodIds.every((method, index) => method === sortedMethodIds[index]) && + auth.serviceDid === audience.serviceDid && + parsedScope.audience === audience.audience && + parsedScope.canonicalScope === auth.scope && + formatServiceOAuthScope(audience.audience, methodIds) === auth.scope + ); + } catch { + return false; + } } export function isPublicServiceManifest( diff --git a/packages/contrail/src/service-auth-contract.ts b/packages/contrail/src/service-auth-contract.ts new file mode 100644 index 0000000..700197c --- /dev/null +++ b/packages/contrail/src/service-auth-contract.ts @@ -0,0 +1,152 @@ +import { isAtprotoAudience } from "@atcute/identity"; +import type { + AtprotoAudience, + AtprotoDid, + Nsid, +} from "@atcute/lexicons/syntax"; +import { isNsid } from "@atcute/lexicons/syntax"; +import { scope } from "@atcute/oauth-types"; + +export type ServiceOAuthScope = `rpc?${string}`; + +export interface ParsedServiceAudience { + audience: AtprotoAudience; + serviceDid: AtprotoDid; + fragment: string; +} + +export interface ParsedServiceOAuthScope extends ParsedServiceAudience { + methods: Nsid[]; + canonicalScope: ServiceOAuthScope; +} + +/** Parse the exact fragmented AT Protocol audience used by OAuth and service JWTs. */ +export function parseServiceAudience(value: unknown): ParsedServiceAudience { + if (typeof value !== "string") { + throw new TypeError( + "service audience must be an absolute AT Protocol DID service reference", + ); + } + + const separator = value.indexOf("#"); + if (separator === -1) { + throw new TypeError( + "service audience must include a non-empty service fragment", + ); + } + if (separator === value.length - 1) { + throw new TypeError("service audience fragment must not be empty"); + } + if (value.indexOf("#", separator + 1) !== -1) { + throw new TypeError("service audience must contain exactly one fragment"); + } + if (!isAtprotoAudience(value)) { + throw new TypeError( + "service audience must use a supported did:plc or did:web service reference", + ); + } + + return { + audience: value, + serviceDid: value.slice(0, separator) as AtprotoDid, + fragment: value.slice(separator + 1), + }; +} + +/** Canonical ordering for every contract field. Code-unit order is identical in + * every runtime, while `localeCompare` varies with the host locale and ICU + * build — and these orderings are compared byte for byte across processes. */ +export function compareCanonical(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0; +} + +function normalizeMethods(values: readonly string[]): Nsid[] { + if (!Array.isArray(values)) { + throw new TypeError("service OAuth scope methods must be an array"); + } + + const methods = new Set(); + for (const value of values) { + if (!isNsid(value)) { + throw new TypeError( + `service OAuth scope method must be a valid NSID: ${String(value)}`, + ); + } + methods.add(value); + } + if (methods.size === 0) { + throw new TypeError("service OAuth scope requires at least one method"); + } + return [...methods].sort(compareCanonical); +} + +/** Format one deterministic least-privilege RPC permission for a service. */ +export function formatServiceOAuthScope( + audience: AtprotoAudience, + methodNsids: readonly string[], +): ServiceOAuthScope { + const parsed = parseServiceAudience(audience); + const methods = normalizeMethods(methodNsids); + return scope.rpc({ aud: parsed.audience, lxm: methods }) as ServiceOAuthScope; +} + +function decodeScopeValue(value: string): string { + try { + // Scope query values use percent encoding, not form encoding: preserve '+'. + return decodeURIComponent(value); + } catch { + throw new TypeError("service OAuth scope contains invalid percent encoding"); + } +} + +/** Parse and semantically normalize the limited exact RPC scope Contrail uses. */ +export function parseServiceOAuthScope( + value: unknown, +): ParsedServiceOAuthScope { + if (typeof value !== "string" || !value.startsWith("rpc?")) { + throw new TypeError("service OAuth scope must be an rpc permission"); + } + + const query = value.slice(4); + if (!query) { + throw new TypeError("service OAuth scope must contain parameters"); + } + + const audiences: string[] = []; + const rawMethods: string[] = []; + for (const part of query.split("&")) { + const separator = part.indexOf("="); + if (separator <= 0 || separator === part.length - 1) { + throw new TypeError("service OAuth scope contains an empty parameter"); + } + + const name = part.slice(0, separator); + const rawValue = part.slice(separator + 1); + if (name !== "aud" && name !== "lxm") { + throw new TypeError(`unsupported service OAuth scope parameter: ${name}`); + } + if (name === "aud" && rawValue.includes("#")) { + throw new TypeError( + "service OAuth scope audience fragment must be percent-encoded", + ); + } + + const decoded = decodeScopeValue(rawValue); + if (name === "aud") audiences.push(decoded); + else rawMethods.push(decoded); + } + + if (audiences.length !== 1) { + throw new TypeError( + "service OAuth scope must contain exactly one audience", + ); + } + + const parsed = parseServiceAudience(audiences[0]); + const methods = normalizeMethods(rawMethods); + return { + ...parsed, + methods, + canonicalScope: formatServiceOAuthScope(parsed.audience, methods), + }; +} diff --git a/packages/contrail/src/worker/index.ts b/packages/contrail/src/worker/index.ts index 3cdd82b..a75f0a0 100644 --- a/packages/contrail/src/worker/index.ts +++ b/packages/contrail/src/worker/index.ts @@ -20,6 +20,7 @@ import type { ContrailConfig, Database } from "../core/types.js"; import type { BackfillRetryOptions } from "../core/backfill.js"; import { normalizePublicServiceEndpoint, + validatePublicServiceAuthEndpoint, validatePublicServiceLexicons, type PublicServiceOptions, } from "../public-service.js"; @@ -50,6 +51,7 @@ export function createWorker( if (options.publicService) { normalizePublicServiceEndpoint(options.publicService.endpoint); validatePublicServiceLexicons(config, options.lexicons ?? []); + validatePublicServiceAuthEndpoint(config, options.publicService); } const contrail = new Contrail({ ...config, lexicons: options.lexicons }); const handle = createHandler(contrail, { diff --git a/packages/contrail/tests/built-client.mjs b/packages/contrail/tests/built-client.mjs index 479d61d..17877a6 100644 --- a/packages/contrail/tests/built-client.mjs +++ b/packages/contrail/tests/built-client.mjs @@ -4,13 +4,19 @@ import { createPublicServiceClient } from "../dist/public-client.js"; const client = createPublicServiceClient({ endpoint: "https://api.example.com", serviceDid: "did:web:api.example.com", - scope: "rpc?lxm=*&aud=did:web:api.example.com", - serviceMethods: ["com.example.listRecords"], + serviceAudience: "did:web:api.example.com#contrail", + scope: + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed", + protectedMethods: ["com.example.getFeed"], + serviceMethods: ["com.example.listRecords", "com.example.getFeed"], collections: ["community.example.event"], fetch: async () => Response.json({ records: [] }), }); assert.equal(client.endpoint, "https://api.example.com"); -assert.equal(client.scope, "rpc?lxm=*&aud=did:web:api.example.com"); +assert.equal( + client.scope, + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed", +); assert.deepEqual(client.collections, ["community.example.event"]); const response = await client.get("com.example.listRecords"); assert.equal(response.ok, true); diff --git a/packages/contrail/tests/connect.test.ts b/packages/contrail/tests/connect.test.ts index e92ae98..0552dad 100644 --- a/packages/contrail/tests/connect.test.ts +++ b/packages/contrail/tests/connect.test.ts @@ -12,6 +12,7 @@ import { } from "../src/cli/commands/connect"; import { digestLexiconDocuments, + type PublicServiceAuthContract, type PublicServiceManifest, } from "../src/public-service"; @@ -41,6 +42,17 @@ const notifyLexicon = { defs: { main: { type: "procedure" } }, }; +function serviceAuthContract(): PublicServiceAuthContract { + return { + type: "atproto-service-auth", + serviceDid: "did:web:api.atmo.rsvp", + audience: "did:web:api.atmo.rsvp#contrail", + scope: + "rpc?aud=did:web:api.atmo.rsvp%23contrail&lxm=atmo.rsvp.notifyOfUpdate", + methods: [{ id: notifyMethod, type: "procedure" }], + }; +} + function providerLock(): ProviderLock { return { format: "contrail.provider-lock", @@ -50,11 +62,7 @@ function providerLock(): ProviderLock { lexiconDigest: `sha256:${"b".repeat(64)}`, methods: [method], collections: ["community.lexicon.calendar.event"], - serviceAuth: { - type: "atproto-service-auth", - audience: "did:web:api.atmo.rsvp", - methods: [{ id: notifyMethod, type: "procedure" }], - }, + serviceAuth: serviceAuthContract(), lexiconRoot: "src/contrail/lexicons/api.atmo.rsvp", }; } @@ -120,6 +128,10 @@ describe("contrail connect", () => { namespace: "atmo.rsvp", profiles: [], notify: true, + serviceAuth: { + audience: "did:web:api.atmo.rsvp#contrail", + methods: ["notifyOfUpdate"], + }, collections: { event: { collection: "community.lexicon.calendar.event" }, }, @@ -164,6 +176,7 @@ describe("contrail connect", () => { ), ), ).rejects.toMatchObject({ code: "ENOENT" }); + expect(result.definition.serviceAuth).toEqual(serviceAuthContract()); expect(result.target.serviceAuth).toBeNull(); expect(result.target.methods).toContain("atmo.rsvp.notifyOfUpdate"); await expect( @@ -183,6 +196,13 @@ describe("contrail connect", () => { expect(source).toContain('endpoint: "http://127.0.0.1:8787"'); expect(source).not.toContain("contractDigest"); + // SQLite dev mode strips `serviceAuth`, so the local factory stays + // anonymous rather than asking a real PDS to authorize a localhost service. + expect(source).not.toContain("serviceDid: contrailApi.serviceDid"); + expect( + source.slice(source.indexOf("export function createLocalContrailClient")), + ).not.toContain("scope"); + await rm(join(root, "lexicons/custom"), { recursive: true }); const deploymentLock = providerLock(); await writeFile( @@ -336,15 +356,42 @@ describe("contrail connect", () => { expect(await readFile(generated.path, "utf8")).toContain( 'endpoint: "https://api.atmo.rsvp"', ); - expect(await readFile(generated.path, "utf8")).toContain( + const generatedSource = await readFile(generated.path, "utf8"); + expect(generatedSource).toContain( 'serviceDid: "did:web:api.atmo.rsvp"', ); - expect(await readFile(generated.path, "utf8")).toContain( - 'scope: "rpc?lxm=*&aud=did:web:api.atmo.rsvp"', + expect(generatedSource).toContain( + 'serviceAudience: "did:web:api.atmo.rsvp#contrail"', + ); + expect(generatedSource).toContain( + 'scope: "rpc?aud=did:web:api.atmo.rsvp%23contrail&lxm=atmo.rsvp.notifyOfUpdate"', + ); + expect(generatedSource).toContain( + 'protectedMethods: [\n "atmo.rsvp.notifyOfUpdate",', ); expect(await readFile(generated.path, "utf8")).toContain( '"community.lexicon.calendar.event",', ); + + // An anonymous provider omits the keys rather than spelling them as nulls; + // this block is reference material consumers paste into their own client. + const anonymousRoot = await temporaryRoot(); + const anonymous = await ensureConsumerLexiconConfig({ + root: anonymousRoot, + out: "src/contrail/lexicons", + api: { ...providerLock(), serviceAuth: null }, + }); + const anonymousSource = await readFile(anonymous.path, "utf8"); + expect(anonymousSource).not.toContain("null"); + for (const key of [ + "serviceDid", + "serviceAudience", + "scope", + "protectedMethods", + ]) { + expect(anonymousSource).not.toContain(key); + } + const updated = await ensureConsumerLexiconConfig({ root, out: "src/contrail/lexicons", @@ -390,11 +437,7 @@ describe("contrail connect", () => { sourceLexicon, notifyLexicon, ]); - fixture.manifest.serviceAuth = { - type: "atproto-service-auth", - audience: "did:web:api.atmo.rsvp", - methods: [{ id: notifyMethod, type: "procedure" }], - }; + fixture.manifest.serviceAuth = serviceAuthContract(); const { lock } = await connectPublicService({ endpoint, root, @@ -411,7 +454,15 @@ describe("contrail connect", () => { expect(source).not.toContain("contractDigest"); expect(source).toContain("export function createLocalContrailClient"); expect(source).toContain('serviceDid: "did:web:api.atmo.rsvp"'); - expect(source).toContain('scope: "rpc?lxm=*&aud=did:web:api.atmo.rsvp"'); + expect(source).toContain( + 'serviceAudience: "did:web:api.atmo.rsvp#contrail"', + ); + expect(source).toContain( + 'scope: "rpc?aud=did:web:api.atmo.rsvp%23contrail&lxm=atmo.rsvp.notifyOfUpdate"', + ); + expect(source).toContain( + 'protectedMethods: [\n "atmo.rsvp.notifyOfUpdate",', + ); expect(source).toContain('"community.lexicon.calendar.event",'); expect(source).toContain(`notifyMethod: ${JSON.stringify(notifyMethod)}`); @@ -577,6 +628,30 @@ describe("contrail connect", () => { }), ).rejects.toThrow("unsupported version 1"); + // A v2 lock written before exact audiences existed needs the same remedy + // as a v1 lock, so it must not fall through to the generic message. + await writeFile( + lockPath, + `${JSON.stringify({ + ...providerLock(), + serviceAuth: { + type: "atproto-service-auth", + audience: "did:web:api.atmo.rsvp", + methods: [{ id: notifyMethod, type: "procedure" }], + }, + })}\n`, + ); + await expect( + connectPublicService({ + endpoint, + root, + out: "src/contrail/lexicons", + lock: "contrail.lock.json", + fetcher, + update: true, + }), + ).rejects.toThrow("predates exact service-auth audiences"); + await writeFile( lockPath, `${JSON.stringify({ @@ -620,11 +695,7 @@ describe("contrail connect", () => { sourceLexicon, notifyLexicon, ]); - fixture.manifest.serviceAuth = { - type: "atproto-service-auth", - audience: "did:web:api.atmo.rsvp", - methods: [{ id: notifyMethod, type: "procedure" }], - }; + fixture.manifest.serviceAuth = serviceAuthContract(); const result = await connectPublicService({ endpoint, @@ -710,11 +781,7 @@ describe("contrail connect", () => { sourceLexicon, { ...notifyLexicon, defs: { main: { type: "query" } } }, ]); - fixture.manifest.serviceAuth = { - type: "atproto-service-auth", - audience: "did:web:api.atmo.rsvp", - methods: [{ id: notifyMethod, type: "procedure" }], - }; + fixture.manifest.serviceAuth = serviceAuthContract(); await expect( connectPublicService({ diff --git a/packages/contrail/tests/public-client.test.ts b/packages/contrail/tests/public-client.test.ts index ad02329..6d77d33 100644 --- a/packages/contrail/tests/public-client.test.ts +++ b/packages/contrail/tests/public-client.test.ts @@ -8,6 +8,10 @@ import { import type { PublicServiceManifest } from "../src/public-service"; const endpoint = "https://api.example.com"; +const serviceDid = "did:web:api.example.com"; +const serviceAudience = "did:web:api.example.com#contrail"; +const serviceScope = + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed"; const method = "com.example.getFeed"; const notifyMethod = "com.example.notifyOfUpdate"; const collection = "community.example.event"; @@ -34,7 +38,9 @@ function manifest(): PublicServiceManifest { methods: ["com.example.getCursor"], serviceAuth: { type: "atproto-service-auth", - audience: "did:web:api.example.com", + serviceDid, + audience: serviceAudience, + scope: serviceScope, methods: [{ id: method, type: "query" }], }, }; @@ -44,7 +50,7 @@ function authenticatedClient(jwt: string) { const handler = vi.fn(async (pathname: string) => { const url = new URL(pathname, "https://pds.example.com"); expect(url.pathname).toBe("/xrpc/com.atproto.server.getServiceAuth"); - expect(url.searchParams.get("aud")).toBe("did:web:api.example.com"); + expect(url.searchParams.get("aud")).toBe(serviceAudience); expect(url.searchParams.get("lxm")).toBe(method); return Response.json({ token: jwt }); }); @@ -70,14 +76,78 @@ describe("public service client", () => { ).toThrow("loopback HTTP"); }); - it("rejects a configured OAuth scope for a different service DID", () => { + it("rejects a configured OAuth scope for a different service audience", () => { expect(() => createPublicServiceClient({ endpoint, - serviceDid: "did:web:api.example.com", - scope: "rpc?lxm=*&aud=did:web:other.example.com", + serviceDid, + serviceAudience, + scope: + "rpc?aud=did:web:other.example.com%23contrail&lxm=com.example.getFeed", + protectedMethods: [method], }), - ).toThrow("OAuth scope mismatch"); + ).toThrow("different service audience"); + }); + + it("accepts semantically equivalent scope parameter ordering", () => { + const client = createPublicServiceClient({ + endpoint, + serviceDid, + serviceAudience, + scope: + "rpc?lxm=com.example.getFeed&aud=did:web:api.example.com%23contrail", + protectedMethods: [method], + }); + expect(client.scope).toBe(serviceScope); + }); + + it("rejects legacy incomplete or wildcard service-auth configuration", () => { + expect(() => + createPublicServiceClient({ endpoint, serviceDid }), + ).toThrow("connect --update"); + expect(() => + createPublicServiceClient({ + endpoint, + serviceDid, + serviceAudience, + scope: "rpc?aud=did:web:api.example.com%23contrail&lxm=*", + protectedMethods: [method], + }), + ).toThrow("connect --update"); + expect(() => + createPublicServiceClient({ + endpoint, + serviceDid, + serviceAudience, + scope: serviceScope, + protectedMethods: [method, notifyMethod], + }), + ).toThrow("exact protected methods"); + }); + + it("reads nulls and an empty method list as an anonymous provider", () => { + // `lex.config.js` is reference material consumers paste from, and an + // untyped copy spells "no service auth" as nulls rather than absent keys. + const client = createPublicServiceClient({ + endpoint, + serviceDid: null, + serviceAudience: null, + scope: null, + protectedMethods: [], + }); + expect(client.scope).toBeNull(); + }); + + it("still rejects a partially configured contract", () => { + expect(() => + createPublicServiceClient({ + endpoint, + serviceDid, + serviceAudience, + scope: serviceScope, + protectedMethods: [], + }), + ).toThrow("incomplete"); }); it("keeps anonymous requests anonymous", async () => { @@ -128,6 +198,10 @@ describe("public service client", () => { const handler = publicServiceFetchHandler({ endpoint, authenticatedClient: pds.client, + serviceDid, + serviceAudience, + scope: serviceScope, + protectedMethods: [method], fetch: fetcher, }); @@ -160,15 +234,15 @@ describe("public service client", () => { }); const publicClient = createPublicServiceClient({ endpoint, - serviceDid: "did:web:api.example.com", - scope: "rpc?lxm=*&aud=did:web:api.example.com", + serviceDid, + serviceAudience, + scope: serviceScope, + protectedMethods: [method], serviceMethods: [method], fetch: fetcher, }); expect(publicClient.endpoint).toBe(endpoint); - expect(publicClient.scope).toBe( - "rpc?lxm=*&aud=did:web:api.example.com", - ); + expect(publicClient.scope).toBe(serviceScope); const client = publicClient.authenticated(pds.client); expect(publicClient.authenticated(pds.client)).toBe(client); @@ -239,7 +313,10 @@ describe("public service client", () => { const discovered = manifest(); discovered.serviceAuth = { type: "atproto-service-auth", - audience: "did:web:api.example.com", + serviceDid, + audience: serviceAudience, + scope: + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed&lxm=com.example.notifyOfUpdate", methods: [ { id: method, type: "query" }, { id: notifyMethod, type: "procedure" }, @@ -308,7 +385,11 @@ describe("public service client", () => { const notificationError = vi.fn(); const client = createPublicServiceClient({ endpoint, - serviceDid: "did:web:api.example.com", + serviceDid, + serviceAudience, + scope: + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed&lxm=com.example.notifyOfUpdate", + protectedMethods: [method, notifyMethod], serviceMethods: [method, notifyMethod], collections: [collection], notifyMethod, @@ -441,7 +522,39 @@ describe("public service client", () => { ).toBe(true); }); - it("refuses a discovered service-auth audience that differs from its lock", async () => { + it("refuses a discovered service-auth audience fragment that differs from its lock", async () => { + const pds = authenticatedClient(token()); + const discovered = manifest(); + discovered.serviceAuth = { + type: "atproto-service-auth", + serviceDid, + audience: "did:web:api.example.com#other", + scope: + "rpc?aud=did:web:api.example.com%23other&lxm=com.example.getFeed", + methods: [{ id: method, type: "query" }], + }; + const fetcher = vi.fn(async (input: RequestInfo | URL) => + String(input).endsWith("/.well-known/contrail") + ? Response.json(discovered) + : new Response(null, { status: 401 }), + ); + const client = createPublicServiceClient({ + endpoint, + authenticatedClient: pds.client, + serviceDid, + serviceAudience, + scope: serviceScope, + protectedMethods: [method], + fetch: fetcher, + }); + + await expect((client as any).get(method)).rejects.toThrow( + "service audience mismatch", + ); + expect(pds.handler).not.toHaveBeenCalled(); + }); + + it("refuses a discovered service DID that differs from its lock", async () => { const pds = authenticatedClient(token()); const fetcher = vi.fn(async (input: RequestInfo | URL) => String(input).endsWith("/.well-known/contrail") @@ -452,6 +565,10 @@ describe("public service client", () => { endpoint, authenticatedClient: pds.client, serviceDid: "did:web:other.example.com", + serviceAudience: "did:web:other.example.com#contrail", + scope: + "rpc?aud=did:web:other.example.com%23contrail&lxm=com.example.getFeed", + protectedMethods: [method], fetch: fetcher, }); diff --git a/packages/contrail/tests/public-service-e2e.test.ts b/packages/contrail/tests/public-service-e2e.test.ts index 3829489..a13ee7c 100644 --- a/packages/contrail/tests/public-service-e2e.test.ts +++ b/packages/contrail/tests/public-service-e2e.test.ts @@ -70,6 +70,10 @@ describe("public service consumer integration", () => { namespace: "com.example", profiles: [], notify: true, + serviceAuth: { + audience: "did:web:api.example.com#contrail", + methods: ["notifyOfUpdate"], + }, orderedSource: { source: "jetstream", epoch: "e2e" }, collections: { event: { @@ -132,11 +136,27 @@ describe("public service consumer integration", () => { notifyMethod: "com.example.notifyOfUpdate", }); expect(generatedClient.created).toBe(true); - expect(connection.lock.serviceAuth).toBeNull(); + expect(connection.lock.serviceAuth).toEqual({ + type: "atproto-service-auth", + serviceDid: "did:web:api.example.com", + audience: "did:web:api.example.com#contrail", + scope: + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.notifyOfUpdate", + methods: [ + { id: "com.example.notifyOfUpdate", type: "procedure" }, + ], + }); expect(connection.lock.methods).not.toContain("com.example.notifyOfUpdate"); - expect(readFileSync(generatedClient.path, "utf8")).toContain( + const generatedClientSource = readFileSync(generatedClient.path, "utf8"); + expect(generatedClientSource).toContain( 'notifyMethod: "com.example.notifyOfUpdate"', ); + expect(generatedClientSource).toContain( + 'serviceAudience: "did:web:api.example.com#contrail"', + ); + expect(generatedClientSource).toContain( + 'scope: "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.notifyOfUpdate"', + ); mkdirSync(join(consumerRoot, "src"), { recursive: true }); writeFileSync( diff --git a/packages/contrail/tests/service-auth-contract.test.ts b/packages/contrail/tests/service-auth-contract.test.ts new file mode 100644 index 0000000..42ad9d1 --- /dev/null +++ b/packages/contrail/tests/service-auth-contract.test.ts @@ -0,0 +1,215 @@ +import type { AtprotoAudience } from "@atcute/lexicons/syntax"; +import { describe, expect, it } from "vitest"; +import { isPublicServiceManifest } from "../src/public-service"; +import { + compareCanonical, + formatServiceOAuthScope, + parseServiceAudience, + parseServiceOAuthScope, +} from "../src/service-auth-contract"; + +const webAudience = "did:web:api.example.com#contrail" as AtprotoAudience; +const plcAudience = + "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa#contrail" as AtprotoAudience; + +describe("parseServiceAudience", () => { + it("separates supported web and PLC audiences from their base DIDs", () => { + expect(parseServiceAudience(webAudience)).toEqual({ + audience: webAudience, + serviceDid: "did:web:api.example.com", + fragment: "contrail", + }); + expect(parseServiceAudience(plcAudience)).toEqual({ + audience: plcAudience, + serviceDid: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa", + fragment: "contrail", + }); + }); + + it.each([ + "did:web:api.example.com", + "did:web:api.example.com#", + "did:web:api.example.com#contrail#other", + "did:web:api.example.com%23contrail", + "did:key:zExample#contrail", + ])("rejects an invalid service audience: %s", (audience) => { + expect(() => parseServiceAudience(audience)).toThrow(/service audience/); + }); +}); + +describe("compareCanonical", () => { + // The canonical order is baked into a scope string that a provider Worker + // emits and a consumer compares byte for byte, so it must not depend on the + // host locale or ICU build the way `localeCompare` does. + it("orders by code unit, not by locale collation", () => { + const mixedCase = ["com.example.get", "com.example.Get"]; + expect([...mixedCase].sort(compareCanonical)).toEqual([ + "com.example.Get", + "com.example.get", + ]); + expect([...mixedCase].sort((left, right) => left.localeCompare(right))).toEqual([ + "com.example.get", + "com.example.Get", + ]); + }); + + it("sorts protected methods identically to a plain string sort", () => { + const methods = [ + "com.example.notifyOfUpdate", + "com.example.Get", + "com.example.getFeed", + "com.example.get", + ]; + expect([...methods].sort(compareCanonical)).toEqual([...methods].sort()); + }); + + it("keeps the emitted scope in code-unit order", () => { + expect( + formatServiceOAuthScope(webAudience, [ + "com.example.getFeed", + "com.example.Get", + ]), + ).toBe( + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.Get&lxm=com.example.getFeed", + ); + }); +}); + +describe("formatServiceOAuthScope", () => { + it("formats sorted, deduplicated exact method permissions", () => { + expect( + formatServiceOAuthScope(webAudience, [ + "com.example.notifyOfUpdate", + "com.example.getFeed", + "com.example.notifyOfUpdate", + ]), + ).toBe( + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed&lxm=com.example.notifyOfUpdate", + ); + }); + + it("rejects empty, wildcard, and malformed method lists", () => { + expect(() => formatServiceOAuthScope(webAudience, [])).toThrow( + "at least one method", + ); + expect(() => formatServiceOAuthScope(webAudience, ["*"])).toThrow( + "valid NSID", + ); + expect(() => formatServiceOAuthScope(webAudience, ["not a method"])).toThrow( + "valid NSID", + ); + }); +}); + +describe("public service-auth contract", () => { + const manifest = () => ({ + format: "contrail.service", + version: 2, + endpoint: "https://api.example.com", + namespace: "com.example", + lexicons: { + url: `https://api.example.com/lexicons/sha256:${"a".repeat(64)}`, + digest: `sha256:${"a".repeat(64)}`, + }, + status: { url: "https://api.example.com/status" }, + collections: [], + methods: ["com.example.getCursor"], + serviceAuth: { + type: "atproto-service-auth", + serviceDid: "did:web:api.example.com", + audience: webAudience, + scope: + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed", + methods: [{ id: "com.example.getFeed", type: "query" }], + }, + }); + + it("accepts a coherent exact contract", () => { + expect(isPublicServiceManifest(manifest())).toBe(true); + }); + + it("rejects incoherent or legacy service-auth fields", () => { + const wrongDid = manifest(); + wrongDid.serviceAuth.serviceDid = "did:web:other.example.com"; + expect(isPublicServiceManifest(wrongDid)).toBe(false); + + const wrongScope = manifest(); + wrongScope.serviceAuth.scope = + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.other"; + expect(isPublicServiceManifest(wrongScope)).toBe(false); + + const duplicateMethod = manifest(); + duplicateMethod.serviceAuth.methods.push({ + id: "com.example.getFeed", + type: "query", + }); + expect(isPublicServiceManifest(duplicateMethod)).toBe(false); + + const legacy = manifest() as any; + delete legacy.serviceAuth.serviceDid; + delete legacy.serviceAuth.scope; + legacy.serviceAuth.audience = "did:web:api.example.com"; + expect(isPublicServiceManifest(legacy)).toBe(false); + }); +}); + +describe("parseServiceOAuthScope", () => { + it("accepts parameter reordering and returns a canonical exact scope", () => { + expect( + parseServiceOAuthScope( + "rpc?lxm=com.example.notifyOfUpdate&aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed", + ), + ).toEqual({ + audience: webAudience, + serviceDid: "did:web:api.example.com", + fragment: "contrail", + methods: ["com.example.getFeed", "com.example.notifyOfUpdate"], + canonicalScope: + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed&lxm=com.example.notifyOfUpdate", + }); + }); + + it("deduplicates methods semantically", () => { + const parsed = parseServiceOAuthScope( + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed&lxm=com.example.getFeed", + ); + expect(parsed.methods).toEqual(["com.example.getFeed"]); + }); + + it("decodes the audience exactly once", () => { + expect( + parseServiceOAuthScope( + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed", + ).audience, + ).toBe(webAudience); + expect(() => + parseServiceOAuthScope( + "rpc?aud=did:web:api.example.com%2523contrail&lxm=com.example.getFeed", + ), + ).toThrow(/service audience/); + }); + + it("preserves plus characters used by the ecosystem formatter", () => { + const audience = + "did:web:api.example.com#contrail+test" as AtprotoAudience; + const formatted = formatServiceOAuthScope(audience, [ + "com.example.getFeed", + ]); + expect(parseServiceOAuthScope(formatted).audience).toBe(audience); + }); + + it.each([ + "atproto", + "rpc?", + "rpc?aud=did:web:api.example.com%23contrail", + "rpc?lxm=com.example.getFeed", + "rpc?aud=did:web:api.example.com%23contrail&aud=did:web:other.example.com%23contrail&lxm=com.example.getFeed", + "rpc?aud=did:web:api.example.com#contrail&lxm=com.example.getFeed", + "rpc?aud=did:web:api.example.com%23contrail&lxm=*", + "rpc?aud=did:web:api.example.com%23contrail&lxm=", + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed&extra=true", + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.getFeed%ZZ", + ])("rejects an unsupported or malformed scope: %s", (scope) => { + expect(() => parseServiceOAuthScope(scope)).toThrow(); + }); +}); diff --git a/packages/contrail/tests/service-auth.test.ts b/packages/contrail/tests/service-auth.test.ts index cd63f49..b58b559 100644 --- a/packages/contrail/tests/service-auth.test.ts +++ b/packages/contrail/tests/service-auth.test.ts @@ -1,5 +1,9 @@ import { Secp256k1PrivateKeyExportable } from "@atcute/crypto"; -import type { Did, Nsid } from "@atcute/lexicons/syntax"; +import type { + AtprotoAudience, + Did, + Nsid, +} from "@atcute/lexicons/syntax"; import { createServiceJwt } from "@atcute/xrpc-server/auth"; import { beforeAll, describe, expect, it } from "vitest"; import { createSqliteDatabase } from "../src/adapters/sqlite"; @@ -14,7 +18,8 @@ import { const issuer = "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa" as Did; const other = "did:plc:bbbbbbbbbbbbbbbbbbbbbbbb" as Did; -const audience = "did:web:api.example.com" as Did; +const audience = + "did:web:api.example.com#contrail" as AtprotoAudience; let keypair: Secp256k1PrivateKeyExportable; beforeAll(async () => { @@ -83,7 +88,10 @@ async function setup(): Promise<{ return { db, app: createApp(db, resolved) }; } -async function token(lxm: string, options: { aud?: Did; iss?: Did } = {}) { +async function token( + lxm: string, + options: { aud?: Did | AtprotoAudience; iss?: Did } = {}, +) { return createServiceJwt({ keypair, issuer: options.iss ?? issuer, @@ -119,11 +127,21 @@ describe("AT Protocol service auth", () => { authorized( url, await token("com.example.getFeed", { - aud: "did:web:other.example.com" as Did, + aud: "did:web:other.example.com#contrail" as AtprotoAudience, }), ), ); expect(wrongAudience.status).toBe(401); + + const wrongFragment = await app.fetch( + authorized( + url, + await token("com.example.getFeed", { + aud: "did:web:api.example.com#other" as AtprotoAudience, + }), + ), + ); + expect(wrongFragment.status).toBe(401); }); it("binds a personalized feed to the token issuer", async () => { diff --git a/packages/contrail/tests/types.test.ts b/packages/contrail/tests/types.test.ts index c485fd6..c0cdec3 100644 --- a/packages/contrail/tests/types.test.ts +++ b/packages/contrail/tests/types.test.ts @@ -153,6 +153,36 @@ describe("getRelationField", () => { }); describe("resolveConfig", () => { + it.each([ + "did:web:api.example.com#contrail", + "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa#contrail", + ])("accepts an exact AT Protocol service audience: %s", (audience) => { + expect(() => + resolveConfig({ + namespace: "test", + profiles: [], + collections: {}, + serviceAuth: { audience: audience as never, methods: [] }, + }), + ).not.toThrow(); + }); + + it.each([ + "did:web:api.example.com", + "did:web:api.example.com#", + "did:web:api.example.com#contrail#other", + "did:key:zExample#contrail", + ])("rejects an invalid AT Protocol service audience: %s", (audience) => { + expect(() => + resolveConfig({ + namespace: "test", + profiles: [], + collections: {}, + serviceAuth: { audience: audience as never, methods: [] }, + }), + ).toThrow("serviceAuth.audience"); + }); + it("adds default profile collection (keyed by short name `profile`)", () => { const resolved = resolveConfig({ namespace: "test", diff --git a/packages/contrail/tests/worker.test.ts b/packages/contrail/tests/worker.test.ts index eb42cfc..bd69244 100644 --- a/packages/contrail/tests/worker.test.ts +++ b/packages/contrail/tests/worker.test.ts @@ -24,6 +24,10 @@ function queryLexicons(...ids: string[]) { })); } +function procedureLexicon(id: string) { + return { lexicon: 1, id, defs: { main: { type: "procedure" } } }; +} + const MINIMAL_PUBLIC_LEXICONS = queryLexicons( "com.example.getCursor", "com.example.event.getRecord", @@ -260,6 +264,105 @@ describe("createWorker", () => { ).toBe(404); }); + it("publishes a coherent fragmented service audience and DID document", async () => { + const config: ContrailConfig = { + ...MINIMAL_CONFIG, + notify: true, + serviceAuth: { + audience: "did:web:api.example.com#contrail", + methods: ["notifyOfUpdate"], + }, + }; + const worker = createWorker(config, { + lexicons: [ + ...MINIMAL_PUBLIC_LEXICONS, + procedureLexicon("com.example.notifyOfUpdate"), + ], + publicService: { endpoint: "https://api.example.com" }, + }); + const env = { DB: createSqliteDatabase(":memory:") }; + + const manifest = await ( + await worker.fetch( + new Request("https://api.example.com/.well-known/contrail"), + env, + ) + ).json(); + expect(manifest.serviceAuth).toEqual({ + type: "atproto-service-auth", + serviceDid: "did:web:api.example.com", + audience: "did:web:api.example.com#contrail", + scope: + "rpc?aud=did:web:api.example.com%23contrail&lxm=com.example.notifyOfUpdate", + methods: [ + { id: "com.example.notifyOfUpdate", type: "procedure" }, + ], + }); + + const response = await worker.fetch( + new Request("https://api.example.com/.well-known/did.json"), + env, + ); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain( + "application/did+ld+json", + ); + expect(await response.json()).toEqual({ + "@context": ["https://www.w3.org/ns/did/v1"], + id: "did:web:api.example.com", + service: [ + { + id: "did:web:api.example.com#contrail", + type: "ContrailService", + serviceEndpoint: "https://api.example.com", + }, + ], + }); + }); + + it("rejects an automatically hosted did:web audience for another origin", () => { + const config: ContrailConfig = { + ...MINIMAL_CONFIG, + notify: true, + serviceAuth: { + audience: "did:web:other.example.com#contrail", + methods: ["notifyOfUpdate"], + }, + }; + expect(() => + createWorker(config, { + lexicons: [ + ...MINIMAL_PUBLIC_LEXICONS, + procedureLexicon("com.example.notifyOfUpdate"), + ], + publicService: { endpoint: "https://api.example.com" }, + }), + ).toThrow("resolves its DID document"); + }); + + it("leaves externally managed did:plc audiences without a local DID route", async () => { + const config: ContrailConfig = { + ...MINIMAL_CONFIG, + notify: true, + serviceAuth: { + audience: "did:plc:aaaaaaaaaaaaaaaaaaaaaaaa#contrail", + methods: ["notifyOfUpdate"], + }, + }; + const worker = createWorker(config, { + lexicons: [ + ...MINIMAL_PUBLIC_LEXICONS, + procedureLexicon("com.example.notifyOfUpdate"), + ], + publicService: { endpoint: "https://api.example.com" }, + }); + const response = await worker.fetch( + new Request("https://api.example.com/.well-known/did.json"), + { DB: createSqliteDatabase(":memory:") }, + ); + expect(response.status).toBe(404); + }); + it("preserves the legacy cursor response without an ordered source", async () => { const config: ContrailConfig = { ...MINIMAL_CONFIG, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cb72e87..a97826f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -251,6 +251,9 @@ importers: '@atcute/client': specifier: ^5.1.1 version: 5.1.1(@atcute/lexicons@2.0.3)(typescript@6.0.3) + '@atcute/identity': + specifier: ^2.0.2 + version: 2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3) '@atcute/identity-resolver': specifier: ^2.0.1 version: 2.0.1(@atcute/identity@2.0.2(@atcute/lexicons@2.0.3)(typescript@6.0.3))(@atcute/lexicons@2.0.3)(typescript@6.0.3) @@ -266,6 +269,9 @@ importers: '@atcute/lexicons': specifier: ^2.0.3 version: 2.0.3 + '@atcute/oauth-types': + specifier: ^1.0.1 + version: 1.0.1(typescript@6.0.3) '@atcute/tid': specifier: 1.1.4 version: 1.1.4 -- 2.51.2