import type { SSOProvider, AuthenticationResponse } from './types' import { queryParams } from './utils' // Global reference to auth API - will be set by setAuthContext let authApiRef: any = null /** * Set the auth context for SSO operations * This is called automatically when using useAuth() */ export function setAuthContext(authApi: any) { authApiRef = authApi } /** * Get current auth context */ function getAuthApi() { if (authApiRef === null || authApiRef === undefined) { throw new Error('SSO auth context not initialized. Make sure to call useAuth() before using SSO methods.') } return authApiRef } /** * SSO Provider Configuration */ export interface SSOProviderConfig { /** Provider identifier */ id: SSOProvider /** Display name */ name: string /** Brand color (hex) */ color: string /** Icon identifier (for UI libraries) */ icon: string /** Default OAuth scopes */ defaultScopes: string[] /** Provider-specific metadata */ metadata?: { authDomain?: string buttonText?: string [key: string]: any } } /** * OAuth Flow Options */ export interface OAuthFlowOptions { /** Custom redirect URI (defaults to current origin + /auth/callback) */ redirectUri?: string /** State parameter for CSRF protection (auto-generated if not provided) */ state?: string /** Custom scopes (overrides provider defaults) */ scopes?: string[] /** Additional OAuth parameters (prompt, login_hint, hd, domain, etc.) */ params?: Record /** Popup window dimensions */ popupDimensions?: { width?: number height?: number } /** Timeout for popup flow in milliseconds (default: 90000) */ popupTimeout?: number } /** * Popup Result */ export interface PopupResult { code: string state?: string error?: string } /** * SSO Error Types */ export class SSOError extends Error { constructor(message: string, public code: string) { super(message) this.name = 'SSOError' } } export class PopupBlockedError extends SSOError { constructor() { super('Popup was blocked. Please allow popups for this site.', 'POPUP_BLOCKED') this.name = 'PopupBlockedError' } } export class PopupClosedError extends SSOError { constructor() { super('Popup was closed by user', 'POPUP_CLOSED') this.name = 'PopupClosedError' } } export class PopupTimeoutError extends SSOError { constructor() { super('Popup authentication timed out', 'POPUP_TIMEOUT') this.name = 'PopupTimeoutError' } } export class StateMismatchError extends SSOError { constructor() { super('State mismatch - possible CSRF attack', 'STATE_MISMATCH') this.name = 'StateMismatchError' } } /** * SSO Provider Instance with functional methods */ export interface SSOProviderInstance extends SSOProviderConfig { /** * Initiate OAuth flow with redirect (most common) * User is redirected to provider's authorization page */ redirect: (options?: OAuthFlowOptions) => Promise /** * Initiate OAuth flow in a popup window * Returns the authorization code without leaving the page */ popup: (options?: OAuthFlowOptions) => Promise /** * Complete OAuth flow after callback * Call this on your callback page */ callback: (code: string, state: string) => Promise /** * Link this provider to the current logged-in user * Call this after OAuth redirect completes on link callback page */ link: (code: string, state: string) => Promise /** * Unlink this provider from the current user */ unlink: () => Promise /** * Get authorization URL without redirecting */ getAuthUrl: (options?: OAuthFlowOptions) => Promise /** * Whether this provider supports popup flow * Some providers (like Apple) work better with redirect */ supportsPopup?: boolean } /** * SSO object type with providers and helper methods */ export interface SSOObject { // Provider instances google: SSOProviderInstance microsoft: SSOProviderInstance github: SSOProviderInstance okta: SSOProviderInstance apple: SSOProviderInstance facebook: SSOProviderInstance // Global helper methods handleCallback: () => Promise handleLinkCallback: () => Promise } /** * Helper to generate random state for CSRF protection * Uses 32 bytes (64 hex chars) for enhanced security */ function generateState(): string { const array = new Uint8Array(32) crypto.getRandomValues(array) return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('') } /** * Helper to open popup window centered on screen */ function openPopup(url: string, width = 500, height = 600): Window | null { const left = window.screenX + (window.outerWidth - width) / 2 const top = window.screenY + (window.outerHeight - height) / 2 return window.open( url, 'oauth-popup', `width=${width},height=${height},left=${left},top=${top},toolbar=no,menubar=no,location=no,status=no` ) } /** * Helper to wait for OAuth callback in popup * Uses postMessage for reliable communication with polling fallback */ function waitForPopupCallback(popup: Window, provider: string, timeoutMs = 90000): Promise { return new Promise((resolve, reject) => { let done = false const finish = (fn: () => void) => { if (!done) { done = true fn() } } // Timeout handler const timer = setTimeout(() => { finish(() => { reject(new PopupTimeoutError()) }) }, timeoutMs) // postMessage listener (preferred method) function onMessage(ev: MessageEvent) { try { // Strict origin check if (window.location.origin !== ev.origin) { return } const data = ev.data ?? {} if (data.type !== 'auth:complete' || provider !== data.provider) { return } cleanup() if (data.error !== undefined && data.error !== null) { reject(new SSOError(data.error, 'OAUTH_ERROR')) } else if (data.code !== undefined && data.code !== null) { resolve({ code: data.code, state: data.state }) } } catch { // Ignore message parsing errors } } // Polling fallback (for when postMessage isn't available) const pollInterval = setInterval(() => { try { if (popup.closed) { cleanup() reject(new PopupClosedError()) return } // Try to read popup URL (only works when same-origin) const url = new URL(popup.location.href) if (window.location.origin === url.origin) { const code = url.searchParams.get('code') const state = url.searchParams.get('state') ?? undefined const error = url.searchParams.get('error') if ((code !== null && code !== '') || (error !== null && error !== '')) { cleanup() try { popup.close() } catch { // Ignore close errors } if (error !== null && error !== '') { reject(new SSOError(error, 'OAUTH_ERROR')) } else if (code !== null && code !== '') { resolve({ code, state }) } } } } catch { // Cross-origin error - popup hasn't redirected back yet // This is expected and normal } }, 150) function cleanup() { finish(() => { clearInterval(pollInterval) clearTimeout(timer) window.removeEventListener('message', onMessage) try { popup.close() } catch { // Ignore close errors } }) } window.addEventListener('message', onMessage) }) } /** * Create SSO Provider Instance with methods */ function createSSOProvider(config: SSOProviderConfig): SSOProviderInstance { const getDefaultRedirectUri = () => { if (typeof window !== 'undefined') { return `${window.location.origin}/auth/callback` } return `/auth/callback` } /** * Get per-provider state storage key */ const getStateKey = () => `oauth_state:${config.id}` return { ...config, async redirect(options: OAuthFlowOptions = {}) { const auth = getAuthApi() const redirectUri = options.redirectUri ?? getDefaultRedirectUri() const state = options.state ?? generateState() // Preserve redirect URL from current location if (typeof window !== 'undefined' && typeof sessionStorage !== 'undefined') { const currentParams = queryParams() const redirectUrl = currentParams.redirect if (redirectUrl) { // Store redirect URL to restore after OAuth callback console.log('[SSO] Preserving redirect URL:', redirectUrl, 'with state:', state) sessionStorage.setItem(`oauth_redirect:${state}`, redirectUrl) } else { console.log('[SSO] No redirect URL found in current params:', currentParams) } } // Store state AND provider in sessionStorage for verification if (typeof sessionStorage !== 'undefined') { sessionStorage.setItem(getStateKey(), state) // Map state -> provider so we can identify which provider on callback sessionStorage.setItem(`oauth_provider:${state}`, config.id) } const authUrl = await auth.initiateSSO(config.id, { redirect_uri: redirectUri, state, scopes: options.scopes ?? config.defaultScopes, params: options.params, }) window.location.href = authUrl }, async popup(options: OAuthFlowOptions = {}) { const auth = getAuthApi() const redirectUri = options.redirectUri ?? getDefaultRedirectUri() const state = options.state ?? generateState() const timeout = options.popupTimeout ?? 90000 // Preserve redirect URL from current location (for popup flow too) if (typeof window !== 'undefined' && typeof sessionStorage !== 'undefined') { const currentParams = queryParams() const redirectUrl = currentParams.redirect if (redirectUrl) { // Store redirect URL to restore after OAuth callback sessionStorage.setItem(`oauth_redirect:${state}`, redirectUrl) } } // Store state AND provider in sessionStorage for verification if (typeof sessionStorage !== 'undefined') { sessionStorage.setItem(getStateKey(), state) // Map state -> provider so we can identify which provider on callback sessionStorage.setItem(`oauth_provider:${state}`, config.id) } const authUrl = await auth.initiateSSO(config.id, { redirect_uri: redirectUri, state, scopes: options.scopes ?? config.defaultScopes, params: options.params, }) const { width = 500, height = 600 } = options.popupDimensions ?? {} const popupWindow = openPopup(authUrl, width, height) if (!popupWindow) { throw new PopupBlockedError() } const result = await waitForPopupCallback(popupWindow, config.id, timeout) return auth.loginWithSSO(config.id, { code: result.code, state: result.state ?? generateState(), }) }, async callback(code: string, state: string) { const auth = getAuthApi() // Verify state if it was stored (per-provider key) if (typeof sessionStorage !== 'undefined') { const storedState = sessionStorage.getItem(getStateKey()) sessionStorage.removeItem(getStateKey()) sessionStorage.removeItem(`oauth_provider:${state}`) if (storedState !== null && storedState !== state) { throw new StateMismatchError() } } return auth.loginWithSSO(config.id, { code, state: state ?? generateState(), }) }, async link(code: string, state: string) { const auth = getAuthApi() // Verify state if it was stored (per-provider key) if (typeof sessionStorage !== 'undefined') { const storedState = sessionStorage.getItem(getStateKey()) sessionStorage.removeItem(getStateKey()) sessionStorage.removeItem(`oauth_provider:${state}`) if (storedState !== null && storedState !== state) { throw new StateMismatchError() } } await auth.linkSSOProvider(config.id, { code, state: state ?? generateState(), }) }, async unlink() { const auth = getAuthApi() await auth.unlinkSSOProvider(config.id) }, async getAuthUrl(options: OAuthFlowOptions = {}) { const auth = getAuthApi() const redirectUri = options.redirectUri ?? getDefaultRedirectUri() const state = options.state ?? generateState() return auth.initiateSSO(config.id, { redirect_uri: redirectUri, state, scopes: options.scopes ?? config.defaultScopes, params: options.params, }) }, supportsPopup: true, // Default, can be overridden per provider } } /** * SSO Provider Implementations with global helpers */ const ssoProviders: Record = { /** * Google OAuth Provider * https://developers.google.com/identity/protocols/oauth2 */ google: createSSOProvider({ id: 'google', name: 'Google', color: '#4285F4', icon: 'google', defaultScopes: ['openid', 'email', 'profile'], metadata: { authDomain: 'accounts.google.com', buttonText: 'Continue with Google', }, }), /** * Microsoft OAuth Provider (Azure AD / Microsoft Entra ID) * https://learn.microsoft.com/en-us/azure/active-directory/develop/v2-oauth2-auth-code-flow */ microsoft: createSSOProvider({ id: 'microsoft', name: 'Microsoft', color: '#00A4EF', icon: 'microsoft', defaultScopes: ['openid', 'email', 'profile', 'User.Read'], metadata: { authDomain: 'login.microsoftonline.com', buttonText: 'Continue with Microsoft', }, }), /** * GitHub OAuth Provider * https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps */ github: createSSOProvider({ id: 'github', name: 'GitHub', color: '#24292E', icon: 'github', defaultScopes: ['read:user', 'user:email'], metadata: { authDomain: 'github.com', buttonText: 'Continue with GitHub', }, }), /** * Okta OAuth Provider * https://developer.okta.com/docs/guides/implement-grant-type/authcode/main/ */ okta: createSSOProvider({ id: 'okta', name: 'Okta', color: '#007DC1', icon: 'okta', defaultScopes: ['openid', 'email', 'profile'], metadata: { buttonText: 'Continue with Okta', }, }), /** * Apple Sign In Provider * https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api * Note: Apple works best with redirect flow on web */ apple: { ...createSSOProvider({ id: 'apple', name: 'Apple', color: '#000000', icon: 'apple', defaultScopes: ['name', 'email'], metadata: { authDomain: 'appleid.apple.com', buttonText: 'Continue with Apple', }, }), supportsPopup: false, // Apple prefers redirect on web // Override popup to use redirect for better UX async popup(options?: OAuthFlowOptions) { return this.redirect(options) as any }, }, /** * Facebook OAuth Provider * https://developers.facebook.com/docs/facebook-login/guides/advanced/manual-flow */ facebook: createSSOProvider({ id: 'facebook', name: 'Facebook', color: '#1877F2', icon: 'facebook', defaultScopes: ['email', 'public_profile'], metadata: { authDomain: 'www.facebook.com', buttonText: 'Continue with Facebook', }, }), custom: createSSOProvider({ id: 'custom', name: 'Custom', color: '#000000', icon: 'custom', defaultScopes: [], metadata: { buttonText: 'Continue with Custom', }, }), } /** * SSO object with providers and global helper methods */ export const sso: SSOObject = { // All provider instances ...ssoProviders, /** * Handle OAuth callback from URL automatically * Detects provider from state and completes login * * @example * // On /auth/callback page * const result = await sso.handleCallback() */ handleCallback: handleOAuthCallback, /** * Handle OAuth link callback from URL automatically * Detects provider from state and completes linking * * @example * // On /settings/link-callback page * await sso.handleLinkCallback() */ handleLinkCallback: handleOAuthLinkCallback, } /** * Array of all SSO provider instances */ export const ssoProvidersList = Object.values(ssoProviders) as readonly SSOProviderInstance[] /** * Get SSO provider instance by ID */ export function getSSOProvider(provider: SSOProvider): SSOProviderInstance | undefined { return ssoProviders[provider] } /** * Get all available SSO providers */ export function getAllSSOProviders(): readonly SSOProviderInstance[] { return ssoProvidersList } /** * Check if a provider is supported */ export function isSupportedProvider(provider: string): provider is SSOProvider { return provider in ssoProviders } /** * Popup-mode OAuth landing handler. * * Call this **once at app startup, before the router mounts**. When the current * page was opened as an OAuth popup (it has `?code`/`?state` — or `?error` — and * a `window.opener`), it posts an `auth:complete` message back to the opener * (which `sso..popup()` is waiting for) and closes the popup. * * This lets a host app support popup SSO **without** a dedicated `/auth/callback` * route or component — the whole flow stays on the original page. * * @returns `true` if it handled a popup callback (caller should stop bootstrapping * the app — the window is closing), otherwise `false`. * * @example * ```ts * import { handleSsoPopup } from '@bagelink/auth' * if (handleSsoPopup()) { // we're the popup; do nothing else * } else { * app.mount('#app') * } * ``` */ export function handleSsoPopup(): boolean { if (typeof window === 'undefined' || !window.opener || window.opener === window) { return false } const { code, state, error } = queryParams() if (!code && !error) return false const provider = state && typeof sessionStorage !== 'undefined' ? sessionStorage.getItem(`oauth_provider:${state}`) ?? undefined : undefined try { window.opener.postMessage( { type: 'auth:complete', provider, code: code || undefined, state, error: error || undefined }, window.location.origin, ) } catch { // Opener gone or cross-origin — the opener's polling fallback still works. } // Close the popup; the opener resolves via the message (or its poll). try { window.close() } catch { /* ignore */ } return true } /** * Handle OAuth callback from URL * Internal helper - use sso.handleCallback() instead */ function handleOAuthCallback(): Promise { const { code, state } = queryParams() if (!code || !state) { return Promise.resolve(null) } // Get the provider from sessionStorage (stored during redirect/popup) const provider = sessionStorage.getItem(`oauth_provider:${state}`) as SSOProvider | null if (!provider || !isSupportedProvider(provider)) { throw new Error('Unable to determine OAuth provider. State may have expired.') } // Restore redirect URL to current location if it was stored if (typeof window !== 'undefined' && typeof sessionStorage !== 'undefined') { const storedRedirect = sessionStorage.getItem(`oauth_redirect:${state}`) if (storedRedirect) { // Add redirect param back to URL so auto-redirect can pick it up console.log('[SSO] Restoring redirect URL:', storedRedirect) const url = new URL(window.location.href) url.searchParams.set('redirect', storedRedirect) window.history.replaceState({}, '', url.toString()) console.log('[SSO] URL updated to:', url.toString()) // Clean up sessionStorage.removeItem(`oauth_redirect:${state}`) } else { console.log('[SSO] No stored redirect URL found for state:', state) console.log('[SSO] SessionStorage keys:', Object.keys(sessionStorage)) } } return ssoProviders[provider].callback(code, state) } /** * Handle OAuth link callback from URL * Internal helper - use sso.handleLinkCallback() instead */ function handleOAuthLinkCallback(): Promise { const { code, state } = queryParams() if (!code || !state) { throw new Error('Missing code or state parameter') } // Get the provider from sessionStorage (stored during redirect) const provider = sessionStorage.getItem(`oauth_provider:${state}`) as SSOProvider | null if (!provider || !isSupportedProvider(provider)) { throw new Error('Unable to determine OAuth provider. State may have expired.') } // Restore redirect URL to current location if it was stored if (typeof window !== 'undefined' && typeof sessionStorage !== 'undefined') { const storedRedirect = sessionStorage.getItem(`oauth_redirect:${state}`) if (storedRedirect) { // Add redirect param back to URL const url = new URL(window.location.href) url.searchParams.set('redirect', storedRedirect) window.history.replaceState({}, '', url.toString()) // Clean up sessionStorage.removeItem(`oauth_redirect:${state}`) } } return ssoProviders[provider].link(code, state) }