@gosso/client 0.2.0 → 0.4.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/dist/client.js ADDED
@@ -0,0 +1,800 @@
1
+ import { base64URLToBuffer, bufferToBase64URL, CookieSessionRefreshError, generateRandomString, generateRefreshOwner, getCookieName, hasAdminAccess, normalizeBaseUrl, parseJsonEnvelope, parseRefreshLock, readCookie, readRolesFromAccessToken, readScopeFromAccessToken, safeLocalPath, } from "./utils.js";
2
+ import { AuthenticationError, CsrfError, PasskeyError, TokenRefreshError, } from "./errors.js";
3
+ import { generateCodeChallenge } from "./pkce.js";
4
+ const REFRESH_LOCK_TTL_MS = 15_000;
5
+ const REFRESH_WAIT_TIMEOUT_MS = 20_000;
6
+ const REFRESH_WAIT_POLL_MS = 100;
7
+ const REFRESH_WEB_LOCK_NAME = "gosso-auth-refresh";
8
+ const AUTH_REDIRECT_GUARD_MS = 30_000;
9
+ export const defaultConfig = {
10
+ scope: "openid profile email",
11
+ postLoginDefaultPath: "/",
12
+ loginPath: "/login",
13
+ storagePrefix: "gosso",
14
+ sessionMode: "cookie",
15
+ };
16
+ export function createGossoClient(inputConfig) {
17
+ const config = {
18
+ ...defaultConfig,
19
+ ...inputConfig,
20
+ issuer: normalizeBaseUrl(inputConfig.issuer),
21
+ sessionMode: inputConfig.sessionMode ?? defaultConfig.sessionMode,
22
+ };
23
+ const cookieSession = config.sessionMode === "cookie";
24
+ const flowStorage = sessionStorage;
25
+ const fetcher = (input, init) => (config.fetchImpl || globalThis.fetch)(input, init);
26
+ const key = (name) => `${config.storagePrefix}:${name}`;
27
+ const storageKeys = {
28
+ accessToken: key("access_token"),
29
+ refreshToken: key("refresh_token"),
30
+ userProfile: key("user_profile"),
31
+ pkceVerifier: key("pkce_verifier"),
32
+ authState: key("auth_state"),
33
+ postLoginRedirect: key("post_login_redirect"),
34
+ tokenIssuedAt: key("token_issued_at"), // legacy cleanup only
35
+ tokenExpiresIn: key("token_expires_in"), // legacy cleanup only
36
+ refreshLock: key("auth_refresh_lock"),
37
+ refreshGeneration: key("auth_refresh_generation"),
38
+ authRedirectGuard: key("auth_redirect_guard"),
39
+ };
40
+ let refreshPromise = null;
41
+ let memoryTokenSet = null;
42
+ let tokenIssuedAt = 0;
43
+ const sessionListeners = new Set();
44
+ const deleteCookie = (name) => {
45
+ const cookieName = getCookieName(name);
46
+ document.cookie = `${cookieName}=; path=/; max-age=-1; SameSite=Lax`;
47
+ };
48
+ const readProfile = () => {
49
+ const profile = sessionStorage.getItem(storageKeys.userProfile);
50
+ if (!profile)
51
+ return null;
52
+ try {
53
+ return JSON.parse(profile);
54
+ }
55
+ catch {
56
+ return null;
57
+ }
58
+ };
59
+ const getAccessToken = () => cookieSession ? null : memoryTokenSet?.access_token || null;
60
+ const getRefreshToken = () => cookieSession ? null : memoryTokenSet?.refresh_token || null;
61
+ const getSnapshot = () => {
62
+ const accessToken = getAccessToken();
63
+ const refreshToken = getRefreshToken();
64
+ const profile = readProfile();
65
+ return {
66
+ accessToken,
67
+ refreshToken,
68
+ profile,
69
+ loggedIn: cookieSession ? Boolean(profile) : Boolean(accessToken),
70
+ isAdmin: hasAdminAccess(profile, accessToken),
71
+ };
72
+ };
73
+ const emitSessionChanged = () => {
74
+ const snapshot = getSnapshot();
75
+ config.onSessionChanged?.(snapshot);
76
+ sessionListeners.forEach((listener) => listener(snapshot));
77
+ };
78
+ /**
79
+ * Observe profile, login and logout changes without reimplementing a session
80
+ * store in each consuming SPA. Callers receive future changes only and must
81
+ * read getSnapshot() for the initial value.
82
+ */
83
+ const subscribe = (listener) => {
84
+ sessionListeners.add(listener);
85
+ return () => {
86
+ sessionListeners.delete(listener);
87
+ };
88
+ };
89
+ const saveTokenSet = (data) => {
90
+ if (cookieSession) {
91
+ emitSessionChanged();
92
+ return;
93
+ }
94
+ memoryTokenSet = {
95
+ access_token: data.access_token,
96
+ refresh_token: data.refresh_token || "",
97
+ expires_in: data.expires_in || 900,
98
+ };
99
+ tokenIssuedAt = Date.now();
100
+ emitSessionChanged();
101
+ };
102
+ const clear = () => {
103
+ memoryTokenSet = null;
104
+ tokenIssuedAt = 0;
105
+ // Remove artifacts written by pre-0.4 releases during migration.
106
+ Object.values(storageKeys).forEach((storageKey) => localStorage.removeItem(storageKey));
107
+ Object.values(storageKeys).forEach((storageKey) => sessionStorage.removeItem(storageKey));
108
+ deleteCookie("access_token");
109
+ emitSessionChanged();
110
+ };
111
+ const redirectToLogin = () => {
112
+ if (config.onAuthRequired) {
113
+ config.onAuthRequired();
114
+ return;
115
+ }
116
+ window.location.href = safeLocalPath(config.loginPath, "/login");
117
+ };
118
+ const identityCSRFCookieName = new URL(config.issuer).protocol === "https:"
119
+ ? "__Host-csrf_token"
120
+ : "csrf_token";
121
+ const issuerOrigin = new URL(config.issuer).origin;
122
+ const credentialsFor = (target) => cookieSession && target.origin !== window.location.origin
123
+ ? "include"
124
+ : "same-origin";
125
+ const identityCredentials = credentialsFor(new URL(config.issuer));
126
+ const allowedApiOrigins = new Set([window.location.origin, issuerOrigin]);
127
+ for (const value of config.allowedApiOrigins || []) {
128
+ const parsed = new URL(value);
129
+ if (parsed.protocol !== "https:" && parsed.hostname !== "localhost") {
130
+ throw new AuthenticationError("Allowed API origins must use HTTPS", "INSECURE_API_ORIGIN");
131
+ }
132
+ allowedApiOrigins.add(parsed.origin);
133
+ }
134
+ const resolveRequestURL = (url) => new URL(url, window.location.origin);
135
+ const isIdentityRequest = (url) => {
136
+ const target = resolveRequestURL(url);
137
+ return (target.origin === issuerOrigin &&
138
+ ["/api/v1/", "/oauth2/", "/oidc/", "/.well-known/"].some((prefix) => target.pathname.startsWith(prefix)));
139
+ };
140
+ const assertAllowedRequest = (url) => {
141
+ const target = resolveRequestURL(url);
142
+ if (!allowedApiOrigins.has(target.origin)) {
143
+ throw new AuthenticationError(`Refusing credentials for untrusted API origin: ${target.origin}`, "UNTRUSTED_API_ORIGIN");
144
+ }
145
+ return target;
146
+ };
147
+ const readIdentityCSRFToken = () => readCookie(identityCSRFCookieName);
148
+ const ensureIdentityCSRFToken = async () => {
149
+ let token = readIdentityCSRFToken();
150
+ if (token)
151
+ return token;
152
+ // Safe methods are accepted without CSRF validation and the GOSSO middleware
153
+ // reissues its own short-lived double-submit cookie before auth is evaluated.
154
+ await fetcher(`${config.issuer}/api/v1/auth/session`, {
155
+ credentials: identityCredentials,
156
+ });
157
+ token = readIdentityCSRFToken();
158
+ if (!token)
159
+ throw new CookieSessionRefreshError(403, "GOSSO CSRF recovery failed");
160
+ return token;
161
+ };
162
+ const tryAcquireRefreshLock = (owner) => {
163
+ const now = Date.now();
164
+ const current = parseRefreshLock(localStorage.getItem(storageKeys.refreshLock));
165
+ if (current && current.expiresAt > now && current.owner !== owner) {
166
+ return false;
167
+ }
168
+ const nextLock = {
169
+ owner,
170
+ expiresAt: now + REFRESH_LOCK_TTL_MS,
171
+ };
172
+ localStorage.setItem(storageKeys.refreshLock, JSON.stringify(nextLock));
173
+ return (parseRefreshLock(localStorage.getItem(storageKeys.refreshLock))?.owner ===
174
+ owner);
175
+ };
176
+ const releaseRefreshLock = (owner) => {
177
+ const current = parseRefreshLock(localStorage.getItem(storageKeys.refreshLock));
178
+ if (!current ||
179
+ current.owner === owner ||
180
+ current.expiresAt <= Date.now()) {
181
+ localStorage.removeItem(storageKeys.refreshLock);
182
+ }
183
+ };
184
+ const requestBrowserRefreshLock = async (callback) => {
185
+ const locks = navigator.locks;
186
+ if (!locks)
187
+ return callback();
188
+ return locks.request(REFRESH_WEB_LOCK_NAME, { mode: "exclusive" }, callback);
189
+ };
190
+ const currentRefreshGeneration = () => localStorage.getItem(storageKeys.refreshGeneration);
191
+ const markCookieRefreshComplete = () => {
192
+ localStorage.setItem(storageKeys.refreshGeneration, `${Date.now()}:${generateRefreshOwner()}`);
193
+ };
194
+ const performCookieRefresh = async (observedGeneration) => {
195
+ if (currentRefreshGeneration() !== observedGeneration)
196
+ return "";
197
+ const csrf = await ensureIdentityCSRFToken();
198
+ const response = await fetcher(`${config.issuer}/api/v1/auth/refresh`, {
199
+ method: "POST",
200
+ headers: { "X-CSRF-Token": csrf, "X-Gosso-Cookie-Session": "1" },
201
+ credentials: identityCredentials,
202
+ });
203
+ if (!response.ok) {
204
+ const message = response.status === 401
205
+ ? "Refresh token is invalid, expired, or revoked"
206
+ : response.status === 403
207
+ ? "GOSSO CSRF recovery failed"
208
+ : "Cookie session refresh failed";
209
+ throw new CookieSessionRefreshError(response.status, message);
210
+ }
211
+ markCookieRefreshComplete();
212
+ return "";
213
+ };
214
+ const performTokenRefresh = async (previousRefreshToken) => {
215
+ const latestRefreshToken = getRefreshToken();
216
+ if (!latestRefreshToken)
217
+ throw new TokenRefreshError("No refresh token found", "NO_REFRESH_TOKEN");
218
+ if (latestRefreshToken !== previousRefreshToken) {
219
+ const latestAccessToken = getAccessToken();
220
+ if (latestAccessToken)
221
+ return latestAccessToken;
222
+ }
223
+ const response = await fetcher(`${config.issuer}/api/v1/auth/refresh`, {
224
+ method: "POST",
225
+ headers: { "Content-Type": "application/json" },
226
+ body: JSON.stringify({ refresh_token: latestRefreshToken }),
227
+ });
228
+ const data = await parseJsonEnvelope(response, "Token refresh failed");
229
+ saveTokenSet(data);
230
+ return data.access_token;
231
+ };
232
+ const refreshAccessToken = async () => {
233
+ if (refreshPromise)
234
+ return refreshPromise;
235
+ const pending = (async () => {
236
+ const owner = generateRefreshOwner();
237
+ let lockAcquired = false;
238
+ try {
239
+ if (cookieSession) {
240
+ const observedGeneration = currentRefreshGeneration();
241
+ if (navigator.locks) {
242
+ return requestBrowserRefreshLock(() => performCookieRefresh(observedGeneration));
243
+ }
244
+ lockAcquired = tryAcquireRefreshLock(owner);
245
+ if (!lockAcquired) {
246
+ const startedAt = Date.now();
247
+ while (Date.now() - startedAt < REFRESH_WAIT_TIMEOUT_MS) {
248
+ if (currentRefreshGeneration() !== observedGeneration)
249
+ return "";
250
+ const lock = parseRefreshLock(localStorage.getItem(storageKeys.refreshLock));
251
+ if (!lock || lock.expiresAt <= Date.now())
252
+ break;
253
+ await new Promise((resolve) => window.setTimeout(resolve, REFRESH_WAIT_POLL_MS));
254
+ }
255
+ lockAcquired = tryAcquireRefreshLock(owner);
256
+ if (!lockAcquired)
257
+ throw new TokenRefreshError("Cookie session refresh is already in progress", "REFRESH_IN_PROGRESS");
258
+ }
259
+ return performCookieRefresh(observedGeneration);
260
+ }
261
+ const refreshToken = getRefreshToken();
262
+ if (!refreshToken)
263
+ throw new TokenRefreshError("No refresh token found", "NO_REFRESH_TOKEN");
264
+ // Legacy token sessions are intentionally tab-local and in-memory.
265
+ // refreshPromise already coalesces concurrent refreshes in this page.
266
+ return performTokenRefresh(refreshToken);
267
+ }
268
+ finally {
269
+ if (lockAcquired)
270
+ releaseRefreshLock(owner);
271
+ }
272
+ })();
273
+ refreshPromise = pending;
274
+ void pending.then(() => {
275
+ if (refreshPromise === pending)
276
+ refreshPromise = null;
277
+ }, () => {
278
+ if (refreshPromise === pending)
279
+ refreshPromise = null;
280
+ });
281
+ return pending;
282
+ };
283
+ const fetchUserProfile = async (accessToken = getAccessToken()) => {
284
+ if (cookieSession) {
285
+ const [identity, session] = await Promise.all([
286
+ fetcher(`${config.issuer}/oidc/userinfo`, {
287
+ credentials: identityCredentials,
288
+ }),
289
+ config.sessionProfileEndpoint
290
+ ? fetcher(config.sessionProfileEndpoint, {
291
+ credentials: credentialsFor(assertAllowedRequest(config.sessionProfileEndpoint)),
292
+ })
293
+ : Promise.resolve(null),
294
+ ]);
295
+ if (!identity.ok || (session && !session.ok))
296
+ throw new AuthenticationError("Failed to fetch user profile", "USER_PROFILE_FAILED");
297
+ const data = (await identity.json());
298
+ if (session)
299
+ Object.assign(data, (await session.json()).data ||
300
+ {});
301
+ sessionStorage.setItem(storageKeys.userProfile, JSON.stringify(data));
302
+ sessionStorage.removeItem(storageKeys.authRedirectGuard);
303
+ emitSessionChanged();
304
+ return data;
305
+ }
306
+ if (!accessToken)
307
+ throw new AuthenticationError("No access token found", "NO_ACCESS_TOKEN");
308
+ const response = await fetcher(`${config.issuer}/oidc/userinfo`, {
309
+ headers: { Authorization: `Bearer ${accessToken}` },
310
+ });
311
+ if (!response.ok)
312
+ throw new AuthenticationError("Failed to fetch user profile", "USER_PROFILE_FAILED");
313
+ const data = (await response.json());
314
+ const roles = readRolesFromAccessToken(accessToken);
315
+ if (roles)
316
+ data.roles = roles;
317
+ const scope = readScopeFromAccessToken(accessToken);
318
+ if (scope)
319
+ data.scope = scope;
320
+ sessionStorage.setItem(storageKeys.userProfile, JSON.stringify(data));
321
+ emitSessionChanged();
322
+ return data;
323
+ };
324
+ const apiFetch = async (url, options = {}) => {
325
+ const target = assertAllowedRequest(url);
326
+ if (cookieSession) {
327
+ const headers = new Headers(options.headers || {});
328
+ const issuerRequest = isIdentityRequest(target.toString());
329
+ if (!["GET", "HEAD", "OPTIONS"].includes((options.method || "GET").toUpperCase()) &&
330
+ !headers.has("X-CSRF-Token")) {
331
+ const csrf = issuerRequest
332
+ ? readIdentityCSRFToken()
333
+ : config.csrfCookieName
334
+ ? readCookie(config.csrfCookieName)
335
+ : null;
336
+ if (csrf)
337
+ headers.set("X-CSRF-Token", csrf);
338
+ }
339
+ let response = await fetcher(url, {
340
+ ...options,
341
+ headers,
342
+ credentials: credentialsFor(target),
343
+ });
344
+ if (response.status === 401 &&
345
+ (!issuerRequest || config.refreshIdentityRequests)) {
346
+ try {
347
+ await refreshAccessToken();
348
+ response = await fetcher(url, {
349
+ ...options,
350
+ headers,
351
+ credentials: credentialsFor(target),
352
+ });
353
+ }
354
+ catch {
355
+ response = new Response(null, {
356
+ status: 401,
357
+ statusText: "Authentication required",
358
+ });
359
+ }
360
+ }
361
+ if (response.status === 401) {
362
+ const returnTo = `${window.location.pathname}${window.location.search}${window.location.hash}`;
363
+ const previous = sessionStorage.getItem(storageKeys.authRedirectGuard);
364
+ const now = Date.now();
365
+ let recentlyRedirected = false;
366
+ try {
367
+ const guard = previous
368
+ ? JSON.parse(previous)
369
+ : null;
370
+ recentlyRedirected =
371
+ guard?.returnTo === returnTo &&
372
+ typeof guard.at === "number" &&
373
+ now - guard.at < AUTH_REDIRECT_GUARD_MS;
374
+ }
375
+ catch {
376
+ recentlyRedirected = false;
377
+ }
378
+ if (!recentlyRedirected) {
379
+ clear();
380
+ sessionStorage.setItem(storageKeys.authRedirectGuard, JSON.stringify({ at: now, returnTo }));
381
+ await redirectToAuthorize(returnTo);
382
+ }
383
+ else if (previous) {
384
+ sessionStorage.removeItem(storageKeys.userProfile);
385
+ emitSessionChanged();
386
+ sessionStorage.setItem(storageKeys.authRedirectGuard, previous);
387
+ }
388
+ }
389
+ return response;
390
+ }
391
+ let token = getAccessToken();
392
+ if (!token) {
393
+ redirectToLogin();
394
+ return new Response(null, { status: 401 });
395
+ }
396
+ const expiresIn = memoryTokenSet?.expires_in || 900;
397
+ if (tokenIssuedAt && Date.now() - tokenIssuedAt > expiresIn * 1000) {
398
+ try {
399
+ token = await refreshAccessToken();
400
+ }
401
+ catch {
402
+ clear();
403
+ redirectToLogin();
404
+ return new Response(null, { status: 401 });
405
+ }
406
+ }
407
+ const headers = new Headers(options.headers || {});
408
+ if (token && !headers.has("Authorization")) {
409
+ headers.set("Authorization", `Bearer ${token}`);
410
+ }
411
+ let response = await fetcher(url, { ...options, headers });
412
+ if (response.status === 401 && getRefreshToken()) {
413
+ try {
414
+ const freshToken = await refreshAccessToken();
415
+ headers.set("Authorization", `Bearer ${freshToken}`);
416
+ response = await fetcher(url, { ...options, headers });
417
+ }
418
+ catch {
419
+ clear();
420
+ redirectToLogin();
421
+ }
422
+ }
423
+ return response;
424
+ };
425
+ const redirectToAuthorize = async (customRedirectUri) => {
426
+ const verifier = generateRandomString(64);
427
+ const state = generateRandomString(16);
428
+ flowStorage.setItem(storageKeys.pkceVerifier, verifier);
429
+ flowStorage.setItem(storageKeys.authState, state);
430
+ if (customRedirectUri) {
431
+ flowStorage.setItem(storageKeys.postLoginRedirect, safeLocalPath(customRedirectUri, config.postLoginDefaultPath));
432
+ }
433
+ const challenge = await generateCodeChallenge(verifier);
434
+ const authUrl = new URL(`${config.issuer}/oauth2/authorize`);
435
+ authUrl.searchParams.append("client_id", config.clientId);
436
+ authUrl.searchParams.append("response_type", "code");
437
+ authUrl.searchParams.append("redirect_uri", config.redirectUri);
438
+ authUrl.searchParams.append("scope", config.scope);
439
+ authUrl.searchParams.append("code_challenge", challenge);
440
+ authUrl.searchParams.append("code_challenge_method", "S256");
441
+ authUrl.searchParams.append("state", state);
442
+ window.location.href = authUrl.toString();
443
+ };
444
+ const exchangeCodeForToken = async (code, state) => {
445
+ const savedState = flowStorage.getItem(storageKeys.authState);
446
+ const verifier = flowStorage.getItem(storageKeys.pkceVerifier);
447
+ if (state !== savedState)
448
+ throw new CsrfError("State mismatch. Potential CSRF attack.", "CSRF_MISMATCH");
449
+ if (!verifier)
450
+ throw new AuthenticationError("PKCE verifier not found. Authentication flow expired.", "PKCE_VERIFIER_MISSING");
451
+ const body = new URLSearchParams();
452
+ body.append("grant_type", "authorization_code");
453
+ body.append("client_id", config.clientId);
454
+ body.append("code", code);
455
+ body.append("code_verifier", verifier);
456
+ body.append("redirect_uri", config.redirectUri);
457
+ const response = await fetcher(`${config.issuer}/oauth2/token`, {
458
+ method: "POST",
459
+ headers: {
460
+ "Content-Type": "application/x-www-form-urlencoded",
461
+ ...(cookieSession ? { "X-Gosso-Cookie-Session": "1" } : {}),
462
+ },
463
+ body: body.toString(),
464
+ credentials: identityCredentials,
465
+ });
466
+ if (!response.ok) {
467
+ throw new AuthenticationError(`Token exchange failed: ${await response.text()}`, "TOKEN_EXCHANGE_FAILED");
468
+ }
469
+ const data = (await response.json());
470
+ if (!cookieSession)
471
+ saveTokenSet(data);
472
+ flowStorage.removeItem(storageKeys.pkceVerifier);
473
+ flowStorage.removeItem(storageKeys.authState);
474
+ return data;
475
+ };
476
+ const handleRedirectCallback = async (code, state) => {
477
+ const tokenSet = await exchangeCodeForToken(code, state);
478
+ await fetchUserProfile(cookieSession ? undefined : tokenSet.access_token);
479
+ const redirectTo = safeLocalPath(flowStorage.getItem(storageKeys.postLoginRedirect) || undefined, config.postLoginDefaultPath);
480
+ flowStorage.removeItem(storageKeys.postLoginRedirect);
481
+ sessionStorage.removeItem(storageKeys.authRedirectGuard);
482
+ if (cookieSession)
483
+ return { sessionMode: "cookie", redirectTo };
484
+ return {
485
+ sessionMode: "token",
486
+ tokenSet: tokenSet,
487
+ redirectTo,
488
+ };
489
+ };
490
+ const logout = async (redirectTo = "/") => {
491
+ if (cookieSession) {
492
+ const csrf = await ensureIdentityCSRFToken();
493
+ const response = await fetcher(`${config.issuer}/api/v1/auth/logout`, {
494
+ method: "POST",
495
+ headers: { "X-CSRF-Token": csrf },
496
+ credentials: identityCredentials,
497
+ keepalive: true,
498
+ });
499
+ if (!response.ok)
500
+ throw new AuthenticationError(`Logout failed (${response.status})`, "LOGOUT_FAILED");
501
+ clear();
502
+ window.location.href = safeLocalPath(redirectTo, "/");
503
+ return;
504
+ }
505
+ const accessToken = getAccessToken();
506
+ try {
507
+ if (accessToken) {
508
+ await fetcher(`${config.issuer}/api/v1/auth/logout`, {
509
+ method: "POST",
510
+ headers: { Authorization: `Bearer ${accessToken}` },
511
+ credentials: identityCredentials,
512
+ keepalive: true,
513
+ });
514
+ }
515
+ }
516
+ finally {
517
+ clear();
518
+ window.location.href = safeLocalPath(redirectTo, "/");
519
+ }
520
+ };
521
+ const loginWithPassword = async (username, password) => {
522
+ const response = await fetcher(`${config.issuer}/api/v1/auth/login`, {
523
+ method: "POST",
524
+ headers: {
525
+ "Content-Type": "application/json",
526
+ ...(cookieSession ? { "X-Gosso-Cookie-Session": "1" } : {}),
527
+ },
528
+ body: JSON.stringify({ username, password }),
529
+ credentials: identityCredentials,
530
+ });
531
+ const result = await parseJsonEnvelope(response, "Login failed");
532
+ if (!cookieSession && result.access_token) {
533
+ saveTokenSet(result);
534
+ await fetchUserProfile(result.access_token);
535
+ }
536
+ else if (cookieSession && !result.requires_mfa) {
537
+ await fetchUserProfile();
538
+ }
539
+ return result;
540
+ };
541
+ const requestPasswordReset = async (email) => {
542
+ const response = await fetcher(`${config.issuer}/api/v1/auth/password/forgot`, {
543
+ method: "POST",
544
+ headers: { "Content-Type": "application/json" },
545
+ body: JSON.stringify({ email }),
546
+ credentials: identityCredentials,
547
+ });
548
+ await parseJsonEnvelope(response, "Failed to request a password reset");
549
+ };
550
+ const resetPassword = async (token, newPassword) => {
551
+ const response = await fetcher(`${config.issuer}/api/v1/auth/password/reset`, {
552
+ method: "POST",
553
+ headers: { "Content-Type": "application/json" },
554
+ body: JSON.stringify({ token, new_password: newPassword }),
555
+ credentials: identityCredentials,
556
+ });
557
+ await parseJsonEnvelope(response, "Failed to reset password");
558
+ };
559
+ const verifyMfa = async (mfaToken, code, type = "totp") => {
560
+ const response = await fetcher(`${config.issuer}/api/v1/auth/mfa/verify`, {
561
+ method: "POST",
562
+ headers: {
563
+ "Content-Type": "application/json",
564
+ ...(cookieSession ? { "X-Gosso-Cookie-Session": "1" } : {}),
565
+ },
566
+ body: JSON.stringify({ mfa_token: mfaToken, code, type }),
567
+ credentials: identityCredentials,
568
+ });
569
+ const data = await parseJsonEnvelope(response, "MFA verification failed");
570
+ if (!cookieSession)
571
+ saveTokenSet(data);
572
+ await fetchUserProfile(cookieSession ? undefined : data.access_token);
573
+ return data;
574
+ };
575
+ const loginWithPasskey = async () => {
576
+ const beginRes = await fetcher(`${config.issuer}/api/v1/passkey/login/begin`, {
577
+ method: "POST",
578
+ headers: { "Content-Type": "application/json" },
579
+ body: JSON.stringify({}),
580
+ credentials: identityCredentials,
581
+ });
582
+ const begin = await parseJsonEnvelope(beginRes, "Failed to begin passkey login");
583
+ const options = {
584
+ ...begin.options,
585
+ challenge: base64URLToBuffer(begin.options.challenge),
586
+ allowCredentials: (begin.options.allowCredentials || []).map((cred) => ({
587
+ ...cred,
588
+ id: base64URLToBuffer(cred.id),
589
+ })),
590
+ };
591
+ const assertion = (await navigator.credentials.get({
592
+ publicKey: options,
593
+ }));
594
+ if (!assertion?.response)
595
+ throw new PasskeyError("Passkey authentication cancelled or failed", "PASSKEY_AUTH_CANCELLED");
596
+ const assertionResponse = assertion.response;
597
+ const completeRes = await fetcher(`${config.issuer}/api/v1/passkey/login/complete`, {
598
+ method: "POST",
599
+ headers: {
600
+ "Content-Type": "application/json",
601
+ ...(cookieSession ? { "X-Gosso-Cookie-Session": "1" } : {}),
602
+ },
603
+ body: JSON.stringify({
604
+ request_id: begin.request_id,
605
+ id: assertion.id,
606
+ rawId: bufferToBase64URL(assertion.rawId),
607
+ type: assertion.type,
608
+ response: {
609
+ clientDataJSON: bufferToBase64URL(assertionResponse.clientDataJSON),
610
+ authenticatorData: bufferToBase64URL(assertionResponse.authenticatorData),
611
+ signature: bufferToBase64URL(assertionResponse.signature),
612
+ userHandle: assertionResponse.userHandle
613
+ ? bufferToBase64URL(assertionResponse.userHandle)
614
+ : null,
615
+ },
616
+ }),
617
+ });
618
+ const data = await parseJsonEnvelope(completeRes, "Passkey login failed");
619
+ if (!cookieSession)
620
+ saveTokenSet(data);
621
+ await fetchUserProfile(cookieSession ? undefined : data.access_token);
622
+ return data;
623
+ };
624
+ const updateProfile = async (displayName) => {
625
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/profile`, {
626
+ method: "PUT",
627
+ headers: { "Content-Type": "application/json" },
628
+ body: JSON.stringify({ display_name: displayName }),
629
+ });
630
+ await parseJsonEnvelope(response, "Failed to update profile");
631
+ return fetchUserProfile();
632
+ };
633
+ const changePassword = async (currentPassword, newPassword) => {
634
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/password/change`, {
635
+ method: "POST",
636
+ headers: { "Content-Type": "application/json" },
637
+ body: JSON.stringify({
638
+ current_password: currentPassword,
639
+ new_password: newPassword,
640
+ }),
641
+ });
642
+ await parseJsonEnvelope(response, "Failed to change password");
643
+ };
644
+ const requestEmailChange = async (newEmail, password) => {
645
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/profile/email/change/request`, {
646
+ method: "POST",
647
+ headers: { "Content-Type": "application/json" },
648
+ body: JSON.stringify({ new_email: newEmail, password }),
649
+ });
650
+ await parseJsonEnvelope(response, "Failed to request email verification code");
651
+ };
652
+ const confirmEmailChange = async (newEmail, code) => {
653
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/profile/email/change/confirm`, {
654
+ method: "POST",
655
+ headers: { "Content-Type": "application/json" },
656
+ body: JSON.stringify({ new_email: newEmail, code }),
657
+ });
658
+ await parseJsonEnvelope(response, "Failed to confirm email change");
659
+ return fetchUserProfile();
660
+ };
661
+ const getMfaStatus = async () => {
662
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/mfa`);
663
+ return parseJsonEnvelope(response, "Failed to load MFA status");
664
+ };
665
+ const enrollMfa = async () => {
666
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/mfa/enroll`, {
667
+ method: "POST",
668
+ });
669
+ return parseJsonEnvelope(response, "Failed to enroll MFA");
670
+ };
671
+ const activateMfa = async (code) => {
672
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/mfa/activate`, {
673
+ method: "POST",
674
+ headers: { "Content-Type": "application/json" },
675
+ body: JSON.stringify({ code }),
676
+ });
677
+ await parseJsonEnvelope(response, "Failed to activate MFA");
678
+ const codesResponse = await apiFetch(`${config.issuer}/api/v1/auth/mfa/backup-codes`, { method: "POST" });
679
+ const data = await parseJsonEnvelope(codesResponse, "Failed to generate backup codes");
680
+ return data.backup_codes || [];
681
+ };
682
+ const disableMfa = async (currentPassword) => {
683
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/mfa`, {
684
+ method: "DELETE",
685
+ headers: { "Content-Type": "application/json" },
686
+ body: JSON.stringify({ current_password: currentPassword }),
687
+ });
688
+ await parseJsonEnvelope(response, "Failed to disable MFA");
689
+ };
690
+ const generateBackupCodes = async () => {
691
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/mfa/backup-codes`, { method: "POST" });
692
+ const data = await parseJsonEnvelope(response, "Failed to generate backup codes");
693
+ return data.backup_codes || [];
694
+ };
695
+ const listPasskeys = async () => {
696
+ const response = await apiFetch(`${config.issuer}/api/v1/passkeys`);
697
+ return parseJsonEnvelope(response, "Failed to load passkeys");
698
+ };
699
+ const registerPasskey = async (name) => {
700
+ const beginRes = await apiFetch(`${config.issuer}/api/v1/passkey/register/begin`, { method: "POST" });
701
+ const begin = await parseJsonEnvelope(beginRes, "Failed to initialize passkey registration");
702
+ const options = {
703
+ ...begin.options,
704
+ challenge: base64URLToBuffer(begin.options.challenge),
705
+ user: {
706
+ ...begin.options.user,
707
+ id: base64URLToBuffer(begin.options.user.id),
708
+ },
709
+ excludeCredentials: (begin.options.excludeCredentials || []).map((cred) => ({
710
+ ...cred,
711
+ id: base64URLToBuffer(cred.id),
712
+ })),
713
+ };
714
+ const credential = (await navigator.credentials.create({
715
+ publicKey: options,
716
+ }));
717
+ if (!credential?.response)
718
+ throw new PasskeyError("Passkey registration cancelled or failed", "PASSKEY_REGISTRATION_CANCELLED");
719
+ const attestationResponse = credential.response;
720
+ const completeRes = await apiFetch(`${config.issuer}/api/v1/passkey/register/complete?request_id=${begin.request_id}`, {
721
+ method: "POST",
722
+ headers: { "Content-Type": "application/json" },
723
+ body: JSON.stringify({
724
+ id: credential.id,
725
+ rawId: bufferToBase64URL(credential.rawId),
726
+ type: credential.type,
727
+ name,
728
+ response: {
729
+ clientDataJSON: bufferToBase64URL(attestationResponse.clientDataJSON),
730
+ attestationObject: bufferToBase64URL(attestationResponse.attestationObject),
731
+ transports: typeof attestationResponse.getTransports === "function"
732
+ ? attestationResponse.getTransports()
733
+ : [],
734
+ },
735
+ }),
736
+ });
737
+ await parseJsonEnvelope(completeRes, "Failed to verify passkey registration");
738
+ };
739
+ const deletePasskey = async (id) => {
740
+ const response = await apiFetch(`${config.issuer}/api/v1/passkeys/${id}`, {
741
+ method: "DELETE",
742
+ });
743
+ await parseJsonEnvelope(response, "Failed to remove passkey");
744
+ };
745
+ const listSessions = async () => {
746
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/sessions`);
747
+ const sessions = await parseJsonEnvelope(response, "Failed to load sessions");
748
+ return sessions.sort((a, b) => new Date(b.last_active_at).getTime() -
749
+ new Date(a.last_active_at).getTime());
750
+ };
751
+ const getCurrentSession = async () => {
752
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/session`);
753
+ return parseJsonEnvelope(response, "Failed to load current session");
754
+ };
755
+ const revokeSession = async (id) => {
756
+ const response = await apiFetch(`${config.issuer}/api/v1/auth/sessions/${id}`, { method: "DELETE" });
757
+ await parseJsonEnvelope(response, "Failed to revoke session");
758
+ };
759
+ return {
760
+ config,
761
+ storageKeys,
762
+ getAccessToken,
763
+ getRefreshToken,
764
+ getUserProfile: readProfile,
765
+ getSnapshot,
766
+ subscribe,
767
+ isLoggedIn: () => cookieSession ? Boolean(readProfile()) : Boolean(getAccessToken()),
768
+ isAdmin: () => hasAdminAccess(readProfile(), getAccessToken()),
769
+ saveTokenSet,
770
+ clear,
771
+ logout,
772
+ redirectToAuthorize,
773
+ exchangeCodeForToken,
774
+ handleRedirectCallback,
775
+ fetchUserProfile,
776
+ refreshAccessToken,
777
+ apiFetch,
778
+ loginWithPassword,
779
+ requestPasswordReset,
780
+ resetPassword,
781
+ verifyMfa,
782
+ loginWithPasskey,
783
+ updateProfile,
784
+ changePassword,
785
+ requestEmailChange,
786
+ confirmEmailChange,
787
+ getMfaStatus,
788
+ enrollMfa,
789
+ activateMfa,
790
+ disableMfa,
791
+ generateBackupCodes,
792
+ listPasskeys,
793
+ registerPasskey,
794
+ deletePasskey,
795
+ listSessions,
796
+ getCurrentSession,
797
+ revokeSession,
798
+ };
799
+ }
800
+ //# sourceMappingURL=client.js.map