From 96c6df2fff1e09472390224db83db20b48db2027 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 04:25:50 +0000 Subject: [PATCH] Fix OIDC email: load Authelia profile from UserInfo Auth.js OIDC only reads ID-token claims by default; Authelia puts email on UserInfo. Set idToken:false, harden claim extraction, and document an optional Authelia claims_policy. --- README.md | 22 ++++++++++++++++++++-- src/lib/auth/auth.ts | 33 +++++++++++++++++++-------------- src/lib/auth/oidc.ts | 20 ++++++++++++++++++++ tests/unit/oidc-email.test.ts | 24 ++++++++++++++++++++++++ 4 files changed, 83 insertions(+), 16 deletions(-) create mode 100644 tests/unit/oidc-email.test.ts diff --git a/README.md b/README.md index 01e0397..da1e5a3 100644 --- a/README.md +++ b/README.md @@ -67,8 +67,26 @@ Open http://localhost:3000 and sign in. 6. Open `/login` — you should see **Sign in with AtlasHorizon**. Notes: -- `AUTH_OIDC_ISSUER` must match discovery (`{issuer}/.well-known/openid-configuration`). Your AtlasHorizon issuer at `https://auth.atlashorizon.net` is valid. -- The IdP must return an **email** claim (we request `openid email profile`). +- `AUTH_OIDC_ISSUER` must match discovery (`{issuer}/.well-known/openid-configuration`). +- The app requests `openid email profile` and loads profile from **UserInfo** (Authelia puts `email` there by default). +- Optional Authelia hardening — also put email on the ID token: + +```yaml +identity_providers: + oidc: + claims_policies: + rfiddb: + id_token: + - 'email' + - 'email_verified' + - 'preferred_username' + - 'name' + clients: + - client_id: 'rfiddb' + claims_policy: 'rfiddb' + # ...rest of client... +``` + - Local password login stays available alongside SSO. - If the button is missing, you are almost certainly on an old image — rebuild. diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index f16a3ec..bd74a1e 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -6,9 +6,9 @@ import { eq } from "drizzle-orm"; import { getDb } from "@/db/client"; import { users } from "@/db/schema"; import { authConfig } from "@/lib/auth/auth.config"; -import { isOidcConfigured } from "@/lib/auth/oidc"; +import { emailFromOidcProfile, isOidcConfigured } from "@/lib/auth/oidc"; -export { isOidcConfigured } from "@/lib/auth/oidc"; +export { emailFromOidcProfile, isOidcConfigured } from "@/lib/auth/oidc"; function buildProviders(): Provider[] { const providers: Provider[] = [ @@ -47,6 +47,9 @@ function buildProviders(): Provider[] { clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET!, // Auth.js OIDC defaults to PKCE-only; Authelia requires a strong `state` checks: ["pkce", "state"], + // Authelia (and many IdPs) put `email` on UserInfo, not the ID token. + // Auth.js OIDC otherwise only reads ID-token claims. + idToken: false, authorization: { params: { scope: "openid email profile", @@ -58,21 +61,16 @@ function buildProviders(): Provider[] { // Link OIDC logins to existing local users by email allowDangerousEmailAccountLinking: true, profile(profile: Record) { - const email = - (typeof profile.email === "string" && profile.email) || - (typeof profile.preferred_username === "string" && - String(profile.preferred_username).includes("@") - ? String(profile.preferred_username) - : null); + const email = emailFromOidcProfile(profile); return { - id: String(profile.sub ?? ""), + id: String(profile.sub ?? email ?? crypto.randomUUID()), name: (typeof profile.name === "string" && profile.name) || (typeof profile.preferred_username === "string" && profile.preferred_username) || email || "OIDC user", - email, + email: email ?? undefined, image: typeof profile.picture === "string" ? profile.picture : null, }; }, @@ -87,17 +85,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ providers: buildProviders(), callbacks: { ...authConfig.callbacks, - async signIn({ user, account }) { + async signIn({ user, account, profile }) { if (account?.provider === "credentials") return true; - if (!user.email) { + + const email = + user.email?.trim().toLowerCase() || + emailFromOidcProfile((profile ?? {}) as Record); + + if (!email) { console.error( - "[auth] OIDC sign-in rejected: IdP did not return an email claim. Enable the email scope/claim on the client." + "[auth] OIDC sign-in rejected: no email in profile. Claim keys:", + profile ? Object.keys(profile) : [] ); return "/login?error=EmailRequired"; } + user.email = email; + const db = getDb(); - const email = user.email.toLowerCase(); let existing = db.select().from(users).where(eq(users.email, email)).get(); if (!existing) { const id = crypto.randomUUID(); diff --git a/src/lib/auth/oidc.ts b/src/lib/auth/oidc.ts index 15b0817..853b727 100644 --- a/src/lib/auth/oidc.ts +++ b/src/lib/auth/oidc.ts @@ -5,3 +5,23 @@ export function isOidcConfigured(): boolean { process.env.AUTH_OIDC_CLIENT_SECRET?.trim() ); } + +/** Pull an email out of common OIDC claim shapes (Authelia, Keycloak, etc.). */ +export function emailFromOidcProfile( + profile: Record +): string | null { + const candidates = [ + profile.email, + profile.preferred_username, + profile.upn, + profile.mail, + (profile.user as { email?: unknown } | undefined)?.email, + ]; + + for (const value of candidates) { + if (typeof value !== "string") continue; + const trimmed = value.trim(); + if (trimmed.includes("@")) return trimmed.toLowerCase(); + } + return null; +} diff --git a/tests/unit/oidc-email.test.ts b/tests/unit/oidc-email.test.ts new file mode 100644 index 0000000..2b6968d --- /dev/null +++ b/tests/unit/oidc-email.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from "vitest"; +import { emailFromOidcProfile } from "@/lib/auth/oidc"; + +describe("emailFromOidcProfile", () => { + it("reads email claim", () => { + expect(emailFromOidcProfile({ email: "Nick@Example.com" })).toBe( + "nick@example.com" + ); + }); + + it("falls back to preferred_username when it looks like an email", () => { + expect( + emailFromOidcProfile({ preferred_username: "nick@example.com" }) + ).toBe("nick@example.com"); + }); + + it("ignores non-email preferred_username", () => { + expect(emailFromOidcProfile({ preferred_username: "nick" })).toBeNull(); + }); + + it("returns null when nothing usable is present", () => { + expect(emailFromOidcProfile({ sub: "abc", name: "Nick" })).toBeNull(); + }); +});