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
33 changes: 33 additions & 0 deletions .changeset/bearer-tokens-in-guards.md
Original file line number Diff line number Diff line change
@@ -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 <access token>`, 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.
12 changes: 11 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
61 changes: 50 additions & 11 deletions packages/core/src/getSeamlessUser.ts
Original file line number Diff line number Diff line change
@@ -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`.
Expand All @@ -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
*
Expand All @@ -45,17 +58,12 @@ export async function getSeamlessUser<T = SeamlessUser>(
): Promise<T | null> {
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,
Expand All @@ -66,3 +74,34 @@ export async function getSeamlessUser<T = SeamlessUser>(
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<string, string | undefined>,
opts: GetSeamlessUserOptions,
): Promise<string | undefined | null> {
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;
}
105 changes: 105 additions & 0 deletions packages/core/src/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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<AuthResult> {
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<AuthResult> {
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.
*
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 @@ -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";
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/jwks.ts
Original file line number Diff line number Diff line change
@@ -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<string, ReturnType<typeof createRemoteJWKSet>>();

export function getAuthServerJwks(
authServerUrl: string,
): ReturnType<typeof createRemoteJWKSet> {
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;
}
74 changes: 74 additions & 0 deletions packages/core/src/verifyAccessToken.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
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<AccessTokenClaims | null> {
// 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,
getAuthServerJwks(authServerUrl),
{
algorithms: ["RS256"],
issuer: authServerUrl,
audience,
},
);

if (payload.typ !== "access" || typeof payload.sub !== "string") {
return null;
}

return payload as AccessTokenClaims;
} catch {
return null;
}
}
Loading
Loading