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
13 changes: 13 additions & 0 deletions .changeset/v020-recovery-sessions-and-notices.md
Original file line number Diff line number Diff line change
@@ -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.
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.29.0",
"@seamless-auth/types": "^0.31.0",
"@types/jsonwebtoken": "^9.0.10",
"jose": "^6.1.3",
"jsonwebtoken": "^9.0.2"
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/apiContract.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, string>> =
Expand Down
53 changes: 49 additions & 4 deletions packages/core/src/authMessaging.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@
* which carry provider implementations, and the adopter-facing configuration.
*/
export type {
AccountNoticeName,
AuthDeliveryInstruction,
DeliveryPurpose,
DeliveryResult,
EmailMessage,
MessagingChannel,
Expand All @@ -22,6 +24,8 @@ export type {
} from "@seamless-auth/types";

import type {
AccountNoticeName,
DeliveryPurpose,
DeliveryResult,
EmailMessage,
SendMagicLinkEmailInput,
Expand All @@ -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<DeliveryResult>;
Expand All @@ -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;
Expand All @@ -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<DeliveryResult>;
sendOtpSms(input: SendOtpSmsInput): Promise<DeliveryResult>;
sendOtpEmail(input: SendOtpEmailInput & OtpPurpose): Promise<DeliveryResult>;
sendOtpSms(input: SendOtpSmsInput & OtpPurpose): Promise<DeliveryResult>;
sendMagicLinkEmail(input: SendMagicLinkEmailInput): Promise<DeliveryResult>;
sendEnrollmentInviteEmail?(
input: SendEnrollmentInviteEmailInput,
): Promise<DeliveryResult>;
sendAccountNoticeEmail?(
input: SendAccountNoticeEmailInput,
): Promise<DeliveryResult>;
sendAccountNoticeSms?(input: SendAccountNoticeSmsInput): Promise<DeliveryResult>;
}

export interface SeamlessAuthMessagingOptions {
Expand Down
126 changes: 117 additions & 9 deletions packages/core/src/deliverAuthMessage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,22 +41,35 @@ 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,
{
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: `<div><h1>Verify your account with ${appName}</h1><p>Please use the verification code below:</p><p><strong>${input.token}</strong></p><p>If you did not request this code, you can safely ignore this message.</p></div>`,
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: `<div><h1>Account recovery for ${appName}</h1><p>Someone asked to recover your account. Your account recovery code is:</p><p><strong>${input.token}</strong></p><p>If this was not you, do not share this code with anyone. Recovery signs out every session on the account.</p></div>`,
}
: {
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: `<div><h1>Verify your account with ${appName}</h1><p>Please use the verification code below:</p><p><strong>${input.token}</strong></p><p>If you did not request this code, you can safely ignore this message.</p></div>`,
},
appName,
);
}
Expand All @@ -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,
);
Expand Down Expand Up @@ -134,6 +151,59 @@ function buildEnrollmentInviteMessage(
);
}

function buildAccountNoticeEmail(
input: Extract<AuthDeliveryInstruction, { kind: "account_notice_email" }>,
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<AuthDeliveryInstruction, { kind: "account_notice_sms" }>,
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,
Expand All @@ -149,6 +219,7 @@ export async function deliverAuthMessage(
to: delivery.to,
token: delivery.token,
from: messaging.defaults?.emailFrom,
...(delivery.purpose ? { purpose: delivery.purpose } : {}),
});
return;
}
Expand All @@ -166,6 +237,7 @@ export async function deliverAuthMessage(
to: delivery.to,
token: delivery.token,
from: messaging.defaults?.smsFrom,
...(delivery.purpose ? { purpose: delivery.purpose } : {}),
});
return;
}
Expand Down Expand Up @@ -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.
Expand Down
33 changes: 33 additions & 0 deletions packages/core/src/guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -232,6 +260,10 @@ export async function authenticateBearer(
};
}

if (!input.allowRecoverySession && claims.recovery === true) {
return { rejection: RECOVERY_SESSION_REJECTION };
}

return {
user: {
id: claims.sub,
Expand Down Expand Up @@ -284,6 +316,7 @@ export async function authenticateRequest(

return authenticateBearer({
authorization: input.authorization,
allowRecoverySession: input.allowRecoverySession,
...input.bearer,
});
}
Expand Down
5 changes: 4 additions & 1 deletion packages/core/src/manifest/adapterManifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
);
}

Expand Down
Loading
Loading