diff --git a/messages/de.json b/messages/de.json index bf91cb1de..51f2af6e1 100644 --- a/messages/de.json +++ b/messages/de.json @@ -290,6 +290,8 @@ "signInDefault": "Mit SSO anmelden", "errorDenied": "Die Anmeldung wurde abgebrochen.", "errorNoEmail": "Dein Identitätsanbieter hat keine bestätigte E-Mail-Adresse übermittelt.", + "errorEmailUnverified": "Dein Identitätsanbieter hat deine E-Mail-Adresse nicht als bestätigt markiert.", + "errorIdentityConflict": "Diese E-Mail-Adresse gehört zu einem Konto, das bereits mit einer anderen SSO-Identität verknüpft ist.", "errorRegistrationDisabled": "Kein Konto passt zu dieser Identität, und neue Registrierungen sind derzeit geschlossen.", "errorFailed": "Single Sign-On ist fehlgeschlagen. Bitte versuche es erneut." }, diff --git a/messages/en.json b/messages/en.json index 5e939bbec..22ed5d61f 100644 --- a/messages/en.json +++ b/messages/en.json @@ -290,6 +290,8 @@ "signInDefault": "Sign in with SSO", "errorDenied": "Sign-in was cancelled.", "errorNoEmail": "Your identity provider did not share a verified email address.", + "errorEmailUnverified": "Your identity provider did not mark your email address as verified.", + "errorIdentityConflict": "That email address belongs to an account that is already linked to a different SSO identity.", "errorRegistrationDisabled": "No account matches that identity, and new sign-ups are currently closed.", "errorFailed": "Single sign-on failed. Please try again." }, diff --git a/messages/es.json b/messages/es.json index 9e81610cf..9ab98e431 100644 --- a/messages/es.json +++ b/messages/es.json @@ -290,6 +290,8 @@ "signInDefault": "Iniciar sesión con SSO", "errorDenied": "El inicio de sesión se canceló.", "errorNoEmail": "Tu proveedor de identidad no compartió un correo electrónico verificado.", + "errorEmailUnverified": "Tu proveedor de identidad no marcó tu correo electrónico como verificado.", + "errorIdentityConflict": "Ese correo electrónico pertenece a una cuenta que ya está vinculada a otra identidad SSO.", "errorRegistrationDisabled": "Ninguna cuenta coincide con esa identidad y los nuevos registros están cerrados actualmente.", "errorFailed": "El inicio de sesión único falló. Inténtalo de nuevo." }, diff --git a/messages/fr.json b/messages/fr.json index 34d5599ce..f69bdcc07 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -290,6 +290,8 @@ "signInDefault": "Se connecter avec le SSO", "errorDenied": "La connexion a été annulée.", "errorNoEmail": "Votre fournisseur d'identité n'a pas transmis d'adresse e-mail vérifiée.", + "errorEmailUnverified": "Votre fournisseur d'identité n'a pas marqué votre adresse e-mail comme vérifiée.", + "errorIdentityConflict": "Cette adresse e-mail appartient à un compte déjà lié à une autre identité SSO.", "errorRegistrationDisabled": "Aucun compte ne correspond à cette identité et les nouvelles inscriptions sont actuellement fermées.", "errorFailed": "L'authentification unique a échoué. Veuillez réessayer." }, diff --git a/messages/it.json b/messages/it.json index df288e671..63d55b918 100644 --- a/messages/it.json +++ b/messages/it.json @@ -290,6 +290,8 @@ "signInDefault": "Accedi con SSO", "errorDenied": "L'accesso è stato annullato.", "errorNoEmail": "Il tuo provider di identità non ha condiviso un indirizzo email verificato.", + "errorEmailUnverified": "Il tuo provider di identità non ha contrassegnato il tuo indirizzo email come verificato.", + "errorIdentityConflict": "Questo indirizzo email appartiene a un account già collegato a un'altra identità SSO.", "errorRegistrationDisabled": "Nessun account corrisponde a questa identità e le nuove registrazioni sono attualmente chiuse.", "errorFailed": "L'accesso Single Sign-On non è riuscito. Riprova." }, diff --git a/messages/pl.json b/messages/pl.json index 02ac53576..e4f6a7f92 100644 --- a/messages/pl.json +++ b/messages/pl.json @@ -290,6 +290,8 @@ "signInDefault": "Zaloguj się przez SSO", "errorDenied": "Logowanie zostało anulowane.", "errorNoEmail": "Twój dostawca tożsamości nie przekazał zweryfikowanego adresu e-mail.", + "errorEmailUnverified": "Twój dostawca tożsamości nie oznaczył Twojego adresu e-mail jako zweryfikowanego.", + "errorIdentityConflict": "Ten adres e-mail należy do konta powiązanego już z inną tożsamością SSO.", "errorRegistrationDisabled": "Żadne konto nie pasuje do tej tożsamości, a nowe rejestracje są obecnie zamknięte.", "errorFailed": "Logowanie jednokrotne nie powiodło się. Spróbuj ponownie." }, diff --git a/prisma/migrations/0240_oidc_identity/migration.sql b/prisma/migrations/0240_oidc_identity/migration.sql new file mode 100644 index 000000000..2b831bb1a --- /dev/null +++ b/prisma/migrations/0240_oidc_identity/migration.sql @@ -0,0 +1,13 @@ +-- v1.28 — OIDC SSO identity pinning. +-- +-- The (oidc_issuer, oidc_sub) pair pins an account to exactly one IdP +-- identity: stamped on first OIDC login (auto-provision or one-time link by +-- verified email), matched on every later OIDC login. Email stays a display +-- field after the stamp — an IdP-side email change can never re-point the +-- login at a different account. Both columns are NULL on accounts that never +-- signed in via SSO; Postgres unique indexes ignore NULL pairs, so the +-- constraint only bites on actual duplicate identities. +ALTER TABLE "users" ADD COLUMN "oidc_issuer" TEXT; +ALTER TABLE "users" ADD COLUMN "oidc_sub" TEXT; + +CREATE UNIQUE INDEX "users_oidc_issuer_oidc_sub_key" ON "users"("oidc_issuer", "oidc_sub"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca2b01fd0..988953f2e 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -42,6 +42,13 @@ model User { email String? @unique passwordHash String? @map("password_hash") // argon2id, optional fallback role String @default("USER") // "ADMIN" | "USER" + // OIDC SSO — the (issuer, sub) pair pins the account to exactly one IdP + // identity. Stamped on first OIDC login (auto-provision or one-time link + // by verified email); every later OIDC login matches on this pair, never + // on the email, so an IdP-side email change can update the display email + // but can never re-point the login at a different account. + oidcIssuer String? @map("oidc_issuer") + oidcSub String? @map("oidc_sub") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -867,6 +874,10 @@ model User { // v1.16.0 — per-signup invite redemption ledger (multi-use history). inviteRedemptions InviteRedemption[] @relation("InviteRedemptions") + // One account per IdP identity — the (issuer, sub) pair is the durable + // OIDC key (both NULL on accounts that never signed in via SSO; Postgres + // unique indexes ignore NULL pairs). + @@unique([oidcIssuer, oidcSub]) @@map("users") } diff --git a/src/app/api/auth/oidc/callback/__tests__/route.test.ts b/src/app/api/auth/oidc/callback/__tests__/route.test.ts index 3e81ecaf7..fbc327556 100644 --- a/src/app/api/auth/oidc/callback/__tests__/route.test.ts +++ b/src/app/api/auth/oidc/callback/__tests__/route.test.ts @@ -25,6 +25,7 @@ vi.mock("@/lib/db", () => ({ findUnique: vi.fn(), count: vi.fn(), create: vi.fn(), + update: vi.fn(), }, appSettings: { findUnique: vi.fn() }, }, @@ -33,6 +34,9 @@ vi.mock("@/lib/db", () => ({ vi.mock("@/lib/auth/audit", () => ({ auditLog: vi.fn() })); vi.mock("@/lib/auth/session", () => ({ createSession: vi.fn() })); vi.mock("@/lib/auth/login-alert", () => ({ recordSignInDevice: vi.fn() })); +vi.mock("@/lib/tz/resolver", () => ({ + resolveServerDefaultTimezone: vi.fn(async () => "Europe/Berlin"), +})); vi.mock("@/lib/auth/oidc", () => ({ getOidcConfig: vi.fn(), @@ -48,6 +52,7 @@ vi.mock("@/lib/auth/oidc", () => ({ import { GET } from "../route"; import { checkAuthSurfaceRateLimit } from "@/lib/rate-limit"; import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; import { createSession } from "@/lib/auth/session"; import { getOidcConfig, @@ -70,6 +75,43 @@ const METADATA = { jwks_uri: "https://idp.example.com/jwks", }; +/** A minimal user row shaped like what the callback consumes. */ +function userRow(overrides: Record = {}) { + return { + id: "user-1", + email: "existing@example.com", + onboardingCompletedAt: new Date(), + oidcIssuer: null, + oidcSub: null, + totpConfirmedAt: null, + mfaEnforced: false, + ...overrides, + }; +} + +/** + * The callback's `prisma.user.findFirst` calls are distinguishable by + * their `where` shape: the (issuer, sub) identity lookup, the display- + * email collision probe (`NOT`), and the link/provision email match. + */ +function mockUserLookups(opts: { + byIdentity?: ReturnType | null; + byEmail?: ReturnType | null; + emailTaken?: boolean; +}) { + vi.mocked(prisma.user.findFirst).mockImplementation(((args: { + where: Record; + }) => { + if (args.where.oidcIssuer !== undefined) { + return Promise.resolve(opts.byIdentity ?? null); + } + if (args.where.NOT !== undefined) { + return Promise.resolve(opts.emailTaken ? { id: "user-other" } : null); + } + return Promise.resolve(opts.byEmail ?? null); + }) as never); +} + function makeRequest(opts: { query?: string; cookie?: string }): NextRequest { const req = new NextRequest( `http://localhost/api/auth/oidc/callback${opts.query ?? ""}`, @@ -84,6 +126,30 @@ function stateCookie(payload: Record): string { return `enc(${JSON.stringify(payload)})`; } +/** A request whose query/state pass the CSRF gate — the default fixture. */ +function validRequest(next = "/"): NextRequest { + return makeRequest({ + query: "?code=abc&state=STATE", + cookie: stateCookie({ + state: "STATE", + nonce: "nonce-1", + codeVerifier: "verifier-1", + next, + }), + }); +} + +function mockIdentity(overrides: Record = {}) { + vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" }); + vi.mocked(verifyIdToken).mockResolvedValue({ + sub: "sub-1", + email: "existing@example.com", + emailVerified: true, + name: null, + ...overrides, + } as never); +} + beforeEach(() => { vi.resetAllMocks(); vi.mocked(checkAuthSurfaceRateLimit).mockResolvedValue({ @@ -127,100 +193,157 @@ describe("GET /api/auth/oidc/callback", () => { expect(res.headers.get("location")).toContain("error=oidc_state"); }); - it("redirects with oidc_no_email when the identity has no verified email", async () => { - vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" }); - vi.mocked(verifyIdToken).mockResolvedValue({ - sub: "sub-1", - email: null, - emailVerified: undefined, - name: null, - }); - const res = await GET( - makeRequest({ - query: "?code=abc&state=STATE", - cookie: stateCookie({ - state: "STATE", - nonce: "nonce-1", - codeVerifier: "verifier-1", - next: "/", - }), - }), - ); + it("redirects with oidc_no_email when the identity has no email at all", async () => { + mockIdentity({ email: null, emailVerified: undefined }); + mockUserLookups({ byIdentity: null }); + const res = await GET(validRequest()); expect(res.headers.get("location")).toContain("error=oidc_no_email"); }); - it("logs in an existing user matched by email without provisioning", async () => { - vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" }); - vi.mocked(verifyIdToken).mockResolvedValue({ - sub: "sub-1", - email: "existing@example.com", - emailVerified: true, - name: "Existing", - }); - vi.mocked(prisma.user.findFirst).mockResolvedValue({ - id: "user-1", - onboardingCompletedAt: new Date(), - } as never); - - const res = await GET( - makeRequest({ - query: "?code=abc&state=STATE", - cookie: stateCookie({ - state: "STATE", - nonce: "nonce-1", - codeVerifier: "verifier-1", - next: "/dashboard", - }), - }), + it("redirects with oidc_email_unverified when email_verified is absent", async () => { + // An IdP that never asserts verification cannot anchor a link or a + // provision — absent is a reject, not a benefit of the doubt. + mockIdentity({ emailVerified: undefined }); + mockUserLookups({ byIdentity: null, byEmail: null }); + const res = await GET(validRequest()); + expect(res.headers.get("location")).toContain( + "error=oidc_email_unverified", ); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + }); + + it("redirects with oidc_email_unverified when email_verified is false", async () => { + mockIdentity({ emailVerified: false }); + mockUserLookups({ byIdentity: null, byEmail: null }); + const res = await GET(validRequest()); + expect(res.headers.get("location")).toContain( + "error=oidc_email_unverified", + ); + }); + + it("logs in by (issuer, sub) without consulting the email", async () => { + // Even with a brand-new email at the IdP, the stamped identity wins — + // no second account is provisioned and no re-link happens. + mockIdentity({ email: "renamed@example.com", emailVerified: true }); + mockUserLookups({ + byIdentity: userRow({ + oidcIssuer: METADATA.issuer, + oidcSub: "sub-1", + }), + emailTaken: false, + }); + vi.mocked(prisma.user.update).mockResolvedValue( + userRow({ + email: "renamed@example.com", + oidcIssuer: METADATA.issuer, + oidcSub: "sub-1", + }) as never, + ); + + const res = await GET(validRequest("/dashboard")); expect(prisma.user.create).not.toHaveBeenCalled(); - expect(createSession).toHaveBeenCalledWith( - "user-1", - false, - "1.2.3.4", - null, - expect.any(Date), - ); + // Display email refresh — the only field the update touches. + expect(prisma.user.update).toHaveBeenCalledWith({ + where: { id: "user-1" }, + data: { email: "renamed@example.com" }, + }); + expect(createSession).toHaveBeenCalled(); expect(res.headers.get("location")).toContain("/dashboard"); }); - it("auto-provisions a new user when registration is open", async () => { - vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" }); - vi.mocked(verifyIdToken).mockResolvedValue({ - sub: "sub-2", - email: "new@example.com", - emailVerified: true, - name: "New", + it("skips the display-email refresh when another account holds the address", async () => { + mockIdentity({ email: "collide@example.com", emailVerified: true }); + mockUserLookups({ + byIdentity: userRow({ + oidcIssuer: METADATA.issuer, + oidcSub: "sub-1", + }), + emailTaken: true, }); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + + const res = await GET(validRequest()); + + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(createSession).toHaveBeenCalled(); + expect(res.status).toBe(307); + }); + + it("links an existing email-matched account ONCE, stamping (issuer, sub)", 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( + "auth.oidc.linked", + expect.objectContaining({ userId: "user-1" }), + ); + expect(createSession).toHaveBeenCalled(); + expect(res.headers.get("location")).toContain("/dashboard"); + }); + + it("rejects a login whose email matches an account bound to a DIFFERENT identity", async () => { + mockIdentity({ sub: "sub-intruder" }); + mockUserLookups({ + byIdentity: null, + byEmail: userRow({ + oidcIssuer: "https://other-idp.example.com", + oidcSub: "sub-original", + }), + }); + + const res = await GET(validRequest()); + + expect(res.headers.get("location")).toContain( + "error=oidc_identity_conflict", + ); + expect(prisma.user.update).not.toHaveBeenCalled(); + expect(prisma.user.create).not.toHaveBeenCalled(); + expect(createSession).not.toHaveBeenCalled(); + expect(auditLog).toHaveBeenCalledWith( + "auth.oidc.link_conflict", + expect.objectContaining({ userId: "user-1" }), + ); + }); + + it("auto-provisions a new user when registration is open", async () => { + mockIdentity({ + sub: "sub-2", + email: "New@Example.com", + emailVerified: true, + }); + mockUserLookups({ byIdentity: null, byEmail: null }); vi.mocked(prisma.user.count).mockResolvedValue(3); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ registrationEnabled: true, } as never); - vi.mocked(prisma.user.create).mockResolvedValue({ - id: "user-new", - onboardingCompletedAt: null, - } as never); - - const res = await GET( - makeRequest({ - query: "?code=abc&state=STATE", - cookie: stateCookie({ - state: "STATE", - nonce: "nonce-1", - codeVerifier: "verifier-1", - next: "/", - }), - }), + vi.mocked(prisma.user.create).mockResolvedValue( + userRow({ id: "user-new", onboardingCompletedAt: null }) as never, ); + const res = await GET(validRequest()); + expect(prisma.user.create).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ + // Lowercased at provision time; stamped with the durable identity + // and the same server-default timezone the register route resolves. email: "new@example.com", passwordHash: null, role: "USER", + timezone: "Europe/Berlin", + oidcIssuer: METADATA.issuer, + oidcSub: "sub-2", }), }), ); @@ -229,30 +352,14 @@ describe("GET /api/auth/oidc/callback", () => { }); it("refuses to auto-provision when registration is closed", async () => { - vi.mocked(exchangeCodeForTokens).mockResolvedValue({ id_token: "idt" }); - vi.mocked(verifyIdToken).mockResolvedValue({ - sub: "sub-3", - email: "blocked@example.com", - emailVerified: true, - name: null, - }); - vi.mocked(prisma.user.findFirst).mockResolvedValue(null); + mockIdentity({ sub: "sub-3", email: "blocked@example.com" }); + mockUserLookups({ byIdentity: null, byEmail: null }); vi.mocked(prisma.user.count).mockResolvedValue(5); vi.mocked(prisma.appSettings.findUnique).mockResolvedValue({ registrationEnabled: false, } as never); - const res = await GET( - makeRequest({ - query: "?code=abc&state=STATE", - cookie: stateCookie({ - state: "STATE", - nonce: "nonce-1", - codeVerifier: "verifier-1", - next: "/", - }), - }), - ); + const res = await GET(validRequest()); expect(prisma.user.create).not.toHaveBeenCalled(); expect(createSession).not.toHaveBeenCalled(); @@ -263,17 +370,7 @@ describe("GET /api/auth/oidc/callback", () => { it("redirects with oidc_failed when the token exchange throws", async () => { vi.mocked(exchangeCodeForTokens).mockRejectedValue(new Error("network")); - const res = await GET( - makeRequest({ - query: "?code=abc&state=STATE", - cookie: stateCookie({ - state: "STATE", - nonce: "nonce-1", - codeVerifier: "verifier-1", - next: "/", - }), - }), - ); + const res = await GET(validRequest()); expect(res.headers.get("location")).toContain("error=oidc_failed"); }); }); diff --git a/src/app/api/auth/oidc/callback/route.ts b/src/app/api/auth/oidc/callback/route.ts index 262dca3a1..8a348151e 100644 --- a/src/app/api/auth/oidc/callback/route.ts +++ b/src/app/api/auth/oidc/callback/route.ts @@ -18,6 +18,7 @@ import { verifyIdToken, } from "@/lib/auth/oidc"; import { OIDC_STATE_COOKIE } from "@/lib/auth/oidc-cookie"; +import { resolveServerDefaultTimezone } from "@/lib/tz/resolver"; const LOGIN_ERROR_URL = "/auth/login"; @@ -133,55 +134,126 @@ export const GET = apiHandler(async (req: NextRequest) => { emailVerified = userinfo.emailVerified; } - // Accept a verified email, or one with no `email_verified` claim at - // all (some providers never send it) — but never one explicitly - // marked unverified. - if (!email || emailVerified === false) { - annotate({ meta: { reason: "no_verified_email" } }); - return errorRedirect(req, "oidc_no_email"); - } - + // (1) Durable identity match — the (issuer, sub) pair stamped on a + // previous login, link, or provision. Email is deliberately NOT + // consulted here: after the stamp it is a display field, so an + // IdP-side email change can refresh it below but can never re-point + // the login at a different account. let user = await prisma.user.findFirst({ - where: { email: { equals: email, mode: "insensitive" } }, + where: { oidcIssuer: metadata.issuer, oidcSub: identity.sub }, }); - if (!user) { - const userCount = await prisma.user.count(); - let registrationEnabled = true; - try { - const settings = await prisma.appSettings.findUnique({ - where: { id: "singleton" }, + if (user) { + // Refresh the display email only — and only from a claim the IdP + // explicitly marks verified. Skipped when another account already + // holds the address (`email` is unique); the login itself proceeds + // either way, keyed by (issuer, sub). + const displayEmail = email ? email.toLowerCase() : null; + if ( + displayEmail && + emailVerified === true && + user.email?.toLowerCase() !== displayEmail + ) { + const emailTaken = await prisma.user.findFirst({ + where: { + email: { equals: displayEmail, mode: "insensitive" }, + NOT: { id: user.id }, + }, + select: { id: true }, }); - if (settings && !settings.registrationEnabled && userCount > 0) { - registrationEnabled = false; + if (!emailTaken) { + user = await prisma.user.update({ + where: { id: user.id }, + data: { email: displayEmail }, + }); } - } catch { - // Table may not exist yet; allow provisioning (matches register/route.ts). } - if (!registrationEnabled) { - annotate({ meta: { reason: "registration_disabled" } }); - return errorRedirect(req, "oidc_registration_disabled"); + } else { + // No stamped identity yet — both remaining paths (link-once, + // provision) bind this (issuer, sub) to an account keyed by email, + // so the claim must be explicitly `email_verified: true`. An ABSENT + // claim is a reject, not a benefit of the doubt: an IdP that does + // not assert verification cannot anchor a link that later gates a + // health record. + if (!email) { + annotate({ meta: { reason: "no_email" } }); + return errorRedirect(req, "oidc_no_email"); + } + if (emailVerified !== true) { + annotate({ meta: { reason: "email_unverified" } }); + return errorRedirect(req, "oidc_email_unverified"); } - const username = await deriveUniqueUsername(email, (candidate) => - prisma.user - .findUnique({ where: { username: candidate } }) - .then((u: unknown) => u !== null), - ); - - user = await prisma.user.create({ - data: { - email, - username, - passwordHash: null, - role: userCount === 0 ? "ADMIN" : "USER", - }, + const byEmail = await prisma.user.findFirst({ + where: { email: { equals: email, mode: "insensitive" } }, }); - await auditLog("auth.oidc.provisioned", { - userId: user.id, - ipAddress: ip, - }); + if (byEmail) { + // (2) Link ONCE. An account already pinned to a different IdP + // identity is never silently re-bound — a rebind would let a + // second IdP identity that acquires the same email capture the + // account. Audited so the operator can see the collision. + if (byEmail.oidcIssuer !== null || byEmail.oidcSub !== null) { + await auditLog("auth.oidc.link_conflict", { + userId: byEmail.id, + ipAddress: ip, + }); + annotate({ meta: { reason: "identity_conflict" } }); + return errorRedirect(req, "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, + ipAddress: ip, + }); + } else { + // (3) Provision. + const userCount = await prisma.user.count(); + let registrationEnabled = true; + try { + const settings = await prisma.appSettings.findUnique({ + where: { id: "singleton" }, + }); + if (settings && !settings.registrationEnabled && userCount > 0) { + registrationEnabled = false; + } + } catch { + // Table may not exist yet; allow provisioning (matches register/route.ts). + } + if (!registrationEnabled) { + annotate({ meta: { reason: "registration_disabled" } }); + return errorRedirect(req, "oidc_registration_disabled"); + } + + const username = await deriveUniqueUsername(email, (candidate) => + prisma.user + .findUnique({ where: { username: candidate } }) + .then((u: unknown) => u !== null), + ); + + user = await prisma.user.create({ + data: { + email: email.toLowerCase(), + username, + passwordHash: null, + role: userCount === 0 ? "ADMIN" : "USER", + // Same server-default resolution the register route uses — an + // SSO-provisioned account has no registration form to carry the + // browser-detected timezone. + timezone: await resolveServerDefaultTimezone(), + oidcIssuer: metadata.issuer, + oidcSub: identity.sub, + }, + }); + + await auditLog("auth.oidc.provisioned", { + userId: user.id, + ipAddress: ip, + }); + } } const ua = req.headers.get("user-agent"); diff --git a/src/app/auth/login/page.tsx b/src/app/auth/login/page.tsx index a90b22a66..17db0b74f 100644 --- a/src/app/auth/login/page.tsx +++ b/src/app/auth/login/page.tsx @@ -39,6 +39,8 @@ export default function LoginPage() { const key: Record = { oidc_denied: "auth.oidc.errorDenied", oidc_no_email: "auth.oidc.errorNoEmail", + oidc_email_unverified: "auth.oidc.errorEmailUnverified", + oidc_identity_conflict: "auth.oidc.errorIdentityConflict", oidc_registration_disabled: "auth.oidc.errorRegistrationDisabled", }; return t(key[oidcError] ?? "auth.oidc.errorFailed");