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..5a251b2 --- /dev/null +++ b/admin/src/AppRoutes.tsx @@ -0,0 +1,179 @@ +/** + * 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 { 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 { + const response = await apiFetch("/api/v1/children", {}, config); + if (response.status === 403) { + setScreen({ + kind: "refusal", + message: "Your account is not in the nestquest-admins group.", + }); + return; + } + setScreen({ kind: "app" }); + } catch (error) { + 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..4d62824 --- /dev/null +++ b/admin/src/api/client.test.ts @@ -0,0 +1,75 @@ +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"); + }); +}); diff --git a/admin/src/api/client.ts b/admin/src/api/client.ts new file mode 100644 index 0000000..f65bfd3 --- /dev/null +++ b/admin/src/api/client.ts @@ -0,0 +1,90 @@ +/** + * 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; + throw new ApiUnauthorizedError(); + } + 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..3175367 --- /dev/null +++ b/admin/src/auth/oidc.test.ts @@ -0,0 +1,136 @@ +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", () => { + 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" }); + + 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(); + }); +}); diff --git a/admin/src/auth/oidc.ts b/admin/src/auth/oidc.ts new file mode 100644 index 0000000..76330d1 --- /dev/null +++ b/admin/src/auth/oidc.ts @@ -0,0 +1,212 @@ +/** + * 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, type AppConfig } 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. + */ +export async function buildAuthorizeUrl(app: AppConfig = config): Promise { + if (!app.clientId) { + throw new AuthError( + "Missing VITE_AUTHENTIK_CLIENT_ID configuration. Set it in your .env before building the admin PWA.", + ); + } + 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(app.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 body = new URLSearchParams({ + grant_type: "authorization_code", + code, + redirect_uri: app.redirectUri, + client_id: app.clientId, + code_verifier: codeVerifier, + }); + + const response = await fetchImpl(app.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 body = new URLSearchParams({ + grant_type: "refresh_token", + refresh_token: refreshToken, + client_id: app.clientId, + }); + + const response = await fetchImpl(app.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 function logout(app: AppConfig = config): void { + 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(`${app.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.ts b/admin/src/config.ts new file mode 100644 index 0000000..5e9309c --- /dev/null +++ b/admin/src/config.ts @@ -0,0 +1,38 @@ +/** + * Runtime configuration, read from Vite env (import.meta.env). + * See .env.example for the variables and their defaults. + */ + +export interface AppConfig { + issuer: string; + clientId: string | undefined; + apiBaseUrl: string; + authorizeEndpoint: string; + tokenEndpoint: string; + userinfoEndpoint: string; + endSessionEndpoint: string; + redirectUri: string; +} + +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, + authorizeEndpoint: "https://auth.cubecraftlabs.com/application/o/authorize/", + tokenEndpoint: "https://auth.cubecraftlabs.com/application/o/token/", + userinfoEndpoint: "https://auth.cubecraftlabs.com/application/o/userinfo/", + endSessionEndpoint: `${issuer}/end-session/`, + redirectUri: `${window.location.origin}/auth/callback`, + }; +} + +export const config: AppConfig = readConfig(); 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