this repo has no description
1import { beforeEach, describe, expect, it, vi } from "vitest";
2import {
3 generateCodeChallenge,
4 generateCodeVerifier,
5 generateState,
6 saveOAuthState,
7 checkForOAuthCallback,
8 clearOAuthCallbackParams,
9} from "../lib/oauth";
10
11describe("OAuth utilities", () => {
12 beforeEach(() => {
13 sessionStorage.clear();
14 vi.restoreAllMocks();
15 });
16
17 describe("generateState", () => {
18 it("generates a 64-character hex string", () => {
19 const state = generateState();
20 expect(state).toMatch(/^[0-9a-f]{64}$/);
21 });
22
23 it("generates unique values", () => {
24 const states = new Set(Array.from({ length: 100 }, () => generateState()));
25 expect(states.size).toBe(100);
26 });
27 });
28
29 describe("generateCodeVerifier", () => {
30 it("generates a 64-character hex string", () => {
31 const verifier = generateCodeVerifier();
32 expect(verifier).toMatch(/^[0-9a-f]{64}$/);
33 });
34
35 it("generates unique values", () => {
36 const verifiers = new Set(
37 Array.from({ length: 100 }, () => generateCodeVerifier()),
38 );
39 expect(verifiers.size).toBe(100);
40 });
41 });
42
43 describe("generateCodeChallenge", () => {
44 it("generates a base64url-encoded SHA-256 hash", async () => {
45 const verifier = "test-verifier-12345";
46 const challenge = await generateCodeChallenge(verifier);
47
48 expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/);
49 expect(challenge).not.toContain("+");
50 expect(challenge).not.toContain("/");
51 expect(challenge).not.toContain("=");
52 });
53
54 it("produces consistent output for same input", async () => {
55 const verifier = "consistent-test-verifier";
56 const challenge1 = await generateCodeChallenge(verifier);
57 const challenge2 = await generateCodeChallenge(verifier);
58
59 expect(challenge1).toBe(challenge2);
60 });
61
62 it("produces different output for different inputs", async () => {
63 const challenge1 = await generateCodeChallenge("verifier-1");
64 const challenge2 = await generateCodeChallenge("verifier-2");
65
66 expect(challenge1).not.toBe(challenge2);
67 });
68
69 it("produces correct S256 challenge", async () => {
70 const challenge = await generateCodeChallenge("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk");
71 expect(challenge).toBe("E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM");
72 });
73 });
74
75 describe("saveOAuthState", () => {
76 it("stores state and verifier in sessionStorage", () => {
77 saveOAuthState({ state: "test-state", codeVerifier: "test-verifier" });
78
79 expect(sessionStorage.getItem("tranquil_pds_oauth_state")).toBe(
80 "test-state",
81 );
82 expect(sessionStorage.getItem("tranquil_pds_oauth_verifier")).toBe(
83 "test-verifier",
84 );
85 });
86 });
87
88 describe("checkForOAuthCallback", () => {
89 it("returns null when no code/state in URL", () => {
90 Object.defineProperty(globalThis.location, "search", {
91 value: "",
92 writable: true,
93 configurable: true,
94 });
95 Object.defineProperty(globalThis.location, "pathname", {
96 value: "/app/",
97 writable: true,
98 configurable: true,
99 });
100
101 expect(checkForOAuthCallback()).toBeNull();
102 });
103
104 it("returns code and state when present in URL", () => {
105 Object.defineProperty(globalThis.location, "search", {
106 value: "?code=auth-code-123&state=state-456",
107 writable: true,
108 configurable: true,
109 });
110 Object.defineProperty(globalThis.location, "pathname", {
111 value: "/app/",
112 writable: true,
113 configurable: true,
114 });
115
116 const result = checkForOAuthCallback();
117 expect(result).toEqual({ code: "auth-code-123", state: "state-456" });
118 });
119
120 it("returns null on migrate path even with code/state", () => {
121 Object.defineProperty(globalThis.location, "search", {
122 value: "?code=auth-code-123&state=state-456",
123 writable: true,
124 configurable: true,
125 });
126 Object.defineProperty(globalThis.location, "pathname", {
127 value: "/app/migrate",
128 writable: true,
129 configurable: true,
130 });
131
132 expect(checkForOAuthCallback()).toBeNull();
133 });
134
135 it("returns null when only code is present", () => {
136 Object.defineProperty(globalThis.location, "search", {
137 value: "?code=auth-code-123",
138 writable: true,
139 configurable: true,
140 });
141 Object.defineProperty(globalThis.location, "pathname", {
142 value: "/app/",
143 writable: true,
144 configurable: true,
145 });
146
147 expect(checkForOAuthCallback()).toBeNull();
148 });
149
150 it("returns null when only state is present", () => {
151 Object.defineProperty(globalThis.location, "search", {
152 value: "?state=state-456",
153 writable: true,
154 configurable: true,
155 });
156 Object.defineProperty(globalThis.location, "pathname", {
157 value: "/app/",
158 writable: true,
159 configurable: true,
160 });
161
162 expect(checkForOAuthCallback()).toBeNull();
163 });
164 });
165
166 describe("clearOAuthCallbackParams", () => {
167 it("removes query params from URL", () => {
168 const replaceStateSpy = vi.spyOn(globalThis.history, "replaceState");
169
170 Object.defineProperty(globalThis.location, "href", {
171 value: "http://localhost:3000/app/?code=123&state=456",
172 writable: true,
173 configurable: true,
174 });
175
176 clearOAuthCallbackParams();
177
178 expect(replaceStateSpy).toHaveBeenCalled();
179 const callArgs = replaceStateSpy.mock.calls[0];
180 expect(callArgs[0]).toEqual({});
181 expect(callArgs[1]).toBe("");
182 const urlString = callArgs[2] as string;
183 expect(urlString).toBe("http://localhost:3000/app/");
184 expect(urlString).not.toContain("?");
185 expect(urlString).not.toContain("code=");
186 expect(urlString).not.toContain("state=");
187 });
188 });
189});
190
191describe("DPoP proof generation", () => {
192 it("base64url encoding produces valid output", async () => {
193 const testData = new Uint8Array([72, 101, 108, 108, 111]);
194 const buffer = testData.buffer;
195
196 const binary = Array.from(testData, (byte) => String.fromCharCode(byte)).join("");
197 const base64url = btoa(binary)
198 .replace(/\+/g, "-")
199 .replace(/\//g, "_")
200 .replace(/=+$/, "");
201
202 expect(base64url).toBe("SGVsbG8");
203 expect(base64url).not.toContain("+");
204 expect(base64url).not.toContain("/");
205 expect(base64url).not.toContain("=");
206 });
207
208 it("JWK thumbprint uses correct key ordering for EC keys", () => {
209 const jwk = {
210 kty: "EC",
211 crv: "P-256",
212 x: "test-x",
213 y: "test-y",
214 };
215
216 const canonical = JSON.stringify({
217 crv: jwk.crv,
218 kty: jwk.kty,
219 x: jwk.x,
220 y: jwk.y,
221 });
222
223 expect(canonical).toBe('{"crv":"P-256","kty":"EC","x":"test-x","y":"test-y"}');
224
225 const keys = Object.keys(JSON.parse(canonical));
226 expect(keys).toEqual(["crv", "kty", "x", "y"]);
227
228 for (let i = 1; i < keys.length; i++) {
229 expect(keys[i - 1] < keys[i]).toBe(true);
230 }
231 });
232});