@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,714 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test, vi } from "vitest";
|
|
2
|
+
import type {
|
|
3
|
+
PasskeyAuthenticationCredential,
|
|
4
|
+
PasskeyRegistrationCredential,
|
|
5
|
+
} from "@smartcrab/contracts-public";
|
|
6
|
+
import { ok } from "@smartcrab/contracts-public";
|
|
7
|
+
import type { Outcome } from "@smartcrab/contracts-public";
|
|
8
|
+
import { base64UrlEncodeBytes } from "./base64url.js";
|
|
9
|
+
import { createBrowserAuthClient } from "./client.js";
|
|
10
|
+
import type { RedirectPort } from "./client.js";
|
|
11
|
+
import type { WebAuthnFailureError } from "./errors.js";
|
|
12
|
+
import type { FetchPort } from "./http.js";
|
|
13
|
+
import type { WebAuthnBrowserPort } from "./webauthn.js";
|
|
14
|
+
|
|
15
|
+
afterEach(() => {
|
|
16
|
+
vi.unstubAllGlobals();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Client behavior against design.md §16's exact request/response shapes.
|
|
21
|
+
* Every test asserts both the outgoing request (method, path, body) and that
|
|
22
|
+
* the response is parsed through the contracts-public zod schemas (malformed
|
|
23
|
+
* payloads must surface as typed `invalid_response` failures).
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const BASE_URL = "https://id.example.com";
|
|
27
|
+
const ISSUER = `${BASE_URL}/e/env_abcdefghij`;
|
|
28
|
+
const CLIENT_ID = `cli_${"a".repeat(32)}`;
|
|
29
|
+
const TXN_ID = `txn_${"b".repeat(32)}`;
|
|
30
|
+
const PASSKEY_ID = `psk_${"e".repeat(32)}`;
|
|
31
|
+
const AUTH_CODE = `ac_${"g".repeat(32)}`;
|
|
32
|
+
const REDIRECT_URI = "https://app.example.com/oauth/callback";
|
|
33
|
+
|
|
34
|
+
interface CapturedRequest {
|
|
35
|
+
url: string;
|
|
36
|
+
method: string;
|
|
37
|
+
headers: Record<string, string>;
|
|
38
|
+
body: unknown;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const parseJson = (text: string): unknown => JSON.parse(text);
|
|
42
|
+
|
|
43
|
+
const createFetchStub = (handler: (req: CapturedRequest) => Response | Promise<Response>) => {
|
|
44
|
+
const calls: CapturedRequest[] = [];
|
|
45
|
+
const fetchPort: FetchPort = (url, init) => {
|
|
46
|
+
const captured: CapturedRequest = {
|
|
47
|
+
url,
|
|
48
|
+
method: init.method ?? "GET",
|
|
49
|
+
headers: Object.fromEntries(new Headers(init.headers).entries()),
|
|
50
|
+
body: typeof init.body === "string" ? parseJson(init.body) : undefined,
|
|
51
|
+
};
|
|
52
|
+
calls.push(captured);
|
|
53
|
+
return Promise.resolve(handler(captured));
|
|
54
|
+
};
|
|
55
|
+
return { calls, fetchPort };
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const jsonResponse = (status: number, body: unknown, headers?: Record<string, string>): Response =>
|
|
59
|
+
new Response(JSON.stringify(body), { status, ...(headers !== undefined ? { headers } : {}) });
|
|
60
|
+
|
|
61
|
+
const CREATE_RESPONSE = {
|
|
62
|
+
transaction_id: TXN_ID,
|
|
63
|
+
expires_at: "2026-08-05T08:10:00Z",
|
|
64
|
+
available_methods: ["passkey", "email_otp", "google"],
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const PUBLIC_CONFIG = {
|
|
68
|
+
issuer: ISSUER,
|
|
69
|
+
rp_id: "example.com",
|
|
70
|
+
available_methods: ["passkey", "email_otp", "google"],
|
|
71
|
+
theme: null,
|
|
72
|
+
sdk_compatibility_version: 1,
|
|
73
|
+
legal: {},
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const createTestSessionStorage = () => {
|
|
77
|
+
const entries = new Map<string, string>();
|
|
78
|
+
return {
|
|
79
|
+
entries,
|
|
80
|
+
storage: {
|
|
81
|
+
getItem: (key: string) => entries.get(key) ?? null,
|
|
82
|
+
removeItem: (key: string) => {
|
|
83
|
+
entries.delete(key);
|
|
84
|
+
},
|
|
85
|
+
setItem: (key: string, value: string) => {
|
|
86
|
+
entries.set(key, value);
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const makeClient = (
|
|
93
|
+
fetchPort: FetchPort,
|
|
94
|
+
overrides?: { redirect?: RedirectPort; webAuthn?: WebAuthnBrowserPort; now?: () => number },
|
|
95
|
+
) =>
|
|
96
|
+
createBrowserAuthClient({
|
|
97
|
+
baseUrl: BASE_URL,
|
|
98
|
+
clientId: CLIENT_ID,
|
|
99
|
+
redirectUri: REDIRECT_URI,
|
|
100
|
+
issuer: ISSUER,
|
|
101
|
+
fetch: fetchPort,
|
|
102
|
+
...(overrides?.redirect !== undefined ? { redirect: overrides.redirect } : {}),
|
|
103
|
+
...(overrides?.webAuthn !== undefined ? { webAuthn: overrides.webAuthn } : {}),
|
|
104
|
+
...(overrides?.now !== undefined ? { now: overrides.now } : {}),
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("createTransaction (design.md §16.1)", () => {
|
|
108
|
+
test("sends the exact §16.1 request shape with generated PKCE/state/nonce", async () => {
|
|
109
|
+
const { calls, fetchPort } = createFetchStub(() => jsonResponse(200, CREATE_RESPONSE));
|
|
110
|
+
const client = makeClient(fetchPort);
|
|
111
|
+
const outcome = await client.createTransaction();
|
|
112
|
+
|
|
113
|
+
expect(outcome.ok).toBe(true);
|
|
114
|
+
if (outcome.ok) {
|
|
115
|
+
expect(outcome.value.transactionId).toBe(TXN_ID);
|
|
116
|
+
expect(outcome.value.availableMethods).toEqual(["passkey", "email_otp", "google"]);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const request = calls[0];
|
|
120
|
+
if (request === undefined) throw new Error("expected a request");
|
|
121
|
+
expect(request.method).toBe("POST");
|
|
122
|
+
expect(request.url).toBe(`${BASE_URL}/v1/auth/transactions`);
|
|
123
|
+
expect(request.body).toMatchObject({
|
|
124
|
+
client_id: CLIENT_ID,
|
|
125
|
+
redirect_uri: REDIRECT_URI,
|
|
126
|
+
code_challenge_method: "S256",
|
|
127
|
+
scope: "openid profile email",
|
|
128
|
+
response_mode: "native_return",
|
|
129
|
+
});
|
|
130
|
+
const body = request.body as Record<string, unknown>;
|
|
131
|
+
expect(body["code_challenge"]).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
132
|
+
expect(body["state"]).toEqual(expect.any(String));
|
|
133
|
+
expect(body["nonce"]).toEqual(expect.any(String));
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("forwards explicit scope/resource/state", async () => {
|
|
137
|
+
const { calls, fetchPort } = createFetchStub(() => jsonResponse(200, CREATE_RESPONSE));
|
|
138
|
+
const client = makeClient(fetchPort);
|
|
139
|
+
await client.createTransaction({
|
|
140
|
+
scope: "openid profile email offline_access",
|
|
141
|
+
resource: ["https://api.customer.example"],
|
|
142
|
+
state: "app-state",
|
|
143
|
+
});
|
|
144
|
+
const body = calls[0]?.body as Record<string, unknown>;
|
|
145
|
+
expect(body["scope"]).toBe("openid profile email offline_access");
|
|
146
|
+
expect(body["resource"]).toEqual(["https://api.customer.example"]);
|
|
147
|
+
expect(body["state"]).toBe("app-state");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a malformed response fails as invalid_response, never trusted", async () => {
|
|
151
|
+
const { fetchPort } = createFetchStub(() => jsonResponse(200, { transaction_id: "not-a-txn" }));
|
|
152
|
+
const client = makeClient(fetchPort);
|
|
153
|
+
const outcome = await client.createTransaction();
|
|
154
|
+
expect(outcome.ok).toBe(false);
|
|
155
|
+
if (!outcome.ok) expect(outcome.error.type).toBe("invalid_response");
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("getTransaction / cancelTransaction (design.md §16.2)", () => {
|
|
160
|
+
test("getTransaction issues GET on the transaction path", async () => {
|
|
161
|
+
const { calls, fetchPort } = createFetchStub(() =>
|
|
162
|
+
jsonResponse(200, {
|
|
163
|
+
transaction_id: TXN_ID,
|
|
164
|
+
status: "challenge_issued",
|
|
165
|
+
expires_at: "2026-08-05T08:10:00Z",
|
|
166
|
+
available_methods: ["email_otp"],
|
|
167
|
+
pending_link: null,
|
|
168
|
+
}),
|
|
169
|
+
);
|
|
170
|
+
const client = makeClient(fetchPort);
|
|
171
|
+
const outcome = await client.getTransaction(TXN_ID);
|
|
172
|
+
expect(outcome.ok).toBe(true);
|
|
173
|
+
if (outcome.ok) expect(outcome.value.status).toBe("challenge_issued");
|
|
174
|
+
expect(calls[0]?.method).toBe("GET");
|
|
175
|
+
expect(calls[0]?.url).toBe(`${BASE_URL}/v1/auth/transactions/${TXN_ID}`);
|
|
176
|
+
expect(calls[0]?.body).toBeUndefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("cancelTransaction POSTs an empty body to /cancel", async () => {
|
|
180
|
+
const { calls, fetchPort } = createFetchStub(() =>
|
|
181
|
+
jsonResponse(200, { transaction_id: TXN_ID, status: "failed" }),
|
|
182
|
+
);
|
|
183
|
+
const client = makeClient(fetchPort);
|
|
184
|
+
const outcome = await client.cancelTransaction(TXN_ID);
|
|
185
|
+
expect(outcome.ok).toBe(true);
|
|
186
|
+
expect(calls[0]?.method).toBe("POST");
|
|
187
|
+
expect(calls[0]?.url).toBe(`${BASE_URL}/v1/auth/transactions/${TXN_ID}/cancel`);
|
|
188
|
+
expect(calls[0]?.body).toEqual({});
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe("email OTP (design.md §16.2, §18)", () => {
|
|
193
|
+
const CHALLENGE = { challenge_id: "chal_abc", expires_at: "2026-08-05T08:05:00Z" };
|
|
194
|
+
|
|
195
|
+
test("startEmailOtp posts {email} to /email/start", async () => {
|
|
196
|
+
const { calls, fetchPort } = createFetchStub(() => jsonResponse(200, CHALLENGE));
|
|
197
|
+
const client = makeClient(fetchPort);
|
|
198
|
+
const outcome = await client.startEmailOtp(TXN_ID, { email: "user@example.com" });
|
|
199
|
+
expect(outcome.ok).toBe(true);
|
|
200
|
+
if (outcome.ok) expect(outcome.value.challenge_id).toBe("chal_abc");
|
|
201
|
+
expect(calls[0]?.url).toBe(`${BASE_URL}/v1/auth/transactions/${TXN_ID}/email/start`);
|
|
202
|
+
expect(calls[0]?.body).toEqual({ email: "user@example.com" });
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("resendEmailOtp posts {challenge_id} to /email/resend", async () => {
|
|
206
|
+
const { calls, fetchPort } = createFetchStub(() => jsonResponse(200, CHALLENGE));
|
|
207
|
+
const client = makeClient(fetchPort);
|
|
208
|
+
await client.resendEmailOtp(TXN_ID, { challenge_id: "chal_abc" });
|
|
209
|
+
expect(calls[0]?.url).toBe(`${BASE_URL}/v1/auth/transactions/${TXN_ID}/email/resend`);
|
|
210
|
+
expect(calls[0]?.body).toEqual({ challenge_id: "chal_abc" });
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("verifyEmailOtp posts {challenge_id, code} and parses the transaction ack", async () => {
|
|
214
|
+
const { calls, fetchPort } = createFetchStub(() =>
|
|
215
|
+
jsonResponse(200, { transaction_id: TXN_ID, status: "authenticated" }),
|
|
216
|
+
);
|
|
217
|
+
const client = makeClient(fetchPort);
|
|
218
|
+
const outcome = await client.verifyEmailOtp(TXN_ID, {
|
|
219
|
+
challenge_id: "chal_abc",
|
|
220
|
+
code: "123456",
|
|
221
|
+
});
|
|
222
|
+
expect(outcome.ok).toBe(true);
|
|
223
|
+
if (outcome.ok) expect(outcome.value.status).toBe("authenticated");
|
|
224
|
+
expect(calls[0]?.url).toBe(`${BASE_URL}/v1/auth/transactions/${TXN_ID}/email/verify`);
|
|
225
|
+
expect(calls[0]?.body).toEqual({ challenge_id: "chal_abc", code: "123456" });
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("a verify response smuggling tokens is rejected (design.md §16.3)", async () => {
|
|
229
|
+
const { fetchPort } = createFetchStub(() =>
|
|
230
|
+
jsonResponse(200, { transaction_id: TXN_ID, status: "authenticated", access_token: "nope" }),
|
|
231
|
+
);
|
|
232
|
+
const client = makeClient(fetchPort);
|
|
233
|
+
const outcome = await client.verifyEmailOtp(TXN_ID, {
|
|
234
|
+
challenge_id: "chal_abc",
|
|
235
|
+
code: "123456",
|
|
236
|
+
});
|
|
237
|
+
expect(outcome.ok).toBe(false);
|
|
238
|
+
if (!outcome.ok) expect(outcome.error.type).toBe("invalid_response");
|
|
239
|
+
});
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
describe("passkeys (design.md §17)", () => {
|
|
243
|
+
const REG_OPTIONS = {
|
|
244
|
+
rp: { id: "example.com", name: "Example" },
|
|
245
|
+
user: { id: "dXNlci0x", name: "user@example.com", displayName: "User" },
|
|
246
|
+
challenge: "Y2hhbGxlbmdl",
|
|
247
|
+
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
|
248
|
+
authenticatorSelection: { residentKey: "required", userVerification: "required" },
|
|
249
|
+
attestation: "none",
|
|
250
|
+
};
|
|
251
|
+
const REG_CREDENTIAL: PasskeyRegistrationCredential = {
|
|
252
|
+
id: "Y3JlZA",
|
|
253
|
+
rawId: "Y3JlZA",
|
|
254
|
+
type: "public-key",
|
|
255
|
+
response: { clientDataJSON: "Y2xpZW50", attestationObject: "YXR0ZXN0" },
|
|
256
|
+
};
|
|
257
|
+
const PASSKEY_SUMMARY = {
|
|
258
|
+
passkey_id: PASSKEY_ID,
|
|
259
|
+
name: "MacBook",
|
|
260
|
+
created_at: "2026-08-05T08:00:00Z",
|
|
261
|
+
last_used_at: null,
|
|
262
|
+
device_type: "multi_device",
|
|
263
|
+
backed_up: true,
|
|
264
|
+
};
|
|
265
|
+
const AUTHN_OPTIONS = {
|
|
266
|
+
challenge: "Y2hhbGxlbmdl",
|
|
267
|
+
rpId: "example.com",
|
|
268
|
+
userVerification: "required",
|
|
269
|
+
};
|
|
270
|
+
const ASSERTION: PasskeyAuthenticationCredential = {
|
|
271
|
+
id: "Y3JlZA",
|
|
272
|
+
rawId: "Y3JlZA",
|
|
273
|
+
type: "public-key",
|
|
274
|
+
response: { clientDataJSON: "Y2xpZW50", authenticatorData: "YXV0aA", signature: "c2ln" },
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
const okRegistration = (): Promise<
|
|
278
|
+
Outcome<PasskeyRegistrationCredential, WebAuthnFailureError>
|
|
279
|
+
> => Promise.resolve(ok(REG_CREDENTIAL));
|
|
280
|
+
const okAssertion = (): Promise<Outcome<PasskeyAuthenticationCredential, WebAuthnFailureError>> =>
|
|
281
|
+
Promise.resolve(ok(ASSERTION));
|
|
282
|
+
|
|
283
|
+
test("startPasskeyRegistration runs options → authenticator → verify", async () => {
|
|
284
|
+
const webAuthn: WebAuthnBrowserPort = {
|
|
285
|
+
isAvailable: () => true,
|
|
286
|
+
create: vi.fn(okRegistration),
|
|
287
|
+
get: vi.fn(okAssertion),
|
|
288
|
+
};
|
|
289
|
+
const { calls, fetchPort } = createFetchStub((req) => {
|
|
290
|
+
if (req.url.endsWith("/passkeys/registration/options")) return jsonResponse(200, REG_OPTIONS);
|
|
291
|
+
return jsonResponse(200, {
|
|
292
|
+
transaction_id: TXN_ID,
|
|
293
|
+
status: "authenticated",
|
|
294
|
+
passkey: PASSKEY_SUMMARY,
|
|
295
|
+
});
|
|
296
|
+
});
|
|
297
|
+
const client = makeClient(fetchPort, { webAuthn });
|
|
298
|
+
const outcome = await client.startPasskeyRegistration(TXN_ID, { name: "MacBook" });
|
|
299
|
+
|
|
300
|
+
expect(outcome.ok).toBe(true);
|
|
301
|
+
if (outcome.ok) expect(outcome.value.passkey.passkey_id).toBe(PASSKEY_ID);
|
|
302
|
+
expect(calls.map((c) => c.url)).toEqual([
|
|
303
|
+
`${BASE_URL}/v1/auth/transactions/${TXN_ID}/passkeys/registration/options`,
|
|
304
|
+
`${BASE_URL}/v1/auth/transactions/${TXN_ID}/passkeys/registration/verify`,
|
|
305
|
+
]);
|
|
306
|
+
expect(webAuthn.create).toHaveBeenCalledWith(REG_OPTIONS);
|
|
307
|
+
expect(calls[1]?.body).toEqual({ credential: REG_CREDENTIAL, name: "MacBook" });
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
test("startPasskeyAuthentication runs the discoverable-credential ceremony", async () => {
|
|
311
|
+
const webAuthn: WebAuthnBrowserPort = {
|
|
312
|
+
isAvailable: () => true,
|
|
313
|
+
create: vi.fn(okRegistration),
|
|
314
|
+
get: vi.fn(okAssertion),
|
|
315
|
+
};
|
|
316
|
+
const { calls, fetchPort } = createFetchStub((req) => {
|
|
317
|
+
if (req.url.endsWith("/passkeys/authentication/options"))
|
|
318
|
+
return jsonResponse(200, AUTHN_OPTIONS);
|
|
319
|
+
return jsonResponse(200, { transaction_id: TXN_ID, status: "authenticated" });
|
|
320
|
+
});
|
|
321
|
+
const client = makeClient(fetchPort, { webAuthn });
|
|
322
|
+
const outcome = await client.startPasskeyAuthentication(TXN_ID);
|
|
323
|
+
|
|
324
|
+
expect(outcome.ok).toBe(true);
|
|
325
|
+
expect(calls.map((c) => c.url)).toEqual([
|
|
326
|
+
`${BASE_URL}/v1/auth/transactions/${TXN_ID}/passkeys/authentication/options`,
|
|
327
|
+
`${BASE_URL}/v1/auth/transactions/${TXN_ID}/passkeys/authentication/verify`,
|
|
328
|
+
]);
|
|
329
|
+
expect(calls[0]?.body).toEqual({});
|
|
330
|
+
expect(calls[1]?.body).toEqual({ credential: ASSERTION });
|
|
331
|
+
expect(webAuthn.get).toHaveBeenCalledWith(AUTHN_OPTIONS);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
test("authentication options accept an optional email narrowing hint", async () => {
|
|
335
|
+
const { calls, fetchPort } = createFetchStub(() => jsonResponse(200, AUTHN_OPTIONS));
|
|
336
|
+
const client = makeClient(fetchPort);
|
|
337
|
+
await client.getPasskeyAuthenticationOptions(TXN_ID, { email: "user@example.com" });
|
|
338
|
+
expect(calls[0]?.body).toEqual({ email: "user@example.com" });
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
test("unsupported WebAuthn surfaces webauthn_error without any HTTP call", async () => {
|
|
342
|
+
const webAuthn: WebAuthnBrowserPort = {
|
|
343
|
+
isAvailable: () => false,
|
|
344
|
+
create: vi.fn(okRegistration),
|
|
345
|
+
get: vi.fn(okAssertion),
|
|
346
|
+
};
|
|
347
|
+
const { calls, fetchPort } = createFetchStub(() => jsonResponse(200, REG_OPTIONS));
|
|
348
|
+
const client = makeClient(fetchPort, { webAuthn });
|
|
349
|
+
const outcome = await client.startPasskeyAuthentication(TXN_ID);
|
|
350
|
+
expect(outcome.ok).toBe(false);
|
|
351
|
+
if (!outcome.ok) {
|
|
352
|
+
expect(outcome.error).toMatchObject({ type: "webauthn_error", reason: "not_supported" });
|
|
353
|
+
}
|
|
354
|
+
expect(calls).toHaveLength(0);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test("an authenticator failure propagates without calling verify", async () => {
|
|
358
|
+
const webAuthn: WebAuthnBrowserPort = {
|
|
359
|
+
isAvailable: () => true,
|
|
360
|
+
create: vi.fn(() =>
|
|
361
|
+
Promise.resolve({
|
|
362
|
+
ok: false as const,
|
|
363
|
+
error: {
|
|
364
|
+
type: "webauthn_error" as const,
|
|
365
|
+
reason: "cancelled" as const,
|
|
366
|
+
message: "user cancelled",
|
|
367
|
+
},
|
|
368
|
+
}),
|
|
369
|
+
),
|
|
370
|
+
get: vi.fn(okAssertion),
|
|
371
|
+
};
|
|
372
|
+
const { calls, fetchPort } = createFetchStub(() => jsonResponse(200, REG_OPTIONS));
|
|
373
|
+
const client = makeClient(fetchPort, { webAuthn });
|
|
374
|
+
const outcome = await client.startPasskeyRegistration(TXN_ID);
|
|
375
|
+
expect(outcome.ok).toBe(false);
|
|
376
|
+
if (!outcome.ok) {
|
|
377
|
+
expect(outcome.error).toMatchObject({ type: "webauthn_error", reason: "cancelled" });
|
|
378
|
+
}
|
|
379
|
+
expect(calls).toHaveLength(1); // options only, verify never reached
|
|
380
|
+
});
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
describe("social sign-in (design.md §20, §22.3)", () => {
|
|
384
|
+
test("startSocialSignIn posts to /social/:provider/start and top-level redirects", async () => {
|
|
385
|
+
const assigned: string[] = [];
|
|
386
|
+
const redirect: RedirectPort = { assign: (url) => assigned.push(url) };
|
|
387
|
+
const { calls, fetchPort } = createFetchStub(() =>
|
|
388
|
+
jsonResponse(200, {
|
|
389
|
+
authorization_url: "https://accounts.google.com/o/oauth2/v2/auth?client_id=x",
|
|
390
|
+
}),
|
|
391
|
+
);
|
|
392
|
+
const client = makeClient(fetchPort, { redirect });
|
|
393
|
+
const outcome = await client.startSocialSignIn(TXN_ID, "google");
|
|
394
|
+
|
|
395
|
+
expect(outcome.ok).toBe(true);
|
|
396
|
+
expect(calls[0]?.method).toBe("POST");
|
|
397
|
+
expect(calls[0]?.url).toBe(`${BASE_URL}/v1/auth/transactions/${TXN_ID}/social/google/start`);
|
|
398
|
+
expect(calls[0]?.body).toEqual({});
|
|
399
|
+
expect(assigned).toEqual(["https://accounts.google.com/o/oauth2/v2/auth?client_id=x"]);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test("no redirect happens when the start call fails", async () => {
|
|
403
|
+
const assigned: string[] = [];
|
|
404
|
+
const redirect: RedirectPort = { assign: (url) => assigned.push(url) };
|
|
405
|
+
const { fetchPort } = createFetchStub(() =>
|
|
406
|
+
jsonResponse(404, {
|
|
407
|
+
type: "https://docs.example-auth.com/errors/not-found",
|
|
408
|
+
title: "Not Found",
|
|
409
|
+
status: 404,
|
|
410
|
+
code: "not_found",
|
|
411
|
+
}),
|
|
412
|
+
);
|
|
413
|
+
const client = makeClient(fetchPort, { redirect });
|
|
414
|
+
const outcome = await client.startSocialSignIn(TXN_ID, "google");
|
|
415
|
+
expect(outcome.ok).toBe(false);
|
|
416
|
+
if (!outcome.ok) expect(outcome.error.type).toBe("problem_details");
|
|
417
|
+
expect(assigned).toHaveLength(0);
|
|
418
|
+
});
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
describe("complete + code exchange (design.md §16.3, §15.2)", () => {
|
|
422
|
+
const setupThroughComplete = async () => {
|
|
423
|
+
const { calls, fetchPort } = createFetchStub((req) => {
|
|
424
|
+
if (req.url.endsWith("/v1/auth/transactions")) return jsonResponse(200, CREATE_RESPONSE);
|
|
425
|
+
if (req.url.endsWith("/complete")) {
|
|
426
|
+
const state = (calls[0]?.body as Record<string, unknown>)["state"];
|
|
427
|
+
return jsonResponse(200, {
|
|
428
|
+
authorization_code: AUTH_CODE,
|
|
429
|
+
state,
|
|
430
|
+
redirect_uri: REDIRECT_URI,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
return jsonResponse(200, {
|
|
434
|
+
access_token: "at_first",
|
|
435
|
+
token_type: "Bearer",
|
|
436
|
+
expires_in: 300,
|
|
437
|
+
scope: "openid profile email",
|
|
438
|
+
});
|
|
439
|
+
});
|
|
440
|
+
const client = makeClient(fetchPort);
|
|
441
|
+
const created = await client.createTransaction();
|
|
442
|
+
expect(created.ok).toBe(true);
|
|
443
|
+
if (!created.ok) throw new Error("setup failed");
|
|
444
|
+
const completed = await client.completeTransaction(created.value.transactionId);
|
|
445
|
+
expect(completed.ok).toBe(true);
|
|
446
|
+
if (!completed.ok) throw new Error("setup failed");
|
|
447
|
+
return { calls, client, created: created.value, completed: completed.value };
|
|
448
|
+
};
|
|
449
|
+
|
|
450
|
+
test("complete returns the one-time code; exchange sends verifier and stores tokens in memory", async () => {
|
|
451
|
+
const { calls, client, created, completed } = await setupThroughComplete();
|
|
452
|
+
expect(completed.authorization_code).toBe(AUTH_CODE);
|
|
453
|
+
expect(completed.state).toBe(created.state);
|
|
454
|
+
|
|
455
|
+
const exchanged = await client.exchangeAuthorizationCode({
|
|
456
|
+
code: completed.authorization_code,
|
|
457
|
+
state: completed.state,
|
|
458
|
+
});
|
|
459
|
+
expect(exchanged.ok).toBe(true);
|
|
460
|
+
if (exchanged.ok) {
|
|
461
|
+
expect(exchanged.value.scope).toBe("openid profile email");
|
|
462
|
+
expect(exchanged.value.hasRefreshToken).toBe(false);
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const tokenRequest = calls[2];
|
|
466
|
+
expect(tokenRequest?.method).toBe("POST");
|
|
467
|
+
expect(tokenRequest?.url).toBe(`${ISSUER}/oauth2/token`);
|
|
468
|
+
const body = tokenRequest?.body as Record<string, unknown>;
|
|
469
|
+
expect(body).toMatchObject({
|
|
470
|
+
grant_type: "authorization_code",
|
|
471
|
+
code: AUTH_CODE,
|
|
472
|
+
redirect_uri: REDIRECT_URI,
|
|
473
|
+
client_id: CLIENT_ID,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
// PKCE end-to-end: the verifier sent to the token endpoint must hash to
|
|
477
|
+
// the challenge sent at transaction create (RFC 7636 §4.6).
|
|
478
|
+
const createBody = calls[0]?.body as Record<string, unknown>;
|
|
479
|
+
const verifier = body["code_verifier"];
|
|
480
|
+
expect(typeof verifier).toBe("string");
|
|
481
|
+
if (typeof verifier === "string") {
|
|
482
|
+
expect(verifier).toMatch(/^[A-Za-z0-9._~-]{43,128}$/);
|
|
483
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
|
484
|
+
expect(base64UrlEncodeBytes(new Uint8Array(digest))).toBe(createBody["code_challenge"]);
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
const token = await client.getAccessToken();
|
|
488
|
+
expect(token).toEqual({ ok: true, value: "at_first" });
|
|
489
|
+
});
|
|
490
|
+
|
|
491
|
+
test("exchange with an unknown state fails closed with state_mismatch", async () => {
|
|
492
|
+
const { client } = await setupThroughComplete();
|
|
493
|
+
const outcome = await client.exchangeAuthorizationCode({
|
|
494
|
+
code: AUTH_CODE,
|
|
495
|
+
state: "forged-state",
|
|
496
|
+
});
|
|
497
|
+
expect(outcome).toEqual({ ok: false, error: { type: "state_mismatch" } });
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test("a complete response with a foreign redirect_uri is rejected (design.md §15.9)", async () => {
|
|
501
|
+
const { calls, fetchPort } = createFetchStub((req) => {
|
|
502
|
+
if (req.url.endsWith("/v1/auth/transactions")) return jsonResponse(200, CREATE_RESPONSE);
|
|
503
|
+
const state = (calls[0]?.body as Record<string, unknown>)["state"];
|
|
504
|
+
return jsonResponse(200, {
|
|
505
|
+
authorization_code: AUTH_CODE,
|
|
506
|
+
state,
|
|
507
|
+
redirect_uri: "https://evil.example.com/callback",
|
|
508
|
+
});
|
|
509
|
+
});
|
|
510
|
+
const client = makeClient(fetchPort);
|
|
511
|
+
const created = await client.createTransaction();
|
|
512
|
+
expect(created.ok).toBe(true);
|
|
513
|
+
if (!created.ok) return;
|
|
514
|
+
const outcome = await client.completeTransaction(created.value.transactionId);
|
|
515
|
+
expect(outcome.ok).toBe(false);
|
|
516
|
+
if (!outcome.ok) expect(outcome.error.type).toBe("invalid_response");
|
|
517
|
+
});
|
|
518
|
+
});
|
|
519
|
+
|
|
520
|
+
describe("hosted authorization redirect flow (design.md §22.3 reload recovery)", () => {
|
|
521
|
+
test("builds the authorize URL with PKCE and handles the callback", async () => {
|
|
522
|
+
const assigned: string[] = [];
|
|
523
|
+
const redirect: RedirectPort = { assign: (url) => assigned.push(url) };
|
|
524
|
+
const { calls, fetchPort } = createFetchStub(() =>
|
|
525
|
+
jsonResponse(200, {
|
|
526
|
+
access_token: "at_hosted",
|
|
527
|
+
token_type: "Bearer",
|
|
528
|
+
expires_in: 300,
|
|
529
|
+
scope: "openid profile email",
|
|
530
|
+
}),
|
|
531
|
+
);
|
|
532
|
+
const client = makeClient(fetchPort, { redirect });
|
|
533
|
+
|
|
534
|
+
const begun = await client.beginHostedAuthorization();
|
|
535
|
+
expect(begun.ok).toBe(true);
|
|
536
|
+
expect(assigned).toHaveLength(1);
|
|
537
|
+
const authorizeUrl = new URL(assigned[0] ?? "");
|
|
538
|
+
expect(authorizeUrl.origin + authorizeUrl.pathname).toBe(`${ISSUER}/oauth2/authorize`);
|
|
539
|
+
expect(authorizeUrl.searchParams.get("response_type")).toBe("code");
|
|
540
|
+
expect(authorizeUrl.searchParams.get("client_id")).toBe(CLIENT_ID);
|
|
541
|
+
expect(authorizeUrl.searchParams.get("redirect_uri")).toBe(REDIRECT_URI);
|
|
542
|
+
expect(authorizeUrl.searchParams.get("code_challenge_method")).toBe("S256");
|
|
543
|
+
expect(authorizeUrl.searchParams.get("code_challenge")).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
|
544
|
+
const state = authorizeUrl.searchParams.get("state");
|
|
545
|
+
expect(state).not.toBeNull();
|
|
546
|
+
|
|
547
|
+
const callback = await client.handleHostedAuthorizationCallback(
|
|
548
|
+
`${REDIRECT_URI}?code=${AUTH_CODE}&state=${state ?? ""}`,
|
|
549
|
+
);
|
|
550
|
+
expect(callback.ok).toBe(true);
|
|
551
|
+
const tokenRequest = calls[0];
|
|
552
|
+
expect(tokenRequest?.url).toBe(`${ISSUER}/oauth2/token`);
|
|
553
|
+
expect((tokenRequest?.body as Record<string, unknown>)["grant_type"]).toBe(
|
|
554
|
+
"authorization_code",
|
|
555
|
+
);
|
|
556
|
+
const token = await client.getAccessToken();
|
|
557
|
+
expect(token).toEqual({ ok: true, value: "at_hosted" });
|
|
558
|
+
});
|
|
559
|
+
test("a fresh client resumes authorization state from sessionStorage", async () => {
|
|
560
|
+
const { entries, storage } = createTestSessionStorage();
|
|
561
|
+
vi.stubGlobal("sessionStorage", storage);
|
|
562
|
+
const { calls, fetchPort } = createFetchStub(() =>
|
|
563
|
+
jsonResponse(200, {
|
|
564
|
+
access_token: "at_session_storage",
|
|
565
|
+
token_type: "Bearer",
|
|
566
|
+
expires_in: 300,
|
|
567
|
+
scope: "openid profile email",
|
|
568
|
+
}),
|
|
569
|
+
);
|
|
570
|
+
const beforeRedirect = makeClient(fetchPort);
|
|
571
|
+
const authorization = await beforeRedirect.createHostedAuthorizationUrl();
|
|
572
|
+
expect(authorization.ok).toBe(true);
|
|
573
|
+
if (!authorization.ok) return;
|
|
574
|
+
|
|
575
|
+
expect(entries.size).toBe(1);
|
|
576
|
+
const afterRedirect = makeClient(fetchPort);
|
|
577
|
+
const callbackUrl = `${REDIRECT_URI}?code=${AUTH_CODE}&state=${authorization.value.state}`;
|
|
578
|
+
expect((await afterRedirect.handleHostedAuthorizationCallback(callbackUrl)).ok).toBe(true);
|
|
579
|
+
expect(entries.size).toBe(0);
|
|
580
|
+
expect(await afterRedirect.getAccessToken()).toEqual({
|
|
581
|
+
ok: true,
|
|
582
|
+
value: "at_session_storage",
|
|
583
|
+
});
|
|
584
|
+
expect(await afterRedirect.handleHostedAuthorizationCallback(callbackUrl)).toEqual({
|
|
585
|
+
ok: false,
|
|
586
|
+
error: { type: "state_mismatch" },
|
|
587
|
+
});
|
|
588
|
+
expect(calls).toHaveLength(1);
|
|
589
|
+
});
|
|
590
|
+
|
|
591
|
+
test("expired persisted PKCE state is removed and rejected", async () => {
|
|
592
|
+
const { entries, storage } = createTestSessionStorage();
|
|
593
|
+
vi.stubGlobal("sessionStorage", storage);
|
|
594
|
+
let nowMs = 1_000;
|
|
595
|
+
const { fetchPort } = createFetchStub(() => jsonResponse(200, {}));
|
|
596
|
+
const beforeRedirect = makeClient(fetchPort, {
|
|
597
|
+
now: () => nowMs,
|
|
598
|
+
});
|
|
599
|
+
const authorization = await beforeRedirect.createHostedAuthorizationUrl();
|
|
600
|
+
expect(authorization.ok).toBe(true);
|
|
601
|
+
if (!authorization.ok) return;
|
|
602
|
+
|
|
603
|
+
expect(entries.size).toBe(1);
|
|
604
|
+
nowMs += 10 * 60 * 1000 + 1;
|
|
605
|
+
const afterRedirect = makeClient(fetchPort, {
|
|
606
|
+
now: () => nowMs,
|
|
607
|
+
});
|
|
608
|
+
const outcome = await afterRedirect.handleHostedAuthorizationCallback(
|
|
609
|
+
`${REDIRECT_URI}?code=${AUTH_CODE}&state=${authorization.value.state}`,
|
|
610
|
+
);
|
|
611
|
+
expect(outcome).toEqual({ ok: false, error: { type: "state_mismatch" } });
|
|
612
|
+
expect(entries.size).toBe(0);
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("callback with a foreign state fails closed", async () => {
|
|
616
|
+
const client = makeClient(createFetchStub(() => jsonResponse(200, {})).fetchPort);
|
|
617
|
+
const outcome = await client.handleHostedAuthorizationCallback(
|
|
618
|
+
`${REDIRECT_URI}?code=${AUTH_CODE}&state=forged`,
|
|
619
|
+
);
|
|
620
|
+
expect(outcome).toEqual({ ok: false, error: { type: "state_mismatch" } });
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
test("callback carrying an OAuth error surfaces the validated code", async () => {
|
|
624
|
+
const client = makeClient(createFetchStub(() => jsonResponse(200, {})).fetchPort);
|
|
625
|
+
const outcome = await client.handleHostedAuthorizationCallback(
|
|
626
|
+
`${REDIRECT_URI}?error=access_denied&error_description=user+refused`,
|
|
627
|
+
);
|
|
628
|
+
expect(outcome.ok).toBe(false);
|
|
629
|
+
if (!outcome.ok) {
|
|
630
|
+
expect(outcome.error).toMatchObject({
|
|
631
|
+
type: "oauth_error",
|
|
632
|
+
code: "access_denied",
|
|
633
|
+
description: "user refused",
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
});
|
|
637
|
+
});
|
|
638
|
+
|
|
639
|
+
describe("getPublicConfig (design.md §16.2)", () => {
|
|
640
|
+
test("GETs /v1/config with client_id and validates the payload", async () => {
|
|
641
|
+
const { calls, fetchPort } = createFetchStub(() => jsonResponse(200, PUBLIC_CONFIG));
|
|
642
|
+
const client = makeClient(fetchPort);
|
|
643
|
+
const outcome = await client.getPublicConfig();
|
|
644
|
+
expect(outcome.ok).toBe(true);
|
|
645
|
+
if (outcome.ok) {
|
|
646
|
+
expect(outcome.value.issuer).toBe(ISSUER);
|
|
647
|
+
expect(outcome.value.rp_id).toBe("example.com");
|
|
648
|
+
}
|
|
649
|
+
expect(calls[0]?.method).toBe("GET");
|
|
650
|
+
expect(calls[0]?.url).toBe(`${BASE_URL}/v1/config?client_id=${CLIENT_ID}`);
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
test("a config payload leaking an internal field is rejected (strictObject defense)", async () => {
|
|
654
|
+
const { fetchPort } = createFetchStub(() =>
|
|
655
|
+
jsonResponse(200, { ...PUBLIC_CONFIG, internal_shard: "shard-7" }),
|
|
656
|
+
);
|
|
657
|
+
const client = makeClient(fetchPort);
|
|
658
|
+
const outcome = await client.getPublicConfig();
|
|
659
|
+
expect(outcome.ok).toBe(false);
|
|
660
|
+
if (!outcome.ok) expect(outcome.error.type).toBe("invalid_response");
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("issuer discovery: token exchange works without a configured issuer", async () => {
|
|
664
|
+
const { calls, fetchPort } = createFetchStub((req) => {
|
|
665
|
+
if (req.url.startsWith(`${BASE_URL}/v1/config`)) return jsonResponse(200, PUBLIC_CONFIG);
|
|
666
|
+
if (req.url.endsWith("/v1/auth/transactions")) return jsonResponse(200, CREATE_RESPONSE);
|
|
667
|
+
if (req.url.endsWith("/complete")) {
|
|
668
|
+
const state = (
|
|
669
|
+
calls.find((c) => c.url.endsWith("/v1/auth/transactions"))?.body as Record<
|
|
670
|
+
string,
|
|
671
|
+
unknown
|
|
672
|
+
>
|
|
673
|
+
)["state"];
|
|
674
|
+
return jsonResponse(200, {
|
|
675
|
+
authorization_code: AUTH_CODE,
|
|
676
|
+
state,
|
|
677
|
+
redirect_uri: REDIRECT_URI,
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
return jsonResponse(200, {
|
|
681
|
+
access_token: "at_discovered",
|
|
682
|
+
token_type: "Bearer",
|
|
683
|
+
expires_in: 300,
|
|
684
|
+
scope: "openid profile email",
|
|
685
|
+
});
|
|
686
|
+
});
|
|
687
|
+
const client = createBrowserAuthClient({
|
|
688
|
+
baseUrl: BASE_URL,
|
|
689
|
+
clientId: CLIENT_ID,
|
|
690
|
+
redirectUri: REDIRECT_URI,
|
|
691
|
+
fetch: fetchPort,
|
|
692
|
+
});
|
|
693
|
+
const created = await client.createTransaction();
|
|
694
|
+
expect(created.ok).toBe(true);
|
|
695
|
+
if (!created.ok) return;
|
|
696
|
+
const completed = await client.completeTransaction(created.value.transactionId);
|
|
697
|
+
expect(completed.ok).toBe(true);
|
|
698
|
+
if (!completed.ok) return;
|
|
699
|
+
const exchanged = await client.exchangeAuthorizationCode({
|
|
700
|
+
code: completed.value.authorization_code,
|
|
701
|
+
state: completed.value.state,
|
|
702
|
+
});
|
|
703
|
+
expect(exchanged.ok).toBe(true);
|
|
704
|
+
// The issuer was discovered lazily via /v1/config, and that fetch must
|
|
705
|
+
// have happened before the token exchange that consumed it.
|
|
706
|
+
const configIndex = calls.findIndex(
|
|
707
|
+
(c) => c.url === `${BASE_URL}/v1/config?client_id=${CLIENT_ID}`,
|
|
708
|
+
);
|
|
709
|
+
const tokenIndex = calls.findIndex((c) => c.url.endsWith("/oauth2/token"));
|
|
710
|
+
expect(configIndex).toBeGreaterThanOrEqual(0);
|
|
711
|
+
expect(tokenIndex).toBeGreaterThan(configIndex);
|
|
712
|
+
expect(calls[tokenIndex]?.url).toBe(`${ISSUER}/oauth2/token`);
|
|
713
|
+
});
|
|
714
|
+
});
|