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