@korajs/auth 1.0.0-beta.11 → 1.0.0-beta.13

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.
Files changed (101) hide show
  1. package/README.md +52 -47
  2. package/dist/{create-org-session-RsDj9cl4.d.cts → create-org-session-ChFdulEM.d.cts} +211 -17
  3. package/dist/{create-org-session-RsDj9cl4.d.ts → create-org-session-ChFdulEM.d.ts} +211 -17
  4. package/dist/index.cjs +645 -150
  5. package/dist/index.cjs.map +1 -1
  6. package/dist/index.d.cts +27 -9
  7. package/dist/index.d.ts +27 -9
  8. package/dist/index.js +644 -150
  9. package/dist/index.js.map +1 -1
  10. package/dist/{operation-encryptor-DRmKNWpF.d.cts → operation-encryptor-DDdlb9bm.d.cts} +16 -0
  11. package/dist/{operation-encryptor-DRmKNWpF.d.ts → operation-encryptor-DDdlb9bm.d.ts} +16 -0
  12. package/dist/react.d.cts +2 -2
  13. package/dist/react.d.ts +2 -2
  14. package/dist/server.cjs +2852 -1675
  15. package/dist/server.cjs.map +1 -1
  16. package/dist/server.d.cts +779 -168
  17. package/dist/server.d.ts +779 -168
  18. package/dist/server.js +2831 -1665
  19. package/dist/server.js.map +1 -1
  20. package/dist/svelte.cjs +2 -2
  21. package/dist/svelte.cjs.map +1 -1
  22. package/dist/svelte.d.cts +2 -2
  23. package/dist/svelte.d.ts +2 -2
  24. package/dist/svelte.js +2 -2
  25. package/dist/svelte.js.map +1 -1
  26. package/dist/vue.d.cts +1 -1
  27. package/dist/vue.d.ts +1 -1
  28. package/package.json +7 -7
  29. package/src/admin/admin-api.ts +327 -0
  30. package/src/admin/audit-log.ts +324 -0
  31. package/src/admin/webhooks.ts +576 -0
  32. package/src/bindings/create-auth-session.ts +184 -0
  33. package/src/bindings/create-org-session.ts +130 -0
  34. package/src/client/auth-client.ts +1592 -0
  35. package/src/client/auth-sync.ts +213 -0
  36. package/src/client/device-session.ts +104 -0
  37. package/src/client/org-client.ts +399 -0
  38. package/src/client/quickstart.ts +108 -0
  39. package/src/client/storage.ts +94 -0
  40. package/src/device/device-identity.ts +330 -0
  41. package/src/device/device-store.ts +379 -0
  42. package/src/encryption/auto-lock.ts +170 -0
  43. package/src/encryption/database-encryption.ts +265 -0
  44. package/src/encryption/key-derivation.ts +149 -0
  45. package/src/encryption/operation-encryptor.ts +361 -0
  46. package/src/index.ts +132 -0
  47. package/src/mfa/totp.ts +826 -0
  48. package/src/org/org-routes.ts +758 -0
  49. package/src/org/org-store.ts +490 -0
  50. package/src/org/org-types.ts +230 -0
  51. package/src/passkey/passkey-client.ts +597 -0
  52. package/src/passkey/passkey-server.ts +779 -0
  53. package/src/postgres/ensure-schema.ts +65 -0
  54. package/src/provider/adapter.ts +246 -0
  55. package/src/provider/built-in/auth-routes.ts +1313 -0
  56. package/src/provider/built-in/email-verification.ts +303 -0
  57. package/src/provider/built-in/password-hash.ts +118 -0
  58. package/src/provider/built-in/password-reset.ts +416 -0
  59. package/src/provider/built-in/postgres-user-store.ts +328 -0
  60. package/src/provider/built-in/quickstart-server.ts +760 -0
  61. package/src/provider/built-in/sqlite-user-store.ts +322 -0
  62. package/src/provider/built-in/sync-scopes.ts +85 -0
  63. package/src/provider/built-in/user-store.ts +465 -0
  64. package/src/provider/external/clerk-adapter.ts +157 -0
  65. package/src/provider/external/external-jwt-provider.ts +491 -0
  66. package/src/provider/external/supabase-adapter.ts +163 -0
  67. package/src/provider/oauth/linked-identity-store.ts +108 -0
  68. package/src/provider/oauth/oauth-flow.ts +550 -0
  69. package/src/provider/oauth/oauth-types.ts +184 -0
  70. package/src/provider/oauth/postgres-oauth-store.ts +296 -0
  71. package/src/provider/oauth/sqlite-oauth-store.ts +272 -0
  72. package/src/rbac/rbac-engine.ts +323 -0
  73. package/src/rbac/rbac-types.ts +210 -0
  74. package/src/rbac/scope-resolver.ts +140 -0
  75. package/src/react/AuthProvider.tsx +97 -0
  76. package/src/react/OrgProvider.tsx +41 -0
  77. package/src/react/auth-context.ts +26 -0
  78. package/src/react/hooks.ts +110 -0
  79. package/src/react/org-hooks.ts +214 -0
  80. package/src/react.ts +26 -0
  81. package/src/server.ts +334 -0
  82. package/src/session/session.ts +401 -0
  83. package/src/svelte/auth-context.ts +50 -0
  84. package/src/svelte/org-context.ts +32 -0
  85. package/src/svelte/org-hooks.ts +201 -0
  86. package/src/svelte/use-auth.ts +115 -0
  87. package/src/svelte.ts +25 -0
  88. package/src/tokens/encrypted-token-store.ts +360 -0
  89. package/src/tokens/jwt.ts +236 -0
  90. package/src/tokens/postgres-token-revocation-store.ts +140 -0
  91. package/src/tokens/sqlite-token-revocation-store.ts +121 -0
  92. package/src/tokens/token-manager.ts +821 -0
  93. package/src/tokens/token-store.ts +192 -0
  94. package/src/types.ts +394 -0
  95. package/src/vue/auth-context.ts +10 -0
  96. package/src/vue/auth-provider-types.ts +5 -0
  97. package/src/vue/auth-provider.ts +76 -0
  98. package/src/vue/org-hooks.ts +193 -0
  99. package/src/vue/org-provider.ts +49 -0
  100. package/src/vue/use-auth.ts +139 -0
  101. package/src/vue.ts +10 -0
