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/social-cars-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'seamless-auth-api': patch
---

Fixed an issue with passing non-valid tokens through to the auth server
9 changes: 6 additions & 3 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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"}]}
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.

---

Expand Down Expand Up @@ -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.

Expand Down
7 changes: 5 additions & 2 deletions docs/production-operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Production deployments should define:
- `OAUTH_STATE_SECRET`
- `SEAMLESS_JWKS_ACTIVE_KID`
- `SEAMLESS_JWKS_KEY_<kid>_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

Expand All @@ -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_<kid>_PRIVATE`.

## Refresh Tokens

Refresh tokens are opaque values. The API stores hashes and lookup fingerprints, not raw refresh tokens.
Expand Down
2 changes: 1 addition & 1 deletion src/controllers/jwks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ export function __resetJwksCache() {
async function loadJwksFromSecrets(): Promise<JWK[]> {
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[] = [];
Expand Down
51 changes: 47 additions & 4 deletions src/middleware/authenticateServiceToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,24 +14,67 @@ 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;
cachedSecret = await getSecret('API_SERVICE_TOKEN');
return cachedSecret;
}

export async function validateInternalServiceToken(token: string): Promise<JwtPayload | null> {
export async function validateInternalServiceToken(
token: string,
options: InternalServiceTokenValidationOptions = {},
): Promise<JwtPayload | null> {
const internalSecret = await getInternalSecret();

if (!token || !internalSecret) {
return null;
}

try {
return jwt.verify(token, internalSecret) as JwtPayload;
if (!usesSupportedInternalServiceAlgorithm(token)) {
if (options.logInvalid) {
logger.warn('Rejected internal service token with unsupported algorithm');
}

return null;
}

return jwt.verify(token, internalSecret, {
algorithms: [...INTERNAL_SERVICE_TOKEN_ALGORITHMS],
}) as JwtPayload;
} catch (error: unknown) {
logger.error(`An error occured validating api to api service. ${error}`);
if (options.logInvalid) {
logger.error(`An error occured validating api to api service. ${error}`);
}

return null;
}
}
Expand All @@ -50,7 +93,7 @@ export async function verifyServiceToken(req: ServiceRequest, res: Response, nex
return res.status(401).json({ error: 'No token provided' });
}

const decoded = await validateInternalServiceToken(token);
const decoded = await validateInternalServiceToken(token, { logInvalid: true });

if (!decoded) {
logger.error('Call to internal endpoints missing M2M token.');
Expand Down
1 change: 1 addition & 0 deletions tests/integration/jwks/jwks.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ describe('JWKS - Production Mode', () => {
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');
});
});
Expand Down
18 changes: 18 additions & 0 deletions tests/unit/middleware/verifyServiceToken.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
},
}));
Expand Down Expand Up @@ -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();
});
});
Loading