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