From 7ac7710a23138c4be593d227f36bcca9540751eb Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 24 Aug 2026 00:10:27 +0000 Subject: [PATCH] Fix OIDC enablement: detect config at runtime, drop NEXT_PUBLIC flag The SSO button required NEXT_PUBLIC_AUTH_OIDC_ENABLED, which is baked at build time and silently fails under Docker runtime env. Drive the login button from server-side AUTH_OIDC_* instead, and document IdP redirect URI + AUTH_URL setup. --- .env.example | 24 ++++++-- README.md | 36 +++++++++-- docker-compose.yml | 7 +-- src/app/login/page.tsx | 96 ++--------------------------- src/components/LoginPageClient.tsx | 98 ++++++++++++++++++++++++++++++ src/lib/auth/auth.ts | 23 +++---- src/lib/auth/oidc.ts | 7 +++ tests/unit/oidc-config.test.ts | 42 +++++++++++++ 8 files changed, 213 insertions(+), 120 deletions(-) create mode 100644 src/components/LoginPageClient.tsx create mode 100644 src/lib/auth/oidc.ts create mode 100644 tests/unit/oidc-config.test.ts diff --git a/.env.example b/.env.example index 8f6149a..e9bf7a5 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,10 @@ AUTH_SECRET=change-me-to-a-long-random-string + +# Public URL of this app (important behind reverse proxies / Docker) +# Example: http://localhost:3000 or https://rfid.example.com +# AUTH_URL=http://localhost:3000 +# AUTH_TRUST_HOST=true + # Optional: path to SQLite file (default ./data/rfid.db) # RFID_DB_PATH=./data/rfid.db @@ -10,10 +16,20 @@ AUTH_SECRET=change-me-to-a-long-random-string # CREATE_USER_PASSWORD=changeme # CREATE_USER_NAME=Admin -# Optional OIDC (Authentik, Keycloak, Authelia, etc.) +# Optional OIDC (Authentik, Keycloak, Authelia, Google, etc.) +# Setting these three enables the "Sign in with SSO" button automatically +# (no NEXT_PUBLIC_* flag needed). +# +# In your IdP, create a confidential OIDC client with redirect URI: +# {AUTH_URL}/api/auth/callback/oidc +# e.g. http://localhost:3000/api/auth/callback/oidc +# +# AUTH_OIDC_ISSUER must be the issuer that serves +# {issuer}/.well-known/openid-configuration +# Authentik example: https://sso.example.com/application/o/rfid/ +# Keycloak example: https://sso.example.com/realms/myrealm +# # AUTH_OIDC_ISSUER=https://sso.example.com/application/o/rfid/ # AUTH_OIDC_CLIENT_ID= # AUTH_OIDC_CLIENT_SECRET= -# AUTH_OIDC_NAME=SSO -# NEXT_PUBLIC_AUTH_OIDC_ENABLED=1 -# NEXT_PUBLIC_AUTH_OIDC_NAME=SSO +# AUTH_OIDC_NAME=Authentik diff --git a/README.md b/README.md index 4a1bcfa..7b23248 100644 --- a/README.md +++ b/README.md @@ -30,13 +30,37 @@ Open http://localhost:3000 and sign in. | Variable | Required | Description | |----------|----------|-------------| | `AUTH_SECRET` | yes | NextAuth secret | +| `AUTH_URL` | recommended | Public app URL (e.g. `https://rfid.example.com`) | | `RFID_DB_PATH` | no | SQLite path (default `./data/rfid.db`) | -| `AUTH_OIDC_ISSUER` | no | OIDC issuer URL | -| `AUTH_OIDC_CLIENT_ID` | no | OIDC client id | -| `AUTH_OIDC_CLIENT_SECRET` | no | OIDC client secret | -| `AUTH_OIDC_NAME` | no | Button label (default `SSO`) | -| `NEXT_PUBLIC_AUTH_OIDC_ENABLED` | no | Set `1` to show SSO button | -| `NEXT_PUBLIC_AUTH_OIDC_NAME` | no | Public SSO button label | +| `AUTH_OIDC_ISSUER` | for OIDC | Issuer URL (must expose `/.well-known/openid-configuration`) | +| `AUTH_OIDC_CLIENT_ID` | for OIDC | OIDC client id | +| `AUTH_OIDC_CLIENT_SECRET` | for OIDC | OIDC client secret | +| `AUTH_OIDC_NAME` | no | SSO button label (default `SSO`) | + +### Enabling OIDC + +1. In your IdP, create a **confidential** OIDC application. +2. Set the redirect / callback URI to: + ``` + {AUTH_URL}/api/auth/callback/oidc + ``` + Example: `http://localhost:3000/api/auth/callback/oidc` +3. Put these in `.env` (Docker Compose reads `.env` automatically): + ```bash + AUTH_URL=http://localhost:3000 + AUTH_SECRET=...long random... + AUTH_OIDC_ISSUER=https://sso.example.com/application/o/rfid/ + AUTH_OIDC_CLIENT_ID=... + AUTH_OIDC_CLIENT_SECRET=... + AUTH_OIDC_NAME=Authentik + ``` +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`). + +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. +- Local password login stays available alongside SSO. ## REST API (`/api/v1`) diff --git a/docker-compose.yml b/docker-compose.yml index abed71d..31bc601 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,15 +16,12 @@ services: PORT: "3000" HOSTNAME: 0.0.0.0 AUTH_TRUST_HOST: "true" + # OIDC: set AUTH_OIDC_ISSUER / CLIENT_ID / CLIENT_SECRET (and AUTH_URL) + # in .env — the login page picks them up at runtime. # Optional bootstrap (only creates if missing): # CREATE_USER_EMAIL: admin@lab.local # CREATE_USER_PASSWORD: change-me # CREATE_USER_NAME: Admin - # Optional OIDC — also set NEXT_PUBLIC_* in .env if using SSO button - # AUTH_OIDC_ISSUER: ${AUTH_OIDC_ISSUER:-} - # AUTH_OIDC_CLIENT_ID: ${AUTH_OIDC_CLIENT_ID:-} - # AUTH_OIDC_CLIENT_SECRET: ${AUTH_OIDC_CLIENT_SECRET:-} - # AUTH_OIDC_NAME: ${AUTH_OIDC_NAME:-SSO} volumes: - rfid-data:/data healthcheck: diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx index b0f0cc4..9775224 100644 --- a/src/app/login/page.tsx +++ b/src/app/login/page.tsx @@ -1,95 +1,11 @@ -"use client"; - -import { FormEvent, useState } from "react"; -import { signIn } from "next-auth/react"; -import { useRouter, useSearchParams } from "next/navigation"; -import { Suspense } from "react"; - -function LoginForm() { - 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 [loading, setLoading] = useState(false); - const oidcName = process.env.NEXT_PUBLIC_AUTH_OIDC_NAME || "SSO"; - const oidcEnabled = process.env.NEXT_PUBLIC_AUTH_OIDC_ENABLED === "1"; - - async function onSubmit(e: FormEvent) { - e.preventDefault(); - setLoading(true); - setError(null); - const res = await signIn("credentials", { - email, - password, - redirect: false, - callbackUrl, - }); - setLoading(false); - if (res?.error) { - setError("Invalid email or password"); - return; - } - router.push(callbackUrl); - router.refresh(); - } - - return ( -
-

