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
16 changes: 16 additions & 0 deletions .changeset/centralize-api-contract.md
Original file line number Diff line number Diff line change
@@ -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.
63 changes: 63 additions & 0 deletions packages/core/src/apiContract.ts
Original file line number Diff line number Diff line change
@@ -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<Record<string, string>> =
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,
})}`;
}
5 changes: 2 additions & 3 deletions packages/core/src/handlers/register.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -37,9 +38,7 @@ export async function registerHandler(
serviceAuthorization: opts.serviceAuthorization,
...(opts.externalDelivery
? {
headers: {
"x-seamless-auth-delivery-mode": "external",
},
headers: EXTERNAL_DELIVERY_HEADERS,
}
: {}),
});
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/handlers/requestMagicLinkHandler.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -28,9 +29,7 @@ export async function requestMagicLinkHandler(
serviceAuthorization: opts.serviceAuthorization,
...(opts.externalDelivery
? {
headers: {
"x-seamless-auth-delivery-mode": "external",
},
headers: EXTERNAL_DELIVERY_HEADERS,
}
: {}),
});
Expand Down
5 changes: 2 additions & 3 deletions packages/core/src/handlers/requestOtpHandler.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -40,9 +41,7 @@ export async function requestOtpHandler(
serviceAuthorization: opts.serviceAuthorization,
...(opts.externalDelivery
? {
headers: {
"x-seamless-auth-delivery-mode": "external",
},
headers: EXTERNAL_DELIVERY_HEADERS,
}
: {}),
});
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
104 changes: 104 additions & 0 deletions packages/core/tests/apiContract.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
12 changes: 7 additions & 5 deletions packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ import {
checkProxyIdentity,
proxyRequest,
redactSensitiveText,
SERVICE_TOKEN_AUDIENCE,
SERVICE_TOKEN_ISSUER,
} from "@seamless-auth/core";
import {
buildProxyServiceAuthorization,
Expand Down Expand Up @@ -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,
}),
Expand Down
24 changes: 11 additions & 13 deletions packages/express/src/internal/buildAuthorization.ts
Original file line number Diff line number Diff line change
@@ -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";

Expand Down Expand Up @@ -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 (
Expand All @@ -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,
})}`;
Expand All @@ -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);
}
2 changes: 1 addition & 1 deletion packages/express/src/internal/validateSecrets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading