diff --git a/charts/relayauth/templates/deployment.yaml b/charts/relayauth/templates/deployment.yaml index 875cdcc0..ae50aa37 100644 --- a/charts/relayauth/templates/deployment.yaml +++ b/charts/relayauth/templates/deployment.yaml @@ -64,6 +64,18 @@ spec: - name: RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN value: "true" {{- end }} + {{- if (.Values.sessionDirectory).issuance }} + - name: RELAYAUTH_SESSION_DIRECTORY_ISSUANCE + value: "true" + {{- end }} + {{- with (.Values.sessionDirectory).issuer }} + - name: RELAYAUTH_SESSION_DIRECTORY_ISSUER + value: {{ . | quote }} + {{- end }} + {{- with (.Values.sessionDirectory).subjectAudiences }} + - name: RELAYAUTH_SESSION_DIRECTORY_SUBJECT_AUDIENCES + value: {{ . | quote }} + {{- end }} {{- with .Values.allowedOrigins }} - name: ALLOWED_ORIGINS value: {{ . | quote }} @@ -84,6 +96,22 @@ spec: - name: RELAYAUTH_SIGNING_KEY_PEM_PREVIOUS_FILE value: /run/secrets/relayauth/{{ . }} {{- end }} + {{- with $keys.sessionDirectorySigningKey }} + - name: RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_FILE + value: /run/secrets/relayauth/{{ . }} + {{- end }} + {{- with $keys.sessionDirectorySigningKeyNext }} + - name: RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_NEXT_FILE + value: /run/secrets/relayauth/{{ . }} + {{- end }} + {{- with $keys.sessionDirectorySigningKeyPrevious }} + - name: RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_PREVIOUS_FILE + value: /run/secrets/relayauth/{{ . }} + {{- end }} + {{- with $keys.sessionDirectoryBootstrapApiKey }} + - name: RELAYAUTH_SESSION_DIRECTORY_BOOTSTRAP_API_KEY_FILE + value: /run/secrets/relayauth/{{ . }} + {{- end }} {{- with $keys.bootstrapApiKey }} - name: RELAYAUTH_BOOTSTRAP_API_KEY_FILE value: /run/secrets/relayauth/{{ . }} diff --git a/charts/relayauth/values.yaml b/charts/relayauth/values.yaml index b7689a07..287d9cbd 100644 --- a/charts/relayauth/values.yaml +++ b/charts/relayauth/values.yaml @@ -36,6 +36,15 @@ relayhistory: # https. Sets RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN=true. allowPrivateHttpOrigin: false +# Second issuer for session directory search tokens. Off by default. Enabling it +# needs an issuer that differs from the general one and +# secretKeys.sessionDirectorySigningKey, or RelayAuth refuses to start. +sessionDirectory: + issuance: false + issuer: "" + # Comma-separated subject token audiences. Empty means "relayhistory". + subjectAudiences: "" + # Pre-created Secret holding keys/secrets (never rendered by this chart). # Each entry under secretKeys names a key inside that Secret; set an optional # entry to "" to leave it unconfigured. See docs/self-hosting.md. @@ -49,6 +58,12 @@ secretKeys: signingKeyNext: "" signingKeyPrevious: "" bootstrapApiKey: bootstrap-api-key + # Session directory search issuer: a dedicated key, never a general one. + sessionDirectorySigningKey: "" + sessionDirectorySigningKeyNext: "" + sessionDirectorySigningKeyPrevious: "" + # The search minting API key, created at boot with only that scope. + sessionDirectoryBootstrapApiKey: "" # Extra plain environment variables, e.g. rate-limit tuning. extraEnv: [] diff --git a/docs/self-hosting.md b/docs/self-hosting.md index dff37d59..a726f289 100644 --- a/docs/self-hosting.md +++ b/docs/self-hosting.md @@ -85,6 +85,41 @@ before it becomes the `aud`. `https` rules are unchanged. The flag widens only which http hosts are accepted. It does not make a public http origin acceptable, so turn it on only where History is reached over a trusted private network. +## Session directory search issuer + +RelayAuth can mint 60-second tokens for one PostgREST RPC, +`sessions.search_session_directory`, under a second issuer with its own key. The +design is in 96BullSessions `docs/session-directory-tenant-issuer.md`. It is off +by default. + +| Setting | Meaning | +| --- | --- | +| `RELAYAUTH_SESSION_DIRECTORY_ISSUANCE` | Exactly `true` enables minting. | +| `RELAYAUTH_SESSION_DIRECTORY_ISSUER` | The `iss` of these tokens. Must differ from `RELAYAUTH_ISSUER`. | +| `RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM[_FILE]` | PKCS#8 RSA key. Must not be a general signing key. | +| `RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_NEXT[_FILE]`, `_PREVIOUS[_FILE]` | Verify-only rotation slots, as for the general key. | +| `RELAYAUTH_SESSION_DIRECTORY_BOOTSTRAP_API_KEY[_FILE]` | Value of the minting API key, created at boot with only the mint scope. Same rules as the bootstrap key above. | +| `RELAYAUTH_SESSION_DIRECTORY_SUBJECT_AUDIENCES` | Accepted subject token audiences, comma-separated. Defaults to `relayhistory`. | + +RelayAuth refuses to start if the issuer or any key overlaps the general issuer, +or if issuance is on without an issuer and key. + +`POST /v1/tokens/session-directory-search` takes `x-api-key` with a key whose +only scope is `relayauth:assertion:create:session-directory-search`. Like every +`relayauth:assertion:*` scope, it can only be granted by a caller holding that +exact scope, so an org admin cannot create such a key; the first one comes from +the bootstrap setting above. The body is +`{ "subjectToken": "...", "workspaceId": "..." }`. The org and workspace come +from the verified subject token, which must be an unrevoked access token with +`rth:read`, an accepted audience and a workspace. There is no refresh token. + +Keys are published at `/.well-known/session-directory/jwks.json`. PostgREST reads +its keys from `jwt-secret`, not a URL, so a rotation must update that file and +reload PostgREST config between each step. + +The API key and subject token travel in the request. Do not call this endpoint +over a plain-http in-cluster Service until RelayAuth has TLS. + ## Docker Release images are published for Linux amd64 and arm64 at diff --git a/packages/server/src/__tests__/session-directory-issuer.test.ts b/packages/server/src/__tests__/session-directory-issuer.test.ts new file mode 100644 index 00000000..bdb8fcdd --- /dev/null +++ b/packages/server/src/__tests__/session-directory-issuer.test.ts @@ -0,0 +1,393 @@ +import assert from "node:assert/strict"; +import { generateKeyPairSync, createPublicKey, verify as verifySignature } from "node:crypto"; +import test from "node:test"; +import type { RelayAuthTokenClaims } from "@relayauth/types"; +import { hashApiKey } from "../lib/api-keys.js"; +import { + ensureBootstrapApiKey, + SESSION_DIRECTORY_BOOTSTRAP_API_KEY_NAME, +} from "../lib/bootstrap-api-key.js"; +import { SigningKeyConfigError } from "../lib/signing-keys.js"; +import { + assertSessionDirectoryIssuerConfig, + normalizeTenantId, + SESSION_DIRECTORY_SEARCH_MINT_SCOPE, +} from "../lib/session-directory-issuer.js"; +import { + assertJsonResponse, + createTestApp, + createTestRequest, + generateTestToken, + seedPlainApiKey, + seedStoredIdentity, + TEST_RS256_PRIVATE_KEY_PEM, +} from "./test-helpers.js"; + +const GENERAL_ISSUER = "https://relayauth.dev"; +const SEARCH_ISSUER = "https://relayauth.dev/session-directory"; +const pem = () => { + const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 }); + return privateKey.export({ type: "pkcs8", format: "pem" }).toString(); +}; +const SEARCH_KEY = pem(); +const NEXT_KEY = pem(); +const ENABLED = { + RELAYAUTH_ISSUER: GENERAL_ISSUER, + RELAYAUTH_SESSION_DIRECTORY_ISSUANCE: "true", + RELAYAUTH_SESSION_DIRECTORY_ISSUER: SEARCH_ISSUER, + RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM: SEARCH_KEY, +}; + +type App = ReturnType; +const post = (app: App, path: string, body: unknown, headers: HeadersInit) => + app.request(createTestRequest("POST", path, body, headers), undefined, app.bindings); +const subject = (claims: Partial = {}) => + generateTestToken({ + iss: GENERAL_ISSUER, + aud: ["relayhistory"], + org: "relay_org_a", + wks: "relay_ws_a", + scopes: ["rth:read"], + sponsorId: "user_a", + ...claims, + }); +const decode = (token: string) => + JSON.parse(Buffer.from(token.split(".")[1]!, "base64url").toString("utf8")); + +async function harness(bindings: Parameters[0] = ENABLED) { + const app = createTestApp(bindings); + await seedStoredIdentity(app, { + id: "agent_operator", + name: "Operator", + type: "agent", + orgId: "org_operator", + workspaceId: "ws_operator", + sponsorId: "user_operator", + sponsorChain: ["user_operator", "agent_operator"], + scopes: [], + roles: [], + metadata: {}, + status: "active", + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as never); + // Since #119 only a plain API key holding the broker literal may delegate it, + // so the operator is a key provisioned out of band, not a bearer token. + const operatorKey = await seedPlainApiKey(app, { + orgId: "org_operator", + name: "operator", + scopes: ["relayauth:api-key:manage:*", "*", SESSION_DIRECTORY_SEARCH_MINT_SCOPE], + }); + const admin = { "x-api-key": operatorKey.key }; + const key = async (scopes: string[]) => { + const created = await assertJsonResponse<{ key: string; apiKey?: { id: string } }>( + await post(app, "/v1/api-keys", { name: "search-minter", scopes }, admin), + 201, + ); + return created.key; + }; + const mintKey = await key([SESSION_DIRECTORY_SEARCH_MINT_SCOPE]); + const exchange = (body: unknown, apiKey = mintKey) => + post(app, "/v1/tokens/session-directory-search", body, { "x-api-key": apiKey }); + return { app, admin, key, mintKey, exchange }; +} + +async function refused(response: Response, status: number, code: string) { + const body = await assertJsonResponse<{ code: string; accessToken?: string }>(response, status); + assert.equal(body.code, code); + assert.equal(body.accessToken, undefined); +} + +test("mints a 60s search token whose tenant comes only from the subject", async () => { + const { app, exchange } = await harness(); + const before = Math.floor(Date.now() / 1000); + const body = await assertJsonResponse<{ accessToken: string; tokenType: string }>( + await exchange({ subjectToken: subject(), workspaceId: "ws_a", orgId: "org_b", role: "postgres" }), + 201, + ); + const claims = decode(body.accessToken); + assert.equal(claims.iss, SEARCH_ISSUER); + assert.equal(claims.aud, "session-directory-search"); + assert.equal(claims.sub, "user_a"); + assert.equal(claims.role, "session_directory_search_worker"); + assert.deepEqual(claims.app_metadata, { org_id: "org_a", workspace_ids: ["ws_a"] }); + assert.ok(claims.iat >= before); + assert.equal(claims.nbf, claims.iat); + assert.equal(claims.exp - claims.iat, 60); + assert.equal(body.tokenType, "Bearer"); + assert.equal(Object.hasOwn(body, "refreshToken"), false); + + const jwks = await assertJsonResponse<{ keys: JsonWebKey[] }>( + await app.request("/.well-known/session-directory/jwks.json", undefined, app.bindings), + 200, + ); + const [header, payload, signature] = body.accessToken.split("."); + const { kid, alg } = JSON.parse(Buffer.from(header!, "base64url").toString()); + assert.equal(alg, "RS256"); + const jwk = jwks.keys.find((candidate) => (candidate as { kid?: string }).kid === kid); + assert.ok(jwk); + assert.equal( + verifySignature( + "RSA-SHA256", + Buffer.from(`${header}.${payload}`), + createPublicKey({ key: jwk as never, format: "jwk" }), + Buffer.from(signature!, "base64url"), + ), + true, + ); +}); + +test("mints are rate limited per subject and workspace", async () => { + const { exchange } = await harness(); + const subjectToken = subject({ sponsorId: "user_rate_limited" }); + for (let index = 0; index < 120; index += 1) { + assert.equal((await exchange({ subjectToken, workspaceId: "ws_a" })).status, 201); + } + await refused(await exchange({ subjectToken, workspaceId: "ws_a" }), 429, "rate_limited"); +}); + +test("search token never outlives its subject", async () => { + const { exchange } = await harness(); + const now = Math.floor(Date.now() / 1000); + const body = await assertJsonResponse<{ accessToken: string }>( + await exchange({ subjectToken: subject({ exp: now + 10 }), workspaceId: "ws_a" }), + 201, + ); + assert.equal(decode(body.accessToken).exp, now + 10); +}); + +test("a subject inside the verifier's expiry grace is refused, never minted with exp <= iat", async () => { + const { exchange } = await harness(); + const now = Math.floor(Date.now() / 1000); + for (const exp of [now, now - 10]) { + await refused( + await exchange({ subjectToken: subject({ iat: now - 120, exp }), workspaceId: "ws_a" }), + 401, + "subject_token_invalid", + ); + } +}); + +test("no search token is minted when revocation cannot be checked", async () => { + const { app, exchange } = await harness(); + const revocations = app.storage.revocations as { isRevoked?: unknown }; + const original = revocations.isRevoked; + revocations.isRevoked = undefined; + try { + await refused( + await exchange({ subjectToken: subject(), workspaceId: "ws_a" }), + 501, + "revocation_check_unavailable", + ); + } finally { + revocations.isRevoked = original; + } +}); + +test("issuance is off by default and with any value but true", async () => { + for (const value of [undefined, "1", "TRUE"]) { + const { exchange } = await harness({ ...ENABLED, RELAYAUTH_SESSION_DIRECTORY_ISSUANCE: value }); + await refused( + await exchange({ subjectToken: subject(), workspaceId: "ws_a" }), + 503, + "session_directory_issuance_disabled", + ); + } +}); + +test("every binding rule refuses", async () => { + const { exchange } = await harness(); + const now = Math.floor(Date.now() / 1000); + const cases: [Partial, string, number, string][] = [ + [{}, "ws_b", 403, "workspace_mismatch"], + [{ wks: "" }, "ws_a", 403, "subject_tenant_missing"], + [{ scopes: ["rth:sync"] }, "ws_a", 403, "subject_scope_missing"], + [{ scopes: ["*"] }, "ws_a", 403, "subject_scope_missing"], + [{ aud: ["relayfile"] }, "ws_a", 403, "subject_audience_refused"], + [{ token_type: "refresh" }, "ws_a", 403, "subject_not_access_token"], + [{ meta: { tokenClass: "migration_assertion" } }, "ws_a", 403, "subject_class_refused"], + [{ meta: { tokenClass: "session_directory_search" } }, "ws_a", 403, "subject_class_refused"], + [{ iss: "https://attacker.example" }, "ws_a", 401, "subject_token_invalid"], + [{ exp: now - 120 }, "ws_a", 401, "subject_token_invalid"], + ]; + for (const [claims, workspaceId, status, code] of cases) { + await refused(await exchange({ subjectToken: subject(claims), workspaceId }), status, code); + } + await refused(await exchange({ subjectToken: "not-a-jwt", workspaceId: "ws_a" }), 401, "subject_token_invalid"); + await refused(await exchange({ workspaceId: "ws_a" }), 400, "subjectToken_required"); + await refused(await exchange({ subjectToken: subject() }), 400, "workspaceId_required"); +}); + +test("a token issued by this endpoint cannot be exchanged again", async () => { + const { exchange } = await harness(); + const first = await assertJsonResponse<{ accessToken: string }>( + await exchange({ subjectToken: subject(), workspaceId: "ws_a" }), + 201, + ); + await refused(await exchange({ subjectToken: first.accessToken, workspaceId: "ws_a" }), 401, "subject_token_invalid"); +}); + +test("a revoked subject is refused", async () => { + const { app, exchange } = await harness(); + const jti = crypto.randomUUID(); + await app.storage.revocations.revoke(jti, Math.floor(Date.now() / 1000) + 600); + await refused(await exchange({ subjectToken: subject({ jti }), workspaceId: "ws_a" }), 401, "subject_token_invalid"); +}); + +test("only a key with the one literal scope can mint", async () => { + const { app, admin, key, exchange } = await harness(); + const body = { subjectToken: subject(), workspaceId: "ws_a" }; + assert.equal((await exchange(body, await key(["*"]))).status, 403); + await refused( + await exchange(body, await key([SESSION_DIRECTORY_SEARCH_MINT_SCOPE, "relayauth:token:read:*"])), + 403, + "assertion_key_required", + ); + await refused(await post(app, "/v1/tokens/session-directory-search", body, admin), 403, "assertion_key_required"); + const wrongScope = await exchange(body, await key(["relayauth:token:read:*"])); + assert.equal(wrongScope.status, 403); +}); + +test("an org admin without the literal scope cannot create a minting key", async () => { + const { app } = await harness(); + const orgAdmin = { + Authorization: `Bearer ${generateTestToken({ + iss: GENERAL_ISSUER, + sub: "agent_operator", + org: "org_operator", + wks: "ws_operator", + scopes: ["relayauth:api-key:manage:*", "*"], + })}`, + }; + await refused( + await post(app, "/v1/api-keys", { name: "stolen", scopes: [SESSION_DIRECTORY_SEARCH_MINT_SCOPE] }, orgAdmin), + 403, + "scope_escalation", + ); +}); + +test("a bearer token holding the literal scope cannot create a minting key", async () => { + const { app } = await harness(); + const bearer = { + Authorization: `Bearer ${generateTestToken({ + iss: GENERAL_ISSUER, + sub: "agent_operator", + org: "org_operator", + wks: "ws_operator", + scopes: ["relayauth:api-key:manage:*", "*", SESSION_DIRECTORY_SEARCH_MINT_SCOPE], + })}`, + }; + for (const [path, body] of [ + ["/v1/api-keys", { name: "jwt-minter", scopes: [SESSION_DIRECTORY_SEARCH_MINT_SCOPE] }], + ["/v1/tokens/workspace", { workspaceId: "ws_operator", scopes: [SESSION_DIRECTORY_SEARCH_MINT_SCOPE] }], + ] as const) { + await refused(await post(app, path, body, bearer), 403, "scope_escalation"); + } +}); + +test("a workspace token holding the literal scope cannot mint", async () => { + const { admin, app, exchange } = await harness(); + const created = await assertJsonResponse<{ token?: string; key?: string }>( + await post(app, "/v1/tokens/workspace", { + workspaceId: "ws_operator", + name: "search-minter", + scopes: [SESSION_DIRECTORY_SEARCH_MINT_SCOPE], + }, admin), + 201, + ); + const response = await exchange({ subjectToken: subject(), workspaceId: "ws_a" }, created.token ?? created.key); + assert.equal(response.status, 403); +}); + +test("an existing dedicated bootstrap key with extra scopes is reported unusable", async () => { + const app = createTestApp(ENABLED); + const value = `rak_${"x".repeat(40)}`; + await app.storage.apiKeys.create({ + orgId: "org_operator", + name: SESSION_DIRECTORY_BOOTSTRAP_API_KEY_NAME, + prefix: value.slice(0, 12), + keyHash: hashApiKey(value), + scopes: [SESSION_DIRECTORY_SEARCH_MINT_SCOPE, "relayauth:token:read:*"], + }); + const result = await ensureBootstrapApiKey(app.storage, value, { + orgId: "org_operator", + scope: SESSION_DIRECTORY_SEARCH_MINT_SCOPE, + exactScope: true, + }); + assert.equal(result.status, "scope_mismatch"); +}); + +test("boot creates the dedicated key with only the mint scope, and it can mint", async () => { + const app = createTestApp(ENABLED); + const value = `rak_${"s".repeat(40)}`; + const first = await ensureBootstrapApiKey(app.storage, value, { + orgId: "org_operator", + scope: SESSION_DIRECTORY_SEARCH_MINT_SCOPE, + name: SESSION_DIRECTORY_BOOTSTRAP_API_KEY_NAME, + }); + assert.equal(first.status, "created"); + const stored = await app.storage.apiKeys.getByHash(hashApiKey(value)); + assert.deepEqual(stored?.scopes, [SESSION_DIRECTORY_SEARCH_MINT_SCOPE]); + const response = await post( + app, + "/v1/tokens/session-directory-search", + { subjectToken: subject(), workspaceId: "ws_a" }, + { "x-api-key": value }, + ); + assert.equal(response.status, 201); + const again = await ensureBootstrapApiKey(app.storage, value, { + orgId: "org_operator", + scope: SESSION_DIRECTORY_SEARCH_MINT_SCOPE, + }); + assert.equal(again.status, "exists"); +}); + +test("tenant ids lose exactly one relay_ prefix", async () => { + assert.equal(normalizeTenantId("relay_ws_a"), "ws_a"); + assert.equal(normalizeTenantId("ws_a"), "ws_a"); + assert.equal(normalizeTenantId("relay_relay_ws_a"), "relay_ws_a"); + const { exchange } = await harness(); + for (const wks of ["relay_ws_a", "ws_a"]) { + const body = await assertJsonResponse<{ accessToken: string }>( + await exchange({ subjectToken: subject({ wks }), workspaceId: "ws_a" }), + 201, + ); + assert.deepEqual(decode(body.accessToken).app_metadata.workspace_ids, ["ws_a"]); + } + await refused( + await exchange({ subjectToken: subject({ wks: "relay_relay_ws_a" }), workspaceId: "ws_a" }), + 403, + "workspace_mismatch", + ); +}); + +test("the search JWKS publishes only its own slots, with a short cache", async () => { + const app = createTestApp({ ...ENABLED, RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_NEXT: NEXT_KEY }); + const response = await app.request("/.well-known/session-directory/jwks.json", undefined, app.bindings); + assert.equal(response.headers.get("cache-control"), "public, max-age=300"); + const search = (await response.json()) as { keys: { kid: string }[] }; + assert.equal(search.keys.length, 2); + const general = (await ( + await app.request("/.well-known/jwks.json", undefined, app.bindings) + ).json()) as { keys: { kid: string }[] }; + const generalKids = new Set(general.keys.map((key) => key.kid)); + assert.equal(search.keys.some((key) => generalKids.has(key.kid)), false); + + const off = createTestApp({}); + assert.equal((await off.request("/.well-known/session-directory/jwks.json", undefined, off.bindings)).status, 404); +}); + +test("config that overlaps the general issuer is refused", async () => { + const base = { ...ENABLED, RELAYAUTH_SIGNING_KEY_PEM: TEST_RS256_PRIVATE_KEY_PEM }; + await assertSessionDirectoryIssuerConfig(base); + await assertSessionDirectoryIssuerConfig({ RELAYAUTH_SIGNING_KEY_PEM: TEST_RS256_PRIVATE_KEY_PEM }); + for (const bad of [ + { ...base, RELAYAUTH_SESSION_DIRECTORY_ISSUER: GENERAL_ISSUER }, + { ...base, RELAYAUTH_SESSION_DIRECTORY_ISSUER: undefined }, + { ...base, RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM: undefined }, + { ...base, RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM: TEST_RS256_PRIVATE_KEY_PEM }, + { ...base, RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_PREVIOUS: TEST_RS256_PRIVATE_KEY_PEM }, + ]) { + await assert.rejects(assertSessionDirectoryIssuerConfig(bad), SigningKeyConfigError); + } +}); diff --git a/packages/server/src/__tests__/test-helpers.ts b/packages/server/src/__tests__/test-helpers.ts index 69d08012..233a2211 100644 --- a/packages/server/src/__tests__/test-helpers.ts +++ b/packages/server/src/__tests__/test-helpers.ts @@ -38,6 +38,12 @@ type TestBindings = Pick< | "RELAYAUTH_STORAGE_CAPACITY_SHED_RATIO" | "RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN" | "RELAYAUTH_ISSUER" + | "RELAYAUTH_SESSION_DIRECTORY_ISSUANCE" + | "RELAYAUTH_SESSION_DIRECTORY_ISSUER" + | "RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM" + | "RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_NEXT" + | "RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_PREVIOUS" + | "RELAYAUTH_SESSION_DIRECTORY_SUBJECT_AUDIENCES" | "RELAYAUTH_OBSERVER_ENABLED" | "BASE_URL" >; @@ -52,6 +58,12 @@ const PASS_THROUGH_TEST_BINDINGS = [ "RELAYAUTH_STORAGE_CAPACITY_SHED_RATIO", "RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN", "RELAYAUTH_ISSUER", + "RELAYAUTH_SESSION_DIRECTORY_ISSUANCE", + "RELAYAUTH_SESSION_DIRECTORY_ISSUER", + "RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM", + "RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_NEXT", + "RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_PREVIOUS", + "RELAYAUTH_SESSION_DIRECTORY_SUBJECT_AUDIENCES", "RELAYAUTH_OBSERVER_ENABLED", "BASE_URL", ] as const satisfies readonly (keyof TestBindings)[]; diff --git a/packages/server/src/env.ts b/packages/server/src/env.ts index 824ae90c..9a5fe732 100644 --- a/packages/server/src/env.ts +++ b/packages/server/src/env.ts @@ -20,6 +20,21 @@ export type AppConfig = { * the JWKS so tokens it signed keep verifying until they expire; never signs. */ RELAYAUTH_SIGNING_KEY_PEM_PREVIOUS?: string; + /** + * Exactly "true" enables `POST /v1/tokens/session-directory-search`. Off by + * default, and revocation of existing tokens is unaffected either way. + */ + RELAYAUTH_SESSION_DIRECTORY_ISSUANCE?: string; + /** `iss` of session directory search tokens. Must differ from RELAYAUTH_ISSUER. */ + RELAYAUTH_SESSION_DIRECTORY_ISSUER?: string; + /** PKCS#8 private key for that issuer. Must not be a general signing key. */ + RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM?: string; + /** Same rules as RELAYAUTH_SIGNING_KEY_PEM_NEXT, for the search issuer. */ + RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_NEXT?: string; + /** Same rules as RELAYAUTH_SIGNING_KEY_PEM_PREVIOUS, for the search issuer. */ + RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_PREVIOUS?: string; + /** Comma-separated subject token audiences accepted. Defaults to "relayhistory". */ + RELAYAUTH_SESSION_DIRECTORY_SUBJECT_AUDIENCES?: string; /** * `iss` stamped on and required of RelayAuth-issued tokens, and advertised * by discovery. Defaults to "https://relayauth.dev" for back-compat. diff --git a/packages/server/src/lib/auth.ts b/packages/server/src/lib/auth.ts index d9cc8bac..738af417 100644 --- a/packages/server/src/lib/auth.ts +++ b/packages/server/src/lib/auth.ts @@ -216,6 +216,14 @@ export async function authenticateFromContext( return auth; } +/** Whether a token verified elsewhere is revoked, directly or through its workspace token. */ +export async function isVerifiedTokenRevoked( + storage: AuthStorage, + claims: RelayAuthTokenClaims, +): Promise { + return await isBearerTokenInactive(storage, claims) || await isWorkspaceLineageRevoked(storage, claims); +} + /** * Context-aware variant of `authenticateAndAuthorize`. See * `authenticateFromContext` for rationale. diff --git a/packages/server/src/lib/bootstrap-api-key.ts b/packages/server/src/lib/bootstrap-api-key.ts index f5be15aa..4c34d149 100644 --- a/packages/server/src/lib/bootstrap-api-key.ts +++ b/packages/server/src/lib/bootstrap-api-key.ts @@ -5,6 +5,7 @@ import type { StoredApiKey } from "../storage/api-key-types.js"; export const BOOTSTRAP_API_KEY_SCOPE = "relayauth:assertion:create:relayhistory"; export const DEFAULT_BOOTSTRAP_API_KEY_ORG = "org_selfhost"; export const BOOTSTRAP_API_KEY_NAME = "selfhost-bootstrap-relayhistory-assertion"; +export const SESSION_DIRECTORY_BOOTSTRAP_API_KEY_NAME = "bootstrap-session-directory-search"; const MIN_BOOTSTRAP_API_KEY_LENGTH = 32; export type BootstrapApiKeyResult = @@ -19,11 +20,21 @@ export type BootstrapApiKeyResult = | { status: "revoked"; id: string; prefix: string } | { status: "scope_mismatch"; id: string; prefix: string; scopes: string[] }; -function classifyExisting(existing: StoredApiKey, requestedOrgId: string): BootstrapApiKeyResult { +function classifyExisting( + existing: StoredApiKey, + requestedOrgId: string, + scope: string, + exactScope: boolean, +): BootstrapApiKeyResult { if (existing.revokedAt) { return { status: "revoked", id: existing.id, prefix: existing.prefix }; } - if (!existing.scopes.includes(BOOTSTRAP_API_KEY_SCOPE)) { + // A dedicated key (the session directory mint) is usable only with exactly its one + // scope; the endpoint refuses a key carrying anything else. + const scopeOk = exactScope + ? existing.scopes.length === 1 && existing.scopes[0] === scope + : existing.scopes.includes(scope); + if (!scopeOk) { return { status: "scope_mismatch", id: existing.id, @@ -43,7 +54,8 @@ function classifyExisting(existing: StoredApiKey, requestedOrgId: string): Boots /** * Idempotently ensure an API key with exactly this value exists and can mint - * History assertions (scope relayauth:assertion:create:relayhistory). + * History assertions (scope relayauth:assertion:create:relayhistory, or + * `options.scope` for another broker). * * Only the SHA-256 hash and display prefix are stored, exactly like keys * minted through POST /v1/api-keys. The raw value is never logged or @@ -54,8 +66,10 @@ function classifyExisting(existing: StoredApiKey, requestedOrgId: string): Boots export async function ensureBootstrapApiKey( storage: AuthStorage, rawValue: string, - options: { orgId?: string; name?: string } = {}, + options: { orgId?: string; name?: string; scope?: string; exactScope?: boolean } = {}, ): Promise { + const scope = options.scope ?? BOOTSTRAP_API_KEY_SCOPE; + const exactScope = options.exactScope ?? false; const apiKey = rawValue.trim(); if (apiKey.length < MIN_BOOTSTRAP_API_KEY_LENGTH || /\s/u.test(apiKey)) { throw new Error( @@ -68,7 +82,7 @@ export async function ensureBootstrapApiKey( const orgId = options.orgId?.trim() || DEFAULT_BOOTSTRAP_API_KEY_ORG; const existing = await storage.apiKeys.getByHash(keyHash); if (existing) { - return classifyExisting(existing, orgId); + return classifyExisting(existing, orgId, scope, exactScope); } try { @@ -77,7 +91,7 @@ export async function ensureBootstrapApiKey( name: options.name?.trim() || BOOTSTRAP_API_KEY_NAME, prefix, keyHash, - scopes: [BOOTSTRAP_API_KEY_SCOPE], + scopes: [scope], }); return { status: "created", id: created.id, prefix: created.prefix }; } catch (error) { @@ -86,7 +100,7 @@ export async function ensureBootstrapApiKey( // fired. Re-read and report what is there instead of failing boot. const raced = await storage.apiKeys.getByHash(keyHash); if (raced) { - return classifyExisting(raced, orgId); + return classifyExisting(raced, orgId, scope, exactScope); } throw error; } diff --git a/packages/server/src/lib/session-directory-issuer.ts b/packages/server/src/lib/session-directory-issuer.ts new file mode 100644 index 00000000..6f7ec51e --- /dev/null +++ b/packages/server/src/lib/session-directory-issuer.ts @@ -0,0 +1,201 @@ +import type { RelayAuthTokenClaims } from "@relayauth/types"; +import type { AppConfig } from "../env.js"; +import { resolveIssuer } from "./sign.js"; +import { signRs256 } from "./sign-rs256.js"; +import { + resolveCurrentPrivateKeyPem, + resolveCurrentPublicJwk, + resolvePublishedSigningKeys, + SigningKeyConfigError, + type PublishedSigningKey, + type SigningKeyEnv, +} from "./signing-keys.js"; + +/** + * The session directory search issuer: a second issuer identity with its own + * signing key, trusted by PostgREST for one RPC and by nothing else. It never + * shares a key or `iss` with the general issuer, so a token from any other + * RelayAuth mint path cannot reach the search role. Design: + * 96BullSessions docs/session-directory-tenant-issuer.md. + */ +export const SESSION_DIRECTORY_SEARCH_MINT_SCOPE = + "relayauth:assertion:create:session-directory-search"; +export const SESSION_DIRECTORY_SEARCH_AUDIENCE = "session-directory-search"; +export const SESSION_DIRECTORY_SEARCH_ROLE = "session_directory_search_worker"; +export const SESSION_DIRECTORY_SEARCH_TOKEN_TTL_SECONDS = 60; +export const SESSION_DIRECTORY_SEARCH_SUBJECT = "agent_session_directory_search"; +export const SESSION_DIRECTORY_SEARCH_TOKEN_CLASS = "session_directory_search"; +/** The tenant source for search is always a token that can read History. */ +export const SESSION_DIRECTORY_SUBJECT_SCOPE = "rth:read"; +const DEFAULT_SUBJECT_AUDIENCES = ["relayhistory"]; +/** Token classes that must never stand in for a person reading sessions. */ +const REFUSED_SUBJECT_TOKEN_CLASSES = new Set([ + "migration_assertion", + SESSION_DIRECTORY_SEARCH_TOKEN_CLASS, +]); + +type IssuerEnv = Pick< + AppConfig, + | "RELAYAUTH_SESSION_DIRECTORY_ISSUANCE" + | "RELAYAUTH_SESSION_DIRECTORY_ISSUER" + | "RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM" + | "RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_NEXT" + | "RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_PREVIOUS" + | "RELAYAUTH_SESSION_DIRECTORY_SUBJECT_AUDIENCES" + | "RELAYAUTH_ISSUER" +> & SigningKeyEnv; + +export type SessionDirectorySearchClaims = { + iss: string; + aud: typeof SESSION_DIRECTORY_SEARCH_AUDIENCE; + sub: string; + role: typeof SESSION_DIRECTORY_SEARCH_ROLE; + app_metadata: { org_id: string; workspace_ids: [string] }; + iat: number; + nbf: number; + exp: number; + jti: string; +}; + +export function sessionDirectoryIssuanceEnabled(env: Pick): boolean { + return env.RELAYAUTH_SESSION_DIRECTORY_ISSUANCE === "true"; +} + +/** The issuer's slots, in the shape the general multi-key rules take. */ +function issuerKeyEnv(env: IssuerEnv): SigningKeyEnv { + return { + RELAYAUTH_SIGNING_KEY_PEM: env.RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM, + RELAYAUTH_SIGNING_KEY_PEM_NEXT: env.RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_NEXT, + RELAYAUTH_SIGNING_KEY_PEM_PREVIOUS: env.RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_PREVIOUS, + }; +} + +export function resolveSessionDirectoryKeys(env: IssuerEnv): Promise { + return resolvePublishedSigningKeys(issuerKeyEnv(env)); +} + +function resolveSessionDirectoryIssuer(env: IssuerEnv): string | undefined { + return env.RELAYAUTH_SESSION_DIRECTORY_ISSUER?.trim() || undefined; +} + +/** + * Refuse to run with an issuer that overlaps the general one. Called at boot and + * before every mint, because Worker deployments have no boot step. + */ +export async function assertSessionDirectoryIssuerConfig(env: IssuerEnv): Promise { + const keys = await resolveSessionDirectoryKeys(env); + const issuer = resolveSessionDirectoryIssuer(env); + if (sessionDirectoryIssuanceEnabled(env)) { + if (!issuer) { + throw new SigningKeyConfigError( + "RELAYAUTH_SESSION_DIRECTORY_ISSUER must be set when issuance is enabled", + ); + } + if (!resolveCurrentPrivateKeyPem(issuerKeyEnv(env))) { + throw new SigningKeyConfigError( + "RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM must be set when issuance is enabled", + ); + } + } + if (issuer && issuer === resolveIssuer(env)) { + throw new SigningKeyConfigError( + "RELAYAUTH_SESSION_DIRECTORY_ISSUER must differ from the general issuer", + ); + } + if (keys.length === 0) return; + const generalKids = new Set((await resolvePublishedSigningKeys(env)).map((key) => key.kid)); + if (keys.some((key) => generalKids.has(key.kid))) { + throw new SigningKeyConfigError( + "session directory signing keys must not reuse a general RelayAuth signing key", + ); + } +} + +/** Remove exactly one `relay_` prefix, matching RelayHistory's stored tenant ids. */ +export function normalizeTenantId(value: string): string { + return value.startsWith("relay_") ? value.slice("relay_".length) : value; +} + +function subjectAudiences(env: IssuerEnv): string[] { + const configured = (env.RELAYAUTH_SESSION_DIRECTORY_SUBJECT_AUDIENCES ?? "") + .split(",") + .map((audience) => audience.trim()) + .filter(Boolean); + return configured.length > 0 ? configured : DEFAULT_SUBJECT_AUDIENCES; +} + +export type SubjectRefusal = { code: string; error: string }; + +/** + * Every tenant value in the issued token comes from this verified subject. The + * caller's `workspaceId` can only select the subject's own workspace. + */ +export function refuseSubject( + subject: RelayAuthTokenClaims, + requestedWorkspaceId: string, + env: IssuerEnv, +): SubjectRefusal | null { + if (subject.token_type !== "access") { + return { code: "subject_not_access_token", error: "Subject must be an access token" }; + } + const tokenClass = subject.meta?.tokenClass; + if (tokenClass && REFUSED_SUBJECT_TOKEN_CLASSES.has(tokenClass)) { + return { code: "subject_class_refused", error: "This token class cannot be exchanged" }; + } + const audiences = subjectAudiences(env); + if (!(subject.aud ?? []).some((audience) => audiences.includes(audience))) { + return { code: "subject_audience_refused", error: "Subject audience is not accepted" }; + } + if (!(subject.scopes ?? []).includes(SESSION_DIRECTORY_SUBJECT_SCOPE)) { + return { code: "subject_scope_missing", error: `Subject needs ${SESSION_DIRECTORY_SUBJECT_SCOPE}` }; + } + const org = typeof subject.org === "string" ? normalizeTenantId(subject.org.trim()) : ""; + const workspace = typeof subject.wks === "string" ? normalizeTenantId(subject.wks.trim()) : ""; + if (!org || !workspace) { + return { code: "subject_tenant_missing", error: "Subject has no org or workspace" }; + } + if (normalizeTenantId(requestedWorkspaceId) !== workspace) { + return { code: "workspace_mismatch", error: "Workspace does not match the subject" }; + } + return null; +} + +export function buildSessionDirectorySearchClaims( + subject: RelayAuthTokenClaims, + env: IssuerEnv, + nowSeconds: number, + jti: string, +): SessionDirectorySearchClaims { + const issuer = resolveSessionDirectoryIssuer(env); + if (!issuer) { + throw new SigningKeyConfigError("RELAYAUTH_SESSION_DIRECTORY_ISSUER must be set"); + } + return { + iss: issuer, + aud: SESSION_DIRECTORY_SEARCH_AUDIENCE, + sub: subject.sponsorId ?? subject.sub, + role: SESSION_DIRECTORY_SEARCH_ROLE, + app_metadata: { + org_id: normalizeTenantId(subject.org.trim()), + workspace_ids: [normalizeTenantId(subject.wks.trim())], + }, + iat: nowSeconds, + nbf: nowSeconds, + // A search token never outlives the token it came from. + exp: Math.min(nowSeconds + SESSION_DIRECTORY_SEARCH_TOKEN_TTL_SECONDS, subject.exp), + jti, + }; +} + +export async function signSessionDirectorySearchToken( + claims: SessionDirectorySearchClaims, + env: IssuerEnv, +): Promise { + const keyEnv = issuerKeyEnv(env); + const privateKeyPem = resolveCurrentPrivateKeyPem(keyEnv); + const publicJwk = await resolveCurrentPublicJwk(keyEnv); + if (!privateKeyPem || !publicJwk) { + throw new SigningKeyConfigError("RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM must be set"); + } + return signRs256(claims, privateKeyPem, publicJwk.kid); +} diff --git a/packages/server/src/routes/jwks.ts b/packages/server/src/routes/jwks.ts index b3231ec7..7b7529f9 100644 --- a/packages/server/src/routes/jwks.ts +++ b/packages/server/src/routes/jwks.ts @@ -1,9 +1,11 @@ import { Hono } from "hono"; import type { AppEnv } from "../env.js"; import type { RsaPublicJwk } from "../lib/jwk.js"; +import { resolveSessionDirectoryKeys } from "../lib/session-directory-issuer.js"; import { resolvePublishedSigningKeys } from "../lib/signing-keys.js"; const CACHE_CONTROL_HEADER = "public, max-age=3600"; +const SESSION_DIRECTORY_CACHE_CONTROL_HEADER = "public, max-age=300"; const jwks = new Hono(); @@ -17,4 +19,15 @@ jwks.get("/jwks.json", async (c) => { return c.json({ keys }, 200); }); +// Only this issuer's keys, so PostgREST can be configured to trust nothing else. +// The shorter cache keeps a rotation's waiting period small. +jwks.get("/session-directory/jwks.json", async (c) => { + const published = await resolveSessionDirectoryKeys(c.env ?? {}); + if (published.length === 0) { + return c.json({ error: "Not found", code: "not_found" }, 404); + } + c.header("Cache-Control", SESSION_DIRECTORY_CACHE_CONTROL_HEADER); + return c.json({ keys: published.map((key) => key.jwk) }, 200); +}); + export default jwks; diff --git a/packages/server/src/routes/tokens.ts b/packages/server/src/routes/tokens.ts index 7a2c567f..21f4fd5f 100644 --- a/packages/server/src/routes/tokens.ts +++ b/packages/server/src/routes/tokens.ts @@ -14,7 +14,10 @@ import { generateApiKey, WORKSPACE_TOKEN_PREFIX, } from "../lib/api-keys.js"; -import { authenticateAndAuthorizeFromContext } from "../lib/auth.js"; +import { + authenticateAndAuthorizeFromContext, + isVerifiedTokenRevoked, +} from "../lib/auth.js"; import { firstUndelegableBrokerScope, matchBrokerScopeExactly, @@ -28,6 +31,17 @@ import { RELAY_PATH_TOKEN_PREFIX, wrapRelayToken, } from "../lib/jwt.js"; +import { FixedWindowRateLimiter } from "../lib/rate-limit.js"; +import { + assertSessionDirectoryIssuerConfig, + buildSessionDirectorySearchClaims, + refuseSubject, + SESSION_DIRECTORY_SEARCH_MINT_SCOPE, + SESSION_DIRECTORY_SEARCH_SUBJECT, + SESSION_DIRECTORY_SEARCH_TOKEN_CLASS, + sessionDirectoryIssuanceEnabled, + signSessionDirectorySearchToken, +} from "../lib/session-directory-issuer.js"; import { resolveIssuer, signToken } from "../lib/sign.js"; import { verifyRs256Token } from "../lib/token-verifier.js"; import type { StoredIdentity } from "../storage/identity-types.js"; @@ -805,6 +819,147 @@ tokens.post("/relayhistory-assertion", async (c) => { return c.json(assertion, 201); }); +// Per subject and workspace. RelayHistory reuses a token for up to 30 seconds, +// so this only trips on a caller minting far more often than search needs. +const sessionDirectoryMintLimiter = new FixedWindowRateLimiter(120, 60_000); + +tokens.post("/session-directory-search", async (c) => { + if (!sessionDirectoryIssuanceEnabled(c.env)) { + return c.json( + { error: "Session directory issuance is disabled", code: "session_directory_issuance_disabled" }, + 503, + ); + } + + const auth = await authenticateAndAuthorizeFromContext( + c, + SESSION_DIRECTORY_SEARCH_MINT_SCOPE, + matchBrokerScopeExactly, + ); + if (!auth.ok) { + return c.json({ error: auth.error, code: auth.code }, auth.status); + } + // A key with other scopes is no longer narrow, and a workspace token is not a + // dedicated key. Only a plain key carrying this one scope may mint. + if ( + c.get("apiKeyVia") !== "api_key" + || auth.claims.meta?.apiKeyKind !== "api_key" + || auth.claims.scopes.length !== 1 + ) { + return c.json( + { + error: "session directory mint requires a dedicated api key", + code: "assertion_key_required", + }, + 403, + ); + } + + const body = await parseJsonObjectBody<{ subjectToken?: unknown; workspaceId?: unknown }>(c.req.raw); + if (!body) { + return c.json({ error: "Invalid JSON body" }, 400); + } + const subjectToken = typeof body.subjectToken === "string" ? body.subjectToken.trim() : ""; + const workspaceId = typeof body.workspaceId === "string" ? body.workspaceId.trim() : ""; + if (!subjectToken) { + return c.json({ error: "subjectToken is required", code: "subjectToken_required" }, 400); + } + if (!workspaceId) { + return c.json({ error: "workspaceId is required", code: "workspaceId_required" }, 400); + } + + await assertSessionDirectoryIssuerConfig(c.env); + + let subject: RelayAuthTokenClaims; + try { + subject = await verifyRs256Token(subjectToken, c.env, { issuer: resolveIssuer(c.env) }); + } catch { + return c.json({ error: "Invalid subject token", code: "subject_token_invalid" }, 401); + } + const storage = c.get("storage"); + // Fail closed: a search token must never come from a subject whose revocation + // cannot be checked (matching GET /v1/tokens/revocation's 501). + if (typeof storage.revocations.isRevoked !== "function") { + return c.json( + { error: "Revocation check unavailable", code: "revocation_check_unavailable" }, + 501, + ); + } + if (await isVerifiedTokenRevoked(storage, subject)) { + return c.json({ error: "Invalid subject token", code: "subject_token_invalid" }, 401); + } + // The verifier allows 30s of clock skew past `exp`. A search token never outlives + // its subject, so a subject at or past `exp` would yield exp <= iat: refuse it. + const nowSeconds = Math.floor(Date.now() / 1000); + if (typeof subject.exp !== "number" || subject.exp <= nowSeconds) { + return c.json({ error: "Invalid subject token", code: "subject_token_invalid" }, 401); + } + const refusal = refuseSubject(subject, workspaceId, c.env); + if (refusal) { + return c.json(refusal, 403); + } + + const claims = buildSessionDirectorySearchClaims( + subject, + c.env, + nowSeconds, + createTokenId(), + ); + const rate = sessionDirectoryMintLimiter.consume([ + `${claims.app_metadata.org_id}:${claims.app_metadata.workspace_ids[0]}:${claims.sub}`, + ]); + if (!rate.allowed) { + return c.json({ error: "Too many search token requests", code: "rate_limited" }, 429); + } + + const accessToken = await signSessionDirectorySearchToken(claims, c.env); + await storage.tokens.persistIssuedWithAudit({ + token: { + id: claims.jti, + tokenId: claims.jti, + jti: claims.jti, + identityId: SESSION_DIRECTORY_SEARCH_SUBJECT, + sessionId: null, + issuedAt: claims.iat, + expiresAt: claims.exp, + createdAt: new Date(claims.iat * 1000).toISOString(), + lineage: { + orgId: claims.app_metadata.org_id, + workspaceId: claims.app_metadata.workspace_ids[0], + sponsorId: claims.sub, + sponsorChain: [claims.sub, SESSION_DIRECTORY_SEARCH_SUBJECT], + tokenType: "access", + }, + }, + auditEntry: { + id: crypto.randomUUID(), + action: "token.issued", + identityId: auth.claims.sub, + orgId: claims.app_metadata.org_id, + workspaceId: claims.app_metadata.workspace_ids[0], + plane: "relayauth", + resource: SESSION_DIRECTORY_SEARCH_TOKEN_CLASS, + result: "allowed", + metadata: { + tokenId: claims.jti, + actorOrgId: auth.claims.org, + sponsorId: claims.sub, + subjectTokenId: subject.jti, + }, + timestamp: new Date().toISOString(), + }, + }); + + return c.json( + { + accessToken, + accessTokenExpiresAt: new Date(claims.exp * 1000).toISOString(), + tokenType: "Bearer", + }, + 201, + ); +}); + tokens.post("/refresh", async (c) => { const body = await parseJsonObjectBody(c.req.raw); if (!body) { diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts index 8667055c..46fd954c 100644 --- a/packages/server/src/server.ts +++ b/packages/server/src/server.ts @@ -20,7 +20,15 @@ import { storageOverloadResponse, toStorageCapacityExhaustedError, } from "./lib/storage-retry.js"; -import { DEFAULT_BOOTSTRAP_API_KEY_ORG, ensureBootstrapApiKey } from "./lib/bootstrap-api-key.js"; +import { + DEFAULT_BOOTSTRAP_API_KEY_ORG, + ensureBootstrapApiKey, + SESSION_DIRECTORY_BOOTSTRAP_API_KEY_NAME, +} from "./lib/bootstrap-api-key.js"; +import { + assertSessionDirectoryIssuerConfig, + SESSION_DIRECTORY_SEARCH_MINT_SCOPE, +} from "./lib/session-directory-issuer.js"; import { assertSigningKeyConfig } from "./lib/signing-keys.js"; import { apiKeyAuth } from "./middleware/api-key-auth.js"; import { SponsorOidcService } from "./lib/sponsor-binding.js"; @@ -522,6 +530,24 @@ export async function startServer(options: StartServerOptions = {}) { options.config?.RELAYAUTH_SIGNING_KEY_PEM_PREVIOUS ?? readEnvOrFile("RELAYAUTH_SIGNING_KEY_PEM_PREVIOUS"), RELAYAUTH_ISSUER: options.config?.RELAYAUTH_ISSUER ?? process.env.RELAYAUTH_ISSUER, + RELAYAUTH_SESSION_DIRECTORY_ISSUANCE: + options.config?.RELAYAUTH_SESSION_DIRECTORY_ISSUANCE + ?? process.env.RELAYAUTH_SESSION_DIRECTORY_ISSUANCE, + RELAYAUTH_SESSION_DIRECTORY_ISSUER: + options.config?.RELAYAUTH_SESSION_DIRECTORY_ISSUER + ?? process.env.RELAYAUTH_SESSION_DIRECTORY_ISSUER, + RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM: + options.config?.RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM + ?? readEnvOrFile("RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM"), + RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_NEXT: + options.config?.RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_NEXT + ?? readEnvOrFile("RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_NEXT"), + RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_PREVIOUS: + options.config?.RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_PREVIOUS + ?? readEnvOrFile("RELAYAUTH_SESSION_DIRECTORY_SIGNING_KEY_PEM_PREVIOUS"), + RELAYAUTH_SESSION_DIRECTORY_SUBJECT_AUDIENCES: + options.config?.RELAYAUTH_SESSION_DIRECTORY_SUBJECT_AUDIENCES + ?? process.env.RELAYAUTH_SESSION_DIRECTORY_SUBJECT_AUDIENCES, RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN: options.config?.RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN ?? process.env.RELAYAUTH_ALLOW_PRIVATE_HTTP_HISTORY_ORIGIN, @@ -548,25 +574,46 @@ export async function startServer(options: StartServerOptions = {}) { // or an explicit public key that does not match the private key) rather // than signing with one key while advertising another. await assertSigningKeyConfig(config); - - const bootstrapApiKey = options.bootstrapApiKey ?? readEnvOrFile("RELAYAUTH_BOOTSTRAP_API_KEY"); - if (bootstrapApiKey) { + await assertSessionDirectoryIssuerConfig(config); + + const requestedBootstrapOrg = (options.bootstrapApiKeyOrgId ?? process.env.RELAYAUTH_BOOTSTRAP_API_KEY_ORG)?.trim() + || DEFAULT_BOOTSTRAP_API_KEY_ORG; + const bootstrapKeys = [ + { + label: "bootstrap api key", + setting: "RELAYAUTH_BOOTSTRAP_API_KEY", + value: options.bootstrapApiKey ?? readEnvOrFile("RELAYAUTH_BOOTSTRAP_API_KEY"), + }, + { + label: "session directory bootstrap api key", + setting: "RELAYAUTH_SESSION_DIRECTORY_BOOTSTRAP_API_KEY", + value: readEnvOrFile("RELAYAUTH_SESSION_DIRECTORY_BOOTSTRAP_API_KEY"), + scope: SESSION_DIRECTORY_SEARCH_MINT_SCOPE, + name: SESSION_DIRECTORY_BOOTSTRAP_API_KEY_NAME, + exactScope: true, + }, + ]; + for (const { label, setting, value, scope, name, exactScope } of bootstrapKeys) { + if (!value) continue; // Never log the key or its prefix: the record id is enough to find it. - const requestedOrg = (options.bootstrapApiKeyOrgId ?? process.env.RELAYAUTH_BOOTSTRAP_API_KEY_ORG)?.trim() - || DEFAULT_BOOTSTRAP_API_KEY_ORG; - const result = await ensureBootstrapApiKey(storage, bootstrapApiKey, { orgId: requestedOrg }); + const result = await ensureBootstrapApiKey(storage, value, { + orgId: requestedBootstrapOrg, + scope, + name, + exactScope, + }); if (result.status === "created" || result.status === "exists") { - console.log(`relayauth bootstrap api key ${result.status}`, { id: result.id }); + console.log(`relayauth ${label} ${result.status}`, { id: result.id }); if (result.status === "exists" && result.orgMismatch) { console.warn( - "relayauth bootstrap api key already exists in a different org than " + `relayauth ${label} already exists in a different org than ` + "RELAYAUTH_BOOTSTRAP_API_KEY_ORG; it was left unchanged (rotate the key to move it)", { id: result.id, ...result.orgMismatch }, ); } } else { console.warn( - `relayauth bootstrap api key not usable (${result.status}); rotate RELAYAUTH_BOOTSTRAP_API_KEY_FILE to a new value`, + `relayauth ${label} not usable (${result.status}); rotate ${setting}_FILE to a new value`, { id: result.id }, ); }