RFID Database

-

Sign in to manage site tag dumps

- {error &&
{error}
} -
-
- - setEmail(e.target.value)} - required - /> -
-
- - setPassword(e.target.value)} - required - /> -
- - {oidcEnabled && ( - - )} -
-
- ); -} +import { isOidcConfigured } from "@/lib/auth/oidc"; +import { LoginPageClient } from "@/components/LoginPageClient"; export default function LoginPage() { return ( - - - + ); } diff --git a/src/components/LoginPageClient.tsx b/src/components/LoginPageClient.tsx new file mode 100644 index 0000000..e681fe7 --- /dev/null +++ b/src/components/LoginPageClient.tsx @@ -0,0 +1,98 @@ +"use client"; + +import { FormEvent, useState } from "react"; +import { signIn } from "next-auth/react"; +import { useRouter, useSearchParams } from "next/navigation"; +import { Suspense } from "react"; + +type Props = { + oidcEnabled: boolean; + oidcName: string; +}; + +function LoginForm({ oidcEnabled, oidcName }: 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 [loading, setLoading] = useState(false); + + async function onSubmit(e: FormEvent) { + e.preventDefault(); + setLoading(true); + setError(null); + const res = await signIn("credentials", { + email, + password, + redirect: false, + callbackUrl, + }); + setLoading(false); + if (res?.error) { + setError("Invalid email or password"); + return; + } + router.push(callbackUrl); + router.refresh(); + } + + return ( +
+

RFID Database

+

Sign in to manage site tag dumps

+ {error &&
{error}
} +
+
+ + setEmail(e.target.value)} + required + /> +
+
+ + setPassword(e.target.value)} + required + /> +
+ + {oidcEnabled && ( + + )} +
+
+ ); +} + +export function LoginPageClient(props: Props) { + return ( + + + + ); +} diff --git a/src/lib/auth/auth.ts b/src/lib/auth/auth.ts index e49c1e4..b6bd4c4 100644 --- a/src/lib/auth/auth.ts +++ b/src/lib/auth/auth.ts @@ -6,6 +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"; + +export { isOidcConfigured } from "@/lib/auth/oidc"; function buildProviders(): Provider[] { const providers: Provider[] = [ @@ -32,17 +35,15 @@ function buildProviders(): Provider[] { }), ]; - const issuer = process.env.AUTH_OIDC_ISSUER; - const clientId = process.env.AUTH_OIDC_CLIENT_ID; - const clientSecret = process.env.AUTH_OIDC_CLIENT_SECRET; - if (issuer && clientId && clientSecret) { + if (isOidcConfigured()) { providers.push({ id: "oidc", name: process.env.AUTH_OIDC_NAME || "SSO", type: "oidc", - issuer, - clientId, - clientSecret, + issuer: process.env.AUTH_OIDC_ISSUER!, + clientId: process.env.AUTH_OIDC_CLIENT_ID!, + clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET!, + // Link OIDC logins to existing local users by email allowDangerousEmailAccountLinking: true, } as Provider); } @@ -82,11 +83,3 @@ export const { handlers, auth, signIn, signOut } = NextAuth({ }, }, }); - -export function isOidcConfigured(): boolean { - return Boolean( - process.env.AUTH_OIDC_ISSUER && - process.env.AUTH_OIDC_CLIENT_ID && - process.env.AUTH_OIDC_CLIENT_SECRET - ); -} diff --git a/src/lib/auth/oidc.ts b/src/lib/auth/oidc.ts new file mode 100644 index 0000000..15b0817 --- /dev/null +++ b/src/lib/auth/oidc.ts @@ -0,0 +1,7 @@ +export function isOidcConfigured(): boolean { + return Boolean( + process.env.AUTH_OIDC_ISSUER?.trim() && + process.env.AUTH_OIDC_CLIENT_ID?.trim() && + process.env.AUTH_OIDC_CLIENT_SECRET?.trim() + ); +} diff --git a/tests/unit/oidc-config.test.ts b/tests/unit/oidc-config.test.ts new file mode 100644 index 0000000..67f55d4 --- /dev/null +++ b/tests/unit/oidc-config.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { isOidcConfigured } from "@/lib/auth/oidc"; + +describe("isOidcConfigured", () => { + const keys = [ + "AUTH_OIDC_ISSUER", + "AUTH_OIDC_CLIENT_ID", + "AUTH_OIDC_CLIENT_SECRET", + ] as const; + const previous: Record = {}; + + afterEach(() => { + for (const key of keys) { + if (previous[key] === undefined) delete process.env[key]; + else process.env[key] = previous[key]; + } + }); + + function save() { + for (const key of keys) previous[key] = process.env[key]; + } + + it("is false when any var is missing", () => { + save(); + delete process.env.AUTH_OIDC_ISSUER; + delete process.env.AUTH_OIDC_CLIENT_ID; + delete process.env.AUTH_OIDC_CLIENT_SECRET; + expect(isOidcConfigured()).toBe(false); + + process.env.AUTH_OIDC_ISSUER = "https://sso.example.com/"; + process.env.AUTH_OIDC_CLIENT_ID = "id"; + expect(isOidcConfigured()).toBe(false); + }); + + it("is true when all three are set", () => { + save(); + process.env.AUTH_OIDC_ISSUER = "https://sso.example.com/"; + process.env.AUTH_OIDC_CLIENT_ID = "id"; + process.env.AUTH_OIDC_CLIENT_SECRET = "secret"; + expect(isOidcConfigured()).toBe(true); + }); +});