From 24f8e81b395f21ca7f88f1bfe51bf017c7f801e6 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Wed, 29 Jul 2026 21:00:50 -0400 Subject: [PATCH] refactor(core,express): give the auth API contract values one home The external-delivery header and the service-token identity were written out at each call site: the x-seamless-auth-delivery-mode header in three core handlers, the fixed issuer and audience in three places across the adapter, and the dev-main key id fallback in three more. Each is defined by seamless-auth-api, so changing one is coordinated cross-repo work and finding every copy was part of the job. Move them to core as named constants, add buildExternalDeliveryAuthorization for the external-delivery token, and point every call site at them. No behavior change: the minted tokens carry the same header and claims, confirmed by decoding them. A new test asserts each contract value literally, so a change breaks a named test rather than surfacing as an upstream rejection at runtime. Refs #72 --- .changeset/centralize-api-contract.md | 16 +++ packages/core/src/apiContract.ts | 63 +++++++++++ packages/core/src/handlers/register.ts | 5 +- .../src/handlers/requestMagicLinkHandler.ts | 5 +- .../core/src/handlers/requestOtpHandler.ts | 5 +- packages/core/src/index.ts | 1 + packages/core/tests/apiContract.test.js | 104 ++++++++++++++++++ packages/express/src/createServer.ts | 12 +- .../src/internal/buildAuthorization.ts | 24 ++-- .../express/src/internal/validateSecrets.ts | 2 +- 10 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 .changeset/centralize-api-contract.md create mode 100644 packages/core/src/apiContract.ts create mode 100644 packages/core/tests/apiContract.test.js diff --git a/.changeset/centralize-api-contract.md b/.changeset/centralize-api-contract.md new file mode 100644 index 0000000..2f0557e --- /dev/null +++ b/.changeset/centralize-api-contract.md @@ -0,0 +1,16 @@ +--- +"@seamless-auth/core": minor +"@seamless-auth/express": patch +--- + +Give the auth API's contract values one home in `@seamless-auth/core`. + +The external-delivery header and the service-token identity were written out at each call site: the `x-seamless-auth-delivery-mode: "external"` header in three core handlers, the fixed issuer and audience in three places across the adapter, and the `dev-main` key id fallback in three more. Each is defined by `seamless-auth-api`, so changing one is coordinated cross-repo work, and finding every copy was part of the job. + +New exports: `AUTH_DELIVERY_MODE_HEADER`, `EXTERNAL_DELIVERY_MODE`, `EXTERNAL_DELIVERY_HEADERS`, `SERVICE_TOKEN_ISSUER`, `SERVICE_TOKEN_AUDIENCE`, `DEV_JWKS_KID`, `EXTERNAL_DELIVERY_TOKEN_SUBJECT`, and `buildExternalDeliveryAuthorization`, which mints the `Authorization` value for an external-delivery request. + +No behavior change. The minted tokens carry the same header and claims as before, confirmed by decoding them. A new test asserts each contract value literally, so a change to one breaks a named test rather than surfacing as an upstream rejection at runtime. + +The service-token issuer and audience are fixed by the API and are not the adopter's configured audience, which applies to user tokens. That is now stated where the constants are defined rather than in a comment at one of the call sites. + +Part of #72. diff --git a/packages/core/src/apiContract.ts b/packages/core/src/apiContract.ts new file mode 100644 index 0000000..b243aef --- /dev/null +++ b/packages/core/src/apiContract.ts @@ -0,0 +1,63 @@ +import { createServiceToken } from "./createServiceToken.js"; + +/** + * Values the auth API defines. Changing one of these is a coordinated change + * with `seamless-auth-api`, so they live here rather than being written out at + * each call site, and every adapter reads the same value. + */ + +/** + * Asks the auth API to return a delivery payload instead of sending the message + * itself, so the adopter's own transports deliver it. + */ +export const AUTH_DELIVERY_MODE_HEADER = "x-seamless-auth-delivery-mode"; +export const EXTERNAL_DELIVERY_MODE = "external"; + +/** Headers that request external delivery, spreadable into an `authFetch` call. */ +export const EXTERNAL_DELIVERY_HEADERS: Readonly> = + Object.freeze({ + [AUTH_DELIVERY_MODE_HEADER]: EXTERNAL_DELIVERY_MODE, + }); + +/** + * The auth API validates machine-to-machine service tokens against a fixed + * issuer and audience. These are not the adopter's configured audience, which + * applies to user tokens: a service token signed with the adopter's audience is + * rejected. + */ +export const SERVICE_TOKEN_ISSUER = "seamless-portal-api"; +export const SERVICE_TOKEN_AUDIENCE = "seamless-auth"; + +/** + * Fallback JWKS key id. Deploying on it is a misconfiguration, and adapters + * warn when it is in use. + */ +export const DEV_JWKS_KID = "dev-main"; + +/** + * Subject for the token that authorizes an external-delivery request. It names + * the caller's role rather than a browser user, because no user is involved: + * the adapter is telling the API to hand back a payload instead of sending it. + */ +export const EXTERNAL_DELIVERY_TOKEN_SUBJECT = + "seamless-auth-external-delivery"; + +export interface ServiceIdentityOptions { + serviceSecret: string; + jwksKid?: string; +} + +/** + * Mints the `Authorization` value for an external-delivery request. + */ +export function buildExternalDeliveryAuthorization( + opts: ServiceIdentityOptions, +): string { + return `Bearer ${createServiceToken({ + subject: EXTERNAL_DELIVERY_TOKEN_SUBJECT, + issuer: SERVICE_TOKEN_ISSUER, + audience: SERVICE_TOKEN_AUDIENCE, + serviceSecret: opts.serviceSecret, + keyId: opts.jwksKid || DEV_JWKS_KID, + })}`; +} diff --git a/packages/core/src/handlers/register.ts b/packages/core/src/handlers/register.ts index a81c0d0..5602e72 100644 --- a/packages/core/src/handlers/register.ts +++ b/packages/core/src/handlers/register.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { EXTERNAL_DELIVERY_HEADERS } from "../apiContract.js"; import type { ResultFailure } from "../result.js"; import type { CookiePayload } from "../ensureCookies.js"; @@ -37,9 +38,7 @@ export async function registerHandler( serviceAuthorization: opts.serviceAuthorization, ...(opts.externalDelivery ? { - headers: { - "x-seamless-auth-delivery-mode": "external", - }, + headers: EXTERNAL_DELIVERY_HEADERS, } : {}), }); diff --git a/packages/core/src/handlers/requestMagicLinkHandler.ts b/packages/core/src/handlers/requestMagicLinkHandler.ts index f452872..fac59e5 100644 --- a/packages/core/src/handlers/requestMagicLinkHandler.ts +++ b/packages/core/src/handlers/requestMagicLinkHandler.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { EXTERNAL_DELIVERY_HEADERS } from "../apiContract.js"; import type { ResultFailure } from "../result.js"; export interface RequestMagicLinkInput { @@ -28,9 +29,7 @@ export async function requestMagicLinkHandler( serviceAuthorization: opts.serviceAuthorization, ...(opts.externalDelivery ? { - headers: { - "x-seamless-auth-delivery-mode": "external", - }, + headers: EXTERNAL_DELIVERY_HEADERS, } : {}), }); diff --git a/packages/core/src/handlers/requestOtpHandler.ts b/packages/core/src/handlers/requestOtpHandler.ts index 064fab3..e7c3c32 100644 --- a/packages/core/src/handlers/requestOtpHandler.ts +++ b/packages/core/src/handlers/requestOtpHandler.ts @@ -1,4 +1,5 @@ import { authFetch } from "../authFetch.js"; +import { EXTERNAL_DELIVERY_HEADERS } from "../apiContract.js"; import type { ResultFailure } from "../result.js"; export interface RequestOtpInput { @@ -40,9 +41,7 @@ export async function requestOtpHandler( serviceAuthorization: opts.serviceAuthorization, ...(opts.externalDelivery ? { - headers: { - "x-seamless-auth-delivery-mode": "external", - }, + headers: EXTERNAL_DELIVERY_HEADERS, } : {}), }); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 87181c4..de7f7e9 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -15,6 +15,7 @@ export { hasScopedRole, roleGrantsAccess, } from "@seamless-auth/types/role/matching"; +export * from "./apiContract.js"; export * from "./applyResult.js"; export * from "./proxyRequest.js"; export * from "./result.js"; diff --git a/packages/core/tests/apiContract.test.js b/packages/core/tests/apiContract.test.js new file mode 100644 index 0000000..59d1308 --- /dev/null +++ b/packages/core/tests/apiContract.test.js @@ -0,0 +1,104 @@ +// These values are defined by the auth API. Asserting them literally is the +// point: a change here is a coordinated change with seamless-auth-api, and this +// test is what makes that break loudly instead of at runtime. +import jwt from "jsonwebtoken"; + +const { + AUTH_DELIVERY_MODE_HEADER, + buildExternalDeliveryAuthorization, + DEV_JWKS_KID, + EXTERNAL_DELIVERY_HEADERS, + EXTERNAL_DELIVERY_MODE, + EXTERNAL_DELIVERY_TOKEN_SUBJECT, + SERVICE_TOKEN_AUDIENCE, + SERVICE_TOKEN_ISSUER, +} = await import("../dist/apiContract.js"); + +const SERVICE_SECRET = "service-secret-service-secret-service-secret"; + +describe("auth API contract values", () => { + it("pins the external delivery header", () => { + expect(AUTH_DELIVERY_MODE_HEADER).toBe("x-seamless-auth-delivery-mode"); + expect(EXTERNAL_DELIVERY_MODE).toBe("external"); + expect(EXTERNAL_DELIVERY_HEADERS).toEqual({ + "x-seamless-auth-delivery-mode": "external", + }); + }); + + it("pins the service token identity", () => { + expect(SERVICE_TOKEN_ISSUER).toBe("seamless-portal-api"); + expect(SERVICE_TOKEN_AUDIENCE).toBe("seamless-auth"); + expect(DEV_JWKS_KID).toBe("dev-main"); + }); + + it("does not let a caller mutate the shared header object", () => { + expect(() => { + EXTERNAL_DELIVERY_HEADERS["x-seamless-auth-delivery-mode"] = "internal"; + }).toThrow(); + expect(EXTERNAL_DELIVERY_HEADERS[AUTH_DELIVERY_MODE_HEADER]).toBe( + "external", + ); + }); +}); + +describe("buildExternalDeliveryAuthorization", () => { + it("mints a bearer token with the fixed service identity", () => { + const authorization = buildExternalDeliveryAuthorization({ + serviceSecret: SERVICE_SECRET, + jwksKid: "main-2026", + }); + + expect(authorization.startsWith("Bearer ")).toBe(true); + + const decoded = jwt.decode(authorization.slice("Bearer ".length), { + complete: true, + }); + + expect(decoded.header).toMatchObject({ alg: "HS256", kid: "main-2026" }); + expect(decoded.payload).toMatchObject({ + iss: SERVICE_TOKEN_ISSUER, + aud: SERVICE_TOKEN_AUDIENCE, + sub: EXTERNAL_DELIVERY_TOKEN_SUBJECT, + }); + }); + + // The audience an adopter configures applies to user tokens. A service token + // signed with it is rejected upstream. + it("ignores any adopter audience and uses the service audience", () => { + const authorization = buildExternalDeliveryAuthorization({ + serviceSecret: SERVICE_SECRET, + jwksKid: "main-2026", + audience: "https://adopter.example.com", + }); + + const { aud } = jwt.decode(authorization.slice("Bearer ".length)); + + expect(aud).toBe(SERVICE_TOKEN_AUDIENCE); + }); + + it("falls back to the dev key id when none is configured", () => { + const authorization = buildExternalDeliveryAuthorization({ + serviceSecret: SERVICE_SECRET, + }); + + const decoded = jwt.decode(authorization.slice("Bearer ".length), { + complete: true, + }); + + expect(decoded.header.kid).toBe(DEV_JWKS_KID); + }); + + it("verifies against the service secret", () => { + const authorization = buildExternalDeliveryAuthorization({ + serviceSecret: SERVICE_SECRET, + jwksKid: "main-2026", + }); + + expect(() => + jwt.verify(authorization.slice("Bearer ".length), SERVICE_SECRET, { + issuer: SERVICE_TOKEN_ISSUER, + audience: SERVICE_TOKEN_AUDIENCE, + }), + ).not.toThrow(); + }); +}); diff --git a/packages/express/src/createServer.ts b/packages/express/src/createServer.ts index 7e2c81b..28cf042 100644 --- a/packages/express/src/createServer.ts +++ b/packages/express/src/createServer.ts @@ -33,6 +33,8 @@ import { checkProxyIdentity, proxyRequest, redactSensitiveText, + SERVICE_TOKEN_AUDIENCE, + SERVICE_TOKEN_ISSUER, } from "@seamless-auth/core"; import { buildProxyServiceAuthorization, @@ -284,11 +286,11 @@ export function createSeamlessAuthServer( preAuthCookieName: resolvedOpts.preAuthCookieName, cookieSecret: resolvedOpts.cookieSecret, serviceSecret: resolvedOpts.serviceSecret, - // The silent-refresh path mints an M2M service token that the auth API - // validates with a fixed issuer/audience (see buildInternalServiceAuthorization), - // not the adopter-configured audience. - issuer: "seamless-portal-api", - audience: "seamless-auth", + // The silent-refresh path mints an M2M service token, which the auth API + // validates against a fixed issuer and audience rather than the + // adopter-configured one. + issuer: SERVICE_TOKEN_ISSUER, + audience: SERVICE_TOKEN_AUDIENCE, keyId: resolvedOpts.jwksKid, resolveClientIp: resolvedOpts.resolveClientIp, }), diff --git a/packages/express/src/internal/buildAuthorization.ts b/packages/express/src/internal/buildAuthorization.ts index 0d96823..729f884 100644 --- a/packages/express/src/internal/buildAuthorization.ts +++ b/packages/express/src/internal/buildAuthorization.ts @@ -1,4 +1,10 @@ -import { createServiceToken } from "@seamless-auth/core"; +import { + buildExternalDeliveryAuthorization, + createServiceToken, + DEV_JWKS_KID, + SERVICE_TOKEN_AUDIENCE, + SERVICE_TOKEN_ISSUER, +} from "@seamless-auth/core"; import { Request } from "express"; import { SeamlessAuthServerOptions } from "../createServer"; @@ -34,7 +40,7 @@ export function buildProxyServiceAuthorization( return undefined; } - const keyId = opts.jwksKid || "dev-main"; + const keyId = opts.jwksKid || DEV_JWKS_KID; const now = Date.now(); if ( @@ -48,8 +54,8 @@ export function buildProxyServiceAuthorization( const authorization = `Bearer ${createServiceToken({ subject: PROXY_TOKEN_SUBJECT, - issuer: "seamless-portal-api", - audience: "seamless-auth", + issuer: SERVICE_TOKEN_ISSUER, + audience: SERVICE_TOKEN_AUDIENCE, serviceSecret: opts.serviceSecret, keyId, })}`; @@ -67,13 +73,5 @@ export function buildProxyServiceAuthorization( export function buildInternalServiceAuthorization( opts: SeamlessAuthServerOptions, ) { - const token = createServiceToken({ - subject: "seamless-auth-external-delivery", - issuer: "seamless-portal-api", - audience: "seamless-auth", - serviceSecret: opts.serviceSecret, - keyId: opts.jwksKid || "dev-main", - }); - - return `Bearer ${token}`; + return buildExternalDeliveryAuthorization(opts); } diff --git a/packages/express/src/internal/validateSecrets.ts b/packages/express/src/internal/validateSecrets.ts index 715a8d5..5b4e20f 100644 --- a/packages/express/src/internal/validateSecrets.ts +++ b/packages/express/src/internal/validateSecrets.ts @@ -4,7 +4,7 @@ export { assertSecrets, } from "@seamless-auth/core"; -const DEV_JWKS_KID = "dev-main"; +import { DEV_JWKS_KID } from "@seamless-auth/core"; export function warnOnDevJwksKid(jwksKid: string | undefined): void { if (!jwksKid || jwksKid === DEV_JWKS_KID) {