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
10 changes: 10 additions & 0 deletions .changeset/enrollment-invites-external-delivery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": minor
"@seamless-auth/fastify": minor
"@seamless-auth/nextjs": minor
---

Deliver enrollment invites through adopter messaging (#183). With `messaging` configured, `POST /admin/enrollment/invites` now asks the auth API for external delivery (`x-seamless-auth-delivery-mode: external`, with the `seamless-auth-external-delivery` service token) and sends each invite in `results[].delivery` through `deliverAuthMessage`, so `handlers.sendEnrollmentInviteEmail`, `overrides.enrollmentInviteEmail` or the email transport send it instead of the auth API's mail system. Without `messaging` the route is unchanged.

The response no longer carries `results[].delivery`, so recipient addresses and sign-in links are not echoed to the browser. A recipient whose delivery throws is reported as `{ "status": "skipped", "reason": "delivery_failed" }` and `sent` and `skipped` are adjusted; the other recipients are still sent and the route answers 200. The auth API has already recorded that recipient as invited, so repeating the call within a day reports them as `recently_invited`.
16 changes: 16 additions & 0 deletions .changeset/refresh-failure-keeps-session.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@seamless-auth/core": minor
"@seamless-auth/express": minor
"@seamless-auth/fastify": minor
"@seamless-auth/nextjs": minor
---

Keep the session cookies when a refresh fails without the auth API refusing it, matching the Go adapter (fells-code/seamless-auth-go#22). The silent refresh on the auth routes and cookie-transport `POST /refresh` used to clear the access and refresh cookies on any failure, so a network blip or an auth API restart signed the browser out.

The cookies are now cleared only when the auth API answers the refresh with 401, or the refresh cookie cannot be read. Those keep their current answers (`401 {"error":"Refresh failed"}` on a silent refresh, `401 {"error":"invalid_refresh_token"}` on `POST /refresh`). Otherwise the cookies stay and:

- the auth API cannot be reached: `502 {"error":"upstream_unavailable"}`;
- the auth API answers any other non-2xx status: that status and body pass through;
- the rotated session fails verification, or a 2xx carries no session: `502 {"error":"upstream_unavailable"}`. `POST /refresh` answered these with 500 before.

Core adds `refreshCookieSession`, which reports how a cookie rotation ended, and `failureBody`. `refreshAccessToken` keeps its signature. In-flight sharing and the 5 second window for a completed cookie rotation are unchanged, and only a successful rotation is kept. No request's abort signal reaches the upstream call, so a caller that goes away does not cancel a rotation other callers share.
16 changes: 3 additions & 13 deletions packages/core/src/applyResult.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import jwt from "jsonwebtoken";

import type { CookiePayload } from "./ensureCookies.js";
import type { ResultFailure } from "./result.js";
import { failureBody, type ResultFailure } from "./result.js";
import type { AuthTransport } from "./transport.js";

export type CookieSameSite = "lax" | "none" | "strict";
Expand Down Expand Up @@ -244,18 +244,8 @@ export function applyResult(
applyCookies(result, adapter, opts);
}

if (result.errorBody !== undefined) {
adapter.send(result.status, result.errorBody);
return;
}

if (result.errorCode !== undefined) {
adapter.send(
result.status,
result.details === undefined
? { error: result.errorCode }
: { error: result.errorCode, details: result.details },
);
if (result.errorBody !== undefined || result.errorCode !== undefined) {
adapter.send(result.status, failureBody(result));
return;
}

Expand Down
70 changes: 70 additions & 0 deletions packages/core/src/deliverAuthMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -370,3 +370,73 @@ export async function applyExternalDelivery(

return body;
}

interface EnrollmentInviteResult {
userId: string;
status: "sent" | "skipped";
reason?: string;
delivery?: AuthDeliveryInstruction;
}

/**
* Sends each invite the auth API handed back from `POST /admin/enrollment/invites` and
* drops the payloads, which carry addresses and links the browser has no use for.
*
* One recipient's failed delivery does not fail the batch: it is reported with the API's
* own `delivery_failed` skip reason and the counts follow. The API has already stamped
* that user as invited, so an immediate retry reports them as `recently_invited`.
*/
export async function deliverEnrollmentInvites(
messaging: SeamlessAuthMessagingOptions | undefined,
body: unknown,
): Promise<unknown> {
if (
!body ||
typeof body !== "object" ||
!Array.isArray((body as { results?: unknown }).results)
) {
return body;
}

const results: EnrollmentInviteResult[] = [];
let failed = 0;

for (const entry of (body as { results: EnrollmentInviteResult[] })
.results) {
if (!entry || typeof entry !== "object" || !("delivery" in entry)) {
results.push(entry);
continue;
}

const { delivery, ...result } = entry;

if (result.status !== "sent") {
results.push(result);
continue;
}

try {
await deliverAuthMessage(messaging, delivery);
results.push(result);
} catch (error) {
getSeamlessLogger().error(
`[SeamlessAuth] An enrollment invite could not be sent: ${
error instanceof Error ? error.name : "unknown error"
}.`,
);
failed += 1;
results.push({ ...result, status: "skipped", reason: "delivery_failed" });
}
}

const counts = body as { sent?: unknown; skipped?: unknown };

return {
...body,
...(typeof counts.sent === "number" ? { sent: counts.sent - failed } : {}),
...(typeof counts.skipped === "number"
? { skipped: counts.skipped + failed }
: {}),
results,
};
}
48 changes: 34 additions & 14 deletions packages/core/src/ensureCookies.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { holdsAccessToken } from "./guards.js";
import { verifyCookieJwt } from "./verifyCookieJwt.js";
import type { ResultFailure } from "./result.js";
import { refreshAccessToken } from "./refreshAccessToken.js";
import { refreshCookieSession } from "./refreshAccessToken.js";
import { UPSTREAM_UNAVAILABLE_ERROR_CODE } from "./authFetch.js";
import { readPassthroughFailure } from "./upstreamError.js";
import { assertSecrets } from "./validateSecrets.js";
import {
type AdapterManifest,
Expand Down Expand Up @@ -284,7 +286,7 @@ async function refreshRequiredCookie(
return null;
}

const refreshed = await refreshAccessToken(refreshCookie, {
const outcome = await refreshCookieSession(refreshCookie, {
authServerUrl: opts.authServerUrl,
cookieSecret: opts.cookieSecret,
serviceSecret: opts.serviceSecret,
Expand All @@ -295,19 +297,37 @@ async function refreshRequiredCookie(
forwardedUserAgent: opts.forwardedUserAgent,
});

const failure: EnsureCookiesResult = {
if (outcome.type === "rejected") {
return {
type: "error",
status: 401,
errorCode: "Refresh failed",
clearCookies: [
cookieName,
opts.registrationCookieName,
opts.refreshCookieName,
],
};
}

// The token may still be good, or rotated with the answer lost, so the
// cookies stay for the next attempt.
if (outcome.type === "answered") {
return {
type: "error",
status: outcome.status,
...readPassthroughFailure(outcome.body),
};
}

const unavailable: EnsureCookiesResult = {
type: "error",
status: 401,
errorCode: "Refresh failed",
clearCookies: [
cookieName,
opts.registrationCookieName,
opts.refreshCookieName,
],
status: 502,
errorCode: UPSTREAM_UNAVAILABLE_ERROR_CODE,
};

if (!refreshed?.token) {
return failure;
if (outcome.type === "unavailable") {
return unavailable;
}

// The cookie signs whatever goes into it with the adopter's own secret, and
Expand All @@ -318,7 +338,7 @@ async function refreshRequiredCookie(
let setCookies: CookieInstruction[];
try {
setCookies = await issueSessionCookies(
refreshed as UpstreamSessionResponse,
outcome.session as UpstreamSessionResponse,
{
authServerUrl: opts.authServerUrl,
audience: opts.accessTokenAudience || opts.authServerUrl,
Expand All @@ -329,7 +349,7 @@ async function refreshRequiredCookie(
},
);
} catch {
return failure;
return unavailable;
}

const access = setCookies[0].value;
Expand Down
30 changes: 23 additions & 7 deletions packages/core/src/handlers/refresh.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { AuthServerIssuerOption } from "../authServerIssuer.js";
import { extractBearerToken } from "../verifyAccessToken.js";
import {
refreshAccessToken,
refreshBearerSession,
refreshCookieSession,
} from "../refreshAccessToken.js";
import { UPSTREAM_UNAVAILABLE_ERROR_CODE } from "../authFetch.js";
import { readPassthroughFailure } from "../upstreamError.js";
import { SERVICE_TOKEN_AUDIENCE, SERVICE_TOKEN_ISSUER } from "../apiContract.js";
import { sessionResult, type UpstreamSessionResponse } from "../upstreamSession.js";
import type { AuthTransport } from "../transport.js";
Expand Down Expand Up @@ -98,7 +100,7 @@ export async function refreshHandler(
return { status: 401, errorCode: "refresh token required" };
}

const rotated = await refreshAccessToken(input.refreshCookie, {
const rotated = await refreshCookieSession(input.refreshCookie, {
authServerUrl: opts.authServerUrl,
cookieSecret: opts.cookieSecret,
serviceSecret: opts.serviceSecret,
Expand All @@ -110,16 +112,30 @@ export async function refreshHandler(
...forwarding,
});

if (!rotated) {
if (rotated.type === "rejected") {
return {
status: 401,
errorCode: "invalid_refresh_token",
clearCookies: [opts.accessCookieName, opts.refreshCookieName],
};
}

return {
status: 200,
...(await sessionResult(rotated as UpstreamSessionResponse, session)),
};
// Short of a refusal the token may still be good, or rotated with the answer
// lost, so the cookies stay for the next attempt.
if (rotated.type === "answered") {
return { status: rotated.status, ...readPassthroughFailure(rotated.body) };
}

if (rotated.type === "unavailable") {
return { status: 502, errorCode: UPSTREAM_UNAVAILABLE_ERROR_CODE };
}

try {
return {
status: 200,
...(await sessionResult(rotated.session as UpstreamSessionResponse, session)),
};
} catch {
return { status: 502, errorCode: UPSTREAM_UNAVAILABLE_ERROR_CODE };
}
}
15 changes: 13 additions & 2 deletions packages/core/src/manifestProxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
applyExternalDelivery,
deliverableNoticeKinds,
deliverAuthMessage,
deliverEnrollmentInvites,
} from "./deliverAuthMessage.js";
import type { AuthDeliveryInstruction } from "./authMessaging.js";
import {
Expand Down Expand Up @@ -78,6 +79,12 @@ function cookieNameFor(held: AdapterHeld, opts: ManifestProxyOptions) {
}[held];
}

// Its payloads come back per recipient in `results[].delivery`, which the manifest's
// `delivery` flag (one top-level payload) cannot describe.
function isEnrollmentInvites(route: AdapterManifestRoute) {
return route.method === "POST" && route.path === "/admin/enrollment/invites";
}

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
Expand Down Expand Up @@ -238,7 +245,9 @@ export async function handleManifestRoute(
? `Bearer ${extractBearerToken(input.authorization)}`
: `Bearer ${input.cookiePayload?.token}`;

const externalDelivery = Boolean(route.delivery && opts.messaging);
const inviteDelivery = isEnrollmentInvites(route) && Boolean(opts.messaging);
const externalDelivery =
inviteDelivery || Boolean(route.delivery && opts.messaging);
// Without a kind it can send, the request goes out as before and the API sends the
// notices itself.
const noticeKinds = route.notices ? deliverableNoticeKinds(opts.messaging) : [];
Expand Down Expand Up @@ -289,7 +298,9 @@ export async function handleManifestRoute(
return { status: upstream.status, ...readPassthroughFailure(data) };
}

if (externalDelivery) {
if (inviteDelivery) {
data = await deliverEnrollmentInvites(opts.messaging, data);
} else if (externalDelivery) {
data = await applyExternalDelivery(opts.messaging, data);
}

Expand Down
Loading
Loading