@@ -0,0 +1,108 @@
1
+ import { randomUUID } from 'node:crypto'
2
+ import { KoraError } from '@korajs/core'
3
+ import type { LinkedIdentity } from './oauth-types'
4
+
5
+ export interface LinkedIdentityStore {
6
+ findByProvider(provider: string, providerUserId: string): Promise<LinkedIdentity | null>
7
+ findByUser(userId: string): Promise<LinkedIdentity[]>
8
+ create(params: {
9
+ userId: string
10
+ provider: string
11
+ providerUserId: string
12
+ email: string | null
13
+ }): Promise<LinkedIdentity>
14
+ delete(userId: string, provider: string): Promise<void>
15
+ }
16
+
17
+ export class DuplicateLinkedIdentityError extends KoraError {
18
+ constructor(provider: string) {
19
+ super(`This ${provider} account is already linked.`, 'DUPLICATE_LINKED_IDENTITY', {
20
+ provider,
21
+ })
22
+ this.name = 'DuplicateLinkedIdentityError'
23
+ }
24
+ }
25
+
26
+ export class InMemoryLinkedIdentityStore implements LinkedIdentityStore {
27
+ private readonly identitiesById = new Map<string, LinkedIdentity>()
28
+ private readonly identityIdByProvider = new Map<string, string>()
29
+ private readonly identityIdsByUser = new Map<string, Set<string>>()
30
+ private readonly identityIdByUserProvider = new Map<string, string>()
31
+
32
+ async findByProvider(provider: string, providerUserId: string): Promise<LinkedIdentity | null> {
33
+ const id = this.identityIdByProvider.get(providerIdentityKey(provider, providerUserId))
34
+ return id ? (this.identitiesById.get(id) ?? null) : null
35
+ }
36
+
37
+ async findByUser(userId: string): Promise<LinkedIdentity[]> {
38
+ const ids = this.identityIdsByUser.get(userId)
39
+ if (!ids) return []
40
+ return [...ids]
41
+ .map((id) => this.identitiesById.get(id))
42
+ .filter((identity): identity is LinkedIdentity => identity !== undefined)
43
+ }
44
+
45
+ async create(params: {
46
+ userId: string
47
+ provider: string
48
+ providerUserId: string
49
+ email: string | null
50
+ }): Promise<LinkedIdentity> {
51
+ const providerKey = providerIdentityKey(params.provider, params.providerUserId)
52
+ const userProviderKeyValue = userProviderKey(params.userId, params.provider)
53
+ if (
54
+ this.identityIdByProvider.has(providerKey) ||
55
+ this.identityIdByUserProvider.has(userProviderKeyValue)
56
+ ) {
57
+ throw new DuplicateLinkedIdentityError(params.provider)
58
+ }
59
+
60
+ const identity: LinkedIdentity = {
61
+ id: randomUUID(),
62
+ userId: params.userId,
63
+ provider: params.provider,
64
+ providerUserId: params.providerUserId,
65
+ email: params.email,
66
+ linkedAt: Date.now(),
67
+ }
68
+
69
+ this.identitiesById.set(identity.id, identity)
70
+ this.identityIdByProvider.set(providerKey, identity.id)
71
+ this.identityIdByUserProvider.set(userProviderKeyValue, identity.id)
72
+
73
+ const userIdentities = this.identityIdsByUser.get(params.userId) ?? new Set<string>()
74
+ userIdentities.add(identity.id)
75
+ this.identityIdsByUser.set(params.userId, userIdentities)
76
+
77
+ return identity
78
+ }
79
+
80
+ async delete(userId: string, provider: string): Promise<void> {
81
+ const userProviderKeyValue = userProviderKey(userId, provider)
82
+ const id = this.identityIdByUserProvider.get(userProviderKeyValue)
83
+ if (!id) return
84
+
85
+ const identity = this.identitiesById.get(id)
86
+ this.identitiesById.delete(id)
87
+ this.identityIdByUserProvider.delete(userProviderKeyValue)
88
+ if (identity) {
89
+ this.identityIdByProvider.delete(
90
+ providerIdentityKey(identity.provider, identity.providerUserId),
91
+ )
92
+ }
93
+
94
+ const userIdentities = this.identityIdsByUser.get(userId)
95
+ userIdentities?.delete(id)
96
+ if (userIdentities?.size === 0) {
97
+ this.identityIdsByUser.delete(userId)
98
+ }
99
+ }
100
+ }
101
+
102
+ function providerIdentityKey(provider: string, providerUserId: string): string {
103
+ return `${provider}:${providerUserId}`
104
+ }
105
+
106
+ function userProviderKey(userId: string, provider: string): string {
107
+ return `${userId}:${provider}`
108
+ }
@@ -0,0 +1,550 @@
1
+ import type {
2
+ OAuthProviderConfig,
3
+ OAuthState,
4
+ OAuthStateStore,
5
+ OAuthTokens,
6
+ OAuthUserInfo,
7
+ } from './oauth-types'
8
+ import {
9
+ OAuthCodeExchangeError,
10
+ OAuthProviderNotFoundError,
11
+ OAuthStateMismatchError,
12
+ OAuthUserInfoError,
13
+ } from './oauth-types'
14
+
15
+ // ============================================================================
16
+ // InMemoryOAuthStateStore
17
+ // ============================================================================
18
+
19
+ /**
20
+ * Reserved metadata key holding the flow binding. Stored inside the state's
21
+ * metadata so every persisted state store round-trips it without a migration,
22
+ * and stripped from the metadata handed back to callers.
23
+ */
24
+ const BINDING_METADATA_KEY = '__kora_oauth_binding'
25
+
26
+ /** What an OAuth flow is for. A state minted for one purpose is useless for the other. */
27
+ export type OAuthFlowPurpose = 'signin' | 'link'
28
+
29
+ /** Binding requested when an authorization URL is created (AUTH-3). */
30
+ export interface OAuthFlowBinding {
31
+ /** The flow purpose. Defaults to `'signin'`. */
32
+ purpose?: OAuthFlowPurpose
33
+ /** For `'link'`: the authenticated Kora user starting the flow. */
34
+ userId?: string
35
+ /**
36
+ * A random secret held only by the initiating client (an HttpOnly cookie on
37
+ * the web, memory for native/PKCE). Only its SHA-256 is stored server-side.
38
+ */
39
+ binding?: string
40
+ }
41
+
42
+ interface StoredBinding {
43
+ purpose: OAuthFlowPurpose
44
+ userId?: string
45
+ bindingHash?: string
46
+ }
47
+
48
+ /** Default state TTL: 10 minutes */
49
+ const DEFAULT_STATE_TTL_MS = 10 * 60 * 1000
50
+ const PKCE_VERIFIER_BYTES = 32
51
+
52
+ /**
53
+ * In-memory OAuth state store for development.
54
+ * Use Redis or a database in production for multi-server deployments.
55
+ */
56
+ export class InMemoryOAuthStateStore implements OAuthStateStore {
57
+ private readonly states = new Map<string, OAuthState>()
58
+
59
+ async store(state: OAuthState): Promise<void> {
60
+ this.states.set(state.state, state)
61
+ }
62
+
63
+ async consume(stateValue: string): Promise<OAuthState | null> {
64
+ const state = this.states.get(stateValue)
65
+ if (!state) return null
66
+
67
+ // Single-use
68
+ this.states.delete(stateValue)
69
+
70
+ // Check expiry
71
+ if (Date.now() > state.expiresAt) return null
72
+
73
+ return state
74
+ }
75
+
76
+ async cleanExpired(): Promise<number> {
77
+ const now = Date.now()
78
+ let count = 0
79
+ for (const [key, state] of this.states) {
80
+ if (now > state.expiresAt) {
81
+ this.states.delete(key)
82
+ count++
83
+ }
84
+ }
85
+ return count
86
+ }
87
+ }
88
+
89
+ // ============================================================================
90
+ // OAuthManager
91
+ // ============================================================================
92
+
93
+ /**
94
+ * Configuration for the OAuth manager.
95
+ */
96
+ export interface OAuthManagerConfig {
97
+ /** Registered OAuth providers */
98
+ providers: OAuthProviderConfig[]
99
+ /** State store. Defaults to InMemoryOAuthStateStore. */
100
+ stateStore?: OAuthStateStore
101
+ /** State TTL in milliseconds. Defaults to 10 minutes. */
102
+ stateTtlMs?: number
103
+ /**
104
+ * Custom fetch function. Defaults to global fetch.
105
+ * Useful for testing or custom HTTP clients.
106
+ */
107
+ fetch?: typeof globalThis.fetch
108
+ }
109
+
110
+ /**
111
+ * Manages the OAuth 2.0 authorization code flow.
112
+ *
113
+ * Supports any standard OAuth 2.0 / OpenID Connect provider.
114
+ * Pre-built configurations available for Google, GitHub, and Microsoft.
115
+ *
116
+ * @example
117
+ * ```typescript
118
+ * const oauth = new OAuthManager({
119
+ * providers: [
120
+ * googleProvider({ clientId: '...', clientSecret: '...', redirectUri: '...' }),
121
+ * githubProvider({ clientId: '...', clientSecret: '...', redirectUri: '...' }),
122
+ * ],
123
+ * })
124
+ *
125
+ * // Step 1: Generate authorization URL
126
+ * const { url, state } = await oauth.getAuthorizationUrl('google')
127
+ * // Redirect user to url...
128
+ *
129
+ * // Step 2: Handle callback
130
+ * const { tokens, userInfo } = await oauth.handleCallback('google', code, stateParam)
131
+ * ```
132
+ */
133
+ export class OAuthManager {
134
+ private readonly providers = new Map<string, OAuthProviderConfig>()
135
+ private readonly stateStore: OAuthStateStore
136
+ private readonly stateTtlMs: number
137
+ private readonly fetchFn: typeof globalThis.fetch
138
+
139
+ constructor(config: OAuthManagerConfig) {
140
+ for (const provider of config.providers) {
141
+ this.providers.set(provider.providerId, provider)
142
+ }
143
+ this.stateStore = config.stateStore ?? new InMemoryOAuthStateStore()
144
+ this.stateTtlMs = config.stateTtlMs ?? DEFAULT_STATE_TTL_MS
145
+ this.fetchFn = config.fetch ?? globalThis.fetch.bind(globalThis)
146
+ }
147
+
148
+ /**
149
+ * Generate an authorization URL for the user to visit.
150
+ * Returns the URL and the state parameter for CSRF validation.
151
+ */
152
+ async getAuthorizationUrl(
153
+ providerId: string,
154
+ metadata?: Record<string, unknown>,
155
+ flow: OAuthFlowBinding = {},
156
+ ): Promise<{ url: string; state: string }> {
157
+ const provider = this.getProvider(providerId)
158
+ const purpose = flow.purpose ?? 'signin'
159
+ if (purpose === 'link' && !flow.userId) {
160
+ throw new OAuthStateMismatchError()
161
+ }
162
+ const stored: StoredBinding = {
163
+ purpose,
164
+ ...(flow.userId ? { userId: flow.userId } : {}),
165
+ ...(flow.binding ? { bindingHash: await sha256Base64Url(flow.binding) } : {}),
166
+ }
167
+ const userMetadata = { ...(metadata ?? {}) }
168
+ delete userMetadata[BINDING_METADATA_KEY]
169
+
170
+ const state = generateState()
171
+ const codeVerifier = provider.pkce ? generateCodeVerifier() : undefined
172
+ const codeChallenge = codeVerifier ? await createCodeChallenge(codeVerifier) : undefined
173
+ const now = Date.now()
174
+
175
+ const oauthState: OAuthState = {
176
+ state,
177
+ provider: providerId,
178
+ redirectUri: provider.redirectUri,
179
+ createdAt: now,
180
+ expiresAt: now + this.stateTtlMs,
181
+ metadata: { ...userMetadata, [BINDING_METADATA_KEY]: stored },
182
+ codeVerifier,
183
+ }
184
+
185
+ await this.stateStore.store(oauthState)
186
+
187
+ const params = new URLSearchParams({
188
+ client_id: provider.clientId,
189
+ redirect_uri: provider.redirectUri,
190
+ response_type: 'code',
191
+ scope: provider.scopes.join(' '),
192
+ state,
193
+ })
194
+ if (codeChallenge) {
195
+ params.set('code_challenge', codeChallenge)
196
+ params.set('code_challenge_method', 'S256')
197
+ }
198
+
199
+ const url = `${provider.authorizationUrl}?${params.toString()}`
200
+ return { url, state }
201
+ }
202
+
203
+ /**
204
+ * Handle the OAuth callback after the user authorizes.
205
+ * Validates the state parameter, exchanges the code for tokens,
206
+ * and fetches user info.
207
+ *
208
+ * The state is single-use and is redeemable only for the purpose it was
209
+ * minted for, by the same user (for linking) and the same client binding.
210
+ * A mismatch is rejected before the code is exchanged.
211
+ *
212
+ * @param providerId - The OAuth provider
213
+ * @param code - The authorization code from the callback
214
+ * @param state - The state parameter from the callback
215
+ * @param expected - The purpose, user and client binding of this callback
216
+ * @returns Tokens and user info from the provider
217
+ */
218
+ async handleCallback(
219
+ providerId: string,
220
+ code: string,
221
+ state: string,
222
+ expected: OAuthFlowBinding = {},
223
+ ): Promise<{
224
+ tokens: OAuthTokens
225
+ userInfo: OAuthUserInfo
226
+ stateMetadata?: Record<string, unknown>
227
+ }> {
228
+ const provider = this.getProvider(providerId)
229
+
230
+ // Validate state (CSRF protection)
231
+ const oauthState = await this.stateStore.consume(state)
232
+ if (!oauthState || oauthState.provider !== providerId) {
233
+ throw new OAuthStateMismatchError()
234
+ }
235
+ await assertBindingMatches(oauthState, expected)
236
+
237
+ // Exchange code for tokens
238
+ const tokens = await this.exchangeCodeForTokens(provider, code, oauthState)
239
+
240
+ // Fetch user info
241
+ const userInfo = await this.fetchUserInfo(provider, tokens.accessToken)
242
+
243
+ return { tokens, userInfo, stateMetadata: withoutBinding(oauthState.metadata) }
244
+ }
245
+
246
+ /**
247
+ * Get a registered provider by ID.
248
+ */
249
+ getProvider(providerId: string): OAuthProviderConfig {
250
+ const provider = this.providers.get(providerId)
251
+ if (!provider) {
252
+ throw new OAuthProviderNotFoundError(providerId)
253
+ }
254
+ return provider
255
+ }
256
+
257
+ /**
258
+ * List all registered provider IDs.
259
+ */
260
+ getProviderIds(): string[] {
261
+ return [...this.providers.keys()]
262
+ }
263
+
264
+ // --- Private ---
265
+
266
+ private async exchangeCodeForTokens(
267
+ provider: OAuthProviderConfig,
268
+ code: string,
269
+ oauthState: OAuthState,
270
+ ): Promise<OAuthTokens> {
271
+ const body = new URLSearchParams({
272
+ grant_type: 'authorization_code',
273
+ code,
274
+ redirect_uri: provider.redirectUri,
275
+ client_id: provider.clientId,
276
+ })
277
+ if (provider.clientSecret) {
278
+ body.set('client_secret', provider.clientSecret)
279
+ }
280
+ if (oauthState.codeVerifier) {
281
+ body.set('code_verifier', oauthState.codeVerifier)
282
+ }
283
+
284
+ let response: Response
285
+ try {
286
+ response = await this.fetchFn(provider.tokenUrl, {
287
+ method: 'POST',
288
+ headers: {
289
+ 'Content-Type': 'application/x-www-form-urlencoded',
290
+ Accept: 'application/json',
291
+ },
292
+ body: body.toString(),
293
+ })
294
+ } catch (err) {
295
+ throw new OAuthCodeExchangeError(err instanceof Error ? err.message : 'Network error')
296
+ }
297
+
298
+ if (!response.ok) {
299
+ let details = `HTTP ${response.status}`
300
+ try {
301
+ const errorBody = await response.text()
302
+ details += `: ${errorBody}`
303
+ } catch {
304
+ // ignore
305
+ }
306
+ throw new OAuthCodeExchangeError(details)
307
+ }
308
+
309
+ const data = (await response.json()) as Record<string, unknown>
310
+
311
+ return {
312
+ accessToken: data.access_token as string,
313
+ tokenType: (data.token_type as string) ?? 'Bearer',
314
+ expiresIn: data.expires_in as number | undefined,
315
+ refreshToken: data.refresh_token as string | undefined,
316
+ idToken: data.id_token as string | undefined,
317
+ scope: data.scope as string | undefined,
318
+ }
319
+ }
320
+
321
+ private async fetchUserInfo(
322
+ provider: OAuthProviderConfig,
323
+ accessToken: string,
324
+ ): Promise<OAuthUserInfo> {
325
+ let response: Response
326
+ try {
327
+ response = await this.fetchFn(provider.userInfoUrl, {
328
+ headers: {
329
+ Authorization: `Bearer ${accessToken}`,
330
+ Accept: 'application/json',
331
+ },
332
+ })
333
+ } catch (err) {
334
+ throw new OAuthUserInfoError(err instanceof Error ? err.message : 'Network error')
335
+ }
336
+
337
+ if (!response.ok) {
338
+ throw new OAuthUserInfoError(`HTTP ${response.status}`)
339
+ }
340
+
341
+ const profile = (await response.json()) as Record<string, unknown>
342
+
343
+ // Normalize user info based on provider
344
+ return normalizeUserInfo(provider.providerId, profile)
345
+ }
346
+ }
347
+
348
+ // ============================================================================
349
+ // User Info Normalization
350
+ // ============================================================================
351
+
352
+ function normalizeUserInfo(providerId: string, profile: Record<string, unknown>): OAuthUserInfo {
353
+ switch (providerId) {
354
+ case 'google':
355
+ return {
356
+ providerId: profile.sub as string,
357
+ provider: 'google',
358
+ email: (profile.email as string) ?? null,
359
+ emailVerified: (profile.email_verified as boolean) ?? false,
360
+ name: (profile.name as string) ?? null,
361
+ avatarUrl: (profile.picture as string) ?? null,
362
+ rawProfile: profile,
363
+ }
364
+ case 'github':
365
+ return {
366
+ providerId: String(profile.id),
367
+ provider: 'github',
368
+ email: (profile.email as string) ?? null,
369
+ emailVerified: false, // GitHub doesn't confirm in the profile response
370
+ name: (profile.name as string) ?? (profile.login as string) ?? null,
371
+ avatarUrl: (profile.avatar_url as string) ?? null,
372
+ rawProfile: profile,
373
+ }
374
+ case 'microsoft':
375
+ return {
376
+ providerId: profile.id as string,
377
+ provider: 'microsoft',
378
+ email: (profile.mail as string) ?? (profile.userPrincipalName as string) ?? null,
379
+ emailVerified: false,
380
+ name: (profile.displayName as string) ?? null,
381
+ avatarUrl: null,
382
+ rawProfile: profile,
383
+ }
384
+ default:
385
+ // Generic normalization
386
+ return {
387
+ providerId: String(profile.id ?? profile.sub ?? ''),
388
+ provider: providerId,
389
+ email: (profile.email as string) ?? null,
390
+ emailVerified: (profile.email_verified as boolean) ?? false,
391
+ name: (profile.name as string) ?? null,
392
+ avatarUrl: (profile.picture as string) ?? (profile.avatar_url as string) ?? null,
393
+ rawProfile: profile,
394
+ }
395
+ }
396
+ }
397
+
398
+ // ============================================================================
399
+ // Provider Factories
400
+ // ============================================================================
401
+
402
+ interface ProviderFactoryConfig {
403
+ clientId: string
404
+ clientSecret?: string
405
+ redirectUri: string
406
+ scopes?: string[]
407
+ pkce?: boolean
408
+ }
409
+
410
+ /**
411
+ * Create a Google OAuth provider configuration.
412
+ */
413
+ export function googleProvider(config: ProviderFactoryConfig): OAuthProviderConfig {
414
+ return {
415
+ providerId: 'google',
416
+ clientId: config.clientId,
417
+ clientSecret: config.clientSecret,
418
+ authorizationUrl: 'https://accounts.google.com/o/oauth2/v2/auth',
419
+ tokenUrl: 'https://oauth2.googleapis.com/token',
420
+ userInfoUrl: 'https://openidconnect.googleapis.com/v1/userinfo',
421
+ scopes: config.scopes ?? ['openid', 'email', 'profile'],
422
+ redirectUri: config.redirectUri,
423
+ pkce: config.pkce,
424
+ }
425
+ }
426
+
427
+ /**
428
+ * Create a GitHub OAuth provider configuration.
429
+ */
430
+ export function githubProvider(config: ProviderFactoryConfig): OAuthProviderConfig {
431
+ return {
432
+ providerId: 'github',
433
+ clientId: config.clientId,
434
+ clientSecret: config.clientSecret,
435
+ authorizationUrl: 'https://github.com/login/oauth/authorize',
436
+ tokenUrl: 'https://github.com/login/oauth/access_token',
437
+ userInfoUrl: 'https://api.github.com/user',
438
+ scopes: config.scopes ?? ['read:user', 'user:email'],
439
+ redirectUri: config.redirectUri,
440
+ pkce: config.pkce,
441
+ }
442
+ }
443
+
444
+ /**
445
+ * Create a Microsoft OAuth provider configuration.
446
+ */
447
+ export function microsoftProvider(
448
+ config: ProviderFactoryConfig & { tenantId?: string },
449
+ ): OAuthProviderConfig {
450
+ const tenant = config.tenantId ?? 'common'
451
+ return {
452
+ providerId: 'microsoft',
453
+ clientId: config.clientId,
454
+ clientSecret: config.clientSecret,
455
+ authorizationUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/authorize`,
456
+ tokenUrl: `https://login.microsoftonline.com/${tenant}/oauth2/v2.0/token`,
457
+ userInfoUrl: 'https://graph.microsoft.com/v1.0/me',
458
+ scopes: config.scopes ?? ['openid', 'email', 'profile', 'User.Read'],
459
+ redirectUri: config.redirectUri,
460
+ pkce: config.pkce,
461
+ }
462
+ }
463
+
464
+ // ============================================================================
465
+ // Helpers
466
+ // ============================================================================
467
+
468
+ function readStoredBinding(state: OAuthState): StoredBinding {
469
+ const raw = state.metadata?.[BINDING_METADATA_KEY]
470
+ if (raw && typeof raw === 'object' && !Array.isArray(raw)) {
471
+ const value = raw as Record<string, unknown>
472
+ return {
473
+ purpose: value.purpose === 'link' ? 'link' : 'signin',
474
+ ...(typeof value.userId === 'string' ? { userId: value.userId } : {}),
475
+ ...(typeof value.bindingHash === 'string' ? { bindingHash: value.bindingHash } : {}),
476
+ }
477
+ }
478
+ // States minted before beta.13 carry no binding: they are sign-in states.
479
+ return { purpose: 'signin' }
480
+ }
481
+
482
+ async function assertBindingMatches(state: OAuthState, expected: OAuthFlowBinding): Promise<void> {
483
+ const stored = readStoredBinding(state)
484
+ const purpose = expected.purpose ?? 'signin'
485
+ if (stored.purpose !== purpose) throw new OAuthStateMismatchError()
486
+ if (purpose === 'link' && (!expected.userId || stored.userId !== expected.userId)) {
487
+ throw new OAuthStateMismatchError()
488
+ }
489
+ if (stored.bindingHash !== undefined) {
490
+ if (!expected.binding) throw new OAuthStateMismatchError()
491
+ const presented = await sha256Base64Url(expected.binding)
492
+ if (!constantTimeEqual(presented, stored.bindingHash)) throw new OAuthStateMismatchError()
493
+ }
494
+ }
495
+
496
+ function withoutBinding(
497
+ metadata: Record<string, unknown> | undefined,
498
+ ): Record<string, unknown> | undefined {
499
+ if (!metadata) return undefined
500
+ const copy = { ...metadata }
501
+ delete copy[BINDING_METADATA_KEY]
502
+ return Object.keys(copy).length > 0 ? copy : undefined
503
+ }
504
+
505
+ async function sha256Base64Url(value: string): Promise<string> {
506
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', new TextEncoder().encode(value))
507
+ return toBase64Url(new Uint8Array(digest))
508
+ }
509
+
510
+ function constantTimeEqual(a: string, b: string): boolean {
511
+ if (a.length !== b.length) return false
512
+ let diff = 0
513
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i)
514
+ return diff === 0
515
+ }
516
+
517
+ /**
518
+ * Generate a random client binding for an OAuth flow (32 bytes, base64url).
519
+ */
520
+ export function generateOAuthBinding(): string {
521
+ const bytes = new Uint8Array(32)
522
+ globalThis.crypto.getRandomValues(bytes)
523
+ return toBase64Url(bytes)
524
+ }
525
+
526
+ function generateState(): string {
527
+ const bytes = new Uint8Array(32)
528
+ globalThis.crypto.getRandomValues(bytes)
529
+ return toBase64Url(bytes)
530
+ }
531
+
532
+ function generateCodeVerifier(): string {
533
+ const bytes = new Uint8Array(PKCE_VERIFIER_BYTES)
534
+ globalThis.crypto.getRandomValues(bytes)
535
+ return toBase64Url(bytes)
536
+ }
537
+
538
+ async function createCodeChallenge(codeVerifier: string): Promise<string> {
539
+ const data = new TextEncoder().encode(codeVerifier)
540
+ const digest = await globalThis.crypto.subtle.digest('SHA-256', data)
541
+ return toBase64Url(new Uint8Array(digest))
542
+ }
543
+
544
+ function toBase64Url(bytes: Uint8Array): string {
545
+ let binary = ''
546
+ for (let i = 0; i < bytes.length; i++) {
547
+ binary += String.fromCharCode(bytes[i] as number)
548
+ }
549
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
550
+ }