From cd1a2fe80812f21c9edd7e897b019ce56ff3ab60 Mon Sep 17 00:00:00 2001 From: Nick Trochalakis Date: Sun, 23 Aug 2026 20:57:29 -0700 Subject: [PATCH] Harden OIDC + auth config diagnostics endpoint (#6) Request openid email profile, use client_secret_post, surface OIDC errors on /login, and expose a no-auth diagnostics endpoint so operators can verify the running container sees AUTH_OIDC_* and the callback URL. Co-authored-by: Cursor Agent --- README.md | 34 +++++++++++++-------- src/app/api/v1/auth/config/route.ts | 30 ++++++++++++++++++ src/app/login/page.tsx | 6 ++++ src/components/LoginPageClient.tsx | 47 +++++++++++++++++++++++------ src/lib/auth/auth.ts | 38 +++++++++++++++++++++-- 5 files changed, 132 insertions(+), 23 deletions(-) create mode 100644 src/app/api/v1/auth/config/route.ts diff --git a/README.md b/README.md index 7b23248..a2f6765 100644 --- a/README.md +++ b/README.md @@ -39,28 +39,38 @@ Open http://localhost:3000 and sign in. ### Enabling OIDC -1. In your IdP, create a **confidential** OIDC application. -2. Set the redirect / callback URI to: +1. **Pull latest and rebuild** (OIDC button detection changed recently): + ```bash + git pull + docker compose up -d --build + ``` +2. In your IdP, create a **confidential** OIDC application. +3. Set the redirect / callback URI to **exactly**: ``` {AUTH_URL}/api/auth/callback/oidc ``` - Example: `http://localhost:3000/api/auth/callback/oidc` -3. Put these in `.env` (Docker Compose reads `.env` automatically): + Example: `https://rfid.atlashorizon.net/api/auth/callback/oidc` +4. Put these in `.env`: ```bash - AUTH_URL=http://localhost:3000 + AUTH_URL=https://rfid.atlashorizon.net AUTH_SECRET=...long random... - AUTH_OIDC_ISSUER=https://sso.example.com/application/o/rfid/ - AUTH_OIDC_CLIENT_ID=... + AUTH_OIDC_ISSUER=https://auth.atlashorizon.net + AUTH_OIDC_CLIENT_ID=rfiddb AUTH_OIDC_CLIENT_SECRET=... - AUTH_OIDC_NAME=Authentik + AUTH_OIDC_NAME=AtlasHorizon ``` -4. Restart the app (`docker compose up -d` or restart `npm run dev`). -5. Open `/login` β€” you should see **Sign in with Authentik** (or your `AUTH_OIDC_NAME`). +5. Restart, then verify the running app sees config (no secrets returned): + ```bash + curl -s https://rfid.atlashorizon.net/api/v1/auth/config | jq + ``` + You want `"oidcEnabled": true` and `"callbackUrl"` matching your IdP. +6. Open `/login` β€” you should see **Sign in with AtlasHorizon**. Notes: -- `AUTH_OIDC_ISSUER` must be exactly the issuer value from discovery (trailing slash matters for some IdPs). -- The IdP must return an **email** claim; accounts are created/linked by email. +- `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`). - Local password login stays available alongside SSO. +- If the button is missing, you are almost certainly on an old image β€” rebuild. ## REST API (`/api/v1`) diff --git a/src/app/api/v1/auth/config/route.ts b/src/app/api/v1/auth/config/route.ts new file mode 100644 index 0000000..8518526 --- /dev/null +++ b/src/app/api/v1/auth/config/route.ts @@ -0,0 +1,30 @@ +import { jsonOk } from "@/lib/api/errors"; +import { isOidcConfigured } from "@/lib/auth/oidc"; + +/** + * Public auth diagnostics (no secrets). + * Useful to verify the running container sees OIDC env vars. + */ +export async function GET() { + const authUrl = (process.env.AUTH_URL || "").replace(/\/$/, ""); + const oidcEnabled = isOidcConfigured(); + + return jsonOk({ + oidcEnabled, + oidcName: process.env.AUTH_OIDC_NAME || "SSO", + issuer: process.env.AUTH_OIDC_ISSUER || null, + clientIdSet: Boolean(process.env.AUTH_OIDC_CLIENT_ID?.trim()), + clientSecretSet: Boolean(process.env.AUTH_OIDC_CLIENT_SECRET?.trim()), + authUrl: authUrl || null, + authSecretSet: Boolean( + process.env.AUTH_SECRET?.trim() && + process.env.AUTH_SECRET !== "change-me-to-a-long-random-string" + ), + callbackUrl: authUrl + ? `${authUrl}/api/auth/callback/oidc` + : "/api/auth/callback/oidc", + hint: oidcEnabled + ? "OIDC looks configured. Register callbackUrl exactly in your IdP." + : "Set AUTH_OIDC_ISSUER, AUTH_OIDC_CLIENT_ID, and AUTH_OIDC_CLIENT_SECRET, then restart.", + }); +} diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index 9775224..c467924 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -2,10 +2,16 @@ import { isOidcConfigured } from "@/lib/auth/oidc"; import { LoginPageClient } from "@/components/LoginPageClient"; export default function LoginPage() { + const authUrl = (process.env.AUTH_URL || "").replace(/\/$/, ""); + const callbackUrlHint = authUrl + ? `${authUrl}/api/auth/callback/oidc` + : "https:///api/auth/callback/oidc"; + return ( ); } diff --git a/src/components/LoginPageClient.tsx b/src/components/LoginPageClient.tsx index e681fe7..8679d0f 100644 --- a/src/components/LoginPageClient.tsx +++ b/src/components/LoginPageClient.tsx @@ -8,15 +8,37 @@ import { Suspense } from "react"; type Props = { oidcEnabled: boolean; oidcName: string; + callbackUrlHint: string; }; -function LoginForm({ oidcEnabled, oidcName }: Props) { +function authErrorMessage(code: string | null): string | null { + if (!code) return null; + switch (code) { + case "EmailRequired": + return "Your IdP did not return an email claim. Enable the email scope/claim for this OIDC client."; + case "OAuthCallbackError": + case "Callback": + return "OIDC callback failed. Check redirect URI, client secret, and container logs."; + case "OAuthSignin": + return "Could not start OIDC login. Check AUTH_OIDC_ISSUER discovery and client id."; + case "Configuration": + return "Auth configuration error. Verify AUTH_SECRET, AUTH_URL, and OIDC env vars."; + case "AccessDenied": + return "Access denied by the identity provider."; + default: + return `Sign-in error: ${code}`; + } +} + +function LoginForm({ oidcEnabled, oidcName, callbackUrlHint }: Props) { const router = useRouter(); const params = useSearchParams(); const callbackUrl = params.get("callbackUrl") || "/"; const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); - const [error, setError] = useState(null); + const [error, setError] = useState( + authErrorMessage(params.get("error")) + ); const [loading, setLoading] = useState(false); async function onSubmit(e: FormEvent) { @@ -76,13 +98,20 @@ function LoginForm({ oidcEnabled, oidcName }: Props) { {loading ? "Signing in…" : "Sign in"} {oidcEnabled && ( - + <> + +

+ IdP redirect URI must be exactly: +
+ {callbackUrlHint} +

+ )} diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index b6bd4c4..e3e949b 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -36,15 +36,44 @@ function buildProviders(): Provider[] { ]; if (isOidcConfigured()) { + const issuer = process.env.AUTH_OIDC_ISSUER!.replace(/\/$/, ""); providers.push({ id: "oidc", name: process.env.AUTH_OIDC_NAME || "SSO", type: "oidc", - issuer: process.env.AUTH_OIDC_ISSUER!, + issuer, + wellKnown: `${issuer}/.well-known/openid-configuration`, clientId: process.env.AUTH_OIDC_CLIENT_ID!, clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET!, + authorization: { + params: { + scope: "openid email profile", + }, + }, + client: { + token_endpoint_auth_method: "client_secret_post", + }, // 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); + return { + id: String(profile.sub ?? ""), + name: + (typeof profile.name === "string" && profile.name) || + (typeof profile.preferred_username === "string" && + profile.preferred_username) || + email || + "OIDC user", + email, + image: typeof profile.picture === "string" ? profile.picture : null, + }; + }, } as Provider); } @@ -58,7 +87,12 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ ...authConfig.callbacks, async signIn({ user, account }) { if (account?.provider === "credentials") return true; - if (!user.email) return false; + if (!user.email) { + console.error( + "[auth] OIDC sign-in rejected: IdP did not return an email claim. Enable the email scope/claim on the client." + ); + return "/login?error=EmailRequired"; + } const db = getDb(); const email = user.email.toLowerCase();