Author SHA1 Message Date
Cursor Agent 96c6df2fff 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.
2026-08-24 04:25:50 +00:00
Nick TrochalakisandCursor Agent c33f651cfe Fix Authelia OIDC: send state (+ remove login redirect hint) (#9)
Auth.js OIDC defaults to PKCE-only, so no `state` was sent; Authelia
rejects that. Enable checks: pkce + state. Remove the public redirect
URI hint from the login page.

Co-authored-by: Cursor Agent <[email protected]>
2026-08-24 04:19:29 +00:00
4 changed files with 83 additions and 16 deletions
+20 -2
View File
@@ -67,8 +67,26 @@ Open http://localhost:3000 and sign in.
6. Open `/login` — you should see **Sign in with AtlasHorizon**. 6. Open `/login` — you should see **Sign in with AtlasHorizon**.
Notes: Notes:
- `AUTH_OIDC_ISSUER` must match discovery (`{issuer}/.well-known/openid-configuration`). Your AtlasHorizon issuer at `https://auth.atlashorizon.net` is valid. - `AUTH_OIDC_ISSUER` must match discovery (`{issuer}/.well-known/openid-configuration`).
- The IdP must return an **email** claim (we request `openid email profile`). - 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. - Local password login stays available alongside SSO.
- If the button is missing, you are almost certainly on an old image — rebuild. - If the button is missing, you are almost certainly on an old image — rebuild.
+19 -14
View File
@@ -6,9 +6,9 @@ import { eq } from "drizzle-orm";
import { getDb } from "@/db/client"; import { getDb } from "@/db/client";
import { users } from "@/db/schema"; import { users } from "@/db/schema";
import { authConfig } from "@/lib/auth/auth.config"; 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[] { function buildProviders(): Provider[] {
const providers: Provider[] = [ const providers: Provider[] = [
@@ -47,6 +47,9 @@ function buildProviders(): Provider[] {
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET!, clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET!,
// Auth.js OIDC defaults to PKCE-only; Authelia requires a strong `state` // Auth.js OIDC defaults to PKCE-only; Authelia requires a strong `state`
checks: ["pkce", "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: { authorization: {
params: { params: {
scope: "openid email profile", scope: "openid email profile",
@@ -58,21 +61,16 @@ function buildProviders(): Provider[] {
// Link OIDC logins to existing local users by email // Link OIDC logins to existing local users by email
allowDangerousEmailAccountLinking: true, allowDangerousEmailAccountLinking: true,
profile(profile: Record<string, unknown>) { profile(profile: Record<string, unknown>) {
const email = const email = emailFromOidcProfile(profile);
(typeof profile.email === "string" && profile.email) ||
(typeof profile.preferred_username === "string" &&
String(profile.preferred_username).includes("@")
? String(profile.preferred_username)
: null);
return { return {
id: String(profile.sub ?? ""), id: String(profile.sub ?? email ?? crypto.randomUUID()),
name: name:
(typeof profile.name === "string" && profile.name) || (typeof profile.name === "string" && profile.name) ||
(typeof profile.preferred_username === "string" && (typeof profile.preferred_username === "string" &&
profile.preferred_username) || profile.preferred_username) ||
email || email ||
"OIDC user", "OIDC user",
email, email: email ?? undefined,
image: typeof profile.picture === "string" ? profile.picture : null, image: typeof profile.picture === "string" ? profile.picture : null,
}; };
}, },
@@ -87,17 +85,24 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
providers: buildProviders(), providers: buildProviders(),
callbacks: { callbacks: {
...authConfig.callbacks, ...authConfig.callbacks,
async signIn({ user, account }) { async signIn({ user, account, profile }) {
if (account?.provider === "credentials") return true; if (account?.provider === "credentials") return true;
if (!user.email) {
const email =
user.email?.trim().toLowerCase() ||
emailFromOidcProfile((profile ?? {}) as Record<string, unknown>);
if (!email) {
console.error( 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"; return "/login?error=EmailRequired";
} }
user.email = email;
const db = getDb(); const db = getDb();
const email = user.email.toLowerCase();
let existing = db.select().from(users).where(eq(users.email, email)).get(); let existing = db.select().from(users).where(eq(users.email, email)).get();
if (!existing) { if (!existing) {
const id = crypto.randomUUID(); const id = crypto.randomUUID();
+20
View File
@@ -5,3 +5,23 @@ export function isOidcConfigured(): boolean {
process.env.AUTH_OIDC_CLIENT_SECRET?.trim() 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, unknown>
): 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;
}
+24
View File
@@ -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: "[email protected]" })).toBe(
"[email protected]"
);
});
it("falls back to preferred_username when it looks like an email", () => {
expect(
emailFromOidcProfile({ preferred_username: "[email protected]" })
).toBe("[email protected]");
});
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();
});
});