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
15 changes: 15 additions & 0 deletions .changeset/upstream-call-parity.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": patch
"@seamless-auth/fastify": patch
"@seamless-auth/nextjs": patch
---

Calls to the auth API now behave the same as in the Go, Python and Rust adapters.

- OTP generation and magic link requests use `POST`. The `GET` routes they called are deprecated in the auth API and missing from its adapter manifest.
- Every call times out after 15 seconds (`timeoutMs` on `authFetch`). An auth API that cannot be reached or does not answer in time gets `502 {"error":"upstream_unavailable"}` instead of a hung request or `500 internal_error`.
- The key set is fetched from `authServerUrl` with its path kept, so an auth API mounted under a prefix such as `https://example.com/auth-api` is verified against its own keys.
- `/registration/register` verifies the registration token (issuer, audience, `typ` `ephemeral`, and a `sub` matching the body) before it sets the registration cookie or returns the token to a bearer client, and answers `502 {"error":"invalid_upstream_session"}` when it fails.

Breaking for code that calls `registerHandler` from `@seamless-auth/core` directly: `RegisterOptions` now requires `audience` and accepts `authServerIssuer`, like `LoginOptions`. The express, fastify and nextjs adapters pass both already.
4 changes: 3 additions & 1 deletion packages/core/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,9 @@ remain for direct imports.
- `checkProxyIdentity(...)` – checks a request carries the session a proxied route requires
- `buildQueryString(...)` / `buildUpstreamUrl(...)` – build an upstream URL
- `signSessionCookie(...)` / `resolveCookieSameSite(...)` – cookie format and policy
- `authFetch(...)` – calls the auth API with the adapter's headers and a tolerant `json()`
- `authFetch(...)` – calls the auth API with the adapter's headers and a tolerant `json()`. A call
that cannot reach the API or takes longer than `timeoutMs` (15 seconds by default) resolves to
`502 {"error":"upstream_unavailable"}`

**The adapter manifest**

