Task 630c3472: Authentik OIDC PKCE login and refusal handling (#188)
This commit was merged in pull request #188.
This commit is contained in:
@@ -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
|
||||||
+48
-1
@@ -67,4 +67,51 @@
|
|||||||
.admin-tab.active {
|
.admin-tab.active {
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: var(--nq-brand-blue);
|
color: var(--nq-brand-blue);
|
||||||
}
|
}
|
||||||
|
/* 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(<AppRoutes />);
|
||||||
|
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(<AppRoutes />);
|
||||||
|
|
||||||
|
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(<AppRoutes />);
|
||||||
|
|
||||||
|
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(<AppRoutes />);
|
||||||
|
expect(screen.getByTestId("config-error-screen")).toBeTruthy();
|
||||||
|
} finally {
|
||||||
|
(config as { clientId: string | undefined }).clientId = original;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 (
|
||||||
|
<div className="admin-login" data-testid="login-screen">
|
||||||
|
<h1 className="admin-title">NestQuest Admin</h1>
|
||||||
|
<p className="admin-subline">Sign in to continue</p>
|
||||||
|
<button type="button" className="admin-login-button" onClick={onLogin}>
|
||||||
|
Sign in with Authentik
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AppRoutes() {
|
||||||
|
const [screen, setScreen] = useState<Screen>({ 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 (
|
||||||
|
<div className="admin-loading" role="status" data-testid="loading-screen">
|
||||||
|
Loading…
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "login":
|
||||||
|
return <LoginScreen onLogin={startLogin} />;
|
||||||
|
case "config-error":
|
||||||
|
return (
|
||||||
|
<div className="admin-error" role="alert" data-testid="config-error-screen">
|
||||||
|
<h1>Configuration error</h1>
|
||||||
|
<p>{screen.message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "auth-error":
|
||||||
|
return (
|
||||||
|
<div className="admin-error" role="alert" data-testid="auth-error-screen">
|
||||||
|
<h1>Sign-in failed</h1>
|
||||||
|
<p>{screen.message}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
clearAuth();
|
||||||
|
window.history.replaceState({}, "", "/");
|
||||||
|
setScreen({ kind: "loading" });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Try again
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "refusal":
|
||||||
|
return (
|
||||||
|
<div className="admin-refusal" role="alert" data-testid="refusal-screen">
|
||||||
|
<h1>Access refused</h1>
|
||||||
|
<p>{screen.message}</p>
|
||||||
|
<button type="button" onClick={() => { clearAuth(); window.history.replaceState({}, "", "/"); setScreen({ kind: "login" }); }}>
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
case "app":
|
||||||
|
return <AdminShell />;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
/**
|
||||||
|
* Minimal fetch wrapper for the NestQuest API.
|
||||||
|
*
|
||||||
|
* - Attaches `Authorization: Bearer <access_token>` 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<RequestInit, "headers"> {
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect the browser to Authentik to start a new login.
|
||||||
|
* Exported for testing injection.
|
||||||
|
*/
|
||||||
|
export async function triggerLogin(app: AppConfig = config): Promise<void> {
|
||||||
|
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<Response> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string> {
|
||||||
|
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<void> {
|
||||||
|
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<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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()}`);
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string> {
|
||||||
|
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))
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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<string, string>;
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/<slug>/, 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<string, string | undefined> = 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<DiscoveredEndpoints> {
|
||||||
|
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<string, Promise<DiscoveredEndpoints>>();
|
||||||
|
|
||||||
|
export function resolveEndpointsFromDiscovery(
|
||||||
|
issuer: string,
|
||||||
|
fetchImpl: typeof fetch = fetch,
|
||||||
|
): Promise<DiscoveredEndpoints> {
|
||||||
|
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<ResolvedAppConfig> {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-2
@@ -1,10 +1,10 @@
|
|||||||
import { StrictMode } from "react";
|
import { StrictMode } from "react";
|
||||||
import { createRoot } from "react-dom/client";
|
import { createRoot } from "react-dom/client";
|
||||||
import App from "./App";
|
import AppRoutes from "./AppRoutes";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
createRoot(document.getElementById("root")!).render(
|
createRoot(document.getElementById("root")!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<AppRoutes />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
Reference in New Issue
Block a user