@bagelink/auth 1.4.178 → 1.4.180

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/sso.ts ADDED
@@ -0,0 +1,565 @@
1
+ import type { SSOProvider, AuthenticationResponse } from './types'
2
+
3
+ // Global reference to auth API - will be set by setAuthContext
4
+ let authApiRef: any = null
5
+
6
+ /**
7
+ * Set the auth context for SSO operations
8
+ * This is called automatically when using useAuth()
9
+ */
10
+ export function setAuthContext(authApi: any) {
11
+ authApiRef = authApi
12
+ }
13
+
14
+ /**
15
+ * Get current auth context
16
+ */
17
+ function getAuthApi() {
18
+ if (!authApiRef) {
19
+ throw new Error('SSO auth context not initialized. Make sure to call useAuth() before using SSO methods.')
20
+ }
21
+ return authApiRef
22
+ }
23
+
24
+ /**
25
+ * SSO Provider Configuration
26
+ */
27
+ export interface SSOProviderConfig {
28
+ /** Provider identifier */
29
+ id: SSOProvider
30
+ /** Display name */
31
+ name: string
32
+ /** Brand color (hex) */
33
+ color: string
34
+ /** Icon identifier (for UI libraries) */
35
+ icon: string
36
+ /** Default OAuth scopes */
37
+ defaultScopes: string[]
38
+ /** Provider-specific metadata */
39
+ metadata?: {
40
+ authDomain?: string
41
+ buttonText?: string
42
+ [key: string]: any
43
+ }
44
+ }
45
+
46
+ /**
47
+ * OAuth Flow Options
48
+ */
49
+ export interface OAuthFlowOptions {
50
+ /** Custom redirect URI (defaults to current origin + /auth/callback) */
51
+ redirectUri?: string
52
+ /** State parameter for CSRF protection (auto-generated if not provided) */
53
+ state?: string
54
+ /** Custom scopes (overrides provider defaults) */
55
+ scopes?: string[]
56
+ /** Additional OAuth parameters (prompt, login_hint, hd, domain, etc.) */
57
+ params?: Record<string, string>
58
+ /** Popup window dimensions */
59
+ popupDimensions?: {
60
+ width?: number
61
+ height?: number
62
+ }
63
+ /** Timeout for popup flow in milliseconds (default: 90000) */
64
+ popupTimeout?: number
65
+ }
66
+
67
+ /**
68
+ * Popup Result
69
+ */
70
+ export interface PopupResult {
71
+ code: string
72
+ state?: string
73
+ error?: string
74
+ }
75
+
76
+ /**
77
+ * SSO Error Types
78
+ */
79
+ export class SSOError extends Error {
80
+ constructor(message: string, public code: string) {
81
+ super(message)
82
+ this.name = 'SSOError'
83
+ }
84
+ }
85
+
86
+ export class PopupBlockedError extends SSOError {
87
+ constructor() {
88
+ super('Popup was blocked. Please allow popups for this site.', 'POPUP_BLOCKED')
89
+ this.name = 'PopupBlockedError'
90
+ }
91
+ }
92
+
93
+ export class PopupClosedError extends SSOError {
94
+ constructor() {
95
+ super('Popup was closed by user', 'POPUP_CLOSED')
96
+ this.name = 'PopupClosedError'
97
+ }
98
+ }
99
+
100
+ export class PopupTimeoutError extends SSOError {
101
+ constructor() {
102
+ super('Popup authentication timed out', 'POPUP_TIMEOUT')
103
+ this.name = 'PopupTimeoutError'
104
+ }
105
+ }
106
+
107
+ export class StateMismatchError extends SSOError {
108
+ constructor() {
109
+ super('State mismatch - possible CSRF attack', 'STATE_MISMATCH')
110
+ this.name = 'StateMismatchError'
111
+ }
112
+ }
113
+
114
+ /**
115
+ * SSO Provider Instance with functional methods
116
+ */
117
+ export interface SSOProviderInstance extends SSOProviderConfig {
118
+ /**
119
+ * Initiate OAuth flow with redirect (most common)
120
+ * User is redirected to provider's authorization page
121
+ */
122
+ redirect: (options?: OAuthFlowOptions) => Promise<void>
123
+
124
+ /**
125
+ * Initiate OAuth flow in a popup window
126
+ * Returns the authorization code without leaving the page
127
+ */
128
+ popup: (options?: OAuthFlowOptions) => Promise<AuthenticationResponse>
129
+
130
+ /**
131
+ * Complete OAuth flow after callback
132
+ * Call this on your callback page
133
+ */
134
+ callback: (code: string, state?: string) => Promise<AuthenticationResponse>
135
+
136
+ /**
137
+ * Link this provider to the current logged-in user
138
+ */
139
+ link: (code: string) => Promise<void>
140
+
141
+ /**
142
+ * Unlink this provider from the current user
143
+ */
144
+ unlink: () => Promise<void>
145
+
146
+ /**
147
+ * Get authorization URL without redirecting
148
+ */
149
+ getAuthUrl: (options?: OAuthFlowOptions) => Promise<string>
150
+
151
+ /**
152
+ * Whether this provider supports popup flow
153
+ * Some providers (like Apple) work better with redirect
154
+ */
155
+ supportsPopup?: boolean
156
+ }
157
+
158
+ /**
159
+ * Helper to generate random state for CSRF protection
160
+ * Uses 32 bytes (64 hex chars) for enhanced security
161
+ */
162
+ function generateState(): string {
163
+ const array = new Uint8Array(32)
164
+ crypto.getRandomValues(array)
165
+ return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('')
166
+ }
167
+
168
+ /**
169
+ * Helper to open popup window centered on screen
170
+ */
171
+ function openPopup(url: string, width = 500, height = 600): Window | null {
172
+ const left = window.screenX + (window.outerWidth - width) / 2
173
+ const top = window.screenY + (window.outerHeight - height) / 2
174
+ return window.open(
175
+ url,
176
+ 'oauth-popup',
177
+ `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no`
178
+ )
179
+ }
180
+
181
+ /**
182
+ * Helper to wait for OAuth callback in popup
183
+ * Uses postMessage for reliable communication with polling fallback
184
+ */
185
+ function waitForPopupCallback(popup: Window, provider: string, timeoutMs = 90000): Promise<PopupResult> {
186
+ return new Promise((resolve, reject) => {
187
+ let done = false
188
+ const finish = (fn: () => void) => {
189
+ if (!done) {
190
+ done = true
191
+ fn()
192
+ }
193
+ }
194
+
195
+ // Timeout handler
196
+ const timer = setTimeout(() => {
197
+ finish(() => { reject(new PopupTimeoutError()) })
198
+ }, timeoutMs)
199
+
200
+ // postMessage listener (preferred method)
201
+ function onMessage(ev: MessageEvent) {
202
+ try {
203
+ // Strict origin check
204
+ if (ev.origin !== window.location.origin) return
205
+
206
+ const data = ev.data || {}
207
+ if (data.type !== 'auth:complete' || data.provider !== provider) return
208
+
209
+ cleanup()
210
+ if (data.error) {
211
+ reject(new SSOError(data.error, 'OAUTH_ERROR'))
212
+ } else if (data.code) {
213
+ resolve({ code: data.code, state: data.state })
214
+ }
215
+ } catch {
216
+ // Ignore message parsing errors
217
+ }
218
+ }
219
+
220
+ // Polling fallback (for when postMessage isn't available)
221
+ const pollInterval = setInterval(() => {
222
+ try {
223
+ if (popup.closed) {
224
+ cleanup()
225
+ reject(new PopupClosedError())
226
+ return
227
+ }
228
+
229
+ // Try to read popup URL (only works when same-origin)
230
+ const url = new URL(popup.location.href)
231
+ if (url.origin === window.location.origin) {
232
+ const code = url.searchParams.get('code')
233
+ const state = url.searchParams.get('state') ?? undefined
234
+ const error = url.searchParams.get('error')
235
+
236
+ if (code || error) {
237
+ cleanup()
238
+ try {
239
+ popup.close()
240
+ } catch {
241
+ // Ignore close errors
242
+ }
243
+
244
+ if (error) {
245
+ reject(new SSOError(error, 'OAUTH_ERROR'))
246
+ } else if (code) {
247
+ resolve({ code, state })
248
+ }
249
+ }
250
+ }
251
+ } catch {
252
+ // Cross-origin error - popup hasn't redirected back yet
253
+ // This is expected and normal
254
+ }
255
+ }, 150)
256
+
257
+ function cleanup() {
258
+ finish(() => {
259
+ clearInterval(pollInterval)
260
+ clearTimeout(timer)
261
+ window.removeEventListener('message', onMessage)
262
+ try {
263
+ popup.close()
264
+ } catch {
265
+ // Ignore close errors
266
+ }
267
+ })
268
+ }
269
+
270
+ window.addEventListener('message', onMessage)
271
+ })
272
+ }
273
+
274
+ /**
275
+ * Create SSO Provider Instance with methods
276
+ */
277
+ function createSSOProvider(config: SSOProviderConfig): SSOProviderInstance {
278
+ const getDefaultRedirectUri = () => {
279
+ if (typeof window !== 'undefined') {
280
+ return `${window.location.origin}/auth/callback`
281
+ }
282
+ return `/auth/callback`
283
+ }
284
+
285
+ /**
286
+ * Get per-provider state storage key
287
+ */
288
+ const getStateKey = () => `oauth_state:${config.id}`
289
+
290
+ return {
291
+ ...config,
292
+
293
+ async redirect(options: OAuthFlowOptions = {}) {
294
+ const auth = getAuthApi()
295
+ const redirectUri = options.redirectUri ?? getDefaultRedirectUri()
296
+ const state = options.state ?? generateState()
297
+
298
+ // Store state AND provider in sessionStorage for verification
299
+ if (typeof sessionStorage !== 'undefined') {
300
+ sessionStorage.setItem(getStateKey(), state)
301
+ // Map state -> provider so we can identify which provider on callback
302
+ sessionStorage.setItem(`oauth_provider:${state}`, config.id)
303
+ }
304
+
305
+ const authUrl = await auth.initiateSSO({
306
+ provider: config.id,
307
+ redirect_uri: redirectUri,
308
+ state,
309
+ scopes: options.scopes ?? config.defaultScopes,
310
+ params: options.params,
311
+ })
312
+
313
+ window.location.href = authUrl
314
+ },
315
+
316
+ async popup(options: OAuthFlowOptions = {}) {
317
+ const auth = getAuthApi()
318
+ const redirectUri = options.redirectUri ?? getDefaultRedirectUri()
319
+ const state = options.state ?? generateState()
320
+ const timeout = options.popupTimeout ?? 90000
321
+
322
+ // Store state AND provider in sessionStorage for verification
323
+ if (typeof sessionStorage !== 'undefined') {
324
+ sessionStorage.setItem(getStateKey(), state)
325
+ // Map state -> provider so we can identify which provider on callback
326
+ sessionStorage.setItem(`oauth_provider:${state}`, config.id)
327
+ }
328
+
329
+ const authUrl = await auth.initiateSSO({
330
+ provider: config.id,
331
+ redirect_uri: redirectUri,
332
+ state,
333
+ scopes: options.scopes ?? config.defaultScopes,
334
+ params: options.params,
335
+ })
336
+
337
+ const { width = 500, height = 600 } = options.popupDimensions ?? {}
338
+ const popupWindow = openPopup(authUrl, width, height)
339
+
340
+ if (!popupWindow) {
341
+ throw new PopupBlockedError()
342
+ }
343
+
344
+ const result = await waitForPopupCallback(popupWindow, config.id, timeout)
345
+
346
+ return auth.loginWithSSO({
347
+ provider: config.id,
348
+ code: result.code,
349
+ state: result.state,
350
+ })
351
+ },
352
+
353
+ async callback(code: string, state?: string) {
354
+ const auth = getAuthApi()
355
+
356
+ // Verify state if it was stored (per-provider key)
357
+ if (typeof sessionStorage !== 'undefined' && state) {
358
+ const storedState = sessionStorage.getItem(getStateKey())
359
+ sessionStorage.removeItem(getStateKey())
360
+ // Clean up provider mapping
361
+ sessionStorage.removeItem(`oauth_provider:${state}`)
362
+
363
+ if (storedState && storedState !== state) {
364
+ throw new StateMismatchError()
365
+ }
366
+ }
367
+
368
+ return auth.loginWithSSO({
369
+ provider: config.id,
370
+ code,
371
+ state,
372
+ })
373
+ },
374
+
375
+ async link(code: string) {
376
+ const auth = getAuthApi()
377
+ await auth.linkSSOProvider({
378
+ provider: config.id,
379
+ code,
380
+ })
381
+ },
382
+
383
+ async unlink() {
384
+ const auth = getAuthApi()
385
+ await auth.unlinkSSOProvider(config.id)
386
+ },
387
+
388
+ async getAuthUrl(options: OAuthFlowOptions = {}) {
389
+ const auth = getAuthApi()
390
+ const redirectUri = options.redirectUri ?? getDefaultRedirectUri()
391
+ const state = options.state ?? generateState()
392
+
393
+ return auth.initiateSSO({
394
+ provider: config.id,
395
+ redirect_uri: redirectUri,
396
+ state,
397
+ scopes: options.scopes ?? config.defaultScopes,
398
+ params: options.params,
399
+ })
400
+ },
401
+
402
+ supportsPopup: true, // Default, can be overridden per provider
403
+ }
404
+ }
405
+
406
+ /**
407
+ * SSO Provider Implementations
408
+ */
409
+ export const sso = {
410
+ /**
411
+ * Google OAuth Provider
412
+ * https://developers.google.com/identity/protocols/oauth2
413
+ */
414
+ google: createSSOProvider({
415
+ id: 'google',
416
+ name: 'Google',
417
+ color: '#4285F4',
418
+ icon: 'google',
419
+ defaultScopes: ['openid', 'email', 'profile'],
420
+ metadata: {
421
+ authDomain: 'accounts.google.com',
422
+ buttonText: 'Continue with Google',
423
+ },
424
+ }),
425
+
426
+ /**
427
+ * Microsoft OAuth Provider (Azure AD / Microsoft Entra ID)
428
+ * https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow
429
+ */
430
+ microsoft: createSSOProvider({
431
+ id: 'microsoft',
432
+ name: 'Microsoft',
433
+ color: '#00A4EF',
434
+ icon: 'microsoft',
435
+ defaultScopes: ['openid', 'email', 'profile', 'User.Read'],
436
+ metadata: {
437
+ authDomain: 'login.microsoftonline.com',
438
+ buttonText: 'Continue with Microsoft',
439
+ },
440
+ }),
441
+
442
+ /**
443
+ * GitHub OAuth Provider
444
+ * https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps
445
+ */
446
+ github: createSSOProvider({
447
+ id: 'github',
448
+ name: 'GitHub',
449
+ color: '#24292E',
450
+ icon: 'github',
451
+ defaultScopes: ['read:user', 'user:email'],
452
+ metadata: {
453
+ authDomain: 'github.com',
454
+ buttonText: 'Continue with GitHub',
455
+ },
456
+ }),
457
+
458
+ /**
459
+ * Okta OAuth Provider
460
+ * https://developer.okta.com/docs/guides/implement-grant-type/authcode/main/
461
+ */
462
+ okta: createSSOProvider({
463
+ id: 'okta',
464
+ name: 'Okta',
465
+ color: '#007DC1',
466
+ icon: 'okta',
467
+ defaultScopes: ['openid', 'email', 'profile'],
468
+ metadata: {
469
+ buttonText: 'Continue with Okta',
470
+ },
471
+ }),
472
+
473
+ /**
474
+ * Apple Sign In Provider
475
+ * https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api
476
+ * Note: Apple works best with redirect flow on web
477
+ */
478
+ apple: {
479
+ ...createSSOProvider({
480
+ id: 'apple',
481
+ name: 'Apple',
482
+ color: '#000000',
483
+ icon: 'apple',
484
+ defaultScopes: ['name', 'email'],
485
+ metadata: {
486
+ authDomain: 'appleid.apple.com',
487
+ buttonText: 'Continue with Apple',
488
+ },
489
+ }),
490
+ supportsPopup: false, // Apple prefers redirect on web
491
+ // Override popup to use redirect for better UX
492
+ async popup(options?: OAuthFlowOptions) {
493
+ return this.redirect(options) as any
494
+ },
495
+ },
496
+
497
+ /**
498
+ * Facebook OAuth Provider
499
+ * https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow
500
+ */
501
+ facebook: createSSOProvider({
502
+ id: 'facebook',
503
+ name: 'Facebook',
504
+ color: '#1877F2',
505
+ icon: 'facebook',
506
+ defaultScopes: ['email', 'public_profile'],
507
+ metadata: {
508
+ authDomain: 'www.facebook.com',
509
+ buttonText: 'Continue with Facebook',
510
+ },
511
+ }),
512
+ }
513
+
514
+ /**
515
+ * Array of all SSO providers
516
+ */
517
+ export const ssoProviders = Object.values(sso) as readonly SSOProviderInstance[]
518
+
519
+ /**
520
+ * Get SSO provider instance by ID
521
+ */
522
+ export function getSSOProvider(provider: SSOProvider): SSOProviderInstance | undefined {
523
+ return sso[provider]
524
+ }
525
+
526
+ /**
527
+ * Get all available SSO providers
528
+ */
529
+ export function getAllSSOProviders(): readonly SSOProviderInstance[] {
530
+ return ssoProviders
531
+ }
532
+
533
+ /**
534
+ * Check if a provider is supported
535
+ */
536
+ export function isSupportedProvider(provider: string): provider is SSOProvider {
537
+ return provider in sso
538
+ }
539
+
540
+ /**
541
+ * Handle OAuth callback from URL
542
+ * Call this on your callback page to automatically detect and process the callback
543
+ */
544
+ export async function handleOAuthCallback(): Promise<AuthenticationResponse | null> {
545
+ if (typeof window === 'undefined') {
546
+ return null
547
+ }
548
+
549
+ const urlParams = new URLSearchParams(window.location.search)
550
+ const code = urlParams.get('code')
551
+ const state = urlParams.get('state')
552
+
553
+ if (!code || !state) {
554
+ return null
555
+ }
556
+
557
+ // Get the provider from sessionStorage (stored during redirect/popup)
558
+ const provider = sessionStorage.getItem(`oauth_provider:${state}`) as SSOProvider | null
559
+
560
+ if (!provider || !isSupportedProvider(provider)) {
561
+ throw new Error('Unable to determine OAuth provider. State may have expired.')
562
+ }
563
+
564
+ return sso[provider].callback(code, state)
565
+ }
package/src/types.ts CHANGED
@@ -42,6 +42,8 @@ export type AuthenticationMethodType =
42
42
  | 'sso'
