From 1df6526e1d3598dffc65ef0de26d6edb792dd1f0 Mon Sep 17 00:00:00 2001 From: Rami Date: Fri, 23 Oct 2026 13:11:36 +0100 Subject: [PATCH 1/3] feat(identity): an API key is looked up in a key source, which is the configured list today The authenticator asks an ApiKeySource, keyOf(id), for the entry of the id a presented key names, on every request, and compares the digests in constant time as before. The list the operator configured, configuredApiKeys, is the one source, and the server composes it; a source that keeps its keys elsewhere admits a key it issues and refuses one it revokes while the server runs. The configured list alone still decides the access mode, and no source is asked in local mode or in a closed server. Co-Authored-By: Claude Fable 5.1 --- packages/identity/README.md | 4 +- .../identity/src/access/authenticator.test.ts | 76 ++++++++++++++++++- packages/identity/src/access/authenticator.ts | 26 ++++--- packages/identity/src/index.ts | 1 + packages/identity/src/keys/api-key-source.ts | 10 +++ packages/identity/src/keys/verify-key.ts | 5 +- packages/server/src/lifecycle/lifecycle.ts | 8 +- 7 files changed, 115 insertions(+), 15 deletions(-) create mode 100644 packages/identity/src/keys/api-key-source.ts diff --git a/packages/identity/README.md b/packages/identity/README.md index 4cee35be1..ad2eb9ef5 100644 --- a/packages/identity/README.md +++ b/packages/identity/README.md @@ -1,11 +1,13 @@ # @on.auto/identity -Who calls the runtime: a key, the local developer, or a signed-in person the Auto identity service vouches for. `authenticatorFor` of `src/index.ts` takes the settings, the address the server listens on, the API keys and, where sign-in is on, the identity service, and answers the access mode and an asynchronous `authenticate`, which takes the credential a request presents, the path of the MCP endpoint it reached, if any, and its `Origin`, if it has one, and answers its `Authentication`: the `Principal` of the caller, or a `Refusal` whose `because` says why a token was refused, in words that name a rule or a claim and never a value of the token (`isRefusal` tells the two apart). A principal holds its own caller, `own`, and answers the caller of an org with `callerIn(org)`, or `undefined` for an org it does not admit: a key admits its own org, the local developer every org, whatever credential the request presents, and a signed-in person the org their token names. The access mode follows from what is configured, and `accessModeOf` of `src/access/access-mode.ts` is the one place that says which, for the authenticator and for the server's settings that follow from it: `credentials` with a key or with sign-in, `closed` when `API_KEYS` is set and lists no keys without sign-in, and with neither `local` on a loopback address, `localhost`, `127.0.0.0/8` or `::1` (`listensOnlyOnLoopback` of `src/access/loopback.ts`), a rule wider than the three names by which a URL or a `Host` header names the loopback, `namesTheLoopback` of `@on.auto/operations`, since a server that listens on any address of `127.0.0.0/8` is reachable from this computer alone. On any other address it is `local` too where the operator declares local mode, `accessMode: 'local'` (`DeclaredAccessMode`), for a server whose port only this computer reaches though it listens on every address of its own, such as a container whose port is published on `127.0.0.1` alone, which it cannot see; a key or sign-in still wins over the declaration. On any other address with neither and no declaration it names no mode, and `authenticatorFor` throws `ReachableWithoutCredentialsError`, which names the `HOST` and says that a server reachable beyond this computer needs `PUBLIC_URL`, to sign people in, or `API_KEYS`, or, where only this computer reaches its port, `ACCESS_MODE=local` with a `PUBLIC_URL` on the loopback, so the server stops before it listens. The server reads `listensOnlyOnLoopback` too, to refuse the declaration on another address without a `PUBLIC_URL` its links can point at. The key command, `src/key-command.ts`, prints a new key once and the `API_KEYS` entry that admits it. +Who calls the runtime: a key, the local developer, or a signed-in person the Auto identity service vouches for. `authenticatorFor` of `src/index.ts` takes the settings, the address the server listens on, the API keys the operator configured, the source a presented key is looked up in and, where sign-in is on, the identity service, and answers the access mode and an asynchronous `authenticate`, which takes the credential a request presents, the path of the MCP endpoint it reached, if any, and its `Origin`, if it has one, and answers its `Authentication`: the `Principal` of the caller, or a `Refusal` whose `because` says why a token was refused, in words that name a rule or a claim and never a value of the token (`isRefusal` tells the two apart). A principal holds its own caller, `own`, and answers the caller of an org with `callerIn(org)`, or `undefined` for an org it does not admit: a key admits its own org, the local developer every org, whatever credential the request presents, and a signed-in person the org their token names. The access mode follows from what is configured, and `accessModeOf` of `src/access/access-mode.ts` is the one place that says which, for the authenticator and for the server's settings that follow from it: `credentials` with a key or with sign-in, `closed` when `API_KEYS` is set and lists no keys without sign-in, and with neither `local` on a loopback address, `localhost`, `127.0.0.0/8` or `::1` (`listensOnlyOnLoopback` of `src/access/loopback.ts`), a rule wider than the three names by which a URL or a `Host` header names the loopback, `namesTheLoopback` of `@on.auto/operations`, since a server that listens on any address of `127.0.0.0/8` is reachable from this computer alone. On any other address it is `local` too where the operator declares local mode, `accessMode: 'local'` (`DeclaredAccessMode`), for a server whose port only this computer reaches though it listens on every address of its own, such as a container whose port is published on `127.0.0.1` alone, which it cannot see; a key or sign-in still wins over the declaration. On any other address with neither and no declaration it names no mode, and `authenticatorFor` throws `ReachableWithoutCredentialsError`, which names the `HOST` and says that a server reachable beyond this computer needs `PUBLIC_URL`, to sign people in, or `API_KEYS`, or, where only this computer reaches its port, `ACCESS_MODE=local` with a `PUBLIC_URL` on the loopback, so the server stops before it listens. The server reads `listensOnlyOnLoopback` too, to refuse the declaration on another address without a `PUBLIC_URL` its links can point at. The key command, `src/key-command.ts`, prints a new key once and the `API_KEYS` entry that admits it. ## Keys A key is `abk__`, stored as the SHA-256 of the whole key with its org, permissions and playbooks, and verified in constant time. The org `local` is local mode's and the id `local` the local developer's, so `API_KEYS` and the key command refuse both. +Keys come from a source, `ApiKeySource` of `src/keys/api-key-source.ts`, whose `keyOf(id)` answers the entry of an id, its digest, org, permissions and playbooks, or nothing. On every request that presents a key, the authenticator asks the source for the id the key names, and asks nothing for a credential that is not of a key's form, then compares in constant time the digest of the key with the entry's, or with the digest of no key where the source answers nothing, so a key the source does not know is refused as a wrong secret is. The list the operator configured, `API_KEYS` or `api_keys` of the configuration file, is the one source today, `configuredApiKeys`, which the server composes and the authenticator takes when it is given no other; a source that keeps its keys elsewhere admits a key it issues and refuses a key it revokes while the server runs, since the authenticator holds no entry of its own. The configured list alone decides the access mode, as `accessModeOf` reads it, and no source is asked in local mode, where every request is the local developer's, or in a closed server, which admits nobody. + ## Signed-in persons `identityServiceOf` of `src/identity-service/identity-service.ts` takes the issuer, `autoIdentityService`, `https://clerk.on.auto`, unless a development runtime names another, `PUBLIC_URL`, the Studio settings and `ALLOWED_ORIGINS`, which the server hands it from its settings as it hands them to the API. A bearer that is not a key is one of two forms of token. An access token holds `aud`, and is what an agent or an agent gateway, a third party acting for a person, obtains through the authorization flow: it is taken for its audience, with the permissions among its scopes. A session holds no `aud`, and is what the identity service gives a page a person signs in to, such as the studio, Auto's own app: it is taken when its `azp` claim names an origin a page may come from and the request that presents it comes from that origin, so it serves its own page and never an agent, and gives every permission in its organization. `pageOriginsOf` of `src/access/page-origins.ts` is the one definition of those origins: the Studio host's, then the runtime's own origin where the runtime serves Studio, and `ALLOWED_ORIGINS`. The identity service gives it `PUBLIC_URL` as the runtime's own origin, and the API's `Origin` check and its CORS, which read it as well, give it the origin the API answers at. `src/access/studio.ts` holds the Studio settings, `StudioSettings`: `origin`, the Studio host, whose default is `defaultStudioOrigin`, `https://studio.on.auto`, and `served`, whether the runtime serves Studio itself at `servedStudioPath`, `/studio/`, since a page on a public origin may not reach an address on the person's machine. `studioBaseOf` is the one base of Studio's address that the runtime writes into a link: `{own origin}/studio/` where it serves Studio, and the Studio host's root otherwise. `signInScopes` are the scopes the runtime asks a client to request of the identity service: `user:org:read`, which binds the access token to the organization the person signs in to, as the `org_id` claim, and the four permissions, `allPermissions` of `@on.auto/operations`, the one definition of them, which the identity service defines as scopes of its own, each with a sentence its consent screen shows. At start it reads the issuer's metadata, from the addresses the MCP authorization specification orders, and its key set, each within 64 KiB and 10 seconds and following no redirect (`src/identity-service/issuer-requests.ts`), and stops with `UnusableIdentityServiceError` naming the service when one cannot be used or its key set holds no keys. diff --git a/packages/identity/src/access/authenticator.test.ts b/packages/identity/src/access/authenticator.test.ts index ba71e9587..735c9b129 100644 --- a/packages/identity/src/access/authenticator.test.ts +++ b/packages/identity/src/access/authenticator.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { authenticatorFor, createApiKey, defaultStudioOrigin, type ApiKey, type IdentityService } from '../index.ts'; +import { + authenticatorFor, + createApiKey, + defaultStudioOrigin, + type ApiKey, + type ApiKeySource, + type IdentityService, +} from '../index.ts'; import { fakeIssuer, principalIn } from '../testing/index.ts'; const reader = createApiKey({ id: 'acme-reader', org: 'acme', permissions: ['playbook:read'], playbooks: ['alpha'] }); @@ -195,3 +202,70 @@ describe('authenticatorFor with the identity service', () => { expect(tokensSeen).toEqual([]); }); }); + +interface FakeKeySource extends ApiKeySource { + readonly revoke: (id: string) => void; + readonly asked: () => readonly string[]; +} + +function fakeKeySource(entries: readonly ApiKey[]): FakeKeySource { + const held = new Map(entries.map((entry) => [entry.id, entry])); + const asked: string[] = []; + return { + keyOf: (id) => { + asked.push(id); + return Promise.resolve(held.get(id)); + }, + revoke: (id) => { + held.delete(id); + }, + asked: () => [...asked], + }; +} + +describe('authenticatorFor with a source of API keys', () => { + it('admits a key the source answers, asking it for the id the key names', async () => { + const source = fakeKeySource([reader.entry]); + const authenticator = authenticatorFor({ host: '0.0.0.0', apiKeys: keys, apiKeySource: source }); + + expect(principalIn(await authenticator.authenticate(reader.key, anyPath))?.own.id).toBe('acme-reader'); + expect(source.asked()).toEqual(['acme-reader']); + }); + + it('refuses a key the source does not know, though the configured list holds it', async () => { + const authenticator = authenticatorFor({ host: '0.0.0.0', apiKeys: keys, apiKeySource: fakeKeySource([]) }); + + expect(await authenticator.authenticate(admin.key, anyPath)).toEqual(unexplained); + }); + + it('refuses a key on the authenticator that admitted it, once the source answers nothing for it', async () => { + const source = fakeKeySource(keys); + const authenticator = authenticatorFor({ host: '0.0.0.0', apiKeys: keys, apiKeySource: source }); + const beforeTheRevocation = principalIn(await authenticator.authenticate(admin.key, anyPath))?.own.id; + source.revoke('acme-admin'); + + expect([beforeTheRevocation, await authenticator.authenticate(admin.key, anyPath)]).toEqual([ + 'acme-admin', + unexplained, + ]); + expect(principalIn(await authenticator.authenticate(reader.key, anyPath))?.own.id).toBe('acme-reader'); + }); + + it('asks the source nothing for a credential that is not a key, and refuses it', async () => { + const source = fakeKeySource(keys); + const authenticator = authenticatorFor({ host: '0.0.0.0', apiKeys: keys, apiKeySource: source }); + + expect(await authenticator.authenticate('abk_not-a-key', anyPath)).toEqual(unexplained); + expect(source.asked()).toEqual([]); + }); + + it('asks the source nothing in local mode or once closed, where no key is checked', async () => { + const source = fakeKeySource(keys); + const local = authenticatorFor({ host: '127.0.0.1', apiKeys: undefined, apiKeySource: source }); + const closed = authenticatorFor({ host: '0.0.0.0', apiKeys: [], apiKeySource: source }); + + expect(principalIn(await local.authenticate(admin.key, anyPath))?.own.id).toBe('local'); + expect(await closed.authenticate(admin.key, anyPath)).toEqual(unexplained); + expect(source.asked()).toEqual([]); + }); +}); diff --git a/packages/identity/src/access/authenticator.ts b/packages/identity/src/access/authenticator.ts index 0f5d85e61..8e4eb730b 100644 --- a/packages/identity/src/access/authenticator.ts +++ b/packages/identity/src/access/authenticator.ts @@ -1,4 +1,5 @@ import type { IdentityService, ProtectedResource } from '../identity-service/identity-service.ts'; +import { configuredApiKeys, type ApiKeySource } from '../keys/api-key-source.ts'; import { keyPrefix, type ApiKey } from '../keys/api-key.ts'; import { verifyKey } from '../keys/verify-key.ts'; import { accessModeOf, type AccessMode, type DeclaredAccessMode } from './access-mode.ts'; @@ -18,20 +19,21 @@ export interface Authenticator { export interface AccessSettings { readonly host: string; readonly apiKeys: readonly ApiKey[] | undefined; + readonly apiKeySource?: ApiKeySource | undefined; readonly identityService?: IdentityService | undefined; readonly accessMode?: DeclaredAccessMode | undefined; } -function keyHolderOf(keys: readonly ApiKey[], presented: string | undefined): Authentication { - const key = presented === undefined ? undefined : verifyKey(keys, presented); +async function keyHolderOf(source: ApiKeySource, presented: string | undefined): Promise { + const key = presented === undefined ? undefined : await verifyKey(source, presented); return key === undefined ? unexplained : principalOf(key); } -function credentialHolderOf(keys: readonly ApiKey[], identityService?: IdentityService): Authenticator['authenticate'] { +function credentialHolderOf(source: ApiKeySource, identityService?: IdentityService): Authenticator['authenticate'] { return (presented, resourcePath, requestOrigin) => presented !== undefined && identityService !== undefined && !presented.startsWith(keyPrefix) ? identityService.personOf(presented, resourcePath, requestOrigin) - : Promise.resolve(keyHolderOf(keys, presented)); + : keyHolderOf(source, presented); } const localAccess: Authenticator = { @@ -40,21 +42,27 @@ const localAccess: Authenticator = { authenticate: () => Promise.resolve(localDeveloper), }; -const closedAccess: Authenticator = { mode: 'closed', resource: undefined, authenticate: credentialHolderOf([]) }; +const closedAccess: Authenticator = { + mode: 'closed', + resource: undefined, + authenticate: credentialHolderOf(configuredApiKeys([])), +}; -function credentialAccessOf(keys: readonly ApiKey[], identityService: IdentityService | undefined): Authenticator { +function credentialAccessOf(source: ApiKeySource, identityService: IdentityService | undefined): Authenticator { return { mode: 'credentials', resource: identityService?.resource, - authenticate: credentialHolderOf(keys, identityService), + authenticate: credentialHolderOf(source, identityService), }; } -function accessOf(mode: AccessMode, { apiKeys, identityService }: AccessSettings): Authenticator { +function accessOf(mode: AccessMode, { apiKeys, apiKeySource, identityService }: AccessSettings): Authenticator { if (mode === 'local') { return localAccess; } - return mode === 'closed' ? closedAccess : credentialAccessOf(apiKeys ?? [], identityService); + return mode === 'closed' + ? closedAccess + : credentialAccessOf(apiKeySource ?? configuredApiKeys(apiKeys), identityService); } export function authenticatorFor(settings: AccessSettings): Authenticator { diff --git a/packages/identity/src/index.ts b/packages/identity/src/index.ts index 1b61a29d2..1fabda4a3 100644 --- a/packages/identity/src/index.ts +++ b/packages/identity/src/index.ts @@ -1,4 +1,5 @@ export { createApiKey, type ApiKey, type CreatedKey, type KeyGrant } from './keys/api-key.ts'; +export { configuredApiKeys, type ApiKeySource } from './keys/api-key-source.ts'; export { ApiKeysSchema, apiKeysVariable, readApiKeys } from './keys/api-keys-setting.ts'; export { authenticatorFor, type AccessSettings, type Authenticator } from './access/authenticator.ts'; export { accessModeOf, type AccessFacts, type AccessMode, type DeclaredAccessMode } from './access/access-mode.ts'; diff --git a/packages/identity/src/keys/api-key-source.ts b/packages/identity/src/keys/api-key-source.ts new file mode 100644 index 000000000..c94c64e22 --- /dev/null +++ b/packages/identity/src/keys/api-key-source.ts @@ -0,0 +1,10 @@ +import type { ApiKey } from './api-key.ts'; + +export interface ApiKeySource { + readonly keyOf: (id: string) => Promise; +} + +export function configuredApiKeys(keys: readonly ApiKey[] | undefined): ApiKeySource { + const byId = new Map((keys ?? []).map((key) => [key.id, key])); + return { keyOf: (id) => Promise.resolve(byId.get(id)) }; +} diff --git a/packages/identity/src/keys/verify-key.ts b/packages/identity/src/keys/verify-key.ts index 8dfcaf486..c98a5d0e5 100644 --- a/packages/identity/src/keys/verify-key.ts +++ b/packages/identity/src/keys/verify-key.ts @@ -2,6 +2,7 @@ import { timingSafeEqual } from 'node:crypto'; import { sha256Of } from '@on.auto/operations'; +import type { ApiKeySource } from './api-key-source.ts'; import { keyIdOf, keyPrefix, type ApiKey } from './api-key.ts'; const digestOfNoKey = sha256Of(keyPrefix); @@ -10,8 +11,8 @@ function sameDigest(presented: string, stored: string): boolean { return timingSafeEqual(Buffer.from(presented, 'hex'), Buffer.from(stored, 'hex')); } -export function verifyKey(keys: readonly ApiKey[], presented: string): ApiKey | undefined { +export async function verifyKey(source: ApiKeySource, presented: string): Promise { const id = keyIdOf(presented); - const candidate = keys.find((key) => key.id === id); + const candidate = id === undefined ? undefined : await source.keyOf(id); return sameDigest(sha256Of(presented), candidate?.sha256 ?? digestOfNoKey) ? candidate : undefined; } diff --git a/packages/server/src/lifecycle/lifecycle.ts b/packages/server/src/lifecycle/lifecycle.ts index e6cd2f502..94d0e7ad7 100644 --- a/packages/server/src/lifecycle/lifecycle.ts +++ b/packages/server/src/lifecycle/lifecycle.ts @@ -1,6 +1,6 @@ import { readPageStyle } from '@on.auto/api'; import type { Environment } from '@on.auto/config'; -import { authenticatorFor, identityServiceOf } from '@on.auto/identity'; +import { authenticatorFor, configuredApiKeys, identityServiceOf } from '@on.auto/identity'; import type { RunningServer, ServerOptions } from '../server-options/server-options.ts'; import { readShippedSettings } from '../settings/served-settings.ts'; @@ -22,7 +22,11 @@ export async function startServer(environment: Environment, options: ServerOp studio: settings.studio, allowedOrigins: settings.allowedOrigins, }); - const authenticator = authenticatorFor({ ...settings, identityService }); + const authenticator = authenticatorFor({ + ...settings, + apiKeySource: configuredApiKeys(settings.apiKeys), + identityService, + }); const telemetry = startTelemetry(settings.telemetry); try { return await started({ settings, authenticator, telemetry, options, pageStyle }); From 7d8385c21fd26a925272d7290a909bed22bd6b8a Mon Sep 17 00:00:00 2001 From: Rami Date: Fri, 23 Oct 2026 13:18:54 +0100 Subject: [PATCH 2/3] feat(identity): a person whose token names no org acts in the org whose id is their subject A session or an access token that holds neither o nor org_id names no org, as the token of a person signed in with no organization of their own does, and was refused for a missing o or org_id claim. It now acts in the org whose id is its sub, and in no other, and the dispatcher's rules hold there as in any org; a sub that is not an org id other than local is refused, naming the sub claim. A token that holds the org claim of the other form without its own is refused as before, so the API's tests of a refused org claim now give the token that claim. The openIdConnect scheme of the OpenAPI document, the kept document, the security guide and the terminology say the new rule. Co-Authored-By: Claude Fable 5.1 --- docs/assets/openapi.json | 2 +- docs/concepts/terminology.md | 4 +- docs/reference/authentication.md | 44 ++++++++++--------- docs/self-host/security.md | 14 +++--- .../src/authentication/page-sessions.test.ts | 14 +++++- .../authentication/signed-in-callers.test.ts | 18 +++++++- .../api/src/openapi/document-components.ts | 2 +- packages/identity/README.md | 4 +- .../identity-service/identity-service.test.ts | 33 +++++++++++++- .../src/identity-service/sessions.test.ts | 25 ++++++++++- .../identity/src/tokens/token-verification.ts | 37 ++++++++++++---- 11 files changed, 149 insertions(+), 48 deletions(-) diff --git a/docs/assets/openapi.json b/docs/assets/openapi.json index be4606e6f..16dac26a4 100644 --- a/docs/assets/openapi.json +++ b/docs/assets/openapi.json @@ -12761,7 +12761,7 @@ "openIdConnect": { "type": "openIdConnect", "openIdConnectUrl": "https://clerk.on.auto/.well-known/openid-configuration", - "description": "An access token of the Auto identity service, sent as Authorization: Bearer , for a person signed in there. A server whose PUBLIC_URL is set takes a token whose audience is that URL. The person acts in the org their organization in the identity service names, over every playbook, with the permissions among the scopes their token was granted. Each permission that permits an operation is the scope of one of its requirements." + "description": "An access token of the Auto identity service, sent as Authorization: Bearer , for a person signed in there. A server whose PUBLIC_URL is set takes a token whose audience is that URL. The person acts in the org their organization in the identity service names, or, where their token names none, in the org whose id is their subject, over every playbook, with the permissions among the scopes their token was granted. Each permission that permits an operation is the scope of one of its requirements." } } } diff --git a/docs/concepts/terminology.md b/docs/concepts/terminology.md index 3605ce7b1..bea16acae 100644 --- a/docs/concepts/terminology.md +++ b/docs/concepts/terminology.md @@ -97,11 +97,11 @@ A prediction function, the predictive model a version of it holds and the predic | Term | Meaning | | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Org | The runtime's name for an organization in the Auto identity service; an org's id is that organization's id. Every playbook and every key belongs to one org. | +| Org | The runtime's name for an organization in the Auto identity service, under that organization's id, or for the org of a person signed in with none, under their subject. Every playbook and every key belongs to one org. | | Caller | Whoever a request acts for: a key, the local developer or a signed-in person, named on every fact it records. A run acts for the caller that started it, and a run a trigger starts acts as the playbook itself. | | Key | A credential the runtime issues itself, for a machine, a pipeline, a trainer's agent or a grant over some of an org's playbooks. | | Local developer | The caller of every request in local mode, on the developer's own machine, with every permission in every org. A server is in local mode when it listens only on a loopback address and neither sign-in nor keys are set. | -| Signed-in person | A person the Auto identity service signs in. They act in the org their sign-in names, with the permissions their token grants, and are recorded under their subject. | +| Signed-in person | A person the Auto identity service signs in. They act in the org their sign-in names, or in their own where it names none, with the permissions their token grants, and are recorded under their subject. | ## Supporting assets diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 3838e28ae..b22151d1c 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -2,7 +2,7 @@ This page sets out the rules the runtime applies to credentials: how it reads one, what a token must hold, what a signed-in person may do and what a refused client is told. It also sets out how the runtime checks calls from a browser and requests in local mode. [Authentication and security](../self-host/security.md) covers the tasks: turning sign-in on, making API keys, local mode and calls from a browser. -A signed-in person acts in an [org](../concepts/terminology.md#orgs-and-callers), the runtime's name for an organization in the Auto identity service. +A signed-in person acts in an [org](../concepts/terminology.md#orgs-and-callers), the runtime's name for an organization in the Auto identity service, or, for a person who signs in with none, for an org of their own, whose id is their subject. ## How a credential is read @@ -36,24 +36,24 @@ A token that holds `aud` is an access token, even when it also holds an `azp`. The runtime accepts a token only when it meets every rule: -| Claim or property | Rule | -| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Size | At most 8 KiB | -| `alg` (header) | `RS256`, `PS256`, `ES256`, or `EdDSA` on `Ed25519`. `none` and every other algorithm are refused | -| `typ` (header) | `at+jwt`, `application/at+jwt`, `JWT` or `application/jwt`, in any letter case, or none | -| Signature | By a key of the service's key set that verifies its algorithm, chosen by the token's `kid`. A token without a `kid` uses the only key of its type in the set; one that several keys fit is refused | -| Signing key | Never an RSA key under 2048 bits, a key meant for encryption, or a key named for another algorithm | -| `iss` | The issuer, `IDENTITY_ISSUER` | -| `aud` | For an access token, an audience the runtime accepts: see [The audience of a token](#the-audience-of-a-token) | -| `azp` | For a session, an origin the runtime allows, equal to the request's `Origin` header | -| `exp` | Present and not past, with 60 seconds of leeway | -| `nbf` and `iat` | Not ahead, with 60 seconds of leeway | -| `sub` | A non-empty string of at most 256 bytes, without control characters, unpaired surrogates or noncharacters. These are the bounds of a name in the facts, since `sub` becomes part of the name recorded for the person | -| `org_id` | For an access token: present, and an org id of 1 to 64 letters, digits, `_` and `-`, other than `local`. It is the id of the organization the person signed in to. The identity service puts it in the token when the person grants `user:org:read` | -| `o` | For a session: an object whose `id` is an org id under the same rule. Version 2 of the service's session token names the organization in `o`, beside its slug, the person's role, their permissions and a feature-permission map. The runtime reads only the `id` | -| `scope` | For an access token that holds it, a string of scopes separated by spaces, as the identity service issues it. The `scope` of a session is not read | - -The org comes from the org claim of the token's form alone: `org_id` for an access token, the `id` of `o` for a session. A token without it is refused; a session issued while the person has no active organization is one example. No org is taken from the subject or from anything else the token holds. That includes the `org_id` of a session and the `o` of an access token. +| Claim or property | Rule | +| ----------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Size | At most 8 KiB | +| `alg` (header) | `RS256`, `PS256`, `ES256`, or `EdDSA` on `Ed25519`. `none` and every other algorithm are refused | +| `typ` (header) | `at+jwt`, `application/at+jwt`, `JWT` or `application/jwt`, in any letter case, or none | +| Signature | By a key of the service's key set that verifies its algorithm, chosen by the token's `kid`. A token without a `kid` uses the only key of its type in the set; one that several keys fit is refused | +| Signing key | Never an RSA key under 2048 bits, a key meant for encryption, or a key named for another algorithm | +| `iss` | The issuer, `IDENTITY_ISSUER` | +| `aud` | For an access token, an audience the runtime accepts: see [The audience of a token](#the-audience-of-a-token) | +| `azp` | For a session, an origin the runtime allows, equal to the request's `Origin` header | +| `exp` | Present and not past, with 60 seconds of leeway | +| `nbf` and `iat` | Not ahead, with 60 seconds of leeway | +| `sub` | A non-empty string of at most 256 bytes, without control characters, unpaired surrogates or noncharacters. These are the bounds of a name in the facts, since `sub` becomes part of the name recorded for the person. For a token that names no org, an org id other than `local` too, since it is the org the person acts in | +| `org_id` | For an access token that names an org: present, and an org id of 1 to 64 letters, digits, `_` and `-`, other than `local`. It is the id of the organization the person signed in to. The identity service puts it in the token when the person grants `user:org:read` | +| `o` | For a session that names an org: an object whose `id` is an org id under the same rule. Version 2 of the service's session token names the organization in `o`, beside its slug, the person's role, their permissions and a feature-permission map. The runtime reads only the `id` | +| `scope` | For an access token that holds it, a string of scopes separated by spaces, as the identity service issues it. The `scope` of a session is not read | + +The org comes from the org claim of the token's form: `org_id` for an access token, the `id` of `o` for a session. A token that holds neither `org_id` nor `o` names no org, as the token of a person signed in with no organization of their own does, such as a session issued while the person has no active organization. It acts in the org whose id is its `sub`, and in no other, and the runtime's rules then hold there as in any org. A token that holds the org claim of the other form without its own is refused. No org is taken from anything else the token holds. That includes the `org_id` of a session and the `o` of an access token. ## How the key set is kept @@ -69,10 +69,11 @@ A token that fails any rule is refused `401` with `invalid_token`, and so is an | Token | Detail | | --------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | | An expired token | `The token was refused: ERR_JWT_EXPIRED on its exp claim` | -| An access token without `org_id` | `The token was refused: ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim` | +| An access token without `org_id` that holds an `o` | `The token was refused: ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim` | | An access token whose `org_id` is not an org id other than `local` | `The token was refused: its org_id claim is not an org id other than local` | -| A session without `o`, such as one issued while the person has no active organization | `The token was refused: ERR_JWT_CLAIM_VALIDATION_FAILED on its o claim` | +| A session without `o` that holds an `org_id` | `The token was refused: ERR_JWT_CLAIM_VALIDATION_FAILED on its o claim` | | A session whose `o` is no object, or holds no `id` that is an org id other than `local` | `The token was refused: its o.id claim is not an org id other than local` | +| A token that names no org, whose `sub` is not an org id other than `local` | `The token was refused: its sub claim is not an org id other than local` | | An access token for another resource | Ends `on its aud claim` | | A session given to a page at an origin the runtime does not allow, or naming no origin | Ends `on its azp claim` | | A session sent without an `Origin` header, or from another origin than its `azp` | `The token was refused: the session was not presented by the page it was issued to` | @@ -145,6 +146,7 @@ The identity service defines each permission as a scope of its own, with a sente What a token then allows: - A session admits the person to the org the `id` of its `o` names, over every playbook, with all four permissions, as they act in Studio. The role and permissions its `o` names do not change that. +- A token that names no org admits the person to the org whose id is its `sub`, over every playbook, with the permissions of its form: every permission for a session, and those among its scopes for an access token. - An access token admits the person to the org its `org_id` names, over every playbook, with the permissions among the scopes in its `scope` claim. Only `org:read`, `org:write`, `playbook:read` and `playbook:write` are permissions. Any other scope grants none, `user:org:read` included. - An access token that grants none of the four, or holds no `scope`, still admits the person to their org, with no permission. Every operation then refuses them `403` with `insufficient_scope`. The challenge's `scope` names the permissions the operation needs, so a client asks the identity service for a token with more. - For an access token, `org_id` binds the org, not `user:org:read`. An access token that holds `org_id` and the four permissions, but not `user:org:read`, admits the person to that org with all four permissions. diff --git a/docs/self-host/security.md b/docs/self-host/security.md index 260998309..3c43be1b9 100644 --- a/docs/self-host/security.md +++ b/docs/self-host/security.md @@ -4,13 +4,13 @@ Every request to the runtime comes from a caller. This page covers the tasks of The runtime recognizes three kinds of caller: -| Caller | Who uses it | How it authenticates | What it may do | -| ------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------- | -| A signed-in person | People, through their agent, an agent gateway, Auto Studio or a page you allow | Signs in through the Auto identity service, then sends a token or a page's session as `Authorization: Bearer ` | Acts in the org they signed in to, over every playbook, with the permissions their token grants | -| An API key | A machine, a pipeline or a trainer's agent that does not sign in, or any caller that needs a narrower grant | Sends a key listed in `API_KEYS` as `Authorization: Bearer ` | Acts in its one org, with the permissions and playbooks its entry lists | -| The local developer | You, on your own machine, in [local mode](#local-mode) | Nothing: every request is trusted | Acts with every permission in whichever org a request names | +| Caller | Who uses it | How it authenticates | What it may do | +| ------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | +| A signed-in person | People, through their agent, an agent gateway, Auto Studio or a page you allow | Signs in through the Auto identity service, then sends a token or a page's session as `Authorization: Bearer ` | Acts in the org they signed in to, or in their own where they signed in to none, over every playbook, with the permissions their token grants | +| An API key | A machine, a pipeline or a trainer's agent that does not sign in, or any caller that needs a narrower grant | Sends a key listed in `API_KEYS` as `Authorization: Bearer ` | Acts in its one org, with the permissions and playbooks its entry lists | +| The local developer | You, on your own machine, in [local mode](#local-mode) | Nothing: every request is trusted | Acts with every permission in whichever org a request names | -An [org](../concepts/terminology.md#orgs-and-callers) is the runtime's name for an organization in the Auto identity service. +An [org](../concepts/terminology.md#orgs-and-callers) is the runtime's name for an organization in the Auto identity service, or for the org of a person who signs in with none, whose id is their subject. ## Who can call a server @@ -64,7 +64,7 @@ With sign-in on, the server checks the identity service before it accepts a requ ### Organizations -An org's id is its organization's id: a person who signs in to the organization `acme` acts in the org `acme`. The runtime keeps no list of people or orgs, since each token names its org. +An org's id is its organization's id: a person who signs in to the organization `acme` acts in the org `acme`. A person who signs in with no organization of their own acts in an org of their own, whose id is the subject of their token, and in no other. The runtime keeps no list of people or orgs, since each token names its org, or, naming none, its subject does. A company whose people sign in through its own identity provider connects that provider to the identity service in Studio, not in the runtime. The runtime trusts one issuer, whichever provider a person used. diff --git a/packages/api/src/authentication/page-sessions.test.ts b/packages/api/src/authentication/page-sessions.test.ts index 481c80d19..045c0d898 100644 --- a/packages/api/src/authentication/page-sessions.test.ts +++ b/packages/api/src/authentication/page-sessions.test.ts @@ -35,6 +35,16 @@ describe('a page at an allowed origin, sending the session the identity service }, ); + it('acts in the org of its subject when the session names no org, and is refused every other with 403', async () => { + const { server, fromPage } = await signedInServer(); + const headers = await fromPage(defaultStudioOrigin, { o: undefined, sub: 'user_2ada' }); + + expect([ + (await call(server.handler, '/v1/orgs/user_2ada/playbook-labels', { headers })).status, + (await call(server.handler, '/v1/orgs/acme/playbook-labels', { headers })).status, + ]).toEqual([200, 403]); + }); + it('is refused every other org with 403', async () => { const { server, fromPage } = await signedInServer(); @@ -65,11 +75,11 @@ describe('a session the runtime refuses', () => { expect(answer.headers.get('www-authenticate')).toMatch(/^Bearer error="invalid_token", /u); }); - it('is answered 401 for a session without an organization, naming the o claim', async () => { + it('is answered 401 for a session that names its org in org_id, not o, naming the o claim', async () => { const { server, fromPage } = await signedInServer(); const answer = await call(server.handler, '/v1/orgs/acme/playbook-labels', { - headers: await fromPage(defaultStudioOrigin, { o: undefined }), + headers: await fromPage(defaultStudioOrigin, { o: undefined, org_id: 'acme' }), }); expect(answer).toMatchObject({ diff --git a/packages/api/src/authentication/signed-in-callers.test.ts b/packages/api/src/authentication/signed-in-callers.test.ts index 4d0516644..8a486b63d 100644 --- a/packages/api/src/authentication/signed-in-callers.test.ts +++ b/packages/api/src/authentication/signed-in-callers.test.ts @@ -104,6 +104,18 @@ describe('a request without a valid token, with sign-in on', () => { }); }); +describe('a signed-in person whose token names no org, over HTTP', () => { + it('is admitted to the org of their subject, and refused every other with 403', async () => { + const { server, bearer } = await signedInServer(); + const namingNoOrg = await bearer({ org_id: undefined, sub: 'user_2ada' }); + + expect([ + (await call(server.handler, '/v1/orgs/user_2ada/playbook-labels', { headers: namingNoOrg })).status, + (await call(server.handler, '/v1/orgs/acme/playbook-labels', { headers: namingNoOrg })).status, + ]).toEqual([200, 403]); + }); +}); + describe('a token the runtime refuses, with sign-in on', () => { it('is answered 401 with invalid_token for an opaque token', async () => { const { server } = await signedInServer(); @@ -122,7 +134,11 @@ describe('a token the runtime refuses, with sign-in on', () => { }); it.each([ - ['no org', { org_id: undefined }, 'ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim'], + [ + 'its org in o, not org_id', + { org_id: undefined, o: { id: 'acme' } }, + 'ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim', + ], ['no sub', { sub: undefined }, 'ERR_JWT_CLAIM_VALIDATION_FAILED on its sub claim'], ['an expired one', { exp: 1 }, 'ERR_JWT_EXPIRED on its exp claim'], ])( diff --git a/packages/api/src/openapi/document-components.ts b/packages/api/src/openapi/document-components.ts index eadd5ebc2..422bfc49a 100644 --- a/packages/api/src/openapi/document-components.ts +++ b/packages/api/src/openapi/document-components.ts @@ -36,7 +36,7 @@ function securitySchemesOf(identityIssuer: string): OpenApiObject { description: [ 'An access token of the Auto identity service, sent as Authorization: Bearer , for a person signed in there.', 'A server whose PUBLIC_URL is set takes a token whose audience is that URL.', - 'The person acts in the org their organization in the identity service names, over every playbook, with the permissions among the scopes their token was granted.', + 'The person acts in the org their organization in the identity service names, or, where their token names none, in the org whose id is their subject, over every playbook, with the permissions among the scopes their token was granted.', 'Each permission that permits an operation is the scope of one of its requirements.', ].join(' '), }, diff --git a/packages/identity/README.md b/packages/identity/README.md index ad2eb9ef5..b0e1600b5 100644 --- a/packages/identity/README.md +++ b/packages/identity/README.md @@ -1,6 +1,6 @@ # @on.auto/identity -Who calls the runtime: a key, the local developer, or a signed-in person the Auto identity service vouches for. `authenticatorFor` of `src/index.ts` takes the settings, the address the server listens on, the API keys the operator configured, the source a presented key is looked up in and, where sign-in is on, the identity service, and answers the access mode and an asynchronous `authenticate`, which takes the credential a request presents, the path of the MCP endpoint it reached, if any, and its `Origin`, if it has one, and answers its `Authentication`: the `Principal` of the caller, or a `Refusal` whose `because` says why a token was refused, in words that name a rule or a claim and never a value of the token (`isRefusal` tells the two apart). A principal holds its own caller, `own`, and answers the caller of an org with `callerIn(org)`, or `undefined` for an org it does not admit: a key admits its own org, the local developer every org, whatever credential the request presents, and a signed-in person the org their token names. The access mode follows from what is configured, and `accessModeOf` of `src/access/access-mode.ts` is the one place that says which, for the authenticator and for the server's settings that follow from it: `credentials` with a key or with sign-in, `closed` when `API_KEYS` is set and lists no keys without sign-in, and with neither `local` on a loopback address, `localhost`, `127.0.0.0/8` or `::1` (`listensOnlyOnLoopback` of `src/access/loopback.ts`), a rule wider than the three names by which a URL or a `Host` header names the loopback, `namesTheLoopback` of `@on.auto/operations`, since a server that listens on any address of `127.0.0.0/8` is reachable from this computer alone. On any other address it is `local` too where the operator declares local mode, `accessMode: 'local'` (`DeclaredAccessMode`), for a server whose port only this computer reaches though it listens on every address of its own, such as a container whose port is published on `127.0.0.1` alone, which it cannot see; a key or sign-in still wins over the declaration. On any other address with neither and no declaration it names no mode, and `authenticatorFor` throws `ReachableWithoutCredentialsError`, which names the `HOST` and says that a server reachable beyond this computer needs `PUBLIC_URL`, to sign people in, or `API_KEYS`, or, where only this computer reaches its port, `ACCESS_MODE=local` with a `PUBLIC_URL` on the loopback, so the server stops before it listens. The server reads `listensOnlyOnLoopback` too, to refuse the declaration on another address without a `PUBLIC_URL` its links can point at. The key command, `src/key-command.ts`, prints a new key once and the `API_KEYS` entry that admits it. +Who calls the runtime: a key, the local developer, or a signed-in person the Auto identity service vouches for. `authenticatorFor` of `src/index.ts` takes the settings, the address the server listens on, the API keys the operator configured, the source a presented key is looked up in and, where sign-in is on, the identity service, and answers the access mode and an asynchronous `authenticate`, which takes the credential a request presents, the path of the MCP endpoint it reached, if any, and its `Origin`, if it has one, and answers its `Authentication`: the `Principal` of the caller, or a `Refusal` whose `because` says why a token was refused, in words that name a rule or a claim and never a value of the token (`isRefusal` tells the two apart). A principal holds its own caller, `own`, and answers the caller of an org with `callerIn(org)`, or `undefined` for an org it does not admit: a key admits its own org, the local developer every org, whatever credential the request presents, and a signed-in person the org their token names, or, where it names none, the org whose id is their subject. The access mode follows from what is configured, and `accessModeOf` of `src/access/access-mode.ts` is the one place that says which, for the authenticator and for the server's settings that follow from it: `credentials` with a key or with sign-in, `closed` when `API_KEYS` is set and lists no keys without sign-in, and with neither `local` on a loopback address, `localhost`, `127.0.0.0/8` or `::1` (`listensOnlyOnLoopback` of `src/access/loopback.ts`), a rule wider than the three names by which a URL or a `Host` header names the loopback, `namesTheLoopback` of `@on.auto/operations`, since a server that listens on any address of `127.0.0.0/8` is reachable from this computer alone. On any other address it is `local` too where the operator declares local mode, `accessMode: 'local'` (`DeclaredAccessMode`), for a server whose port only this computer reaches though it listens on every address of its own, such as a container whose port is published on `127.0.0.1` alone, which it cannot see; a key or sign-in still wins over the declaration. On any other address with neither and no declaration it names no mode, and `authenticatorFor` throws `ReachableWithoutCredentialsError`, which names the `HOST` and says that a server reachable beyond this computer needs `PUBLIC_URL`, to sign people in, or `API_KEYS`, or, where only this computer reaches its port, `ACCESS_MODE=local` with a `PUBLIC_URL` on the loopback, so the server stops before it listens. The server reads `listensOnlyOnLoopback` too, to refuse the declaration on another address without a `PUBLIC_URL` its links can point at. The key command, `src/key-command.ts`, prints a new key once and the `API_KEYS` entry that admits it. ## Keys @@ -12,7 +12,7 @@ Keys come from a source, `ApiKeySource` of `src/keys/api-key-source.ts`, whose ` `identityServiceOf` of `src/identity-service/identity-service.ts` takes the issuer, `autoIdentityService`, `https://clerk.on.auto`, unless a development runtime names another, `PUBLIC_URL`, the Studio settings and `ALLOWED_ORIGINS`, which the server hands it from its settings as it hands them to the API. A bearer that is not a key is one of two forms of token. An access token holds `aud`, and is what an agent or an agent gateway, a third party acting for a person, obtains through the authorization flow: it is taken for its audience, with the permissions among its scopes. A session holds no `aud`, and is what the identity service gives a page a person signs in to, such as the studio, Auto's own app: it is taken when its `azp` claim names an origin a page may come from and the request that presents it comes from that origin, so it serves its own page and never an agent, and gives every permission in its organization. `pageOriginsOf` of `src/access/page-origins.ts` is the one definition of those origins: the Studio host's, then the runtime's own origin where the runtime serves Studio, and `ALLOWED_ORIGINS`. The identity service gives it `PUBLIC_URL` as the runtime's own origin, and the API's `Origin` check and its CORS, which read it as well, give it the origin the API answers at. `src/access/studio.ts` holds the Studio settings, `StudioSettings`: `origin`, the Studio host, whose default is `defaultStudioOrigin`, `https://studio.on.auto`, and `served`, whether the runtime serves Studio itself at `servedStudioPath`, `/studio/`, since a page on a public origin may not reach an address on the person's machine. `studioBaseOf` is the one base of Studio's address that the runtime writes into a link: `{own origin}/studio/` where it serves Studio, and the Studio host's root otherwise. `signInScopes` are the scopes the runtime asks a client to request of the identity service: `user:org:read`, which binds the access token to the organization the person signs in to, as the `org_id` claim, and the four permissions, `allPermissions` of `@on.auto/operations`, the one definition of them, which the identity service defines as scopes of its own, each with a sentence its consent screen shows. At start it reads the issuer's metadata, from the addresses the MCP authorization specification orders, and its key set, each within 64 KiB and 10 seconds and following no redirect (`src/identity-service/issuer-requests.ts`), and stops with `UnusableIdentityServiceError` naming the service when one cannot be used or its key set holds no keys. -The [`jose`](https://github.com/panva/jose) library verifies the tokens and keeps the key set: `decodeJwt` reads, before anything is verified, whether the token holds `aud`, which decides its form, and a token it cannot decode is judged an access token, so its refusal is the one verification gives; `jwtVerify` then checks the signature with the key of the token's `kid`, or, for a token without one, with the issuer's only key of its type, refusing it when several fit, the algorithm among `RS256`, `PS256`, `ES256` and `EdDSA`, the issuer, the audience of an access token, `exp` and `nbf` with 60 seconds of leeway and that `exp`, `sub` and the org claim of its form, `org_id` or `o`, are present; `createRemoteJWKSet` holds the service's key set, read through the bounded read above, read again for an unknown `kid` once 60 seconds have passed since the last read that succeeded, and at the first token after it is ten minutes old, `jose`'s default age, set in `src/identity-service/issuer-keys.ts`; a token whose key set cannot be read once it is ten minutes old is refused, never verified against the old one. The runtime checks itself what is its own rule (`src/tokens/token-verification.ts`): a token at most 8 KiB before anything is decoded, a `typ` of `at+jwt`, `application/at+jwt`, `JWT` or `application/jwt` or none, an `iat` at most 60 seconds ahead, a `sub` that is a non-empty string of at most 256 bytes without control characters, unpaired surrogates or noncharacters, an org, an access token's `org_id` or the `id` of a session's `o`, that is an org id other than `local`, a `scope` claim, where an access token holds one, that is a string, and a session's `azp` among the origins of `pageOriginsOf`, refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its azp claim` otherwise, as `jose` names a claim it refuses, and then the `Origin` of the request exactly its `azp`, refused `the session was not presented by the page it was issued to` otherwise, a request without `Origin` among them; `identityServiceOf` names the audiences a request may hold, `PUBLIC_URL`, with a final `/` or not, and on an MCP endpoint that endpoint's URL. A token that holds both `aud` and `azp` is an access token, and its `azp` is not read. Each form names its org in a claim of its own, and `orgClaimPaths` of `src/tokens/token-verification.ts` is the one definition of both, since the service's metadata advertises no claim name: an access token names it as `org_id`, which the service puts in it when the person grants `user:org:read`, and a session as the `id` of its `o`, since version 2 of the service's session token, marked `v: 2`, holds the organization the person signed in to as the object `o`, with its slug, `slg`, the person's role, `rol`, their permissions, `per`, and the feature-permission map, `fpm`, beside its `id`. A session without `o` is refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its o claim` and one whose `o` is no object or holds no `id` that is an org id other than `local` is refused `its o.id claim is not an org id other than local`; nothing else of `o` is read, and `v` is not checked, since a format that named the organization elsewhere would hold no `o` and be refused for it. The tests of `src/identity-service/identity-service.test.ts` pin the claim of an access token and those of `src/identity-service/sessions.test.ts` the claim of a session. The person is `person:`, which `isSignedInPerson` tells apart from a key's id and from `local`, since neither holds a colon, in that org alone, over every playbook, with `allPermissions` for a session, whose `scope` is not read, and for an access token the permissions among the scopes its `scope` claim names, split at each space, in the order of `allPermissions`; any other scope, `user:org:read` among them, grants none, so an access token that grants none of the four, or holds no `scope`, is a person of the org with no permission (`signedInPersonOf` of `src/access/principal.ts`). No org is taken from anything else the token holds, the `org_id` of a session and the `o` of an access token among it, and `user:org:read` is not checked: `org_id` is. The security guide of the documentation sets out every rule. +The [`jose`](https://github.com/panva/jose) library verifies the tokens and keeps the key set: `decodeJwt` reads, before anything is verified, whether the token holds `aud`, which decides its form, and a token it cannot decode is judged an access token, so its refusal is the one verification gives; `jwtVerify` then checks the signature with the key of the token's `kid`, or, for a token without one, with the issuer's only key of its type, refusing it when several fit, the algorithm among `RS256`, `PS256`, `ES256` and `EdDSA`, the issuer, the audience of an access token, `exp` and `nbf` with 60 seconds of leeway and that `exp` and `sub` are present; `createRemoteJWKSet` holds the service's key set, read through the bounded read above, read again for an unknown `kid` once 60 seconds have passed since the last read that succeeded, and at the first token after it is ten minutes old, `jose`'s default age, set in `src/identity-service/issuer-keys.ts`; a token whose key set cannot be read once it is ten minutes old is refused, never verified against the old one. The runtime checks itself what is its own rule (`src/tokens/token-verification.ts`): a token at most 8 KiB before anything is decoded, the org claim of its form present unless it holds neither, refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim` or `on its o claim` otherwise, as `jose` names a claim it refuses, a `typ` of `at+jwt`, `application/at+jwt`, `JWT` or `application/jwt` or none, an `iat` at most 60 seconds ahead, a `sub` that is a non-empty string of at most 256 bytes without control characters, unpaired surrogates or noncharacters, an org, an access token's `org_id`, the `id` of a session's `o`, or the `sub` of a token that holds neither, that is an org id other than `local`, a `scope` claim, where an access token holds one, that is a string, and a session's `azp` among the origins of `pageOriginsOf`, refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its azp claim` otherwise, and then the `Origin` of the request exactly its `azp`, refused `the session was not presented by the page it was issued to` otherwise, a request without `Origin` among them; `identityServiceOf` names the audiences a request may hold, `PUBLIC_URL`, with a final `/` or not, and on an MCP endpoint that endpoint's URL. A token that holds both `aud` and `azp` is an access token, and its `azp` is not read. Each form names its org in a claim of its own, and `orgClaimPaths` of `src/tokens/token-verification.ts` is the one definition of both, since the service's metadata advertises no claim name: an access token names it as `org_id`, which the service puts in it when the person grants `user:org:read`, and a session as the `id` of its `o`, since version 2 of the service's session token, marked `v: 2`, holds the organization the person signed in to as the object `o`, with its slug, `slg`, the person's role, `rol`, their permissions, `per`, and the feature-permission map, `fpm`, beside its `id`. A session without `o` that holds an `org_id` is refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its o claim`, as an access token without `org_id` that holds an `o` is refused `on its org_id claim`, and a session whose `o` is no object or holds no `id` that is an org id other than `local` is refused `its o.id claim is not an org id other than local`; nothing else of `o` is read. A token that holds neither `org_id` nor `o` names no org, as the token of a person signed in with no organization of their own does, and acts in the org whose id is its `sub`, `subjectAsOrg` of `src/tokens/token-verification.ts`, refused `its sub claim is not an org id other than local` where the `sub` is not one; the person is then a caller of that org as of any other. `v` is not checked, since a format that named the organization elsewhere would hold no `o` and act in the org of the person's own `sub`, never in another. The tests of `src/identity-service/identity-service.test.ts` pin the claim of an access token and those of `src/identity-service/sessions.test.ts` the claim of a session. The person is `person:`, which `isSignedInPerson` tells apart from a key's id and from `local`, since neither holds a colon, in that org alone, over every playbook, with `allPermissions` for a session, whose `scope` is not read, and for an access token the permissions among the scopes its `scope` claim names, split at each space, in the order of `allPermissions`; any other scope, `user:org:read` among them, grants none, so an access token that grants none of the four, or holds no `scope`, is a person of the org with no permission (`signedInPersonOf` of `src/access/principal.ts`). No org is taken from anything else the token holds, the `org_id` of a session and the `o` of an access token among it, which only refuse a token that holds them without the claim of its form, and `user:org:read` is not checked: `org_id` is. The security guide of the documentation sets out every rule. `fakeIssuer` of `@on.auto/identity/testing` is an issuer for tests: it answers its metadata, its key set and a token endpoint for the client credentials grant, whose token's `scope` claim holds the scopes the request asked for, signs a token with `jose` and a key of each algorithm, issues the session of a page, `session(page, claims)`, in the shape of version 2 of the service's session token: `iss`, `sub`, a `sid`, the page as `azp`, `iat`, `nbf`, an `exp` a minute later, `v: 2` and as `o` the organization `acme` with its `id`, `slg`, `rol`, `per` and `fpm`, under the `typ` `JWT` and with no `aud`, each claim a test gives taking the place of its own, `o` whole, so `{ o: undefined }` issues a session without one, and turns its keys over; `signedTokens` and `principalIn` help tests sign a token, which holds every sign-in scope unless a test gives it a `scope` of its own, or a session, against an identity service that allows `testAllowedOrigin` beside the Studio host, `hostedStudio`, or beside the Studio host and `testPublicUrl` when given `servedStudio`, and read the person it admits. diff --git a/packages/identity/src/identity-service/identity-service.test.ts b/packages/identity/src/identity-service/identity-service.test.ts index e90361a69..3ebf41096 100644 --- a/packages/identity/src/identity-service/identity-service.test.ts +++ b/packages/identity/src/identity-service/identity-service.test.ts @@ -165,7 +165,6 @@ const refusedPersons: ReadonlyArray = [ ['a sub over 256 bytes', { sub: 'ü'.repeat(129) }, subjectRefusal], ['a sub with a control character', { sub: 'ada\u0000' }, subjectRefusal], ['a sub with a noncharacter', { sub: 'ada\uFFFF' }, subjectRefusal], - ['no org_id', { org_id: undefined }, 'ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim'], ['an org_id that is no org id', { org_id: 'ac me' }, 'its org_id claim is not an org id other than local'], ['the org of local mode', { org_id: 'local' }, 'its org_id claim is not an org id other than local'], ['an org_id that is not a string', { org_id: 7 }, 'its org_id claim is not an org id other than local'], @@ -195,6 +194,38 @@ describe('a token the runtime refuses, saying why', () => { }); }); +const noOrgClaim = { org_id: undefined, o: undefined }; + +const subjectAsOrgRefusal = 'its sub claim is not an org id other than local'; + +describe('the person a token that names no org names', () => { + it('is admitted to the org of its sub, with the permissions its scopes grant over every playbook, and nowhere else', async () => { + const { identityService, token } = await signedTokens(fakeIssuer('https://login.example'), atStart); + const person = principalIn( + await identityService.personOf(await token({ ...noOrgClaim, sub: 'user_2ada', scope: 'playbook:read' }), '/mcp'), + ); + + expect(person?.own).toEqual({ + id: 'person:user_2ada', + org: 'user_2ada', + permissions: ['playbook:read'], + playbooks: '*', + }); + expect(['user_2ada', 'acme'].map((org) => person?.callerIn(org)?.org)).toEqual(['user_2ada', undefined]); + }); + + it.each([ + ['a sub that is no org id', 'user|ada'], + ['the sub local, the org of local mode', 'local'], + ])('is refused for %s, naming the claim the org would come from', async (_case, sub) => { + const { identityService, token } = await signedTokens(fakeIssuer('https://login.example'), atStart); + + expect(await identityService.personOf(await token({ ...noOrgClaim, sub }), '/mcp')).toEqual({ + because: subjectAsOrgRefusal, + }); + }); +}); + describe('a token the runtime refuses for its form, saying why', () => { it('is refused for the typ of another kind of token', async () => { const { identityService, token } = await signedTokens(fakeIssuer('https://login.example'), atStart); diff --git a/packages/identity/src/identity-service/sessions.test.ts b/packages/identity/src/identity-service/sessions.test.ts index 8c6c8c09e..c881a04fb 100644 --- a/packages/identity/src/identity-service/sessions.test.ts +++ b/packages/identity/src/identity-service/sessions.test.ts @@ -94,6 +94,30 @@ describe('a session read for its o claim', () => { }); }); +describe('a session that names no org', () => { + it('is the person of the org of its sub, with every permission over every playbook, and of no other org', async () => { + const { identityService, session } = await signedTokens(fakeIssuer('https://login.example'), atStart); + const claims = { o: undefined, sub: 'user_2ada' }; + const person = principalIn( + await identityService.personOf(await session(defaultStudioOrigin, claims), '', defaultStudioOrigin), + ); + + expect(person?.own).toEqual({ id: 'person:user_2ada', org: 'user_2ada', ...everyPermissionOverEveryPlaybook }); + expect(person?.callerIn('acme')).toBeUndefined(); + }); + + it('is refused for a sub that is no org id, naming the claim the org would come from', async () => { + const { identityService, session } = await signedTokens(fakeIssuer('https://login.example'), atStart); + const claims = { o: undefined, sub: 'user|ada' }; + + expect(await identityService.personOf(await session(defaultStudioOrigin, claims), '', defaultStudioOrigin)).toEqual( + { + because: 'its sub claim is not an org id other than local', + }, + ); + }); +}); + describe('a session given to the page the runtime serves at its own origin', () => { it('is the person of its org where the runtime serves Studio, since its public URL is then a page origin', async () => { const { identityService, session } = await signedTokens(fakeIssuer('https://login.example'), atStart, servedStudio); @@ -142,7 +166,6 @@ const refusedSessions: ReadonlyArray ['the studio with a final slash', `${defaultStudioOrigin}/`, {}, pageRefusal], ['the public URL of the runtime', testPublicUrl, {}, pageRefusal], ['an azp that is not a string', defaultStudioOrigin, { azp: 7 }, pageRefusal], - ['no o', defaultStudioOrigin, { o: undefined }, noOrgClaim], ['no o, though it holds an org_id', defaultStudioOrigin, { o: undefined, org_id: 'acme' }, noOrgClaim], ['an o that is no object', defaultStudioOrigin, { o: 'acme' }, orgRefusal], ['an o without an id', defaultStudioOrigin, { o: {} }, orgRefusal], diff --git a/packages/identity/src/tokens/token-verification.ts b/packages/identity/src/tokens/token-verification.ts index 60a7a1c7a..aea5fead2 100644 --- a/packages/identity/src/tokens/token-verification.ts +++ b/packages/identity/src/tokens/token-verification.ts @@ -31,17 +31,25 @@ type Payload = Readonly>; type Form = 'access token' | 'session'; -const orgClaimPaths: Readonly> = { +type ClaimPath = readonly [string, ...string[]]; + +const orgClaimPaths: Readonly> = { 'access token': ['org_id'], session: ['o', 'id'], }; -function orgClaimOf(form: Form): string { - return orgClaimPaths[form][0]; +const subjectAsOrg: ClaimPath = ['sub']; + +function namesNoOrg(payload: Payload): boolean { + return Object.values(orgClaimPaths).every(([claim]) => !Predicate.hasProperty(payload, claim)); +} + +function orgClaimPathOf(payload: Payload, form: Form): ClaimPath { + return namesNoOrg(payload) ? subjectAsOrg : orgClaimPaths[form]; } -function orgIn(payload: Payload, form: Form): unknown { - return orgClaimPaths[form].reduce( +function orgIn(payload: Payload, path: ClaimPath): unknown { + return path.reduce( (claims, claim) => (Predicate.hasProperty(claims, claim) ? claims[claim] : undefined), payload, ); @@ -81,11 +89,18 @@ function optionsOf({ issuer, audiences, now }: Expected, form: Form): JWTVerifyO ...(form === 'access token' ? { audience: [...audiences] } : {}), algorithms: signingAlgorithms, clockTolerance: leewaySeconds, - requiredClaims: ['exp', 'sub', orgClaimOf(form)], + requiredClaims: ['exp', 'sub'], currentDate: new Date(now), }; } +function orgClaimRefusal(payload: Payload, form: Form): string | undefined { + const [claim] = orgClaimPaths[form]; + return namesNoOrg(payload) || Predicate.hasProperty(payload, claim) + ? undefined + : onItsClaim(errors.JWTClaimValidationFailed.code, claim); +} + function typeRefusal(typ: unknown): string | undefined { return typ === undefined || (Predicate.isString(typ) && accessTokenTypes.has(typ.toLowerCase())) ? undefined @@ -119,7 +134,8 @@ function permissionsGrantedBy({ [scopeClaim]: scope = '' }: Payload, form: Form) function personIn(payload: Payload, form: Form, now: number): string | VerifiedPerson { const { iat, sub } = payload; - const org = orgIn(payload, form); + const orgClaimPath = orgClaimPathOf(payload, form); + const org = orgIn(payload, orgClaimPath); if (Predicate.isNumber(iat) && iat > now / 1000 + leewaySeconds) { return `its iat is more than ${leewaySeconds} seconds ahead`; } @@ -127,7 +143,7 @@ function personIn(payload: Payload, form: Form, now: number): string | VerifiedP return `its sub is not a non-empty string of at most ${mostReferenceBytes} bytes without ${forbiddenCharactersInWords}`; } if (!isCredentialOrg(org)) { - return `its ${orgClaimPaths[form].join('.')} claim is not an org id other than local`; + return `its ${orgClaimPath.join('.')} claim is not an org id other than local`; } const permissions = permissionsGrantedBy(payload, form); return Predicate.isString(permissions) ? permissions : { subject: sub, org, permissions }; @@ -137,7 +153,10 @@ async function verified(token: string, keySet: JWTVerifyGetKey, expected: Expect const form = formOf(token); const { payload, protectedHeader } = await jwtVerify(token, keySet, optionsOf(expected, form)); return ( - typeRefusal(protectedHeader.typ) ?? pageRefusal(payload, form, expected) ?? personIn(payload, form, expected.now) + orgClaimRefusal(payload, form) ?? + typeRefusal(protectedHeader.typ) ?? + pageRefusal(payload, form, expected) ?? + personIn(payload, form, expected.now) ); } From 72b9afa5316d870f4ef9098c3d91f7a2c8c539ec Mon Sep 17 00:00:00 2001 From: Rami Date: Fri, 23 Oct 2026 13:32:18 +0100 Subject: [PATCH 3/3] feat(server): the identity service's key set may be given, as IDENTITY_JWKS or at IDENTITY_JWKS_URL The verifier takes its signing keys from an IssuerKeySource of the identity package: discovery through the issuer's metadata, the default as before; a URL, read in place of the metadata's jwks_uri and kept for ten minutes as a key set found through it is; or a key set given, which is read from nowhere. The server reads IDENTITY_JWKS, the JSON of a key set that holds a key, and IDENTITY_JWKS_URL, an https URL, beside IDENTITY_ISSUER, refuses both at once, and composes the source they name, so a server whose issuer publishes its keys another way, or that should read nothing of the issuer at start, still signs people in. Co-Authored-By: Claude Fable 5.1 --- docs/reference/authentication.md | 11 ++ docs/self-host/configuration.md | 4 +- packages/identity/README.md | 4 +- .../src/identity-service/identity-service.ts | 10 +- .../issuer-key-sources.test.ts | 168 ++++++++++++++++++ .../src/identity-service/issuer-keys.ts | 40 ++++- packages/identity/src/index.ts | 8 + packages/server/src/lifecycle/lifecycle.ts | 19 +- .../src/settings/access-settings.test.ts | 51 ++++++ .../server/src/settings/access-settings.ts | 47 ++++- packages/server/src/settings/origin.ts | 8 + packages/server/src/settings/settings.test.ts | 3 + .../server/src/sign-in/issuer-over-https.ts | 6 +- .../sign-in/sign-in-with-public-url.test.ts | 31 ++++ 14 files changed, 386 insertions(+), 24 deletions(-) create mode 100644 packages/identity/src/identity-service/issuer-key-sources.test.ts diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index b22151d1c..d2e2df31e 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -21,6 +21,13 @@ With sign-in on, the server reads the identity service before it accepts a reque Each read waits at most 10 seconds for a response and reads at most 64 KiB. It follows no redirect: an address that responds with one is refused, like an address that does not respond. +The key set may instead be given, for a service that publishes its keys another way, or a server that should read nothing of the service at start: + +- With `IDENTITY_JWKS_URL`, the server reads no metadata, and reads the key set at that `https` URL in place of the `jwks_uri`, as step 3 does, with the same bounds. +- With `IDENTITY_JWKS`, the JSON of the key set itself, which must hold a key, the server reads nothing of the service, at start or later. + +Setting both stops the start with an `InvalidSettingsError`. The tokens are checked the same way whichever gives the key set. + A service whose metadata or key set cannot be used stops the start with an `UnusableIdentityServiceError`. The error names the service and each address the server read. ## The two token forms @@ -57,11 +64,15 @@ The org comes from the org claim of the token's form: `org_id` for an access tok ## How the key set is kept +A key set read at start, through the metadata or at `IDENTITY_JWKS_URL`, is kept this way: + - The server reads the key set again for a `kid` it does not hold, once 60 seconds have passed since the last read that succeeded. - It reads the set again at the first token after the set is ten minutes old. So a key the service removes is trusted for ten minutes at most. - When it cannot read the set again for an unknown `kid`, the keys it held stay in use. - When it cannot read the set once ten minutes have passed, every token is refused. An identity service that is unreachable for longer than that keeps sign-ins out until it responds again. +A key set that `IDENTITY_JWKS` gives is read from nowhere: its keys are the ones trusted until the server restarts with others, and a token signed with any other key is refused. + ## When a token is refused A token that fails any rule is refused `401` with `invalid_token`, and so is an opaque token. The problem's `detail` names the rule or the claim the token failed, and never a value it holds: diff --git a/docs/self-host/configuration.md b/docs/self-host/configuration.md index 5cf22ca21..4218abf47 100644 --- a/docs/self-host/configuration.md +++ b/docs/self-host/configuration.md @@ -72,7 +72,7 @@ Inside an argument, only the value is judged, never the name the argument gives | Secrets kept in AWS Parameter Store, not in the ledger | `SECRETS_KEEPER=aws-parameter-store:`, and `SECRETS_KEEPER_PREVIOUS` while the secrets move from the ledger or back | [Keep secrets in your secret manager](secret-manager.md) | | Content kept in an object store instead of the ledger | `CONTENT_STORE_URL`, an `s3://bucket/prefix` URL | [Object storage](object-storage.md) | | Tools that functions may call | `tool_servers` in the file, each entry's `allowed` to allow only some of its tools | [Tool servers](#tool-servers) | -| People who sign in through the Auto identity service | `PUBLIC_URL`, which turns sign-in on, and `IDENTITY_ISSUER` for a development instance of the service | [Let people sign in](security.md#let-people-sign-in) | +| People who sign in through the Auto identity service | `PUBLIC_URL`, which turns sign-in on, `IDENTITY_ISSUER` for a development instance, `IDENTITY_JWKS` or `IDENTITY_JWKS_URL` for its key set | [Let people sign in](security.md#let-people-sign-in) | | Machines and pipelines that call the API | `API_KEYS`, with an entry the key command prints for each | [API keys](security.md#api-keys) | | Auto in a container on your own machine, without sign-in | `ACCESS_MODE=local` and `PUBLIC_URL=http://localhost:5555`, with the port published on `127.0.0.1` alone | [Run with Docker](docker.md#on-your-own-machine) | | Studio served by the runtime, or fetched from another host | `SERVE_STUDIO` and `STUDIO_ORIGIN` | [Studio served by the runtime](security.md#studio-served-by-the-runtime) | @@ -107,6 +107,8 @@ Every setting is an environment variable. The tables below group them by area. | --------------------- | --------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `PUBLIC_URL` | none | The URL at which callers reach the runtime, which its links point at. Without `ACCESS_MODE`, it turns sign-in through the Auto identity service on, and every access token names it as its audience; [Let people sign in](security.md#let-people-sign-in) gives the URLs it accepts. Beside `ACCESS_MODE=local`, it signs no one in, is taken only on `localhost`, `127.0.0.1` or `[::1]`, and is needed where the server listens beyond the loopback, as in a container ([Run with Docker](docker.md#on-your-own-machine)) | | `IDENTITY_ISSUER` | `https://clerk.on.auto` | The identity service that people sign in through, an `https` URL with no path, query or fragment. A development runtime names the development instance of the service | +| `IDENTITY_JWKS` | none | The key set of the identity service, as the JSON of a JWK set, `{"keys":[…]}`, that holds at least one key, each naming its `kty`, for a service that publishes its keys another way, or a server that should read nothing of the service at start. The server then reads neither the metadata nor a key set of the service, and trusts these keys until it restarts. Set it or `IDENTITY_JWKS_URL`, not both. See [What the server checks at start](../reference/authentication.md#what-the-server-checks-at-start) | +| `IDENTITY_JWKS_URL` | none | The `https` URL of the identity service's key set, read in place of the `jwks_uri` of its metadata, which the server then does not read. The key set is kept and read again as one found through the metadata is ([How the key set is kept](../reference/authentication.md#how-the-key-set-is-kept)). Set it or `IDENTITY_JWKS`, not both | | `API_KEYS` | none | The API keys the server accepts, as a compact JSON array of the entries the key command prints; `api_keys` in the file. See [API keys](security.md#api-keys) | | `ACCESS_MODE` | none | `local` declares [local mode](security.md#local-mode) on any address, for a server whose port only this computer reaches, such as a container whose port Docker publishes on `127.0.0.1` alone; never with a wider publish, and never beside containers on the same Docker network that you would not let act as you. It stops the start beside `API_KEYS`, beside a `PUBLIC_URL` that is not on `localhost`, `127.0.0.1` or `[::1]`, and without a `PUBLIC_URL` where the server listens beyond the loopback, as in a container; `access_mode` in the file. See [Run with Docker](docker.md#on-your-own-machine) | | `ALLOWED_ORIGINS` | none | Comma-separated origins a browser page may call the API from, besides the Studio host's and, where the runtime serves Studio, its own; `allowed_origins` in the file. See [Calling from a browser](security.md#calling-from-a-browser) | diff --git a/packages/identity/README.md b/packages/identity/README.md index b0e1600b5..c95cc477d 100644 --- a/packages/identity/README.md +++ b/packages/identity/README.md @@ -10,9 +10,9 @@ Keys come from a source, `ApiKeySource` of `src/keys/api-key-source.ts`, whose ` ## Signed-in persons -`identityServiceOf` of `src/identity-service/identity-service.ts` takes the issuer, `autoIdentityService`, `https://clerk.on.auto`, unless a development runtime names another, `PUBLIC_URL`, the Studio settings and `ALLOWED_ORIGINS`, which the server hands it from its settings as it hands them to the API. A bearer that is not a key is one of two forms of token. An access token holds `aud`, and is what an agent or an agent gateway, a third party acting for a person, obtains through the authorization flow: it is taken for its audience, with the permissions among its scopes. A session holds no `aud`, and is what the identity service gives a page a person signs in to, such as the studio, Auto's own app: it is taken when its `azp` claim names an origin a page may come from and the request that presents it comes from that origin, so it serves its own page and never an agent, and gives every permission in its organization. `pageOriginsOf` of `src/access/page-origins.ts` is the one definition of those origins: the Studio host's, then the runtime's own origin where the runtime serves Studio, and `ALLOWED_ORIGINS`. The identity service gives it `PUBLIC_URL` as the runtime's own origin, and the API's `Origin` check and its CORS, which read it as well, give it the origin the API answers at. `src/access/studio.ts` holds the Studio settings, `StudioSettings`: `origin`, the Studio host, whose default is `defaultStudioOrigin`, `https://studio.on.auto`, and `served`, whether the runtime serves Studio itself at `servedStudioPath`, `/studio/`, since a page on a public origin may not reach an address on the person's machine. `studioBaseOf` is the one base of Studio's address that the runtime writes into a link: `{own origin}/studio/` where it serves Studio, and the Studio host's root otherwise. `signInScopes` are the scopes the runtime asks a client to request of the identity service: `user:org:read`, which binds the access token to the organization the person signs in to, as the `org_id` claim, and the four permissions, `allPermissions` of `@on.auto/operations`, the one definition of them, which the identity service defines as scopes of its own, each with a sentence its consent screen shows. At start it reads the issuer's metadata, from the addresses the MCP authorization specification orders, and its key set, each within 64 KiB and 10 seconds and following no redirect (`src/identity-service/issuer-requests.ts`), and stops with `UnusableIdentityServiceError` naming the service when one cannot be used or its key set holds no keys. +`identityServiceOf` of `src/identity-service/identity-service.ts` takes the issuer, `autoIdentityService` unless a development runtime names another, `PUBLIC_URL`, the Studio settings and `ALLOWED_ORIGINS`, which the server hands it from its settings as it hands them to the API. A bearer that is not a key is one of two forms of token. An access token holds `aud`, and is what an agent or an agent gateway, a third party acting for a person, obtains through the authorization flow: it is taken for its audience, with the permissions among its scopes. A session holds no `aud`, and is what the identity service gives a page a person signs in to, such as the studio, Auto's own app: it is taken when its `azp` claim names an origin a page may come from and the request that presents it comes from that origin, so it serves its own page and never an agent, and gives every permission in its organization. `pageOriginsOf` of `src/access/page-origins.ts` is the one definition of those origins: the Studio host's, then the runtime's own origin where the runtime serves Studio, and `ALLOWED_ORIGINS`. The identity service gives it `PUBLIC_URL` as the runtime's own origin, and the API's `Origin` check and its CORS, which read it as well, give it the origin the API answers at. `src/access/studio.ts` holds the Studio settings, `StudioSettings`: `origin`, the Studio host, whose default is `defaultStudioOrigin`, `https://studio.on.auto`, and `served`, whether the runtime serves Studio itself at `servedStudioPath`, `/studio/`, since a page on a public origin may not reach an address on the person's machine. `studioBaseOf` is the one base of Studio's address that the runtime writes into a link: `{own origin}/studio/` where it serves Studio, and the Studio host's root otherwise. `signInScopes` are the scopes the runtime asks a client to request of the identity service: `user:org:read`, which binds the access token to the organization the person signs in to, as the `org_id` claim, and the four permissions, `allPermissions` of `@on.auto/operations`, the one definition of them, which the identity service defines as scopes of its own, each with a sentence its consent screen shows. At start it takes the issuer's key set, the signing keys the tokens are verified with, from an `IssuerKeySource` of `src/identity-service/issuer-keys.ts`, the `issuerKeys` of its settings, which the server composes from its own: `discoveredIssuerKeys`, the default, reads the issuer's metadata, from the addresses the MCP authorization specification orders, and the key set at the `jwks_uri` it names; `issuerKeysAt(url)` reads the key set at a URL the operator gives, and no metadata, for an issuer that publishes its keys another way; and `issuerKeysGiven(keySet)` takes a key set the operator gives, of `IssuerKeySetSchema`, a `keys` array of at least one key, each naming its `kty` and kept with every member it holds, and reads nothing. Each read is within 64 KiB and 10 seconds and follows no redirect (`src/identity-service/issuer-requests.ts`), and the identity service stops with `UnusableIdentityServiceError` naming the service when one cannot be used or its key set holds no keys. -The [`jose`](https://github.com/panva/jose) library verifies the tokens and keeps the key set: `decodeJwt` reads, before anything is verified, whether the token holds `aud`, which decides its form, and a token it cannot decode is judged an access token, so its refusal is the one verification gives; `jwtVerify` then checks the signature with the key of the token's `kid`, or, for a token without one, with the issuer's only key of its type, refusing it when several fit, the algorithm among `RS256`, `PS256`, `ES256` and `EdDSA`, the issuer, the audience of an access token, `exp` and `nbf` with 60 seconds of leeway and that `exp` and `sub` are present; `createRemoteJWKSet` holds the service's key set, read through the bounded read above, read again for an unknown `kid` once 60 seconds have passed since the last read that succeeded, and at the first token after it is ten minutes old, `jose`'s default age, set in `src/identity-service/issuer-keys.ts`; a token whose key set cannot be read once it is ten minutes old is refused, never verified against the old one. The runtime checks itself what is its own rule (`src/tokens/token-verification.ts`): a token at most 8 KiB before anything is decoded, the org claim of its form present unless it holds neither, refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim` or `on its o claim` otherwise, as `jose` names a claim it refuses, a `typ` of `at+jwt`, `application/at+jwt`, `JWT` or `application/jwt` or none, an `iat` at most 60 seconds ahead, a `sub` that is a non-empty string of at most 256 bytes without control characters, unpaired surrogates or noncharacters, an org, an access token's `org_id`, the `id` of a session's `o`, or the `sub` of a token that holds neither, that is an org id other than `local`, a `scope` claim, where an access token holds one, that is a string, and a session's `azp` among the origins of `pageOriginsOf`, refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its azp claim` otherwise, and then the `Origin` of the request exactly its `azp`, refused `the session was not presented by the page it was issued to` otherwise, a request without `Origin` among them; `identityServiceOf` names the audiences a request may hold, `PUBLIC_URL`, with a final `/` or not, and on an MCP endpoint that endpoint's URL. A token that holds both `aud` and `azp` is an access token, and its `azp` is not read. Each form names its org in a claim of its own, and `orgClaimPaths` of `src/tokens/token-verification.ts` is the one definition of both, since the service's metadata advertises no claim name: an access token names it as `org_id`, which the service puts in it when the person grants `user:org:read`, and a session as the `id` of its `o`, since version 2 of the service's session token, marked `v: 2`, holds the organization the person signed in to as the object `o`, with its slug, `slg`, the person's role, `rol`, their permissions, `per`, and the feature-permission map, `fpm`, beside its `id`. A session without `o` that holds an `org_id` is refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its o claim`, as an access token without `org_id` that holds an `o` is refused `on its org_id claim`, and a session whose `o` is no object or holds no `id` that is an org id other than `local` is refused `its o.id claim is not an org id other than local`; nothing else of `o` is read. A token that holds neither `org_id` nor `o` names no org, as the token of a person signed in with no organization of their own does, and acts in the org whose id is its `sub`, `subjectAsOrg` of `src/tokens/token-verification.ts`, refused `its sub claim is not an org id other than local` where the `sub` is not one; the person is then a caller of that org as of any other. `v` is not checked, since a format that named the organization elsewhere would hold no `o` and act in the org of the person's own `sub`, never in another. The tests of `src/identity-service/identity-service.test.ts` pin the claim of an access token and those of `src/identity-service/sessions.test.ts` the claim of a session. The person is `person:`, which `isSignedInPerson` tells apart from a key's id and from `local`, since neither holds a colon, in that org alone, over every playbook, with `allPermissions` for a session, whose `scope` is not read, and for an access token the permissions among the scopes its `scope` claim names, split at each space, in the order of `allPermissions`; any other scope, `user:org:read` among them, grants none, so an access token that grants none of the four, or holds no `scope`, is a person of the org with no permission (`signedInPersonOf` of `src/access/principal.ts`). No org is taken from anything else the token holds, the `org_id` of a session and the `o` of an access token among it, which only refuse a token that holds them without the claim of its form, and `user:org:read` is not checked: `org_id` is. The security guide of the documentation sets out every rule. +The [`jose`](https://github.com/panva/jose) library verifies the tokens and keeps the key set: `decodeJwt` reads, before anything is verified, whether the token holds `aud`, which decides its form, and a token it cannot decode is judged an access token, so its refusal is the one verification gives; `jwtVerify` then checks the signature with the key of the token's `kid`, or, for a token without one, with the issuer's only key of its type, refusing it when several fit, the algorithm among `RS256`, `PS256`, `ES256` and `EdDSA`, the issuer, the audience of an access token, `exp` and `nbf` with 60 seconds of leeway and that `exp` and `sub` are present; `createRemoteJWKSet` holds a key set read at a URL, the `jwks_uri` of the metadata or the URL the operator gives, read through the bounded read above, read again for an unknown `kid` once 60 seconds have passed since the last read that succeeded, and at the first token after it is ten minutes old, `jose`'s default age, set in `src/identity-service/issuer-keys.ts`; a token whose key set cannot be read once it is ten minutes old is refused, never verified against the old one; `createLocalJWKSet` holds a key set given, which is read from nowhere and trusted until the server restarts. The runtime checks itself what is its own rule (`src/tokens/token-verification.ts`): a token at most 8 KiB before anything is decoded, the org claim of its form present unless it holds neither, refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its org_id claim` or `on its o claim` otherwise, as `jose` names a claim it refuses, a `typ` of `at+jwt`, `application/at+jwt`, `JWT` or `application/jwt` or none, an `iat` at most 60 seconds ahead, a `sub` that is a non-empty string of at most 256 bytes without control characters, unpaired surrogates or noncharacters, an org, an access token's `org_id`, the `id` of a session's `o`, or the `sub` of a token that holds neither, that is an org id other than `local`, a `scope` claim, where an access token holds one, that is a string, and a session's `azp` among the origins of `pageOriginsOf`, refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its azp claim` otherwise, and then the `Origin` of the request exactly its `azp`, refused `the session was not presented by the page it was issued to` otherwise, a request without `Origin` among them; `identityServiceOf` names the audiences a request may hold, `PUBLIC_URL`, with a final `/` or not, and on an MCP endpoint that endpoint's URL. A token that holds both `aud` and `azp` is an access token, and its `azp` is not read. Each form names its org in a claim of its own, and `orgClaimPaths` of `src/tokens/token-verification.ts` is the one definition of both, since the service's metadata advertises no claim name: an access token names it as `org_id`, which the service puts in it when the person grants `user:org:read`, and a session as the `id` of its `o`, since version 2 of the service's session token, marked `v: 2`, holds the organization the person signed in to as the object `o`, with its slug, `slg`, the person's role, `rol`, their permissions, `per`, and the feature-permission map, `fpm`, beside its `id`. A session without `o` that holds an `org_id` is refused `ERR_JWT_CLAIM_VALIDATION_FAILED on its o claim`, as an access token without `org_id` that holds an `o` is refused `on its org_id claim`, and a session whose `o` is no object or holds no `id` that is an org id other than `local` is refused `its o.id claim is not an org id other than local`; nothing else of `o` is read. A token that holds neither `org_id` nor `o` names no org, as the token of a person signed in with no organization of their own does, and acts in the org whose id is its `sub`, `subjectAsOrg` of `src/tokens/token-verification.ts`, refused `its sub claim is not an org id other than local` where the `sub` is not one; the person is then a caller of that org as of any other. `v` is not checked, since a format that named the organization elsewhere would hold no `o` and act in the org of the person's own `sub`, never in another. The tests of `src/identity-service/identity-service.test.ts` pin the claim of an access token and those of `src/identity-service/sessions.test.ts` the claim of a session. The person is `person:`, which `isSignedInPerson` tells apart from a key's id and from `local`, since neither holds a colon, in that org alone, over every playbook, with `allPermissions` for a session, whose `scope` is not read, and for an access token the permissions among the scopes its `scope` claim names, split at each space, in the order of `allPermissions`; any other scope, `user:org:read` among them, grants none, so an access token that grants none of the four, or holds no `scope`, is a person of the org with no permission (`signedInPersonOf` of `src/access/principal.ts`). No org is taken from anything else the token holds, the `org_id` of a session and the `o` of an access token among it, which only refuse a token that holds them without the claim of its form, and `user:org:read` is not checked: `org_id` is. The security guide of the documentation sets out every rule. `fakeIssuer` of `@on.auto/identity/testing` is an issuer for tests: it answers its metadata, its key set and a token endpoint for the client credentials grant, whose token's `scope` claim holds the scopes the request asked for, signs a token with `jose` and a key of each algorithm, issues the session of a page, `session(page, claims)`, in the shape of version 2 of the service's session token: `iss`, `sub`, a `sid`, the page as `azp`, `iat`, `nbf`, an `exp` a minute later, `v: 2` and as `o` the organization `acme` with its `id`, `slg`, `rol`, `per` and `fpm`, under the `typ` `JWT` and with no `aud`, each claim a test gives taking the place of its own, `o` whole, so `{ o: undefined }` issues a session without one, and turns its keys over; `signedTokens` and `principalIn` help tests sign a token, which holds every sign-in scope unless a test gives it a `scope` of its own, or a session, against an identity service that allows `testAllowedOrigin` beside the Studio host, `hostedStudio`, or beside the Studio host and `testPublicUrl` when given `servedStudio`, and read the person it admits. diff --git a/packages/identity/src/identity-service/identity-service.ts b/packages/identity/src/identity-service/identity-service.ts index 25f7b7edf..cefb2847d 100644 --- a/packages/identity/src/identity-service/identity-service.ts +++ b/packages/identity/src/identity-service/identity-service.ts @@ -4,7 +4,7 @@ import { Result } from 'effect'; import { pageOriginsOf, type PageSettings } from '../access/page-origins.ts'; import { signedInPersonOf, type Authentication } from '../access/principal.ts'; import { verifiedPerson } from '../tokens/token-verification.ts'; -import { keysOfIssuer } from './issuer-keys.ts'; +import { discoveredIssuerKeys, type IssuerKeySource } from './issuer-keys.ts'; import { issuerRequestsOverTheNetwork, type IssuerRequests } from './issuer-requests.ts'; import { UnusableIdentityServiceError } from './unusable-identity-service-error.ts'; @@ -29,17 +29,19 @@ export interface SignInSettings { readonly publicUrl: string; } -export interface IdentityServiceSettings extends SignInSettings, PageSettings {} +export interface IdentityServiceSettings extends SignInSettings, PageSettings { + readonly issuerKeys?: IssuerKeySource | undefined; +} function audiencesOf(publicUrl: string, resourcePath: string): readonly string[] { return [publicUrl, `${publicUrl}/`, ...(resourcePath === '' ? [] : [`${publicUrl}${resourcePath}`])]; } export async function identityServiceOf( - { issuer, publicUrl, studio, allowedOrigins }: IdentityServiceSettings, + { issuer, publicUrl, studio, allowedOrigins, issuerKeys = discoveredIssuerKeys }: IdentityServiceSettings, requests: IssuerRequests = issuerRequestsOverTheNetwork, ): Promise { - const keys = await keysOfIssuer(issuer, requests); + const keys = await issuerKeys.keysOf(requests, issuer); if (Result.isFailure(keys)) { throw new UnusableIdentityServiceError({ message: `The identity service at ${issuer} cannot be used: ${keys.failure}`, diff --git a/packages/identity/src/identity-service/issuer-key-sources.test.ts b/packages/identity/src/identity-service/issuer-key-sources.test.ts new file mode 100644 index 000000000..d91ab57de --- /dev/null +++ b/packages/identity/src/identity-service/issuer-key-sources.test.ts @@ -0,0 +1,168 @@ +import { Schema } from 'effect'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { + identityServiceOf, + issuerKeysAt, + issuerKeysGiven, + IssuerKeySetSchema, + type IdentityService, + type IssuerFetch, + type IssuerKeySet, + type IssuerKeySource, +} from '../index.ts'; +import { fakeIssuer, hostedStudio, principalIn, testPublicUrl, type FakeIssuer } from '../testing/index.ts'; + +const startedAt = Date.parse('2026-10-13T09:00:00Z'); + +beforeEach(() => { + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(startedAt); +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +function after(milliseconds: number): void { + vi.setSystemTime(startedAt + milliseconds); +} + +interface Asking { + readonly fetch: IssuerFetch; + readonly asked: () => readonly string[]; +} + +function asking(answer: IssuerFetch): Asking { + const asked: string[] = []; + return { + fetch: (request) => { + asked.push(request.url); + return answer(request); + }, + asked: () => [...asked], + }; +} + +const unreachable: IssuerFetch = () => Promise.reject(new TypeError('fetch failed')); + +const answeringNoKeys: IssuerFetch = () => Promise.resolve(Response.json({ keys: [] })); + +const decodeKeySet = Schema.decodeUnknownSync(IssuerKeySetSchema); + +async function keySetOf(fake: FakeIssuer): Promise { + const answer = await fake.answer(new Request(`${fake.issuer}/jwks`)); + return decodeKeySet(await answer.json()); +} + +function serviceOf(fake: FakeIssuer, fetch: IssuerFetch, issuerKeys?: IssuerKeySource): Promise { + return identityServiceOf( + { + issuer: fake.issuer, + publicUrl: testPublicUrl, + studio: hostedStudio, + allowedOrigins: [], + ...(issuerKeys === undefined ? {} : { issuerKeys }), + }, + { fetch, now: Date.now }, + ); +} + +function signedBy(fake: FakeIssuer): Promise { + const seconds = Math.floor(startedAt / 1000); + return fake.sign({ iss: fake.issuer, sub: 'ada', aud: testPublicUrl, exp: seconds + 3600, org_id: 'acme' }); +} + +async function callerOf(identityService: IdentityService, token: string): Promise { + return principalIn(await identityService.personOf(token, '/mcp'))?.callerIn('acme')?.id; +} + +describe('the key set of an issuer when none is given', () => { + it('is found through the metadata of the issuer, which names where it is read', async () => { + const fake = fakeIssuer('https://login.example'); + const network = asking(fake.answer); + const identityService = await serviceOf(fake, network.fetch); + + expect(await callerOf(identityService, await signedBy(fake))).toBe('person:ada'); + expect(network.asked()).toEqual([ + 'https://login.example/.well-known/oauth-authorization-server', + 'https://login.example/jwks', + ]); + }); +}); + +describe('the key set at a URL', () => { + it('is read once at start, from that URL alone, and kept for ten minutes, after which it is read again', async () => { + const fake = fakeIssuer('https://login.example'); + const network = asking(fake.answer); + const identityService = await serviceOf(fake, network.fetch, issuerKeysAt('https://login.example/jwks')); + const signed = await signedBy(fake); + const atStart = await callerOf(identityService, signed); + after(599_999); + const beforeTheAge = await callerOf(identityService, signed); + const readBeforeTheAge = network.asked(); + after(600_001); + + expect([atStart, beforeTheAge, await callerOf(identityService, signed)]).toEqual([ + 'person:ada', + 'person:ada', + 'person:ada', + ]); + expect([readBeforeTheAge, network.asked()]).toEqual([ + ['https://login.example/jwks'], + ['https://login.example/jwks', 'https://login.example/jwks'], + ]); + }); + + it('stops the server at start, naming the service and the URL, when the key set there holds no keys', async () => { + const fake = fakeIssuer('https://login.example'); + + await expect(serviceOf(fake, answeringNoKeys, issuerKeysAt('https://keys.example/jwks'))).rejects.toMatchObject({ + name: 'UnusableIdentityServiceError', + message: + 'The identity service at https://login.example cannot be used: its key set at https://keys.example/jwks holds no keys', + }); + }); +}); + +describe('the key set given', () => { + it('verifies a token signed with one of its keys, and reads nothing of the issuer, at start or ten minutes on', async () => { + const fake = fakeIssuer('https://login.example'); + const network = asking(unreachable); + const identityService = await serviceOf(fake, network.fetch, issuerKeysGiven(await keySetOf(fake))); + const signed = await signedBy(fake); + const atStart = await callerOf(identityService, signed); + after(600_001); + + expect([atStart, await callerOf(identityService, signed)]).toEqual(['person:ada', 'person:ada']); + expect(network.asked()).toEqual([]); + }); + + it('refuses a token signed with a key it does not hold', async () => { + const fake = fakeIssuer('https://login.example'); + const identityService = await serviceOf(fake, unreachable, issuerKeysGiven(await keySetOf(fake))); + const another = fakeIssuer('https://login.example'); + + expect(await identityService.personOf(await signedBy(another), '/mcp')).toEqual({ + because: 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED', + }); + }); +}); + +const refusedKeySets: ReadonlyArray = [ + ['no keys', { keys: [] }], + ['a key without a kty', { keys: [{ kid: 'one' }] }], + ['keys that are no array', { keys: { kty: 'RSA' } }], +]; + +describe('the schema of a key set given', () => { + it('takes a set of keys that each name their kty, keeping every member of each key', () => { + const key = { kty: 'OKP', crv: 'Ed25519', x: 'AAAA', kid: 'one', use: 'sig', key_ops: ['verify'] }; + + expect(decodeKeySet({ keys: [key] })).toEqual({ keys: [key] }); + }); + + it.each(refusedKeySets)('refuses a set with %s', (_case, keySet) => { + expect(Schema.is(IssuerKeySetSchema)(keySet)).toBe(false); + }); +}); diff --git a/packages/identity/src/identity-service/issuer-keys.ts b/packages/identity/src/identity-service/issuer-keys.ts index f8d471be5..6b326969d 100644 --- a/packages/identity/src/identity-service/issuer-keys.ts +++ b/packages/identity/src/identity-service/issuer-keys.ts @@ -1,5 +1,5 @@ -import { Result } from 'effect'; -import { createRemoteJWKSet, customFetch, type RemoteJWKSet } from 'jose'; +import { Result, Schema } from 'effect'; +import { createLocalJWKSet, createRemoteJWKSet, customFetch, type JWTVerifyGetKey } from 'jose'; import { metadataOf } from './issuer-metadata.ts'; import { boundedFetchOf, issuerAnswerMs, UnreadAnswer, type IssuerRequests } from './issuer-requests.ts'; @@ -10,7 +10,21 @@ const leastRefetchMs = 60_000; const mostKeySetAgeMs = 600_000; -async function keySetAt(jwksUri: string, requests: IssuerRequests): Promise> { +const IssuerKeySchema = Schema.StructWithRest(Schema.Struct({ kty: Schema.String }), [ + Schema.Record(Schema.String, Schema.Unknown), +]); + +export const IssuerKeySetSchema = Schema.Struct({ keys: Schema.NonEmptyArray(IssuerKeySchema) }); + +export type IssuerKeySet = typeof IssuerKeySetSchema.Type; + +type IssuerKeys = Result.Result; + +export interface IssuerKeySource { + readonly keysOf: (requests: IssuerRequests, issuer: string) => Promise; +} + +async function keySetAt(jwksUri: string, requests: IssuerRequests): Promise { const keySet = createRemoteJWKSet(new URL(jwksUri), { timeoutDuration: issuerAnswerMs, cooldownDuration: leastRefetchMs, @@ -29,10 +43,18 @@ async function keySetAt(jwksUri: string, requests: IssuerRequests): Promise> { - const metadata = await metadataOf(issuer, requests); - return Result.isFailure(metadata) ? Result.fail(metadata.failure) : keySetAt(metadata.success.jwksUri, requests); +export const discoveredIssuerKeys: IssuerKeySource = { + keysOf: async (requests, issuer) => { + const metadata = await metadataOf(issuer, requests); + return Result.isFailure(metadata) ? Result.fail(metadata.failure) : keySetAt(metadata.success.jwksUri, requests); + }, +}; + +export function issuerKeysAt(jwksUri: string): IssuerKeySource { + return { keysOf: (requests) => keySetAt(jwksUri, requests) }; +} + +export function issuerKeysGiven({ keys }: IssuerKeySet): IssuerKeySource { + const keySet = createLocalJWKSet({ keys: [...keys] }); + return { keysOf: () => Promise.resolve(Result.succeed(keySet)) }; } diff --git a/packages/identity/src/index.ts b/packages/identity/src/index.ts index 1fabda4a3..af95f4949 100644 --- a/packages/identity/src/index.ts +++ b/packages/identity/src/index.ts @@ -26,3 +26,11 @@ export { type SignInSettings, } from './identity-service/identity-service.ts'; export type { IssuerFetch, IssuerRequests } from './identity-service/issuer-requests.ts'; +export { + discoveredIssuerKeys, + issuerKeysAt, + issuerKeysGiven, + IssuerKeySetSchema, + type IssuerKeySet, + type IssuerKeySource, +} from './identity-service/issuer-keys.ts'; diff --git a/packages/server/src/lifecycle/lifecycle.ts b/packages/server/src/lifecycle/lifecycle.ts index 94d0e7ad7..ca2c1591e 100644 --- a/packages/server/src/lifecycle/lifecycle.ts +++ b/packages/server/src/lifecycle/lifecycle.ts @@ -1,13 +1,29 @@ import { readPageStyle } from '@on.auto/api'; import type { Environment } from '@on.auto/config'; -import { authenticatorFor, configuredApiKeys, identityServiceOf } from '@on.auto/identity'; +import { + authenticatorFor, + configuredApiKeys, + discoveredIssuerKeys, + identityServiceOf, + issuerKeysAt, + issuerKeysGiven, + type IssuerKeySource, +} from '@on.auto/identity'; import type { RunningServer, ServerOptions } from '../server-options/server-options.ts'; +import type { IdentityKeySet } from '../settings/access-settings.ts'; import { readShippedSettings } from '../settings/served-settings.ts'; import { readSettings } from '../settings/settings.ts'; import { startTelemetry } from '../telemetry/start-telemetry.ts'; import { started } from './server-start.ts'; +function issuerKeysOf(keySet: IdentityKeySet): IssuerKeySource { + if (keySet.from === 'given') { + return issuerKeysGiven(keySet.keySet); + } + return keySet.from === 'url' ? issuerKeysAt(keySet.url) : discoveredIssuerKeys; +} + export async function startServer(environment: Environment, options: ServerOptions): Promise { const [shipped, pageStyle] = await Promise.all([ readShippedSettings(options.shippedFiles), @@ -21,6 +37,7 @@ export async function startServer(environment: Environment, options: ServerOp ...settings.signIn, studio: settings.studio, allowedOrigins: settings.allowedOrigins, + issuerKeys: issuerKeysOf(settings.identityKeySet), }); const authenticator = authenticatorFor({ ...settings, diff --git a/packages/server/src/settings/access-settings.test.ts b/packages/server/src/settings/access-settings.test.ts index 8f38b66b7..bedb4db33 100644 --- a/packages/server/src/settings/access-settings.test.ts +++ b/packages/server/src/settings/access-settings.test.ts @@ -99,3 +99,54 @@ describe('readSettings rejects invalid credential settings', () => { }, ); }); + +const keySet = { + keys: [{ kty: 'OKP', crv: 'Ed25519', x: '11qYAYKxCrfVS_7TyWQHOg7hcvPapiMlrwIaaPcHURo', kid: 'one', use: 'sig' }], +}; + +describe('readSettings given the key set of the identity service', () => { + it('finds it through the metadata of the issuer when neither IDENTITY_JWKS nor IDENTITY_JWKS_URL is set', () => { + expect(readSettingsOf({ PUBLIC_URL: 'https://runtime.example.com' }).identityKeySet).toEqual({ from: 'discovery' }); + }); + + it('takes the key set IDENTITY_JWKS gives, as the JSON of a key set', () => { + expect(readSettingsOf({ IDENTITY_JWKS: JSON.stringify(keySet) }).identityKeySet).toEqual({ + from: 'given', + keySet, + }); + }); + + it('reads the key set at the https URL IDENTITY_JWKS_URL names', () => { + expect(readSettingsOf({ IDENTITY_JWKS_URL: 'https://identity.example/keys/jwks.json' }).identityKeySet).toEqual({ + from: 'url', + url: 'https://identity.example/keys/jwks.json', + }); + }); + + it('refuses both, naming the two settings', () => { + expect( + String(errorFrom({ IDENTITY_JWKS: JSON.stringify(keySet), IDENTITY_JWKS_URL: 'https://identity.example/jwks' })), + ).toBe('InvalidSettingsError: IDENTITY_JWKS: Set one of IDENTITY_JWKS and IDENTITY_JWKS_URL, not more'); + }); + + it.each([ + ['text that is not JSON', '{keys:[]}', 'Expected a valid JSON string\n at ["IDENTITY_JWKS"]'], + ['a set of no keys', '{"keys":[]}', 'Missing key\n at ["IDENTITY_JWKS"]["keys"][0]'], + ['a key without a kty', '{"keys":[{"kid":"one"}]}', 'Missing key\n at ["IDENTITY_JWKS"]["keys"][0]["kty"]'], + ])('refuses as IDENTITY_JWKS %s, naming the setting and where in it', (_case, value, words) => { + expect(String(errorFrom({ IDENTITY_JWKS: value }))).toBe(`InvalidSettingsError: SchemaError(${words})`); + }); + + it('takes an empty IDENTITY_JWKS as not set', () => { + expect(readSettingsOf({ IDENTITY_JWKS: '' }).identityKeySet).toEqual({ from: 'discovery' }); + }); + + it.each(['http://identity.example/jwks', 'identity.example/jwks', 'ftp://identity.example/jwks'])( + 'refuses IDENTITY_JWKS_URL="%s", which is not an https URL, naming the setting', + (url) => { + expect(String(errorFrom({ IDENTITY_JWKS_URL: url }))).toBe( + 'InvalidSettingsError: SchemaError(Expected the https URL of the key set of the identity service, such as https://identity.example.com/.well-known/jwks.json\n at ["IDENTITY_JWKS_URL"])', + ); + }, + ); +}); diff --git a/packages/server/src/settings/access-settings.ts b/packages/server/src/settings/access-settings.ts index 6947a4995..959671920 100644 --- a/packages/server/src/settings/access-settings.ts +++ b/packages/server/src/settings/access-settings.ts @@ -1,20 +1,22 @@ import type { Environment } from '@on.auto/config'; import { autoIdentityService, + IssuerKeySetSchema, listensOnlyOnLoopback, readApiKeys, type ApiKey, type DeclaredAccessMode, + type IssuerKeySet, type SignInSettings, type StudioSettings, } from '@on.auto/identity'; import { alternatives, loopbackNames, namesTheLoopback } from '@on.auto/operations'; -import { Config, ConfigProvider, Effect, Option } from 'effect'; +import { Config, ConfigProvider, Effect, Option, Schema } from 'effect'; import type { LogFormat, LogLevelSetting } from '../logging/log-formats.ts'; import { readStudioSettings } from '../studio/studio-settings.ts'; import { asInvalidSettings, InvalidSettingsError } from './invalid-settings-error.ts'; -import { httpOffTheLoopbackIn, IssuerOrigin, Origin, PublicOrigin } from './origin.ts'; +import { httpOffTheLoopbackIn, IssuerOrigin, KeySetUrl, Origin, PublicOrigin } from './origin.ts'; export const accessVariables: readonly string[] = [ 'ALLOWED_ORIGINS', @@ -23,6 +25,8 @@ export const accessVariables: readonly string[] = [ 'PUBLIC_URL', 'ACCESS_MODE', 'IDENTITY_ISSUER', + 'IDENTITY_JWKS', + 'IDENTITY_JWKS_URL', ]; export interface AccessSettings { @@ -41,9 +45,15 @@ export function readAccessSettings(environment: Environment): Effect.Effect + URL.parse(text)?.protocol === 'https:' || + 'Expected the https URL of the key set of the identity service, such as https://identity.example.com/.well-known/jwks.json', + ), +); + function aWebOriginSuchAs(example: string) { return Schema.makeFilter( (text: string) => diff --git a/packages/server/src/settings/settings.test.ts b/packages/server/src/settings/settings.test.ts index 7532d35aa..c6479f58a 100644 --- a/packages/server/src/settings/settings.test.ts +++ b/packages/server/src/settings/settings.test.ts @@ -53,6 +53,7 @@ describe('readSettings', () => { allowedOrigins: [], apiKeys: undefined, identityIssuer: 'https://clerk.on.auto', + identityKeySet: { from: 'discovery' }, publicUrl: undefined, accessMode: undefined, signIn: undefined, @@ -91,6 +92,7 @@ const everySetting = { ALLOWED_ORIGINS: 'https://app.example.com,http://localhost:5173,http://[::1]:3000', API_KEYS: JSON.stringify([entry]), IDENTITY_ISSUER: 'https://identity.example', + IDENTITY_JWKS_URL: 'https://identity.example/jwks', PUBLIC_URL: 'https://runtime.example.com', LEDGER_FILE: '/data/ledger.db', CONTENT_STORE_URL: 's3://playbook-content/production', @@ -117,6 +119,7 @@ const everySettingRead = { allowedOrigins: ['https://app.example.com', 'http://localhost:5173', 'http://[::1]:3000'], apiKeys: [entry], identityIssuer: 'https://identity.example', + identityKeySet: { from: 'url', url: 'https://identity.example/jwks' }, signIn: { issuer: 'https://identity.example', publicUrl: 'https://runtime.example.com' }, ledger: { store: 'sqlite', file: '/data/ledger.db' }, secrets: aKeyFileAt('/data/secrets.key'), diff --git a/packages/server/src/sign-in/issuer-over-https.ts b/packages/server/src/sign-in/issuer-over-https.ts index 8f2c166a9..e532793ac 100644 --- a/packages/server/src/sign-in/issuer-over-https.ts +++ b/packages/server/src/sign-in/issuer-over-https.ts @@ -2,7 +2,7 @@ import { once } from 'node:events'; import type { IncomingMessage, ServerResponse } from 'node:http'; import { createServer } from 'node:https'; -import { fakeIssuer, type FakeIssuer } from '@on.auto/identity/testing'; +import { fakeIssuer, type FakeIssuer, type FakeIssuerOptions } from '@on.auto/identity/testing'; import { Schema } from 'effect'; import { onTestFinished } from 'vitest'; @@ -34,7 +34,7 @@ export function accessTokenOf(issuer: FakeIssuer, audience: string, scope: strin }); } -export async function issuerOverHttps(): Promise { +export async function issuerOverHttps(options: FakeIssuerOptions = {}): Promise { const certificate = aCertificateOfTheLoopback(); trustedForTheTest(certificate); const issuer = Promise.withResolvers(); @@ -47,6 +47,6 @@ export async function issuerOverHttps(): Promise { server.close(); server.closeAllConnections(); }); - issuer.resolve(fakeIssuer(`https://127.0.0.1:${portOf(server.address()).port}`)); + issuer.resolve(fakeIssuer(`https://127.0.0.1:${portOf(server.address()).port}`, options)); return issuer.promise; } diff --git a/packages/server/src/sign-in/sign-in-with-public-url.test.ts b/packages/server/src/sign-in/sign-in-with-public-url.test.ts index 84dabb422..0d4009d57 100644 --- a/packages/server/src/sign-in/sign-in-with-public-url.test.ts +++ b/packages/server/src/sign-in/sign-in-with-public-url.test.ts @@ -81,3 +81,34 @@ describe('a runtime on its own loopback address', () => { ); }); }); + +const everySignInScope = signInScopes.join(' '); + +describe('a runtime given the key set of its identity service', () => { + it('reads nothing of the service at start with IDENTITY_JWKS, and admits a token signed with a key of the set', async () => { + const issuer = await issuerOverHttps(); + const keySet = await (await issuer.answer(new Request(`${issuer.issuer}/jwks`))).text(); + const port = await aPortNothingListensOn(); + const ownAddress = `http://localhost:${port}`; + + await startedOn(port, { IDENTITY_ISSUER: issuer.issuer, IDENTITY_JWKS: keySet, PUBLIC_URL: ownAddress }); + + expect(await statusOfPlaybooks(port, await accessTokenOf(issuer, ownAddress, everySignInScope))).toBe(200); + expect(issuer.keySetReads()).toBe(1); + }); + + it('reads the key set at IDENTITY_JWKS_URL, and not the metadata, which names another', async () => { + const issuer = await issuerOverHttps({ metadata: { jwks_uri: 'http://127.0.0.1/jwks' } }); + const port = await aPortNothingListensOn(); + const ownAddress = `http://localhost:${port}`; + + await startedOn(port, { + IDENTITY_ISSUER: issuer.issuer, + IDENTITY_JWKS_URL: `${issuer.issuer}/jwks`, + PUBLIC_URL: ownAddress, + }); + + expect(await statusOfPlaybooks(port, await accessTokenOf(issuer, ownAddress, everySignInScope))).toBe(200); + expect(issuer.keySetReads()).toBe(1); + }); +});