Expand Down
2 changes: 1 addition & 1 deletion packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@
"test:watch": "pnpm run build && NODE_OPTIONS=--experimental-vm-modules jest --watch"
},
"dependencies": {
"@seamless-auth/types": "^0.34.0",
"@seamless-auth/types": "^0.35.0",
"@types/jsonwebtoken": "^9.0.10",
"jose": "^6.1.3",
"jsonwebtoken": "^9.0.2"
Expand Down
55 changes: 43 additions & 12 deletions packages/core/src/authFetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* Licensed under the Apache License, Version 2.0
*/
import { getSeamlessLogger } from "./logger.js";
import { redactSensitiveText } from "./redaction.js";

export interface AuthFetchOptions {
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
Expand All @@ -12,8 +13,16 @@ export interface AuthFetchOptions {
serviceAuthorization?: string;
forwardedClientIp?: string;
forwardedUserAgent?: string;
/** Defaults to {@link DEFAULT_UPSTREAM_TIMEOUT_MS}. */
timeoutMs?: number;
}

/** How long a call to the auth API may take before it answers 502, matching the other adapters. */
export const DEFAULT_UPSTREAM_TIMEOUT_MS = 15_000;

/** The error code a request gets when the auth API could not be reached or timed out. */
export const UPSTREAM_UNAVAILABLE_ERROR_CODE = "upstream_unavailable";

export async function authFetch(
url: string,
options: AuthFetchOptions,
Expand All @@ -35,28 +44,50 @@ export async function authFetch(

// Following a redirect would resend the service token and the forwarded
// client headers to wherever a misconfigured proxy in front of the API points.
const response = await fetch(url, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
redirect: "manual",
});
let response: Response;
try {
response = await fetch(url, {
method: options.method,
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
redirect: "manual",
signal: AbortSignal.timeout(
options.timeoutMs ?? DEFAULT_UPSTREAM_TIMEOUT_MS,
),
});
} catch (err) {
getSeamlessLogger().error(
`[SeamlessAuth] The auth API at ${originOf(url)} could not be reached for a ${options.method} (${describeFetchError(err)}).`,
);
return jsonError(502, UPSTREAM_UNAVAILABLE_ERROR_CODE);
}

if (isRedirect(response)) {
getSeamlessLogger().error(
`[SeamlessAuth] The auth API at ${originOf(url)} answered a ${options.method} with a redirect (HTTP ${response.status}), which is not followed.`,
);
return makeJsonTolerant(
new Response(JSON.stringify({ error: UPSTREAM_REDIRECT_ERROR_CODE }), {
status: 502,
headers: { "Content-Type": "application/json" },
}),
);
return jsonError(502, UPSTREAM_REDIRECT_ERROR_CODE);
}

return makeJsonTolerant(response);
}

function jsonError(status: number, error: string): Response {
return makeJsonTolerant(
new Response(JSON.stringify({ error }), {
status,
headers: { "Content-Type": "application/json" },
}),
);
}

function describeFetchError(err: unknown): string {
if (err instanceof Error && err.name === "TimeoutError") {
return "timed out";
}
return redactSensitiveText(err instanceof Error ? err.message : String(err));
}

/** The error code a request gets when the auth API answered it with a redirect. */
export const UPSTREAM_REDIRECT_ERROR_CODE = "upstream_redirect";

Expand Down
20 changes: 19 additions & 1 deletion packages/core/src/handlers/register.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
import type { AuthServerIssuerOption } from "../authServerIssuer.js";
import { authFetch } from "../authFetch.js";
import { readPassthroughFailure } from "../upstreamError.js";
import { EXTERNAL_DELIVERY_HEADERS } from "../apiContract.js";
import type { ResultFailure } from "../result.js";
import type { CookiePayload } from "../ensureCookies.js";
import { verifyUpstreamSession } from "../upstreamSession.js";
import type { AuthTransport } from "../transport.js";
import { withoutTokens } from "../bodyTokens.js";

export interface RegisterInput {
body: unknown;
}

export interface RegisterOptions {
export interface RegisterOptions extends AuthServerIssuerOption {
authServerUrl: string;
audience: string;
cookieDomain?: string;
registrationCookieName: string;
externalDelivery?: boolean;
Expand All @@ -32,6 +35,9 @@ export interface RegisterResult extends ResultFailure {
}[];
}

/** The error code a registration gets when the auth API's token fails verification. */
export const INVALID_UPSTREAM_SESSION_ERROR_CODE = "invalid_upstream_session";

export async function registerHandler(
input: RegisterInput,
opts: RegisterOptions,
Expand All @@ -58,6 +64,18 @@ export async function registerHandler(
};
}

try {
await verifyUpstreamSession(
data,
opts.authServerUrl,
opts.audience,
opts.authServerIssuer,
"ephemeral",
);
} catch {
return { status: 502, errorCode: INVALID_UPSTREAM_SESSION_ERROR_CODE };
}

if (opts.transport === "bearer") {
return { status: 200, body: data };
}
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/handlers/requestMagicLinkHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ export async function requestMagicLinkHandler(
);

const up = await authFetch(url, {
method: "GET",
method: "POST",
authorization: input.authorization,
forwardedClientIp: opts.forwardedClientIp,
forwardedUserAgent: opts.forwardedUserAgent,
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/handlers/requestOtpHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ export async function requestOtpHandler(
: "otp/generate-phone-otp";

const up = await authFetch(`${opts.authServerUrl}/${path}`, {
method: "GET",
method: "POST",
authorization: input.authorization,
forwardedClientIp: opts.forwardedClientIp,
forwardedUserAgent: opts.forwardedUserAgent,
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/jwks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ 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();
// Appended rather than resolved, so an auth server mounted under a path keeps its prefix.
const jwksUrl = `${authServerUrl.replace(/\/+$/, "")}/.well-known/jwks.json`;

let jwks = jwksByUrl.get(jwksUrl);
if (!jwks) {
Expand Down
41 changes: 41 additions & 0 deletions packages/core/tests/authFetch.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -168,4 +168,45 @@ describe("authFetch against a redirecting upstream", () => {
expect(elsewhere).not.toHaveBeenCalled();
},
);

it("answers 502 upstream_unavailable when the API does not answer in time", async () => {
const { authFetch, UPSTREAM_UNAVAILABLE_ERROR_CODE } = await import(
"../dist/authFetch.js"
);
const api = await listen(() => {});

const response = await authFetch(`${api}/login`, {
method: "POST",
timeoutMs: 50,
});

expect(response.status).toBe(502);
expect(await response.json()).toEqual({ error: UPSTREAM_UNAVAILABLE_ERROR_CODE });
});

it("answers 502 upstream_unavailable when the API cannot be reached", async () => {
const { authFetch, UPSTREAM_UNAVAILABLE_ERROR_CODE } = await import(
"../dist/authFetch.js"
);
const api = await listen(() => {});
await new Promise((resolve) => servers.pop().close(resolve));

const response = await authFetch(`${api}/login`, { method: "POST" });

expect(response.status).toBe(502);
expect(await response.json()).toEqual({ error: UPSTREAM_UNAVAILABLE_ERROR_CODE });
});

it("applies a timeout to every call by default", async () => {
const { authFetch } = await import("../dist/authFetch.js");
const original = global.fetch;
global.fetch = jest.fn().mockResolvedValue({ ok: true, status: 200 });

try {
await authFetch("https://auth.example.com/login", { method: "POST" });
expect(global.fetch.mock.calls[0][1].signal).toBeInstanceOf(AbortSignal);
} finally {
global.fetch = original;
}
});
});
39 changes: 39 additions & 0 deletions packages/core/tests/jwks.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { jest } from "@jest/globals";
import { exportJWK, generateKeyPair, SignJWT } from "jose";

describe("auth server JWKS", () => {
const originalFetch = global.fetch;

afterEach(() => {
global.fetch = originalFetch;
});

// Resolving "/.well-known/jwks.json" against the URL dropped the path, so an
// API mounted under a prefix had its keys fetched from the bare origin.
it.each([
["https://a.example.com/auth-api", "https://a.example.com/auth-api"],
["https://b.example.com/auth-api/", "https://b.example.com/auth-api"],
])("keeps the path prefix of %s", async (authServerUrl, base) => {
const { verifyWithAuthServerJwks } = await import("../dist/jwks.js");
const { privateKey, publicKey } = await generateKeyPair("RS256");
const jwk = { ...(await exportJWK(publicKey)), alg: "RS256", kid: "k1" };
const token = await new SignJWT({ sub: "user-123" })
.setProtectedHeader({ alg: "RS256", kid: "k1" })
.setExpirationTime("5m")
.sign(privateKey);

global.fetch = jest.fn(async (url) => {
if (url.toString() === `${base}/.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}`);
});

const payload = await verifyWithAuthServerJwks(token, authServerUrl, {});

expect(payload.sub).toBe("user-123");
});
});
Loading
Loading