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
12 changes: 12 additions & 0 deletions .changeset/post-release-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": minor
---

Post-release follow-up cleanups from the pre-release audit.

- Bound the refresh-result cache in core. Entries were keyed by the rotating refresh cookie and never revisited, so the map grew without limit and retained tokens for the process lifetime. It now sweeps expired entries (throttled) and caps total size.
- Memoize the JWKS key set per auth-server URL in `verifySignedAuthResponse`. It was rebuilt on every call, so jose's key cache and refetch cooldown never engaged and every verification made an extra request to `/.well-known/jwks.json`.
- `SeamlessAuthUser.email` is now optional and `phone` is `string | null`, matching the cookie payload and the upstream `/users/me` shape. This is a type-level change: consumers that treated `phone` as a non-null `string` will need to handle `null`.
- Export `redactSensitiveText` from core and use it to mask tokens and secrets before the Express router logs an unhandled error.
- Reorder the `/magic-link/check` cookie requirement so it is no longer shadowed by `/magic-link`, throw a clear error when a route parameter is missing instead of forwarding the literal string `"undefined"`, correct the `Missing cookieSecret` message that named a removed environment variable, and drop a redundant terminal `.end()` after `res.json(...)`.
1 change: 1 addition & 0 deletions packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ Key exports include:
- `getSeamlessUser(...)` – resolves the hydrated user, typed as `SeamlessUser | null`
- `hasScopedRole(...)` – checks scoped role grants such as `admin:read`
- `assertSecretStrength(...)` / `assertSecrets(...)` – enforce the minimum secret length
- `redactSensitiveText(...)` – masks tokens, bearer values, and secrets before logging
- `listOAuthProvidersHandler(...)` – retrieves public OAuth provider metadata
- `startOAuthLoginHandler(...)` – starts an OAuth authorization-code login
- `finishOAuthLoginHandler(...)` – finishes OAuth login and returns cookie instructions
Expand Down
6 changes: 4 additions & 2 deletions packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,11 +108,13 @@ const COOKIE_REQUIREMENTS: Record<
name: "preAuthCookieName",
required: false,
},
"/magic-link": {
// Must precede "/magic-link" for the same prefix-match reason; otherwise this
// entry is shadowed and would silently stop applying if its config diverged.
"/magic-link/check": {
name: "preAuthCookieName",
required: true,
},
"/magic-link/check": {
"/magic-link": {
name: "preAuthCookieName",
required: true,
},
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 @@ -7,6 +7,7 @@ export * from "./getSeamlessUser.js";
export * from "./createServiceToken.js";
export * from "./validateSecrets.js";
export * from "./scopedRoles.js";
export * from "./redaction.js";

export * from "./handlers/login.js";
export * from "./handlers/finishLogin.js";
Expand Down
26 changes: 25 additions & 1 deletion packages/core/src/refreshAccessToken.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,28 @@ const recentRefreshResults = new Map<
{ result: RefreshAccessTokenResult; expiresAt: number }
>();
const RECENT_REFRESH_RESULT_TTL_MS = 5000;
// Refresh cookies rotate, so a completed entry's key is never looked up again and
// would live forever without this. Sweep expired entries (throttled so it stays off
// the hot path) and cap total size as a backstop against a burst of distinct cookies.
const MAX_RECENT_REFRESH_RESULTS = 10_000;
let lastPruneAt = 0;

function pruneRecentRefreshResults(now: number): void {
if (now - lastPruneAt >= RECENT_REFRESH_RESULT_TTL_MS) {
for (const [key, entry] of recentRefreshResults) {
if (entry.expiresAt <= now) {
recentRefreshResults.delete(key);
}
}
lastPruneAt = now;
}

while (recentRefreshResults.size > MAX_RECENT_REFRESH_RESULTS) {
const oldest = recentRefreshResults.keys().next().value;
if (oldest === undefined) break;
recentRefreshResults.delete(oldest);
}
}

export async function refreshAccessToken(
refreshCookie: string,
Expand Down Expand Up @@ -82,9 +104,11 @@ export async function refreshAccessToken(
try {
const result = await refreshPromise;
if (result) {
const insertedAt = Date.now();
pruneRecentRefreshResults(insertedAt);
recentRefreshResults.set(refreshCookie, {
result,
expiresAt: Date.now() + RECENT_REFRESH_RESULT_TTL_MS,
expiresAt: insertedAt + RECENT_REFRESH_RESULT_TTL_MS,
});
}
return result;
Expand Down
16 changes: 15 additions & 1 deletion packages/core/src/verifySignedAuthResponse.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
import { createRemoteJWKSet, jwtVerify } 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>>();

function getJwks(jwksUrl: string): ReturnType<typeof createRemoteJWKSet> {
let jwks = jwksByUrl.get(jwksUrl);
if (!jwks) {
jwks = createRemoteJWKSet(new URL(jwksUrl));
jwksByUrl.set(jwksUrl, jwks);
}
return jwks;
}

export async function verifySignedAuthResponse<T = any>(
token: string,
authServerUrl: string,
audience: string,
): Promise<T | null> {
try {
const jwksUrl = new URL("/.well-known/jwks.json", authServerUrl).toString();
const JWKS = createRemoteJWKSet(new URL(jwksUrl));
const JWKS = getJwks(jwksUrl);

const { payload } = await jwtVerify(token, JWKS, {
algorithms: ["RS256"],
Expand Down
2 changes: 2 additions & 0 deletions packages/core/tests/publicExports.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
getSeamlessUser,
hasScopedRole,
listOAuthProvidersHandler,
redactSensitiveText,
refreshAccessToken,
startOAuthLoginHandler,
verifyCookieJwt,
Expand All @@ -23,6 +24,7 @@ const DOCUMENTED_EXPORTS = {
getSeamlessUser,
hasScopedRole,
listOAuthProvidersHandler,
redactSensitiveText,
refreshAccessToken,
startOAuthLoginHandler,
verifyCookieJwt,
Expand Down
4 changes: 2 additions & 2 deletions packages/express/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -491,8 +491,8 @@ app.get("/api/profile", guard, (req, res) => {
{
id: string;
roles: string[];
email: string;
phone: string;
email?: string;
phone?: string | null;
iat?: number;
exp?: number;
token?: string;
Expand Down
23 changes: 18 additions & 5 deletions packages/express/src/createServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ import {
startOAuthLogin,
} from "./handlers/oauth";
import * as admin from "./handlers/admin";
import { authFetch, AuthFetchOptions } from "@seamless-auth/core";
import {
authFetch,
AuthFetchOptions,
redactSensitiveText,
} from "@seamless-auth/core";
import {
buildProxyServiceAuthorization,
buildServiceAuthorization,
Expand Down Expand Up @@ -99,8 +103,8 @@ export type SeamlessAuthServerOptions = {
export interface SeamlessAuthUser {
id: string;
roles: string[];
email: string;
phone: string;
email?: string;
phone?: string | null;
iat?: number;
exp?: number;
token?: string;
Expand Down Expand Up @@ -129,7 +133,11 @@ function buildProxyQueryString(queryInput: Request["query"]): string {

function routeParam(req: Request, name: string): string {
const value = req.params[name];
return Array.isArray(value) ? value[0] : value;
const resolved = Array.isArray(value) ? value[0] : value;
if (typeof resolved !== "string") {
throw new Error(`Missing route parameter "${name}"`);
}
return resolved;
}

function clientErrorStatus(err: unknown): number | null {
Expand Down Expand Up @@ -683,7 +691,12 @@ export function createSeamlessAuthServer(
const status = clientErrorStatus(err) ?? 500;

if (status >= 500) {
console.error("[SEAMLESS-AUTH-EXPRESS] - Unhandled route error.", err);
const detail =
err instanceof Error ? (err.stack ?? err.message) : String(err);
console.error(
"[SEAMLESS-AUTH-EXPRESS] - Unhandled route error.",
redactSensitiveText(detail),
);
res.status(500).json({ error: "internal_error" });
return;
}
Expand Down
2 changes: 1 addition & 1 deletion packages/express/src/handlers/finishLogin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,5 @@ export async function finishLogin(
return res.status(result.status).json(result.error);
}

res.status(result.status).json(result.body).end();
res.status(result.status).json(result.body);
}
2 changes: 1 addition & 1 deletion packages/express/src/internal/cookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function buildCookieSigner(
opts: CookieSecurityOptions,
): CookieSignerOptions {
if (!opts.cookieSecret) {
throw new Error("Missing COOKIE_SIGNING_KEY");
throw new Error("Missing cookieSecret");
}

return {
Expand Down
19 changes: 16 additions & 3 deletions packages/express/tests/routeErrorHandling.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -115,16 +115,29 @@ describe("route error handling", () => {
});

it("logs the underlying error server side", async () => {
const cause = new Error("network down");
global.fetch.mockRejectedValue(cause);
global.fetch.mockRejectedValue(new Error("network down"));

await request(createApp())
.get("/auth/magic-link/check")
.set("Cookie", createPreAuthCookie());

expect(errorSpy).toHaveBeenCalledWith(
"[SEAMLESS-AUTH-EXPRESS] - Unhandled route error.",
cause,
expect.stringContaining("network down"),
);
});

it("redacts sensitive values in the logged error", async () => {
global.fetch.mockRejectedValue(
new Error("upstream rejected Bearer sk-secret-token-value"),
);

await request(createApp())
.get("/auth/magic-link/check")
.set("Cookie", createPreAuthCookie());

const [, logged] = errorSpy.mock.calls[0];
expect(logged).not.toContain("sk-secret-token-value");
expect(logged).toContain("[REDACTED]");
});
});
Loading