From 192135db94f87582a59bc19c7181bd7d4af568bc Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 19 Jul 2026 21:31:29 -0400 Subject: [PATCH] fix: require a validated service token for external delivery in all environments External delivery and sensitive bootstrap details were gated on NODE_ENV not being production, plus a client-supplied header. Both now fall through to the existing internal service token check (issuer, audience, and subject) in every environment. Local development that cannot present a service token can set ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true. The flag must be set deliberately and is ignored under a production NODE_ENV, so it cannot become the default. Specs that exercised external delivery relied on the NODE_ENV shortcut. They now mint a trusted service token through tests/factories/serviceTokenFactory. --- ...ire-service-token-for-external-delivery.md | 5 ++ .env.example | 6 ++ docs/direct-http-quickstart.md | 12 +-- docs/extending.md | 6 +- src/lib/externalDelivery.ts | 26 +++++- tests/factories/serviceTokenFactory.ts | 27 ++++++ tests/integration/bootstrap/bootstrap.spec.ts | 7 +- tests/integration/magicLink/magicLink.spec.ts | 7 +- .../integration/registration/register.spec.ts | 3 + tests/setup/mocks.ts | 2 + tests/unit/controllers/otp.spec.ts | 26 +++++- tests/unit/lib/externalDelivery.spec.ts | 89 ++++++++++++++++++- 12 files changed, 198 insertions(+), 18 deletions(-) create mode 100644 .changeset/require-service-token-for-external-delivery.md create mode 100644 tests/factories/serviceTokenFactory.ts diff --git a/.changeset/require-service-token-for-external-delivery.md b/.changeset/require-service-token-for-external-delivery.md new file mode 100644 index 0000000..d308473 --- /dev/null +++ b/.changeset/require-service-token-for-external-delivery.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': minor +--- + +Require a validated internal service token for external delivery in all environments. `canReturnExternalDelivery` no longer treats a non-production `NODE_ENV` as sufficient, so the `x-seamless-auth-delivery-mode: external` header must now be accompanied by an `x-seamless-service-token` that passes issuer, audience, and subject validation. `canReturnSensitiveDevelopmentDetails` is gated the same way. Local development that cannot present a service token can set `ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true`, an explicit opt-in that is ignored when `NODE_ENV=production`. diff --git a/.env.example b/.env.example index 1159f86..669a8f1 100644 --- a/.env.example +++ b/.env.example @@ -87,6 +87,12 @@ SEAMLESS_BOOTSTRAP_SECRET=dev-bootstrap-secret-123 # Never enable in production. SEAMLESS_AUTH_DEBUG_SECRETS=false +# EXTERNAL DELIVERY +# External delivery returns OTP and magic-link secrets in the response body instead of sending +# them. It requires a valid x-seamless-service-token in every environment. Set this to true only +# for local development without a service token. It is ignored when NODE_ENV=production. +ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=false + # OPTIONAL DIRECT DELIVERY # Needed only if this auth API sends OTPs or magic links itself. # Not needed when a SeamlessAuth server adapter handles external delivery. diff --git a/docs/direct-http-quickstart.md b/docs/direct-http-quickstart.md index ef645f8..d88bcd9 100644 --- a/docs/direct-http-quickstart.md +++ b/docs/direct-http-quickstart.md @@ -55,15 +55,17 @@ EPHEMERAL= `GET /otp/generate-login-email-otp` with the ephemeral token as a Bearer credential sends the code to the user's email. -To read the code back in a headless flow, add `x-seamless-auth-delivery-mode: external`. In -development this returns the code directly in a `delivery` payload. **In production, external -delivery also requires a valid `x-seamless-service-token` from a trusted server adapter**; without -it the code is only sent through the configured messaging provider. +To read the code back in a headless flow, add `x-seamless-auth-delivery-mode: external`. This +returns the code directly in a `delivery` payload. **External delivery requires a valid +`x-seamless-service-token` from a trusted server adapter in every environment**; without it the +code is only sent through the configured messaging provider. For local development you can set +`ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true` instead, which is ignored when `NODE_ENV=production`. ```bash curl -sS "$BASE/otp/generate-login-email-otp" \ -H "Authorization: Bearer $EPHEMERAL" \ - -H 'x-seamless-auth-delivery-mode: external' + -H 'x-seamless-auth-delivery-mode: external' \ + -H "x-seamless-service-token: Bearer $SERVICE_TOKEN" ``` ```json diff --git a/docs/extending.md b/docs/extending.md index fe6aaf6..4be0145 100644 --- a/docs/extending.md +++ b/docs/extending.md @@ -32,8 +32,10 @@ A trusted caller can take over delivery entirely by sending the header delivery payload (recipient + token, and a URL for magic links) so the caller sends it through its own channel. See [`src/lib/externalDelivery.ts`](../src/lib/externalDelivery.ts). -- In development, external delivery is returned without additional credentials. -- In production, it requires a valid `x-seamless-service-token` from a trusted server adapter. +- External delivery requires a valid `x-seamless-service-token` from a trusted server adapter + in every environment. +- For local development without a service token, set `ALLOW_UNCREDENTIALED_DELIVERY_SECRETS=true`. + The flag is ignored when `NODE_ENV=production`. This is how a server adapter integrates its own email/SMS stack without the API baking in a provider. See the delivery payload shapes in diff --git a/src/lib/externalDelivery.ts b/src/lib/externalDelivery.ts index 6c2921b..ec96260 100644 --- a/src/lib/externalDelivery.ts +++ b/src/lib/externalDelivery.ts @@ -7,10 +7,14 @@ import { Request } from 'express'; import { validateInternalServiceToken } from '../middleware/authenticateServiceToken.js'; +import getLogger from '../utils/logger.js'; + +const logger = getLogger('externalDelivery'); const EXTERNAL_DELIVERY_HEADER = 'x-seamless-auth-delivery-mode'; const SERVICE_TOKEN_HEADER = 'x-seamless-service-token'; const INCLUDE_SENSITIVE_HEADER = 'x-seamless-auth-include-sensitive'; +const UNCREDENTIALED_OPT_IN = 'ALLOW_UNCREDENTIALED_DELIVERY_SECRETS'; function extractBearerToken(headerValue: string | undefined): string | null { if (!headerValue) { @@ -28,12 +32,30 @@ export function wantsExternalDelivery(req: Request) { return req.get(EXTERNAL_DELIVERY_HEADER)?.toLowerCase() === 'external'; } +/** + * Opt-in escape hatch for local development, where returning delivery secrets in the + * response body replaces a real mail/SMS provider. It must be set deliberately, and it is + * refused outright under a production NODE_ENV so it can never become the deployed default. + */ +function uncredentialedSecretsAllowed() { + if (process.env[UNCREDENTIALED_OPT_IN] !== 'true') { + return false; + } + + if (process.env.NODE_ENV === 'production') { + logger.error(`${UNCREDENTIALED_OPT_IN} is set in a production environment and was ignored.`); + return false; + } + + return true; +} + export async function canReturnExternalDelivery(req: Request) { if (!wantsExternalDelivery(req)) { return false; } - if (process.env.NODE_ENV !== 'production') { + if (uncredentialedSecretsAllowed()) { return true; } @@ -51,7 +73,7 @@ export async function canReturnExternalDelivery(req: Request) { } export function canReturnSensitiveDevelopmentDetails(req: Request) { - if (process.env.NODE_ENV === 'production') { + if (!uncredentialedSecretsAllowed()) { return false; } diff --git a/tests/factories/serviceTokenFactory.ts b/tests/factories/serviceTokenFactory.ts new file mode 100644 index 0000000..735b482 --- /dev/null +++ b/tests/factories/serviceTokenFactory.ts @@ -0,0 +1,27 @@ +export const TRUSTED_SERVICE_TOKEN = 'trusted-internal-service-token'; + +/** + * External delivery requires a validated internal service token in every environment. The + * suite mocks the service-token middleware globally, so this stubs the validator to accept + * TRUSTED_SERVICE_TOKEN and reject anything else, and returns the value to send in the + * x-seamless-service-token header. + * + * The module is imported at call time because specs that use vi.resetModules() would + * otherwise stub a stale copy of the mock. + */ +export async function mintInternalServiceToken(claims: Record = {}) { + const { validateInternalServiceToken } = + await import('../../src/middleware/authenticateServiceToken.js'); + + ( + validateInternalServiceToken as unknown as { + mockImplementation: (fn: (token: string) => unknown) => void; + } + ).mockImplementation((token: string) => + token === TRUSTED_SERVICE_TOKEN + ? { sub: 'test-service', iss: 'seamless-portal-api', aud: 'seamless-auth', ...claims } + : null, + ); + + return TRUSTED_SERVICE_TOKEN; +} diff --git a/tests/integration/bootstrap/bootstrap.spec.ts b/tests/integration/bootstrap/bootstrap.spec.ts index b8115ad..e9a367e 100644 --- a/tests/integration/bootstrap/bootstrap.spec.ts +++ b/tests/integration/bootstrap/bootstrap.spec.ts @@ -1,3 +1,4 @@ +import { mintInternalServiceToken } from '../../factories/serviceTokenFactory.js'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { Application } from 'express'; @@ -18,6 +19,7 @@ beforeAll(async () => { beforeEach(() => { vi.clearAllMocks(); + vi.unstubAllEnvs(); }); vi.mock('../../../src/services/bootstrapService.js', () => ({ @@ -67,7 +69,9 @@ it('creates bootstrap invite successfully', async () => { expect(res.body.data.token).toBeUndefined(); }); -it('returns bootstrap invite token details only when explicitly requested in non-production', async () => { +it('returns bootstrap invite token details only behind the local uncredentialed opt-in', async () => { + vi.stubEnv('ALLOW_UNCREDENTIALED_DELIVERY_SECRETS', 'true'); + (createAdminBootstrapInvite as any).mockResolvedValue({ registrationUrl: 'http://localhost:3000/register?bootstrapToken=test-secret-that-is-very-long-very-very-very-long', @@ -101,6 +105,7 @@ it('returns an external delivery payload when requested', async () => { .post('/internal/bootstrap/admin-invite') .set('Authorization', 'Bearer test-secret-that-is-very-long-very-very-very-long') .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', await mintInternalServiceToken()) .send({ email: 'test@example.com' }); expect(res.status).toBe(201); diff --git a/tests/integration/magicLink/magicLink.spec.ts b/tests/integration/magicLink/magicLink.spec.ts index 0b9ae23..96dd60d 100644 --- a/tests/integration/magicLink/magicLink.spec.ts +++ b/tests/integration/magicLink/magicLink.spec.ts @@ -1,3 +1,4 @@ +import { mintInternalServiceToken } from '../../factories/serviceTokenFactory.js'; import request from 'supertest'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; import { Application } from 'express'; @@ -96,7 +97,8 @@ describe('GET /magic-link', () => { const res = await request(app) .get('/magic-link') - .set('x-seamless-auth-delivery-mode', 'external'); + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', await mintInternalServiceToken()); expect(res.status).toBe(200); expect(sendMagicLinkEmail).not.toHaveBeenCalled(); @@ -125,7 +127,8 @@ describe('GET /magic-link', () => { const res = await request(app) .get('/magic-link') - .set('x-seamless-auth-delivery-mode', 'external'); + .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', await mintInternalServiceToken()); expect(res.status).toBe(200); expect(res.body.delivery.magicLinkUrl).toContain( diff --git a/tests/integration/registration/register.spec.ts b/tests/integration/registration/register.spec.ts index 315326c..1eba820 100644 --- a/tests/integration/registration/register.spec.ts +++ b/tests/integration/registration/register.spec.ts @@ -1,3 +1,4 @@ +import { mintInternalServiceToken } from '../../factories/serviceTokenFactory.js'; import request from 'supertest'; import { describe, it, expect, beforeAll, beforeEach, vi } from 'vitest'; import { createApp } from '../../../src/app'; @@ -123,6 +124,7 @@ describe('POST /registration/register', () => { const res = await request(app) .post('/registration/register') .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', await mintInternalServiceToken()) .send(buildRegistrationRequest()); expect(res.status).toBe(200); @@ -309,6 +311,7 @@ describe('POST /registration/phone', () => { const res = await request(app) .post('/registration/phone') .set('x-seamless-auth-delivery-mode', 'external') + .set('x-seamless-service-token', await mintInternalServiceToken()) .send({ phone: '+14155550000' }); expect(res.status).toBe(200); diff --git a/tests/setup/mocks.ts b/tests/setup/mocks.ts index 18038b3..7d81270 100644 --- a/tests/setup/mocks.ts +++ b/tests/setup/mocks.ts @@ -151,6 +151,8 @@ vi.mock('../../src/middleware/authenticateServiceToken.js', () => ({ verifyServiceToken: (_req: any, _res: any, next: any) => { next(); }, + // Defaults to rejecting every token; specs opt in via tests/factories/serviceTokenFactory. + validateInternalServiceToken: vi.fn(), })); vi.mock('../../src/middleware/requireAdmin.js', () => ({ diff --git a/tests/unit/controllers/otp.spec.ts b/tests/unit/controllers/otp.spec.ts index af962eb..b64ce00 100644 --- a/tests/unit/controllers/otp.spec.ts +++ b/tests/unit/controllers/otp.spec.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { mintInternalServiceToken } from '../../factories/serviceTokenFactory.js'; + const signEphemeralTokenMock = vi.fn(); const authEventLogMock = vi.fn(); const issueSessionAndRespondMock = vi.fn(); @@ -142,7 +144,10 @@ describe('otp controller', () => { const { sendPhoneOTP } = await loadOtpController(); const user = buildUser(); const req = buildReq(user, { - headers: { 'x-seamless-auth-delivery-mode': 'external' }, + headers: { + 'x-seamless-auth-delivery-mode': 'external', + 'x-seamless-service-token': await mintInternalServiceToken(), + }, }); const res = buildRes(); @@ -198,7 +203,10 @@ describe('otp controller', () => { const { sendEmailOTP } = await loadOtpController(); const user = buildUser(); const req = buildReq(user, { - headers: { 'x-seamless-auth-delivery-mode': 'external' }, + headers: { + 'x-seamless-auth-delivery-mode': 'external', + 'x-seamless-service-token': await mintInternalServiceToken(), + }, }); const res = buildRes(); @@ -569,7 +577,12 @@ describe('otp controller', () => { const res = buildRes(); await sendLoginPhoneOTP( - buildReq(buildUser(), { headers: { 'x-seamless-auth-delivery-mode': 'external' } }), + buildReq(buildUser(), { + headers: { + 'x-seamless-auth-delivery-mode': 'external', + 'x-seamless-service-token': await mintInternalServiceToken(), + }, + }), res, ); @@ -582,7 +595,12 @@ describe('otp controller', () => { const res = buildRes(); await sendLoginEmailOTP( - buildReq(buildUser(), { headers: { 'x-seamless-auth-delivery-mode': 'external' } }), + buildReq(buildUser(), { + headers: { + 'x-seamless-auth-delivery-mode': 'external', + 'x-seamless-service-token': await mintInternalServiceToken(), + }, + }), res, ); diff --git a/tests/unit/lib/externalDelivery.spec.ts b/tests/unit/lib/externalDelivery.spec.ts index ad62f40..ee67d9d 100644 --- a/tests/unit/lib/externalDelivery.spec.ts +++ b/tests/unit/lib/externalDelivery.spec.ts @@ -18,10 +18,12 @@ function req(headers: Record) { describe('external delivery gates', () => { const originalNodeEnv = process.env.NODE_ENV; + const originalOptIn = process.env.ALLOW_UNCREDENTIALED_DELIVERY_SECRETS; beforeEach(() => { vi.clearAllMocks(); process.env.NODE_ENV = 'test'; + delete process.env.ALLOW_UNCREDENTIALED_DELIVERY_SECRETS; }); afterEach(() => { @@ -30,18 +32,87 @@ describe('external delivery gates', () => { } else { process.env.NODE_ENV = originalNodeEnv; } + + if (originalOptIn === undefined) { + delete process.env.ALLOW_UNCREDENTIALED_DELIVERY_SECRETS; + } else { + process.env.ALLOW_UNCREDENTIALED_DELIVERY_SECRETS = originalOptIn; + } }); - it('allows explicit external delivery outside production', async () => { + it('blocks external delivery outside production without a trusted service token', async () => { + (validateInternalServiceToken as any).mockResolvedValue(null); + await expect( canReturnExternalDelivery( req({ 'x-seamless-auth-delivery-mode': 'external', }), ), + ).resolves.toBe(false); + }); + + it('allows external delivery with a trusted service token outside production', async () => { + (validateInternalServiceToken as any).mockResolvedValue({ + sub: 'service', + iss: 'seamless-portal-api', + aud: 'seamless-auth', + }); + + await expect( + canReturnExternalDelivery( + req({ + 'x-seamless-auth-delivery-mode': 'external', + 'x-seamless-service-token': 'Bearer service-token', + }), + ), ).resolves.toBe(true); }); + it('rejects a token from an untrusted issuer or audience', async () => { + (validateInternalServiceToken as any).mockResolvedValue({ + sub: 'service', + iss: 'someone-else', + aud: 'seamless-auth', + }); + + await expect( + canReturnExternalDelivery( + req({ + 'x-seamless-auth-delivery-mode': 'external', + 'x-seamless-service-token': 'Bearer service-token', + }), + ), + ).resolves.toBe(false); + }); + + it('allows uncredentialed external delivery only behind the explicit local opt-in', async () => { + process.env.ALLOW_UNCREDENTIALED_DELIVERY_SECRETS = 'true'; + + await expect( + canReturnExternalDelivery( + req({ + 'x-seamless-auth-delivery-mode': 'external', + }), + ), + ).resolves.toBe(true); + + expect(validateInternalServiceToken).not.toHaveBeenCalled(); + }); + + it('ignores the local opt-in in production', async () => { + process.env.NODE_ENV = 'production'; + process.env.ALLOW_UNCREDENTIALED_DELIVERY_SECRETS = 'true'; + + await expect( + canReturnExternalDelivery( + req({ + 'x-seamless-auth-delivery-mode': 'external', + }), + ), + ).resolves.toBe(false); + }); + it('requires a valid internal service token in production', async () => { process.env.NODE_ENV = 'production'; (validateInternalServiceToken as any).mockResolvedValue({ @@ -145,7 +216,20 @@ describe('external delivery gates', () => { expect(validateInternalServiceToken).not.toHaveBeenCalled(); }); - it('requires explicit sensitive-details opt-in outside production', () => { + it('does not return sensitive details on the header alone', () => { + expect( + canReturnSensitiveDevelopmentDetails( + req({ + 'x-seamless-auth-include-sensitive': 'true', + }), + ), + ).toBe(false); + }); + + it('returns sensitive details only with the header and the local opt-in', () => { + process.env.ALLOW_UNCREDENTIALED_DELIVERY_SECRETS = 'true'; + + expect(canReturnSensitiveDevelopmentDetails(req({}))).toBe(false); expect( canReturnSensitiveDevelopmentDetails( req({ @@ -157,6 +241,7 @@ describe('external delivery gates', () => { it('never returns sensitive details in production', () => { process.env.NODE_ENV = 'production'; + process.env.ALLOW_UNCREDENTIALED_DELIVERY_SECRETS = 'true'; expect( canReturnSensitiveDevelopmentDetails(