@bagelink/auth 1.4.178 → 1.4.182
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/README.md +11 -8
- package/dist/index.cjs +461 -34
- package/dist/index.d.cts +297 -8
- package/dist/index.d.mts +297 -8
- package/dist/index.d.ts +297 -8
- package/dist/index.mjs +450 -35
- package/package.json +1 -1
- package/src/api.ts +54 -36
- package/src/index.ts +1 -0
- package/src/sso.ts +565 -0
- package/src/types.ts +35 -2
- package/src/useAuth.ts +87 -5
- package/src/utils.ts +3 -3
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
|
|
@@ -72,6 +74,8 @@ export interface AuthMethodInfo {
|
|
|
72
74
|
is_verified: boolean
|
|
73
75
|
last_used?: string
|
|
74
76
|
use_count: number
|
|
77
|
+
provider?: SSOProvider
|
|
78
|
+
provider_user_id?: string
|
|
75
79
|
}
|
|
76
80
|
|
|
77
81
|
export interface AccountInfo {
|
|
@@ -216,11 +220,36 @@ export interface OTPMetadata {
|
|
|
216
220
|
}
|
|
217
221
|
|
|
218
222
|
export interface SSOMetadata {
|
|
219
|
-
provider:
|
|
223
|
+
provider: SSOProvider
|
|
220
224
|
sso_user_info: { [key: string]: any }
|
|
221
225
|
can_create_account?: boolean
|
|
222
226
|
}
|
|
223
227
|
|
|
228
|
+
export interface SSOInitiateRequest {
|
|
229
|
+
provider: SSOProvider
|
|
230
|
+
redirect_uri?: string
|
|
231
|
+
state?: string
|
|
232
|
+
scopes?: string[]
|
|
233
|
+
params?: Record<string, string> // Additional OAuth params (prompt, login_hint, hd, etc.)
|
|
234
|
+
code_challenge?: string // For PKCE flow
|
|
235
|
+
code_challenge_method?: 'S256' | 'plain'
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
export interface SSOCallbackRequest {
|
|
239
|
+
provider: SSOProvider
|
|
240
|
+
code: string
|
|
241
|
+
state?: string
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export interface SSOLinkRequest {
|
|
245
|
+
provider: SSOProvider
|
|
246
|
+
code: string
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export interface SSOUnlinkRequest {
|
|
250
|
+
provider: SSOProvider
|
|
251
|
+
}
|
|
252
|
+
|
|
224
253
|
export interface AuthenticationResponse {
|
|
225
254
|
success: boolean
|
|
226
255
|
account_id?: string
|
|
@@ -263,6 +292,10 @@ export type DeleteSessionResponse = AxiosResponse<MessageResponse>
|
|
|
263
292
|
export type DeleteAllSessionsResponse = AxiosResponse<MessageResponse>
|
|
264
293
|
export type CleanupSessionsResponse = AxiosResponse<MessageResponse>
|
|
265
294
|
export type GetMethodsResponse = AxiosResponse<AvailableMethodsResponse>
|
|
295
|
+
export type SSOInitiateResponse = AxiosResponse<{ authorization_url: string }>
|
|
296
|
+
export type SSOCallbackResponse = AxiosResponse<AuthenticationResponse>
|
|
297
|
+
export type SSOLinkResponse = AxiosResponse<MessageResponse>
|
|
298
|
+
export type SSOUnlinkResponse = AxiosResponse<MessageResponse>
|
|
266
299
|
|
|
267
300
|
// ============================================
|
|
268
301
|
// Helper Functions (exported for convenience)
|
|
@@ -306,7 +339,7 @@ export function accountToUser(account: AccountInfo | null): User | null {
|
|
|
306
339
|
|
|
307
340
|
// Fallback - use account info directly
|
|
308
341
|
// Extract email from authentication methods
|
|
309
|
-
const emailMethod = account.authentication_methods
|
|
342
|
+
const emailMethod = account.authentication_methods.find(
|
|
310
343
|
m => m.type === 'password' || m.type === 'email_token',
|
|
311
344
|
)
|
|
312
345
|
|