@sentroy-co/client-sdk 2.13.7 → 2.13.9

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.
@@ -0,0 +1,778 @@
1
+ import {
2
+ type SentroyAuthUser,
3
+ type SignupResponse,
4
+ type LoginResponse,
5
+ type AuthTokensResponse,
6
+ type LoginOutcome,
7
+ type SessionSummary,
8
+ type ActivityEntry,
9
+ type MfaStatus,
10
+ type MfaEnrollResponse,
11
+ type MfaVerifyEnrollmentResponse,
12
+ type PasskeySummary,
13
+ type SocialProvider,
14
+ } from "./types"
15
+ import { AuthHttp, type AuthHttpOptions } from "./http"
16
+
17
+ /**
18
+ * Browser-facing Sentroy Auth SDK — Firebase Auth tarzı session API.
19
+ *
20
+ * `apiKey` BROWSER'DA OLMAMALI; bu sınıf `apiKey`'i header'a koyacaktır.
21
+ * RP backend gerçek api-key tutar; browser'da end-user kendi access/refresh
22
+ * token'larıyla yaşar. Yine de DX için sınıf hem apiKey-less browser
23
+ * akışına (signup/login backend proxy üzerinden) hem apiKey'li server
24
+ * akışına (admin) tek tip API sunar — caller hangi mod'da olduğunu
25
+ * `SentroyAuthAdmin` (admin SDK, sunucu) vs `SentroyAuth` (browser SDK,
26
+ * apiKey-less) seçimiyle netleştirir.
27
+ *
28
+ * Storage: browser'da access + refresh `storage` adapter'a yazılır
29
+ * (default `localStorage`). Refresh expire'a 5dk kala arka planda
30
+ * yenilenir; fail olursa `onAuthStateChanged(null)` ve storage silinir.
31
+ *
32
+ * **Server-side rendering**: `typeof window === "undefined"` korumalı —
33
+ * Node ortamında `localStorage` yok, default `memory` storage'a düşer.
34
+ */
35
+
36
+ export type AuthStateChangeListener = (user: SentroyAuthUser | null) => void
37
+
38
+ export interface AuthStorageAdapter {
39
+ read(): { accessToken: string; refreshToken: string; user: SentroyAuthUser } | null
40
+ write(value: {
41
+ accessToken: string
42
+ refreshToken: string
43
+ user: SentroyAuthUser
44
+ }): void
45
+ clear(): void
46
+ }
47
+
48
+ const STORAGE_KEY_PREFIX = "sentroy.auth"
49
+
50
+ /**
51
+ * Base64URL → UTF-8 decode. Browser'da `atob` + manuel UTF-8 reconstruction;
52
+ * Node'da `Buffer.from(..., "base64url")`. Tek kod yolu, runtime detect.
53
+ */
54
+ function decodeBase64Url(s: string): string {
55
+ // Pad + standard base64
56
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/")
57
+ const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4))
58
+ if (typeof atob === "function") {
59
+ const binary = atob(padded + pad)
60
+ // UTF-8 reconstruction (JWT claims yabancı karakter içerebilir)
61
+ const bytes = new Uint8Array(binary.length)
62
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
63
+ return new TextDecoder().decode(bytes)
64
+ }
65
+ // Node fallback
66
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
67
+ const B = (globalThis as any).Buffer
68
+ if (B) return B.from(padded + pad, "base64").toString("utf8")
69
+ throw new Error("No base64 decoder available")
70
+ }
71
+
72
+ /**
73
+ * Optional `@simplewebauthn/browser` import — RP webauthn flow kullanmak
74
+ * isterse kendi devDependencies'ine ekler; lazy import ile bundle'a
75
+ * sızmaz.
76
+ */
77
+ async function loadSimpleWebAuthnBrowser(): Promise<{
78
+ startRegistration: (opts: {
79
+ optionsJSON: Record<string, unknown>
80
+ }) => Promise<unknown>
81
+ startAuthentication: (opts: {
82
+ optionsJSON: Record<string, unknown>
83
+ }) => Promise<unknown>
84
+ }> {
85
+ try {
86
+ // String-concat hides the specifier from TS resolver — `@simplewebauthn/browser`
87
+ // is an optional peer; SDK ships without it bundled. RP'nin npm install'unda
88
+ // varsa runtime'da çözülür, yoksa catch'e düşüp kullanıcıya net hata verir.
89
+ const specifier = "@simplewebauthn/" + "browser"
90
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
91
+ const mod: any = await import(/* @vite-ignore */ specifier)
92
+ return {
93
+ startRegistration: mod.startRegistration,
94
+ startAuthentication: mod.startAuthentication,
95
+ }
96
+ } catch {
97
+ throw new Error(
98
+ "Passkey support requires `@simplewebauthn/browser` — install it as a peer dependency.",
99
+ )
100
+ }
101
+ }
102
+
103
+ function localStorageAdapter(projectSlug: string): AuthStorageAdapter {
104
+ if (typeof window === "undefined" || !window.localStorage) {
105
+ return memoryStorageAdapter()
106
+ }
107
+ const key = `${STORAGE_KEY_PREFIX}.${projectSlug}`
108
+ return {
109
+ read() {
110
+ try {
111
+ const raw = window.localStorage.getItem(key)
112
+ if (!raw) return null
113
+ return JSON.parse(raw) as {
114
+ accessToken: string
115
+ refreshToken: string
116
+ user: SentroyAuthUser
117
+ }
118
+ } catch {
119
+ return null
120
+ }
121
+ },
122
+ write(value) {
123
+ try {
124
+ window.localStorage.setItem(key, JSON.stringify(value))
125
+ } catch {
126
+ // QuotaExceeded, etc — degrade to memory silently.
127
+ }
128
+ },
129
+ clear() {
130
+ try {
131
+ window.localStorage.removeItem(key)
132
+ } catch {
133
+ // ignore
134
+ }
135
+ },
136
+ }
137
+ }
138
+
139
+ function memoryStorageAdapter(): AuthStorageAdapter {
140
+ let store: {
141
+ accessToken: string
142
+ refreshToken: string
143
+ user: SentroyAuthUser
144
+ } | null = null
145
+ return {
146
+ read: () => store,
147
+ write: (value) => {
148
+ store = value
149
+ },
150
+ clear: () => {
151
+ store = null
152
+ },
153
+ }
154
+ }
155
+
156
+ export interface SentroyAuthOptions extends AuthHttpOptions {
157
+ /** Token persistence stratejisi. Default `"localStorage"` browser'da,
158
+ * Node'da otomatik `"memory"`. Custom için adapter geçilebilir. */
159
+ storage?: "localStorage" | "memory" | AuthStorageAdapter
160
+ /** Background refresh tetikleme süresi (saniye, expiresIn altında).
161
+ * Default 300 (5dk). */
162
+ refreshSkew?: number
163
+ }
164
+
165
+ export class SentroyAuth {
166
+ private readonly http: AuthHttp
167
+ private readonly storage: AuthStorageAdapter
168
+ private readonly listeners = new Set<AuthStateChangeListener>()
169
+ private readonly refreshSkew: number
170
+ private refreshTimer: ReturnType<typeof setTimeout> | null = null
171
+ private currentUser: SentroyAuthUser | null = null
172
+
173
+ constructor(opts: SentroyAuthOptions) {
174
+ this.http = new AuthHttp(opts)
175
+ this.refreshSkew = opts.refreshSkew ?? 300
176
+
177
+ if (opts.storage === "memory") {
178
+ this.storage = memoryStorageAdapter()
179
+ } else if (
180
+ opts.storage &&
181
+ typeof opts.storage === "object" &&
182
+ "read" in opts.storage
183
+ ) {
184
+ this.storage = opts.storage
185
+ } else {
186
+ this.storage = localStorageAdapter(opts.projectSlug)
187
+ }
188
+
189
+ // Restore from storage on construct — `onAuthStateChanged` listener'ları
190
+ // henüz yok; ilk subscribe sırasında dispatch edilir.
191
+ const restored = this.storage.read()
192
+ if (restored) {
193
+ this.currentUser = restored.user
194
+ this.scheduleRefresh(this.estimateExpiry(restored.accessToken))
195
+ }
196
+ }
197
+
198
+ // ─── Public API ──────────────────────────────────────────────────────────
199
+
200
+ get user(): SentroyAuthUser | null {
201
+ return this.currentUser
202
+ }
203
+
204
+ get accessToken(): string | null {
205
+ return this.storage.read()?.accessToken ?? null
206
+ }
207
+
208
+ async signUp(input: {
209
+ email: string
210
+ password: string
211
+ displayName?: string
212
+ metadata?: Record<string, unknown>
213
+ }): Promise<SignupResponse> {
214
+ const res = await this.http.request<SignupResponse>("/signup", {
215
+ method: "POST",
216
+ json: input,
217
+ })
218
+ if (res.accessToken && res.refreshToken) {
219
+ this.persist({
220
+ accessToken: res.accessToken,
221
+ refreshToken: res.refreshToken,
222
+ user: res.user,
223
+ })
224
+ }
225
+ return res
226
+ }
227
+
228
+ /**
229
+ * Sign in with email/password. MFA enrolled user'lar için response
230
+ * discriminated union: `kind: "mfa"` → caller `verifyMfa()` çağırır.
231
+ * `kind: "tokens"` → session kuruldu.
232
+ */
233
+ async signIn(input: {
234
+ email: string
235
+ password: string
236
+ rememberMe?: boolean
237
+ }): Promise<LoginOutcome> {
238
+ const res = await this.http.request<
239
+ LoginResponse | { mfaRequired: true; mfaToken: string; factorType: "totp" }
240
+ >("/login", { method: "POST", json: input })
241
+ if ("mfaRequired" in res && res.mfaRequired) {
242
+ return { kind: "mfa", data: res }
243
+ }
244
+ const tokens = res as LoginResponse
245
+ this.persist({
246
+ accessToken: tokens.accessToken,
247
+ refreshToken: tokens.refreshToken,
248
+ user: tokens.user,
249
+ })
250
+ return { kind: "tokens", data: tokens }
251
+ }
252
+
253
+ /**
254
+ * MFA verify — `signIn` ile `kind: "mfa"` döndüyse, kullanıcıdan code
255
+ * (veya recovery code) alıp bu method'u çağır. Başarılıysa session
256
+ * kurulur ve login tamamlanır.
257
+ */
258
+ async verifyMfa(input: {
259
+ mfaToken: string
260
+ code?: string
261
+ recoveryCode?: string
262
+ }): Promise<LoginResponse> {
263
+ const res = await this.http.request<LoginResponse>(
264
+ "/login/mfa/verify",
265
+ { method: "POST", json: input },
266
+ )
267
+ this.persist({
268
+ accessToken: res.accessToken,
269
+ refreshToken: res.refreshToken,
270
+ user: res.user,
271
+ })
272
+ return res
273
+ }
274
+
275
+ async signOut(): Promise<void> {
276
+ const restored = this.storage.read()
277
+ if (restored?.refreshToken) {
278
+ // Best-effort revoke — fail'ı sessizce yut (network problem
279
+ // sign-out'u bloklamasın).
280
+ await this.http
281
+ .request("/logout", {
282
+ method: "POST",
283
+ json: { refreshToken: restored.refreshToken },
284
+ })
285
+ .catch(() => {})
286
+ }
287
+ this.clearSession()
288
+ }
289
+
290
+ async sendPasswordReset(email: string): Promise<void> {
291
+ await this.http.request("/password-reset/request", {
292
+ method: "POST",
293
+ json: { email },
294
+ })
295
+ }
296
+
297
+ /**
298
+ * Reset password using token from email. `newPassword` policy +
299
+ * HaveIBeenPwned breach check yapılır.
300
+ */
301
+ async confirmPasswordReset(input: {
302
+ token: string
303
+ newPassword: string
304
+ }): Promise<SentroyAuthUser> {
305
+ const res = await this.http.request<{ user: SentroyAuthUser }>(
306
+ "/password-reset/confirm",
307
+ { method: "POST", json: input },
308
+ )
309
+ return res.user
310
+ }
311
+
312
+ async verifyEmail(token: string): Promise<SentroyAuthUser> {
313
+ const res = await this.http.request<{ user: SentroyAuthUser }>(
314
+ "/verify-email",
315
+ { method: "POST", json: { token } },
316
+ )
317
+ if (this.currentUser && this.currentUser.id === res.user.id) {
318
+ const restored = this.storage.read()
319
+ if (restored) {
320
+ this.persist({ ...restored, user: res.user })
321
+ } else {
322
+ this.currentUser = res.user
323
+ this.notify()
324
+ }
325
+ }
326
+ return res.user
327
+ }
328
+
329
+ // ─── Magic link ──────────────────────────────────────────────────────────
330
+
331
+ /**
332
+ * Email magic-link request. Project'in `magicLinkEnabled` true
333
+ * olması gerekir. Uniform 200 response — email yoksa da hata vermez.
334
+ */
335
+ async sendMagicLink(input: { email: string; redirectUri?: string }): Promise<void> {
336
+ await this.http.request("/magic-link/request", {
337
+ method: "POST",
338
+ json: input,
339
+ })
340
+ }
341
+
342
+ /**
343
+ * Magic link mail'inden gelen token ile login. Session kurulur.
344
+ */
345
+ async consumeMagicLink(token: string): Promise<LoginResponse> {
346
+ const res = await this.http.request<LoginResponse & { redirectUri?: string | null }>(
347
+ "/magic-link/consume",
348
+ { method: "POST", json: { token } },
349
+ )
350
+ this.persist({
351
+ accessToken: res.accessToken,
352
+ refreshToken: res.refreshToken,
353
+ user: res.user,
354
+ })
355
+ return res
356
+ }
357
+
358
+ // ─── Invitation ──────────────────────────────────────────────────────────
359
+
360
+ /**
361
+ * Accept admin invitation. Token mail'den gelir, kullanıcı password
362
+ * + optional displayName girer; hesap create + session kurulur.
363
+ */
364
+ async acceptInvitation(input: {
365
+ token: string
366
+ password: string
367
+ displayName?: string
368
+ }): Promise<LoginResponse> {
369
+ const res = await this.http.request<LoginResponse>(
370
+ "/invitation/accept",
371
+ { method: "POST", json: input },
372
+ )
373
+ this.persist({
374
+ accessToken: res.accessToken,
375
+ refreshToken: res.refreshToken,
376
+ user: res.user,
377
+ })
378
+ return res
379
+ }
380
+
381
+ // ─── Social federation ───────────────────────────────────────────────────
382
+
383
+ /**
384
+ * Provider authorize URL üret. `window.location.assign(url)` ile
385
+ * RP'nin sayfasından redirect — callback'te Sentroy session kurulur,
386
+ * redirectUri fragment'ında token'lar döner.
387
+ */
388
+ socialAuthorizeUrl(
389
+ provider: SocialProvider,
390
+ opts: { redirectUri?: string; rememberMe?: boolean } = {},
391
+ ): string {
392
+ const params = new URLSearchParams()
393
+ if (opts.redirectUri) params.set("redirectUri", opts.redirectUri)
394
+ if (opts.rememberMe) params.set("rememberMe", "1")
395
+ const qs = params.toString()
396
+ return `${this.http.baseUrl}/api/v1/auth/${this.http.projectSlug}/social/${provider}/authorize${qs ? `?${qs}` : ""}`
397
+ }
398
+
399
+ /**
400
+ * `window.location.hash`tan social login redirect sonrası gelen
401
+ * `#access_token=...&refresh_token=...&token_type=Bearer` parse +
402
+ * session kur. RP sayfasına redirectUri varsayılan akış kullanıldıysa
403
+ * çağırın. Başarılıysa user döner, fail'da null.
404
+ */
405
+ async consumeRedirectFragment(): Promise<SentroyAuthUser | null> {
406
+ if (typeof window === "undefined") return null
407
+ const hash = window.location.hash.replace(/^#/, "")
408
+ if (!hash) return null
409
+ const params = new URLSearchParams(hash)
410
+ const accessToken = params.get("access_token")
411
+ const refreshToken = params.get("refresh_token")
412
+ if (!accessToken || !refreshToken) return null
413
+ // Fragment'ı URL'den temizle (history clean)
414
+ window.history.replaceState(
415
+ null,
416
+ "",
417
+ window.location.pathname + window.location.search,
418
+ )
419
+ const user = await this.fetchMe(accessToken)
420
+ if (!user) return null
421
+ this.persist({ accessToken, refreshToken, user })
422
+ return user
423
+ }
424
+
425
+ // ─── /me (current user info) ─────────────────────────────────────────────
426
+
427
+ async getCurrentUser(): Promise<SentroyAuthUser | null> {
428
+ const restored = this.storage.read()
429
+ if (!restored) return null
430
+ const user = await this.fetchMe(restored.accessToken)
431
+ if (user) {
432
+ this.persist({ ...restored, user })
433
+ }
434
+ return user
435
+ }
436
+
437
+ private async fetchMe(accessToken: string): Promise<SentroyAuthUser | null> {
438
+ try {
439
+ return await this.http.request<SentroyAuthUser>("/me", {
440
+ method: "GET",
441
+ bearer: accessToken,
442
+ })
443
+ } catch {
444
+ return null
445
+ }
446
+ }
447
+
448
+ // ─── /me/sessions ────────────────────────────────────────────────────────
449
+
450
+ async listSessions(): Promise<SessionSummary[]> {
451
+ return this.http.request<SessionSummary[]>("/me/sessions", {
452
+ method: "GET",
453
+ bearer: this.requireToken(),
454
+ })
455
+ }
456
+
457
+ async revokeSession(id: string): Promise<void> {
458
+ await this.http.request(`/me/sessions/${encodeURIComponent(id)}`, {
459
+ method: "DELETE",
460
+ bearer: this.requireToken(),
461
+ })
462
+ }
463
+
464
+ // ─── /me/password ────────────────────────────────────────────────────────
465
+
466
+ /**
467
+ * Change password. Backend tüm session'ları revoke eder; SDK local
468
+ * session'ı temizler — caller `signIn` ile tekrar oturum açar.
469
+ */
470
+ async changePassword(input: {
471
+ currentPassword: string
472
+ newPassword: string
473
+ }): Promise<void> {
474
+ await this.http.request("/me/password", {
475
+ method: "POST",
476
+ json: input,
477
+ bearer: this.requireToken(),
478
+ })
479
+ this.clearSession()
480
+ }
481
+
482
+ // ─── /me/email/change ────────────────────────────────────────────────────
483
+
484
+ /**
485
+ * Request email change — confirmation mail yeni adrese gönderilir.
486
+ * Kullanıcı `confirmEmailChange(token)` ile finalize eder.
487
+ */
488
+ async requestEmailChange(input: {
489
+ newEmail: string
490
+ currentPassword: string
491
+ }): Promise<void> {
492
+ await this.http.request("/me/email/change-request", {
493
+ method: "POST",
494
+ json: input,
495
+ bearer: this.requireToken(),
496
+ })
497
+ }
498
+
499
+ /** Token-based confirm (mail link'inden gelir). */
500
+ async confirmEmailChange(token: string): Promise<SentroyAuthUser> {
501
+ const user = await this.http.request<SentroyAuthUser>(
502
+ "/me/email/change-confirm",
503
+ { method: "POST", json: { token } },
504
+ )
505
+ // Email changed — tüm sessions revoke edildi, local clear
506
+ this.clearSession()
507
+ return user
508
+ }
509
+
510
+ // ─── /me/account (delete) ────────────────────────────────────────────────
511
+
512
+ async requestAccountDeletion(currentPassword: string): Promise<void> {
513
+ await this.http.request("/me/account/delete-request", {
514
+ method: "POST",
515
+ json: { currentPassword },
516
+ bearer: this.requireToken(),
517
+ })
518
+ }
519
+
520
+ async confirmAccountDeletion(token: string): Promise<void> {
521
+ await this.http.request("/me/account/delete-confirm", {
522
+ method: "POST",
523
+ json: { token },
524
+ })
525
+ this.clearSession()
526
+ }
527
+
528
+ // ─── /me/activity ────────────────────────────────────────────────────────
529
+
530
+ async getActivity(): Promise<ActivityEntry[]> {
531
+ return this.http.request<ActivityEntry[]>("/me/activity", {
532
+ method: "GET",
533
+ bearer: this.requireToken(),
534
+ })
535
+ }
536
+
537
+ // ─── /me/mfa ─────────────────────────────────────────────────────────────
538
+
539
+ readonly mfa = {
540
+ getStatus: async (): Promise<MfaStatus> =>
541
+ this.http.request<MfaStatus>("/me/mfa", {
542
+ method: "GET",
543
+ bearer: this.requireToken(),
544
+ }),
545
+ enrollTotp: async (): Promise<MfaEnrollResponse> =>
546
+ this.http.request<MfaEnrollResponse>("/me/mfa/totp/enroll", {
547
+ method: "POST",
548
+ bearer: this.requireToken(),
549
+ }),
550
+ verifyTotpEnrollment: async (
551
+ code: string,
552
+ ): Promise<MfaVerifyEnrollmentResponse> =>
553
+ this.http.request<MfaVerifyEnrollmentResponse>(
554
+ "/me/mfa/totp/verify-enrollment",
555
+ { method: "POST", json: { code }, bearer: this.requireToken() },
556
+ ),
557
+ disableTotp: async (currentPassword: string): Promise<void> => {
558
+ await this.http.request("/me/mfa/totp/disable", {
559
+ method: "POST",
560
+ json: { currentPassword },
561
+ bearer: this.requireToken(),
562
+ })
563
+ },
564
+ }
565
+
566
+ // ─── /me/passkey + /passkey/auth ─────────────────────────────────────────
567
+
568
+ readonly passkey = {
569
+ list: async (): Promise<PasskeySummary[]> =>
570
+ this.http.request<PasskeySummary[]>("/me/passkey", {
571
+ method: "GET",
572
+ bearer: this.requireToken(),
573
+ }),
574
+ delete: async (id: string): Promise<void> => {
575
+ await this.http.request(`/me/passkey/${encodeURIComponent(id)}`, {
576
+ method: "DELETE",
577
+ bearer: this.requireToken(),
578
+ })
579
+ },
580
+ /**
581
+ * Register a new passkey on this device.
582
+ *
583
+ * Browser-only: dynamically imports `@simplewebauthn/browser`. RP
584
+ * SDK kullanıyor ama webauthn/browser bağımlılığı yoksa caller
585
+ * `peerDependencies` aracılığıyla manuel ekler.
586
+ */
587
+ register: async (deviceName?: string): Promise<void> => {
588
+ const begin = await this.http.request<{
589
+ options: unknown
590
+ challengeToken: string
591
+ }>("/me/passkey/register/begin", {
592
+ method: "POST",
593
+ bearer: this.requireToken(),
594
+ })
595
+ const sw = await loadSimpleWebAuthnBrowser()
596
+ const attestation = await sw.startRegistration({
597
+ optionsJSON: begin.options as Parameters<typeof sw.startRegistration>[0]["optionsJSON"],
598
+ })
599
+ await this.http.request("/me/passkey/register/complete", {
600
+ method: "POST",
601
+ json: {
602
+ challengeToken: begin.challengeToken,
603
+ response: attestation,
604
+ deviceName:
605
+ deviceName ??
606
+ (typeof navigator !== "undefined"
607
+ ? navigator.userAgent.slice(0, 80)
608
+ : null),
609
+ },
610
+ bearer: this.requireToken(),
611
+ })
612
+ },
613
+ /**
614
+ * Sign in with passkey. Email opsiyonel — verilirse server o
615
+ * user'ın passkey'lerini allowList yapar, yoksa "usernameless".
616
+ * Session kurulur.
617
+ */
618
+ authenticate: async (
619
+ opts: { email?: string; rememberMe?: boolean } = {},
620
+ ): Promise<LoginResponse> => {
621
+ const begin = await this.http.request<{
622
+ options: unknown
623
+ challengeToken: string
624
+ }>("/passkey/authenticate/begin", {
625
+ method: "POST",
626
+ json: { email: opts.email },
627
+ })
628
+ const sw = await loadSimpleWebAuthnBrowser()
629
+ const assertion = await sw.startAuthentication({
630
+ optionsJSON: begin.options as Parameters<typeof sw.startAuthentication>[0]["optionsJSON"],
631
+ })
632
+ const res = await this.http.request<LoginResponse>(
633
+ "/passkey/authenticate/complete",
634
+ {
635
+ method: "POST",
636
+ json: {
637
+ challengeToken: begin.challengeToken,
638
+ response: assertion,
639
+ rememberMe: opts.rememberMe,
640
+ },
641
+ },
642
+ )
643
+ this.persist({
644
+ accessToken: res.accessToken,
645
+ refreshToken: res.refreshToken,
646
+ user: res.user,
647
+ })
648
+ return res
649
+ },
650
+ }
651
+
652
+ /**
653
+ * Force refresh now — caller'ın sürdüğü access token süresi dolmuş
654
+ * olabilir; bu method yeni token'ları persist eder.
655
+ */
656
+ async refreshNow(): Promise<void> {
657
+ await this.refresh()
658
+ }
659
+
660
+ /** Manual session injection — fragment / cookie redirect dışında tokens
661
+ * başka bir kanaldan elde edildiyse (örn. RP custom auth callback). */
662
+ setSession(input: {
663
+ accessToken: string
664
+ refreshToken: string
665
+ user: SentroyAuthUser
666
+ }): void {
667
+ this.persist(input)
668
+ }
669
+
670
+ private requireToken(): string {
671
+ const restored = this.storage.read()
672
+ if (!restored?.accessToken) {
673
+ throw new Error("Not signed in — accessToken missing.")
674
+ }
675
+ return restored.accessToken
676
+ }
677
+
678
+ /**
679
+ * Subscription pattern — Firebase Auth uyumlu. Caller'ın hemen mevcut
680
+ * state'i alabilmesi için constructor'da restore edilen user
681
+ * subscribe sırasında bir kez dispatch edilir.
682
+ */
683
+ onAuthStateChanged(listener: AuthStateChangeListener): () => void {
684
+ this.listeners.add(listener)
685
+ // Microtask gibi async dispatch — caller's `useEffect` cleanup race
686
+ // problemlerini önler.
687
+ Promise.resolve().then(() => listener(this.currentUser))
688
+ return () => {
689
+ this.listeners.delete(listener)
690
+ }
691
+ }
692
+
693
+ // ─── Internals ───────────────────────────────────────────────────────────
694
+
695
+ private persist(value: {
696
+ accessToken: string
697
+ refreshToken: string
698
+ user: SentroyAuthUser
699
+ }): void {
700
+ this.storage.write(value)
701
+ this.currentUser = value.user
702
+ this.notify()
703
+ this.scheduleRefresh(this.estimateExpiry(value.accessToken))
704
+ }
705
+
706
+ private clearSession(): void {
707
+ this.storage.clear()
708
+ this.currentUser = null
709
+ if (this.refreshTimer) {
710
+ clearTimeout(this.refreshTimer)
711
+ this.refreshTimer = null
712
+ }
713
+ this.notify()
714
+ }
715
+
716
+ private notify(): void {
717
+ for (const l of this.listeners) {
718
+ try {
719
+ l(this.currentUser)
720
+ } catch {
721
+ // Listener hatası diğer subscriber'ları engellemesin.
722
+ }
723
+ }
724
+ }
725
+
726
+ /**
727
+ * JWT'nin `exp` claim'inden expiry'i tahmin et. Parsing fail ise
728
+ * 1 saat varsay (default access TTL). Refresh window: exp - skew.
729
+ *
730
+ * **Browser-safe**: `Buffer` Node'a özel, tarayıcıda yok. `atob`
731
+ * + URL-safe charset normalization ile decode ediyoruz.
732
+ */
733
+ private estimateExpiry(accessToken: string): number {
734
+ try {
735
+ const [, payloadB64] = accessToken.split(".")
736
+ const payload = JSON.parse(decodeBase64Url(payloadB64)) as {
737
+ exp?: number
738
+ }
739
+ if (typeof payload.exp === "number") {
740
+ return payload.exp * 1000
741
+ }
742
+ } catch {
743
+ // ignore — fall through to default
744
+ }
745
+ return Date.now() + 60 * 60 * 1000
746
+ }
747
+
748
+ private scheduleRefresh(expiryMs: number): void {
749
+ if (typeof window === "undefined") return // SSR'da auto-refresh yok
750
+ if (this.refreshTimer) clearTimeout(this.refreshTimer)
751
+ const fireAt = expiryMs - this.refreshSkew * 1000
752
+ const delay = Math.max(fireAt - Date.now(), 5_000)
753
+ this.refreshTimer = setTimeout(() => {
754
+ this.refresh().catch(() => {
755
+ // Refresh fail → session cleared, listener'lar null user görür
756
+ this.clearSession()
757
+ })
758
+ }, delay)
759
+ }
760
+
761
+ private async refresh(): Promise<void> {
762
+ const restored = this.storage.read()
763
+ if (!restored?.refreshToken) {
764
+ this.clearSession()
765
+ return
766
+ }
767
+ const res = await this.http.request<AuthTokensResponse>("/refresh", {
768
+ method: "POST",
769
+ json: { refreshToken: restored.refreshToken },
770
+ })
771
+ this.storage.write({
772
+ ...restored,
773
+ accessToken: res.accessToken,
774
+ refreshToken: res.refreshToken,
775
+ })
776
+ this.scheduleRefresh(this.estimateExpiry(res.accessToken))
777
+ }
778
+ }