diff --git a/admin/.env.example b/admin/.env.example
new file mode 100644
index 0000000..706fd43
--- /dev/null
+++ b/admin/.env.example
@@ -0,0 +1,13 @@
+# NestQuest Admin PWA — runtime configuration (Vite env).
+# Copy to .env (or .env.local) and fill in real values. NEVER commit .env.
+
+# Authentik OIDC provider. Defaults to the issuer below when unset.
+VITE_AUTHENTIK_ISSUER=https://auth.cubecraftlabs.com/application/o/nestquest/
+
+# REQUIRED. OAuth2/OIDC client id of the PUBLIC (PKCE) Authentik application.
+# No default: the app shows a configuration error screen when this is missing.
+# Never set a client_secret — this is a browser SPA using PKCE only.
+VITE_AUTHENTIK_CLIENT_ID=
+
+# NestQuest API base URL.
+VITE_API_BASE_URL=https://nestquest.cubecraftlabs.com
diff --git a/admin/src/App.css b/admin/src/App.css
index fb95e11..590a28f 100644
--- a/admin/src/App.css
+++ b/admin/src/App.css
@@ -67,4 +67,51 @@
.admin-tab.active {
font-weight: 700;
color: var(--nq-brand-blue);
-}
\ No newline at end of file
+}
+/* Auth screens (login, errors, refusal). */
+
+.admin-login,
+.admin-error,
+.admin-refusal,
+.admin-loading {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ min-height: 100%;
+ padding: 32px 16px;
+ text-align: center;
+}
+
+.admin-login-button {
+ margin-top: 8px;
+ padding: 12px 24px;
+ border: none;
+ border-radius: 8px;
+ background: var(--nq-a-accent, #2563eb);
+ color: #fff;
+ font-size: 15px;
+ font-weight: 700;
+ cursor: pointer;
+}
+
+.admin-error,
+.admin-refusal {
+ color: var(--nq-a-ink, inherit);
+}
+
+.admin-refusal h1,
+.admin-error h1 {
+ margin: 0;
+ font-size: 20px;
+}
+
+.admin-refusal p,
+.admin-error p {
+ margin: 0;
+ max-width: 34em;
+ font-size: 14px;
+ font-weight: 600;
+ color: var(--nq-a-ink-secondary);
+}
diff --git a/admin/src/AppRoutes.test.tsx b/admin/src/AppRoutes.test.tsx
new file mode 100644
index 0000000..abb0fc4
--- /dev/null
+++ b/admin/src/AppRoutes.test.tsx
@@ -0,0 +1,73 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import { cleanup, render, screen, waitFor } from "@testing-library/react";
+import AppRoutes from "./AppRoutes";
+import { storeTokens } from "./auth/oidc";
+import { config } from "./config";
+
+// The bundled config reads real env at import time; seed the shared module's
+// clientId directly so AppRoutes sees a configured app in these tests.
+(config as { clientId: string | undefined }).clientId = "test-client-id";
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("AppRoutes auth gating", () => {
+ afterEach(() => {
+ cleanup();
+ sessionStorage.clear();
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ window.history.replaceState({}, "", "/");
+ });
+
+ it("renders the login screen when unauthenticated at /", () => {
+ render();
+ expect(screen.getByTestId("login-screen")).toBeTruthy();
+ expect(
+ screen.getByRole("button", { name: /sign in with authentik/i }),
+ ).toBeTruthy();
+ });
+
+ it("renders the four-tab shell after a successful authenticated probe", async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
+ vi.stubGlobal("fetch", fetchMock);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByRole("heading", { name: "NestQuest Admin" })).toBeTruthy();
+ });
+ for (const label of ["Today", "Tasks", "Schedule", "History"]) {
+ expect(screen.getByRole("tab", { name: label })).toBeTruthy();
+ }
+ });
+
+ it("renders the explicit refusal when the API answers 403", async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
+ vi.stubGlobal("fetch", fetchMock);
+
+ render();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("refusal-screen")).toBeTruthy();
+ });
+ expect(screen.getByText(/not in the nestquest-admins group/i)).toBeTruthy();
+ });
+
+ it("shows the configuration error screen when the client id is missing", () => {
+ const original = config.clientId;
+ (config as { clientId: string | undefined }).clientId = undefined;
+ try {
+ render();
+ expect(screen.getByTestId("config-error-screen")).toBeTruthy();
+ } finally {
+ (config as { clientId: string | undefined }).clientId = original;
+ }
+ });
+});
diff --git a/admin/src/AppRoutes.tsx b/admin/src/AppRoutes.tsx
new file mode 100644
index 0000000..5d6b1ab
--- /dev/null
+++ b/admin/src/AppRoutes.tsx
@@ -0,0 +1,176 @@
+/**
+ * Minimal path-based routing without react-router-dom: the app only needs two
+ * paths (`/` shell and `/auth/callback`), so a tiny window.location switch is
+ * enough and keeps the bundle small. Change screens listen to popstate.
+ */
+import { useEffect, useState } from "react";
+import { ConfigError, config } from "./config";
+import {
+ AuthError,
+ buildAuthorizeUrl,
+ clearAuth,
+ handleCallback,
+ isAuthenticated,
+} from "./auth/oidc";
+import { ApiForbiddenError, apiFetch } from "./api/client";
+import AdminShell from "./App";
+
+type Screen =
+ | { kind: "loading" }
+ | { kind: "login" }
+ | { kind: "config-error"; message: string }
+ | { kind: "auth-error"; message: string }
+ | { kind: "refusal"; message: string }
+ | { kind: "app" };
+
+function currentPath(): string {
+ return window.location.pathname.replace(/\/+$/, "") || "/";
+}
+
+async function probeApi(setScreen: (screen: Screen) => void) {
+ // A cheap authenticated probe so a 403 surfaces as an explicit refusal
+ // right after login instead of a silent empty shell.
+ try {
+ await apiFetch("/api/v1/children", {}, config);
+ setScreen({ kind: "app" });
+ } catch (error) {
+ if (error instanceof ConfigError) {
+ setScreen({ kind: "config-error", message: error.message });
+ return;
+ }
+ if (error instanceof ApiForbiddenError) {
+ setScreen({ kind: "refusal", message: error.message });
+ return;
+ }
+ // Network or other failures still allow the shell to render; API screens
+ // handle their own errors in later tasks.
+ setScreen({ kind: "app" });
+ }
+}
+
+function LoginScreen({ onLogin }: { onLogin: () => void }) {
+ return (
+
+
NestQuest Admin
+
Sign in to continue
+
+
+ );
+}
+
+export default function AppRoutes() {
+ const [screen, setScreen] = useState({ kind: "loading" });
+
+ useEffect(() => {
+ // Re-evaluate on navigation (popstate) — routing is path based.
+ const onPop = () => setScreen({ kind: "loading" });
+ window.addEventListener("popstate", onPop);
+ return () => window.removeEventListener("popstate", onPop);
+ }, []);
+
+ useEffect(() => {
+ let cancelled = false;
+ const path = currentPath();
+
+ if (!config.clientId) {
+ setScreen({
+ kind: "config-error",
+ message:
+ "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
+ });
+ return;
+ }
+
+ if (path === "/auth/callback") {
+ (async () => {
+ try {
+ await handleCallback(window.location.search, config);
+ if (cancelled) return;
+ window.history.replaceState({}, "", "/");
+ await probeApi((next) => !cancelled && setScreen(next));
+ } catch (error) {
+ if (cancelled) return;
+ const message =
+ error instanceof AuthError || error instanceof Error
+ ? error.message
+ : "Authentication failed.";
+ setScreen({ kind: "auth-error", message });
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }
+
+ if (isAuthenticated()) {
+ void probeApi((next) => !cancelled && setScreen(next));
+ } else {
+ setScreen({ kind: "login" });
+ }
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ const startLogin = async () => {
+ try {
+ const url = await buildAuthorizeUrl(config);
+ window.location.assign(url);
+ } catch (error) {
+ setScreen({
+ kind: "config-error",
+ message: error instanceof Error ? error.message : "Login failed.",
+ });
+ }
+ };
+
+ switch (screen.kind) {
+ case "loading":
+ return (
+
+ Loading…
+
+ );
+ case "login":
+ return ;
+ case "config-error":
+ return (
+
+
Configuration error
+
{screen.message}
+
+ );
+ case "auth-error":
+ return (
+
+
Sign-in failed
+
{screen.message}
+
+
+ );
+ case "refusal":
+ return (
+
+
Access refused
+
{screen.message}
+
+
+ );
+ case "app":
+ return ;
+ }
+}
diff --git a/admin/src/api/client.test.ts b/admin/src/api/client.test.ts
new file mode 100644
index 0000000..9cd828a
--- /dev/null
+++ b/admin/src/api/client.test.ts
@@ -0,0 +1,94 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ ApiForbiddenError,
+ ApiUnauthorizedError,
+ apiFetch,
+} from "./client";
+import { storeTokens } from "../auth/oidc";
+import type { AppConfig } from "../config";
+
+const app: AppConfig = {
+ issuer: "https://auth.example.com/application/o/nestquest",
+ clientId: "test-client-id",
+ apiBaseUrl: "https://api.example.com",
+ authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
+ tokenEndpoint: "https://auth.example.com/application/o/token/",
+ userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
+ endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
+ redirectUri: "http://localhost:5173/auth/callback",
+};
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("API client", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ vi.unstubAllGlobals();
+ });
+
+ it("attaches the Bearer token to API calls", async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse([]));
+
+ await apiFetch("/api/v1/children", {}, app, fetchMock as any);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe("https://api.example.com/api/v1/children");
+ expect(init.headers.Authorization).toBe("Bearer at-123");
+ });
+
+ it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => {
+ storeTokens({ access_token: "at-123", expires_in: 600 });
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 }));
+
+ await expect(
+ apiFetch("/api/v1/children", {}, app, fetchMock as any),
+ ).rejects.toBeInstanceOf(ApiForbiddenError);
+ });
+
+ it("triggers a login redirect on 401 with no refresh token", async () => {
+ storeTokens({ access_token: "stale", expires_in: 600 });
+ const assign = vi.fn();
+ vi.stubGlobal("location", { ...window.location, assign });
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 }));
+
+ await expect(
+ apiFetch("/api/v1/children", {}, app, fetchMock as any),
+ ).rejects.toBeInstanceOf(ApiUnauthorizedError);
+ expect(assign).toHaveBeenCalledTimes(1);
+ const url = new URL(assign.mock.calls[0][0] as string);
+ expect(url.origin + url.pathname).toBe(
+ "https://auth.example.com/application/o/authorize/",
+ );
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
+ });
+
+ it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => {
+ storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 });
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call
+ .mockResolvedValueOnce(
+ jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant
+ )
+ .mockResolvedValueOnce(new Response("", { status: 500 })); // retry
+
+ const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any);
+
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint);
+ expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh");
+ // A server failure must surface as the real status, not ApiUnauthorizedError.
+ expect(response.status).toBe(500);
+ });
+});
diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts
new file mode 100644
index 0000000..4ad468b
--- /dev/null
+++ b/admin/src/api/client.ts
@@ -0,0 +1,93 @@
+/**
+ * Minimal fetch wrapper for the NestQuest API.
+ *
+ * - Attaches `Authorization: Bearer ` when a token is stored.
+ * - 401: not authenticated (or token expired) -> trigger a new login.
+ * - 403: authenticated but refused -> throws ApiForbiddenError so the UI can
+ * render an explicit refusal message.
+ */
+import { config, type AppConfig } from "../config";
+import {
+ buildAuthorizeUrl,
+ getToken,
+ isExpired,
+ refreshAccessToken,
+} from "../auth/oidc";
+
+export class ApiForbiddenError extends Error {
+ constructor(message = "Your account is not in the nestquest-admins group.") {
+ super(message);
+ this.name = "ApiForbiddenError";
+ }
+}
+
+export class ApiUnauthorizedError extends Error {
+ constructor(message = "Not authenticated.") {
+ super(message);
+ this.name = "ApiUnauthorizedError";
+ }
+}
+
+export interface ApiRequestOptions extends Omit {
+ headers?: Record;
+}
+
+/**
+ * Redirect the browser to Authentik to start a new login.
+ * Exported for testing injection.
+ */
+export async function triggerLogin(app: AppConfig = config): Promise {
+ const url = await buildAuthorizeUrl(app);
+ window.location.assign(url);
+}
+
+export async function apiFetch(
+ path: string,
+ options: ApiRequestOptions = {},
+ app: AppConfig = config,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ if (isExpired() && (await refreshAccessToken(app, fetchImpl))) {
+ // token renewed; fall through and use the fresh one
+ }
+
+ const token = getToken();
+ const headers = {
+ ...(options.headers ?? {}),
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
+ };
+
+ const response = await fetchImpl(`${app.apiBaseUrl}${path}`, {
+ ...options,
+ headers,
+ });
+
+ if (response.status === 401) {
+ // Not authenticated: either no token or an unusable one.
+ if (await refreshAccessToken(app, fetchImpl)) {
+ const retryToken = getToken();
+ const retryResponse = await fetchImpl(`${app.apiBaseUrl}${path}`, {
+ ...options,
+ headers: {
+ ...(options.headers ?? {}),
+ ...(retryToken ? { Authorization: `Bearer ${retryToken}` } : {}),
+ },
+ });
+ if (retryResponse.status === 403) throw new ApiForbiddenError();
+ if (retryResponse.ok) return retryResponse;
+ // Only 401 means "unauthorized"; preserve every other status (e.g. a
+ // 500 from the API) so callers can react to the real response.
+ if (retryResponse.status === 401) throw new ApiUnauthorizedError();
+ return retryResponse;
+ }
+ await triggerLogin(app);
+ throw new ApiUnauthorizedError();
+ }
+
+ if (response.status === 403) {
+ // Authenticated, but Authentik's token does not belong to nestquest-admins.
+ throw new ApiForbiddenError();
+ }
+
+ return response;
+}
diff --git a/admin/src/auth/oidc.test.ts b/admin/src/auth/oidc.test.ts
new file mode 100644
index 0000000..cd63b02
--- /dev/null
+++ b/admin/src/auth/oidc.test.ts
@@ -0,0 +1,173 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ buildAuthorizeUrl,
+ clearAuth,
+ getToken,
+ handleCallback,
+ logout,
+ storeTokens,
+} from "./oidc";
+import type { AppConfig } from "../config";
+
+function testApp(): AppConfig {
+ return {
+ issuer: "https://auth.example.com/application/o/nestquest",
+ clientId: "test-client-id",
+ apiBaseUrl: "https://api.example.com",
+ authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
+ tokenEndpoint: "https://auth.example.com/application/o/token/",
+ userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
+ endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
+ redirectUri: "http://localhost:5173/auth/callback",
+ };
+}
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+describe("OIDC flow", () => {
+ beforeEach(() => {
+ sessionStorage.clear();
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("buildAuthorizeUrl carries all required params and stores state/verifier", async () => {
+ const app = testApp();
+ const url = new URL(await buildAuthorizeUrl(app));
+
+ expect(url.origin + url.pathname).toBe(app.authorizeEndpoint);
+ expect(url.searchParams.get("response_type")).toBe("code");
+ expect(url.searchParams.get("client_id")).toBe("test-client-id");
+ expect(url.searchParams.get("redirect_uri")).toBe(app.redirectUri);
+ expect(url.searchParams.get("scope")).toBe("openid email profile");
+ expect(url.searchParams.get("code_challenge_method")).toBe("S256");
+ expect(url.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
+ expect(url.searchParams.get("state")).toMatch(/^[A-Za-z0-9_-]+$/);
+
+ expect(sessionStorage.getItem("nestquest.oidc_state")).toBe(
+ url.searchParams.get("state"),
+ );
+ expect(sessionStorage.getItem("nestquest.code_verifier")).toMatch(/^[A-Za-z0-9-._~]{43,128}$/);
+ });
+
+ it("handleCallback rejects a mismatched state", async () => {
+ await buildAuthorizeUrl(testApp());
+ sessionStorage.setItem("nestquest.oidc_state", "different-state");
+
+ const fetchMock = vi.fn();
+ await expect(
+ handleCallback("?code=abc&state=tampered", testApp(), fetchMock as any),
+ ).rejects.toThrow(/state mismatch/i);
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(getToken()).toBeUndefined();
+ });
+
+ it("handleCallback exchanges the code with the correct form body and stores the token", async () => {
+ await buildAuthorizeUrl(testApp());
+ const verifier = sessionStorage.getItem("nestquest.code_verifier")!;
+ const state = sessionStorage.getItem("nestquest.oidc_state")!;
+
+ const fetchMock = vi.fn().mockResolvedValue(
+ jsonResponse({
+ access_token: "at-123",
+ refresh_token: "rt-456",
+ id_token: "id-789",
+ expires_in: 300,
+ }),
+ );
+ await handleCallback(`?code=abc&state=${state}`, testApp(), fetchMock as any);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ const [url, init] = fetchMock.mock.calls[0];
+ expect(url).toBe("https://auth.example.com/application/o/token/");
+ expect(init.method).toBe("POST");
+ expect(init.headers["Content-Type"]).toBe("application/x-www-form-urlencoded");
+
+ const body = new URLSearchParams(init.body);
+ expect(body.get("grant_type")).toBe("authorization_code");
+ expect(body.get("code")).toBe("abc");
+ expect(body.get("redirect_uri")).toBe(testApp().redirectUri);
+ expect(body.get("client_id")).toBe("test-client-id");
+ expect(body.get("code_verifier")).toBe(verifier);
+ expect(body.has("client_secret")).toBe(false);
+
+ expect(getToken()).toBe("at-123");
+ // one-time values consumed
+ expect(sessionStorage.getItem("nestquest.oidc_state")).toBeNull();
+ expect(sessionStorage.getItem("nestquest.code_verifier")).toBeNull();
+ });
+
+ it("handleCallback surfaces provider errors", async () => {
+ await expect(
+ handleCallback("?error=access_denied&error_description=nope", testApp(), vi.fn() as any),
+ ).rejects.toThrow(/nope|access_denied/);
+ });
+
+ it("clearAuth removes all stored auth data", () => {
+ storeTokens({ access_token: "a", refresh_token: "r", expires_in: 60 });
+ clearAuth();
+ expect(getToken()).toBeUndefined();
+ expect(sessionStorage.length).toBe(0);
+ });
+
+ it("logout clears auth and redirects to the end-session endpoint", async () => {
+ storeTokens({ access_token: "a", id_token: "idt", expires_in: 60 });
+ const assign = vi.fn();
+ const original = window.location;
+ vi.stubGlobal("location", { ...original, assign, origin: "http://localhost:5173" });
+
+ await logout(testApp());
+
+ expect(assign).toHaveBeenCalledTimes(1);
+ const url = new URL(assign.mock.calls[0][0] as string);
+ expect(url.toString()).toBe(
+ "https://auth.example.com/application/o/nestquest/end-session/?id_token_hint=idt&client_id=test-client-id&post_logout_redirect_uri=http%3A%2F%2Flocalhost%3A5173",
+ );
+ expect(getToken()).toBeUndefined();
+ vi.unstubAllGlobals();
+ });
+
+ it("buildAuthorizeUrl resolves the authorize endpoint via discovery for a non-default issuer", async () => {
+ const issuer = "https://sso.discovery.example/application/o/discovered";
+ const discoveryDoc = {
+ issuer,
+ authorization_endpoint: "https://sso.discovery.example/application/o/authorize/",
+ token_endpoint: "https://sso.discovery.example/application/o/token/",
+ userinfo_endpoint: "https://sso.discovery.example/application/o/userinfo/",
+ end_session_endpoint: `${issuer}/end-session/`,
+ jwks_uri: `${issuer}/jwks/`,
+ };
+ const fetchMock = vi.fn().mockImplementation((url: string) =>
+ Promise.resolve(
+ String(url).endsWith("/.well-known/openid-configuration")
+ ? jsonResponse(discoveryDoc)
+ : jsonResponse({}),
+ ),
+ );
+ const app: AppConfig = {
+ issuer,
+ clientId: "test-client-id",
+ apiBaseUrl: "https://api.example.com",
+ redirectUri: "http://localhost:5173/auth/callback",
+ };
+
+ const url = new URL(await buildAuthorizeUrl(app, fetchMock as any));
+
+ // The authorize endpoint comes from the discovery document — Authentik
+ // serves it outside the app-specific issuer path, so it is NOT
+ // `${issuer}/authorize/` and must never be a hardcoded production host.
+ expect(url.origin + url.pathname).toBe(discoveryDoc.authorization_endpoint);
+ expect(url.searchParams.get("client_id")).toBe("test-client-id");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(String(fetchMock.mock.calls[0][0])).toBe(
+ `${issuer}/.well-known/openid-configuration`,
+ );
+ });
+});
diff --git a/admin/src/auth/oidc.ts b/admin/src/auth/oidc.ts
new file mode 100644
index 0000000..f2fe723
--- /dev/null
+++ b/admin/src/auth/oidc.ts
@@ -0,0 +1,225 @@
+/**
+ * OIDC authorization-code + PKCE flow for a PUBLIC client.
+ *
+ * Never sends a client_secret anywhere. Tokens are stored in sessionStorage
+ * (per-tab, cleared on tab close) — never localStorage.
+ */
+import { config, resolveEndpoints, type AppConfig, type ResolvedAppConfig } from "../config";
+import {
+ computeCodeChallenge,
+ generateCodeVerifier,
+ generateState,
+} from "./pkce";
+
+const STORAGE_KEYS = {
+ accessToken: "nestquest.access_token",
+ refreshToken: "nestquest.refresh_token",
+ expiresAt: "nestquest.expires_at",
+ idToken: "nestquest.id_token",
+ state: "nestquest.oidc_state",
+ codeVerifier: "nestquest.code_verifier",
+} as const;
+
+export class AuthError extends Error {}
+
+export interface TokenResponse {
+ access_token?: string;
+ refresh_token?: string;
+ id_token?: string;
+ expires_in?: number;
+ token_type?: string;
+}
+
+function makeScopes(): string {
+ return "openid email profile";
+}
+
+/**
+ * Build the Authentik authorization redirect URL, storing state + verifier in
+ * sessionStorage so the callback can validate them. Endpoints are resolved
+ * from the issuer's OIDC discovery document when not set on the config.
+ */
+export async function buildAuthorizeUrl(
+ app: AppConfig = config,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ if (!app.clientId) {
+ throw new AuthError(
+ "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
+ );
+ }
+ const resolved = await resolveEndpoints(app, fetchImpl);
+ const state = generateState();
+ const codeVerifier = generateCodeVerifier();
+ const codeChallenge = await computeCodeChallenge(codeVerifier);
+
+ sessionStorage.setItem(STORAGE_KEYS.state, state);
+ sessionStorage.setItem(STORAGE_KEYS.codeVerifier, codeVerifier);
+
+ const url = new URL(resolved.authorizeEndpoint);
+ url.searchParams.set("response_type", "code");
+ url.searchParams.set("client_id", app.clientId);
+ url.searchParams.set("redirect_uri", app.redirectUri);
+ url.searchParams.set("scope", makeScopes());
+ url.searchParams.set("state", state);
+ url.searchParams.set("code_challenge", codeChallenge);
+ url.searchParams.set("code_challenge_method", "S256");
+ return url.toString();
+}
+
+/**
+ * Handle the OAuth callback: validate state, exchange the code at the token
+ * endpoint (PKCE, form-urlencoded, no client_secret), store tokens.
+ */
+export async function handleCallback(
+ query: URLSearchParams | string = window.location.search,
+ app: AppConfig = config,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const params =
+ typeof query === "string" ? new URLSearchParams(query) : query;
+
+ const providerError = params.get("error");
+ if (providerError) {
+ throw new AuthError(
+ params.get("error_description") || `Provider returned error: ${providerError}`,
+ );
+ }
+
+ const code = params.get("code");
+ const state = params.get("state");
+ if (!code || !state) {
+ throw new AuthError("Callback is missing the code or state parameter.");
+ }
+
+ const storedState = sessionStorage.getItem(STORAGE_KEYS.state);
+ const codeVerifier = sessionStorage.getItem(STORAGE_KEYS.codeVerifier);
+ if (!storedState || storedState !== state || !codeVerifier) {
+ throw new AuthError("State mismatch — possible CSRF; refusing to log in.");
+ }
+
+ if (!app.clientId) {
+ throw new AuthError(
+ "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.",
+ );
+ }
+
+ const resolved: ResolvedAppConfig = await resolveEndpoints(app, fetchImpl);
+
+ const body = new URLSearchParams({
+ grant_type: "authorization_code",
+ code,
+ redirect_uri: app.redirectUri,
+ client_id: app.clientId,
+ code_verifier: codeVerifier,
+ });
+
+ const response = await fetchImpl(resolved.tokenEndpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: body.toString(),
+ });
+ if (!response.ok) {
+ throw new AuthError(`Token exchange failed with HTTP ${response.status}`);
+ }
+ const tokens = (await response.json()) as TokenResponse;
+ if (!tokens.access_token) {
+ throw new AuthError("Token response did not include an access token.");
+ }
+
+ storeTokens(tokens);
+ // One-time use values are consumed on success.
+ sessionStorage.removeItem(STORAGE_KEYS.state);
+ sessionStorage.removeItem(STORAGE_KEYS.codeVerifier);
+}
+
+export function storeTokens(tokens: TokenResponse): void {
+ sessionStorage.setItem(STORAGE_KEYS.accessToken, tokens.access_token!);
+ if (tokens.refresh_token) {
+ sessionStorage.setItem(STORAGE_KEYS.refreshToken, tokens.refresh_token);
+ }
+ if (tokens.id_token) {
+ sessionStorage.setItem(STORAGE_KEYS.idToken, tokens.id_token);
+ }
+ if (typeof tokens.expires_in === "number") {
+ sessionStorage.setItem(
+ STORAGE_KEYS.expiresAt,
+ String(Date.now() + tokens.expires_in * 1000),
+ );
+ }
+}
+
+export function getToken(): string | undefined {
+ return sessionStorage.getItem(STORAGE_KEYS.accessToken) ?? undefined;
+}
+
+export function getRefreshToken(): string | undefined {
+ return sessionStorage.getItem(STORAGE_KEYS.refreshToken) ?? undefined;
+}
+
+export function getIdToken(): string | undefined {
+ return sessionStorage.getItem(STORAGE_KEYS.idToken) ?? undefined;
+}
+
+export function isAuthenticated(): boolean {
+ return Boolean(getToken());
+}
+
+/** True once the stored access token has passed its (approximate) expiry. */
+export function isExpired(): boolean {
+ const expiresAt = sessionStorage.getItem(STORAGE_KEYS.expiresAt);
+ if (!expiresAt) return false;
+ return Date.now() >= Number(expiresAt);
+}
+
+/**
+ * Refresh the access token with the refresh token grant. Returns false when
+ * no refresh token is stored or the grant fails (caller should re-login).
+ */
+export async function refreshAccessToken(
+ app: AppConfig = config,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const refreshToken = getRefreshToken();
+ if (!refreshToken || !app.clientId) return false;
+
+ const resolved = await resolveEndpoints(app, fetchImpl);
+
+ const body = new URLSearchParams({
+ grant_type: "refresh_token",
+ refresh_token: refreshToken,
+ client_id: app.clientId,
+ });
+
+ const response = await fetchImpl(resolved.tokenEndpoint, {
+ method: "POST",
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
+ body: body.toString(),
+ });
+ if (!response.ok) return false;
+ const tokens = (await response.json()) as TokenResponse;
+ if (!tokens.access_token) return false;
+ storeTokens(tokens);
+ return true;
+}
+
+export function clearAuth(): void {
+ for (const key of Object.values(STORAGE_KEYS)) {
+ sessionStorage.removeItem(key);
+ }
+}
+
+/** Sign out: clear local auth, then send the browser to the end-session endpoint. */
+export async function logout(
+ app: AppConfig = config,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const resolved = await resolveEndpoints(app, fetchImpl);
+ const idTokenHint = getIdToken();
+ clearAuth();
+ const params = new URLSearchParams();
+ if (idTokenHint) params.set("id_token_hint", idTokenHint);
+ if (app.clientId) params.set("client_id", app.clientId);
+ params.set("post_logout_redirect_uri", window.location.origin);
+ window.location.assign(`${resolved.endSessionEndpoint}?${params.toString()}`);
+}
diff --git a/admin/src/auth/pkce.test.ts b/admin/src/auth/pkce.test.ts
new file mode 100644
index 0000000..7b7f686
--- /dev/null
+++ b/admin/src/auth/pkce.test.ts
@@ -0,0 +1,41 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ base64UrlEncode,
+ computeCodeChallenge,
+ generateCodeVerifier,
+ generateState,
+ isLikelyValidVerifier,
+} from "./pkce";
+
+describe("PKCE helpers", () => {
+ it("base64UrlEncode has no padding and url-safe characters", () => {
+ // bytes chosen to force "+" and "/" and padding in plain base64
+ const bytes = new Uint8Array([0xfb, 0xff, 0xbf, 0xff]);
+ const encoded = base64UrlEncode(bytes);
+ expect(encoded).not.toMatch(/[+/=]/);
+ });
+
+ it("computes the RFC 7636 appendix-B S256 vector", async () => {
+ const verifier =
+ "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
+ const challenge = await computeCodeChallenge(verifier);
+ expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
+ });
+
+ it("generates verifiers with valid charset and length", () => {
+ for (let i = 0; i < 20; i++) {
+ const verifier = generateCodeVerifier();
+ expect(isLikelyValidVerifier(verifier)).toBe(true);
+ }
+ });
+
+ it("rejects verifiers outside 43-128 chars or with reserved chars", () => {
+ expect(isLikelyValidVerifier("short")).toBe(false);
+ expect(isLikelyValidVerifier("a".repeat(129))).toBe(false);
+ expect(isLikelyValidVerifier("a".repeat(43) + "+")).toBe(false);
+ });
+
+ it("generates distinct random states", () => {
+ expect(generateState()).not.toBe(generateState());
+ });
+});
diff --git a/admin/src/auth/pkce.ts b/admin/src/auth/pkce.ts
new file mode 100644
index 0000000..de04c5f
--- /dev/null
+++ b/admin/src/auth/pkce.ts
@@ -0,0 +1,55 @@
+/**
+ * PKCE (RFC 7636) helpers for a public SPA client.
+ *
+ * Uses the Web Crypto API, which requires a SECURE CONTEXT: the app must be
+ * served over https or http://localhost, otherwise crypto.subtle is
+ * unavailable and login cannot work.
+ */
+
+const VERIFIER_CHARSET =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
+
+/** base64url-encode bytes with NO padding. */
+export function base64UrlEncode(bytes: Uint8Array): string {
+ let binary = "";
+ for (const byte of bytes) {
+ binary += String.fromCharCode(byte);
+ }
+ return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
+}
+
+function randomBase64Url(byteLength: number): string {
+ const bytes = new Uint8Array(byteLength);
+ crypto.getRandomValues(bytes);
+ return base64UrlEncode(bytes);
+}
+
+/**
+ * Random code_verifier: 43-128 chars, unreserved charset only.
+ * 32 random bytes -> 43 base64url chars, which is the minimum allowed length.
+ */
+export function generateCodeVerifier(): string {
+ return randomBase64Url(32);
+}
+
+/** S256 code_challenge = base64url(SHA-256(verifier)), no padding. */
+export async function computeCodeChallenge(verifier: string): Promise {
+ const digest = await crypto.subtle.digest(
+ "SHA-256",
+ new TextEncoder().encode(verifier),
+ );
+ return base64UrlEncode(new Uint8Array(digest));
+}
+
+/** Random opaque state value for CSRF protection. */
+export function generateState(): string {
+ return randomBase64Url(16);
+}
+
+export function isLikelyValidVerifier(verifier: string): boolean {
+ return (
+ verifier.length >= 43 &&
+ verifier.length <= 128 &&
+ [...verifier].every((char) => VERIFIER_CHARSET.includes(char))
+ );
+}
diff --git a/admin/src/config.test.ts b/admin/src/config.test.ts
new file mode 100644
index 0000000..9fcf9bf
--- /dev/null
+++ b/admin/src/config.test.ts
@@ -0,0 +1,215 @@
+import { afterEach, describe, expect, it, vi } from "vitest";
+import {
+ ConfigError,
+ discoveryUrlForIssuer,
+ readConfig,
+ resolveEndpoints,
+ resolveEndpointsFromDiscovery,
+ type AppConfig,
+} from "./config";
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+// Mirrors Authentik's layout: authorize/token/userinfo live OUTSIDE the
+// application-specific issuer path, end-session/jwks under it.
+function discoveryDocument(issuer: string) {
+ const normalized = issuer.replace(/\/+$/, "");
+ const origin = new URL(normalized).origin;
+ return {
+ issuer,
+ authorization_endpoint: `${origin}/application/o/authorize/`,
+ token_endpoint: `${origin}/application/o/token/`,
+ userinfo_endpoint: `${origin}/application/o/userinfo/`,
+ end_session_endpoint: `${normalized}/end-session/`,
+ jwks_uri: `${normalized}/jwks/`,
+ };
+}
+
+describe("config", () => {
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it("readConfig keeps the production issuer as the default", () => {
+ const cfg = readConfig({});
+ expect(cfg.issuer).toBe("https://auth.cubecraftlabs.com/application/o/nestquest");
+ expect(cfg.clientId).toBeUndefined();
+ });
+
+ it("readConfig normalizes a trailing slash off a custom issuer", () => {
+ expect(
+ readConfig({ VITE_AUTHENTIK_ISSUER: "https://sso.custom.example/application/o/custom/" })
+ .issuer,
+ ).toBe("https://sso.custom.example/application/o/custom");
+ });
+
+ it("readConfig hardcodes no OIDC endpoints — they come from discovery", () => {
+ const cfg = readConfig({});
+ expect(cfg.authorizeEndpoint).toBeUndefined();
+ expect(cfg.tokenEndpoint).toBeUndefined();
+ expect(cfg.userinfoEndpoint).toBeUndefined();
+ expect(cfg.endSessionEndpoint).toBeUndefined();
+ expect(cfg.jwksUri).toBeUndefined();
+ });
+
+ it("builds the discovery URL for issuers with and without a trailing slash", () => {
+ expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app/")).toBe(
+ "https://sso.example.com/application/o/app/.well-known/openid-configuration",
+ );
+ expect(discoveryUrlForIssuer("https://sso.example.com/application/o/app")).toBe(
+ "https://sso.example.com/application/o/app/.well-known/openid-configuration",
+ );
+ });
+
+ it("resolves every endpoint from the discovery document of a non-default issuer", async () => {
+ const issuer = "https://sso.other-host.example/application/o/other-app";
+ const doc = discoveryDocument(issuer);
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
+ const app: AppConfig = {
+ issuer,
+ clientId: "client-id",
+ apiBaseUrl: "https://api.example.com",
+ redirectUri: "http://localhost:5173/auth/callback",
+ };
+
+ const resolved = await resolveEndpoints(app, fetchMock as unknown as typeof fetch);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://sso.other-host.example/application/o/other-app/.well-known/openid-configuration",
+ );
+ // Every endpoint comes verbatim from the (non-default) discovery document.
+ expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
+ expect(resolved.tokenEndpoint).toBe(doc.token_endpoint);
+ expect(resolved.userinfoEndpoint).toBe(doc.userinfo_endpoint);
+ expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
+ expect(resolved.jwksUri).toBe(doc.jwks_uri);
+ // Proves no hardcoded production host leaked into the resolved endpoints.
+ for (const endpoint of [
+ resolved.authorizeEndpoint,
+ resolved.tokenEndpoint,
+ resolved.userinfoEndpoint,
+ resolved.endSessionEndpoint,
+ resolved.jwksUri,
+ ]) {
+ expect(endpoint).not.toMatch(/cubecraftlabs/);
+ }
+ });
+
+ it("resolves discovery for an issuer configured with a trailing slash", async () => {
+ const issuer = "https://sso.slashy.example/application/o/slashy/";
+ const doc = discoveryDocument(issuer);
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
+
+ const resolved = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
+
+ expect(fetchMock).toHaveBeenCalledWith(
+ "https://sso.slashy.example/application/o/slashy/.well-known/openid-configuration",
+ );
+ expect(resolved.authorizeEndpoint).toBe(doc.authorization_endpoint);
+ expect(resolved.endSessionEndpoint).toBe(doc.end_session_endpoint);
+ });
+
+ it("caches discovery per issuer for the session", async () => {
+ const issuer = "https://sso.cached.example/application/o/cached";
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
+
+ const first = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
+ const second = await resolveEndpointsFromDiscovery(issuer, fetchMock as any);
+
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(second).toBe(first);
+ });
+
+ it("surfaces a clear configuration error when discovery returns an HTTP error", async () => {
+ const issuer = "https://sso.http-error.example/application/o/broken";
+ const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 500 }));
+
+ const error = await resolveEndpointsFromDiscovery(issuer, fetchMock as any).then(
+ () => null,
+ (error: unknown) => error,
+ );
+
+ expect(error).toBeInstanceOf(ConfigError);
+ expect((error as Error).message).toContain(issuer);
+ expect((error as Error).message).toContain("HTTP 500");
+ });
+
+ it("surfaces a clear configuration error when discovery is unreachable", async () => {
+ const issuer = "https://sso.unreachable.example/application/o/unreachable";
+ const fetchMock = vi.fn().mockRejectedValue(new TypeError("network down"));
+
+ await expect(
+ resolveEndpointsFromDiscovery(issuer, fetchMock as any),
+ ).rejects.toBeInstanceOf(ConfigError);
+ });
+
+ it("surfaces a clear configuration error when the discovery document is incomplete", async () => {
+ const issuer = "https://sso.incomplete.example/application/o/incomplete";
+ const doc = discoveryDocument(issuer) as Record;
+ delete doc.end_session_endpoint;
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
+
+ await expect(
+ resolveEndpointsFromDiscovery(issuer, fetchMock as any),
+ ).rejects.toThrow(/end_session_endpoint/);
+ });
+
+ it("rejects a discovery document advertising a different issuer", async () => {
+ const issuer = "https://sso.mismatch.example/application/o/mismatch";
+ const doc = {
+ ...discoveryDocument(issuer),
+ issuer: "https://sso.elsewhere.example/application/o/elsewhere",
+ };
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(doc));
+
+ await expect(
+ resolveEndpointsFromDiscovery(issuer, fetchMock as any),
+ ).rejects.toThrow(/issuer mismatch/i);
+ });
+
+ it("the bundled config resolves its endpoints from discovery at runtime", async () => {
+ const issuer = "https://sso.bundled.example/application/o/bundled";
+ const fetchMock = vi.fn().mockResolvedValue(jsonResponse(discoveryDocument(issuer)));
+ const bundled = readConfig({ VITE_AUTHENTIK_ISSUER: `${issuer}/`, VITE_AUTHENTIK_CLIENT_ID: "bundled-client" });
+ expect(bundled.authorizeEndpoint).toBeUndefined();
+
+ const resolved = await resolveEndpoints(bundled, fetchMock as any);
+
+ expect(resolved.authorizeEndpoint).toBe(
+ "https://sso.bundled.example/application/o/authorize/",
+ );
+ expect(resolved.tokenEndpoint).toBe("https://sso.bundled.example/application/o/token/");
+ expect(resolved.userinfoEndpoint).toBe(
+ "https://sso.bundled.example/application/o/userinfo/",
+ );
+ expect(resolved.endSessionEndpoint).toBe(
+ "https://sso.bundled.example/application/o/bundled/end-session/",
+ );
+ expect(resolved.jwksUri).toBe("https://sso.bundled.example/application/o/bundled/jwks/");
+ });
+
+ it("does not re-fetch discovery when the config already carries endpoints", async () => {
+ const app: AppConfig = {
+ issuer: "https://auth.example.com/application/o/nestquest",
+ clientId: "client-id",
+ apiBaseUrl: "https://api.example.com",
+ authorizeEndpoint: "https://auth.example.com/application/o/authorize/",
+ tokenEndpoint: "https://auth.example.com/application/o/token/",
+ userinfoEndpoint: "https://auth.example.com/application/o/userinfo/",
+ endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/",
+ redirectUri: "http://localhost:5173/auth/callback",
+ };
+ const fetchMock = vi.fn();
+
+ const resolved = await resolveEndpoints(app, fetchMock as any);
+
+ expect(fetchMock).not.toHaveBeenCalled();
+ expect(resolved).toBe(app);
+ });
+});
diff --git a/admin/src/config.ts b/admin/src/config.ts
new file mode 100644
index 0000000..9880be9
--- /dev/null
+++ b/admin/src/config.ts
@@ -0,0 +1,221 @@
+/**
+ * Runtime configuration, read from Vite env (import.meta.env).
+ * See .env.example for the variables and their defaults.
+ *
+ * OIDC endpoints are NOT hardcoded here. They are resolved lazily from the
+ * issuer's OIDC discovery document (see resolveEndpoints), because Authentik
+ * serves authorize/token/userinfo OUTSIDE the application-specific issuer
+ * path (issuer is /application/o//, authorize is /application/o/authorize/).
+ * Both hardcoding the production host and naively concatenating onto the
+ * issuer would break non-default issuers; discovery is the only correct way.
+ */
+
+export interface AppConfig {
+ issuer: string;
+ clientId: string | undefined;
+ apiBaseUrl: string;
+ /** Resolved from the issuer's OIDC discovery document — see resolveEndpoints(). */
+ authorizeEndpoint?: string;
+ tokenEndpoint?: string;
+ userinfoEndpoint?: string;
+ endSessionEndpoint?: string;
+ jwksUri?: string;
+ redirectUri: string;
+}
+
+/** AppConfig whose OIDC endpoints have been filled in by resolveEndpoints(). */
+export type ResolvedAppConfig = AppConfig &
+ Required<
+ Pick<
+ AppConfig,
+ "authorizeEndpoint" | "tokenEndpoint" | "userinfoEndpoint" | "endSessionEndpoint"
+ >
+ >;
+
+export interface DiscoveredEndpoints {
+ issuer: string;
+ authorizeEndpoint: string;
+ tokenEndpoint: string;
+ userinfoEndpoint: string;
+ endSessionEndpoint: string;
+ jwksUri: string;
+}
+
+/** Raised when OIDC discovery fails or the configuration is unusable. */
+export class ConfigError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "ConfigError";
+ }
+}
+
+const REQUIRED_ENDPOINT_NAMES = [
+ "authorizeEndpoint",
+ "tokenEndpoint",
+ "userinfoEndpoint",
+ "endSessionEndpoint",
+] as const;
+
+export function readConfig(env: Record = import.meta.env as any): AppConfig {
+ const issuer = (
+ env.VITE_AUTHENTIK_ISSUER || "https://auth.cubecraftlabs.com/application/o/nestquest/"
+ ).replace(/\/$/, "");
+ const clientId = env.VITE_AUTHENTIK_CLIENT_ID;
+ const apiBaseUrl = env.VITE_API_BASE_URL || "https://nestquest.cubecraftlabs.com";
+
+ return {
+ issuer,
+ // No default: a missing client id must surface as a configuration error
+ // screen, not a broken redirect to Authentik.
+ clientId: clientId || undefined,
+ apiBaseUrl,
+ // authorize/token/userinfo/end-session endpoints are resolved from the
+ // issuer's discovery document at runtime — never hardcoded here.
+ redirectUri: `${window.location.origin}/auth/callback`,
+ };
+}
+
+export const config: AppConfig = readConfig();
+
+interface DiscoveryDocument {
+ issuer?: unknown;
+ authorization_endpoint?: unknown;
+ token_endpoint?: unknown;
+ userinfo_endpoint?: unknown;
+ end_session_endpoint?: unknown;
+ jwks_uri?: unknown;
+}
+
+function normalizeIssuer(issuer: string): string {
+ return issuer.replace(/\/+$/, "");
+}
+
+function nonEmptyString(value: unknown): string | undefined {
+ return typeof value === "string" && value.length > 0 ? value : undefined;
+}
+
+/** Discovery document location for an issuer, tolerant of a trailing slash. */
+export function discoveryUrlForIssuer(issuer: string): string {
+ return `${normalizeIssuer(issuer)}/.well-known/openid-configuration`;
+}
+
+async function fetchDiscoveredEndpoints(
+ issuer: string,
+ fetchImpl: typeof fetch,
+): Promise {
+ const normalizedIssuer = normalizeIssuer(issuer);
+ const discoveryUrl = `${normalizedIssuer}/.well-known/openid-configuration`;
+
+ let response: Response;
+ try {
+ response = await fetchImpl(discoveryUrl);
+ } catch (error) {
+ const reason = error instanceof Error ? error.message : String(error);
+ throw new ConfigError(
+ `OIDC discovery request to ${discoveryUrl} failed (${reason}). ` +
+ "Check VITE_AUTHENTIK_ISSUER and network reachability.",
+ );
+ }
+ if (!response.ok) {
+ throw new ConfigError(
+ `OIDC discovery failed: ${discoveryUrl} returned HTTP ${response.status}. ` +
+ "Check VITE_AUTHENTIK_ISSUER.",
+ );
+ }
+
+ let doc: DiscoveryDocument;
+ try {
+ doc = (await response.json()) as DiscoveryDocument;
+ } catch {
+ throw new ConfigError(
+ `OIDC discovery document at ${discoveryUrl} is not valid JSON. Check VITE_AUTHENTIK_ISSUER.`,
+ );
+ }
+
+ const authorizationEndpoint = nonEmptyString(doc.authorization_endpoint);
+ const tokenEndpoint = nonEmptyString(doc.token_endpoint);
+ const userinfoEndpoint = nonEmptyString(doc.userinfo_endpoint);
+ const endSessionEndpoint = nonEmptyString(doc.end_session_endpoint);
+ const jwksUri = nonEmptyString(doc.jwks_uri);
+ const discoveredIssuer = nonEmptyString(doc.issuer);
+
+ const endpointFields: Array<[string, string | undefined]> = [
+ ["authorization_endpoint", authorizationEndpoint],
+ ["token_endpoint", tokenEndpoint],
+ ["userinfo_endpoint", userinfoEndpoint],
+ ["end_session_endpoint", endSessionEndpoint],
+ ["jwks_uri", jwksUri],
+ ];
+ const missing = endpointFields.filter(([, value]) => !value).map(([name]) => name);
+ if (missing.length > 0) {
+ throw new ConfigError(
+ `OIDC discovery document at ${discoveryUrl} is missing: ${missing.join(", ")}. ` +
+ "The issuer must point at an OIDC provider that publishes these endpoints.",
+ );
+ }
+
+ if (discoveredIssuer && normalizeIssuer(discoveredIssuer) !== normalizedIssuer) {
+ throw new ConfigError(
+ `OIDC discovery issuer mismatch: ${discoveryUrl} advertises "${discoveredIssuer}" ` +
+ `but VITE_AUTHENTIK_ISSUER is "${normalizedIssuer}".`,
+ );
+ }
+
+ return {
+ issuer: normalizedIssuer,
+ authorizeEndpoint: authorizationEndpoint!,
+ tokenEndpoint: tokenEndpoint!,
+ userinfoEndpoint: userinfoEndpoint!,
+ endSessionEndpoint: endSessionEndpoint!,
+ jwksUri: jwksUri!,
+ };
+}
+
+/**
+ * Session cache: one discovery fetch per issuer per SPA session. Failed
+ * lookups are evicted so a transient network error can be retried instead of
+ * poisoning the whole session.
+ */
+const discoveryCache = new Map>();
+
+export function resolveEndpointsFromDiscovery(
+ issuer: string,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const normalizedIssuer = normalizeIssuer(issuer);
+ const cached = discoveryCache.get(normalizedIssuer);
+ if (cached) return cached;
+
+ const pending = fetchDiscoveredEndpoints(normalizedIssuer, fetchImpl);
+ discoveryCache.set(normalizedIssuer, pending);
+ pending.catch(() => {
+ discoveryCache.delete(normalizedIssuer);
+ });
+ return pending;
+}
+
+/**
+ * Return `app` with all OIDC endpoints filled in. When the config already
+ * carries them (pre-resolved config, tests) this is a no-op; otherwise the
+ * issuer's discovery document is fetched (cached for the session) and its
+ * authorization/token/userinfo/end-session/jwks endpoints are used.
+ */
+export async function resolveEndpoints(
+ app: AppConfig,
+ fetchImpl: typeof fetch = fetch,
+): Promise {
+ const missing = REQUIRED_ENDPOINT_NAMES.filter((name) => !app[name]);
+ if (missing.length === 0) {
+ return app as ResolvedAppConfig;
+ }
+
+ const discovered = await resolveEndpointsFromDiscovery(app.issuer, fetchImpl);
+ return {
+ ...app,
+ authorizeEndpoint: discovered.authorizeEndpoint,
+ tokenEndpoint: discovered.tokenEndpoint,
+ userinfoEndpoint: discovered.userinfoEndpoint,
+ endSessionEndpoint: discovered.endSessionEndpoint,
+ jwksUri: discovered.jwksUri,
+ };
+}
diff --git a/admin/src/main.tsx b/admin/src/main.tsx
index e9c7e64..35e1ff7 100644
--- a/admin/src/main.tsx
+++ b/admin/src/main.tsx
@@ -1,10 +1,10 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
-import App from "./App";
+import AppRoutes from "./AppRoutes";
import "./index.css";
createRoot(document.getElementById("root")!).render(
-
+
,
);
\ No newline at end of file