Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/warn-shared-secrets.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'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.

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.
16 changes: 8 additions & 8 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
23 changes: 22 additions & 1 deletion docs/production-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,28 @@ 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.

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.

Refresh tokens are opaque values. The API stores hashes and lookup fingerprints, not raw refresh tokens.

Expand Down
38 changes: 38 additions & 0 deletions src/config/serviceToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,46 @@ 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('; ') +
`. 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.`,
);
}

if (resolved.source === LEGACY_SERVICE_TOKEN_ENV) {
logger.info(
Expand Down
39 changes: 39 additions & 0 deletions tests/unit/config/serviceToken.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
assertServiceTokenConfig,
getServiceToken,
resolveServiceToken,
secretsSharingServiceToken,
ServiceTokenConflictError,
} from '../../../src/config/serviceToken.js';
import getLogger from '../../../src/utils/logger.js';
Expand Down Expand Up @@ -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', () => {
Expand All @@ -87,6 +89,43 @@ 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');
expect(message).toContain('deprecated and is removed in v0.20');
});

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();
Expand Down
Loading