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
4 changes: 3 additions & 1 deletion .changeset/core-applies-results.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ Cookie signing moves to core with them, because the cookie format is core's: an

The Express adapter drops 291 lines of source and 5.5KB of bundle, and `@seamless-auth/express` no longer carries its own cookie module. Nothing is removed from its public surface. `CookieSameSite` is now re-exported from core rather than declared locally, so `SeamlessAuthServerOptions` is unchanged for adopters.

Responses are unchanged, verified rather than assumed. Status, body, and every `Set-Cookie` header were captured on both revisions across eleven scenarios covering session set and clear, secure and insecure policy, a custom cookie domain, coded and passthrough failures, an empty failure body, and success bodies. All are byte-identical, including `HttpOnly`, `Secure`, `SameSite`, `Domain`, `Path`, and `Max-Age`.
Responses are unchanged with one exception, noted below. Status, body, and every `Set-Cookie` header were captured on both revisions across eleven scenarios covering session set and clear, secure and insecure policy, a custom cookie domain, coded and passthrough failures, an empty failure body, and success bodies. All are byte-identical, including `HttpOnly`, `Secure`, `SameSite`, `Domain`, `Path`, and `Max-Age`.

Empty responses are now consistent about their content type. A route whose upstream returned success with no body previously sent `Content-Type: application/json` with a zero-length body, because the handler called the framework's JSON method with `undefined`. It now sends no content type, matching the routes that already ended the response instead. `Content-Length: 0` is unchanged either way, and a client reading the body sees nothing in both cases, since parsing an empty body fails regardless of the content type. Anything asserting on the content type of an empty response needs updating.

Part of #72.
16 changes: 16 additions & 0 deletions .changeset/core-proxy-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": patch
---

Move the passthrough proxy into `@seamless-auth/core`, and fix repeated query parameters being joined.

The 33 organizations, step-up, TOTP, users, and admin passthrough routes existed only inside the Express adapter, with no core equivalent, so a new adapter would have had to rebuild both the upstream call and the session gate that guards it. New exports:

- `proxyRequest({ authServerUrl, path, method, authorization, serviceAuthorization, forwardedClientIp, query, body })` forwards a request and returns the upstream status and body unchanged.
- `checkProxyIdentity({ subject, cookies, identity, ...cookieNames })` is the pure session gate, returning the rejection to send or `undefined` to proceed.
- `buildQueryString` and `buildUpstreamUrl` replace three separate querystring builders that had drifted apart.

**Fix:** a repeated query parameter reached the auth API joined into a single comma-separated value on the admin and internal-metrics routes. `GET /admin/auth-events?type=login&type=logout` was forwarded as `type=login,logout`, and the API's `AuthEventQuerySchema` accepts `type` as an array, so the joined value matched no event type and the filter silently returned the wrong set. Array parameters are now forwarded as repeated parameters on every route. Nested objects are dropped rather than stringified, so a query like `?filter[from]=x` can no longer reach the API as `filter=[object Object]`.

The Express adapter drops 38 lines, `createServer.ts` drops from 705 to 667 lines, and the proxy handler is now a gate check, a call, and a response.
17 changes: 3 additions & 14 deletions packages/core/src/handlers/admin.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { authFetch } from "../authFetch.js";
import { buildUpstreamUrl, type QueryInput } from "../proxyRequest.js";
import type { ResultFailure } from "../result.js";
import { readUpstreamFailure } from "../upstreamError.js";

Expand All @@ -10,7 +11,7 @@ type BaseOpts = {
};

type WithQuery = BaseOpts & {
query?: Record<string, any>;
query?: QueryInput;
};

type WithBody = BaseOpts & {
Expand All @@ -22,25 +23,13 @@ type Result = ResultFailure & {
body?: any;
};

function buildUrl(base: string, query?: Record<string, any>) {
if (!query) return base;

const qs = new URLSearchParams(
Object.entries(query)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => [k, String(v)]),
).toString();

return qs ? `${base}?${qs}` : base;
}

