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/extract-session-helpers.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@seamless-auth/core": patch
---

Extract the copy-pasted session helpers, with a typed `UpstreamSessionResponse`.

Seven handlers repeated the same block: verify the signed access token, check it describes the same subject as the response body, read the `sid` claim, then build the access and refresh cookie payloads. Seven copies of a security-relevant check is seven places to get an early return wrong, and the upstream fields were read untyped, so a rename on the API side surfaced as an undefined cookie field at runtime rather than a type error.

`issueSessionCookies` now does the whole thing in one call, `verifyUpstreamSession` is available for the one flow that verifies without issuing a session (login, which issues only the pre-auth cookie), and `UpstreamSessionResponse` states what the auth API returns when it issues a session. The handlers drop 220 lines.

One behavior change: the access cookie issued by `POST /webAuthn/register/finish` now carries `organizationId: null` where it previously omitted the key. Every other flow already included it, and the refresh path writes it on every reissue, so that session disagreed with itself after a single refresh. It is now consistent from the start.

New exports: `issueSessionCookies`, `verifyUpstreamSession`, `UpstreamSessionResponse`, `VerifiedUpstreamSession`, `IssueSessionCookiesOptions`.

Closes #136.
15 changes: 15 additions & 0 deletions .changeset/injectable-logger.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
"@seamless-auth/core": patch
---

Let adopters route this package's diagnostics somewhere other than `console`.

Core wrote to `console` directly, so an adopter could not capture, level, or silence its output. An adapter with its own logger still leaked core's lines to `console`, which meant one request could produce output in two places.

`setSeamlessLogger(logger)` accepts anything with `warn` and `error`, which a platform logger already satisfies, and `setSeamlessLogger()` with no argument goes back to `console`. Nothing changes for callers that do not set one.

The logger is process-wide, not per-request, so it changes where core's diagnostics go rather than attaching request context to them. Core logs two things (a failed signature verification and a misconfigured external-delivery setup), and neither depends on request context. Threading a per-request logger through every handler would be a larger change.

New exports: `setSeamlessLogger`, `getSeamlessLogger`, `SeamlessLogger`.

Closes #137.
19 changes: 15 additions & 4 deletions packages/core/src/deliverAuthMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,15 @@ import type {
SeamlessAuthMessagingOptions,
SmsMessage,
} from "./authMessaging.js";
import { getSeamlessLogger } from "./logger.js";

