From 8e9131ac0986954b47a542f71f205dd6787cec6e Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 12 Sep 2026 18:38:45 -0700 Subject: [PATCH 1/2] feat(guards): accept the auth API's access token as a bearer credential requireAuth and getSeamlessUser read req.cookies and nothing else, so a native client, which has no cookie jar and holds the auth API's own tokens, was rejected on every request to an adopter's routes. requireAuth takes an optional authServerUrl + audience pair. With both set it also accepts Authorization: Bearer , verified against the auth API's JWKS and required to carry typ "access", so a sign-in flow's ephemeral token is refused even though the same key signs it. The cookie wins when both are present, and leaving the pair out keeps the guard cookie-only. getSeamlessUser resolves a bearer session too. The router options already carry the audience, so a request with no cookie but a valid bearer token is verified the same way and forwarded to GET /users/me, while a request with neither, or a token that fails verification, returns null without an upstream call. Core exports verifyAccessToken, extractBearerToken, authenticateBearer and authenticateRequest. The JWKS memo moves to a shared module so both verifiers share one instance per auth server. Fastify gains direct guard tests, which it had only through the parity suite. Refs #147. --- .changeset/bearer-tokens-in-guards.md | 33 +++ packages/core/README.md | 12 +- packages/core/src/getSeamlessUser.ts | 61 +++- packages/core/src/guards.ts | 105 +++++++ packages/core/src/index.ts | 1 + packages/core/src/jwks.ts | 19 ++ packages/core/src/verifyAccessToken.ts | 68 +++++ packages/core/src/verifySignedAuthResponse.ts | 34 +-- .../core/tests/authenticateBearer.test.js | 247 ++++++++++++++++ .../core/tests/getSeamlessUser.bearer.test.js | 151 ++++++++++ packages/core/tests/publicExports.test.js | 12 + packages/core/tests/verifyAccessToken.test.js | 153 ++++++++++ packages/express/README.md | 39 ++- packages/express/src/getSeamlessUser.ts | 3 + .../express/src/middleware/requireAuth.ts | 62 +++- .../express/tests/requireAuth.bearer.test.js | 216 ++++++++++++++ packages/fastify/README.md | 28 +- packages/fastify/src/getSeamlessUser.ts | 3 + packages/fastify/src/guards.ts | 55 +++- packages/fastify/tests/guards.test.js | 272 ++++++++++++++++++ 20 files changed, 1504 insertions(+), 70 deletions(-) create mode 100644 .changeset/bearer-tokens-in-guards.md create mode 100644 packages/core/src/jwks.ts create mode 100644 packages/core/src/verifyAccessToken.ts create mode 100644 packages/core/tests/authenticateBearer.test.js create mode 100644 packages/core/tests/getSeamlessUser.bearer.test.js create mode 100644 packages/core/tests/verifyAccessToken.test.js create mode 100644 packages/express/tests/requireAuth.bearer.test.js create mode 100644 packages/fastify/tests/guards.test.js diff --git a/.changeset/bearer-tokens-in-guards.md b/.changeset/bearer-tokens-in-guards.md new file mode 100644 index 0000000..76e067a --- /dev/null +++ b/.changeset/bearer-tokens-in-guards.md @@ -0,0 +1,33 @@ +--- +'@seamless-auth/core': minor +'@seamless-auth/express': minor +'@seamless-auth/fastify': minor +--- + +Accept the auth API's access token as a bearer credential in `requireAuth` and `getSeamlessUser`. + +Both guards read `req.cookies` and nothing else, so a native client, which has no cookie jar +and holds the auth API's own tokens, was rejected on every request to an adopter's routes. +`requireAuth` now takes an optional `authServerUrl` + `audience` pair; with both configured it +also accepts `Authorization: Bearer `, verified against the auth API's JWKS +(issuer, audience, expiry) and required to carry `typ: "access"`, so a sign-in flow's +ephemeral token is refused even though the same key signs it. The cookie wins when both are +present, and leaving the pair out keeps the guard cookie-only, which is what every earlier +version did. Half of the pair is a setup error. + +`getSeamlessUser` resolves a bearer session too. The router options already carry +`authServerUrl` and `audience`, so no new option is needed there: a request with no access +cookie but a valid bearer token is verified the same way and forwarded to `GET /users/me` as +the caller's identity, while a request with neither, or a token that fails verification, +returns `null` without an upstream call. + +On the bearer path `req.user.email` and `req.user.phone` are unset, because the access token +does not carry them; `getSeamlessUser` hydrates the profile. + +Core exports `verifyAccessToken`, `extractBearerToken`, `authenticateBearer`, and +`authenticateRequest`. `getSeamlessUser` in core takes an optional `bearer: { authorization, +audience }`. The JWKS memo that `verifySignedAuthResponse` used moves to a shared module so both +verifiers share one instance per auth server. Fastify gains direct tests for its guards, which +until now were covered only through the plugin parity suite. + +Tracks fells-code/seamless-auth-server#147. diff --git a/packages/core/README.md b/packages/core/README.md index 1326296..4b64612 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -92,9 +92,19 @@ remain for direct imports. - `verifyCookieJwt(...)` – verifies signed cookie payloads - `verifyRefreshCookie(...)` – verifies a refresh cookie, returning `null` on failure - `verifySignedAuthResponse(...)` – verifies an auth API response signature against its JWKS -- `getSeamlessUser(...)` – resolves the hydrated user, typed as `SeamlessUser | null` +- `verifyAccessToken(...)` – verifies an auth API access token against its JWKS, requiring `typ: "access"` +- `extractBearerToken(...)` – reads the token out of an `Authorization: Bearer` header +- `getSeamlessUser(...)` – resolves the hydrated user, typed as `SeamlessUser | null`, from a cookie or a bearer token - `hasScopedRole(...)` – checks scoped role grants such as `admin:read` +**Guards** + +- `authenticateCookie(...)` – verifies an access cookie into a session +- `authenticateBearer(...)` – verifies a bearer access token into a session +- `authenticateRequest(...)` – the cookie when present, otherwise the bearer token when enabled +- `authorizeRoles(...)` – role check against an authenticated session +- `checkOrigin(...)` – cross-site request check for `SameSite=None` deployments + **Building an adapter** - `applyResult(result, adapter, opts)` – turns a handler result into a response diff --git a/packages/core/src/getSeamlessUser.ts b/packages/core/src/getSeamlessUser.ts index bb1a1b4..1b80bc4 100644 --- a/packages/core/src/getSeamlessUser.ts +++ b/packages/core/src/getSeamlessUser.ts @@ -1,8 +1,9 @@ import type { MeUser } from "@seamless-auth/types"; -import { verifyCookieJwt } from "./verifyCookieJwt.js"; import { authFetch } from "./authFetch.js"; import { assertSecretStrength } from "./validateSecrets.js"; +import { extractBearerToken, verifyAccessToken } from "./verifyAccessToken.js"; +import { verifyCookieJwt } from "./verifyCookieJwt.js"; /** * The user object returned by the auth server's `GET /users/me`. @@ -27,13 +28,25 @@ export interface GetSeamlessUserOptions { serviceAuthorization?: string; forwardedClientIp?: string; forwardedUserAgent?: string; + /** + * Enables the bearer path for a request that carries no access cookie: the + * auth API's own access token in `Authorization: Bearer`, verified against the + * API's JWKS before it is forwarded. Left out, only the cookie is consulted. + */ + bearer?: { + /** The request's raw `Authorization` header. */ + authorization?: string; + /** Expected `aud` on the access token. The issuer is `authServerUrl`. */ + audience: string; + }; } /** - * Resolves the authenticated Seamless Auth user from an access cookie. + * Resolves the authenticated Seamless Auth user from the request's credential. * * This function: - * - Verifies the access cookie locally + * - Verifies the access cookie locally, or the bearer access token against JWKS + * when there is no cookie and `bearer` is configured * - Uses the verified token to authenticate a request to the auth server * - Returns the canonical user object, or null if authentication fails * @@ -45,17 +58,12 @@ export async function getSeamlessUser( ): Promise { assertSecretStrength("cookieSecret", opts.cookieSecret); - const cookieName = opts.cookieName ?? "seamless-access"; - const token = cookies[cookieName]; - - if (!token) return null; - - const payload = verifyCookieJwt(token, opts.cookieSecret); - if (!payload) return null; + const authorization = await resolveUpstreamAuthorization(cookies, opts); + if (authorization === null) return null; const response = await authFetch(`${opts.authServerUrl}/users/me`, { method: "GET", - authorization: opts.authorization, + authorization, serviceAuthorization: opts.serviceAuthorization, forwardedClientIp: opts.forwardedClientIp, forwardedUserAgent: opts.forwardedUserAgent, @@ -66,3 +74,34 @@ export async function getSeamlessUser( const data = await response.json(); return data?.user ?? null; } + +/** + * `null` means the request carried nothing that verifies. `undefined` keeps the + * cookie path's historical shape: a verified cookie with no caller-supplied + * `authorization` still reaches the auth server, which decides for itself. + */ +async function resolveUpstreamAuthorization( + cookies: Record, + opts: GetSeamlessUserOptions, +): Promise { + const cookieName = opts.cookieName ?? "seamless-access"; + const cookie = cookies[cookieName]; + + if (cookie) { + const payload = verifyCookieJwt(cookie, opts.cookieSecret); + return payload ? opts.authorization : null; + } + + if (!opts.bearer) return null; + + const token = extractBearerToken(opts.bearer.authorization); + if (!token) return null; + + const claims = await verifyAccessToken( + token, + opts.authServerUrl, + opts.bearer.audience, + ); + + return claims ? `Bearer ${token}` : null; +} diff --git a/packages/core/src/guards.ts b/packages/core/src/guards.ts index 608b304..3b2f8ff 100644 --- a/packages/core/src/guards.ts +++ b/packages/core/src/guards.ts @@ -3,6 +3,7 @@ import type { SeamlessAuthUser } from "@seamless-auth/types"; import { resolveCookieSameSite, type CookieSameSite } from "./applyResult.js"; import { hasScopedRole } from "@seamless-auth/types/role/matching"; import { assertSecretStrength } from "./validateSecrets.js"; +import { extractBearerToken, verifyAccessToken } from "./verifyAccessToken.js"; import { verifyCookieJwt } from "./verifyCookieJwt.js"; /** @@ -153,6 +154,110 @@ export function authenticateCookie(input: CookieAuthInput): CookieAuthResult { }; } +export interface BearerAuthInput { + /** The raw `Authorization` header, or `undefined` when the request carried none. */ + authorization?: string; + authServerUrl: string; + /** Expected `aud` on the access token. The issuer is `authServerUrl`. */ + audience: string; +} + +export type AuthResult = CookieAuthResult; + +function missingTokenRejection(warn: string): GuardRejection { + return { + status: 401, + errorCode: "Failed to find authentication token required", + warn, + }; +} + +/** + * Verifies an access token the auth API issued, carried as `Authorization: + * Bearer`, into a session. + * + * This is the path a native client takes: it has no cookie jar, so it holds the + * API's own access token and presents it directly. The token carries `sub`, + * `roles` and the session id but not the profile, so `email` and `phone` are + * left unset; `getSeamlessUser` hydrates them. + */ +export async function authenticateBearer( + input: BearerAuthInput, +): Promise { + const token = extractBearerToken(input.authorization); + + if (!token) { + return { + rejection: missingTokenRejection( + "Missing expected Authorization bearer token.", + ), + }; + } + + const claims = await verifyAccessToken( + token, + input.authServerUrl, + input.audience, + ); + + if (!claims) { + return { + rejection: { status: 401, errorCode: "Invalid or expired session" }, + }; + } + + return { + user: { + id: claims.sub, + roles: Array.isArray(claims.roles) ? claims.roles : [], + iat: claims.iat, + exp: claims.exp, + token, + }, + }; +} + +export interface RequestAuthInput extends CookieAuthInput { + /** The raw `Authorization` header, or `undefined` when the request carried none. */ + authorization?: string; + /** + * Enables bearer tokens. Left out, the guard accepts cookies only, which is + * what every adopter predating this option gets. + */ + bearer?: { authServerUrl: string; audience: string }; +} + +/** + * Authenticates a request from whichever credential it carries. + * + * The cookie wins when present, including when it is invalid: a browser with a + * stale cookie should see the session rejected rather than fall through to a + * header it never meant to send. Bearer is only consulted when there is no + * cookie at all and the adopter has opted in. + */ +export async function authenticateRequest( + input: RequestAuthInput, +): Promise { + if (input.token || !input.bearer) { + return authenticateCookie(input); + } + + assertSecretStrength("requireAuth: cookieSecret", input.cookieSecret); + + if (!extractBearerToken(input.authorization)) { + return { + rejection: missingTokenRejection( + "Missing expected auth cookie or Authorization bearer token.", + ), + }; + } + + return authenticateBearer({ + authorization: input.authorization, + ...input.bearer, + }); +} + /** * Authorization only, against a session a guard has already authenticated. * diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index e5cb65a..cec24fc 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -5,6 +5,7 @@ export * from "./ensureCookies.js"; export * from "./verifyCookieJwt.js"; export * from "./verifyRefreshCookie.js"; export * from "./verifySignedAuthResponse.js"; +export * from "./verifyAccessToken.js"; export * from "./refreshAccessToken.js"; export * from "./getSeamlessUser.js"; export * from "./logger.js"; diff --git a/packages/core/src/jwks.ts b/packages/core/src/jwks.ts new file mode 100644 index 0000000..f9e49d6 --- /dev/null +++ b/packages/core/src/jwks.ts @@ -0,0 +1,19 @@ +import { createRemoteJWKSet } from "jose"; + +// jose caches keys and applies a refetch cooldown per JWKS instance, so the instance +// must outlive a single call. Memoize per JWKS URL (one per auth server, so the map +// stays tiny) instead of building a fresh, empty-cache instance on every verification. +const jwksByUrl = new Map>(); + +export function getAuthServerJwks( + authServerUrl: string, +): ReturnType { + const jwksUrl = new URL("/.well-known/jwks.json", authServerUrl).toString(); + + let jwks = jwksByUrl.get(jwksUrl); + if (!jwks) { + jwks = createRemoteJWKSet(new URL(jwksUrl)); + jwksByUrl.set(jwksUrl, jwks); + } + return jwks; +} diff --git a/packages/core/src/verifyAccessToken.ts b/packages/core/src/verifyAccessToken.ts new file mode 100644 index 0000000..85fd87f --- /dev/null +++ b/packages/core/src/verifyAccessToken.ts @@ -0,0 +1,68 @@ +import { jwtVerify, type JWTPayload } from "jose"; +import { getAuthServerJwks } from "./jwks.js"; + +/** + * The claims the auth API signs into an access token. + * + * `typ` is what separates a session from the ephemeral token a sign-in flow + * carries between steps. Both are RS256, both have the same issuer and + * audience, so a verifier that only checks the signature would accept a + * registration-in-progress as a signed-in user. + */ +export interface AccessTokenClaims extends JWTPayload { + sub: string; + typ: "access"; + sid?: string; + roles?: string[]; + org_id?: string; +} + +/** + * Returns a bearer token's value, or `undefined` when the header is absent or + * uses another scheme. + */ +export function extractBearerToken( + authorization: string | undefined, +): string | undefined { + if (!authorization) return undefined; + + const [scheme, ...rest] = authorization.trim().split(/\s+/); + if (scheme?.toLowerCase() !== "bearer" || rest.length !== 1) { + return undefined; + } + + return rest[0] || undefined; +} + +/** + * Verifies an access token the auth API issued, against its JWKS. + * + * Silent on failure: a guard turns `null` into a 401 and has nothing useful to + * add from the reason, while logging the reason for every bad token a client + * sends is a log-flooding vector. + */ +export async function verifyAccessToken( + token: string, + authServerUrl: string, + audience: string, +): Promise { + try { + const { payload } = await jwtVerify( + token, + getAuthServerJwks(authServerUrl), + { + algorithms: ["RS256"], + issuer: authServerUrl, + audience, + }, + ); + + if (payload.typ !== "access" || typeof payload.sub !== "string") { + return null; + } + + return payload as AccessTokenClaims; + } catch { + return null; + } +} diff --git a/packages/core/src/verifySignedAuthResponse.ts b/packages/core/src/verifySignedAuthResponse.ts index 1da495f..5dafe42 100644 --- a/packages/core/src/verifySignedAuthResponse.ts +++ b/packages/core/src/verifySignedAuthResponse.ts @@ -1,34 +1,22 @@ -import { createRemoteJWKSet, jwtVerify } from "jose"; +import { jwtVerify } from "jose"; +import { getAuthServerJwks } from "./jwks.js"; import { getSeamlessLogger } from "./logger.js"; -// jose caches keys and applies a refetch cooldown per JWKS instance, so the instance -// must outlive a single call. Memoize per JWKS URL (one per auth server, so the map -// stays tiny) instead of building a fresh, empty-cache instance on every verification. -const jwksByUrl = new Map>(); - -function getJwks(jwksUrl: string): ReturnType { - let jwks = jwksByUrl.get(jwksUrl); - if (!jwks) { - jwks = createRemoteJWKSet(new URL(jwksUrl)); - jwksByUrl.set(jwksUrl, jwks); - } - return jwks; -} - export async function verifySignedAuthResponse( token: string, authServerUrl: string, audience: string, ): Promise { try { - const jwksUrl = new URL("/.well-known/jwks.json", authServerUrl).toString(); - const JWKS = getJwks(jwksUrl); - - const { payload } = await jwtVerify(token, JWKS, { - algorithms: ["RS256"], - issuer: authServerUrl, - audience, - }); + const { payload } = await jwtVerify( + token, + getAuthServerJwks(authServerUrl), + { + algorithms: ["RS256"], + issuer: authServerUrl, + audience, + }, + ); return payload as T; } catch { diff --git a/packages/core/tests/authenticateBearer.test.js b/packages/core/tests/authenticateBearer.test.js new file mode 100644 index 0000000..37c3c2c --- /dev/null +++ b/packages/core/tests/authenticateBearer.test.js @@ -0,0 +1,247 @@ +import { jest } from "@jest/globals"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import jwt from "jsonwebtoken"; + +const { authenticateBearer, authenticateRequest } = await import( + "../dist/guards.js" +); + +const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; + +const { privateKey, publicKey } = await generateKeyPair("RS256"); +const jwk = { ...(await exportJWK(publicKey)), alg: "RS256", kid: "k1", use: "sig" }; + +let serverCount = 0; +function nextServer() { + serverCount += 1; + return `https://guard-${serverCount}.example.com`; +} + +function mockJwks(authServerUrl) { + global.fetch = jest.fn(async (url) => { + if (url.toString() === `${authServerUrl}/.well-known/jwks.json`) { + return new Response(JSON.stringify({ keys: [jwk] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); +} + +async function accessToken(authServerUrl, overrides = {}) { + return new SignJWT({ + sub: "user-123", + typ: "access", + sid: "session-1", + roles: ["athlete", "coach"], + ...overrides, + }) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(authServerUrl) + .setAudience(authServerUrl) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); +} + +describe("authenticateBearer", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("maps a verified access token onto the session shape", async () => { + const server = nextServer(); + mockJwks(server); + const token = await accessToken(server); + + const result = await authenticateBearer({ + authorization: `Bearer ${token}`, + authServerUrl: server, + audience: server, + }); + + expect(result.rejection).toBeUndefined(); + expect(result.user).toMatchObject({ + id: "user-123", + roles: ["athlete", "coach"], + token, + }); + expect(typeof result.user.iat).toBe("number"); + expect(typeof result.user.exp).toBe("number"); + // The access token carries no profile; hydration is getSeamlessUser's job. + expect(result.user.email).toBeUndefined(); + expect(result.user.phone).toBeUndefined(); + }); + + it("defaults roles to an empty list when the token carries none", async () => { + const server = nextServer(); + mockJwks(server); + + const result = await authenticateBearer({ + authorization: `Bearer ${await accessToken(server, { roles: undefined })}`, + authServerUrl: server, + audience: server, + }); + + expect(result.user.roles).toEqual([]); + }); + + it("rejects a missing header as a missing token, with a warning", async () => { + const server = nextServer(); + + const result = await authenticateBearer({ + authorization: undefined, + authServerUrl: server, + audience: server, + }); + + expect(result.rejection).toMatchObject({ + status: 401, + errorCode: "Failed to find authentication token required", + }); + expect(result.rejection.warn).toMatch(/bearer/i); + }); + + it("rejects another scheme as a missing token", async () => { + const server = nextServer(); + + const result = await authenticateBearer({ + authorization: "Basic dXNlcjpwYXNz", + authServerUrl: server, + audience: server, + }); + + expect(result.rejection.status).toBe(401); + expect(result.rejection.errorCode).toBe( + "Failed to find authentication token required", + ); + }); + + it("rejects an ephemeral token as an invalid session", async () => { + const server = nextServer(); + mockJwks(server); + + const result = await authenticateBearer({ + authorization: `Bearer ${await accessToken(server, { typ: "ephemeral" })}`, + authServerUrl: server, + audience: server, + }); + + expect(result.rejection).toEqual({ + status: 401, + errorCode: "Invalid or expired session", + }); + }); + + it("rejects a token for another audience", async () => { + const server = nextServer(); + mockJwks(server); + + const result = await authenticateBearer({ + authorization: `Bearer ${await accessToken(server)}`, + authServerUrl: server, + audience: "https://someone-else.example.com", + }); + + expect(result.rejection.errorCode).toBe("Invalid or expired session"); + }); +}); + +describe("authenticateRequest", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + function cookie(payload) { + return jwt.sign(payload, COOKIE_SECRET, { + algorithm: "HS256", + expiresIn: "300s", + }); + } + + it("behaves exactly like authenticateCookie when bearer is not configured", async () => { + const withCookie = await authenticateRequest({ + token: cookie({ sub: "user-1", token: "inner" }), + cookieSecret: COOKIE_SECRET, + authorization: "Bearer ignored", + }); + expect(withCookie.user).toMatchObject({ id: "user-1", token: "inner" }); + + const withoutCookie = await authenticateRequest({ + token: undefined, + cookieSecret: COOKIE_SECRET, + authorization: "Bearer ignored", + }); + expect(withoutCookie.rejection).toMatchObject({ + status: 401, + errorCode: "Failed to find authentication token required", + warn: "Missing expected auth cookie.", + }); + }); + + it("prefers the cookie when both are present, even an invalid one", async () => { + const server = nextServer(); + mockJwks(server); + + const result = await authenticateRequest({ + token: "not-a-cookie-jwt", + cookieSecret: COOKIE_SECRET, + authorization: `Bearer ${await accessToken(server)}`, + bearer: { authServerUrl: server, audience: server }, + }); + + expect(result.rejection).toEqual({ + status: 401, + errorCode: "Invalid or expired session", + }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("falls back to the bearer token when there is no cookie", async () => { + const server = nextServer(); + mockJwks(server); + const token = await accessToken(server); + + const result = await authenticateRequest({ + token: undefined, + cookieSecret: COOKIE_SECRET, + authorization: `Bearer ${token}`, + bearer: { authServerUrl: server, audience: server }, + }); + + expect(result.user).toMatchObject({ id: "user-123", token }); + }); + + it("names both credentials in the warning when neither is present", async () => { + const server = nextServer(); + + const result = await authenticateRequest({ + token: undefined, + cookieSecret: COOKIE_SECRET, + authorization: undefined, + bearer: { authServerUrl: server, audience: server }, + }); + + expect(result.rejection.status).toBe(401); + expect(result.rejection.warn).toMatch(/cookie/i); + expect(result.rejection.warn).toMatch(/bearer/i); + }); + + it("still enforces cookie secret strength on the bearer path", async () => { + const server = nextServer(); + + await expect( + authenticateRequest({ + token: undefined, + cookieSecret: "short", + authorization: `Bearer ${await accessToken(server)}`, + bearer: { authServerUrl: server, audience: server }, + }), + ).rejects.toThrow(/cookieSecret/); + }); +}); diff --git a/packages/core/tests/getSeamlessUser.bearer.test.js b/packages/core/tests/getSeamlessUser.bearer.test.js new file mode 100644 index 0000000..7ff8268 --- /dev/null +++ b/packages/core/tests/getSeamlessUser.bearer.test.js @@ -0,0 +1,151 @@ +import { jest } from "@jest/globals"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; + +const { getSeamlessUser } = await import("../dist/getSeamlessUser.js"); + +const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; +const USER = { + id: "user-123", + email: "user@example.com", + phone: null, + roles: ["athlete"], + lastLogin: null, + activeOrganizationId: null, +}; + +const { privateKey, publicKey } = await generateKeyPair("RS256"); +const jwk = { ...(await exportJWK(publicKey)), alg: "RS256", kid: "k1", use: "sig" }; + +let serverCount = 0; +function nextServer() { + serverCount += 1; + return `https://me-${serverCount}.example.com`; +} + +// Serves the JWKS and records the /users/me call so the test can inspect it. +function mockAuthServer(authServerUrl, { meStatus = 200 } = {}) { + const calls = []; + global.fetch = jest.fn(async (url, init) => { + const href = url.toString(); + if (href === `${authServerUrl}/.well-known/jwks.json`) { + return new Response(JSON.stringify({ keys: [jwk] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (href === `${authServerUrl}/users/me`) { + calls.push(init); + return { + ok: meStatus < 400, + status: meStatus, + text: async () => + JSON.stringify(meStatus < 400 ? { user: USER } : { error: "nope" }), + }; + } + throw new Error(`Unexpected fetch URL: ${href}`); + }); + return calls; +} + +async function accessToken(authServerUrl, overrides = {}) { + return new SignJWT({ sub: "user-123", typ: "access", roles: ["athlete"], ...overrides }) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(authServerUrl) + .setAudience(authServerUrl) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); +} + +function options(authServerUrl, authorization) { + return { + authServerUrl, + cookieSecret: COOKIE_SECRET, + serviceAuthorization: "Bearer service-token", + forwardedClientIp: "203.0.113.44", + bearer: { authorization, audience: authServerUrl }, + }; +} + +describe("getSeamlessUser with a bearer access token", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("hydrates the user from a verified bearer token when there is no cookie", async () => { + const server = nextServer(); + const meCalls = mockAuthServer(server); + const token = await accessToken(server); + + const user = await getSeamlessUser({}, options(server, `Bearer ${token}`)); + + expect(user).toEqual(USER); + expect(meCalls).toHaveLength(1); + expect(meCalls[0].headers).toMatchObject({ + Authorization: `Bearer ${token}`, + "x-seamless-service-token": "Bearer service-token", + "x-seamless-client-ip": "203.0.113.44", + }); + }); + + it("returns null without calling /users/me for an ephemeral token", async () => { + const server = nextServer(); + const meCalls = mockAuthServer(server); + const token = await accessToken(server, { typ: "ephemeral" }); + + await expect( + getSeamlessUser({}, options(server, `Bearer ${token}`)), + ).resolves.toBeNull(); + expect(meCalls).toHaveLength(0); + }); + + it("returns null without calling /users/me for a token signed for another audience", async () => { + const server = nextServer(); + const meCalls = mockAuthServer(server); + const token = await accessToken(server, {}); + + await expect( + getSeamlessUser( + {}, + { + ...options(server, `Bearer ${token}`), + bearer: { authorization: `Bearer ${token}`, audience: "https://other.example.com" }, + }, + ), + ).resolves.toBeNull(); + expect(meCalls).toHaveLength(0); + }); + + it("returns null when bearer is not configured, even with a valid token", async () => { + const server = nextServer(); + mockAuthServer(server); + const token = await accessToken(server); + + const opts = options(server, `Bearer ${token}`); + delete opts.bearer; + + await expect(getSeamlessUser({}, opts)).resolves.toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns null when the header is absent or uses another scheme", async () => { + const server = nextServer(); + mockAuthServer(server); + + await expect(getSeamlessUser({}, options(server, undefined))).resolves.toBeNull(); + await expect(getSeamlessUser({}, options(server, "Basic abc"))).resolves.toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns null when the auth server rejects the token", async () => { + const server = nextServer(); + mockAuthServer(server, { meStatus: 401 }); + const token = await accessToken(server); + + await expect( + getSeamlessUser({}, options(server, `Bearer ${token}`)), + ).resolves.toBeNull(); + }); +}); diff --git a/packages/core/tests/publicExports.test.js b/packages/core/tests/publicExports.test.js index 36696ed..0a8bd8f 100644 --- a/packages/core/tests/publicExports.test.js +++ b/packages/core/tests/publicExports.test.js @@ -7,6 +7,10 @@ import { assertSecretStrength, assertSecrets, authFetch, + authenticateBearer, + authenticateCookie, + authenticateRequest, + authorizeRoles, buildExternalDeliveryAuthorization, buildQueryString, buildUpstreamUrl, @@ -14,6 +18,7 @@ import { createServiceToken, deliverAuthMessage, ensureCookies, + extractBearerToken, finishLoginHandler, finishOAuthLoginHandler, finishRegisterHandler, @@ -48,6 +53,7 @@ import { verifyRefreshCookie, verifyRegistrationOtpHandler, verifySignedAuthResponse, + verifyAccessToken, AUTH_DELIVERY_MODE_HEADER, DEV_JWKS_KID, EXTERNAL_DELIVERY_HEADERS, @@ -63,6 +69,10 @@ const DOCUMENTED_FUNCTIONS = { assertSecretStrength, assertSecrets, authFetch, + authenticateBearer, + authenticateCookie, + authenticateRequest, + authorizeRoles, buildExternalDeliveryAuthorization, buildQueryString, buildUpstreamUrl, @@ -70,6 +80,7 @@ const DOCUMENTED_FUNCTIONS = { createServiceToken, deliverAuthMessage, ensureCookies, + extractBearerToken, finishLoginHandler, finishOAuthLoginHandler, finishRegisterHandler, @@ -104,6 +115,7 @@ const DOCUMENTED_FUNCTIONS = { verifyRefreshCookie, verifyRegistrationOtpHandler, verifySignedAuthResponse, + verifyAccessToken, }; const DOCUMENTED_CONSTANTS = { diff --git a/packages/core/tests/verifyAccessToken.test.js b/packages/core/tests/verifyAccessToken.test.js new file mode 100644 index 0000000..e061c74 --- /dev/null +++ b/packages/core/tests/verifyAccessToken.test.js @@ -0,0 +1,153 @@ +import { jest } from "@jest/globals"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; + +const { extractBearerToken, verifyAccessToken } = await import( + "../dist/verifyAccessToken.js" +); + +// One key pair per suite, and a distinct auth server URL per test. The JWKS +// instance is memoised per URL and caches keys by kid, so reusing a URL with a +// different key would verify against the stale one. +const { privateKey, publicKey } = await generateKeyPair("RS256"); +const jwk = { ...(await exportJWK(publicKey)), alg: "RS256", kid: "k1", use: "sig" }; + +let serverCount = 0; +function nextServer() { + serverCount += 1; + return `https://auth-${serverCount}.example.com`; +} + +function mockJwks(authServerUrl) { + global.fetch = jest.fn(async (url) => { + if (url.toString() === `${authServerUrl}/.well-known/jwks.json`) { + return new Response(JSON.stringify({ keys: [jwk] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + throw new Error(`Unexpected fetch URL: ${url}`); + }); +} + +async function sign(authServerUrl, claims, { audience = authServerUrl } = {}) { + return new SignJWT(claims) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(authServerUrl) + .setAudience(audience) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); +} + +const ACCESS_CLAIMS = { + sub: "user-123", + typ: "access", + sid: "session-1", + roles: ["athlete"], +}; + +describe("extractBearerToken", () => { + it.each([ + ["Bearer abc.def.ghi", "abc.def.ghi"], + ["bearer abc", "abc"], + [" Bearer abc ", "abc"], + ])("reads %p", (header, expected) => { + expect(extractBearerToken(header)).toBe(expected); + }); + + it.each([ + [undefined], + [""], + ["Bearer"], + ["Bearer "], + ["Basic abc"], + ["Bearer a b"], + ])("rejects %p", (header) => { + expect(extractBearerToken(header)).toBeUndefined(); + }); +}); + +describe("verifyAccessToken", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("returns the claims of an access token for the configured audience", async () => { + const server = nextServer(); + mockJwks(server); + + const claims = await verifyAccessToken( + await sign(server, ACCESS_CLAIMS), + server, + server, + ); + + expect(claims).toMatchObject(ACCESS_CLAIMS); + }); + + it("rejects an ephemeral token even though it is signed by the same key", async () => { + const server = nextServer(); + mockJwks(server); + + const claims = await verifyAccessToken( + await sign(server, { ...ACCESS_CLAIMS, typ: "ephemeral" }), + server, + server, + ); + + expect(claims).toBeNull(); + }); + + it("rejects a token with no typ", async () => { + const server = nextServer(); + mockJwks(server); + + const { typ: _typ, ...untyped } = ACCESS_CLAIMS; + const claims = await verifyAccessToken( + await sign(server, untyped), + server, + server, + ); + + expect(claims).toBeNull(); + }); + + it("rejects a token for another audience", async () => { + const server = nextServer(); + mockJwks(server); + + const claims = await verifyAccessToken( + await sign(server, ACCESS_CLAIMS, { audience: "https://other.example.com" }), + server, + server, + ); + + expect(claims).toBeNull(); + }); + + it("rejects a token from another issuer", async () => { + const server = nextServer(); + const otherIssuer = nextServer(); + mockJwks(server); + + const claims = await verifyAccessToken( + await sign(otherIssuer, ACCESS_CLAIMS, { audience: server }), + server, + server, + ); + + expect(claims).toBeNull(); + }); + + it("rejects garbage without touching the network", async () => { + const server = nextServer(); + global.fetch = jest.fn(); + + const claims = await verifyAccessToken("not-a-jwt", server, server); + + expect(claims).toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/express/README.md b/packages/express/README.md index 1445f58..08d8d6e 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -470,14 +470,26 @@ authentication in the Seamless Auth API. ### `requireAuth(options)` -Express middleware that verifies a signed access cookie and attaches the decoded user payload to `req.user`. +Express middleware that verifies the request's Seamless Auth session and attaches the decoded user +payload to `req.user`. Two credentials are understood: -`cookieSecret` is required and must match the secret given to `createSeamlessAuthServer`. This guard -does not attempt token refresh; silent refresh is handled by the `/auth` router's `ensureCookies` -middleware. +- the signed access cookie the `/auth` router issues to browsers, always; +- the auth API's own access token in `Authorization: Bearer`, when `authServerUrl` and `audience` + are configured. This is how a native client with no cookie jar authenticates. The token is + verified against the auth API's JWKS (issuer, audience, expiry, and `typ: "access"`, so a + sign-in flow's ephemeral token is refused). + +A cookie takes precedence when both are present. `cookieSecret` is required and must match the +secret given to `createSeamlessAuthServer`. This guard does not attempt token refresh; silent +refresh is handled by the `/auth` router's `ensureCookies` middleware for cookies, and a bearer +client refreshes through `POST /auth/refresh` itself. ```ts -const guard = requireAuth({ cookieSecret: process.env.COOKIE_SECRET! }); +const guard = requireAuth({ + cookieSecret: process.env.COOKIE_SECRET!, + authServerUrl: process.env.AUTH_SERVER_URL!, + audience: process.env.AUTH_SERVER_URL!, +}); app.get("/api/profile", guard, (req, res) => { res.json({ user: req.user }); @@ -488,11 +500,16 @@ app.get("/api/profile", guard, (req, res) => { ```ts { - cookieSecret: string; // required, must match createSeamlessAuthServer - cookieName?: string; // optional (defaults to "seamless-access") + cookieSecret: string; // required, must match createSeamlessAuthServer + cookieName?: string; // optional (defaults to "seamless-access") + authServerUrl?: string; // with audience, enables bearer access tokens + audience?: string; // expected `aud` on a bearer token, usually the auth server URL } ``` +`authServerUrl` and `audience` must be given together. Leave both out and the guard accepts +cookies only, which is what every earlier version did. + **`req.user` shape (`SeamlessAuthUser`)** ```ts @@ -511,6 +528,9 @@ app.get("/api/profile", guard, (req, res) => { exposed a duplicate `sub` field on `req.user`, which was removed in `@seamless-auth/express` 0.9.0. See [Migration](#migration-usersub-to-userid). +On the bearer path `email` and `phone` are not set, because the access token does not carry +them. Use [`getSeamlessUser`](#getseamlessuserreq-options) when a route needs the profile. + --- ### `requireRole(role: string | string[])` @@ -564,6 +584,11 @@ const user = await getSeamlessUser(req, authOptions); `cookieSecret` is required and must be at least 32 characters, otherwise the call throws. The access cookie name is read from `accessCookieName` and defaults to `seamless-access`. +A request with no access cookie but an `Authorization: Bearer` access token from the auth API +resolves too: the token is verified against the auth API's JWKS using `authServerUrl` and +`audience` from the options, then forwarded as the caller's identity. A request that carries +neither, or a token that fails verification, returns `null` without calling the auth server. + Returns `SeamlessUser | null`: ```ts diff --git a/packages/express/src/getSeamlessUser.ts b/packages/express/src/getSeamlessUser.ts index adc7a73..68e0d3c 100644 --- a/packages/express/src/getSeamlessUser.ts +++ b/packages/express/src/getSeamlessUser.ts @@ -22,5 +22,8 @@ export async function getSeamlessUser( serviceAuthorization: buildProxyServiceAuthorization(opts), forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), forwardedUserAgent: buildForwardedUserAgent(req), + // The router options always carry the audience, so a request with no cookie + // but a bearer access token resolves too. Core verifies it before forwarding. + bearer: { authorization: req.headers?.authorization, audience: opts.audience }, }); } diff --git a/packages/express/src/middleware/requireAuth.ts b/packages/express/src/middleware/requireAuth.ts index 44085c2..1217a58 100644 --- a/packages/express/src/middleware/requireAuth.ts +++ b/packages/express/src/middleware/requireAuth.ts @@ -1,51 +1,89 @@ import { Request, Response, NextFunction } from "express"; -import { assertSecretStrength, authenticateCookie } from "@seamless-auth/core"; +import { assertSecretStrength, authenticateRequest } from "@seamless-auth/core"; export interface RequireAuthOptions { cookieName?: string; cookieSecret: string; + /** + * Together with `audience`, lets the guard accept the auth API's own access + * token in `Authorization: Bearer`, which is how a native client with no + * cookie jar authenticates. Leave both out and the guard accepts cookies + * only, as it always has. + */ + authServerUrl?: string; + /** Expected `aud` on a bearer access token. Usually the same value as `authServerUrl`. */ + audience?: string; } /** * Express middleware that enforces authentication using an already-issued - * Seamless Auth access cookie. + * Seamless Auth session: the signed access cookie, or, when `authServerUrl` and + * `audience` are configured, a bearer access token from the auth API. * - * Verifies the signed access cookie, attaches the decoded session payload to - * `req.user`, and responds 401 when the cookie is missing or invalid. + * Attaches the decoded session to `req.user` and responds 401 when the + * credential is missing or invalid. A cookie takes precedence when present. * * This guard does NOT attempt token refresh. Silent refresh is handled upstream - * by the ensureCookies() middleware mounted on the `/auth` router. + * by the ensureCookies() middleware mounted on the `/auth` router; a bearer + * client refreshes through `POST /auth/refresh` itself. * * ### Example * ```ts - * const guard = requireAuth({ cookieSecret: process.env.COOKIE_SECRET! }); + * const guard = requireAuth({ + * cookieSecret: process.env.COOKIE_SECRET!, + * authServerUrl: process.env.AUTH_SERVER_URL, + * audience: process.env.AUTH_SERVER_URL, + * }); * * app.get("/api/me", guard, (req, res) => { * res.json({ user: req.user }); * }); * ``` * - * @param opts - `cookieSecret` (required, must match createSeamlessAuthServer) - * and `cookieName` (defaults to `"seamless-access"`). + * @param opts - `cookieSecret` (required, must match createSeamlessAuthServer), + * `cookieName` (defaults to `"seamless-access"`), and the optional + * `authServerUrl` + `audience` pair that enables bearer tokens. * * @returns An Express middleware function that enforces authentication. */ export function requireAuth(opts: RequireAuthOptions) { - const { cookieName = "seamless-access", cookieSecret } = opts; + const { + cookieName = "seamless-access", + cookieSecret, + authServerUrl, + audience, + } = opts; // Eagerly, so a weak secret fails at setup rather than on the first request. assertSecretStrength("requireAuth: cookieSecret", cookieSecret); - return function (req: Request, res: Response, next: NextFunction) { - const { user, rejection } = authenticateCookie({ + if ((authServerUrl === undefined) !== (audience === undefined)) { + throw new Error( + "requireAuth: authServerUrl and audience must be configured together to accept bearer tokens", + ); + } + + const bearer = + authServerUrl !== undefined && audience !== undefined + ? { authServerUrl, audience } + : undefined; + + const hint = bearer + ? "Ensure you are using `cookieParser` in your express server, or that the client sends an `Authorization: Bearer` access token" + : "Ensure you are using `cookieParser` in your express server"; + + return async function (req: Request, res: Response, next: NextFunction) { + const { user, rejection } = await authenticateRequest({ token: req.cookies?.[cookieName], cookieSecret, + authorization: req.headers.authorization, + bearer, }); if (rejection) { if (rejection.warn) { console.warn( - `[SEAMLESS-AUTH-EXPRESS] - (requireAuth) - ${rejection.warn} Ensure you are using \`cookieParser\` in your express server`, + `[SEAMLESS-AUTH-EXPRESS] - (requireAuth) - ${rejection.warn} ${hint}`, ); } diff --git a/packages/express/tests/requireAuth.bearer.test.js b/packages/express/tests/requireAuth.bearer.test.js new file mode 100644 index 0000000..3d059d9 --- /dev/null +++ b/packages/express/tests/requireAuth.bearer.test.js @@ -0,0 +1,216 @@ +import { jest } from "@jest/globals"; +import express from "express"; +import cookieParser from "cookie-parser"; +import request from "supertest"; +import jwt from "jsonwebtoken"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; + +const { requireAuth, getSeamlessUser } = await import("../dist/index.js"); + +const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; +const SERVICE_SECRET = "service-secret-service-secret-service-secret"; + +const { privateKey, publicKey } = await generateKeyPair("RS256"); +const jwk = { ...(await exportJWK(publicKey)), alg: "RS256", kid: "k1", use: "sig" }; + +let serverCount = 0; +function nextServer() { + serverCount += 1; + return `https://express-${serverCount}.example.com`; +} + +const ME = { id: "user-123", email: "u@example.com", phone: null, roles: ["athlete"] }; + +function mockAuthServer(authServerUrl) { + const meCalls = []; + global.fetch = jest.fn(async (url, init) => { + const href = url.toString(); + if (href === `${authServerUrl}/.well-known/jwks.json`) { + return new Response(JSON.stringify({ keys: [jwk] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (href === `${authServerUrl}/users/me`) { + meCalls.push(init); + return { ok: true, status: 200, text: async () => JSON.stringify({ user: ME }) }; + } + throw new Error(`Unexpected fetch URL: ${href}`); + }); + return meCalls; +} + +async function accessToken(authServerUrl, overrides = {}) { + return new SignJWT({ sub: "user-123", typ: "access", roles: ["athlete"], ...overrides }) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(authServerUrl) + .setAudience(authServerUrl) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); +} + +function buildApp(guardOptions) { + const app = express(); + app.use(cookieParser()); + app.use(requireAuth(guardOptions)); + app.get("/protected", (req, res) => { + res.json({ user: req.user }); + }); + return app; +} + +describe("requireAuth with bearer tokens (express)", () => { + const originalFetch = global.fetch; + let warn; + + beforeEach(() => { + warn = jest.spyOn(console, "warn").mockImplementation(() => {}); + }); + + afterEach(() => { + warn.mockRestore(); + global.fetch = originalFetch; + }); + + it("accepts the auth API's access token when authServerUrl and audience are configured", async () => { + const server = nextServer(); + mockAuthServer(server); + const token = await accessToken(server); + + const res = await request( + buildApp({ cookieSecret: COOKIE_SECRET, authServerUrl: server, audience: server }), + ) + .get("/protected") + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ + id: "user-123", + roles: ["athlete"], + token, + }); + }); + + it("keeps rejecting bearer tokens when the option pair is not configured", async () => { + const server = nextServer(); + mockAuthServer(server); + const token = await accessToken(server); + + const res = await request(buildApp({ cookieSecret: COOKIE_SECRET })) + .get("/protected") + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "Failed to find authentication token required" }); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects an ephemeral token", async () => { + const server = nextServer(); + mockAuthServer(server); + const token = await accessToken(server, { typ: "ephemeral" }); + + const res = await request( + buildApp({ cookieSecret: COOKIE_SECRET, authServerUrl: server, audience: server }), + ) + .get("/protected") + .set("Authorization", `Bearer ${token}`); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "Invalid or expired session" }); + }); + + it("still honours the cookie, and prefers it over a bearer header", async () => { + const server = nextServer(); + mockAuthServer(server); + const cookie = jwt.sign({ sub: "cookie-user", token: "inner" }, COOKIE_SECRET, { + expiresIn: "1h", + }); + + const res = await request( + buildApp({ cookieSecret: COOKIE_SECRET, authServerUrl: server, audience: server }), + ) + .get("/protected") + .set("Cookie", [`seamless-access=${cookie}`]) + .set("Authorization", `Bearer ${await accessToken(server)}`); + + expect(res.status).toBe(200); + expect(res.body.user.id).toBe("cookie-user"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("mentions both credentials in the warning when neither is sent", async () => { + const server = nextServer(); + + const res = await request( + buildApp({ cookieSecret: COOKIE_SECRET, authServerUrl: server, audience: server }), + ).get("/protected"); + + expect(res.status).toBe(401); + expect(warn).toHaveBeenCalledWith(expect.stringMatching(/Authorization: Bearer/)); + }); + + it("refuses a half-configured option pair at setup", () => { + expect(() => + requireAuth({ cookieSecret: COOKIE_SECRET, authServerUrl: "https://a.example.com" }), + ).toThrow(/authServerUrl and audience/); + expect(() => + requireAuth({ cookieSecret: COOKIE_SECRET, audience: "https://a.example.com" }), + ).toThrow(/authServerUrl and audience/); + }); +}); + +describe("getSeamlessUser with bearer tokens (express)", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("hydrates the user for a request that carries only a bearer token", async () => { + const server = nextServer(); + const meCalls = mockAuthServer(server); + const token = await accessToken(server); + + const user = await getSeamlessUser( + { + cookies: {}, + headers: { authorization: `Bearer ${token}` }, + ip: "203.0.113.44", + app: { get: () => 1 }, + }, + { + authServerUrl: server, + cookieSecret: COOKIE_SECRET, + serviceSecret: SERVICE_SECRET, + audience: server, + jwksKid: "test-main", + }, + ); + + expect(user).toEqual(ME); + expect(meCalls[0].headers.Authorization).toBe(`Bearer ${token}`); + expect(meCalls[0].headers["x-seamless-service-token"]).toMatch(/^Bearer /); + expect(meCalls[0].headers["x-seamless-client-ip"]).toBe("203.0.113.44"); + }); + + it("returns null for a bearer token signed for a different audience", async () => { + const server = nextServer(); + const meCalls = mockAuthServer(server); + const token = await accessToken(server); + + const user = await getSeamlessUser( + { cookies: {}, headers: { authorization: `Bearer ${token}` }, app: { get: () => 1 } }, + { + authServerUrl: server, + cookieSecret: COOKIE_SECRET, + serviceSecret: SERVICE_SECRET, + audience: "https://other.example.com", + }, + ); + + expect(user).toBeNull(); + expect(meCalls).toHaveLength(0); + }); +}); diff --git a/packages/fastify/README.md b/packages/fastify/README.md index 9036f1f..c441171 100644 --- a/packages/fastify/README.md +++ b/packages/fastify/README.md @@ -50,7 +50,11 @@ the plugin: ```ts import { requireAuth, requireRole } from "@seamless-auth/fastify"; -const authenticated = requireAuth({ cookieSecret: process.env.COOKIE_SECRET! }); +const authenticated = requireAuth({ + cookieSecret: process.env.COOKIE_SECRET!, + authServerUrl: process.env.AUTH_SERVER_URL!, + audience: process.env.AUTH_SERVER_URL!, +}); app.get("/api/me", { preHandler: authenticated }, async (req) => ({ user: req.user, @@ -63,13 +67,23 @@ app.get( ); ``` -`requireAuth` verifies the access cookie and puts the session on `request.user`. -It does not refresh: silent refresh belongs to the plugin's own hook on the auth -routes. Role checks understand scoped names, so `admin` grants everything under -it and `admin:write` grants `admin:read`. +`requireAuth` verifies the request's session and puts it on `request.user`. It +understands the signed access cookie the plugin issues to browsers, and, when +`authServerUrl` and `audience` are configured together, the auth API's own access +token in `Authorization: Bearer`, which is how a native client with no cookie jar +authenticates. A bearer token is verified against the auth API's JWKS, including +`typ: "access"`, so a sign-in flow's ephemeral token is refused. The cookie wins +when both are present. Leave the pair out and the guard accepts cookies only. -For the hydrated profile rather than the cookie payload, `getSeamlessUser(request, options)` -fetches it from the auth API and returns `SeamlessUser | null`. +It does not refresh: silent refresh belongs to the plugin's own hook on the auth +routes, and a bearer client refreshes through `POST /auth/refresh` itself. Role +checks understand scoped names, so `admin` grants everything under it and +`admin:write` grants `admin:read`. + +For the hydrated profile rather than the token payload, `getSeamlessUser(request, options)` +fetches it from the auth API and returns `SeamlessUser | null`. It resolves a cookie +session or a bearer access token, and returns `null` without calling the auth API when +the request carries neither or the token fails verification. ## Adopter-supplied message delivery diff --git a/packages/fastify/src/getSeamlessUser.ts b/packages/fastify/src/getSeamlessUser.ts index 3e51cad..ded4ba8 100644 --- a/packages/fastify/src/getSeamlessUser.ts +++ b/packages/fastify/src/getSeamlessUser.ts @@ -21,5 +21,8 @@ export async function getSeamlessUser( serviceAuthorization: buildProxyServiceAuthorization(opts), forwardedClientIp: buildForwardedClientIp(req, opts.resolveClientIp), forwardedUserAgent: buildForwardedUserAgent(req), + // The plugin options always carry the audience, so a request with no cookie + // but a bearer access token resolves too. Core verifies it before forwarding. + bearer: { authorization: req.headers?.authorization, audience: opts.audience }, }); } diff --git a/packages/fastify/src/guards.ts b/packages/fastify/src/guards.ts index ff4afc0..fe91b18 100644 --- a/packages/fastify/src/guards.ts +++ b/packages/fastify/src/guards.ts @@ -1,51 +1,88 @@ import type { FastifyReply, FastifyRequest } from "fastify"; import { assertSecretStrength, - authenticateCookie, + authenticateRequest, authorizeRoles, } from "@seamless-auth/core"; export interface RequireAuthOptions { cookieName?: string; cookieSecret: string; + /** + * Together with `audience`, lets the guard accept the auth API's own access + * token in `Authorization: Bearer`, which is how a native client with no + * cookie jar authenticates. Leave both out and the guard accepts cookies + * only, as it always has. + */ + authServerUrl?: string; + /** Expected `aud` on a bearer access token. Usually the same value as `authServerUrl`. */ + audience?: string; } /** * Fastify `preHandler` that enforces authentication using an already-issued - * Seamless Auth access cookie. + * Seamless Auth session: the signed access cookie, or, when `authServerUrl` and + * `audience` are configured, a bearer access token from the auth API. * - * Verifies the signed access cookie, attaches the decoded session to - * `request.user`, and replies 401 when the cookie is missing or invalid. + * Attaches the decoded session to `request.user` and replies 401 when the + * credential is missing or invalid. A cookie takes precedence when present. * * This guard does NOT attempt token refresh. Silent refresh is handled by the - * plugin's own hook on the auth routes. + * plugin's own hook on the auth routes; a bearer client refreshes through + * `POST /auth/refresh` itself. * * ### Example * ```ts - * const guard = requireAuth({ cookieSecret: process.env.COOKIE_SECRET! }); + * const guard = requireAuth({ + * cookieSecret: process.env.COOKIE_SECRET!, + * authServerUrl: process.env.AUTH_SERVER_URL, + * audience: process.env.AUTH_SERVER_URL, + * }); * * app.get("/api/me", { preHandler: guard }, async (req) => ({ user: req.user })); * ``` */ export function requireAuth(opts: RequireAuthOptions) { - const { cookieName = "seamless-access", cookieSecret } = opts; + const { + cookieName = "seamless-access", + cookieSecret, + authServerUrl, + audience, + } = opts; // Eagerly, so a weak secret fails at setup rather than on the first request. assertSecretStrength("requireAuth: cookieSecret", cookieSecret); + if ((authServerUrl === undefined) !== (audience === undefined)) { + throw new Error( + "requireAuth: authServerUrl and audience must be configured together to accept bearer tokens", + ); + } + + const bearer = + authServerUrl !== undefined && audience !== undefined + ? { authServerUrl, audience } + : undefined; + + const hint = bearer + ? "Ensure @fastify/cookie is registered, or that the client sends an `Authorization: Bearer` access token." + : "Ensure @fastify/cookie is registered."; + return async function requireAuthHook( req: FastifyRequest, reply: FastifyReply, ) { - const { user, rejection } = authenticateCookie({ + const { user, rejection } = await authenticateRequest({ token: req.cookies?.[cookieName], cookieSecret, + authorization: req.headers.authorization, + bearer, }); if (rejection) { if (rejection.warn) { req.log.warn( - `[SEAMLESS-AUTH-FASTIFY] - (requireAuth) - ${rejection.warn} Ensure @fastify/cookie is registered.`, + `[SEAMLESS-AUTH-FASTIFY] - (requireAuth) - ${rejection.warn} ${hint}`, ); } diff --git a/packages/fastify/tests/guards.test.js b/packages/fastify/tests/guards.test.js new file mode 100644 index 0000000..47f4815 --- /dev/null +++ b/packages/fastify/tests/guards.test.js @@ -0,0 +1,272 @@ +import { jest } from "@jest/globals"; +import cookie from "@fastify/cookie"; +import Fastify from "fastify"; +import jwt from "jsonwebtoken"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; + +const { requireAuth, requireRole, getSeamlessUser } = await import( + "../dist/index.js" +); + +const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; +const SERVICE_SECRET = "service-secret-service-secret-service-secret"; + +const { privateKey, publicKey } = await generateKeyPair("RS256"); +const jwk = { ...(await exportJWK(publicKey)), alg: "RS256", kid: "k1", use: "sig" }; + +let serverCount = 0; +function nextServer() { + serverCount += 1; + return `https://fastify-${serverCount}.example.com`; +} + +const ME = { id: "user-123", email: "u@example.com", phone: null, roles: ["athlete"] }; + +function mockAuthServer(authServerUrl) { + const meCalls = []; + global.fetch = jest.fn(async (url, init) => { + const href = url.toString(); + if (href === `${authServerUrl}/.well-known/jwks.json`) { + return new Response(JSON.stringify({ keys: [jwk] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); + } + if (href === `${authServerUrl}/users/me`) { + meCalls.push(init); + return { ok: true, status: 200, text: async () => JSON.stringify({ user: ME }) }; + } + throw new Error(`Unexpected fetch URL: ${href}`); + }); + return meCalls; +} + +async function accessToken(authServerUrl, overrides = {}) { + return new SignJWT({ sub: "user-123", typ: "access", roles: ["athlete"], ...overrides }) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(authServerUrl) + .setAudience(authServerUrl) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); +} + +function signedCookie(payload) { + return jwt.sign(payload, COOKIE_SECRET, { algorithm: "HS256", expiresIn: "300s" }); +} + +async function buildApp(guardOptions, { role } = {}) { + const app = Fastify(); + await app.register(cookie); + app.get( + "/protected", + { + preHandler: role + ? [requireAuth(guardOptions), requireRole(role)] + : requireAuth(guardOptions), + }, + async (req) => ({ user: req.user }), + ); + await app.ready(); + return app; +} + +async function get(app, headers = {}) { + try { + const res = await app.inject({ method: "GET", url: "/protected", headers }); + return { status: res.statusCode, body: res.json() }; + } finally { + await app.close(); + } +} + +describe("requireAuth (fastify)", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + it("accepts a valid access cookie and sets request.user", async () => { + const app = await buildApp({ cookieSecret: COOKIE_SECRET }); + + const res = await get(app, { + cookie: `seamless-access=${signedCookie({ sub: "user-1", roles: ["admin"], token: "inner" })}`, + }); + + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ id: "user-1", roles: ["admin"], token: "inner" }); + }); + + it("rejects a request with no credential", async () => { + const res = await get(await buildApp({ cookieSecret: COOKIE_SECRET })); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "Failed to find authentication token required" }); + }); + + it("rejects a cookie signed by another secret", async () => { + const forged = jwt.sign({ sub: "user-1" }, "attacker-secret-attacker-secret-attacker"); + + const res = await get(await buildApp({ cookieSecret: COOKIE_SECRET }), { + cookie: `seamless-access=${forged}`, + }); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "Invalid or expired session" }); + }); + + it("accepts the auth API's access token when authServerUrl and audience are configured", async () => { + const server = nextServer(); + mockAuthServer(server); + const token = await accessToken(server); + + const res = await get( + await buildApp({ cookieSecret: COOKIE_SECRET, authServerUrl: server, audience: server }), + { authorization: `Bearer ${token}` }, + ); + + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ id: "user-123", roles: ["athlete"], token }); + }); + + it("keeps rejecting bearer tokens when the option pair is not configured", async () => { + const server = nextServer(); + mockAuthServer(server); + + const res = await get(await buildApp({ cookieSecret: COOKIE_SECRET }), { + authorization: `Bearer ${await accessToken(server)}`, + }); + + expect(res.status).toBe(401); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("rejects an ephemeral token", async () => { + const server = nextServer(); + mockAuthServer(server); + + const res = await get( + await buildApp({ cookieSecret: COOKIE_SECRET, authServerUrl: server, audience: server }), + { authorization: `Bearer ${await accessToken(server, { typ: "ephemeral" })}` }, + ); + + expect(res.status).toBe(401); + expect(res.body).toEqual({ error: "Invalid or expired session" }); + }); + + it("prefers the cookie over a bearer header", async () => { + const server = nextServer(); + mockAuthServer(server); + + const res = await get( + await buildApp({ cookieSecret: COOKIE_SECRET, authServerUrl: server, audience: server }), + { + cookie: `seamless-access=${signedCookie({ sub: "cookie-user" })}`, + authorization: `Bearer ${await accessToken(server)}`, + }, + ); + + expect(res.status).toBe(200); + expect(res.body.user.id).toBe("cookie-user"); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("feeds requireRole from a bearer session", async () => { + const server = nextServer(); + mockAuthServer(server); + + const allowed = await get( + await buildApp( + { cookieSecret: COOKIE_SECRET, authServerUrl: server, audience: server }, + { role: "athlete" }, + ), + { authorization: `Bearer ${await accessToken(server)}` }, + ); + expect(allowed.status).toBe(200); + + const denied = await get( + await buildApp( + { cookieSecret: COOKIE_SECRET, authServerUrl: server, audience: server }, + { role: "organizer" }, + ), + { authorization: `Bearer ${await accessToken(server)}` }, + ); + expect(denied.status).toBe(403); + expect(denied.body.error).toBe("Insufficient role"); + }); + + it("refuses a half-configured option pair at setup", () => { + expect(() => + requireAuth({ cookieSecret: COOKIE_SECRET, authServerUrl: "https://a.example.com" }), + ).toThrow(/authServerUrl and audience/); + }); +}); + +describe("getSeamlessUser (fastify)", () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + }); + + const options = (server) => ({ + authServerUrl: server, + cookieSecret: COOKIE_SECRET, + serviceSecret: SERVICE_SECRET, + audience: server, + jwksKid: "test-main", + }); + + it("hydrates the user from a cookie session", async () => { + const server = nextServer(); + const meCalls = mockAuthServer(server); + + const user = await getSeamlessUser( + { + cookies: { "seamless-access": signedCookie({ sub: "user-123", token: "inner" }) }, + user: { token: "inner" }, + headers: {}, + ip: "203.0.113.44", + server: { initialConfig: {} }, + }, + options(server), + ); + + expect(user).toEqual(ME); + expect(meCalls[0].headers.Authorization).toBe("Bearer inner"); + }); + + it("hydrates the user from a bearer token when there is no cookie", async () => { + const server = nextServer(); + const meCalls = mockAuthServer(server); + const token = await accessToken(server); + + const user = await getSeamlessUser( + { + cookies: {}, + headers: { authorization: `Bearer ${token}` }, + ip: "203.0.113.44", + server: { initialConfig: {} }, + }, + options(server), + ); + + expect(user).toEqual(ME); + expect(meCalls[0].headers.Authorization).toBe(`Bearer ${token}`); + expect(meCalls[0].headers["x-seamless-service-token"]).toMatch(/^Bearer /); + }); + + it("returns null when the request carries neither credential", async () => { + const server = nextServer(); + mockAuthServer(server); + + await expect( + getSeamlessUser( + { cookies: {}, headers: {}, server: { initialConfig: {} } }, + options(server), + ), + ).resolves.toBeNull(); + expect(global.fetch).not.toHaveBeenCalled(); + }); +}); From 6dc3b04da795a377e07d8c91becf753969bdd0e6 Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sat, 12 Sep 2026 19:09:05 -0700 Subject: [PATCH 2/2] fix(guards): refuse an empty issuer or audience for bearer verification jose skips the claim check for an empty expected value, so a blank audience would have verified against any audience. verifyAccessToken returns null for an empty issuer or audience, and requireAuth refuses the pair at setup. --- packages/core/src/verifyAccessToken.ts | 6 ++++++ packages/core/tests/verifyAccessToken.test.js | 9 +++++++++ packages/express/src/middleware/requireAuth.ts | 6 ++++++ packages/express/tests/requireAuth.bearer.test.js | 3 +++ packages/fastify/src/guards.ts | 6 ++++++ 5 files changed, 30 insertions(+) diff --git a/packages/core/src/verifyAccessToken.ts b/packages/core/src/verifyAccessToken.ts index 85fd87f..732090b 100644 --- a/packages/core/src/verifyAccessToken.ts +++ b/packages/core/src/verifyAccessToken.ts @@ -46,6 +46,12 @@ export async function verifyAccessToken( authServerUrl: string, audience: string, ): Promise { + // jose skips the claim check for an empty expected value, which would turn a + // blank audience into "any audience". Refuse rather than verify loosely. + if (!authServerUrl || !audience) { + return null; + } + try { const { payload } = await jwtVerify( token, diff --git a/packages/core/tests/verifyAccessToken.test.js b/packages/core/tests/verifyAccessToken.test.js index e061c74..dd84785 100644 --- a/packages/core/tests/verifyAccessToken.test.js +++ b/packages/core/tests/verifyAccessToken.test.js @@ -141,6 +141,15 @@ describe("verifyAccessToken", () => { expect(claims).toBeNull(); }); + it("rejects an empty issuer or audience instead of verifying loosely", async () => { + const server = nextServer(); + mockJwks(server); + const token = await sign(server, ACCESS_CLAIMS); + + await expect(verifyAccessToken(token, server, "")).resolves.toBeNull(); + await expect(verifyAccessToken(token, "", server)).resolves.toBeNull(); + }); + it("rejects garbage without touching the network", async () => { const server = nextServer(); global.fetch = jest.fn(); diff --git a/packages/express/src/middleware/requireAuth.ts b/packages/express/src/middleware/requireAuth.ts index 1217a58..9b65d93 100644 --- a/packages/express/src/middleware/requireAuth.ts +++ b/packages/express/src/middleware/requireAuth.ts @@ -63,6 +63,12 @@ export function requireAuth(opts: RequireAuthOptions) { ); } + if (authServerUrl === "" || audience === "") { + throw new Error( + "requireAuth: authServerUrl and audience must be non-empty to accept bearer tokens", + ); + } + const bearer = authServerUrl !== undefined && audience !== undefined ? { authServerUrl, audience } diff --git a/packages/express/tests/requireAuth.bearer.test.js b/packages/express/tests/requireAuth.bearer.test.js index 3d059d9..6c4cf23 100644 --- a/packages/express/tests/requireAuth.bearer.test.js +++ b/packages/express/tests/requireAuth.bearer.test.js @@ -158,6 +158,9 @@ describe("requireAuth with bearer tokens (express)", () => { expect(() => requireAuth({ cookieSecret: COOKIE_SECRET, audience: "https://a.example.com" }), ).toThrow(/authServerUrl and audience/); + expect(() => + requireAuth({ cookieSecret: COOKIE_SECRET, authServerUrl: "https://a.example.com", audience: "" }), + ).toThrow(/non-empty/); }); }); diff --git a/packages/fastify/src/guards.ts b/packages/fastify/src/guards.ts index fe91b18..b037050 100644 --- a/packages/fastify/src/guards.ts +++ b/packages/fastify/src/guards.ts @@ -59,6 +59,12 @@ export function requireAuth(opts: RequireAuthOptions) { ); } + if (authServerUrl === "" || audience === "") { + throw new Error( + "requireAuth: authServerUrl and audience must be non-empty to accept bearer tokens", + ); + } + const bearer = authServerUrl !== undefined && audience !== undefined ? { authServerUrl, audience }