From 0cdca3c39ac5baad0973c25ac38853a7523e6326 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Fri, 16 Oct 2026 22:32:05 -0400 Subject: [PATCH 1/2] feat(config): warn at boot when a secret falls back to the service token Closes #432 --- .changeset/warn-shared-secrets.md | 5 ++++ docs/production-operations.md | 19 ++++++++++++- src/config/serviceToken.ts | 37 +++++++++++++++++++++++++ tests/unit/config/serviceToken.spec.ts | 38 ++++++++++++++++++++++++++ 4 files changed, 98 insertions(+), 1 deletion(-) create mode 100644 .changeset/warn-shared-secrets.md diff --git a/.changeset/warn-shared-secrets.md b/.changeset/warn-shared-secrets.md new file mode 100644 index 00000000..9aa71ee2 --- /dev/null +++ b/.changeset/warn-shared-secrets.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': patch +--- + +The server logs a warning at boot naming each of `REFRESH_TOKEN_LOOKUP_SECRET`, `TOTP_SECRET_ENCRYPTION_KEY`, `OAUTH_STATE_SECRET` and `DECOY_SUBJECT_SECRET` that is unset and so falls back to the service token, with what rotating the service token would then break. `docs/production-operations.md` describes how to separate them safely: set each to the current service token value first, then rotate the service token. diff --git a/docs/production-operations.md b/docs/production-operations.md index e9bfc187..ccf89025 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -79,7 +79,24 @@ selecting the signer, and the three steps above are three applies. See ADR 0011, `Terraform owns the JWKS document, and rotation overlaps`, and the procedure in that repo's `portal-auth/README.md`. -## Refresh Tokens +## Secrets That Fall Back To The Service Token + +`REFRESH_TOKEN_LOOKUP_SECRET`, `TOTP_SECRET_ENCRYPTION_KEY`, `OAUTH_STATE_SECRET` and +`DECOY_SUBJECT_SECRET` each fall back to the service token (`SEAMLESS_SERVICE_TOKEN`, or +`API_SERVICE_TOKEN`) when unset, and the server logs a warning at boot naming each one that does. +The server adapter holds the service token as well, so it is the secret most likely to need +rotating, and while the others share it, rotating it signs everyone out and makes every stored TOTP +secret unreadable. + +To separate them without breaking anything, in this order: + +1. Set each of the four variables to the **current** service token value and redeploy. Nothing + changes yet, because each one already resolves to that value. +2. Rotate the service token: set the new value on the API and on every server adapter. + +From then on the service token can be rotated freely. Do not set the four to new values in step 1: +stored refresh token fingerprints and TOTP secrets were made with the old key and would stop +matching. Refresh tokens are opaque values. The API stores hashes and lookup fingerprints, not raw refresh tokens. diff --git a/src/config/serviceToken.ts b/src/config/serviceToken.ts index 506029b6..9eb991c8 100644 --- a/src/config/serviceToken.ts +++ b/src/config/serviceToken.ts @@ -58,8 +58,45 @@ export function getServiceToken(env: EnvSource = process.env): string | undefine * Boot-time check: throws on conflicting values so a misconfigured instance refuses to * start, and notes once when only the legacy name is in use. */ +/** + * Secrets that fall back to the service token when unset, and what rotating the + * service token would then break. The server adapter holds the service token too, so + * it is the one most likely to need rotating, and the fallback turns that into a + * lockout or a forced sign-out. + */ +export const SECRETS_SHARING_SERVICE_TOKEN = [ + { + env: 'REFRESH_TOKEN_LOOKUP_SECRET', + onRotate: 'every refresh token stops working and everyone is signed out', + }, + { + env: 'TOTP_SECRET_ENCRYPTION_KEY', + onRotate: 'every stored TOTP secret becomes unreadable and those users must re-enroll', + }, + { env: 'OAUTH_STATE_SECRET', onRotate: 'OAuth sign-ins in progress fail' }, + { env: 'DECOY_SUBJECT_SECRET', onRotate: 'the decoy subjects for unknown identifiers change' }, +] as const; + +export function secretsSharingServiceToken(env: EnvSource = process.env) { + if (!resolveServiceToken(env).value) { + return []; + } + + return SECRETS_SHARING_SERVICE_TOKEN.filter(({ env: name }) => !env[name]?.trim()); +} + export function assertServiceTokenConfig(env: EnvSource = process.env) { const resolved = resolveServiceToken(env); + const shared = secretsSharingServiceToken(env); + + if (shared.length > 0) { + logger.warn( + `These secrets are unset and fall back to the service token: ` + + shared.map(({ env: name, onRotate }) => `${name} (rotating it: ${onRotate})`).join('; ') + + `. To separate them safely, set each one to the current service token value first, ` + + `then rotate the service token. See docs/production-operations.md.`, + ); + } if (resolved.source === LEGACY_SERVICE_TOKEN_ENV) { logger.info( diff --git a/tests/unit/config/serviceToken.spec.ts b/tests/unit/config/serviceToken.spec.ts index 850e7b49..e5c9ede4 100644 --- a/tests/unit/config/serviceToken.spec.ts +++ b/tests/unit/config/serviceToken.spec.ts @@ -4,6 +4,7 @@ import { assertServiceTokenConfig, getServiceToken, resolveServiceToken, + secretsSharingServiceToken, ServiceTokenConflictError, } from '../../../src/config/serviceToken.js'; import getLogger from '../../../src/utils/logger.js'; @@ -68,6 +69,7 @@ describe('assertServiceTokenConfig', () => { beforeEach(() => { vi.spyOn(logger, 'info'); + vi.spyOn(logger, 'warn'); }); it('logs a single info notice when only API_SERVICE_TOKEN is set', () => { @@ -87,6 +89,42 @@ describe('assertServiceTokenConfig', () => { expect(logger.info).not.toHaveBeenCalled(); }); + it('warns about every secret that falls back to the service token', () => { + assertServiceTokenConfig({ + SEAMLESS_SERVICE_TOKEN: 'token', + REFRESH_TOKEN_LOOKUP_SECRET: 'separate', + }); + + expect(logger.warn).toHaveBeenCalledTimes(1); + const message = (logger.warn as any).mock.calls[0][0] as string; + expect(message).toContain('TOTP_SECRET_ENCRYPTION_KEY'); + expect(message).toContain('OAUTH_STATE_SECRET'); + expect(message).toContain('DECOY_SUBJECT_SECRET'); + expect(message).not.toContain('REFRESH_TOKEN_LOOKUP_SECRET'); + expect(message).toContain('set each one to the current service token value first'); + }); + + it('does not warn when every secret is set separately', () => { + assertServiceTokenConfig({ + SEAMLESS_SERVICE_TOKEN: 'token', + REFRESH_TOKEN_LOOKUP_SECRET: 'a', + TOTP_SECRET_ENCRYPTION_KEY: 'b', + OAUTH_STATE_SECRET: 'c', + DECOY_SUBJECT_SECRET: 'd', + }); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + + // Without a service token there is nothing to share; development has its own + // fallbacks for that case. + it('does not warn when no service token is set', () => { + expect(secretsSharingServiceToken({})).toEqual([]); + assertServiceTokenConfig({}); + + expect(logger.warn).not.toHaveBeenCalled(); + }); + it('stays quiet when neither name is set', () => { expect(assertServiceTokenConfig({})).toEqual({ value: undefined, source: null }); expect(logger.info).not.toHaveBeenCalled(); From 20380251b08985f14058911fd1f22e69094d9303 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 17 Oct 2026 08:42:43 -0400 Subject: [PATCH 2/2] docs(config): mark the service token fallback deprecated, removed in v0.20 --- .changeset/warn-shared-secrets.md | 2 ++ docs/configuration.md | 16 ++++++++-------- docs/production-operations.md | 4 ++++ src/config/serviceToken.ts | 5 +++-- tests/unit/config/serviceToken.spec.ts | 1 + 5 files changed, 18 insertions(+), 10 deletions(-) diff --git a/.changeset/warn-shared-secrets.md b/.changeset/warn-shared-secrets.md index 9aa71ee2..cdaca03c 100644 --- a/.changeset/warn-shared-secrets.md +++ b/.changeset/warn-shared-secrets.md @@ -3,3 +3,5 @@ --- The server logs a warning at boot naming each of `REFRESH_TOKEN_LOOKUP_SECRET`, `TOTP_SECRET_ENCRYPTION_KEY`, `OAUTH_STATE_SECRET` and `DECOY_SUBJECT_SECRET` that is unset and so falls back to the service token, with what rotating the service token would then break. `docs/production-operations.md` describes how to separate them safely: set each to the current service token value first, then rotate the service token. + +The fallback to the service token is deprecated and is removed in v0.20, which requires each secret whose feature is in use. Set them now, to the current service token value, so the upgrade is a no-op. diff --git a/docs/configuration.md b/docs/configuration.md index 95b4c223..0fca23f4 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -107,14 +107,14 @@ then daily, and each run also logs the audit chain head as an external anchor. S ### Service tokens and secrets -| Variable | Required | Default | Notes | -| ----------------------------- | -------- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `SEAMLESS_SERVICE_TOKEN` | Yes | - | Trusted server-adapter and internal bearer secret. Also the fallback for the secrets below. | -| `API_SERVICE_TOKEN` | No | - | Supported fallback name for `SEAMLESS_SERVICE_TOKEN`. Used only when `SEAMLESS_SERVICE_TOKEN` is unset, with an info notice at boot. Setting both to different values stops the server at boot. | -| `REFRESH_TOKEN_LOOKUP_SECRET` | No | falls back to the service token | HMAC key for indexed refresh-token lookup fingerprints. In development only, an unset value derives a local secret. | -| `TOTP_SECRET_ENCRYPTION_KEY` | No | falls back to the service token | 32-byte base64url/base64/hex key for encrypted TOTP secrets. Production requires this or the service token. | -| `OAUTH_STATE_SECRET` | No | falls back to the service token | Dedicated signing secret for OAuth state. | -| `DECOY_SUBJECT_SECRET` | No | falls back to the service token | HMAC key the decoy pre-auth subjects on `POST /login` derive from. Rotating it re-maps which subject an unknown identifier gets, which is harmless: decoys are stateless and pre-auth tokens live five minutes. | +| Variable | Required | Default | Notes | +| ----------------------------- | -------- | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `SEAMLESS_SERVICE_TOKEN` | Yes | - | Trusted server-adapter and internal bearer secret. Also the fallback for the secrets below. | +| `API_SERVICE_TOKEN` | No | - | Supported fallback name for `SEAMLESS_SERVICE_TOKEN`. Used only when `SEAMLESS_SERVICE_TOKEN` is unset, with an info notice at boot. Setting both to different values stops the server at boot. | +| `REFRESH_TOKEN_LOOKUP_SECRET` | No | falls back to the service token (deprecated, required from v0.20) | HMAC key for indexed refresh-token lookup fingerprints. In development only, an unset value derives a local secret. | +| `TOTP_SECRET_ENCRYPTION_KEY` | No | falls back to the service token (deprecated, required from v0.20) | 32-byte base64url/base64/hex key for encrypted TOTP secrets. Production requires this or the service token. | +| `OAUTH_STATE_SECRET` | No | falls back to the service token (deprecated, required from v0.20) | Dedicated signing secret for OAuth state. | +| `DECOY_SUBJECT_SECRET` | No | falls back to the service token (deprecated, required from v0.20) | HMAC key the decoy pre-auth subjects on `POST /login` derive from. Rotating it re-maps which subject an unknown identifier gets, which is harmless: decoys are stateless and pre-auth tokens live five minutes. | > Prefer setting dedicated secrets in production rather than relying on the service token > fallbacks. See [docs/production-operations.md](./production-operations.md). diff --git a/docs/production-operations.md b/docs/production-operations.md index ccf89025..8b8b9117 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -94,6 +94,10 @@ To separate them without breaking anything, in this order: changes yet, because each one already resolves to that value. 2. Rotate the service token: set the new value on the API and on every server adapter. +The fallback is deprecated. From v0.20 each secret is required whenever the feature that uses it +is in use (TOTP for the encryption key, OAuth sign-in for the state secret, and always for the +refresh lookup and decoy secrets), so do this before upgrading to v0.20. + From then on the service token can be rotated freely. Do not set the four to new values in step 1: stored refresh token fingerprints and TOTP secrets were made with the old key and would stop matching. diff --git a/src/config/serviceToken.ts b/src/config/serviceToken.ts index 9eb991c8..3e1f8526 100644 --- a/src/config/serviceToken.ts +++ b/src/config/serviceToken.ts @@ -93,8 +93,9 @@ export function assertServiceTokenConfig(env: EnvSource = process.env) { logger.warn( `These secrets are unset and fall back to the service token: ` + shared.map(({ env: name, onRotate }) => `${name} (rotating it: ${onRotate})`).join('; ') + - `. To separate them safely, set each one to the current service token value first, ` + - `then rotate the service token. See docs/production-operations.md.`, + `. This fallback is deprecated and is removed in v0.20, which requires each secret ` + + `whose feature is in use. To separate them safely, set each one to the current service ` + + `token value first, then rotate the service token. See docs/production-operations.md.`, ); } diff --git a/tests/unit/config/serviceToken.spec.ts b/tests/unit/config/serviceToken.spec.ts index e5c9ede4..80b329a5 100644 --- a/tests/unit/config/serviceToken.spec.ts +++ b/tests/unit/config/serviceToken.spec.ts @@ -102,6 +102,7 @@ describe('assertServiceTokenConfig', () => { expect(message).toContain('DECOY_SUBJECT_SECRET'); expect(message).not.toContain('REFRESH_TOKEN_LOOKUP_SECRET'); expect(message).toContain('set each one to the current service token value first'); + expect(message).toContain('deprecated and is removed in v0.20'); }); it('does not warn when every secret is set separately', () => {