feat(auth): pin OIDC accounts to their (issuer, sub) identity

An OIDC login now resolves the account by the durable (issuer, sub)
pair instead of the email claim. First contact stamps the pair — by
auto-provision or a one-time link against an existing account matched
by verified email — and every later login matches on it alone, so an
IdP-side email change refreshes the display email but can never
re-point the login at a different account. An email that matches an
account already bound to a different identity is rejected and audited
(auth.oidc.link_conflict), never silently re-bound.

The email gate tightens with it: linking and provisioning now require
an explicit email_verified: true — an absent claim is a reject
(oidc_email_unverified) rather than a benefit of the doubt, since an
IdP that does not assert verification cannot anchor the account match.
Provisioned accounts get a lowercased email and the same server-default
timezone resolution the register route uses.

Migration 0240 adds the nullable users.oidc_issuer / users.oidc_sub
columns with a composite unique index.
This commit is contained in:
Marc-André Bombeck committed 2026-07-14 11:43:53 +02:00
1 parent f815e4a83f
commit fa326c398a
11 files changed
+348 -141

No files matched your search

+2
View File
@@ -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."
},
+2
View File
@@ -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."
},
+2
View File
@@ -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."
},
+2
View File
@@ -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."
},
+2
View File
@@ -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."
},
+2
View File
@@ -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."
},
@@ -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");
+11
View File
@@ -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")
}
@@ -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<string, unknown> = {}) {
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<typeof userRow> | null;
byEmail?: ReturnType<typeof userRow> | null;
emailTaken?: boolean;
}) {
vi.mocked(prisma.user.findFirst).mockImplementation(((args: {
where: Record<string, unknown>;
}) => {
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, unknown>): 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<string, unknown> = {}) {
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");
});
});
+111 -39
View File
@@ -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");
+2
View File
@@ -39,6 +39,8 @@ export default function LoginPage() {
const key: Record<string, string> = {
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");