Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
174 lines
6.6 KiB
TypeScript
174 lines
6.6 KiB
TypeScript
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`,
|
|
);
|
|
});
|
|
});
|