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

@@ -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();