@smartcrab/browser 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +202 -0
- package/dist/base64url.d.ts +12 -0
- package/dist/base64url.js +44 -0
- package/dist/client.d.ts +192 -0
- package/dist/client.js +551 -0
- package/dist/errors.d.ts +95 -0
- package/dist/errors.js +44 -0
- package/dist/http.d.ts +49 -0
- package/dist/http.js +113 -0
- package/dist/index.d.ts +28 -0
- package/dist/index.js +26 -0
- package/dist/pkce.d.ts +25 -0
- package/dist/pkce.js +21 -0
- package/dist/webauthn.d.ts +26 -0
- package/dist/webauthn.js +214 -0
- package/package.json +41 -0
- package/src/base64url.ts +47 -0
- package/src/client-me.test.ts +321 -0
- package/src/client-tokens.test.ts +378 -0
- package/src/client.test.ts +714 -0
- package/src/client.ts +1117 -0
- package/src/errors.ts +154 -0
- package/src/http.test.ts +225 -0
- package/src/http.ts +173 -0
- package/src/index.ts +68 -0
- package/src/pkce.test.ts +51 -0
- package/src/pkce.ts +43 -0
- package/src/webauthn.ts +295 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { describe, expect, test } from "vitest";
|
|
2
|
+
import { createBrowserAuthClient } from "./client.js";
|
|
3
|
+
import type { FetchPort } from "./http.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `/v1/me` self-service surface (design.md §16.2): every route is
|
|
7
|
+
* Bearer-authenticated with the in-memory token, requests match the contract
|
|
8
|
+
* shapes, and responses are schema-validated.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const BASE_URL = "https://id.example.com";
|
|
12
|
+
const ISSUER = `${BASE_URL}/e/env_abcdefghij`;
|
|
13
|
+
const CLIENT_ID = `cli_${"a".repeat(32)}`;
|
|
14
|
+
const TXN_ID = `txn_${"b".repeat(32)}`;
|
|
15
|
+
const USER_ID = `usr_${"c".repeat(32)}`;
|
|
16
|
+
const SESSION_ID = `ses_${"d".repeat(32)}`;
|
|
17
|
+
const PASSKEY_ID = `psk_${"e".repeat(32)}`;
|
|
18
|
+
const IDENTITY_ID = `idn_${"f".repeat(32)}`;
|
|
19
|
+
const AUTH_CODE = `ac_${"g".repeat(32)}`;
|
|
20
|
+
const REDIRECT_URI = "https://app.example.com/oauth/callback";
|
|
21
|
+
|
|
22
|
+
interface CapturedRequest {
|
|
23
|
+
url: string;
|
|
24
|
+
method: string;
|
|
25
|
+
headers: Record<string, string>;
|
|
26
|
+
body: unknown;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parseJson = (text: string): unknown => JSON.parse(text);
|
|
30
|
+
|
|
31
|
+
const jsonResponse = (status: number, body: unknown): Response =>
|
|
32
|
+
new Response(JSON.stringify(body), { status });
|
|
33
|
+
|
|
34
|
+
const ME_USER = {
|
|
35
|
+
user_id: USER_ID,
|
|
36
|
+
email: "user@example.com",
|
|
37
|
+
email_verified: true,
|
|
38
|
+
name: "Ada Lovelace",
|
|
39
|
+
avatar_url: null,
|
|
40
|
+
locale: "en-US",
|
|
41
|
+
timezone: "UTC",
|
|
42
|
+
status: "active",
|
|
43
|
+
created_at: "2026-08-02T08:10:00Z",
|
|
44
|
+
last_login_at: null,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const PASSKEY_SUMMARY = {
|
|
48
|
+
passkey_id: PASSKEY_ID,
|
|
49
|
+
name: "MacBook",
|
|
50
|
+
created_at: "2026-08-05T08:00:00Z",
|
|
51
|
+
last_used_at: null,
|
|
52
|
+
device_type: "multi_device",
|
|
53
|
+
backed_up: true,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
const SESSION_SUMMARY = {
|
|
57
|
+
session_id: SESSION_ID,
|
|
58
|
+
client_id: CLIENT_ID,
|
|
59
|
+
device_name: "Chrome on macOS",
|
|
60
|
+
created_at: "2026-08-01T08:00:00Z",
|
|
61
|
+
last_active_at: "2026-08-05T07:00:00Z",
|
|
62
|
+
current: true,
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const IDENTITY_SUMMARY = {
|
|
66
|
+
identity_id: IDENTITY_ID,
|
|
67
|
+
provider: "google",
|
|
68
|
+
linked_at: "2026-08-01T08:00:00Z",
|
|
69
|
+
last_used_at: null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetch stub that serves the sign-in path (create/complete/token) and then
|
|
74
|
+
* routes every `/v1/me*` call to `onMe`.
|
|
75
|
+
*/
|
|
76
|
+
const createStub = (onMe: (req: CapturedRequest) => Response) => {
|
|
77
|
+
const calls: CapturedRequest[] = [];
|
|
78
|
+
const fetchPort: FetchPort = (url, init) => {
|
|
79
|
+
const captured: CapturedRequest = {
|
|
80
|
+
url,
|
|
81
|
+
method: init.method ?? "GET",
|
|
82
|
+
headers: Object.fromEntries(new Headers(init.headers).entries()),
|
|
83
|
+
body: typeof init.body === "string" ? parseJson(init.body) : undefined,
|
|
84
|
+
};
|
|
85
|
+
calls.push(captured);
|
|
86
|
+
if (url.endsWith("/v1/auth/transactions")) {
|
|
87
|
+
return Promise.resolve(
|
|
88
|
+
jsonResponse(200, {
|
|
89
|
+
transaction_id: TXN_ID,
|
|
90
|
+
expires_at: "2026-08-05T08:10:00Z",
|
|
91
|
+
available_methods: ["email_otp"],
|
|
92
|
+
}),
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
if (url.endsWith("/complete")) {
|
|
96
|
+
const state = (
|
|
97
|
+
calls.find((c) => c.url.endsWith("/v1/auth/transactions"))?.body as Record<string, unknown>
|
|
98
|
+
)["state"];
|
|
99
|
+
return Promise.resolve(
|
|
100
|
+
jsonResponse(200, { authorization_code: AUTH_CODE, state, redirect_uri: REDIRECT_URI }),
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (url.endsWith("/oauth2/token")) {
|
|
104
|
+
return Promise.resolve(
|
|
105
|
+
jsonResponse(200, {
|
|
106
|
+
access_token: "at_me",
|
|
107
|
+
token_type: "Bearer",
|
|
108
|
+
expires_in: 300,
|
|
109
|
+
scope: "openid profile email",
|
|
110
|
+
}),
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
return Promise.resolve(onMe(captured));
|
|
114
|
+
};
|
|
115
|
+
return { calls, fetchPort };
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
const createSignedInClient = async (fetchPort: FetchPort) => {
|
|
119
|
+
const client = createBrowserAuthClient({
|
|
120
|
+
baseUrl: BASE_URL,
|
|
121
|
+
clientId: CLIENT_ID,
|
|
122
|
+
redirectUri: REDIRECT_URI,
|
|
123
|
+
issuer: ISSUER,
|
|
124
|
+
fetch: fetchPort,
|
|
125
|
+
});
|
|
126
|
+
const created = await client.createTransaction();
|
|
127
|
+
if (!created.ok) throw new Error("setup failed");
|
|
128
|
+
const completed = await client.completeTransaction(created.value.transactionId);
|
|
129
|
+
if (!completed.ok) throw new Error("setup failed");
|
|
130
|
+
const exchanged = await client.exchangeAuthorizationCode({
|
|
131
|
+
code: completed.value.authorization_code,
|
|
132
|
+
state: completed.value.state,
|
|
133
|
+
});
|
|
134
|
+
if (!exchanged.ok) throw new Error("setup failed");
|
|
135
|
+
return client;
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
describe("/v1/me surface (design.md §16.2)", () => {
|
|
139
|
+
test("getMe sends the Bearer token and parses the profile", async () => {
|
|
140
|
+
const { calls, fetchPort } = createStub(() => jsonResponse(200, ME_USER));
|
|
141
|
+
const client = await createSignedInClient(fetchPort);
|
|
142
|
+
const outcome = await client.getMe();
|
|
143
|
+
expect(outcome.ok).toBe(true);
|
|
144
|
+
if (outcome.ok) expect(outcome.value.user_id).toBe(USER_ID);
|
|
145
|
+
const meCall = calls.find((c) => c.url === `${BASE_URL}/v1/me`);
|
|
146
|
+
expect(meCall?.method).toBe("GET");
|
|
147
|
+
expect(meCall?.headers["authorization"]).toBe("Bearer at_me");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("updateMe PATCHes the editable fields only (design.md §12.1: email excluded)", async () => {
|
|
151
|
+
const { calls, fetchPort } = createStub(() => jsonResponse(200, ME_USER));
|
|
152
|
+
const client = await createSignedInClient(fetchPort);
|
|
153
|
+
const outcome = await client.updateMe({ name: "Grace Hopper", locale: "ja-JP" });
|
|
154
|
+
expect(outcome.ok).toBe(true);
|
|
155
|
+
const patch = calls.find((c) => c.url === `${BASE_URL}/v1/me` && c.method === "PATCH");
|
|
156
|
+
expect(patch?.body).toEqual({ name: "Grace Hopper", locale: "ja-JP" });
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("a malformed profile is invalid_response", async () => {
|
|
160
|
+
const { fetchPort } = createStub(() => jsonResponse(200, { user_id: "nope" }));
|
|
161
|
+
const client = await createSignedInClient(fetchPort);
|
|
162
|
+
const outcome = await client.getMe();
|
|
163
|
+
expect(outcome.ok).toBe(false);
|
|
164
|
+
if (!outcome.ok) expect(outcome.error.type).toBe("invalid_response");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("passkey list / register-verify / delete hit the contract routes", async () => {
|
|
168
|
+
const { calls, fetchPort } = createStub((req) => {
|
|
169
|
+
if (req.url.endsWith("/v1/me/passkeys"))
|
|
170
|
+
return jsonResponse(200, { passkeys: [PASSKEY_SUMMARY] });
|
|
171
|
+
if (req.url.endsWith("/registration/verify"))
|
|
172
|
+
return jsonResponse(200, { passkey: PASSKEY_SUMMARY });
|
|
173
|
+
if (req.url.endsWith(`/v1/me/passkeys/${PASSKEY_ID}`)) {
|
|
174
|
+
return jsonResponse(200, { passkey_id: PASSKEY_ID, deleted: true });
|
|
175
|
+
}
|
|
176
|
+
return jsonResponse(500, {});
|
|
177
|
+
});
|
|
178
|
+
const client = await createSignedInClient(fetchPort);
|
|
179
|
+
|
|
180
|
+
const list = await client.listMyPasskeys();
|
|
181
|
+
expect(list.ok).toBe(true);
|
|
182
|
+
if (list.ok) expect(list.value.passkeys).toHaveLength(1);
|
|
183
|
+
|
|
184
|
+
const verified = await client.verifyMyPasskeyRegistration({
|
|
185
|
+
credential: {
|
|
186
|
+
id: "Y3JlZA",
|
|
187
|
+
rawId: "Y3JlZA",
|
|
188
|
+
type: "public-key",
|
|
189
|
+
response: { clientDataJSON: "Y2xpZW50", attestationObject: "YXR0ZXN0" },
|
|
190
|
+
},
|
|
191
|
+
name: "MacBook",
|
|
192
|
+
});
|
|
193
|
+
expect(verified.ok).toBe(true);
|
|
194
|
+
const verifyCall = calls.find((c) => c.url.endsWith("/v1/me/passkeys/registration/verify"));
|
|
195
|
+
expect(verifyCall?.method).toBe("POST");
|
|
196
|
+
expect((verifyCall?.body as Record<string, unknown>)["name"]).toBe("MacBook");
|
|
197
|
+
|
|
198
|
+
const deleted = await client.deleteMyPasskey(PASSKEY_ID);
|
|
199
|
+
expect(deleted.ok).toBe(true);
|
|
200
|
+
const deleteCall = calls.find((c) => c.url.endsWith(`/v1/me/passkeys/${PASSKEY_ID}`));
|
|
201
|
+
expect(deleteCall?.method).toBe("DELETE");
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("identities list / link / unlink", async () => {
|
|
205
|
+
const { calls, fetchPort } = createStub((req) => {
|
|
206
|
+
if (req.url.endsWith("/v1/me/identities"))
|
|
207
|
+
return jsonResponse(200, { identities: [IDENTITY_SUMMARY] });
|
|
208
|
+
if (req.url.endsWith("/link")) {
|
|
209
|
+
return jsonResponse(200, {
|
|
210
|
+
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth?x=1",
|
|
211
|
+
});
|
|
212
|
+
}
|
|
213
|
+
return jsonResponse(200, { identity_id: IDENTITY_ID, unlinked: true });
|
|
214
|
+
});
|
|
215
|
+
const client = await createSignedInClient(fetchPort);
|
|
216
|
+
|
|
217
|
+
const list = await client.listMyIdentities();
|
|
218
|
+
expect(list.ok).toBe(true);
|
|
219
|
+
if (list.ok) expect(list.value.identities[0]?.provider).toBe("google");
|
|
220
|
+
|
|
221
|
+
const linked = await client.linkMyIdentity("google", {
|
|
222
|
+
redirect_uri: "https://app.example.com/settings",
|
|
223
|
+
});
|
|
224
|
+
expect(linked.ok).toBe(true);
|
|
225
|
+
const linkCall = calls.find((c) => c.url.endsWith("/v1/me/identities/google/link"));
|
|
226
|
+
expect(linkCall?.method).toBe("POST");
|
|
227
|
+
expect(linkCall?.body).toEqual({ redirect_uri: "https://app.example.com/settings" });
|
|
228
|
+
|
|
229
|
+
const unlinked = await client.unlinkMyIdentity(IDENTITY_ID);
|
|
230
|
+
expect(unlinked.ok).toBe(true);
|
|
231
|
+
const unlinkCall = calls.find((c) => c.url.endsWith(`/v1/me/identities/${IDENTITY_ID}`));
|
|
232
|
+
expect(unlinkCall?.method).toBe("DELETE");
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("sessions list / revoke / revoke-all", async () => {
|
|
236
|
+
const { calls, fetchPort } = createStub((req) => {
|
|
237
|
+
if (req.url.endsWith("/v1/me/sessions") && req.method === "GET") {
|
|
238
|
+
return jsonResponse(200, { sessions: [SESSION_SUMMARY] });
|
|
239
|
+
}
|
|
240
|
+
if (req.url.endsWith("/v1/me/sessions") && req.method === "DELETE") {
|
|
241
|
+
return jsonResponse(200, { revoked: true });
|
|
242
|
+
}
|
|
243
|
+
return jsonResponse(200, { session_id: SESSION_ID, revoked: true });
|
|
244
|
+
});
|
|
245
|
+
const client = await createSignedInClient(fetchPort);
|
|
246
|
+
|
|
247
|
+
const list = await client.listMySessions();
|
|
248
|
+
expect(list.ok).toBe(true);
|
|
249
|
+
if (list.ok) expect(list.value.sessions[0]?.current).toBe(true);
|
|
250
|
+
|
|
251
|
+
const revoked = await client.revokeMySession(SESSION_ID);
|
|
252
|
+
expect(revoked.ok).toBe(true);
|
|
253
|
+
const revokeCall = calls.find((c) => c.url.endsWith(`/v1/me/sessions/${SESSION_ID}`));
|
|
254
|
+
expect(revokeCall?.method).toBe("DELETE");
|
|
255
|
+
|
|
256
|
+
const revokedAll = await client.revokeAllMySessions();
|
|
257
|
+
expect(revokedAll.ok).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("email change: start → verify-old → verify-new (design.md §25)", async () => {
|
|
261
|
+
const { calls, fetchPort } = createStub((req) => {
|
|
262
|
+
if (req.url.endsWith("/email/change/start")) {
|
|
263
|
+
return jsonResponse(200, { challenge_id: "chal_old", expires_at: "2026-08-05T08:05:00Z" });
|
|
264
|
+
}
|
|
265
|
+
if (req.url.endsWith("/email/change/verify-old")) {
|
|
266
|
+
return jsonResponse(200, { challenge_id: "chal_new", expires_at: "2026-08-05T08:06:00Z" });
|
|
267
|
+
}
|
|
268
|
+
return jsonResponse(200, { email: "new@example.com", email_verified: true });
|
|
269
|
+
});
|
|
270
|
+
const client = await createSignedInClient(fetchPort);
|
|
271
|
+
|
|
272
|
+
const started = await client.startMyEmailChange({ new_email: "new@example.com" });
|
|
273
|
+
expect(started.ok).toBe(true);
|
|
274
|
+
if (started.ok) expect(started.value.challenge_id).toBe("chal_old");
|
|
275
|
+
const startCall = calls.find((c) => c.url.endsWith("/email/change/start"));
|
|
276
|
+
expect(startCall?.body).toEqual({ new_email: "new@example.com" });
|
|
277
|
+
|
|
278
|
+
const verifiedOld = await client.verifyMyEmailChangeOld({
|
|
279
|
+
challenge_id: "chal_old",
|
|
280
|
+
code: "123456",
|
|
281
|
+
});
|
|
282
|
+
expect(verifiedOld.ok).toBe(true);
|
|
283
|
+
if (verifiedOld.ok) expect(verifiedOld.value.challenge_id).toBe("chal_new");
|
|
284
|
+
|
|
285
|
+
const verifiedNew = await client.verifyMyEmailChangeNew({
|
|
286
|
+
challenge_id: "chal_new",
|
|
287
|
+
code: "654321",
|
|
288
|
+
});
|
|
289
|
+
expect(verifiedNew.ok).toBe(true);
|
|
290
|
+
if (verifiedNew.ok) {
|
|
291
|
+
expect(verifiedNew.value.email).toBe("new@example.com");
|
|
292
|
+
expect(verifiedNew.value.email_verified).toBe(true);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
test("deleteMyAccount issues DELETE /v1/me and parses pending_deletion", async () => {
|
|
297
|
+
const { calls, fetchPort } = createStub(() =>
|
|
298
|
+
jsonResponse(200, { user_id: USER_ID, status: "pending_deletion" }),
|
|
299
|
+
);
|
|
300
|
+
const client = await createSignedInClient(fetchPort);
|
|
301
|
+
const outcome = await client.deleteMyAccount();
|
|
302
|
+
expect(outcome.ok).toBe(true);
|
|
303
|
+
if (outcome.ok) expect(outcome.value.status).toBe("pending_deletion");
|
|
304
|
+
const deleteCall = calls.find((c) => c.url === `${BASE_URL}/v1/me` && c.method === "DELETE");
|
|
305
|
+
expect(deleteCall).toBeDefined();
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
test("me calls without a session resolve not_authenticated without any HTTP call", async () => {
|
|
309
|
+
const { calls, fetchPort } = createStub(() => jsonResponse(200, ME_USER));
|
|
310
|
+
const client = createBrowserAuthClient({
|
|
311
|
+
baseUrl: BASE_URL,
|
|
312
|
+
clientId: CLIENT_ID,
|
|
313
|
+
redirectUri: REDIRECT_URI,
|
|
314
|
+
issuer: ISSUER,
|
|
315
|
+
fetch: fetchPort,
|
|
316
|
+
});
|
|
317
|
+
const outcome = await client.getMe();
|
|
318
|
+
expect(outcome).toEqual({ ok: false, error: { type: "not_authenticated" } });
|
|
319
|
+
expect(calls).toHaveLength(0);
|
|
320
|
+
});
|
|
321
|
+
});
|
|
@@ -0,0 +1,378 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import { createBrowserAuthClient } from "./client.js";
|
|
3
|
+
import type { BrowserAuthClient, RefreshTokenStoragePort } from "./client.js";
|
|
4
|
+
import type { FetchPort } from "./http.js";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Token lifecycle (design.md §22.3, §21.7):
|
|
8
|
+
* - access and refresh tokens stay in memory by default; only transient PKCE callback state uses sessionStorage
|
|
9
|
+
* - refresh persistence is opt-in; rotation replaces the stored value
|
|
10
|
+
* - concurrent refresh is deduped to one network call
|
|
11
|
+
* - invalid_grant wipes the local session; transient failures do not
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const BASE_URL = "https://id.example.com";
|
|
15
|
+
const ISSUER = `${BASE_URL}/e/env_abcdefghij`;
|
|
16
|
+
const CLIENT_ID = `cli_${"a".repeat(32)}`;
|
|
17
|
+
const TXN_ID = `txn_${"b".repeat(32)}`;
|
|
18
|
+
const AUTH_CODE = `ac_${"g".repeat(32)}`;
|
|
19
|
+
const REDIRECT_URI = "https://app.example.com/oauth/callback";
|
|
20
|
+
|
|
21
|
+
interface CapturedRequest {
|
|
22
|
+
url: string;
|
|
23
|
+
method: string;
|
|
24
|
+
body: unknown;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const parseJson = (text: string): unknown => JSON.parse(text);
|
|
28
|
+
|
|
29
|
+
const jsonResponse = (status: number, body: unknown, headers?: Record<string, string>): Response =>
|
|
30
|
+
new Response(JSON.stringify(body), { status, ...(headers !== undefined ? { headers } : {}) });
|
|
31
|
+
|
|
32
|
+
const tokenBody = (overrides?: Record<string, unknown>) => ({
|
|
33
|
+
access_token: "at_1",
|
|
34
|
+
token_type: "Bearer",
|
|
35
|
+
expires_in: 300,
|
|
36
|
+
scope: "openid profile email offline_access",
|
|
37
|
+
...overrides,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
interface Stub {
|
|
41
|
+
calls: CapturedRequest[];
|
|
42
|
+
fetchPort: FetchPort;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* A fetch stub covering transaction create/complete; everything else (token,
|
|
47
|
+
* revocation) is delegated to `onApi`. Self-referential so the `/complete`
|
|
48
|
+
* handler can echo back the `state` issued at create time.
|
|
49
|
+
*/
|
|
50
|
+
const createStub = (onApi: (req: CapturedRequest) => Response | Promise<Response>): Stub => {
|
|
51
|
+
const stub: Stub = {
|
|
52
|
+
calls: [],
|
|
53
|
+
fetchPort: (url, init) => {
|
|
54
|
+
const captured: CapturedRequest = {
|
|
55
|
+
url,
|
|
56
|
+
method: init.method ?? "GET",
|
|
57
|
+
body: typeof init.body === "string" ? parseJson(init.body) : undefined,
|
|
58
|
+
};
|
|
59
|
+
stub.calls.push(captured);
|
|
60
|
+
if (url.endsWith("/v1/auth/transactions")) {
|
|
61
|
+
return Promise.resolve(
|
|
62
|
+
jsonResponse(200, {
|
|
63
|
+
transaction_id: TXN_ID,
|
|
64
|
+
expires_at: "2026-08-05T08:10:00Z",
|
|
65
|
+
available_methods: ["email_otp"],
|
|
66
|
+
}),
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
if (url.endsWith("/complete")) {
|
|
70
|
+
const createCall = stub.calls.find((c) => c.url.endsWith("/v1/auth/transactions"));
|
|
71
|
+
const state = (createCall?.body as Record<string, unknown>)["state"];
|
|
72
|
+
return Promise.resolve(
|
|
73
|
+
jsonResponse(200, { authorization_code: AUTH_CODE, state, redirect_uri: REDIRECT_URI }),
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
return Promise.resolve(onApi(captured));
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
return stub;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
const createClient = (
|
|
83
|
+
fetchPort: FetchPort,
|
|
84
|
+
options?: { storage?: RefreshTokenStoragePort; now?: () => number },
|
|
85
|
+
): BrowserAuthClient =>
|
|
86
|
+
createBrowserAuthClient({
|
|
87
|
+
baseUrl: BASE_URL,
|
|
88
|
+
clientId: CLIENT_ID,
|
|
89
|
+
redirectUri: REDIRECT_URI,
|
|
90
|
+
issuer: ISSUER,
|
|
91
|
+
scope: "openid profile email offline_access",
|
|
92
|
+
fetch: fetchPort,
|
|
93
|
+
...(options?.storage !== undefined ? { refreshTokenStorage: options.storage } : {}),
|
|
94
|
+
...(options?.now !== undefined ? { now: options.now } : {}),
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/** Drives create → complete → exchange so the client holds a token set. */
|
|
98
|
+
const signIn = async (client: BrowserAuthClient) => {
|
|
99
|
+
const created = await client.createTransaction();
|
|
100
|
+
if (!created.ok) throw new Error("setup failed at createTransaction");
|
|
101
|
+
const completed = await client.completeTransaction(created.value.transactionId);
|
|
102
|
+
if (!completed.ok) throw new Error("setup failed at completeTransaction");
|
|
103
|
+
const exchanged = await client.exchangeAuthorizationCode({
|
|
104
|
+
code: completed.value.authorization_code,
|
|
105
|
+
state: completed.value.state,
|
|
106
|
+
});
|
|
107
|
+
if (!exchanged.ok) throw new Error("setup failed at exchange");
|
|
108
|
+
return exchanged.value;
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
interface MemoryStorage extends RefreshTokenStoragePort {
|
|
112
|
+
saved: string[];
|
|
113
|
+
cleared: number;
|
|
114
|
+
stored: string | null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const createMemoryStorage = (): MemoryStorage => {
|
|
118
|
+
const storage: MemoryStorage = {
|
|
119
|
+
saved: [],
|
|
120
|
+
cleared: 0,
|
|
121
|
+
stored: null,
|
|
122
|
+
load() {
|
|
123
|
+
return storage.stored;
|
|
124
|
+
},
|
|
125
|
+
save(refreshToken: string) {
|
|
126
|
+
storage.saved.push(refreshToken);
|
|
127
|
+
storage.stored = refreshToken;
|
|
128
|
+
},
|
|
129
|
+
clear() {
|
|
130
|
+
storage.cleared += 1;
|
|
131
|
+
storage.stored = null;
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
return storage;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
describe("access token memory rules (design.md §22.3)", () => {
|
|
138
|
+
afterEach(() => {
|
|
139
|
+
Reflect.deleteProperty(globalThis, "localStorage");
|
|
140
|
+
Reflect.deleteProperty(globalThis, "sessionStorage");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("only transient PKCE metadata, never tokens, reaches Web Storage", async () => {
|
|
144
|
+
const localSetItem = vi.fn((_key: string, _value: string): void => undefined);
|
|
145
|
+
const sessionValues: string[] = [];
|
|
146
|
+
const sessionSetItem = vi.fn((_key: string, value: string): void => {
|
|
147
|
+
sessionValues.push(value);
|
|
148
|
+
});
|
|
149
|
+
const shim = (setItem: (key: string, value: string) => void): Storage => ({
|
|
150
|
+
length: 0,
|
|
151
|
+
clear: () => undefined,
|
|
152
|
+
getItem: () => null,
|
|
153
|
+
key: () => null,
|
|
154
|
+
removeItem: () => undefined,
|
|
155
|
+
setItem,
|
|
156
|
+
});
|
|
157
|
+
Object.defineProperty(globalThis, "localStorage", {
|
|
158
|
+
value: shim(localSetItem),
|
|
159
|
+
configurable: true,
|
|
160
|
+
});
|
|
161
|
+
Object.defineProperty(globalThis, "sessionStorage", {
|
|
162
|
+
value: shim(sessionSetItem),
|
|
163
|
+
configurable: true,
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
const stub = createStub(() => jsonResponse(200, tokenBody({ refresh_token: "rt_1" })));
|
|
167
|
+
const client = createClient(stub.fetchPort);
|
|
168
|
+
await signIn(client);
|
|
169
|
+
const token = await client.getAccessToken();
|
|
170
|
+
expect(token).toEqual({ ok: true, value: "at_1" });
|
|
171
|
+
|
|
172
|
+
expect(localSetItem).not.toHaveBeenCalled();
|
|
173
|
+
expect(sessionSetItem).toHaveBeenCalledTimes(1);
|
|
174
|
+
const pending = JSON.parse(sessionValues[0] ?? "{}") as Record<string, unknown>;
|
|
175
|
+
expect(Object.keys(pending).sort()).toEqual([
|
|
176
|
+
"codeVerifier",
|
|
177
|
+
"createdAt",
|
|
178
|
+
"nonce",
|
|
179
|
+
"redirectUri",
|
|
180
|
+
"state",
|
|
181
|
+
]);
|
|
182
|
+
expect(sessionValues.join("")).not.toContain("at_1");
|
|
183
|
+
expect(sessionValues.join("")).not.toContain("rt_1");
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("the auth snapshot exposes metadata but never raw tokens", async () => {
|
|
187
|
+
const stub = createStub(() => jsonResponse(200, tokenBody({ refresh_token: "rt_1" })));
|
|
188
|
+
const client = createClient(stub.fetchPort);
|
|
189
|
+
await signIn(client);
|
|
190
|
+
const snapshot = client.getSnapshot();
|
|
191
|
+
expect(snapshot.status).toBe("signed_in");
|
|
192
|
+
expect(snapshot.hasRefreshToken).toBe(true);
|
|
193
|
+
expect(JSON.stringify(snapshot)).not.toContain("at_1");
|
|
194
|
+
expect(JSON.stringify(snapshot)).not.toContain("rt_1");
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("subscribers observe sign-in and sign-out transitions, and unsubscribe works", async () => {
|
|
198
|
+
const stub = createStub(() => jsonResponse(200, tokenBody({ refresh_token: "rt_1" })));
|
|
199
|
+
const client = createClient(stub.fetchPort);
|
|
200
|
+
const events: string[] = [];
|
|
201
|
+
const unsubscribe = client.subscribe((snapshot) => events.push(snapshot.status));
|
|
202
|
+
|
|
203
|
+
await signIn(client);
|
|
204
|
+
await client.signOut();
|
|
205
|
+
expect(events).toEqual(["signed_in", "signed_out"]);
|
|
206
|
+
|
|
207
|
+
unsubscribe();
|
|
208
|
+
await client.signOut();
|
|
209
|
+
expect(events).toHaveLength(2);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
describe("refresh token persistence opt-in (design.md §22.3)", () => {
|
|
214
|
+
test("default: in-memory only, flow succeeds with no storage anywhere", async () => {
|
|
215
|
+
const stub = createStub(() => jsonResponse(200, tokenBody({ refresh_token: "rt_1" })));
|
|
216
|
+
const client = createClient(stub.fetchPort);
|
|
217
|
+
const session = await signIn(client);
|
|
218
|
+
expect(session.hasRefreshToken).toBe(true);
|
|
219
|
+
expect(client.getSnapshot().hasRefreshToken).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("opt-in storage receives the refresh token on exchange and on rotation", async () => {
|
|
223
|
+
const storage = createMemoryStorage();
|
|
224
|
+
const stub = createStub((req) => {
|
|
225
|
+
const body = req.body as Record<string, unknown>;
|
|
226
|
+
if (body["grant_type"] === "refresh_token") {
|
|
227
|
+
return jsonResponse(200, tokenBody({ access_token: "at_2", refresh_token: "rt_2" }));
|
|
228
|
+
}
|
|
229
|
+
return jsonResponse(200, tokenBody({ refresh_token: "rt_1" }));
|
|
230
|
+
});
|
|
231
|
+
const client = createClient(stub.fetchPort, { storage });
|
|
232
|
+
await signIn(client);
|
|
233
|
+
expect(storage.saved).toEqual(["rt_1"]);
|
|
234
|
+
|
|
235
|
+
const refreshed = await client.refreshSession();
|
|
236
|
+
expect(refreshed.ok).toBe(true);
|
|
237
|
+
expect(storage.saved).toEqual(["rt_1", "rt_2"]);
|
|
238
|
+
|
|
239
|
+
// The refresh grant used the previous (now-rotated-out) token.
|
|
240
|
+
const refreshCall = stub.calls.find(
|
|
241
|
+
(c) => (c.body as Record<string, unknown> | undefined)?.["grant_type"] === "refresh_token",
|
|
242
|
+
);
|
|
243
|
+
expect((refreshCall?.body as Record<string, unknown>)["refresh_token"]).toBe("rt_1");
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
test("restoreSession loads the persisted token and rotates it into memory", async () => {
|
|
247
|
+
const storage = createMemoryStorage();
|
|
248
|
+
storage.stored = "rt_persisted";
|
|
249
|
+
const stub = createStub((req) => {
|
|
250
|
+
const body = req.body as Record<string, unknown>;
|
|
251
|
+
expect(body["grant_type"]).toBe("refresh_token");
|
|
252
|
+
expect(body["refresh_token"]).toBe("rt_persisted");
|
|
253
|
+
return jsonResponse(
|
|
254
|
+
200,
|
|
255
|
+
tokenBody({ access_token: "at_restored", refresh_token: "rt_fresh" }),
|
|
256
|
+
);
|
|
257
|
+
});
|
|
258
|
+
const client = createClient(stub.fetchPort, { storage });
|
|
259
|
+
const restored = await client.restoreSession();
|
|
260
|
+
expect(restored.ok).toBe(true);
|
|
261
|
+
const token = await client.getAccessToken();
|
|
262
|
+
expect(token).toEqual({ ok: true, value: "at_restored" });
|
|
263
|
+
expect(storage.stored).toBe("rt_fresh");
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
test("restoreSession without opt-in storage resolves not_authenticated", async () => {
|
|
267
|
+
const client = createClient(createStub(() => jsonResponse(200, {})).fetchPort);
|
|
268
|
+
const outcome = await client.restoreSession();
|
|
269
|
+
expect(outcome).toEqual({ ok: false, error: { type: "not_authenticated" } });
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("restoreSession with an empty store resolves not_authenticated", async () => {
|
|
273
|
+
const storage = createMemoryStorage();
|
|
274
|
+
const client = createClient(createStub(() => jsonResponse(200, {})).fetchPort, { storage });
|
|
275
|
+
const outcome = await client.restoreSession();
|
|
276
|
+
expect(outcome).toEqual({ ok: false, error: { type: "not_authenticated" } });
|
|
277
|
+
});
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
describe("refresh semantics (design.md §21.7, §32.1)", () => {
|
|
281
|
+
test("concurrent getAccessToken on an expired token dedupes to one refresh request", async () => {
|
|
282
|
+
let nowMs = 1_000_000;
|
|
283
|
+
let refreshCalls = 0;
|
|
284
|
+
const stub = createStub((req) => {
|
|
285
|
+
const body = req.body as Record<string, unknown>;
|
|
286
|
+
if (body["grant_type"] === "refresh_token") {
|
|
287
|
+
refreshCalls += 1;
|
|
288
|
+
return jsonResponse(
|
|
289
|
+
200,
|
|
290
|
+
tokenBody({ access_token: "at_refreshed", refresh_token: "rt_2" }),
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
return jsonResponse(200, tokenBody({ refresh_token: "rt_1" }));
|
|
294
|
+
});
|
|
295
|
+
const client = createClient(stub.fetchPort, { now: () => nowMs });
|
|
296
|
+
await signIn(client);
|
|
297
|
+
|
|
298
|
+
nowMs += 400_000; // access token (300s TTL) is now expired
|
|
299
|
+
const [a, b, c] = await Promise.all([
|
|
300
|
+
client.getAccessToken(),
|
|
301
|
+
client.getAccessToken(),
|
|
302
|
+
client.getAccessToken(),
|
|
303
|
+
]);
|
|
304
|
+
expect(a).toEqual({ ok: true, value: "at_refreshed" });
|
|
305
|
+
expect(b).toEqual({ ok: true, value: "at_refreshed" });
|
|
306
|
+
expect(c).toEqual({ ok: true, value: "at_refreshed" });
|
|
307
|
+
expect(refreshCalls).toBe(1);
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("invalid_grant on refresh wipes the local session and the opt-in store", async () => {
|
|
311
|
+
const storage = createMemoryStorage();
|
|
312
|
+
const stub = createStub((req) => {
|
|
313
|
+
const body = req.body as Record<string, unknown>;
|
|
314
|
+
if (body["grant_type"] === "refresh_token") {
|
|
315
|
+
return jsonResponse(400, {
|
|
316
|
+
error: "invalid_grant",
|
|
317
|
+
error_description: "refresh token replayed",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
return jsonResponse(200, tokenBody({ refresh_token: "rt_1" }));
|
|
321
|
+
});
|
|
322
|
+
const client = createClient(stub.fetchPort, { storage });
|
|
323
|
+
await signIn(client);
|
|
324
|
+
expect(storage.stored).toBe("rt_1");
|
|
325
|
+
|
|
326
|
+
const refreshed = await client.refreshSession();
|
|
327
|
+
expect(refreshed.ok).toBe(false);
|
|
328
|
+
if (!refreshed.ok) {
|
|
329
|
+
expect(refreshed.error).toMatchObject({ type: "oauth_error", code: "invalid_grant" });
|
|
330
|
+
}
|
|
331
|
+
expect(client.getSnapshot().status).toBe("signed_out");
|
|
332
|
+
expect(storage.cleared).toBe(1);
|
|
333
|
+
expect(storage.stored).toBeNull();
|
|
334
|
+
const token = await client.getAccessToken();
|
|
335
|
+
expect(token).toEqual({ ok: false, error: { type: "not_authenticated" } });
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test("a transient network failure on refresh keeps the session", async () => {
|
|
339
|
+
const stub = createStub((req) => {
|
|
340
|
+
const body = req.body as Record<string, unknown>;
|
|
341
|
+
if (body["grant_type"] === "refresh_token") {
|
|
342
|
+
return Promise.reject(new TypeError("Failed to fetch"));
|
|
343
|
+
}
|
|
344
|
+
return jsonResponse(200, tokenBody({ refresh_token: "rt_1" }));
|
|
345
|
+
});
|
|
346
|
+
const client = createClient(stub.fetchPort);
|
|
347
|
+
await signIn(client);
|
|
348
|
+
const refreshed = await client.refreshSession();
|
|
349
|
+
expect(refreshed.ok).toBe(false);
|
|
350
|
+
if (!refreshed.ok) expect(refreshed.error.type).toBe("network_error");
|
|
351
|
+
// Session survives transient failures (design.md §21.7).
|
|
352
|
+
expect(client.getSnapshot().status).toBe("signed_in");
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
test("signOut revokes the refresh token best-effort and clears memory", async () => {
|
|
356
|
+
const stub = createStub((req) => {
|
|
357
|
+
const url = req.url;
|
|
358
|
+
if (url.endsWith("/oauth2/revoke")) return new Response("", { status: 200 });
|
|
359
|
+
return jsonResponse(200, tokenBody({ refresh_token: "rt_1" }));
|
|
360
|
+
});
|
|
361
|
+
const client = createClient(stub.fetchPort);
|
|
362
|
+
await signIn(client);
|
|
363
|
+
const signedOut = await client.signOut();
|
|
364
|
+
expect(signedOut).toEqual({ ok: true, value: { signedOut: true } });
|
|
365
|
+
expect(client.getSnapshot().status).toBe("signed_out");
|
|
366
|
+
|
|
367
|
+
const revokeCall = stub.calls.find((c) => c.url.endsWith("/oauth2/revoke"));
|
|
368
|
+
expect(revokeCall).toBeDefined();
|
|
369
|
+
expect(revokeCall?.body).toMatchObject({
|
|
370
|
+
token: "rt_1",
|
|
371
|
+
token_type_hint: "refresh_token",
|
|
372
|
+
client_id: CLIENT_ID,
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const token = await client.getAccessToken();
|
|
376
|
+
expect(token).toEqual({ ok: false, error: { type: "not_authenticated" } });
|
|
377
|
+
});
|
|
378
|
+
});
|