fix(auth): link single sign-on to an existing account only after its own sign-in

The SSO callback bound a verified provider email to the local account with
the same address on the spot. The local address is not verified: anyone
could register with it, or type it into their profile, and the SSO user
then landed in an account the other person still held the password to.

The callback now links nothing. It records the link it would make in a
sealed, ten-minute, HttpOnly cookie and sends the browser to the sign-in
page, and the link is made only when that browser then signs in to that
same account with its password (and second factor) or passkey. Under an
SSO-only policy the password form opens for exactly that account. The
native sign-in answers oidc_link_required; the link is confirmed once on
the web.

Moving an account to a new email address now also needs a fresh proof: a
sign-in or second factor in the last five minutes, or the current password
sent alongside the change. Without it the address is left as it was and
reported in rejectedFields; the rest of the save still lands.
This commit is contained in:
Marc-André Bombeck committed 2026-09-26 16:10:32 +02:00
1 parent 7b5bc130d8
commit 8da7583ec6
27 files changed
+878 -76

No files matched your search

+14 -1
View File
@@ -19,12 +19,19 @@ import { createMfaChallenge } from "@/lib/auth/mfa/challenge";
import { consumeTrustedDevice } from "@/lib/auth/trusted-device";
import { syncMfaEnrollCookie } from "@/lib/auth/mfa-enrollment";
import { isOidcOnly } from "@/lib/auth/oidc";
import { readPendingLink } from "@/lib/auth/oidc-pending-link";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY means password login must be a dead end, not just a hidden
// button — otherwise a pre-existing password (or a leaked/reused one)
// stays a live bypass of the operator's SSO-only policy.
if (isOidcOnly()) {
//
// One exception, and only for one account: a browser the SSO callback sent
// here to confirm an existing account carries a sealed pending link naming
// that account. It may prove that account's password (checked again once
// the account is resolved) so the link can be made; nothing else opens.
const pendingLink = isOidcOnly() ? await readPendingLink() : null;
if (isOidcOnly() && !pendingLink) {
return apiError("Password login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
@@ -119,6 +126,12 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiError("Invalid credentials", 401);
}
if (pendingLink && pendingLink.userId !== user.id) {
return apiError("Password login is disabled. Sign in with SSO.", 403, {
errorCode: "oidc_only",
});
}
const ua = request.headers.get("user-agent");
await auditLog("auth.login.password", {
@@ -16,6 +16,7 @@ vi.mock("@/lib/rate-limit", () => ({
vi.mock("@/lib/crypto", () => ({
decrypt: vi.fn((s: string) => s.replace(/^enc\(/, "").replace(/\)$/, "")),
encrypt: vi.fn((s: string) => `enc(${s})`),
}));
vi.mock("@/lib/db", () => ({
@@ -321,26 +322,34 @@ describe("GET /api/auth/oidc/callback", () => {
expect(res.status).toBe(307);
});
it("links an existing email-matched account ONCE, stamping (issuer, sub)", async () => {
// Inverted in v1.39.3. This used to pin the auto-link: an existing account
// with the same (unverified) local email was bound on the spot and signed
// in. Whoever set that address first owned the account the SSO user landed
// in, so the callback now links nothing and signs nobody in; it leaves a
// sealed pending link and sends the browser to confirm the account with its
// own credential (`oidc-pending-link.test.ts` covers the completion).
it("does not link an email-matched account on the email alone", async () => {
mockIdentity();
mockUserLookups({ byIdentity: null, byEmail: userRow() });
vi.mocked(prisma.user.update).mockResolvedValue(
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
);
const res = await GET(validRequest("/dashboard"));
expect(prisma.user.create).not.toHaveBeenCalled();
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: "user-1" },
data: { oidcIssuer: METADATA.issuer, oidcSub: "sub-1" },
});
expect(auditLog).toHaveBeenCalledWith(
expect(prisma.user.update).not.toHaveBeenCalled();
expect(createSession).not.toHaveBeenCalled();
expect(auditLog).not.toHaveBeenCalledWith(
"auth.oidc.linked",
expect.anything(),
);
expect(auditLog).toHaveBeenCalledWith(
"auth.oidc.link_pending",
expect.objectContaining({ userId: "user-1" }),
);
expect(createSession).toHaveBeenCalled();
expect(res.headers.get("location")).toContain("/dashboard");
expect(res.headers.get("location")).toContain("error=oidc_link_required");
const setCookie = res.headers.get("set-cookie") ?? "";
expect(setCookie).toContain("hl_oidc_link=");
expect(setCookie.toLowerCase()).toContain("httponly");
expect(setCookie).toContain("Path=/api/auth");
});
it("rejects a login whose email matches an account bound to a DIFFERENT identity", async () => {
@@ -505,10 +514,9 @@ describe("GET /api/auth/oidc/callback", () => {
it("never stamps the session step-up fresh — the 5th createSession arg is null", async () => {
mockIdentity();
mockUserLookups({ byIdentity: null, byEmail: userRow() });
vi.mocked(prisma.user.update).mockResolvedValue(
userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }) as never,
);
mockUserLookups({
byIdentity: userRow({ oidcIssuer: METADATA.issuer, oidcSub: "sub-1" }),
});
await GET(validRequest());
+22 -6
View File
@@ -37,6 +37,7 @@ import {
buildNativeCallbackUrl,
mintNativeHandoff,
} from "@/lib/auth/oidc-native-handoff";
import { setPendingLinkCookie } from "@/lib/auth/oidc-pending-link";
const LOGIN_ERROR_URL = "/auth/login";
@@ -340,14 +341,29 @@ export const GET = apiHandler(async (req: NextRequest) => {
annotate({ meta: { reason: "identity_conflict" } });
return failRedirect("oidc_identity_conflict");
}
user = await prisma.user.update({
where: { id: byEmail.id },
data: { oidcIssuer: metadata.issuer, oidcSub: identity.sub },
});
await auditLog("auth.oidc.linked", {
userId: user.id,
// (2b) Never link on the email alone. The local address was never
// verified — anyone can register with it or type it into a profile —
// so matching it proves nothing about who owns the account. Record
// the link this login would make and send the browser to sign in to
// the account with its own credential; the link is made there
// (`completePendingOidcLink`), and only for this account.
await auditLog("auth.oidc.link_pending", {
userId: byEmail.id,
ipAddress: ip,
});
annotate({ meta: { reason: "link_confirmation_required" } });
if (isNative) {
// The native sign-in sheet cannot run the confirmation; the person
// signs in on the web once, after which the app's SSO works.
return failRedirect("oidc_link_required");
}
const response = errorRedirect("oidc_link_required");
setPendingLinkCookie(response, {
userId: byEmail.id,
issuer: metadata.issuer,
sub: identity.sub,
});
return response;
} else {
// (3) Provision.
const userCount = await prisma.user.count();
@@ -18,6 +18,7 @@ import {
} from "@/lib/auth/native-client";
import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { isOidcOnly } from "@/lib/auth/oidc";
import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link";
export const POST = apiHandler(async (request: NextRequest) => {
// OIDC_ONLY must block passkey login too, not just password login — a
@@ -87,6 +88,10 @@ export const POST = apiHandler(async (request: NextRequest) => {
// meets an MFA-enforcement policy AND passes `requireFreshMfa` step-up,
// exactly like a completed password+TOTP login. (The password+TOTP path is
// unchanged — it stamps the session at `/api/auth/mfa/verify`.)
// The passkey proved this account's own credential, so a single sign-on
// identity this browser was sent here to confirm may now be linked to it.
await completePendingOidcLink(user.id, ip ?? null);
const ua = request.headers.get("user-agent");
await createSession(
user.id,
+16 -2
View File
@@ -9,16 +9,30 @@ import { NextRequest } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { authorizeSensitiveChange } from "@/lib/auth/existing-factor-proof";
export const PUT = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const auth = await requireAuth();
const { user } = auth;
const { data: body, error: jsonError } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (jsonError) return jsonError;
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
const ip = getClientIp(request);
const result = await applyProfileUpdate(user.id, body, ip, {
// A new email address needs a fresh proof: the address is what single
// sign-on links an existing account by.
authorizeEmailChange: (currentPassword) =>
authorizeSensitiveChange({
user,
cookieSessionId: auth.authMethod === "cookie" ? auth.session.id : null,
currentPassword,
ipAddress: ip,
stage: "email_change",
}),
});
if (!result.ok) {
const meta = result.errorCode ? { errorCode: result.errorCode } : undefined;
return result.issues
+16 -2
View File
@@ -17,6 +17,7 @@ import {
} from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
import { applyProfileUpdate } from "@/lib/auth/profile-update";
import { authorizeSensitiveChange } from "@/lib/auth/existing-factor-proof";
import { prisma } from "@/lib/db";
import { decrypt } from "@/lib/crypto";
import { resolveModuleMap } from "@/lib/modules/gate";
@@ -97,14 +98,27 @@ export const GET = apiHandler(async () => {
});
export const PATCH = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const auth = await requireAuth();
const { user } = auth;
const { data: body, error } = await safeJson(request, {
maxBytes: 64 * 1024,
});
if (error) return error;
const result = await applyProfileUpdate(user.id, body, getClientIp(request));
const ip = getClientIp(request);
const result = await applyProfileUpdate(user.id, body, ip, {
// A new email address needs a fresh proof: the address is what single
// sign-on links an existing account by.
authorizeEmailChange: (currentPassword) =>
authorizeSensitiveChange({
user,
cookieSessionId: auth.authMethod === "cookie" ? auth.session.id : null,
currentPassword,
ipAddress: ip,
stage: "email_change",
}),
});
if (!result.ok) {
const meta = result.errorCode ? { errorCode: result.errorCode } : undefined;
return result.issues
+43 -29
View File
@@ -32,7 +32,14 @@ export default function LoginPage() {
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const { t } = useTranslations();
const [mode, setMode] = useState<"passkey" | "password">("passkey");
// The SSO callback sends a browser here when its email matches an account
// that exists but is not linked yet: the person signs in to that account
// once with its own credential, which links it. Under an SSO-only policy
// this is the one case the password form is shown for.
const linkPending = searchParams.get("error") === "oidc_link_required";
const [mode, setMode] = useState<"passkey" | "password">(
linkPending ? "password" : "passkey",
);
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(() => {
@@ -43,6 +50,7 @@ export default function LoginPage() {
oidc_no_email: "auth.oidc.errorNoEmail",
oidc_email_unverified: "auth.oidc.errorEmailUnverified",
oidc_identity_conflict: "auth.oidc.errorIdentityConflict",
oidc_link_required: "auth.oidc.errorLinkRequired",
oidc_registration_disabled: "auth.oidc.errorRegistrationDisabled",
};
return t(key[oidcError] ?? "auth.oidc.errorFailed");
@@ -394,33 +402,37 @@ export default function LoginPage() {
</div>
)}
{!oidcStatus?.only && (
{(!oidcStatus?.only || linkPending) && (
<>
{/* Phase A5 / B-mobile: bumped from default size (h-9, 36px)
to size="lg" so the login CTAs meet WCAG 2.5.5 (44px
minimum) on mobile. Login is the most-tapped flow on a
fresh install. */}
<Button
onClick={handlePasskeyLogin}
className="min-h-11 w-full"
size="lg"
disabled={loading}
>
{loading && mode === "passkey" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("auth.loginWithPasskey")}
</Button>
{!oidcStatus?.only && (
<>
<Button
onClick={handlePasskeyLogin}
className="min-h-11 w-full"
size="lg"
disabled={loading}
>
{loading && mode === "passkey" ? (
<Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" />
) : (
<KeyRound className="h-4 w-4" />
)}
{t("auth.loginWithPasskey")}
</Button>
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">
{t("common.or")}
</span>
<Separator className="flex-1" />
</div>
<div className="flex items-center gap-3">
<Separator className="flex-1" />
<span className="text-muted-foreground text-xs">
{t("common.or")}
</span>
<Separator className="flex-1" />
</div>
</>
)}
{mode === "passkey" ? (
<Button
@@ -488,13 +500,15 @@ export default function LoginPage() {
)}
{t("auth.login")}
</Button>
<button
type="button"
onClick={() => setMode("passkey")}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{t("auth.backToPasskey")}
</button>
{!oidcStatus?.only && (
<button
type="button"
onClick={() => setMode("passkey")}
className="text-muted-foreground hover:text-foreground inline-flex min-h-11 w-full items-center justify-center text-center text-xs"
>
{t("auth.backToPasskey")}
</button>
)}
</form>
)}
</>
@@ -116,7 +116,11 @@ describe("change-password card placement", () => {
"settings.passwordReset",
"settings.changePassword",
"/api/auth/password",
"current-password",
// A change-password form is the pair current + new. The Account form
// asks for the CURRENT password alone, beside a new email address,
// which is a proof and not a password change; the new-password half is
// what would mean the old form came back.
"new-password",
]) {
expect(account, `Account still carries ${marker}`).not.toContain(marker);
}
@@ -71,6 +71,10 @@ export function AccountSection() {
);
const [email, setEmail] = useState("");
// A new email address needs the current password unless the session signed
// in within the last few minutes; the server decides and names the field if
// it wanted one. Never stored beyond this form.
const [emailPassword, setEmailPassword] = useState("");
const [height, setHeight] = useState<HeightDraft>(EMPTY_HEIGHT_DRAFT);
const [dateOfBirth, setDateOfBirth] = useState("");
const [gender, setGender] = useState<string>("");
@@ -185,6 +189,10 @@ export function AccountSection() {
navigate,
});
const emailChanged =
email.trim() !== "" &&
email.trim().toLowerCase() !== profileSeed.email.trim().toLowerCase();
async function handleSaveProfile(e: React.FormEvent) {
e.preventDefault();
setSaving(true);
@@ -212,6 +220,9 @@ export function AccountSection() {
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email: savedProfile.email || null,
...(emailChanged && emailPassword
? { currentPassword: emailPassword }
: {}),
heightCm: heightAdapter.toCanonicalCm(savedProfile.height),
dateOfBirth: savedProfile.dateOfBirth || null,
gender: savedProfile.gender || null,
@@ -229,6 +240,8 @@ export function AccountSection() {
: Promise.resolve({ ok: true } as Response),
]);
setEmailPassword("");
if (profileRes.ok && tzRes.ok) {
// Instant same-tab flip of every rendered timestamp (issue #490):
// mirror the accepted zone before the `/me` refetch confirms it —
@@ -373,6 +386,24 @@ export function AccountSection() {
aria-describedby={fieldErrors.email ? "email-error" : undefined}
/>
<FieldError id="email-error" message={fieldErrors.email} />
{emailChanged && (
<div className="space-y-2 pt-1">
<Label htmlFor="email-current-password">
{t("settings.emailChangePassword")}
</Label>
<Input
id="email-current-password"
type="password"
value={emailPassword}
onChange={(e) => {
setEmailPassword(e.target.value);
clearFieldError("email");
}}
autoComplete="current-password"
maxLength={512}
/>
</div>
)}
</div>
</div>
@@ -33,7 +33,18 @@ vi.mock("@/lib/rate-limit", () => ({
checkProfileEmailRateLimit: vi.fn(),
}));
import { applyProfileUpdate } from "../profile-update";
import { applyProfileUpdate as applyWithProof } from "../profile-update";
/** These suites are about validation and the email budget, not the proof:
* every call is made as an owner who has just proved themselves. */
const applyProfileUpdate = (
userId: string,
body: unknown,
ipAddress?: string | null,
) =>
applyWithProof(userId, body, ipAddress, {
authorizeEmailChange: async () => "ok",
});
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { checkProfileEmailRateLimit } from "@/lib/rate-limit";
@@ -238,3 +249,72 @@ describe("applyProfileUpdate — the email conflict is metered", () => {
expect(checkProfileEmailRateLimit).toHaveBeenCalledTimes(1);
});
});
describe("applyProfileUpdate — a new address needs a fresh proof", () => {
it("refuses an address-only change without asking about the address", async () => {
const authorizeEmailChange = vi.fn(async () => "required" as const);
const result = await applyWithProof(
USER_ID,
{ email: "someone@example.test" },
null,
{ authorizeEmailChange },
);
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe(401);
expect(result.errorCode).toBe("auth.reproof.required");
}
expect(emailProbes()).toHaveLength(0);
expect(checkProfileEmailRateLimit).not.toHaveBeenCalled();
expect(prisma.user.update).not.toHaveBeenCalled();
});
it("drops only the address when the save carries other fields", async () => {
const result = await applyWithProof(
USER_ID,
{ email: "someone@example.test", heightCm: 180 },
null,
{ authorizeEmailChange: async () => "failed" },
);
expect(result.ok).toBe(true);
if (result.ok) {
expect(result.rejectedFields).toEqual([
expect.objectContaining({ path: "email", code: "reproof_failed" }),
]);
}
const written = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: Record<string, unknown>;
};
expect(written.data).not.toHaveProperty("email");
expect(written.data).toHaveProperty("heightCm", 180);
});
it("hands the body's current password to the proof and never writes it", async () => {
const authorizeEmailChange = vi.fn(async () => "ok" as const);
await applyWithProof(
USER_ID,
{ email: "someone@example.test", currentPassword: "hunter2hunter2" },
null,
{ authorizeEmailChange },
);
expect(authorizeEmailChange).toHaveBeenCalledWith("hunter2hunter2");
const written = vi.mocked(prisma.user.update).mock.calls[0]?.[0] as {
data: Record<string, unknown>;
};
expect(JSON.stringify(written.data)).not.toContain("hunter2");
});
it("does not ask when the address on file is re-posted", async () => {
const authorizeEmailChange = vi.fn(async () => "required" as const);
const result = await applyWithProof(
USER_ID,
{ email: ON_FILE, heightCm: 170 },
null,
{ authorizeEmailChange },
);
expect(result.ok).toBe(true);
expect(authorizeEmailChange).not.toHaveBeenCalled();
});
});
+12 -1
View File
@@ -16,7 +16,18 @@ vi.mock("@/lib/auth/audit", () => ({
auditLog: vi.fn().mockResolvedValue(undefined),
}));
import { applyProfileUpdate } from "../profile-update";
import { applyProfileUpdate as applyWithProof } from "../profile-update";
/** These suites are about validation and the email budget, not the proof:
* every call is made as an owner who has just proved themselves. */
const applyProfileUpdate = (
userId: string,
body: unknown,
ipAddress?: string | null,
) =>
applyWithProof(userId, body, ipAddress, {
authorizeEmailChange: async () => "ok",
});
import { prisma } from "@/lib/db";
const USER_ID = "user-1";
+73
View File
@@ -282,3 +282,76 @@ async function readProof(
const parsed = stepUpMintSchema.safeParse(body);
return parsed.success ? parsed.data : "invalid";
}
export type SensitiveChangeProof = "ok" | "required" | "failed" | "rate_limited";
/**
* The proof in front of changing the account's email address.
*
* The address is what single sign-on matches an existing account by, so it is
* part of how the account is reached and a stolen session must not rewrite it.
* Accepted: on a cookie session, a sign-in or second factor inside the last
* five minutes; on either transport, the current password alongside the
* change. The password draws on the shared re-proof budget and a wrong one is
* audited like every other refused proof.
*/
export async function authorizeSensitiveChange(args: {
user: Pick<
User,
| "id"
| "passwordHash"
| "totpSecretEncrypted"
| "totpLastStep"
| "totpConfirmedAt"
>;
/** The session row id on a cookie request; null on a Bearer request. */
cookieSessionId: string | null;
currentPassword: string | null;
ipAddress: string | null;
stage: string;
}): Promise<SensitiveChangeProof> {
const { user, cookieSessionId, currentPassword, ipAddress, stage } = args;
if (cookieSessionId) {
const row = await prisma.session.findUnique({
where: { id: cookieSessionId },
select: { mfaVerifiedAt: true, createdAt: true },
});
const now = Date.now();
const recent = (d: Date | null | undefined) =>
d != null && now - d.getTime() <= RECENT_PROOF_MAX_AGE_MS;
if (recent(row?.mfaVerifiedAt) || recent(row?.createdAt)) return "ok";
}
if (!currentPassword) return "required";
const rl = await checkRateLimit(
reproofBucket(user.id),
REPROOF_LIMIT,
REPROOF_WINDOW_MS,
);
if (!rl.allowed) {
await auditLog("auth.reproof.rate_limited", {
userId: user.id,
ipAddress,
details: { stage },
});
return "rate_limited";
}
const outcome = await verifyExistingFactorProof(user, {
method: "password",
password: currentPassword,
});
if (!outcome.ok) {
await recordReproofFailure(
user.id,
ipAddress,
stage,
"password",
outcome.reason,
);
return "failed";
}
return "ok";
}
+8
View File
@@ -29,6 +29,7 @@ import { issueAccessAndRefresh } from "@/lib/auth/refresh-token";
import { recordSignInDevice } from "@/lib/auth/login-alert";
import { detectInsecureCookieTransport } from "@/lib/auth/secure-cookie";
import { annotate } from "@/lib/logging/context";
import { completePendingOidcLink } from "@/lib/auth/oidc-pending-link";
import type { User } from "@/generated/prisma/client";
export interface FinishLoginParams {
@@ -130,6 +131,13 @@ export async function finishLogin(
annotate({ meta: { session_cookie_secure_transport_mismatch: true } });
}
// A browser sign-in that proved the account's own credential confirms a
// single sign-on link the callback left pending for this very account. An
// SSO sign-in is not such a proof, so it never completes one.
if (!source.startsWith("login.oidc") && source !== "login.web_handoff") {
await completePendingOidcLink(user.id, ip);
}
// `createSession` anchors the onboarding cookie itself; thread the user's
// onboarding state through.
await createSession(
+143
View File
@@ -0,0 +1,143 @@
/**
* Linking a single sign-on identity to an account that already exists here.
*
* The SSO callback used to bind a verified IdP email to the local account with
* the same address on the spot. The local address is NOT verified — anyone can
* register with any address, and an account holder can type any address into
* their profile — so whoever set that address first owned the account the
* other person's SSO login landed in: they kept the password, and the SSO user
* filled their health record into an account the other person could read.
*
* So the callback no longer links. It records what it WOULD link in a sealed,
* short-lived, HttpOnly cookie and sends the browser to the sign-in page, and
* the link is made only when that same browser then signs in to that same
* account with the account's own credential — password (plus its second
* factor, if it has one) or passkey. The IdP proved who the person is; the
* local sign-in proves the account is theirs. Neither alone links anything.
*
* The cookie is AES-256-GCM sealed with the at-rest key (`encrypt`), so it can
* be neither read nor forged, and it carries its own expiry. It is scoped to
* `/api/auth`, the only place that reads it.
*/
import type { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
import { auditLog } from "@/lib/auth/audit";
import { annotate } from "@/lib/logging/context";
import { shouldEmitSecureCookie } from "@/lib/auth/secure-cookie";
export const OIDC_LINK_COOKIE = "hl_oidc_link";
export const OIDC_LINK_COOKIE_PATH = "/api/auth";
export const OIDC_LINK_TTL_MS = 10 * 60 * 1000;
export interface PendingOidcLink {
userId: string;
issuer: string;
sub: string;
}
/** Attach the sealed pending link to a callback redirect. */
export function setPendingLinkCookie(
response: NextResponse,
link: PendingOidcLink,
): void {
const sealed = encrypt(
JSON.stringify({
u: link.userId,
i: link.issuer,
s: link.sub,
e: Date.now() + OIDC_LINK_TTL_MS,
}),
);
response.cookies.set(OIDC_LINK_COOKIE, sealed, {
httpOnly: true,
secure: shouldEmitSecureCookie(),
// Lax, not Strict: the browser arrives here from the IdP's redirect and
// must carry it on the sign-in that follows. A cross-site POST cannot
// carry it, and every reader is a JSON POST besides.
sameSite: "lax",
maxAge: Math.floor(OIDC_LINK_TTL_MS / 1000),
path: OIDC_LINK_COOKIE_PATH,
});
}
/** The pending link this browser carries, or null when absent, forged or expired. */
export async function readPendingLink(): Promise<PendingOidcLink | null> {
let raw: string | undefined;
try {
raw = (await cookies()).get(OIDC_LINK_COOKIE)?.value;
} catch {
return null;
}
if (!raw) return null;
try {
const parsed = JSON.parse(decrypt(raw)) as Record<string, unknown>;
if (
typeof parsed.u !== "string" ||
typeof parsed.i !== "string" ||
typeof parsed.s !== "string" ||
typeof parsed.e !== "number" ||
parsed.e <= Date.now()
) {
return null;
}
return { userId: parsed.u, issuer: parsed.i, sub: parsed.s };
} catch {
return null;
}
}
async function clearPendingLinkCookie(): Promise<void> {
try {
(await cookies()).set(OIDC_LINK_COOKIE, "", {
httpOnly: true,
secure: shouldEmitSecureCookie(),
sameSite: "lax",
maxAge: 0,
path: OIDC_LINK_COOKIE_PATH,
});
} catch {
// Outside a request scope there is no cookie to clear.
}
}
/**
* Called once a browser sign-in has proved the account's OWN credential.
* Binds the pending SSO identity when the cookie names this very account and
* the account is still unlinked. Never throws into the sign-in: a link that
* cannot be made leaves the login intact and the account as it was.
*/
export async function completePendingOidcLink(
userId: string,
ipAddress: string | null,
): Promise<boolean> {
const link = await readPendingLink();
if (!link) return false;
await clearPendingLinkCookie();
if (link.userId !== userId) {
annotate({ meta: { oidc_link: "other_account" } });
return false;
}
try {
const linked = await prisma.user.updateMany({
where: { id: userId, oidcIssuer: null, oidcSub: null },
data: { oidcIssuer: link.issuer, oidcSub: link.sub },
});
if (linked.count !== 1) {
annotate({ meta: { oidc_link: "already_linked" } });
return false;
}
} catch {
// The (issuer, sub) pair became bound to another account in between.
annotate({ meta: { oidc_link: "identity_taken" } });
return false;
}
await auditLog("auth.oidc.linked", {
userId,
ipAddress,
details: { confirmedBy: "local_sign_in" },
});
annotate({ action: { name: "auth.oidc.linked" } });
return true;
}
+77 -1
View File
@@ -16,6 +16,11 @@ import { sanitiseZodIssues, type SanitisedZodIssue } from "@/lib/api-response";
import { checkProfileEmailRateLimit } from "@/lib/rate-limit";
import { annotate } from "@/lib/logging/context";
import { z } from "zod/v4";
import {
REPROOF_FAILED_CODE,
REPROOF_REQUIRED_CODE,
type SensitiveChangeProof,
} from "@/lib/auth/existing-factor-proof";
const extendedProfileSchema = profileSchema.extend({
displayName: z.string().min(1).max(80).nullable().optional(),
@@ -185,7 +190,19 @@ export interface ApplyProfileError {
export async function applyProfileUpdate(
userId: string,
body: unknown,
ipAddress?: string | null,
ipAddress: string | null | undefined,
opts: {
/**
* Decides whether this request may move the account to a NEW email
* address. The address is what single sign-on links an existing account
* by, so changing it asks for a fresh proof (`authorizeSensitiveChange`).
* Receives the `currentPassword` the body carried, if any; the password is
* never written anywhere.
*/
authorizeEmailChange: (
currentPassword: string | null,
) => Promise<SensitiveChangeProof>;
},
): Promise<ApplyProfileResult | ApplyProfileError> {
const parsed = extendedProfileSchema.safeParse(body);
@@ -258,6 +275,65 @@ export async function applyProfileUpdate(
const addressOnFile = current?.email?.trim().toLowerCase() ?? null;
if (addressOnFile !== normalizedEmail) {
// Asked before the uniqueness probe and before the budget: a caller who
// cannot show it is the account owner learns nothing about which
// addresses exist here and spends nothing.
const rawPassword =
typeof body === "object" && body !== null && !Array.isArray(body)
? (body as Record<string, unknown>).currentPassword
: undefined;
const currentPassword =
typeof rawPassword === "string" &&
rawPassword.length > 0 &&
rawPassword.length <= 512
? rawPassword
: null;
const proof = await opts.authorizeEmailChange(currentPassword);
if (proof !== "ok") {
annotate({
action: { name: "profile.email.reproof_refused" },
meta: { proof },
});
const code =
proof === "rate_limited"
? "rate_limited"
: proof === "failed"
? "reproof_failed"
: "reproof_required";
// Same narrowing as the email budget below: the address is dropped,
// the rest of the save lands, and `rejectedFields` says why.
const { email: _unproven, ...keepable } = data;
if (Object.keys(keepable).length === 0) {
return {
ok: false,
status: proof === "rate_limited" ? 429 : 401,
message:
proof === "failed"
? "Verification failed"
: proof === "rate_limited"
? "Too many attempts. Please wait 15 minutes."
: "Confirm your current password to change the email address.",
errorCode:
proof === "failed"
? REPROOF_FAILED_CODE
: proof === "rate_limited"
? "profile.update.emailRateLimited"
: REPROOF_REQUIRED_CODE,
};
}
data = keepable;
rejectedFields = [
...rejectedFields,
{
path: "email",
code,
message: "Confirm your current password to change the email address.",
},
];
}
}
if (data.email !== undefined && addressOnFile !== normalizedEmail) {
// Charged BEFORE the probe, not after: a refused request must not
// learn the answer it was refused for. The 409 below tells any
// signed-in caller whether an address is registered here, and until
+2 -1
View File
@@ -652,7 +652,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
},
"403": {
description:
"Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead.",
"Password login is disabled — the operator runs `OIDC_ONLY=true`. `meta.errorCode` = `oidc_only`; sign in through SSO instead. The one exception is a browser the SSO callback sent back with a pending link to confirm an existing account: it may sign in with that one account's password, which links the account.",
content: { "application/json": { schema: errorEnvelope } },
},
...stdResponses,
@@ -1903,6 +1903,7 @@ export const authPaths: NonNullable<ZodOpenApiObject["paths"]> = {
"**Browser arm.** No parameters, or `next=<path>` to return somewhere specific afterwards (sanitised to an in-app path — an absolute URL is discarded, not honoured). Redirects to the provider's authorization endpoint. Every failure redirects to `/auth/login?error=<reason>`.\n\n" +
"**Native arm.** The iOS app opens this URL inside an `ASWebAuthenticationSession` with `client=native` and its own PKCE `code_challenge`. The challenge is mandatory on this arm and must be S256, 43–128 characters; `plain` is structurally unsupported because the exchange only ever verifies S256. `next` is ignored and pinned to `/` — a post-login web path means nothing to the app. Every failure redirects to `healthlog://oidc-callback?error=<reason>`, and an error redirect carries no code, no ticket and no session, which is why it is safe that `client` is caller-supplied.\n\n" +
"**What comes back.** The provider returns to the server's own callback, not to the app. On success the callback redirects to the compiled-in `healthlog://oidc-callback` with a one-time `code=hlh_…`, which the app exchanges at POST /api/auth/oidc/native/token. When the account has a second factor the callback instead returns `mfa_ticket=…`, which the app completes at /api/auth/mfa/verify to obtain the same bundle. The token pair NEVER rides a URL — only the opaque code or ticket does. There is no `redirect_uri` parameter and no allowlist to configure: the scheme is a compile-time constant, so the open-redirect class is removed by construction.\n\n" +
"**An account that exists but is not linked yet.** When the provider's verified email matches a local account with no SSO identity, nothing is linked and nobody is signed in: the local address was never verified, so matching it proves nothing about who owns the account. The browser arm redirects to `/auth/login?error=oidc_link_required` with a sealed, ten-minute, HttpOnly pending-link cookie; the person then signs in to that account once with its own password (and second factor) or passkey, and that sign-in makes the link. Under `OIDC_ONLY=true` the password sign-in is admitted for exactly the account the pending link names. The native arm redirects to `healthlog://oidc-callback?error=oidc_link_required`; the link is confirmed once on the web, after which the app's SSO sign-in works.\n\n" +
"Two PKCE exchanges run here and must not be confused: the server↔provider verifier the server generates for itself, and the app↔server challenge supplied above. The native flag and the app's challenge travel only inside an AES-256-GCM state cookie, so the callback branches on tamper-authenticated state rather than on anything the provider or a network attacker can flip.",
requestParams: {
query: z.object({
+7
View File
@@ -482,6 +482,13 @@ export const profileUpdateRequest = z
.describe(
"German insurer institution number (IKNR). Optional; empty/null clears it. A non-empty value must be exactly 9 digits (no checksum enforced).",
),
currentPassword: z
.string()
.max(512)
.optional()
.describe(
"The account password, sent only alongside a NEW `email`. Moving the account to a new address needs a fresh proof because single sign-on links an existing account by it: a browser session that signed in or completed a second factor in the last five minutes needs nothing more, otherwise this field must carry the current password. Without an accepted proof the address is not changed and comes back in `rejectedFields` as `reproof_required` or `reproof_failed` (or, when the address was the only field sent, 401 `auth.reproof.required` / `auth.reproof.failed`). Attempts draw on the account's shared re-proof budget of five per fifteen minutes. Never stored.",
),
})
.meta({
id: "ProfileUpdateRequest",
+4
View File
@@ -78,6 +78,10 @@ const REJECTION_REASON_KEYS: Record<string, string> = {
// still lands. Without an entry it would fall back to "check it and try
// again", which is wrong — the value was fine, the timing was not.
rate_limited: "settings.profileRejection.rateLimited",
// Not Zod codes either: a new email address asks for a fresh proof, and
// these say whether none was given or the one given was wrong.
reproof_required: "settings.profileRejection.reproofRequired",
reproof_failed: "settings.profileRejection.reproofFailed",
too_big: "settings.profileRejection.tooBig",
too_small: "settings.profileRejection.tooSmall",
invalid_value: "settings.profileRejection.invalidValue",