43
43
  | 'otp'
44
44
 
45
+ export type SSOProvider = 'google' | 'microsoft' | 'github' | 'okta' | 'apple' | 'facebook'
46
+
45
47
  export interface AuthenticationAccount {
46
48
  created_at?: string
47
49
  updated_at?: string
@@ -216,11 +218,36 @@ export interface OTPMetadata {
216
218
  }
217
219
 
218
220
  export interface SSOMetadata {
219
- provider: string
221
+ provider: SSOProvider
220
222
  sso_user_info: { [key: string]: any }
221
223
  can_create_account?: boolean
222
224
  }
223
225
 
226
+ export interface SSOInitiateRequest {
227
+ provider: SSOProvider
228
+ redirect_uri?: string
229
+ state?: string
230
+ scopes?: string[]
231
+ params?: Record<string, string> // Additional OAuth params (prompt, login_hint, hd, etc.)
232
+ code_challenge?: string // For PKCE flow
233
+ code_challenge_method?: 'S256' | 'plain'
234
+ }
235
+
236
+ export interface SSOCallbackRequest {
237
+ provider: SSOProvider
238
+ code: string
239
+ state?: string
240
+ }
241
+
242
+ export interface SSOLinkRequest {
243
+ provider: SSOProvider
244
+ code: string
245
+ }
246
+
247
+ export interface SSOUnlinkRequest {
248
+ provider: SSOProvider
249
+ }
250
+
224
251
  export interface AuthenticationResponse {
225
252
  success: boolean
226
253
  account_id?: string
@@ -263,6 +290,10 @@ export type DeleteSessionResponse = AxiosResponse<MessageResponse>
263
290
  export type DeleteAllSessionsResponse = AxiosResponse<MessageResponse>
264
291
  export type CleanupSessionsResponse = AxiosResponse<MessageResponse>
265
292
  export type GetMethodsResponse = AxiosResponse<AvailableMethodsResponse>
293
+ export type SSOInitiateResponse = AxiosResponse<{ authorization_url: string }>
294
+ export type SSOCallbackResponse = AxiosResponse<AuthenticationResponse>
295
+ export type SSOLinkResponse = AxiosResponse<MessageResponse>
296
+ export type SSOUnlinkResponse = AxiosResponse<MessageResponse>
266
297
 
267
298
  // ============================================
268
299
  // Helper Functions (exported for convenience)
@@ -306,7 +337,7 @@ export function accountToUser(account: AccountInfo | null): User | null {
306
337
 
307
338
  // Fallback - use account info directly
308
339
  // Extract email from authentication methods
309
- const emailMethod = account.authentication_methods?.find(
340
+ const emailMethod = account.authentication_methods.find(
310
341
  m => m.type === 'password' || m.type === 'email_token',
311
342
  )
312
343