mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-13 22:32:04 +00:00
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:
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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user