mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 21:10:59 +00:00
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:
1 parent
f815e4a83f
commit
fa326c398a
11 files changed
+348
-141
No files matched your search
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in new issue
Block a user