@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/src/client.ts ADDED
@@ -0,0 +1,1117 @@
1
+ import type {
2
+ AuthorizationCodeGrantRequest,
3
+ ClientId,
4
+ EmailChallengeResponse,
5
+ EmailResendRequest,
6
+ EmailStartRequest,
7
+ EmailVerifyRequest,
8
+ EmailVerifyResponse,
9
+ MeDeleteResponse,
10
+ MeEmailChangeStartRequest,
11
+ MeEmailChangeStartResponse,
12
+ MeEmailChangeVerifyNewRequest,
13
+ MeEmailChangeVerifyNewResponse,
14
+ MeEmailChangeVerifyOldRequest,
15
+ MeEmailChangeVerifyOldResponse,
16
+ MeIdentitiesListResponse,
17
+ MeIdentityLinkRequest,
18
+ MeIdentityLinkResponse,
19
+ MeIdentityUnlinkResponse,
20
+ MePasskeyRegistrationOptionsResponse,
21
+ MePasskeyRegistrationVerifyRequest,
22
+ MePasskeyRegistrationVerifyResponse,
23
+ MePasskeyDeleteResponse,
24
+ MePasskeysListResponse,
25
+ MeSessionRevokeResponse,
26
+ MeSessionsListResponse,
27
+ MeSessionsRevokeAllResponse,
28
+ MeUpdateRequest,
29
+ MeUpdateResponse,
30
+ MeUser,
31
+ PasskeyAuthenticationOptionsRequest,
32
+ PasskeyAuthenticationOptionsResponse,
33
+ PasskeyAuthenticationVerifyRequest,
34
+ PasskeyAuthenticationVerifyResponse,
35
+ PasskeyRegistrationOptionsResponse,
36
+ PasskeyRegistrationVerifyRequest,
37
+ PublicConfigResponse,
38
+ RefreshTokenGrantRequest,
39
+ RevocationRequest,
40
+ SocialProvider,
41
+ SocialStartResponse,
42
+ TokenResponse,
43
+ TransactionCancelResponse,
44
+ TransactionCompleteResponse,
45
+ TransactionCreateRequest,
46
+ TransactionCreateResponse,
47
+ TransactionGetResponse,
48
+ TransactionPasskeyRegistrationVerifyResponse,
49
+ } from "@smartcrab/contracts-public";
50
+ import {
51
+ clientIdSchema,
52
+ emailChallengeResponseSchema,
53
+ emailVerifyResponseSchema,
54
+ meDeleteResponseSchema,
55
+ meEmailChangeStartResponseSchema,
56
+ meEmailChangeVerifyNewResponseSchema,
57
+ meEmailChangeVerifyOldResponseSchema,
58
+ meIdentitiesListResponseSchema,
59
+ meIdentityLinkResponseSchema,
60
+ meIdentityUnlinkResponseSchema,
61
+ mePasskeyDeleteResponseSchema,
62
+ mePasskeyRegistrationOptionsResponseSchema,
63
+ mePasskeyRegistrationVerifyResponseSchema,
64
+ mePasskeysListResponseSchema,
65
+ meSessionRevokeResponseSchema,
66
+ meSessionsListResponseSchema,
67
+ meSessionsRevokeAllResponseSchema,
68
+ meUpdateResponseSchema,
69
+ meUserSchema,
70
+ passkeyAuthenticationOptionsResponseSchema,
71
+ passkeyAuthenticationVerifyResponseSchema,
72
+ passkeyRegistrationOptionsResponseSchema,
73
+ publicConfigResponseSchema,
74
+ scopeStringSchema,
75
+ socialStartResponseSchema,
76
+ tokenResponseSchema,
77
+ transactionCancelResponseSchema,
78
+ transactionCompleteResponseSchema,
79
+ transactionCreateResponseSchema,
80
+ transactionGetResponseSchema,
81
+ transactionPasskeyRegistrationVerifyResponseSchema,
82
+ } from "@smartcrab/contracts-public";
83
+ import { err, ok } from "@smartcrab/contracts-public";
84
+ import type { Outcome } from "@smartcrab/contracts-public";
85
+ import type { BrowserAuthError } from "./errors.js";
86
+ import type { FetchPort } from "./http.js";
87
+ import { buildUrl, isOAuthErrorCode, requestJson } from "./http.js";
88
+ import type { WireSchema } from "./http.js";
89
+ import { generateNonce, generatePkcePair, generateState } from "./pkce.js";
90
+ import { navigatorWebAuthnPort } from "./webauthn.js";
91
+ import type { WebAuthnBrowserPort } from "./webauthn.js";
92
+
93
+ /**
94
+ * `BrowserAuthClient` — the SPA-facing client for the public authentication
95
+ * API (design.md §16, §22.3).
96
+ *
97
+ * Security posture (design.md §22.3, §32.1/§32.2):
98
+ * - Authorization Code + PKCE S256; `state` and `nonce` are generated when not supplied.
99
+ * - Access and refresh tokens stay in memory by default; access tokens never enter Web Storage.
100
+ * - Short-lived PKCE callback metadata is stored in `sessionStorage` (never `localStorage`) so a
101
+ * top-level redirect can resume after the SPA reloads. It is consumed once and expires after
102
+ * 10 minutes; when storage is unavailable, the current client falls back to its memory copy.
103
+ * - Refresh-token persistence is a separate explicit opt-in via `refreshTokenStorage`.
104
+ * - No silent-iframe / third-party-cookie renewal. After reload, reauthorize top-level via
105
+ * `beginHostedAuthorization()` against the existing Hosted Session, or call `restoreSession()`
106
+ * when opt-in refresh-token persistence is configured.
107
+ *
108
+ * Result channel: `@praha/byethrow` does not resolve from this package (not a
109
+ * declared dependency), so every fallible method returns the local
110
+ * `Outcome<T, BrowserAuthError>` discriminated union re-exported from
111
+ * `@smartcrab/contracts-public` instead of `Result`.
112
+ */
113
+
114
+ // ---------------------------------------------------------------------------
115
+ // Public types
116
+ // ---------------------------------------------------------------------------
117
+
118
+ /** Top-level navigation port (design.md §20.6/§22.3: external, top-level redirects only). */
119
+ export interface RedirectPort {
120
+ assign(url: string): void;
121
+ }
122
+
123
+ /**
124
+ * OPT-IN refresh token persistence (design.md §22.3: "refresh tokenの永続保存
125
+ * は既定で無効").
126
+ *
127
+ * WARNING — think before enabling: anything written here survives page
128
+ * reloads and is readable by every script running on your origin, including
129
+ * any XSS payload. Prefer the default (memory-only) and re-authorize through
130
+ * the Hosted Session after reload. If you enable this, use the most
131
+ * constrained storage available to you (e.g. an encrypted, origin-isolated
132
+ * store), never plain `localStorage` on a hostile-script surface, and combine
133
+ * it with a strict CSP (design.md §32.2). The platform rotates refresh tokens
134
+ * on every use and detects replay (design.md §32.1), which limits — but does
135
+ * not eliminate — the blast radius of a stolen token.
136
+ */
137
+ export interface RefreshTokenStoragePort {
138
+ load(): Promise<string | null> | string | null;
139
+ save(refreshToken: string): Promise<void> | void;
140
+ clear(): Promise<void> | void;
141
+ }
142
+ /**
143
+ * Short-lived PKCE metadata needed to resume a top-level authorization
144
+ * redirect. This is not a token set; the browser client stores it in
145
+ * sessionStorage under a key derived from `state`.
146
+ */
147
+ interface PendingAuthorization {
148
+ readonly state: string;
149
+ readonly nonce: string;
150
+ readonly codeVerifier: string;
151
+ readonly redirectUri: string;
152
+ readonly createdAt: number;
153
+ }
154
+
155
+ export interface BrowserAuthClientConfig {
156
+ /** Public API origin, e.g. `https://id.example.com` (trailing slash tolerated). */
157
+ readonly baseUrl: string;
158
+ readonly clientId: ClientId;
159
+ /** Must be byte-for-byte registered on the client (design.md §15.9). */
160
+ readonly redirectUri: string;
161
+ /** Space-delimited OIDC scopes; defaults to `openid profile email`. */
162
+ readonly scope?: string;
163
+ /** RFC 8707 resource indicators to bind to the grant (design.md §15.7). */
164
+ readonly resource?: readonly string[];
165
+ /**
166
+ * Issuer (`https://host/e/env_xxx`). Optional: discovered and cached from
167
+ * `GET /v1/config` on first token/authorize call when omitted.
168
+ */
169
+ readonly issuer?: string;
170
+ /** fetch override (tests, custom instrumentation). Defaults to global `fetch`. */
171
+ readonly fetch?: FetchPort;
172
+ /** WebAuthn ceremony port. Defaults to `navigator.credentials`. */
173
+ readonly webAuthn?: WebAuthnBrowserPort;
174
+ /** Top-level navigation port. Defaults to `window.location.assign`. */
175
+ readonly redirect?: RedirectPort;
176
+ /** OPT-IN refresh token persistence — read the security warning on the type. */
177
+ readonly refreshTokenStorage?: RefreshTokenStoragePort;
178
+ /** Clock override for tests (epoch ms). */
179
+ readonly now?: () => number;
180
+ }
181
+
182
+ /** A live auth transaction plus the client-generated `state` that correlates its completion. */
183
+ export interface BrowserTransaction {
184
+ readonly transactionId: string;
185
+ readonly state: string;
186
+ readonly expiresAt: string;
187
+ readonly availableMethods: TransactionCreateResponse["available_methods"];
188
+ }
189
+
190
+ export interface CreateTransactionInput {
191
+ readonly scope?: string;
192
+ readonly resource?: readonly string[];
193
+ /** App-supplied correlation value; the SDK generates one when omitted. */
194
+ readonly state?: string;
195
+ readonly nonce?: string;
196
+ }
197
+
198
+ /** Session metadata. Note: the access token itself is only ever handed out by `getAccessToken()`. */
199
+ export interface AuthenticatedSession {
200
+ /** Epoch ms when the in-memory access token expires. */
201
+ readonly accessTokenExpiresAt: number;
202
+ readonly scope: string;
203
+ readonly hasRefreshToken: boolean;
204
+ }
205
+
206
+ /** Auth state snapshot for `subscribe`/`getSnapshot` (React bindings consume this). */
207
+ export interface BrowserAuthSnapshot {
208
+ readonly status: "signed_out" | "signed_in";
209
+ readonly accessTokenExpiresAt?: number;
210
+ readonly scope?: string;
211
+ readonly hasRefreshToken: boolean;
212
+ }
213
+
214
+ export type BrowserAuthListener = (snapshot: BrowserAuthSnapshot) => void;
215
+
216
+ export interface HostedAuthorization {
217
+ readonly url: string;
218
+ readonly state: string;
219
+ }
220
+
221
+ export interface BrowserAuthClient {
222
+ /** `GET /v1/config` (design.md §16.2), schema-validated. */
223
+ getPublicConfig(): Promise<Outcome<PublicConfigResponse, BrowserAuthError>>;
224
+
225
+ // -- Transactions (design.md §16.1-§16.3) ---------------------------------
226
+ createTransaction(
227
+ input?: CreateTransactionInput,
228
+ ): Promise<Outcome<BrowserTransaction, BrowserAuthError>>;
229
+ getTransaction(transactionId: string): Promise<Outcome<TransactionGetResponse, BrowserAuthError>>;
230
+ cancelTransaction(
231
+ transactionId: string,
232
+ ): Promise<Outcome<TransactionCancelResponse, BrowserAuthError>>;
233
+
234
+ // -- Email OTP (design.md §18) ---------------------------------------------
235
+ startEmailOtp(
236
+ transactionId: string,
237
+ input: EmailStartRequest,
238
+ ): Promise<Outcome<EmailChallengeResponse, BrowserAuthError>>;
239
+ resendEmailOtp(
240
+ transactionId: string,
241
+ input: EmailResendRequest,
242
+ ): Promise<Outcome<EmailChallengeResponse, BrowserAuthError>>;
243
+ verifyEmailOtp(
244
+ transactionId: string,
245
+ input: EmailVerifyRequest,
246
+ ): Promise<Outcome<EmailVerifyResponse, BrowserAuthError>>;
247
+
248
+ // -- Passkeys (design.md §17) ----------------------------------------------
249
+ getPasskeyRegistrationOptions(
250
+ transactionId: string,
251
+ ): Promise<Outcome<PasskeyRegistrationOptionsResponse, BrowserAuthError>>;
252
+ verifyPasskeyRegistration(
253
+ transactionId: string,
254
+ input: PasskeyRegistrationVerifyRequest,
255
+ ): Promise<Outcome<TransactionPasskeyRegistrationVerifyResponse, BrowserAuthError>>;
256
+ /** Full registration ceremony: options → authenticator → verify. */
257
+ startPasskeyRegistration(
258
+ transactionId: string,
259
+ input?: { readonly name?: string },
260
+ ): Promise<Outcome<TransactionPasskeyRegistrationVerifyResponse, BrowserAuthError>>;
261
+ getPasskeyAuthenticationOptions(
262
+ transactionId: string,
263
+ input?: PasskeyAuthenticationOptionsRequest,
264
+ ): Promise<Outcome<PasskeyAuthenticationOptionsResponse, BrowserAuthError>>;
265
+ verifyPasskeyAuthentication(
266
+ transactionId: string,
267
+ input: PasskeyAuthenticationVerifyRequest,
268
+ ): Promise<Outcome<PasskeyAuthenticationVerifyResponse, BrowserAuthError>>;
269
+ /** Full authentication ceremony (discoverable-credential default, design.md §17.2). */
270
+ startPasskeyAuthentication(
271
+ transactionId: string,
272
+ input?: PasskeyAuthenticationOptionsRequest,
273
+ ): Promise<Outcome<PasskeyAuthenticationVerifyResponse, BrowserAuthError>>;
274
+
275
+ // -- Social (design.md §20) --------------------------------------------------
276
+ /**
277
+ * Starts social sign-in and performs a TOP-LEVEL redirect to the returned
278
+ * `authorization_url` (embedded iframes/WebViews are never used). The
279
+ * provider callback eventually lands on `redirectUri`; resume with
280
+ * `handleHostedAuthorizationCallback`.
281
+ */
282
+ startSocialSignIn(
283
+ transactionId: string,
284
+ provider: SocialProvider,
285
+ ): Promise<Outcome<SocialStartResponse, BrowserAuthError>>;
286
+
287
+ // -- Completion / code exchange (design.md §16.3, §15.2) ---------------------
288
+ completeTransaction(
289
+ transactionId: string,
290
+ ): Promise<Outcome<TransactionCompleteResponse, BrowserAuthError>>;
291
+ /**
292
+ * Exchanges a one-time authorization code at the token endpoint using the
293
+ * PKCE verifier registered under `state`. Fails with `state_mismatch` when
294
+ * the state is unknown or expired.
295
+ */
296
+ exchangeAuthorizationCode(input: {
297
+ readonly code: string;
298
+ readonly state: string;
299
+ }): Promise<Outcome<AuthenticatedSession, BrowserAuthError>>;
300
+
301
+ // -- Hosted (redirect) authorization ------------------------------------------
302
+ /** Builds `{issuer}/oauth2/authorize` with fresh PKCE/state/nonce (design.md §15.1). */
303
+ createHostedAuthorizationUrl(): Promise<Outcome<HostedAuthorization, BrowserAuthError>>;
304
+ /** Same as `createHostedAuthorizationUrl` plus a top-level navigation. */
305
+ beginHostedAuthorization(): Promise<Outcome<{ readonly state: string }, BrowserAuthError>>;
306
+ /** Parses the authorize/social callback URL and exchanges the code it carries. */
307
+ handleHostedAuthorizationCallback(
308
+ callbackUrl: string,
309
+ ): Promise<Outcome<AuthenticatedSession, BrowserAuthError>>;
310
+
311
+ // -- Token/session management ---------------------------------------------------
312
+ getSnapshot(): BrowserAuthSnapshot;
313
+ subscribe(listener: BrowserAuthListener): () => void;
314
+ /** Runtime WebAuthn capability (design.md §21.2: UI must hide passkeys when false). */
315
+ isPasskeySupported(): boolean;
316
+ /** Current access token; silently refreshes first when expired and a refresh token exists. */
317
+ getAccessToken(): Promise<Outcome<string, BrowserAuthError>>;
318
+ /** Explicit refresh. Concurrent calls share one in-flight request (design.md §21.7). */
319
+ refreshSession(): Promise<Outcome<AuthenticatedSession, BrowserAuthError>>;
320
+ /**
321
+ * Reload recovery for the OPT-IN persistence path: loads the persisted
322
+ * refresh token and immediately rotates it into a fresh in-memory session.
323
+ * Without `refreshTokenStorage` (or an empty store) this resolves to
324
+ * `not_authenticated` and the app should start a fresh authorization.
325
+ */
326
+ restoreSession(): Promise<Outcome<AuthenticatedSession, BrowserAuthError>>;
327
+ /** Best-effort refresh-token revocation, then full local cleanup. */
328
+ signOut(): Promise<Outcome<{ readonly signedOut: true }, BrowserAuthError>>;
329
+
330
+ // -- /v1/me (design.md §16.2; all Bearer-authenticated) --------------------------
331
+ getMe(): Promise<Outcome<MeUser, BrowserAuthError>>;
332
+ updateMe(input: MeUpdateRequest): Promise<Outcome<MeUpdateResponse, BrowserAuthError>>;
333
+ listMyPasskeys(): Promise<Outcome<MePasskeysListResponse, BrowserAuthError>>;
334
+ getMyPasskeyRegistrationOptions(): Promise<
335
+ Outcome<MePasskeyRegistrationOptionsResponse, BrowserAuthError>
336
+ >;
337
+ verifyMyPasskeyRegistration(
338
+ input: MePasskeyRegistrationVerifyRequest,
339
+ ): Promise<Outcome<MePasskeyRegistrationVerifyResponse, BrowserAuthError>>;
340
+ /** Full me-scoped registration ceremony: options → authenticator → verify. */
341
+ registerMyPasskey(input?: {
342
+ readonly name?: string;
343
+ }): Promise<Outcome<MePasskeyRegistrationVerifyResponse, BrowserAuthError>>;
344
+ deleteMyPasskey(passkeyId: string): Promise<Outcome<MePasskeyDeleteResponse, BrowserAuthError>>;
345
+ listMyIdentities(): Promise<Outcome<MeIdentitiesListResponse, BrowserAuthError>>;
346
+ linkMyIdentity(
347
+ provider: SocialProvider,
348
+ input: MeIdentityLinkRequest,
349
+ ): Promise<Outcome<MeIdentityLinkResponse, BrowserAuthError>>;
350
+ unlinkMyIdentity(
351
+ identityId: string,
352
+ ): Promise<Outcome<MeIdentityUnlinkResponse, BrowserAuthError>>;
353
+ listMySessions(): Promise<Outcome<MeSessionsListResponse, BrowserAuthError>>;
354
+ revokeMySession(sessionId: string): Promise<Outcome<MeSessionRevokeResponse, BrowserAuthError>>;
355
+ revokeAllMySessions(): Promise<Outcome<MeSessionsRevokeAllResponse, BrowserAuthError>>;
356
+ startMyEmailChange(
357
+ input: MeEmailChangeStartRequest,
358
+ ): Promise<Outcome<MeEmailChangeStartResponse, BrowserAuthError>>;
359
+ verifyMyEmailChangeOld(
360
+ input: MeEmailChangeVerifyOldRequest,
361
+ ): Promise<Outcome<MeEmailChangeVerifyOldResponse, BrowserAuthError>>;
362
+ verifyMyEmailChangeNew(
363
+ input: MeEmailChangeVerifyNewRequest,
364
+ ): Promise<Outcome<MeEmailChangeVerifyNewResponse, BrowserAuthError>>;
365
+ deleteMyAccount(): Promise<Outcome<MeDeleteResponse, BrowserAuthError>>;
366
+ }
367
+
368
+ // ---------------------------------------------------------------------------
369
+ // Implementation
370
+ // ---------------------------------------------------------------------------
371
+
372
+ const EXPIRY_SKEW_MS = 30_000;
373
+ /** Matches the transaction lifetime implied by design.md §16.1's example (10 minutes). */
374
+ const PENDING_AUTHORIZATION_TTL_MS = 10 * 60 * 1000;
375
+
376
+ interface TokenSet {
377
+ accessToken: string;
378
+ /** Epoch ms of expiry (before skew). */
379
+ accessTokenExpiresAt: number;
380
+ scope: string;
381
+ refreshToken: string | null;
382
+ }
383
+
384
+ const defaultFetchPort: FetchPort = (url, init) => fetch(url, init);
385
+
386
+ const defaultRedirectPort: RedirectPort = {
387
+ assign: (url) => {
388
+ if (typeof window !== "undefined") {
389
+ window.location.assign(url);
390
+ }
391
+ },
392
+ };
393
+ const PENDING_AUTHORIZATION_STORAGE_PREFIX = "@smartcrab/browser:pending:";
394
+
395
+ const isPendingAuthorization = (value: unknown): value is PendingAuthorization => {
396
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
397
+ const record = value as Record<string, unknown>;
398
+ return (
399
+ typeof record["state"] === "string" &&
400
+ typeof record["nonce"] === "string" &&
401
+ typeof record["codeVerifier"] === "string" &&
402
+ typeof record["redirectUri"] === "string" &&
403
+ typeof record["createdAt"] === "number" &&
404
+ Number.isFinite(record["createdAt"])
405
+ );
406
+ };
407
+
408
+ const encodeSegment = encodeURIComponent;
409
+
410
+ export const createBrowserAuthClient = (config: BrowserAuthClientConfig): BrowserAuthClient => {
411
+ // Programmer-error validation: fail fast at factory time (not a runtime
412
+ // failure channel, hence a TypeError rather than an Outcome).
413
+ if (!URL.canParse(config.baseUrl)) {
414
+ throw new TypeError(`baseUrl is not a valid URL: ${config.baseUrl}`);
415
+ }
416
+ if (!clientIdSchema.safeParse(config.clientId).success) {
417
+ throw new TypeError("clientId is not a valid cli_ identifier");
418
+ }
419
+ const scope = config.scope ?? "openid profile email";
420
+ if (!scopeStringSchema.safeParse(scope).success) {
421
+ throw new TypeError(`scope contains unknown tokens: ${scope}`);
422
+ }
423
+ if (config.redirectUri.length === 0) {
424
+ throw new TypeError("redirectUri must not be empty");
425
+ }
426
+
427
+ const baseUrl = config.baseUrl.replace(/\/+$/, "");
428
+ const fetchPort = config.fetch ?? defaultFetchPort;
429
+ const webAuthn = config.webAuthn ?? navigatorWebAuthnPort;
430
+ const redirectPort = config.redirect ?? defaultRedirectPort;
431
+ const now = config.now ?? (() => Date.now());
432
+ const storage = config.refreshTokenStorage;
433
+
434
+ // Tokens stay in this closure; only short-lived PKCE callback state uses sessionStorage.
435
+ let tokenSet: TokenSet | null = null;
436
+ let cachedIssuer: string | null = config.issuer ?? null;
437
+ let inflightRefresh: Promise<Outcome<AuthenticatedSession, BrowserAuthError>> | null = null;
438
+ const pendingByState = new Map<string, PendingAuthorization>();
439
+ const listeners = new Set<BrowserAuthListener>();
440
+ let snapshot: BrowserAuthSnapshot = { status: "signed_out", hasRefreshToken: false };
441
+
442
+ const notify = (): void => {
443
+ snapshot =
444
+ tokenSet === null
445
+ ? { status: "signed_out", hasRefreshToken: false }
446
+ : {
447
+ status: "signed_in",
448
+ accessTokenExpiresAt: tokenSet.accessTokenExpiresAt,
449
+ scope: tokenSet.scope,
450
+ hasRefreshToken: tokenSet.refreshToken !== null,
451
+ };
452
+ for (const listener of listeners) {
453
+ listener(snapshot);
454
+ }
455
+ };
456
+
457
+ const prunePending = (): void => {
458
+ const cutoff = now() - PENDING_AUTHORIZATION_TTL_MS;
459
+ for (const [state, pending] of pendingByState) {
460
+ if (pending.createdAt < cutoff) pendingByState.delete(state);
461
+ }
462
+ };
463
+
464
+ // Storage is a navigation-recovery aid: unavailable storage (Node/SSR, blocked storage,
465
+ // quota) or malformed entries fall back to the in-memory copy. Exception boundaries are
466
+ // promise rejection handlers, never try/catch (scripts/check-backend-error-style.ts).
467
+ const registerPending = async (pending: PendingAuthorization): Promise<void> => {
468
+ prunePending();
469
+ pendingByState.set(pending.state, pending);
470
+ await Promise.resolve()
471
+ .then(() =>
472
+ sessionStorage.setItem(
473
+ `${PENDING_AUTHORIZATION_STORAGE_PREFIX}${pending.state}`,
474
+ JSON.stringify(pending),
475
+ ),
476
+ )
477
+ .then(undefined, () => undefined);
478
+ };
479
+
480
+ const consumePending = async (state: string): Promise<PendingAuthorization | null> => {
481
+ const inMemory = pendingByState.get(state) ?? null;
482
+ pendingByState.delete(state);
483
+ const stored = await Promise.resolve()
484
+ .then((): PendingAuthorization | null => {
485
+ const key = `${PENDING_AUTHORIZATION_STORAGE_PREFIX}${state}`;
486
+ const serialized = sessionStorage.getItem(key);
487
+ if (serialized === null) return null;
488
+ sessionStorage.removeItem(key);
489
+ const parsed: unknown = JSON.parse(serialized);
490
+ return isPendingAuthorization(parsed) && parsed.state === state ? parsed : null;
491
+ })
492
+ .then(undefined, () => null);
493
+ const pending = inMemory ?? stored;
494
+ if (pending === null || now() - pending.createdAt > PENDING_AUTHORIZATION_TTL_MS) {
495
+ return null;
496
+ }
497
+ return pending;
498
+ };
499
+
500
+ const applyTokenResponse = async (
501
+ tokenResponse: TokenResponse,
502
+ ): Promise<AuthenticatedSession> => {
503
+ const refreshToken = tokenResponse.refresh_token ?? tokenSet?.refreshToken ?? null;
504
+ tokenSet = {
505
+ accessToken: tokenResponse.access_token,
506
+ accessTokenExpiresAt: now() + tokenResponse.expires_in * 1000,
507
+ scope: tokenResponse.scope,
508
+ refreshToken,
509
+ };
510
+ const persistToken = tokenResponse.refresh_token;
511
+ if (persistToken !== undefined && storage !== undefined) {
512
+ // Best-effort: a failing storage port must not break authentication.
513
+ await Promise.resolve()
514
+ .then(() => storage.save(persistToken))
515
+ .then(undefined, () => undefined);
516
+ }
517
+ notify();
518
+ return {
519
+ accessTokenExpiresAt: tokenSet.accessTokenExpiresAt,
520
+ scope: tokenSet.scope,
521
+ hasRefreshToken: refreshToken !== null,
522
+ };
523
+ };
524
+
525
+ const clearSession = (): void => {
526
+ tokenSet = null;
527
+ if (storage !== undefined) {
528
+ void Promise.resolve()
529
+ .then(() => storage.clear())
530
+ .then(undefined, () => undefined);
531
+ }
532
+ notify();
533
+ };
534
+
535
+ const request = <T>(
536
+ method: "GET" | "POST" | "PATCH" | "DELETE",
537
+ path: string,
538
+ schema: WireSchema<T>,
539
+ options?: {
540
+ readonly body?: unknown;
541
+ readonly accessToken?: string;
542
+ readonly query?: Record<string, string>;
543
+ },
544
+ ): Promise<Outcome<T, BrowserAuthError>> =>
545
+ requestJson(
546
+ fetchPort,
547
+ {
548
+ method,
549
+ url: buildUrl(baseUrl, path, options?.query),
550
+ ...(options?.body !== undefined ? { body: options.body } : {}),
551
+ ...(options?.accessToken !== undefined ? { accessToken: options.accessToken } : {}),
552
+ },
553
+ schema,
554
+ );
555
+
556
+ const getPublicConfig = (): Promise<Outcome<PublicConfigResponse, BrowserAuthError>> =>
557
+ request("GET", "/v1/config", publicConfigResponseSchema, {
558
+ query: { client_id: config.clientId },
559
+ });
560
+
561
+ const resolveIssuer = async (): Promise<Outcome<string, BrowserAuthError>> => {
562
+ if (cachedIssuer !== null) return ok(cachedIssuer);
563
+ const configOutcome = await getPublicConfig();
564
+ if (!configOutcome.ok) return configOutcome;
565
+ cachedIssuer = configOutcome.value.issuer;
566
+ return ok(cachedIssuer);
567
+ };
568
+
569
+ // --- transactions -----------------------------------------------------------
570
+
571
+ const createTransaction = async (
572
+ input?: CreateTransactionInput,
573
+ ): Promise<Outcome<BrowserTransaction, BrowserAuthError>> => {
574
+ const pkce = await generatePkcePair();
575
+ const state = input?.state ?? generateState();
576
+ const nonce = input?.nonce ?? generateNonce();
577
+ const resource = input?.resource ?? config.resource;
578
+ const body: TransactionCreateRequest = {
579
+ client_id: config.clientId,
580
+ redirect_uri: config.redirectUri,
581
+ code_challenge: pkce.challenge,
582
+ code_challenge_method: "S256",
583
+ state,
584
+ nonce,
585
+ scope: input?.scope ?? scope,
586
+ ...(resource !== undefined ? { resource: [...resource] } : {}),
587
+ // The SPA drives the ceremony itself and calls `/complete` over XHR
588
+ // (design.md §16.3), which is the `native_return` mode.
589
+ response_mode: "native_return",
590
+ };
591
+ const outcome = await request(
592
+ "POST",
593
+ "/v1/auth/transactions",
594
+ transactionCreateResponseSchema,
595
+ { body },
596
+ );
597
+ if (!outcome.ok) return outcome;
598
+ await registerPending({
599
+ state,
600
+ nonce,
601
+ codeVerifier: pkce.verifier,
602
+ redirectUri: config.redirectUri,
603
+ createdAt: now(),
604
+ });
605
+ return ok({
606
+ transactionId: outcome.value.transaction_id,
607
+ state,
608
+ expiresAt: outcome.value.expires_at,
609
+ availableMethods: outcome.value.available_methods,
610
+ });
611
+ };
612
+
613
+ const transactionPath = (transactionId: string): string =>
614
+ `/v1/auth/transactions/${encodeSegment(transactionId)}`;
615
+
616
+ const getTransaction = (transactionId: string) =>
617
+ request("GET", transactionPath(transactionId), transactionGetResponseSchema);
618
+
619
+ const cancelTransaction = (transactionId: string) =>
620
+ request("POST", `${transactionPath(transactionId)}/cancel`, transactionCancelResponseSchema, {
621
+ body: {},
622
+ });
623
+
624
+ // --- email OTP -----------------------------------------------------------------
625
+
626
+ const startEmailOtp = (transactionId: string, input: EmailStartRequest) =>
627
+ request("POST", `${transactionPath(transactionId)}/email/start`, emailChallengeResponseSchema, {
628
+ body: input,
629
+ });
630
+
631
+ const resendEmailOtp = (transactionId: string, input: EmailResendRequest) =>
632
+ request(
633
+ "POST",
634
+ `${transactionPath(transactionId)}/email/resend`,
635
+ emailChallengeResponseSchema,
636
+ { body: input },
637
+ );
638
+
639
+ const verifyEmailOtp = (transactionId: string, input: EmailVerifyRequest) =>
640
+ request("POST", `${transactionPath(transactionId)}/email/verify`, emailVerifyResponseSchema, {
641
+ body: input,
642
+ });
643
+
644
+ // --- passkeys --------------------------------------------------------------------
645
+
646
+ const getPasskeyRegistrationOptions = (transactionId: string) =>
647
+ request(
648
+ "POST",
649
+ `${transactionPath(transactionId)}/passkeys/registration/options`,
650
+ passkeyRegistrationOptionsResponseSchema,
651
+ {
652
+ body: {},
653
+ },
654
+ );
655
+
656
+ const verifyPasskeyRegistration = (
657
+ transactionId: string,
658
+ input: PasskeyRegistrationVerifyRequest,
659
+ ) =>
660
+ request(
661
+ "POST",
662
+ `${transactionPath(transactionId)}/passkeys/registration/verify`,
663
+ transactionPasskeyRegistrationVerifyResponseSchema,
664
+ { body: input },
665
+ );
666
+
667
+ const startPasskeyRegistration = async (
668
+ transactionId: string,
669
+ input?: { readonly name?: string },
670
+ ): Promise<Outcome<TransactionPasskeyRegistrationVerifyResponse, BrowserAuthError>> => {
671
+ if (!webAuthn.isAvailable()) {
672
+ return err({
673
+ type: "webauthn_error",
674
+ reason: "not_supported",
675
+ message: "WebAuthn is not available",
676
+ });
677
+ }
678
+ const options = await getPasskeyRegistrationOptions(transactionId);
679
+ if (!options.ok) return options;
680
+ const credential = await webAuthn.create(options.value);
681
+ if (!credential.ok) return credential;
682
+ return verifyPasskeyRegistration(transactionId, {
683
+ credential: credential.value,
684
+ ...(input?.name !== undefined ? { name: input.name } : {}),
685
+ });
686
+ };
687
+
688
+ const getPasskeyAuthenticationOptions = (
689
+ transactionId: string,
690
+ input?: PasskeyAuthenticationOptionsRequest,
691
+ ) =>
692
+ request(
693
+ "POST",
694
+ `${transactionPath(transactionId)}/passkeys/authentication/options`,
695
+ passkeyAuthenticationOptionsResponseSchema,
696
+ { body: input ?? {} },
697
+ );
698
+
699
+ const verifyPasskeyAuthentication = (
700
+ transactionId: string,
701
+ input: PasskeyAuthenticationVerifyRequest,
702
+ ) =>
703
+ request(
704
+ "POST",
705
+ `${transactionPath(transactionId)}/passkeys/authentication/verify`,
706
+ passkeyAuthenticationVerifyResponseSchema,
707
+ { body: input },
708
+ );
709
+
710
+ const startPasskeyAuthentication = async (
711
+ transactionId: string,
712
+ input?: PasskeyAuthenticationOptionsRequest,
713
+ ): Promise<Outcome<PasskeyAuthenticationVerifyResponse, BrowserAuthError>> => {
714
+ if (!webAuthn.isAvailable()) {
715
+ return err({
716
+ type: "webauthn_error",
717
+ reason: "not_supported",
718
+ message: "WebAuthn is not available",
719
+ });
720
+ }
721
+ const options = await getPasskeyAuthenticationOptions(transactionId, input);
722
+ if (!options.ok) return options;
723
+ const credential = await webAuthn.get(options.value);
724
+ if (!credential.ok) return credential;
725
+ return verifyPasskeyAuthentication(transactionId, { credential: credential.value });
726
+ };
727
+
728
+ // --- social -----------------------------------------------------------------------
729
+
730
+ const startSocialSignIn = async (
731
+ transactionId: string,
732
+ provider: SocialProvider,
733
+ ): Promise<Outcome<SocialStartResponse, BrowserAuthError>> => {
734
+ const outcome = await request(
735
+ "POST",
736
+ `${transactionPath(transactionId)}/social/${encodeSegment(provider)}/start`,
737
+ socialStartResponseSchema,
738
+ { body: {} },
739
+ );
740
+ if (!outcome.ok) return outcome;
741
+ // design.md §22.3/§20.6: top-level navigation only; the SPA resumes via
742
+ // the redirect back to `redirectUri`.
743
+ redirectPort.assign(outcome.value.authorization_url);
744
+ return outcome;
745
+ };
746
+
747
+ // --- completion + token exchange ------------------------------------------------------
748
+
749
+ const completeTransaction = async (
750
+ transactionId: string,
751
+ ): Promise<Outcome<TransactionCompleteResponse, BrowserAuthError>> => {
752
+ const outcome = await request(
753
+ "POST",
754
+ `${transactionPath(transactionId)}/complete`,
755
+ transactionCompleteResponseSchema,
756
+ {
757
+ body: {},
758
+ },
759
+ );
760
+ if (!outcome.ok) return outcome;
761
+ if (outcome.value.redirect_uri !== config.redirectUri) {
762
+ // design.md §15.9: exact redirect matching. A mismatch here is a server
763
+ // contract violation (or an attack), never a usable code.
764
+ return err({
765
+ type: "invalid_response",
766
+ message: "complete response redirect_uri does not match the configured redirectUri",
767
+ });
768
+ }
769
+ return outcome;
770
+ };
771
+
772
+ const exchangeAuthorizationCode = async (input: {
773
+ readonly code: string;
774
+ readonly state: string;
775
+ }): Promise<Outcome<AuthenticatedSession, BrowserAuthError>> => {
776
+ const pending = await consumePending(input.state);
777
+ if (pending === null) {
778
+ // Unknown, expired, or already-consumed state: possible CSRF/replay.
779
+ return err({ type: "state_mismatch" });
780
+ }
781
+ const issuerOutcome = await resolveIssuer();
782
+ if (!issuerOutcome.ok) return issuerOutcome;
783
+ const body: AuthorizationCodeGrantRequest = {
784
+ grant_type: "authorization_code",
785
+ code: input.code,
786
+ redirect_uri: pending.redirectUri,
787
+ code_verifier: pending.codeVerifier,
788
+ client_id: config.clientId,
789
+ ...(config.resource !== undefined ? { resource: [...config.resource] } : {}),
790
+ };
791
+ const outcome = await requestJson(
792
+ fetchPort,
793
+ { method: "POST", url: `${issuerOutcome.value}/oauth2/token`, body },
794
+ tokenResponseSchema,
795
+ );
796
+ if (!outcome.ok) return outcome;
797
+ return ok(await applyTokenResponse(outcome.value));
798
+ };
799
+
800
+ // --- hosted (redirect) authorization ------------------------------------------------------
801
+
802
+ const createHostedAuthorizationUrl = async (): Promise<
803
+ Outcome<HostedAuthorization, BrowserAuthError>
804
+ > => {
805
+ const issuerOutcome = await resolveIssuer();
806
+ if (!issuerOutcome.ok) return issuerOutcome;
807
+ const pkce = await generatePkcePair();
808
+ const state = generateState();
809
+ const nonce = generateNonce();
810
+ const params = new URLSearchParams();
811
+ params.set("response_type", "code");
812
+ params.set("client_id", config.clientId);
813
+ params.set("redirect_uri", config.redirectUri);
814
+ params.set("scope", scope);
815
+ params.set("state", state);
816
+ params.set("nonce", nonce);
817
+ params.set("code_challenge", pkce.challenge);
818
+ params.set("code_challenge_method", "S256");
819
+ for (const resource of config.resource ?? []) {
820
+ params.append("resource", resource);
821
+ }
822
+ await registerPending({
823
+ state,
824
+ nonce,
825
+ codeVerifier: pkce.verifier,
826
+ redirectUri: config.redirectUri,
827
+ createdAt: now(),
828
+ });
829
+ return ok({ url: `${issuerOutcome.value}/oauth2/authorize?${params.toString()}`, state });
830
+ };
831
+
832
+ const beginHostedAuthorization = async (): Promise<
833
+ Outcome<{ readonly state: string }, BrowserAuthError>
834
+ > => {
835
+ const outcome = await createHostedAuthorizationUrl();
836
+ if (!outcome.ok) return outcome;
837
+ redirectPort.assign(outcome.value.url);
838
+ return ok({ state: outcome.value.state });
839
+ };
840
+
841
+ const handleHostedAuthorizationCallback = async (
842
+ callbackUrl: string,
843
+ ): Promise<Outcome<AuthenticatedSession, BrowserAuthError>> => {
844
+ if (!URL.canParse(callbackUrl)) {
845
+ return err({ type: "invalid_response", message: "callback URL is not parseable" });
846
+ }
847
+ const url = new URL(callbackUrl);
848
+ const state = url.searchParams.get("state");
849
+ const errorParam = url.searchParams.get("error");
850
+ if (errorParam !== null) {
851
+ if (state !== null) await consumePending(state);
852
+ // RFC 6749 §4.1.2.1 error redirect. The code list is closed; an unknown
853
+ // value means the callback is not contract-shaped.
854
+ if (!isOAuthErrorCode(errorParam)) {
855
+ return err({
856
+ type: "invalid_response",
857
+ message: `callback carried an unknown OAuth error: ${errorParam}`,
858
+ });
859
+ }
860
+ const description = url.searchParams.get("error_description");
861
+ return err({
862
+ type: "oauth_error",
863
+ code: errorParam,
864
+ ...(description !== null ? { description } : {}),
865
+ });
866
+ }
867
+ const code = url.searchParams.get("code");
868
+ if (code === null || state === null) {
869
+ if (state !== null) await consumePending(state);
870
+ return err({ type: "invalid_response", message: "callback URL is missing code or state" });
871
+ }
872
+ return exchangeAuthorizationCode({ code, state });
873
+ };
874
+
875
+ // --- token / session management -------------------------------------------------------------
876
+
877
+ const refreshSession = (): Promise<Outcome<AuthenticatedSession, BrowserAuthError>> => {
878
+ if (inflightRefresh !== null) return inflightRefresh;
879
+ const pending = (async (): Promise<Outcome<AuthenticatedSession, BrowserAuthError>> => {
880
+ const refreshToken = tokenSet?.refreshToken ?? null;
881
+ if (refreshToken === null) {
882
+ return err({ type: "not_authenticated" });
883
+ }
884
+ const issuerOutcome = await resolveIssuer();
885
+ if (!issuerOutcome.ok) return issuerOutcome;
886
+ const body: RefreshTokenGrantRequest = {
887
+ grant_type: "refresh_token",
888
+ refresh_token: refreshToken,
889
+ client_id: config.clientId,
890
+ ...(config.resource !== undefined ? { resource: [...config.resource] } : {}),
891
+ };
892
+ const outcome = await requestJson(
893
+ fetchPort,
894
+ { method: "POST", url: `${issuerOutcome.value}/oauth2/token`, body },
895
+ tokenResponseSchema,
896
+ );
897
+ if (!outcome.ok) {
898
+ // design.md §21.7: invalid_grant kills the local session entirely;
899
+ // transient failures (network/5xx/429) must not.
900
+ if (outcome.error.type === "oauth_error" && outcome.error.code === "invalid_grant") {
901
+ clearSession();
902
+ }
903
+ return outcome;
904
+ }
905
+ return ok(await applyTokenResponse(outcome.value));
906
+ })();
907
+ inflightRefresh = pending;
908
+ const clearInflight = (): void => {
909
+ if (inflightRefresh === pending) inflightRefresh = null;
910
+ };
911
+ void pending.then(clearInflight, clearInflight);
912
+ return pending;
913
+ };
914
+
915
+ const getAccessToken = async (): Promise<Outcome<string, BrowserAuthError>> => {
916
+ if (tokenSet !== null && now() < tokenSet.accessTokenExpiresAt - EXPIRY_SKEW_MS) {
917
+ return ok(tokenSet.accessToken);
918
+ }
919
+ if (tokenSet !== null && tokenSet.refreshToken !== null) {
920
+ const refreshed = await refreshSession();
921
+ if (!refreshed.ok) return refreshed;
922
+ const current = tokenSet;
923
+ if (current === null) return err({ type: "not_authenticated" });
924
+ return ok(current.accessToken);
925
+ }
926
+ return err({ type: "not_authenticated" });
927
+ };
928
+
929
+ const restoreSession = async (): Promise<Outcome<AuthenticatedSession, BrowserAuthError>> => {
930
+ if (storage === undefined) return err({ type: "not_authenticated" });
931
+ // A rejecting storage port is treated as "nothing persisted".
932
+ const persisted: string | null = await Promise.resolve()
933
+ .then(() => storage.load())
934
+ .then(
935
+ (value) => value,
936
+ () => null,
937
+ );
938
+ if (persisted === null) return err({ type: "not_authenticated" });
939
+ tokenSet = {
940
+ accessToken: "",
941
+ accessTokenExpiresAt: 0,
942
+ scope: "",
943
+ refreshToken: persisted,
944
+ };
945
+ return refreshSession();
946
+ };
947
+
948
+ const signOut = async (): Promise<Outcome<{ readonly signedOut: true }, BrowserAuthError>> => {
949
+ const refreshToken = tokenSet?.refreshToken ?? null;
950
+ if (refreshToken !== null && cachedIssuer !== null) {
951
+ // Best-effort revocation (RFC 7009; design.md §15.8): the endpoint
952
+ // succeeds even for unknown tokens, and local cleanup proceeds
953
+ // regardless of the outcome.
954
+ const body: RevocationRequest = {
955
+ token: refreshToken,
956
+ token_type_hint: "refresh_token",
957
+ client_id: config.clientId,
958
+ };
959
+ await requestJson(
960
+ fetchPort,
961
+ { method: "POST", url: `${cachedIssuer}/oauth2/revoke`, body },
962
+ {
963
+ safeParse: () => ({ success: true as const, data: {} }),
964
+ },
965
+ );
966
+ }
967
+ clearSession();
968
+ return ok({ signedOut: true });
969
+ };
970
+
971
+ // --- /v1/me -------------------------------------------------------------------------
972
+
973
+ const authed = async <T>(
974
+ method: "GET" | "POST" | "PATCH" | "DELETE",
975
+ path: string,
976
+ schema: WireSchema<T>,
977
+ body?: unknown,
978
+ ): Promise<Outcome<T, BrowserAuthError>> => {
979
+ const token = await getAccessToken();
980
+ if (!token.ok) return token;
981
+ return request(method, path, schema, {
982
+ accessToken: token.value,
983
+ ...(body !== undefined ? { body } : {}),
984
+ });
985
+ };
986
+
987
+ const getMe = () => authed("GET", "/v1/me", meUserSchema);
988
+
989
+ const updateMe = (input: MeUpdateRequest) =>
990
+ authed("PATCH", "/v1/me", meUpdateResponseSchema, input);
991
+
992
+ const listMyPasskeys = () => authed("GET", "/v1/me/passkeys", mePasskeysListResponseSchema);
993
+
994
+ const getMyPasskeyRegistrationOptions = () =>
995
+ authed(
996
+ "POST",
997
+ "/v1/me/passkeys/registration/options",
998
+ mePasskeyRegistrationOptionsResponseSchema,
999
+ {},
1000
+ );
1001
+
1002
+ const verifyMyPasskeyRegistration = (input: MePasskeyRegistrationVerifyRequest) =>
1003
+ authed(
1004
+ "POST",
1005
+ "/v1/me/passkeys/registration/verify",
1006
+ mePasskeyRegistrationVerifyResponseSchema,
1007
+ input,
1008
+ );
1009
+
1010
+ const registerMyPasskey = async (input?: {
1011
+ readonly name?: string;
1012
+ }): Promise<Outcome<MePasskeyRegistrationVerifyResponse, BrowserAuthError>> => {
1013
+ if (!webAuthn.isAvailable()) {
1014
+ return err({
1015
+ type: "webauthn_error",
1016
+ reason: "not_supported",
1017
+ message: "WebAuthn is not available",
1018
+ });
1019
+ }
1020
+ const options = await getMyPasskeyRegistrationOptions();
1021
+ if (!options.ok) return options;
1022
+ const credential = await webAuthn.create(options.value);
1023
+ if (!credential.ok) return credential;
1024
+ return verifyMyPasskeyRegistration({
1025
+ credential: credential.value,
1026
+ ...(input?.name !== undefined ? { name: input.name } : {}),
1027
+ });
1028
+ };
1029
+
1030
+ const deleteMyPasskey = (passkeyId: string) =>
1031
+ authed("DELETE", `/v1/me/passkeys/${encodeSegment(passkeyId)}`, mePasskeyDeleteResponseSchema);
1032
+
1033
+ const listMyIdentities = () => authed("GET", "/v1/me/identities", meIdentitiesListResponseSchema);
1034
+
1035
+ const linkMyIdentity = (provider: SocialProvider, input: MeIdentityLinkRequest) =>
1036
+ authed(
1037
+ "POST",
1038
+ `/v1/me/identities/${encodeSegment(provider)}/link`,
1039
+ meIdentityLinkResponseSchema,
1040
+ input,
1041
+ );
1042
+
1043
+ const unlinkMyIdentity = (identityId: string) =>
1044
+ authed(
1045
+ "DELETE",
1046
+ `/v1/me/identities/${encodeSegment(identityId)}`,
1047
+ meIdentityUnlinkResponseSchema,
1048
+ );
1049
+
1050
+ const listMySessions = () => authed("GET", "/v1/me/sessions", meSessionsListResponseSchema);
1051
+
1052
+ const revokeMySession = (sessionId: string) =>
1053
+ authed("DELETE", `/v1/me/sessions/${encodeSegment(sessionId)}`, meSessionRevokeResponseSchema);
1054
+
1055
+ const revokeAllMySessions = () =>
1056
+ authed("DELETE", "/v1/me/sessions", meSessionsRevokeAllResponseSchema);
1057
+
1058
+ const startMyEmailChange = (input: MeEmailChangeStartRequest) =>
1059
+ authed("POST", "/v1/me/email/change/start", meEmailChangeStartResponseSchema, input);
1060
+
1061
+ const verifyMyEmailChangeOld = (input: MeEmailChangeVerifyOldRequest) =>
1062
+ authed("POST", "/v1/me/email/change/verify-old", meEmailChangeVerifyOldResponseSchema, input);
1063
+
1064
+ const verifyMyEmailChangeNew = (input: MeEmailChangeVerifyNewRequest) =>
1065
+ authed("POST", "/v1/me/email/change/verify-new", meEmailChangeVerifyNewResponseSchema, input);
1066
+
1067
+ const deleteMyAccount = () => authed("DELETE", "/v1/me", meDeleteResponseSchema);
1068
+
1069
+ return {
1070
+ getPublicConfig,
1071
+ createTransaction,
1072
+ getTransaction,
1073
+ cancelTransaction,
1074
+ startEmailOtp,
1075
+ resendEmailOtp,
1076
+ verifyEmailOtp,
1077
+ getPasskeyRegistrationOptions,
1078
+ verifyPasskeyRegistration,
1079
+ startPasskeyRegistration,
1080
+ getPasskeyAuthenticationOptions,
1081
+ verifyPasskeyAuthentication,
1082
+ startPasskeyAuthentication,
1083
+ startSocialSignIn,
1084
+ completeTransaction,
1085
+ exchangeAuthorizationCode,
1086
+ createHostedAuthorizationUrl,
1087
+ beginHostedAuthorization,
1088
+ handleHostedAuthorizationCallback,
1089
+ getSnapshot: () => snapshot,
1090
+ subscribe: (listener) => {
1091
+ listeners.add(listener);
1092
+ return () => listeners.delete(listener);
1093
+ },
1094
+ isPasskeySupported: () => webAuthn.isAvailable(),
1095
+ getAccessToken,
1096
+ refreshSession,
1097
+ restoreSession,
1098
+ signOut,
1099
+ getMe,
1100
+ updateMe,
1101
+ listMyPasskeys,
1102
+ getMyPasskeyRegistrationOptions,
1103
+ verifyMyPasskeyRegistration,
1104
+ registerMyPasskey,
1105
+ deleteMyPasskey,
1106
+ listMyIdentities,
1107
+ linkMyIdentity,
1108
+ unlinkMyIdentity,
1109
+ listMySessions,
1110
+ revokeMySession,
1111
+ revokeAllMySessions,
1112
+ startMyEmailChange,
1113
+ verifyMyEmailChangeOld,
1114
+ verifyMyEmailChangeNew,
1115
+ deleteMyAccount,
1116
+ };
1117
+ };