diff --git a/.changeset/v020-recovery-sessions-and-notices.md b/.changeset/v020-recovery-sessions-and-notices.md new file mode 100644 index 0000000..ef82167 --- /dev/null +++ b/.changeset/v020-recovery-sessions-and-notices.md @@ -0,0 +1,13 @@ +--- +"@seamless-auth/core": minor +"@seamless-auth/express": minor +"@seamless-auth/fastify": minor +"@seamless-auth/nextjs": minor +--- + +Support for the auth server's v0.20 account recovery, built on `@seamless-auth/types` 0.31.0. + +- **Recovery sessions are refused by default.** An access token from account recovery or a recovery code carries `recovery: true` and can only enroll a new passkey or authenticator app. `requireAuth` (Express, Fastify) answers it with `403 { "error": "enrollment_required" }`, and the Next.js `getSeamlessSession` and `getSeamlessClaims` treat it as signed out, unless `allowRecoverySession: true` is passed. Set it only where the app drives that enrollment. After enrolling, a refresh issues a token without the claim. +- **Account notices.** On routes the auth server's manifest marks `notices: true` (today `POST /recovery/verify`), an adapter with `messaging` names the notice kinds it can send in `x-seamless-auth-delivery-kinds` and delivers the `account_notice_email` / `account_notice_sms` items the server returns. These items are its "recovery opened" notice with a cancel link, which goes only to the owner and is never logged. If none goes out, the adapter cancels the request with that link's token and answers `503 recovery_notice_failed`, so recovery never runs without the owner being told. New `handlers.sendAccountNoticeEmail` / `sendAccountNoticeSms` and `overrides.accountNoticeEmail` / `accountNoticeSms`; the default sends the server's text as plain text. +- An OTP sent for account recovery (`purpose: "account_recovery"`) is worded as a recovery code, and the purpose is passed to custom OTP handlers and overrides. +- The bundled fallback manifest now includes the v0.20 routes, including the recovery, recovery code and staged policy routes. diff --git a/packages/core/package.json b/packages/core/package.json index 62c164f..5452890 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -51,7 +51,7 @@ "test:watch": "pnpm run build && NODE_OPTIONS=--experimental-vm-modules jest --watch" }, "dependencies": { - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/types": "^0.31.0", "@types/jsonwebtoken": "^9.0.10", "jose": "^6.1.3", "jsonwebtoken": "^9.0.2" diff --git a/packages/core/src/apiContract.ts b/packages/core/src/apiContract.ts index 4a1ab49..199d9c3 100644 --- a/packages/core/src/apiContract.ts +++ b/packages/core/src/apiContract.ts @@ -12,6 +12,8 @@ import { createServiceToken } from "./createServiceToken.js"; */ export const AUTH_DELIVERY_MODE_HEADER = "x-seamless-auth-delivery-mode"; export const EXTERNAL_DELIVERY_MODE = "external"; +/** Lists the delivery kinds this server can send that an older adapter would not know. */ +export const AUTH_DELIVERY_KINDS_HEADER = "x-seamless-auth-delivery-kinds"; /** Headers that request external delivery, spreadable into an `authFetch` call. */ export const EXTERNAL_DELIVERY_HEADERS: Readonly> = diff --git a/packages/core/src/authMessaging.ts b/packages/core/src/authMessaging.ts index 3db1a38..04d4dc3 100644 --- a/packages/core/src/authMessaging.ts +++ b/packages/core/src/authMessaging.ts @@ -11,7 +11,9 @@ * which carry provider implementations, and the adopter-facing configuration. */ export type { + AccountNoticeName, AuthDeliveryInstruction, + DeliveryPurpose, DeliveryResult, EmailMessage, MessagingChannel, @@ -22,6 +24,8 @@ export type { } from "@seamless-auth/types"; import type { + AccountNoticeName, + DeliveryPurpose, DeliveryResult, EmailMessage, SendMagicLinkEmailInput, @@ -37,6 +41,33 @@ export interface SendEnrollmentInviteEmailInput { subject?: string; } +/** + * An owner notice the auth API asked this server to send, such as "account recovery + * was requested". `text` is ready to send. `cancelUrl` carries a credential: send it + * only to `to`, and never log it. + */ +export interface SendAccountNoticeEmailInput { + notice: AccountNoticeName; + to: string; + subject: string; + text: string; + cancelUrl?: string; + from?: string; +} + +export interface SendAccountNoticeSmsInput { + notice: AccountNoticeName; + to: string; + text: string; + cancelUrl?: string; + from?: string; +} + +/** Why a code was sent, when it is not an ordinary sign-in or verification code. */ +export interface OtpPurpose { + purpose?: DeliveryPurpose; +} + export interface EmailTransport { readonly name: string; send(message: EmailMessage): Promise; @@ -53,12 +84,12 @@ export interface AuthMessageOverrideContext { export interface AuthMessageOverrides { otpEmail?: ( - input: SendOtpEmailInput, + input: SendOtpEmailInput & OtpPurpose, defaults: EmailMessage, context: AuthMessageOverrideContext, ) => EmailMessage; otpSms?: ( - input: SendOtpSmsInput, + input: SendOtpSmsInput & OtpPurpose, defaults: SmsMessage, context: AuthMessageOverrideContext, ) => SmsMessage; @@ -72,15 +103,29 @@ export interface AuthMessageOverrides { defaults: EmailMessage, context: AuthMessageOverrideContext, ) => EmailMessage; + accountNoticeEmail?: ( + input: SendAccountNoticeEmailInput, + defaults: EmailMessage, + context: AuthMessageOverrideContext, + ) => EmailMessage; + accountNoticeSms?: ( + input: SendAccountNoticeSmsInput, + defaults: SmsMessage, + context: AuthMessageOverrideContext, + ) => SmsMessage; } export interface AuthMessagingHandlers { - sendOtpEmail(input: SendOtpEmailInput): Promise; - sendOtpSms(input: SendOtpSmsInput): Promise; + sendOtpEmail(input: SendOtpEmailInput & OtpPurpose): Promise; + sendOtpSms(input: SendOtpSmsInput & OtpPurpose): Promise; sendMagicLinkEmail(input: SendMagicLinkEmailInput): Promise; sendEnrollmentInviteEmail?( input: SendEnrollmentInviteEmailInput, ): Promise; + sendAccountNoticeEmail?( + input: SendAccountNoticeEmailInput, + ): Promise; + sendAccountNoticeSms?(input: SendAccountNoticeSmsInput): Promise; } export interface SeamlessAuthMessagingOptions { diff --git a/packages/core/src/deliverAuthMessage.ts b/packages/core/src/deliverAuthMessage.ts index 09453d8..751dbb2 100644 --- a/packages/core/src/deliverAuthMessage.ts +++ b/packages/core/src/deliverAuthMessage.ts @@ -41,6 +41,10 @@ function buildOtpEmailMessage( messaging: SeamlessAuthMessagingOptions, ): EmailMessage { const appName = messaging.defaults?.appName ?? "Seamless Auth"; + const recovery = input.purpose === "account_recovery"; + const subject = recovery + ? `${appName} - Account recovery code` + : `${appName} - Verify your email`; return applyEmailOverride( messaging.overrides?.otpEmail, @@ -48,15 +52,24 @@ function buildOtpEmailMessage( to: input.to, token: input.token, from: messaging.defaults?.emailFrom, - subject: `${appName} - Verify your email`, - }, - { - to: input.to, - from: messaging.defaults?.emailFrom, - subject: `${appName} - Verify your email`, - text: `Verify your account with ${appName}.\n\nYour verification code is: ${input.token}\n\nIf you did not request this code, you can safely ignore this message.`, - html: `

Verify your account with ${appName}

Please use the verification code below:

${input.token}

If you did not request this code, you can safely ignore this message.

`, + subject, + ...(input.purpose ? { purpose: input.purpose } : {}), }, + recovery + ? { + to: input.to, + from: messaging.defaults?.emailFrom, + subject, + text: `Someone asked to recover your ${appName} account.\n\nYour account recovery code is: ${input.token}\n\nIf this was not you, do not share this code with anyone. Recovery signs out every session on the account.`, + html: `

Account recovery for ${appName}

Someone asked to recover your account. Your account recovery code is:

${input.token}

If this was not you, do not share this code with anyone. Recovery signs out every session on the account.

`, + } + : { + to: input.to, + from: messaging.defaults?.emailFrom, + subject, + text: `Verify your account with ${appName}.\n\nYour verification code is: ${input.token}\n\nIf you did not request this code, you can safely ignore this message.`, + html: `

Verify your account with ${appName}

Please use the verification code below:

${input.token}

If you did not request this code, you can safely ignore this message.

`, + }, appName, ); } @@ -73,11 +86,15 @@ function buildOtpSmsMessage( to: input.to, token: input.token, from: messaging.defaults?.smsFrom, + ...(input.purpose ? { purpose: input.purpose } : {}), }, { to: input.to, from: messaging.defaults?.smsFrom, - body: `Your ${appName} verification code is: ${input.token}. No one will ever ask you for this code. Do not share it.`, + body: + input.purpose === "account_recovery" + ? `Someone asked to recover your ${appName} account. Your account recovery code is: ${input.token}. If this was not you, do not share it.` + : `Your ${appName} verification code is: ${input.token}. No one will ever ask you for this code. Do not share it.`, }, appName, ); @@ -134,6 +151,59 @@ function buildEnrollmentInviteMessage( ); } +function buildAccountNoticeEmail( + input: Extract, + messaging: SeamlessAuthMessagingOptions, +): EmailMessage { + const { kind: _kind, ...notice } = input; + const from = messaging.defaults?.emailFrom; + + // Plain text only: the API writes the wording, and none of it is markup. + return applyEmailOverride( + messaging.overrides?.accountNoticeEmail, + { ...notice, from }, + { to: input.to, from, subject: input.subject, text: input.text }, + messaging.defaults?.appName, + ); +} + +function buildAccountNoticeSms( + input: Extract, + messaging: SeamlessAuthMessagingOptions, +): SmsMessage { + const { kind: _kind, ...notice } = input; + const from = messaging.defaults?.smsFrom; + + return applySmsOverride( + messaging.overrides?.accountNoticeSms, + { ...notice, from }, + { to: input.to, from, body: input.text }, + messaging.defaults?.appName, + ); +} + +/** + * The account notice kinds this configuration can send, for the auth API's + * `x-seamless-auth-delivery-kinds` header. The API counts a returned notice as the + * owner having been told, so a kind is only listed when it will really go out. + */ +export function deliverableNoticeKinds( + messaging: SeamlessAuthMessagingOptions | undefined, +): Array<"account_notice_email" | "account_notice_sms"> { + if (!messaging) return []; + + const kinds: Array<"account_notice_email" | "account_notice_sms"> = []; + + if (messaging.handlers?.sendAccountNoticeEmail || messaging.email) { + kinds.push("account_notice_email"); + } + if (messaging.handlers?.sendAccountNoticeSms || messaging.sms) { + kinds.push("account_notice_sms"); + } + + return kinds; +} + export async function deliverAuthMessage( messaging: SeamlessAuthMessagingOptions | undefined, delivery: AuthDeliveryInstruction | undefined, @@ -149,6 +219,7 @@ export async function deliverAuthMessage( to: delivery.to, token: delivery.token, from: messaging.defaults?.emailFrom, + ...(delivery.purpose ? { purpose: delivery.purpose } : {}), }); return; } @@ -166,6 +237,7 @@ export async function deliverAuthMessage( to: delivery.to, token: delivery.token, from: messaging.defaults?.smsFrom, + ...(delivery.purpose ? { purpose: delivery.purpose } : {}), }); return; } @@ -216,6 +288,42 @@ export async function deliverAuthMessage( ); return; + case "account_notice_email": { + if (messaging.handlers?.sendAccountNoticeEmail) { + const { kind: _kind, ...notice } = delivery; + await messaging.handlers.sendAccountNoticeEmail({ + ...notice, + from: messaging.defaults?.emailFrom, + }); + return; + } + + if (!messaging.email) { + throw new Error("Missing email transport for account notice delivery."); + } + + await messaging.email.send(buildAccountNoticeEmail(delivery, messaging)); + return; + } + + case "account_notice_sms": { + if (messaging.handlers?.sendAccountNoticeSms) { + const { kind: _kind, ...notice } = delivery; + await messaging.handlers.sendAccountNoticeSms({ + ...notice, + from: messaging.defaults?.smsFrom, + }); + return; + } + + if (!messaging.sms) { + throw new Error("Missing SMS transport for account notice delivery."); + } + + await messaging.sms.send(buildAccountNoticeSms(delivery, messaging)); + return; + } + default: { // A new kind in @seamless-auth/types fails the build here. At runtime an // API newer than this adapter can still send one, so it is logged, not thrown. diff --git a/packages/core/src/guards.ts b/packages/core/src/guards.ts index 2cce911..4b34766 100644 --- a/packages/core/src/guards.ts +++ b/packages/core/src/guards.ts @@ -109,6 +109,28 @@ export interface CookieAuthInput { /** The raw access cookie, or `undefined` when the request carried none. */ token?: string; cookieSecret: string; + /** + * Accept a recovery session: one issued by account recovery or a recovery code, + * which can only enroll a new passkey or authenticator app until it does. Off by + * default, so a half-recovered account is not treated as signed in. Turn it on only + * for the routes that drive that enrollment. + */ + allowRecoverySession?: boolean; +} + +const RECOVERY_SESSION_REJECTION: GuardRejection = { + status: 403, + errorCode: "enrollment_required", +}; + +function isRecoveryToken(token: unknown): boolean { + if (typeof token !== "string") return false; + + try { + return decodeJwt(token).recovery === true; + } catch { + return false; + } } export type CookieAuthResult = @@ -163,6 +185,10 @@ export function authenticateCookie(input: CookieAuthInput): CookieAuthResult { }; } + if (!input.allowRecoverySession && isRecoveryToken(payload.token)) { + return { rejection: RECOVERY_SESSION_REJECTION }; + } + return { user: { id: payload.sub, @@ -185,6 +211,8 @@ export interface BearerAuthInput extends AuthServerIssuerOption { * `authServerIssuer`, which defaults to `authServerUrl`. */ audience: string; + /** As on `CookieAuthInput`. */ + allowRecoverySession?: boolean; } export type AuthResult = CookieAuthResult; @@ -232,6 +260,10 @@ export async function authenticateBearer( }; } + if (!input.allowRecoverySession && claims.recovery === true) { + return { rejection: RECOVERY_SESSION_REJECTION }; + } + return { user: { id: claims.sub, @@ -284,6 +316,7 @@ export async function authenticateRequest( return authenticateBearer({ authorization: input.authorization, + allowRecoverySession: input.allowRecoverySession, ...input.bearer, }); } diff --git a/packages/core/src/manifest/adapterManifest.ts b/packages/core/src/manifest/adapterManifest.ts index c09334f..99339d3 100644 --- a/packages/core/src/manifest/adapterManifest.ts +++ b/packages/core/src/manifest/adapterManifest.ts @@ -28,6 +28,8 @@ export interface AdapterManifestRoute { /** Cookie transport only: the body fields passed to the browser. */ body?: { pick: string[] }; delivery?: true; + /** Owner notices come back as `deliveries` when the request names their kinds. */ + notices?: true; } export interface AdapterManifest { @@ -68,7 +70,8 @@ function isRoute(value: unknown): value is AdapterManifestRoute { (typeof route.body === "object" && route.body !== null && Array.isArray((route.body as { pick?: unknown }).pick))) && - (route.delivery === undefined || route.delivery === true) + (route.delivery === undefined || route.delivery === true) && + (route.notices === undefined || route.notices === true) ); } diff --git a/packages/core/src/manifest/bundledManifest.ts b/packages/core/src/manifest/bundledManifest.ts index 0539e46..5ccd9a0 100644 --- a/packages/core/src/manifest/bundledManifest.ts +++ b/packages/core/src/manifest/bundledManifest.ts @@ -3,7 +3,7 @@ import type { AdapterManifest } from "./adapterManifest.js"; export const BUNDLED_ADAPTER_MANIFEST: AdapterManifest = { "schemaVersion": 1, - "apiVersion": "0.19.2", + "apiVersion": "0.19.4", "session": { "subject": "sub", "token": "token", @@ -372,6 +372,60 @@ export const BUNDLED_ADAPTER_MANIFEST: AdapterManifest = { "credential": "registration", "issues": "session" }, + { + "method": "GET", + "path": "/recovery-codes", + "credential": "access" + }, + { + "method": "POST", + "path": "/recovery-codes", + "credential": "access" + }, + { + "method": "POST", + "path": "/recovery-codes/redeem", + "credential": "preAuth", + "issues": "session" + }, + { + "method": "POST", + "path": "/recovery/cancel", + "credential": "none" + }, + { + "method": "POST", + "path": "/recovery/complete", + "credential": "preAuth", + "issues": "session" + }, + { + "method": "GET", + "path": "/recovery/requests", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/recovery/requests/{id}", + "credential": "access" + }, + { + "method": "POST", + "path": "/recovery/start", + "credential": "preAuth", + "delivery": true + }, + { + "method": "GET", + "path": "/recovery/status", + "credential": "preAuth" + }, + { + "method": "POST", + "path": "/recovery/verify", + "credential": "preAuth", + "notices": true + }, { "method": "POST", "path": "/refresh", @@ -456,6 +510,41 @@ export const BUNDLED_ADAPTER_MANIFEST: AdapterManifest = { "path": "/system-config/oauth-providers/{id}", "credential": "access" }, + { + "method": "GET", + "path": "/system-config/policy", + "credential": "access" + }, + { + "method": "DELETE", + "path": "/system-config/policy/draft", + "credential": "access" + }, + { + "method": "PUT", + "path": "/system-config/policy/draft", + "credential": "access" + }, + { + "method": "POST", + "path": "/system-config/policy/dry-run", + "credential": "access" + }, + { + "method": "POST", + "path": "/system-config/policy/publish", + "credential": "access" + }, + { + "method": "POST", + "path": "/system-config/policy/rollback", + "credential": "access" + }, + { + "method": "GET", + "path": "/system-config/policy/versions", + "credential": "access" + }, { "method": "GET", "path": "/system-config/public", diff --git a/packages/core/src/manifestProxy.ts b/packages/core/src/manifestProxy.ts index 431955f..c376d57 100644 --- a/packages/core/src/manifestProxy.ts +++ b/packages/core/src/manifestProxy.ts @@ -1,10 +1,19 @@ import type { AppliableResult, SessionCookie } from "./applyResult.js"; import { authFetch } from "./authFetch.js"; +import { getSeamlessLogger } from "./logger.js"; import type { AuthServerIssuerOption } from "./authServerIssuer.js"; import type { SeamlessAuthMessagingOptions } from "./authMessaging.js"; -import { EXTERNAL_DELIVERY_HEADERS } from "./apiContract.js"; +import { + AUTH_DELIVERY_KINDS_HEADER, + EXTERNAL_DELIVERY_HEADERS, +} from "./apiContract.js"; import { withoutTokens } from "./bodyTokens.js"; -import { applyExternalDelivery } from "./deliverAuthMessage.js"; +import { + applyExternalDelivery, + deliverableNoticeKinds, + deliverAuthMessage, +} from "./deliverAuthMessage.js"; +import type { AuthDeliveryInstruction } from "./authMessaging.js"; import { type AdapterHeld, type AdapterManifestRoute, @@ -135,6 +144,64 @@ function isJson(contentType: string | null | undefined) { * A response only issues cookies when it actually carries a session: an OTP * verify on a phone-first signup step, for example, succeeds without one. */ +/** + * Sends the owner notices the auth API handed back and drops them from the body: they + * can carry a cancel link that only the account owner may see. + * + * The API counted them as the owner being told, so if none goes out the request they + * announce is cancelled with the token in its link, and the caller gets the refusal the + * API gives when it cannot notify the owner itself. + */ +async function deliverNotices( + data: unknown, + opts: ManifestProxyOptions, +): Promise<{ data: unknown; refused?: AppliableResult }> { + if (!data || typeof data !== "object" || !("deliveries" in data)) { + return { data }; + } + + const { deliveries, ...rest } = data as { deliveries?: unknown }; + const items = Array.isArray(deliveries) + ? (deliveries as AuthDeliveryInstruction[]) + : []; + let sent = 0; + + for (const delivery of items) { + try { + await deliverAuthMessage(opts.messaging, delivery); + sent += 1; + } catch (error) { + getSeamlessLogger().error( + `[SeamlessAuth] An account notice (${delivery.kind}) could not be sent: ${ + error instanceof Error ? error.name : "unknown error" + }.`, + ); + } + } + + if (items.length === 0 || sent > 0) { + return { data: rest }; + } + + const cancelToken = items + .map((item) => ("cancelUrl" in item ? item.cancelUrl : undefined)) + .map((url) => url?.split("#")[1]) + .find(Boolean); + + if (cancelToken) { + await authFetch(`${opts.authServerUrl}/recovery/cancel`, { + method: "POST", + serviceAuthorization: opts.serviceAuthorization, + body: { cancelToken }, + }); + } + + return { + data: rest, + refused: { status: 503, errorCode: "recovery_notice_failed" }, + }; +} + export async function handleManifestRoute( input: ManifestProxyInput, opts: ManifestProxyOptions, @@ -170,6 +237,10 @@ export async function handleManifestRoute( : `Bearer ${input.cookiePayload?.token}`; const externalDelivery = 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) : []; + const ownsDelivery = externalDelivery || noticeKinds.length > 0; const upstream = await authFetch( buildUpstreamUrl( @@ -180,12 +251,21 @@ export async function handleManifestRoute( { method: route.method, authorization, - serviceAuthorization: externalDelivery + serviceAuthorization: ownsDelivery ? opts.deliveryAuthorization : opts.serviceAuthorization, forwardedClientIp: input.forwardedClientIp, forwardedUserAgent: input.forwardedUserAgent, - ...(externalDelivery ? { headers: { ...EXTERNAL_DELIVERY_HEADERS } } : {}), + ...(ownsDelivery + ? { + headers: { + ...EXTERNAL_DELIVERY_HEADERS, + ...(noticeKinds.length > 0 + ? { [AUTH_DELIVERY_KINDS_HEADER]: noticeKinds.join(",") } + : {}), + }, + } + : {}), ...(route.method === "GET" ? {} : { body: input.body }), }, ); @@ -211,6 +291,16 @@ export async function handleManifestRoute( data = await applyExternalDelivery(opts.messaging, data); } + if (noticeKinds.length > 0) { + const notices = await deliverNotices(data, opts); + + if (notices.refused) { + return notices.refused; + } + + data = notices.data; + } + const clearCookies = route.clears ? [...new Set(route.clears.map((held) => cookieNameFor(held, opts)))] : undefined; diff --git a/packages/core/tests/deliverAuthMessage.test.js b/packages/core/tests/deliverAuthMessage.test.js index 6e6d8a8..9b26a2b 100644 --- a/packages/core/tests/deliverAuthMessage.test.js +++ b/packages/core/tests/deliverAuthMessage.test.js @@ -3,8 +3,12 @@ // a new adapter would call it. import { jest } from "@jest/globals"; -const { applyExternalDelivery, deliverAuthMessage, stripDelivery } = - await import("../dist/deliverAuthMessage.js"); +const { + applyExternalDelivery, + deliverableNoticeKinds, + deliverAuthMessage, + stripDelivery, +} = await import("../dist/deliverAuthMessage.js"); function emailTransport() { return { @@ -236,6 +240,118 @@ describe("deliverAuthMessage", () => { }); }); +describe("account recovery deliveries", () => { + const emailNotice = { + kind: "account_notice_email", + notice: "account_recovery_opened", + to: "owner@acme.test", + subject: "Account recovery requested", + text: "Cancel it: https://app.acme.test/recovery/cancel#t", + cancelUrl: "https://app.acme.test/recovery/cancel#t", + }; + + it("words a recovery code as one, by email and by SMS", async () => { + const email = emailTransport(); + const sms = smsTransport(); + const messaging = { email, sms, defaults: { appName: "Acme" } }; + + await deliverAuthMessage(messaging, { + kind: "otp_email", + to: "owner@acme.test", + token: "123456", + purpose: "account_recovery", + }); + await deliverAuthMessage(messaging, { + kind: "otp_sms", + to: "+15555550100", + token: "123456", + purpose: "account_recovery", + }); + + expect(email.send.mock.calls[0][0]).toMatchObject({ + subject: "Acme - Account recovery code", + text: expect.stringContaining("recover your Acme account"), + }); + expect(sms.send.mock.calls[0][0].body).toContain("recover your Acme account"); + }); + + it("passes the purpose to a handler", async () => { + const sendOtpEmail = jest.fn(async () => ({})); + + await deliverAuthMessage( + { handlers: { sendOtpEmail } }, + { kind: "otp_email", to: "owner@acme.test", token: "1", purpose: "account_recovery" }, + ); + + expect(sendOtpEmail).toHaveBeenCalledWith( + expect.objectContaining({ purpose: "account_recovery" }), + ); + }); + + it("sends an account notice as the API wrote it, in plain text", async () => { + const email = emailTransport(); + const sms = smsTransport(); + + await deliverAuthMessage({ email, defaults: { emailFrom: "no-reply@acme.test" } }, emailNotice); + await deliverAuthMessage( + { sms }, + { kind: "account_notice_sms", notice: "recovery_code_used", to: "+15555550100", text: "Used." }, + ); + + expect(email.send.mock.calls[0][0]).toEqual({ + to: "owner@acme.test", + from: "no-reply@acme.test", + subject: "Account recovery requested", + text: emailNotice.text, + }); + expect(sms.send.mock.calls[0][0]).toEqual({ to: "+15555550100", body: "Used." }); + }); + + it("prefers a notice handler, and lets an override reshape the message", async () => { + const sendAccountNoticeEmail = jest.fn(async () => ({})); + const email = emailTransport(); + + await deliverAuthMessage({ email, handlers: { sendAccountNoticeEmail } }, emailNotice); + await deliverAuthMessage( + { + email, + overrides: { + accountNoticeEmail: (input, defaults) => ({ ...defaults, subject: input.notice }), + }, + }, + emailNotice, + ); + + expect(sendAccountNoticeEmail).toHaveBeenCalledWith( + expect.objectContaining({ notice: "account_recovery_opened", cancelUrl: emailNotice.cancelUrl }), + ); + expect(email.send).toHaveBeenCalledTimes(1); + expect(email.send.mock.calls[0][0].subject).toBe("account_recovery_opened"); + }); + + it("throws when a notice has no way to go out", async () => { + await expect(deliverAuthMessage({ sms: smsTransport() }, emailNotice)).rejects.toThrow( + "Missing email transport", + ); + await expect( + deliverAuthMessage({ email: emailTransport() }, { ...emailNotice, kind: "account_notice_sms" }), + ).rejects.toThrow("Missing SMS transport"); + }); + + it("lists only the notice kinds the configuration can send", () => { + expect(deliverableNoticeKinds(undefined)).toEqual([]); + expect(deliverableNoticeKinds({})).toEqual([]); + expect(deliverableNoticeKinds({ email: emailTransport() })).toEqual(["account_notice_email"]); + expect( + deliverableNoticeKinds({ handlers: { sendAccountNoticeSms: async () => ({}) } }), + ).toEqual(["account_notice_sms"]); + expect(deliverableNoticeKinds({ email: emailTransport(), sms: smsTransport() })).toEqual([ + "account_notice_email", + "account_notice_sms", + ]); + }); +}); + describe("applyExternalDelivery", () => { it("delivers the payload and strips it from the body", async () => { const email = emailTransport(); diff --git a/packages/core/tests/manifestProxy.test.js b/packages/core/tests/manifestProxy.test.js index 4605aef..a47d958 100644 --- a/packages/core/tests/manifestProxy.test.js +++ b/packages/core/tests/manifestProxy.test.js @@ -3,6 +3,8 @@ import { jest } from "@jest/globals"; const authFetchMock = jest.fn(); const verifySignedAuthResponseMock = jest.fn(); const applyExternalDeliveryMock = jest.fn(); +const deliverAuthMessageMock = jest.fn(); +const deliverableNoticeKindsMock = jest.fn(); jest.unstable_mockModule("../dist/authFetch.js", () => ({ authFetch: authFetchMock, @@ -12,6 +14,8 @@ jest.unstable_mockModule("../dist/verifySignedAuthResponse.js", () => ({ })); jest.unstable_mockModule("../dist/deliverAuthMessage.js", () => ({ applyExternalDelivery: applyExternalDeliveryMock, + deliverAuthMessage: deliverAuthMessageMock, + deliverableNoticeKinds: deliverableNoticeKindsMock, })); const { handleManifestRoute } = await import("../dist/manifestProxy.js"); @@ -67,6 +71,9 @@ beforeEach(() => { verifySignedAuthResponseMock.mockReset(); verifySignedAuthResponseMock.mockResolvedValue({ sub: "user-1", sid: "s-1" }); applyExternalDeliveryMock.mockReset(); + deliverAuthMessageMock.mockReset(); + deliverableNoticeKindsMock.mockReset(); + deliverableNoticeKindsMock.mockReturnValue([]); }); describe("handleManifestRoute", () => { @@ -380,4 +387,116 @@ describe("handleManifestRoute", () => { expect(applyExternalDeliveryMock).not.toHaveBeenCalled(); }); }); + + describe("notice routes", () => { + const route = { + method: "POST", + path: "/recovery/verify", + credential: "preAuth", + notices: true, + }; + const preAuth = { + cookiePayload: { sub: "user-1", token: "pre-auth" }, + cookies: { "seamless-ephemeral": "signed" }, + }; + const messaging = { email: { send: jest.fn() }, sms: { send: jest.fn() } }; + const emailNotice = { + kind: "account_notice_email", + notice: "account_recovery_opened", + to: "owner@example.com", + subject: "Recovery requested", + text: "Cancel it: https://app.test/recovery/cancel#cancel-token", + cancelUrl: "https://app.test/recovery/cancel#cancel-token", + }; + const smsNotice = { + kind: "account_notice_sms", + notice: "account_recovery_opened", + to: "+15555550100", + text: "Cancel it: https://app.test/recovery/cancel#cancel-token", + cancelUrl: "https://app.test/recovery/cancel#cancel-token", + }; + const status = { state: "pending", requestedAt: "t0", completesAt: "t1" }; + + it("names the kinds it can send, delivers every notice and strips them", async () => { + deliverableNoticeKindsMock.mockReturnValue([ + "account_notice_email", + "account_notice_sms", + ]); + authFetchMock.mockResolvedValue( + upstream(200, { ...status, deliveries: [emailNotice, smsNotice] }), + ); + + const result = await handleManifestRoute(input(route, preAuth), { + ...OPTIONS, + messaging, + }); + + expect(authFetchMock.mock.calls[0][1]).toMatchObject({ + serviceAuthorization: "Bearer delivery-token", + headers: { + "x-seamless-auth-delivery-mode": "external", + "x-seamless-auth-delivery-kinds": "account_notice_email,account_notice_sms", + }, + }); + expect(deliverAuthMessageMock).toHaveBeenCalledWith(messaging, emailNotice); + expect(deliverAuthMessageMock).toHaveBeenCalledWith(messaging, smsNotice); + expect(result).toEqual({ status: 200, body: status }); + }); + + it("leaves delivery to the auth API when it can send no notice kind", async () => { + authFetchMock.mockResolvedValue(upstream(200, status)); + + const result = await handleManifestRoute(input(route, preAuth), OPTIONS); + + expect(authFetchMock.mock.calls[0][1]).toMatchObject({ + serviceAuthorization: "Bearer proxy-token", + }); + expect(authFetchMock.mock.calls[0][1]).not.toHaveProperty("headers"); + expect(result.body).toEqual(status); + }); + + it("keeps the request open when one notice still goes out", async () => { + deliverableNoticeKindsMock.mockReturnValue([ + "account_notice_email", + "account_notice_sms", + ]); + deliverAuthMessageMock + .mockRejectedValueOnce(new Error("smtp down")) + .mockResolvedValueOnce(undefined); + authFetchMock.mockResolvedValue( + upstream(200, { ...status, deliveries: [emailNotice, smsNotice] }), + ); + + const result = await handleManifestRoute(input(route, preAuth), { + ...OPTIONS, + messaging, + }); + + expect(result).toEqual({ status: 200, body: status }); + expect(authFetchMock).toHaveBeenCalledTimes(1); + }); + + it("cancels the request and refuses when no notice goes out", async () => { + deliverableNoticeKindsMock.mockReturnValue(["account_notice_email"]); + deliverAuthMessageMock.mockRejectedValue(new Error("smtp down")); + authFetchMock + .mockResolvedValueOnce(upstream(200, { ...status, deliveries: [emailNotice] })) + .mockResolvedValueOnce(upstream(200, { message: "cancelled" })); + + const result = await handleManifestRoute(input(route, preAuth), { + ...OPTIONS, + messaging, + }); + + expect(result).toEqual({ status: 503, errorCode: "recovery_notice_failed" }); + expect(authFetchMock.mock.calls[1]).toEqual([ + "https://auth.test/recovery/cancel", + { + method: "POST", + serviceAuthorization: "Bearer proxy-token", + body: { cancelToken: "cancel-token" }, + }, + ]); + }); + }); }); diff --git a/packages/core/tests/recoverySession.test.js b/packages/core/tests/recoverySession.test.js new file mode 100644 index 0000000..1b196c1 --- /dev/null +++ b/packages/core/tests/recoverySession.test.js @@ -0,0 +1,93 @@ +import { jest } from "@jest/globals"; +import { exportJWK, generateKeyPair, SignJWT } from "jose"; +import jwt from "jsonwebtoken"; + +const { authenticateCookie, authenticateRequest } = await import("../dist/guards.js"); + +const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; +const SERVER = "https://recovery-guard.example.com"; + +const inner = (claims) => + jwt.sign({ sub: "user-1", typ: "access", ...claims }, "upstream-signing-key"); +const cookie = (token) => jwt.sign({ sub: "user-1", token }, COOKIE_SECRET, { expiresIn: "300s" }); + +const { privateKey, publicKey } = await generateKeyPair("RS256"); +const jwk = { ...(await exportJWK(publicKey)), alg: "RS256", kid: "k1", use: "sig" }; + +async function bearerToken(claims) { + return new SignJWT({ sub: "user-1", typ: "access", sid: "s-1", ...claims }) + .setProtectedHeader({ alg: "RS256", kid: "k1" }) + .setIssuer(SERVER) + .setAudience(SERVER) + .setIssuedAt() + .setExpirationTime("5m") + .sign(privateKey); +} + +const originalFetch = global.fetch; + +beforeEach(() => { + global.fetch = jest.fn(async () => + new Response(JSON.stringify({ keys: [jwk] }), { + status: 200, + headers: { "Content-Type": "application/json" }, + }), + ); +}); + +afterEach(() => { + global.fetch = originalFetch; +}); + +// A recovery session can only enroll a new factor, so an app must not treat it as +// signed in unless the route asks for it. +describe("recovery sessions", () => { + it("refuses a recovery cookie with enrollment_required", () => { + const result = authenticateCookie({ + token: cookie(inner({ recovery: true })), + cookieSecret: COOKIE_SECRET, + }); + + expect(result.user).toBeUndefined(); + expect(result.rejection).toEqual({ status: 403, errorCode: "enrollment_required" }); + }); + + it("accepts a recovery cookie when the route allows it", () => { + const { user } = authenticateCookie({ + token: cookie(inner({ recovery: true })), + cookieSecret: COOKIE_SECRET, + allowRecoverySession: true, + }); + + expect(user).toMatchObject({ id: "user-1" }); + }); + + it("is unaffected by a session without the claim", () => { + const { user } = authenticateCookie({ + token: cookie(inner({ recovery: false })), + cookieSecret: COOKIE_SECRET, + }); + + expect(user).toMatchObject({ id: "user-1" }); + }); + + it("refuses a recovery bearer token, and accepts it when allowed", async () => { + const authorization = `Bearer ${await bearerToken({ recovery: true })}`; + const bearer = { authServerUrl: SERVER, audience: SERVER }; + + const refused = await authenticateRequest({ + cookieSecret: COOKIE_SECRET, + authorization, + bearer, + }); + const allowed = await authenticateRequest({ + cookieSecret: COOKIE_SECRET, + authorization, + bearer, + allowRecoverySession: true, + }); + + expect(refused.rejection).toEqual({ status: 403, errorCode: "enrollment_required" }); + expect(allowed.user).toMatchObject({ id: "user-1" }); + }); +}); diff --git a/packages/express/README.md b/packages/express/README.md index 6abfa70..e378391 100644 --- a/packages/express/README.md +++ b/packages/express/README.md @@ -379,8 +379,21 @@ When `messaging` is provided, `@seamless-auth/express` requests external-deliver This currently applies to: - OTP email -- OTP SMS +- OTP SMS (both carry `purpose: "account_recovery"` for an account recovery code, and the + default wording says so) - magic-link email +- account notices (`account_notice_email`, `account_notice_sms`) on routes the API's manifest + marks `notices: true`, today `POST /recovery/verify` + +Account notices tell the owner about something that happened to the account, such as account +recovery being requested, and can carry a `cancelUrl`. That link holds a credential, so it goes +only to the notice's recipient and is never logged. The adapter lists the notice kinds it can +send in `x-seamless-auth-delivery-kinds` (email when `email` or `handlers.sendAccountNoticeEmail` +is set, SMS likewise), and the auth API sends the rest itself. The API counts a returned notice +as the owner having been told, so if none of them goes out the adapter cancels the recovery +request with the link's token and answers `503 recovery_notice_failed`, as the API would. +Customize them with `handlers.sendAccountNoticeEmail` / `sendAccountNoticeSms` or +`overrides.accountNoticeEmail` / `accountNoticeSms`; the default sends the API's text as is. External delivery is a credentialed request. The adapter mints a short-lived service token from `serviceSecret`, and the auth server withholds the delivery payload if that token does not validate. `serviceSecret` must therefore match the auth server's `API_SERVICE_TOKEN` in every environment, including local development. If it does not, auth-message routes still return a success response but no message is delivered, so the adapter logs a warning whenever it requests external delivery and receives no payload back. @@ -600,9 +613,17 @@ app.get("/api/profile", guard, (req, res) => { authServerUrl?: string; // with audience, enables bearer access tokens audience?: string; // expected `aud` on a bearer token: the auth server's ISSUER authServerIssuer?: string; // expected `iss` on a bearer token (defaults to authServerUrl) + allowRecoverySession?: boolean; // accept a recovery session (default false) } ``` +A recovery session comes from account recovery or a recovery code, and its access token carries +`recovery: true`. It can only enroll a new passkey or authenticator app, so the guard refuses it +with `403 { "error": "enrollment_required" }` unless `allowRecoverySession` is set. Set it only on +routes that drive that enrollment. Once a factor is enrolled the auth API lifts the restriction, +and the next `POST /auth/refresh` issues an access token without the claim; the React SDK +refreshes after enrollment for you. + With `authServerIssuer` set, pass the same value as `audience`: the auth API sets both `iss` and `aud` to its `ISSUER`. diff --git a/packages/express/src/middleware/requireAuth.ts b/packages/express/src/middleware/requireAuth.ts index 859a324..eababdf 100644 --- a/packages/express/src/middleware/requireAuth.ts +++ b/packages/express/src/middleware/requireAuth.ts @@ -25,6 +25,12 @@ export interface RequireAuthOptions { * `audience`. */ authServerIssuer?: string; + /** + * Accept a recovery session (`recovery: true` on the access token), which can only + * enroll a new passkey or authenticator app. Off by default, so such a session gets + * 403 `enrollment_required`; turn it on only for routes that drive that enrollment. + */ + allowRecoverySession?: boolean; } /** @@ -66,6 +72,7 @@ export function requireAuth(opts: RequireAuthOptions) { authServerUrl, audience, authServerIssuer, + allowRecoverySession, } = opts; // Eagerly, so a weak secret fails at setup rather than on the first request. @@ -98,6 +105,7 @@ export function requireAuth(opts: RequireAuthOptions) { cookieSecret, authorization: req.headers.authorization, bearer, + allowRecoverySession, }); if (rejection) { diff --git a/packages/express/tests/requireAuth.test.js b/packages/express/tests/requireAuth.test.js index 52810d1..4d7255c 100644 --- a/packages/express/tests/requireAuth.test.js +++ b/packages/express/tests/requireAuth.test.js @@ -85,3 +85,35 @@ describe("requireAuth (smoke)", () => { }); }); +describe("requireAuth and recovery sessions", () => { + const secret = "cookie-secret-cookie-secret-cookie-secret"; + const recovery = jwt.sign( + { sub: "user-1", typ: "access", recovery: true }, + "upstream-signing-key", + ); + const token = jwt.sign({ sub: "user-1", token: recovery }, secret, { expiresIn: "1h" }); + + function app(options) { + const server = express(); + server.use(cookieParser()); + server.use(requireAuth({ cookieName: "access", cookieSecret: secret, ...options })); + server.get("/protected", (req, res) => res.json({ user: req.user })); + return server; + } + + it("refuses a recovery session with enrollment_required by default", async () => { + const res = await request(app()).get("/protected").set("Cookie", [`access=${token}`]); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: "enrollment_required" }); + }); + + it("accepts it on a route that allows recovery sessions", async () => { + const res = await request(app({ allowRecoverySession: true })) + .get("/protected") + .set("Cookie", [`access=${token}`]); + + expect(res.status).toBe(200); + expect(res.body.user.id).toBe("user-1"); + }); +}); diff --git a/packages/fastify/README.md b/packages/fastify/README.md index ad52766..21149a4 100644 --- a/packages/fastify/README.md +++ b/packages/fastify/README.md @@ -89,6 +89,13 @@ routes, and a bearer client refreshes through `POST /auth/refresh` itself. Role checks understand scoped names, so `admin` grants everything under it and `admin:write` grants `admin:read`. +A recovery session, from account recovery or a recovery code, carries `recovery: +true` on its access token and can only enroll a new passkey or authenticator app. +`requireAuth` refuses it with `403 { "error": "enrollment_required" }` unless +`allowRecoverySession: true` is set, so set that only on the routes that drive the +enrollment. Once a factor is enrolled, the next `POST /auth/refresh` issues an +access token without the claim; the React SDK refreshes after enrollment for you. + For the hydrated profile rather than the token payload, `getSeamlessUser(request, options)` fetches it from the auth API and returns `SeamlessUser | null`. It resolves a cookie session or a bearer access token, and returns `null` without calling the auth API when @@ -125,6 +132,16 @@ await app.register(seamlessAuth, { Delivery payloads carry one-time codes and links. They are stripped from the response before it reaches the browser. +The same transports also send account notices (`account_notice_email`, +`account_notice_sms`) on routes the API's manifest marks `notices: true`, today +`POST /recovery/verify`: the notice that account recovery was requested, with a +cancel link that goes only to the account owner and is never logged. A recovery +code arrives with `purpose: "account_recovery"` and is worded as one. Customize +notices with `handlers.sendAccountNoticeEmail` / `sendAccountNoticeSms` or +`overrides.accountNoticeEmail` / `accountNoticeSms`. If no notice can be sent, the +adapter cancels the recovery request and answers `503 recovery_notice_failed`, +as the auth API does, so recovery never runs without the owner being told. + ## Serving the admin console `seamlessConsoleProxy` reverse-proxies the Seamless admin dashboard, so the diff --git a/packages/fastify/src/guards.ts b/packages/fastify/src/guards.ts index bca0a17..1fc7e4b 100644 --- a/packages/fastify/src/guards.ts +++ b/packages/fastify/src/guards.ts @@ -31,6 +31,12 @@ export interface RequireAuthOptions { * `audience`. */ authServerIssuer?: string; + /** + * Accept a recovery session (`recovery: true` on the access token), which can only + * enroll a new passkey or authenticator app. Off by default, so such a session gets + * 403 `enrollment_required`; turn it on only for routes that drive that enrollment. + */ + allowRecoverySession?: boolean; } /** @@ -63,6 +69,7 @@ export function requireAuth(opts: RequireAuthOptions) { authServerUrl, audience, authServerIssuer, + allowRecoverySession, } = opts; // Eagerly, so a weak secret fails at setup rather than on the first request. @@ -98,6 +105,7 @@ export function requireAuth(opts: RequireAuthOptions) { cookieSecret, authorization: req.headers.authorization, bearer, + allowRecoverySession, }); if (rejection) { diff --git a/packages/fastify/tests/guards.test.js b/packages/fastify/tests/guards.test.js index 95be9bf..5aeedcb 100644 --- a/packages/fastify/tests/guards.test.js +++ b/packages/fastify/tests/guards.test.js @@ -497,3 +497,28 @@ describe("routes outside the plugin, with no cookie plugin of their own", () => expect(meCalls[0].headers.Authorization).toBe(`Bearer ${INNER}`); }); }); + +describe("requireAuth (fastify) and recovery sessions", () => { + const recovery = jwt.sign( + { sub: "user-1", typ: "access", recovery: true }, + "upstream-signing-key", + ); + const header = { cookie: `seamless-access=${signedCookie({ sub: "user-1", token: recovery })}` }; + + it("refuses a recovery session with enrollment_required by default", async () => { + const res = await get(await buildApp({ cookieSecret: COOKIE_SECRET }), header); + + expect(res.status).toBe(403); + expect(res.body).toEqual({ error: "enrollment_required" }); + }); + + it("accepts it when the guard allows recovery sessions", async () => { + const res = await get( + await buildApp({ cookieSecret: COOKIE_SECRET, allowRecoverySession: true }), + header, + ); + + expect(res.status).toBe(200); + expect(res.body.user).toMatchObject({ id: "user-1" }); + }); +}); diff --git a/packages/nextjs/README.md b/packages/nextjs/README.md index a3294ea..b323779 100644 --- a/packages/nextjs/README.md +++ b/packages/nextjs/README.md @@ -96,6 +96,14 @@ session through the auth routes as usual. For the same reason, do not call your own `/auth/users/me` from the server with the browser's cookies forwarded. +A recovery session, from account recovery or a recovery code, carries `recovery: +true` and can only enroll a new passkey or authenticator app. `getSeamlessSession` +and `getSeamlessClaims` treat it as signed out unless you pass +`allowRecoverySession: true`, so do that only on the pages that drive the +enrollment. `hasSeamlessSession` still reports it, since the session can use the +auth routes to enroll. Once a factor is enrolled, the next refresh drops the claim; +the React SDK refreshes after enrollment for you. + ## Protect routes in `proxy.ts` ```ts diff --git a/packages/nextjs/package.json b/packages/nextjs/package.json index 19b013c..def5fcc 100644 --- a/packages/nextjs/package.json +++ b/packages/nextjs/package.json @@ -53,7 +53,7 @@ }, "dependencies": { "@seamless-auth/core": "workspace:^", - "@seamless-auth/types": "^0.29.0", + "@seamless-auth/types": "^0.31.0", "cookie": "^1.1.1" }, "devDependencies": { diff --git a/packages/nextjs/src/session.ts b/packages/nextjs/src/session.ts index f789bfa..bcbeea7 100644 --- a/packages/nextjs/src/session.ts +++ b/packages/nextjs/src/session.ts @@ -33,6 +33,12 @@ export interface SeamlessSessionOptions { refreshCookieName?: string; /** The browser's user agent, for the auth API to record instead of this server's. */ userAgent?: string; + /** + * Accept a recovery session (`recovery: true` on the access token), which can only + * enroll a new passkey or authenticator app. Off by default, so the helpers treat it + * as signed out; turn it on only where the page drives that enrollment. + */ + allowRecoverySession?: boolean; } /** @@ -61,11 +67,15 @@ function accessCookie( */ export function getSeamlessClaims( cookies: CookieReader, - opts: Pick, + opts: Pick< + SeamlessSessionOptions, + "cookieSecret" | "accessCookieName" | "allowRecoverySession" + >, ): SeamlessClaims | null { const { user } = authenticateCookie({ token: accessCookie(cookies, opts), cookieSecret: opts.cookieSecret, + allowRecoverySession: opts.allowRecoverySession, }); if (!user) { @@ -117,6 +127,7 @@ export async function getSeamlessSession( const { user } = authenticateCookie({ token: accessCookie(cookies, opts), cookieSecret: opts.cookieSecret, + allowRecoverySession: opts.allowRecoverySession, }); if (typeof user?.token !== "string") { diff --git a/packages/nextjs/tests/session.test.js b/packages/nextjs/tests/session.test.js index ca56361..448664d 100644 --- a/packages/nextjs/tests/session.test.js +++ b/packages/nextjs/tests/session.test.js @@ -192,6 +192,19 @@ describe("session helpers", () => { }); }); + it("getSeamlessClaims treats a recovery session as signed out unless allowed", () => { + const recovery = jwt.sign( + { sub: "user-123", typ: "access", recovery: true }, + "upstream-signing-key", + ); + const cookies = jar({ "seamless-access": signed({ ...ACCESS, token: recovery }) }); + + expect(getSeamlessClaims(cookies, OPTIONS)).toBeNull(); + expect( + getSeamlessClaims(cookies, { ...OPTIONS, allowRecoverySession: true }), + ).toMatchObject({ id: "user-123" }); + }); + it("getSeamlessClaims refuses a pre-auth cookie presented as the session", () => { const ephemeral = jwt.sign({ sub: "user-123", typ: "ephemeral" }, "upstream-signing-key"); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 21c7ea2..aa8a984 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -24,8 +24,8 @@ importers: packages/core: dependencies: '@seamless-auth/types': - specifier: ^0.29.0 - version: 0.29.0 + specifier: ^0.31.0 + version: 0.31.0 '@types/jsonwebtoken': specifier: ^9.0.10 version: 9.0.10 @@ -144,8 +144,8 @@ importers: specifier: workspace:^ version: link:../core '@seamless-auth/types': - specifier: ^0.29.0 - version: 0.29.0 + specifier: ^0.31.0 + version: 0.31.0 cookie: specifier: ^1.1.1 version: 1.1.1 @@ -882,8 +882,8 @@ packages: cpu: [x64] os: [win32] - '@seamless-auth/types@0.29.0': - resolution: {integrity: sha512-Moiuw17RNZaORrrb5Wypjvl1iBj5K1PqDQkkK8ACYh9NSHD7khMyHaTsx9AzK6zOqROXgRRrIAurk9S1w2CZhA==} + '@seamless-auth/types@0.31.0': + resolution: {integrity: sha512-3XcwiseZWXaus7QxDMkgHvHPRF+4qwYVl78KGLVTf+ktAN9cKhBp2zbW6jJ1/aEN7Zd08Gy+4C/CRS57MYb2TQ==} engines: {node: '>=22'} '@sinclair/typebox@0.27.10': @@ -3433,7 +3433,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.60.4': optional: true - '@seamless-auth/types@0.29.0': + '@seamless-auth/types@0.31.0': dependencies: zod: 4.6.5