function applyEmailOverride<TInput>(
override:
| ((input: TInput, defaults: EmailMessage, context: { appName?: string }) => EmailMessage)
| ((
input: TInput,
defaults: EmailMessage,
context: { appName?: string },
) => EmailMessage)
| undefined,
input: TInput,
defaults: EmailMessage,
Expand All @@ -18,7 +23,11 @@ function applyEmailOverride<TInput>(

function applySmsOverride<TInput>(
override:
| ((input: TInput, defaults: SmsMessage, context: { appName?: string }) => SmsMessage)
| ((
input: TInput,
defaults: SmsMessage,
context: { appName?: string },
) => SmsMessage)
| undefined,
input: TInput,
defaults: SmsMessage,
Expand Down Expand Up @@ -163,7 +172,9 @@ export async function deliverAuthMessage(
}
}

export function stripDelivery<T extends { delivery?: unknown }>(body: T): Omit<T, "delivery"> {
export function stripDelivery<T extends { delivery?: unknown }>(
body: T,
): Omit<T, "delivery"> {
const { delivery: _delivery, ...rest } = body;
return rest;
}
Expand All @@ -189,7 +200,7 @@ export async function applyExternalDelivery(
}

if (messaging) {
console.warn(
getSeamlessLogger().warn(
"[SeamlessAuth] External delivery was requested but the auth API returned no delivery payload, so no message was sent. Verify that serviceSecret matches the auth API's API_SERVICE_TOKEN.",
);
}
Expand Down
12 changes: 10 additions & 2 deletions packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -303,7 +303,11 @@ export async function ensureCookies(
const refreshCookie = input.cookies[opts.refreshCookieName];

if (required && !cookieValue) {
const refreshed = await refreshRequiredCookie(cookieName, refreshCookie, opts);
const refreshed = await refreshRequiredCookie(
cookieName,
refreshCookie,
opts,
);

if (!refreshed) {
return {
Expand All @@ -329,7 +333,11 @@ export async function ensureCookies(
const token = typeof payload.token === "string" ? payload.token : undefined;

if (required && !token && cookieName === opts.accessCookieName) {
const refreshed = await refreshRequiredCookie(cookieName, refreshCookie, opts);
const refreshed = await refreshRequiredCookie(
cookieName,
refreshCookie,
opts,
);

if (refreshed) {
return refreshed;
Expand Down
53 changes: 8 additions & 45 deletions packages/core/src/handlers/finishLogin.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { authFetch } from "../authFetch.js";
import { issueSessionCookies } from "../upstreamSession.js";
import { readPassthroughFailure } from "../upstreamError.js";
import type { ResultFailure } from "../result.js";
import type { CookiePayload } from "../ensureCookies.js";
import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js";

export interface FinishLoginInput {
body: unknown;
Expand Down Expand Up @@ -51,52 +51,15 @@ export async function finishLoginHandler(
};
}

const verifiedAccessToken = await verifySignedAuthResponse(
data.token,
opts.authServerUrl,
opts.audience,
);

if (!verifiedAccessToken) {
throw new Error("Invalid signed response from Auth Server");
}

if (verifiedAccessToken.sub !== data.sub) {
throw new Error("Signature mismatch with data payload");
}

const sessionId =
typeof verifiedAccessToken.sid === "string"
? verifiedAccessToken.sid
: undefined;

return {
status: 200,
body: data,
setCookies: [
{
name: opts.accessCookieName,
value: {
sub: data.sub,
...(sessionId === undefined ? {} : { sessionId }),
token: data.token,
roles: data.roles,
email: data.email,
phone: data.phone,
organizationId: data.organizationId ?? null,
},
ttl: data.ttl,
domain: opts.cookieDomain,
},
{
name: opts.refreshCookieName,
value: {
sub: data.sub,
refreshToken: data.refreshToken,
},
ttl: data.refreshTtl,
domain: opts.cookieDomain,
},
],
setCookies: await issueSessionCookies(data, {
authServerUrl: opts.authServerUrl,
audience: opts.audience,
accessCookieName: opts.accessCookieName,
refreshCookieName: opts.refreshCookieName,
cookieDomain: opts.cookieDomain,
}),
};
}
49 changes: 8 additions & 41 deletions packages/core/src/handlers/finishRegister.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { authFetch } from "../authFetch.js";
import { issueSessionCookies } from "../upstreamSession.js";
import { readPassthroughFailure } from "../upstreamError.js";
import type { ResultFailure } from "../result.js";
import type { CookiePayload } from "../ensureCookies.js";
import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js";

export interface FinishRegisterInput {
authorization?: string;
Expand Down Expand Up @@ -52,47 +52,14 @@ export async function finishRegisterHandler(
};
}

const verified = await verifySignedAuthResponse(
data.token,
opts.authServerUrl,
opts.audience,
);

if (!verified) {
throw new Error("Invalid signed response from Auth Server");
}

if (verified.sub !== data.sub) {
throw new Error("Signature mismatch with data payload");
}

const sessionId = typeof verified.sid === "string" ? verified.sid : undefined;

return {
status: 204,
setCookies: [
{
name: opts.accessCookieName,
value: {
sub: data.sub,
...(sessionId === undefined ? {} : { sessionId }),
token: data.token,
roles: data.roles,
email: data.email,
phone: data.phone,
},
ttl: data.ttl,
domain: opts.cookieDomain,
},
{
name: opts.refreshCookieName,
value: {
sub: data.sub,
refreshToken: data.refreshToken,
},
ttl: data.refreshTtl,
domain: opts.cookieDomain,
},
],
setCookies: await issueSessionCookies(data, {
authServerUrl: opts.authServerUrl,
audience: opts.audience,
accessCookieName: opts.accessCookieName,
refreshCookieName: opts.refreshCookieName,
cookieDomain: opts.cookieDomain,
}),
};
}
18 changes: 4 additions & 14 deletions packages/core/src/handlers/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { authFetch } from "../authFetch.js";
import { readPassthroughFailure } from "../upstreamError.js";
import type { ResultFailure } from "../result.js";
import type { CookiePayload } from "../ensureCookies.js";
import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js";
import { verifyUpstreamSession } from "../upstreamSession.js";

export interface LoginInput {
body: unknown;
Expand Down Expand Up @@ -52,19 +52,9 @@ export async function loginHandler(
};
}

const verified = await verifySignedAuthResponse(
data.token,
opts.authServerUrl,
opts.audience,
);

if (!verified) {
throw new Error("Invalid signed response from Auth Server");
}

if (verified.sub !== data.sub) {
throw new Error("Signature mismatch with data payload");
}
// Login issues only the pre-auth cookie, so it verifies the response without
// building session cookies from it.
await verifyUpstreamSession(data, opts.authServerUrl, opts.audience);

const body = {
...(typeof data.message === "string" ? { message: data.message } : {}),
Expand Down
53 changes: 8 additions & 45 deletions packages/core/src/handlers/oauthHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { authFetch } from "../authFetch.js";
import { issueSessionCookies } from "../upstreamSession.js";
import { readPassthroughFailure } from "../upstreamError.js";
import type { ResultFailure } from "../result.js";
import type { CookiePayload } from "../ensureCookies.js";
import { verifySignedAuthResponse } from "../verifySignedAuthResponse.js";

export interface OAuthHandlerOptions {
authServerUrl: string;
Expand Down Expand Up @@ -90,52 +90,15 @@ export async function finishOAuthLoginHandler(
};
}

const verifiedAccessToken = await verifySignedAuthResponse(
data.token,
opts.authServerUrl,
opts.audience,
);

if (!verifiedAccessToken) {
throw new Error("Invalid signed response from Auth Server");
}

if (verifiedAccessToken.sub !== data.sub) {
throw new Error("Signature mismatch with data payload");
}

const sessionId =
typeof verifiedAccessToken.sid === "string"
? verifiedAccessToken.sid
: undefined;

return {
status: up.status,
body: data,
setCookies: [
{
name: opts.accessCookieName,
value: {
sub: data.sub,
...(sessionId === undefined ? {} : { sessionId }),
token: data.token,
roles: data.roles,
email: data.email,
phone: data.phone,
organizationId: data.organizationId ?? null,
},
ttl: data.ttl,
domain: opts.cookieDomain,
},
{
name: opts.refreshCookieName,
value: {
sub: data.sub,
refreshToken: data.refreshToken,
},
ttl: data.refreshTtl,
domain: opts.cookieDomain,
},
],
setCookies: await issueSessionCookies(data, {
authServerUrl: opts.authServerUrl,
audience: opts.audience,
accessCookieName: opts.accessCookieName,
refreshCookieName: opts.refreshCookieName,
cookieDomain: opts.cookieDomain,
}),
};
}
Loading
Loading