@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,387 @@
1
+ import { AuthHttp } from "../http"
2
+ import type {
3
+ SentroyAuthUser,
4
+ SignupResponse,
5
+ LoginResponse,
6
+ LoginOutcome,
7
+ AuthTokensResponse,
8
+ MfaChallengeResponse,
9
+ } from "../types"
10
+
11
+ /**
12
+ * Server-side Sentroy Auth admin SDK. **Node only — apiKey browser'a
13
+ * koymayın**; bu sınıf Project'in master `aps_` token'ını taşır.
14
+ *
15
+ * Tüm public auth endpoint'lerini apiKey'le proxy eder ve JWT'yi local
16
+ * verify edebilir (JWKS cache + RSA Subtle). RP backend tipik akış:
17
+ *
18
+ * const admin = new SentroyAuthAdmin({ projectSlug, apiKey })
19
+ * const out = await admin.users.signIn({ email, password })
20
+ * if (out.kind === "tokens") setCookie(out.data.accessToken, ...)
21
+ * else // MFA flow
22
+ *
23
+ * // Mid-request: verify
24
+ * const claims = await admin.verifyIdToken(req.cookies.accessToken)
25
+ *
26
+ * Token persistence yok — server-side request-scoped; caller cookie /
27
+ * session / DB nereye isterse oraya yazar.
28
+ */
29
+
30
+ export interface SentroyAuthAdminOptions {
31
+ authBaseUrl?: string
32
+ projectSlug: string
33
+ apiKey: string
34
+ /** JWKS cache TTL (saniye). Default 3600 (1 saat) — JWT rotation
35
+ * grace period'una uyumlu; daha agresif rotation yapan project'ler
36
+ * daha düşük set edebilir. */
37
+ jwksCacheTtl?: number
38
+ }
39
+
40
+ interface CachedJwks {
41
+ keys: Record<string, unknown>[]
42
+ expiresAt: number
43
+ }
44
+
45
+ export class SentroyAuthAdmin {
46
+ private readonly http: AuthHttp
47
+ private readonly jwksCacheTtl: number
48
+ private cachedJwks: CachedJwks | null = null
49
+
50
+ constructor(opts: SentroyAuthAdminOptions) {
51
+ this.http = new AuthHttp(opts)
52
+ this.jwksCacheTtl = opts.jwksCacheTtl ?? 3600
53
+ }
54
+
55
+ get projectSlug(): string {
56
+ return this.http.projectSlug
57
+ }
58
+
59
+ get baseUrl(): string {
60
+ return this.http.baseUrl
61
+ }
62
+
63
+ // ─── User pool admin ──────────────────────────────────────────────────
64
+
65
+ users = {
66
+ /**
67
+ * Server-side signup proxy. apiKey backend'de — browser'a sızmaz.
68
+ * Email verification project config'ine bağlı: required ise
69
+ * `emailVerificationRequired: true` döner, tokens undefined.
70
+ */
71
+ create: (input: {
72
+ email: string
73
+ password: string
74
+ displayName?: string
75
+ metadata?: Record<string, unknown>
76
+ }): Promise<SignupResponse> =>
77
+ this.http.request<SignupResponse>("/signup", {
78
+ method: "POST",
79
+ json: input,
80
+ }),
81
+
82
+ /**
83
+ * Server-side login proxy. MFA-aware: tokens VEYA MFA challenge.
84
+ * RP backend `out.kind === "mfa"` ise kullanıcıdan code alıp
85
+ * `users.verifyMfa(...)` çağırır.
86
+ */
87
+ signIn: async (input: {
88
+ email: string
89
+ password: string
90
+ rememberMe?: boolean
91
+ }): Promise<LoginOutcome> => {
92
+ const res = await this.http.request<
93
+ LoginResponse | MfaChallengeResponse
94
+ >("/login", { method: "POST", json: input })
95
+ if ("mfaRequired" in res && res.mfaRequired) {
96
+ return { kind: "mfa", data: res }
97
+ }
98
+ return { kind: "tokens", data: res as LoginResponse }
99
+ },
100
+
101
+ /** MFA verify ikinci adımı — `signIn` kind:"mfa" döndüyse. */
102
+ verifyMfa: (input: {
103
+ mfaToken: string
104
+ code?: string
105
+ recoveryCode?: string
106
+ }): Promise<LoginResponse> =>
107
+ this.http.request<LoginResponse>("/login/mfa/verify", {
108
+ method: "POST",
109
+ json: input,
110
+ }),
111
+
112
+ /** Refresh access token (rotation). Yeni refresh + access döner. */
113
+ refresh: (refreshToken: string): Promise<AuthTokensResponse> =>
114
+ this.http.request<AuthTokensResponse>("/refresh", {
115
+ method: "POST",
116
+ json: { refreshToken },
117
+ }),
118
+
119
+ /** Logout (refresh token revoke). */
120
+ signOut: (refreshToken: string): Promise<void> =>
121
+ this.http
122
+ .request<void>("/logout", {
123
+ method: "POST",
124
+ json: { refreshToken },
125
+ })
126
+ .then(() => undefined),
127
+
128
+ /** Verify email — link'ten gelen token. */
129
+ verifyEmail: (token: string): Promise<{ user: SentroyAuthUser }> =>
130
+ this.http.request<{ user: SentroyAuthUser }>("/verify-email", {
131
+ method: "POST",
132
+ json: { token },
133
+ }),
134
+
135
+ /** Password reset mail tetikle. */
136
+ requestPasswordReset: (email: string): Promise<void> =>
137
+ this.http
138
+ .request<void>("/password-reset/request", {
139
+ method: "POST",
140
+ json: { email },
141
+ })
142
+ .then(() => undefined),
143
+
144
+ /** Reset token + yeni şifre ile finalize. */
145
+ confirmPasswordReset: (input: {
146
+ token: string
147
+ newPassword: string
148
+ }): Promise<{ user: SentroyAuthUser }> =>
149
+ this.http.request<{ user: SentroyAuthUser }>("/password-reset/confirm", {
150
+ method: "POST",
151
+ json: input,
152
+ }),
153
+
154
+ /** Magic-link mail tetikle. */
155
+ sendMagicLink: (input: {
156
+ email: string
157
+ redirectUri?: string
158
+ }): Promise<void> =>
159
+ this.http
160
+ .request<void>("/magic-link/request", {
161
+ method: "POST",
162
+ json: input,
163
+ })
164
+ .then(() => undefined),
165
+
166
+ /** Magic-link token consume → login. */
167
+ consumeMagicLink: (token: string): Promise<LoginResponse> =>
168
+ this.http.request<LoginResponse>("/magic-link/consume", {
169
+ method: "POST",
170
+ json: { token },
171
+ }),
172
+
173
+ /** Davet token'ı ile yeni hesap + login. */
174
+ acceptInvitation: (input: {
175
+ token: string
176
+ password: string
177
+ displayName?: string
178
+ }): Promise<LoginResponse> =>
179
+ this.http.request<LoginResponse>("/invitation/accept", {
180
+ method: "POST",
181
+ json: input,
182
+ }),
183
+
184
+ /**
185
+ * Access token ile remote /me — JWT'ye bağımlı kalmadan canlı
186
+ * profile çek. Token expire ise SentroyAuthError fırlatır.
187
+ */
188
+ getUser: (accessToken: string): Promise<SentroyAuthUser> =>
189
+ this.http.request<SentroyAuthUser>("/me", {
190
+ method: "GET",
191
+ bearer: accessToken,
192
+ }),
193
+
194
+ /**
195
+ * Token bazlı userinfo (OIDC tarzı). `/userinfo` claims response —
196
+ * SentroyAuthUser shape'inden daha minimal olabilir.
197
+ */
198
+ getUserinfo: (
199
+ accessToken: string,
200
+ ): Promise<{
201
+ sub: string
202
+ email?: string
203
+ email_verified?: boolean
204
+ name?: string
205
+ picture?: string
206
+ }> =>
207
+ this.http.request("/userinfo", {
208
+ method: "GET",
209
+ bearer: accessToken,
210
+ }),
211
+
212
+ /**
213
+ * Admin list (paginated). **Şu an public API yok** — dashboard
214
+ * cookie-auth `/api/companies/[slug]/auth-projects/[id]/users`
215
+ * kullan. v2'de stk_ token'lı admin endpoint açılacak.
216
+ */
217
+ list: (_opts: {
218
+ limit?: number
219
+ skip?: number
220
+ emailVerified?: boolean
221
+ } = {}): Promise<{
222
+ items: SentroyAuthUser[]
223
+ pagination: { total: number; limit: number; skip: number }
224
+ }> => {
225
+ throw new Error(
226
+ "admin.users.list requires session-authenticated admin API; use dashboard /api/companies/[slug]/auth-projects/[id]/users instead. (v2 admin SDK will proxy this with stk_ tokens.)",
227
+ )
228
+ },
229
+ }
230
+
231
+ // ─── ID token verification ─────────────────────────────────────────────
232
+
233
+ /**
234
+ * Local verify — JWKS cache'lenir (default 1h TTL, opts ile değişir),
235
+ * JWT signature RS256 ile WebCrypto Subtle üzerinden verify edilir.
236
+ * `iss`/`aud` claim eşleşmesi de kontrol edilir.
237
+ *
238
+ * Throw'lar: malformed JWT, expired, iss/aud mismatch, key not found,
239
+ * signature mismatch. Tipik kullanım `try/catch` içinde — fail ise
240
+ * 401 dön.
241
+ */
242
+ async verifyIdToken(token: string): Promise<{
243
+ sub: string
244
+ email?: string
245
+ email_verified?: boolean
246
+ name?: string
247
+ picture?: string
248
+ iss: string
249
+ aud: string
250
+ iat: number
251
+ exp: number
252
+ [claim: string]: unknown
253
+ }> {
254
+ const parts = token.split(".")
255
+ if (parts.length !== 3) {
256
+ throw new Error("Malformed JWT — expected three segments.")
257
+ }
258
+ const [headerB64, payloadB64, sigB64] = parts
259
+ const header = JSON.parse(decodeBase64Url(headerB64)) as {
260
+ alg?: string
261
+ kid?: string
262
+ }
263
+ if (header.alg !== "RS256") {
264
+ throw new Error("Only RS256 supported.")
265
+ }
266
+ const claims = JSON.parse(decodeBase64Url(payloadB64)) as {
267
+ exp?: number
268
+ iss?: string
269
+ aud?: string
270
+ }
271
+ if (typeof claims.exp !== "number" || claims.exp * 1000 < Date.now()) {
272
+ throw new Error("Token expired.")
273
+ }
274
+ const expectedIssSuffix = `/p/${this.http.projectSlug}`
275
+ if (
276
+ typeof claims.iss !== "string" ||
277
+ !claims.iss.endsWith(expectedIssSuffix)
278
+ ) {
279
+ throw new Error("Issuer mismatch.")
280
+ }
281
+ // aud == project apiKeyPrefix (first 12 chars of api key)
282
+ if (
283
+ typeof claims.aud !== "string" ||
284
+ !this.http.apiKey?.startsWith(claims.aud)
285
+ ) {
286
+ throw new Error("Audience mismatch.")
287
+ }
288
+
289
+ const jwks = await this.fetchJwks()
290
+ const key = jwks.keys.find(
291
+ (k) => (k as { kid?: string }).kid === header.kid,
292
+ ) ?? jwks.keys[0]
293
+ if (!key) throw new Error("No public key in JWKS.")
294
+
295
+ await verifyRsaSignature({
296
+ data: `${headerB64}.${payloadB64}`,
297
+ sigB64,
298
+ jwk: key as JsonWebKey,
299
+ })
300
+
301
+ return claims as never
302
+ }
303
+
304
+ /** JWKS cache'ini elle temizle (key rotation sonrası). */
305
+ invalidateJwksCache(): void {
306
+ this.cachedJwks = null
307
+ }
308
+
309
+ private async fetchJwks(): Promise<{ keys: Record<string, unknown>[] }> {
310
+ if (this.cachedJwks && this.cachedJwks.expiresAt > Date.now()) {
311
+ return { keys: this.cachedJwks.keys }
312
+ }
313
+ const jwks = await this.http.request<{
314
+ keys: Record<string, unknown>[]
315
+ }>("/jwks.json", { method: "GET" })
316
+ this.cachedJwks = {
317
+ keys: jwks.keys,
318
+ expiresAt: Date.now() + this.jwksCacheTtl * 1000,
319
+ }
320
+ return jwks
321
+ }
322
+ }
323
+
324
+ // ─── Helpers ─────────────────────────────────────────────────────────────
325
+
326
+ function decodeBase64Url(s: string): string {
327
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/")
328
+ const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4))
329
+ if (typeof atob === "function") {
330
+ const binary = atob(padded + pad)
331
+ const bytes = new Uint8Array(binary.length)
332
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
333
+ return new TextDecoder().decode(bytes)
334
+ }
335
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
336
+ const B = (globalThis as any).Buffer
337
+ if (B) return B.from(padded + pad, "base64").toString("utf8")
338
+ throw new Error("No base64 decoder available")
339
+ }
340
+
341
+ function base64UrlToBytes(s: string): Uint8Array {
342
+ const padded = s.replace(/-/g, "+").replace(/_/g, "/")
343
+ const pad = padded.length % 4 === 0 ? "" : "=".repeat(4 - (padded.length % 4))
344
+ if (typeof atob === "function") {
345
+ const binary = atob(padded + pad)
346
+ const bytes = new Uint8Array(binary.length)
347
+ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
348
+ return bytes
349
+ }
350
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
351
+ const B = (globalThis as any).Buffer
352
+ if (B) return new Uint8Array(B.from(padded + pad, "base64"))
353
+ throw new Error("No base64 decoder available")
354
+ }
355
+
356
+ async function verifyRsaSignature(input: {
357
+ data: string
358
+ sigB64: string
359
+ jwk: JsonWebKey
360
+ }): Promise<void> {
361
+ // Browser + modern Node (>=18) have crypto.subtle. Tek kod yolu.
362
+ const subtle =
363
+ typeof crypto !== "undefined" && crypto.subtle ? crypto.subtle : null
364
+ if (!subtle) {
365
+ throw new Error(
366
+ "Web Crypto unavailable — upgrade Node >= 18 or run in a browser.",
367
+ )
368
+ }
369
+ const key = await subtle.importKey(
370
+ "jwk",
371
+ input.jwk,
372
+ { name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
373
+ false,
374
+ ["verify"],
375
+ )
376
+ // Web Crypto types want ArrayBuffer-backed BufferSource. Bytes are
377
+ // created fresh from base64 decode so they are ArrayBuffer-safe.
378
+ const sigBytes = base64UrlToBytes(input.sigB64) as Uint8Array
379
+ const dataBytes = new TextEncoder().encode(input.data) as Uint8Array
380
+ const ok = await subtle.verify(
381
+ { name: "RSASSA-PKCS1-v1_5" },
382
+ key,
383
+ sigBytes as unknown as ArrayBuffer,
384
+ dataBytes as unknown as ArrayBuffer,
385
+ )
386
+ if (!ok) throw new Error("Signature mismatch.")
387
+ }