mirror of
https://github.com/Chewbaccalakis/rfid-database.git
synced 2026-09-10 00:11:56 -07:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ac7710a23 |
@@ -39,38 +39,28 @@ Open http://localhost:3000 and sign in.
|
|||||||
|
|
||||||
### Enabling OIDC
|
### Enabling OIDC
|
||||||
|
|
||||||
1. **Pull latest and rebuild** (OIDC button detection changed recently):
|
1. In your IdP, create a **confidential** OIDC application.
|
||||||
```bash
|
2. Set the redirect / callback URI to:
|
||||||
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
|
{AUTH_URL}/api/auth/callback/oidc
|
||||||
```
|
```
|
||||||
Example: `https://rfid.atlashorizon.net/api/auth/callback/oidc`
|
Example: `http://localhost:3000/api/auth/callback/oidc`
|
||||||
4. Put these in `.env`:
|
3. Put these in `.env` (Docker Compose reads `.env` automatically):
|
||||||
```bash
|
```bash
|
||||||
AUTH_URL=https://rfid.atlashorizon.net
|
AUTH_URL=http://localhost:3000
|
||||||
AUTH_SECRET=...long random...
|
AUTH_SECRET=...long random...
|
||||||
AUTH_OIDC_ISSUER=https://auth.atlashorizon.net
|
AUTH_OIDC_ISSUER=https://sso.example.com/application/o/rfid/
|
||||||
AUTH_OIDC_CLIENT_ID=rfiddb
|
AUTH_OIDC_CLIENT_ID=...
|
||||||
AUTH_OIDC_CLIENT_SECRET=...
|
AUTH_OIDC_CLIENT_SECRET=...
|
||||||
AUTH_OIDC_NAME=AtlasHorizon
|
AUTH_OIDC_NAME=Authentik
|
||||||
```
|
```
|
||||||
5. Restart, then verify the running app sees config (no secrets returned):
|
4. Restart the app (`docker compose up -d` or restart `npm run dev`).
|
||||||
```bash
|
5. Open `/login` — you should see **Sign in with Authentik** (or your `AUTH_OIDC_NAME`).
|
||||||
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:
|
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 be exactly the issuer value from discovery (trailing slash matters for some IdPs).
|
||||||
- The IdP must return an **email** claim (we request `openid email profile`).
|
- The IdP must return an **email** claim; accounts are created/linked by email.
|
||||||
- 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.
|
|
||||||
|
|
||||||
## REST API (`/api/v1`)
|
## REST API (`/api/v1`)
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
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.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,11 @@
|
|||||||
import { isOidcConfigured } from "@/lib/auth/oidc";
|
import { isOidcConfigured } from "@/lib/auth/oidc";
|
||||||
import { LoginPageClient } from "@/components/LoginPageClient";
|
import { LoginPageClient } from "@/components/LoginPageClient";
|
||||||
|
|
||||||
// OIDC env is only available at runtime (Docker). Never prerender this page
|
|
||||||
// at build time or the SSO button stays permanently hidden.
|
|
||||||
export const dynamic = "force-dynamic";
|
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const authUrl = (process.env.AUTH_URL || "").replace(/\/$/, "");
|
|
||||||
const callbackUrlHint = authUrl
|
|
||||||
? `${authUrl}/api/auth/callback/oidc`
|
|
||||||
: "https://<your-host>/api/auth/callback/oidc";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoginPageClient
|
<LoginPageClient
|
||||||
oidcEnabled={isOidcConfigured()}
|
oidcEnabled={isOidcConfigured()}
|
||||||
oidcName={process.env.AUTH_OIDC_NAME || "SSO"}
|
oidcName={process.env.AUTH_OIDC_NAME || "SSO"}
|
||||||
callbackUrlHint={callbackUrlHint}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useEffect, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { Suspense } from "react";
|
import { Suspense } from "react";
|
||||||
@@ -8,61 +8,16 @@ import { Suspense } from "react";
|
|||||||
type Props = {
|
type Props = {
|
||||||
oidcEnabled: boolean;
|
oidcEnabled: boolean;
|
||||||
oidcName: string;
|
oidcName: string;
|
||||||
callbackUrlHint: string;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type AuthConfig = {
|
function LoginForm({ oidcEnabled, oidcName }: Props) {
|
||||||
oidcEnabled: boolean;
|
|
||||||
oidcName: string;
|
|
||||||
callbackUrl: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 router = useRouter();
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const callbackUrl = params.get("callbackUrl") || "/";
|
const callbackUrl = params.get("callbackUrl") || "/";
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(
|
const [error, setError] = useState<string | null>(null);
|
||||||
authErrorMessage(params.get("error"))
|
|
||||||
);
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [runtimeOidc, setRuntimeOidc] = useState<AuthConfig | null>(null);
|
|
||||||
|
|
||||||
// Belt-and-suspenders: ask the live API so a stale static shell can't hide SSO
|
|
||||||
useEffect(() => {
|
|
||||||
fetch("/api/v1/auth/config")
|
|
||||||
.then((r) => (r.ok ? r.json() : null))
|
|
||||||
.then((data: AuthConfig | null) => {
|
|
||||||
if (data) setRuntimeOidc(data);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
/* ignore */
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const showOidc = runtimeOidc?.oidcEnabled ?? oidcEnabled;
|
|
||||||
const displayName = runtimeOidc?.oidcName || oidcName;
|
|
||||||
const redirectHint = runtimeOidc?.callbackUrl || callbackUrlHint;
|
|
||||||
|
|
||||||
async function onSubmit(e: FormEvent) {
|
async function onSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -120,21 +75,14 @@ function LoginForm({ oidcEnabled, oidcName, callbackUrlHint }: Props) {
|
|||||||
<button className="btn" type="submit" disabled={loading}>
|
<button className="btn" type="submit" disabled={loading}>
|
||||||
{loading ? "Signing in…" : "Sign in"}
|
{loading ? "Signing in…" : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
{showOidc && (
|
{oidcEnabled && (
|
||||||
<>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn btn-secondary"
|
className="btn btn-secondary"
|
||||||
onClick={() => signIn("oidc", { callbackUrl })}
|
onClick={() => signIn("oidc", { callbackUrl })}
|
||||||
>
|
>
|
||||||
Sign in with {displayName}
|
Sign in with {oidcName}
|
||||||
</button>
|
</button>
|
||||||
<p className="muted" style={{ margin: 0, fontSize: "0.75rem" }}>
|
|
||||||
IdP redirect URI must be exactly:
|
|
||||||
<br />
|
|
||||||
<code className="mono">{redirectHint}</code>
|
|
||||||
</p>
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-36
@@ -36,44 +36,15 @@ function buildProviders(): Provider[] {
|
|||||||
];
|
];
|
||||||
|
|
||||||
if (isOidcConfigured()) {
|
if (isOidcConfigured()) {
|
||||||
const issuer = process.env.AUTH_OIDC_ISSUER!.replace(/\/$/, "");
|
|
||||||
providers.push({
|
providers.push({
|
||||||
id: "oidc",
|
id: "oidc",
|
||||||
name: process.env.AUTH_OIDC_NAME || "SSO",
|
name: process.env.AUTH_OIDC_NAME || "SSO",
|
||||||
type: "oidc",
|
type: "oidc",
|
||||||
issuer,
|
issuer: process.env.AUTH_OIDC_ISSUER!,
|
||||||
wellKnown: `${issuer}/.well-known/openid-configuration`,
|
|
||||||
clientId: process.env.AUTH_OIDC_CLIENT_ID!,
|
clientId: process.env.AUTH_OIDC_CLIENT_ID!,
|
||||||
clientSecret: process.env.AUTH_OIDC_CLIENT_SECRET!,
|
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
|
// Link OIDC logins to existing local users by email
|
||||||
allowDangerousEmailAccountLinking: true,
|
allowDangerousEmailAccountLinking: true,
|
||||||
profile(profile: Record<string, unknown>) {
|
|
||||||
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);
|
} as Provider);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,12 +58,7 @@ export const { handlers, auth, signIn, signOut } = NextAuth({
|
|||||||
...authConfig.callbacks,
|
...authConfig.callbacks,
|
||||||
async signIn({ user, account }) {
|
async signIn({ user, account }) {
|
||||||
if (account?.provider === "credentials") return true;
|
if (account?.provider === "credentials") return true;
|
||||||
if (!user.email) {
|
if (!user.email) return false;
|
||||||
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 db = getDb();
|
||||||
const email = user.email.toLowerCase();
|
const email = user.email.toLowerCase();
|
||||||
|
|||||||
Reference in New Issue
Block a user