async function request(
method: "GET" | "POST" | "PATCH" | "DELETE",
path: string,
opts: WithQuery & WithBody,
): Promise<Result> {
const up = await authFetch(
buildUrl(`${opts.authServerUrl}${path}`, opts.query),
buildUpstreamUrl(opts.authServerUrl, path, opts.query),
{
method,
authorization: opts.authorization,
Expand Down
16 changes: 3 additions & 13 deletions packages/core/src/handlers/internalMetrics.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { authFetch } from "../authFetch.js";
import { buildUpstreamUrl, type QueryInput } from "../proxyRequest.js";
import type { ResultFailure } from "../result.js";
import { readUpstreamFailure } from "../upstreamError.js";

Expand All @@ -10,28 +11,17 @@ type BaseOpts = {
};

type WithQuery = BaseOpts & {
query?: Record<string, string | number | boolean | undefined>;
query?: QueryInput;
};

type Result = ResultFailure & {
status: number;
body?: any;
};

function buildUrl(base: string, query?: WithQuery["query"]) {
if (!query) return base;
const qs = new URLSearchParams(
Object.entries(query)
.filter(([, v]) => v !== undefined && v !== null)
.map(([k, v]) => [k, String(v)]),
).toString();

return qs ? `${base}?${qs}` : base;
}

async function get(path: string, opts: WithQuery): Promise<Result> {
const up = await authFetch(
buildUrl(`${opts.authServerUrl}${path}`, opts.query),
buildUpstreamUrl(opts.authServerUrl, path, opts.query),
{
method: "GET",
authorization: opts.authorization,
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 @@ -16,6 +16,7 @@ export {
roleGrantsAccess,
} from "@seamless-auth/types/role/matching";
export * from "./applyResult.js";
export * from "./proxyRequest.js";
export * from "./result.js";
export * from "./redaction.js";

Expand Down
147 changes: 147 additions & 0 deletions packages/core/src/proxyRequest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import type { AppliableResult } from "./applyResult.js";
import { authFetch, type AuthFetchOptions } from "./authFetch.js";

/**
* Query parameters to forward upstream.
*
* Values are deliberately `unknown`: adapters hand over whatever their
* framework parsed, and Express's `ParsedQs` can nest objects. Scalars and
* arrays of scalars are forwarded, everything else is dropped, so a nested
* object cannot reach the auth API as `[object Object]`.
*/
export type QueryInput = Record<string, unknown>;

function isScalar(value: unknown): value is string | number | boolean {
return (
typeof value === "string" ||
typeof value === "number" ||
typeof value === "boolean"
);
}

/**
* Builds a querystring for an upstream call.
*
* An array becomes repeated parameters (`?type=login&type=logout`), which is
* what the auth API's schemas accept. Joining them into one comma-separated
* value produces a parameter that matches nothing upstream.
*/
export function buildQueryString(query?: QueryInput): string {
if (!query) return "";

const params = new URLSearchParams();

for (const [key, value] of Object.entries(query)) {
if (Array.isArray(value)) {
for (const item of value) {
if (isScalar(item)) params.append(key, String(item));
}
continue;
}

if (isScalar(value)) params.append(key, String(value));
}

return params.toString();
}

export function buildUpstreamUrl(
authServerUrl: string,
path: string,
query?: QueryInput,
): string {
const base = `${authServerUrl}${path.startsWith("/") ? "" : "/"}${path}`;
const qs = buildQueryString(query);

return qs ? `${base}?${qs}` : base;
}

export type ProxyIdentity = "preAuth" | "access" | "register";

export interface ProxyIdentityInput {
/** `sub` from the verified cookie payload, if the request carried one. */
subject?: string;
cookies: Record<string, unknown>;
identity: ProxyIdentity;
accessCookieName: string;
preAuthCookieName: string;
registrationCookieName: string;
}

export interface ProxyIdentityRejection {
status: number;
errorCode: string;
/** Set when the caller should log the rejection rather than only return it. */
warn?: string;
}

/**
* Checks that a request carries the session a proxied route requires.
*
* Returns the rejection to send, or `undefined` when the request may proceed.
* The cookie payload alone is not enough: it survives a refresh, so the route
* also has to see the specific cookie for the identity it needs.
*/
export function checkProxyIdentity(
input: ProxyIdentityInput,
): ProxyIdentityRejection | undefined {
if (!input.subject) {
return {
status: 401,
errorCode: "Unauthenticated request",
warn: "Missing expected cookie payload/sub.",
};
}

const required: Record<ProxyIdentity, { name: string; error: string }> = {
access: { name: input.accessCookieName, error: "access session required" },
preAuth: {
name: input.preAuthCookieName,
error: "pre-auth session required",
},
register: {
name: input.registrationCookieName,
error: "registration session required",
},
};

const { name, error } = required[input.identity];

return input.cookies[name] ? undefined : { status: 401, errorCode: error };
}

export interface ProxyRequestOptions {
authServerUrl: string;
path: string;
method?: AuthFetchOptions["method"];
authorization?: string;
serviceAuthorization?: string;
forwardedClientIp?: string;
query?: QueryInput;
body?: unknown;
}

/**
* Forwards a request to the auth API and returns its status and body unchanged.
*
* Transparent on purpose: a proxied route has no view into what the response
* means, so a failure body is returned as-is rather than reshaped into a code.
*/
export async function proxyRequest(
opts: ProxyRequestOptions,
): Promise<AppliableResult> {
const method = opts.method ?? "POST";

const upstream = await authFetch(
buildUpstreamUrl(opts.authServerUrl, opts.path, opts.query),
{
method,
authorization: opts.authorization,
serviceAuthorization: opts.serviceAuthorization,
forwardedClientIp: opts.forwardedClientIp,
...(method === "GET" ? {} : { body: opts.body }),
},
);

return { status: upstream.status, body: await upstream.json() };
}
Loading
Loading