From 95d321f72e8069f247df91ef122d8ead42c6a334 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 3 Jun 2026 21:39:38 -0400 Subject: [PATCH] fix: token issuance could be invalid but still proceed --- .changeset/social-cars-unite.md | 5 ++ .env.example | 9 ++-- README.md | 2 + docs/production-operations.md | 7 ++- src/controllers/jwks.ts | 2 +- src/middleware/authenticateServiceToken.ts | 51 +++++++++++++++++-- tests/integration/jwks/jwks.spec.ts | 1 + .../middleware/verifyServiceToken.spec.ts | 18 +++++++ 8 files changed, 85 insertions(+), 10 deletions(-) create mode 100644 .changeset/social-cars-unite.md diff --git a/.changeset/social-cars-unite.md b/.changeset/social-cars-unite.md new file mode 100644 index 0000000..d59a02c --- /dev/null +++ b/.changeset/social-cars-unite.md @@ -0,0 +1,5 @@ +--- +'seamless-auth-api': patch +--- + +Fixed an issue with passing non-valid tokens through to the auth server diff --git a/.env.example b/.env.example index cffc410..36fa844 100644 --- a/.env.example +++ b/.env.example @@ -71,6 +71,9 @@ OAUTH_STATE_SECRET= # ADMIN BOOTSTRAP SEAMLESS_BOOTSTRAP_ENABLED=true SEAMLESS_BOOTSTRAP_SECRET=dev-bootstrap-secret-123 +# Development only. When true, logs bootstrap invite links/secrets for local debugging. +# Never enable in production. +SEAMLESS_AUTH_DEBUG_SECRETS=false # OPTIONAL DIRECT DELIVERY # Needed only if this auth API sends OTPs or magic links itself. @@ -86,6 +89,6 @@ MESSAGING_TWILIO_AUTH_TOKEN= # PRODUCTION SIGNING AND JWKS SECRETS # Required when NODE_ENV=production. -# SEAMLESS_JWKS_ACTIVE_KID=main-2026-04 -# SEAMLESS_JWKS_KEY_main-2026-04_PRIVATE="-----BEGIN PRIVATE KEY-----..." -# JWKS_PUBLIC_KEYS={"keys":[{"kid":"main-2026-04","pem":"-----BEGIN PUBLIC KEY-----...","createdAt":"2026-04-22T00:00:00.000Z"}]} +# SEAMLESS_JWKS_ACTIVE_KID=main_2026_04 +# SEAMLESS_JWKS_KEY_main_2026_04_PRIVATE="-----BEGIN PRIVATE KEY-----..." +# SEAMLESS_JWKS_PUBLIC_KEYS={"keys":[{"kid":"main_2026_04","pem":"-----BEGIN PUBLIC KEY-----...","createdAt":"2026-04-22T00:00:00.000Z"}]} diff --git a/README.md b/README.md index 64c705e..0c261a3 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,7 @@ Seamless Auth API returns JSON tokens instead of browser auth cookies. - Refresh uses the opaque `refreshToken` value, not the access token. - Internal service tokens remain separate. They are used only by explicitly service-token-protected paths or headers such as external delivery support, not as user access or ephemeral bearer tokens. + Do not send SeamlessAuth access or ephemeral JWTs as `x-seamless-service-token`. --- @@ -280,6 +281,7 @@ through admin endpoints. Delivery payloads that contain OTPs or magic-link/bootstrap tokens are returned only when callers explicitly request external delivery with `x-seamless-auth-delivery-mode: external`. In production, external delivery also requires a valid `x-seamless-service-token` from a trusted server adapter. +This must be an internal service token, not a SeamlessAuth access or ephemeral token. Development-only bootstrap token details require `x-seamless-auth-include-sensitive: true` and are never enabled in production. diff --git a/docs/production-operations.md b/docs/production-operations.md index 2351b65..d88f25c 100644 --- a/docs/production-operations.md +++ b/docs/production-operations.md @@ -24,7 +24,7 @@ Production deployments should define: - `OAUTH_STATE_SECRET` - `SEAMLESS_JWKS_ACTIVE_KID` - `SEAMLESS_JWKS_KEY__PRIVATE` -- `JWKS_PUBLIC_KEYS` +- `SEAMLESS_JWKS_PUBLIC_KEYS` - OAuth client-secret environment variables referenced by provider `clientSecretEnv` - Messaging provider credentials when direct delivery is enabled @@ -35,12 +35,15 @@ Do not store raw secrets in `system_config`. Access tokens are signed with configured JWKS signing keys. A typical rotation is: 1. Generate a new key pair. -2. Publish the new public key in `JWKS_PUBLIC_KEYS`. +2. Publish the new public key in `SEAMLESS_JWKS_PUBLIC_KEYS`. 3. Deploy with both old and new public keys available. 4. Switch `SEAMLESS_JWKS_ACTIVE_KID` to the new key id. 5. Keep retired public keys until all tokens signed with them expire. 6. Remove retired public keys after the token TTL window. +Use key ids with letters, numbers, and underscores because the active key id is used to derive the +private-key environment variable name: `SEAMLESS_JWKS_KEY__PRIVATE`. + ## Refresh Tokens Refresh tokens are opaque values. The API stores hashes and lookup fingerprints, not raw refresh tokens. diff --git a/src/controllers/jwks.ts b/src/controllers/jwks.ts index 076a700..aaad194 100644 --- a/src/controllers/jwks.ts +++ b/src/controllers/jwks.ts @@ -29,7 +29,7 @@ export function __resetJwksCache() { async function loadJwksFromSecrets(): Promise { logger.info('Loading JWKS from Secrets Manager'); - const raw = await getSecret('JWKS_PUBLIC_KEYS'); + const raw = await getSecret('SEAMLESS_JWKS_PUBLIC_KEYS'); const parsed = JSON.parse(raw); const jwks: JWK[] = []; diff --git a/src/middleware/authenticateServiceToken.ts b/src/middleware/authenticateServiceToken.ts index d1d7c86..aa84f8a 100644 --- a/src/middleware/authenticateServiceToken.ts +++ b/src/middleware/authenticateServiceToken.ts @@ -14,6 +14,33 @@ import { getSecret } from '../utils/secretsStore.js'; const logger = getLogger('authenticateServiceToken'); let cachedSecret: string | null = null; +const INTERNAL_SERVICE_TOKEN_ALGORITHMS = ['HS256', 'HS384', 'HS512'] as const; + +interface InternalServiceTokenValidationOptions { + logInvalid?: boolean; +} + +function getJwtAlgorithm(token: string): string | null { + const decoded = jwt.decode(token, { complete: true }); + + if (!decoded || typeof decoded !== 'object') { + return null; + } + + const alg = (decoded as { header?: { alg?: unknown } }).header?.alg; + + return typeof alg === 'string' ? alg : null; +} + +function usesSupportedInternalServiceAlgorithm(token: string) { + const alg = getJwtAlgorithm(token); + + if (!alg) { + return true; + } + + return (INTERNAL_SERVICE_TOKEN_ALGORITHMS as readonly string[]).includes(alg); +} async function getInternalSecret() { if (cachedSecret) return cachedSecret; @@ -21,7 +48,10 @@ async function getInternalSecret() { return cachedSecret; } -export async function validateInternalServiceToken(token: string): Promise { +export async function validateInternalServiceToken( + token: string, + options: InternalServiceTokenValidationOptions = {}, +): Promise { const internalSecret = await getInternalSecret(); if (!token || !internalSecret) { @@ -29,9 +59,22 @@ export async function validateInternalServiceToken(token: string): Promise { expect(res.status).toBe(200); expect(res.body.keys[0].kid).toBe('key-1'); + expect(getSecret).toHaveBeenCalledWith('SEAMLESS_JWKS_PUBLIC_KEYS'); expect(res.headers['cache-control']).toContain('max-age=300'); }); }); diff --git a/tests/unit/middleware/verifyServiceToken.spec.ts b/tests/unit/middleware/verifyServiceToken.spec.ts index 17733bd..8bff054 100644 --- a/tests/unit/middleware/verifyServiceToken.spec.ts +++ b/tests/unit/middleware/verifyServiceToken.spec.ts @@ -4,6 +4,7 @@ import { verifyServiceToken } from '../../../src/middleware/authenticateServiceT vi.unmock('../../../src/middleware/authenticateServiceToken'); vi.mock('jsonwebtoken', () => ({ default: { + decode: vi.fn(() => ({ header: { alg: 'HS256' } })), verify: vi.fn(), }, })); @@ -151,4 +152,21 @@ describe('verifyServiceToken', () => { expect(res.status).toHaveBeenCalledWith(401); }); + + it('rejects RS256 user JWTs without attempting internal service verification', async () => { + const { getSecret } = await import('../../../src/utils/secretsStore'); + const jwt = await import('jsonwebtoken'); + + (getSecret as any).mockResolvedValue('secret'); + (jwt.default.decode as any).mockReturnValue({ + header: { alg: 'RS256' }, + }); + + const { validateInternalServiceToken } = + await import('../../../src/middleware/authenticateServiceToken'); + + await expect(validateInternalServiceToken('user-jwt')).resolves.toBeNull(); + + expect(jwt.default.verify).not.toHaveBeenCalled(); + }); });