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
5 changes: 5 additions & 0 deletions .changeset/require-service-token-for-external-delivery.md
Original file line number Diff line number Diff line change
@@ -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`.
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 7 additions & 5 deletions docs/direct-http-quickstart.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,17 @@ EPHEMERAL=<EPHEMERAL_TOKEN>
`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
Expand Down
6 changes: 4 additions & 2 deletions docs/extending.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 24 additions & 2 deletions src/lib/externalDelivery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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;
}

Expand All @@ -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;
}

Expand Down
27 changes: 27 additions & 0 deletions tests/factories/serviceTokenFactory.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown> = {}) {
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;
}
7 changes: 6 additions & 1 deletion tests/integration/bootstrap/bootstrap.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -18,6 +19,7 @@ beforeAll(async () => {

beforeEach(() => {
vi.clearAllMocks();
vi.unstubAllEnvs();
});

vi.mock('../../../src/services/bootstrapService.js', () => ({
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 5 additions & 2 deletions tests/integration/magicLink/magicLink.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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(
Expand Down
3 changes: 3 additions & 0 deletions tests/integration/registration/register.spec.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions tests/setup/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => ({
Expand Down
26 changes: 22 additions & 4 deletions tests/unit/controllers/otp.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
Expand Down Expand Up @@ -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();

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

Expand Down Expand Up @@ -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,
);

Expand All @@ -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,
);

Expand Down
Loading
Loading