diff --git a/dist/client-CTKWBZ26.d.mts b/dist/client-CTKWBZ26.d.mts new file mode 100644 index 0000000..95e7c24 --- /dev/null +++ b/dist/client-CTKWBZ26.d.mts @@ -0,0 +1,185 @@ +/** + * Stuffle IAM SDK Types + */ +interface StuffleIAMConfig { + /** IAM server base URL (e.g., http://localhost:5000) */ + issuer: string; + /** OAuth2 client ID */ + clientId: string; + /** Redirect URI after login */ + redirectUri: string; + /** Requested scopes (default: openid profile email) */ + scopes?: string[]; + /** Post-logout redirect URI */ + postLogoutRedirectUri?: string; + /** Enable PKCE (default: true, recommended for SPAs) */ + usePkce?: boolean; + /** Storage type for tokens (default: sessionStorage) */ + storage?: 'localStorage' | 'sessionStorage' | 'memory'; + /** Auto-refresh tokens before expiry (default: true) */ + autoRefresh?: boolean; + /** Seconds before expiry to trigger refresh (default: 60) */ + refreshThreshold?: number; +} +interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; + id_token?: string; + scope?: string; +} +interface User { + sub: string; + email?: string; + email_verified?: boolean; + name?: string; + given_name?: string; + family_name?: string; + picture?: string; + username?: string; + roles?: string[]; + tenantId?: string; + [key: string]: unknown; +} +interface AuthState { + isAuthenticated: boolean; + isLoading: boolean; + user: User | null; + accessToken: string | null; + idToken: string | null; + error: Error | null; +} +interface LoginOptions { + /** Additional scopes to request */ + scopes?: string[]; + /** State parameter (auto-generated if not provided) */ + state?: string; + /** Nonce for ID token validation */ + nonce?: string; + /** Redirect to signup instead of login */ + signup?: boolean; + /** Prompt parameter (none, login, consent, select_account) */ + prompt?: 'none' | 'login' | 'consent' | 'select_account'; + /** Login hint (email or username) */ + loginHint?: string; +} +interface LogoutOptions { + /** Post-logout redirect URI */ + returnTo?: string; + /** Include id_token_hint in logout request */ + idTokenHint?: string; +} +interface CallbackResult { + success: boolean; + user?: User; + accessToken?: string; + idToken?: string; + refreshToken?: string; + error?: string; + errorDescription?: string; +} +interface OIDCDiscovery { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + jwks_uri: string; + end_session_endpoint?: string; + registration_endpoint?: string; + scopes_supported: string[]; + response_types_supported: string[]; + grant_types_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + code_challenge_methods_supported?: string[]; +} + +/** + * Stuffle IAM Client + * + * OIDC/OAuth2 client for browser-based applications. + * Supports authorization code flow with PKCE. + */ + +declare class StuffleIAMClient { + private config; + private storage; + private discovery; + private refreshTimer; + private listeners; + constructor(config: StuffleIAMConfig); + /** + * Fetch OIDC discovery document + */ + getDiscovery(): Promise; + /** + * Start login flow - redirects to authorization endpoint + */ + login(options?: LoginOptions): Promise; + /** + * Alias for login({ signup: true }) + */ + signup(options?: Omit): Promise; + /** + * Handle callback from authorization server + */ + handleCallback(url?: string): Promise; + /** + * Exchange authorization code for tokens + */ + private exchangeCode; + /** + * Refresh access token using refresh token + */ + refreshToken(): Promise; + /** + * Logout - end session + */ + logout(options?: LogoutOptions): Promise; + /** + * Get current access token (refreshes if needed) + */ + getAccessToken(): Promise; + /** + * Get current user from stored ID token + */ + getUser(): User | null; + /** + * Fetch user info from userinfo endpoint + */ + fetchUserInfo(accessToken?: string): Promise; + /** + * Check if user is authenticated + */ + isAuthenticated(): boolean; + /** + * Get current auth state + */ + getAuthState(): AuthState; + /** + * Subscribe to auth state changes + */ + subscribe(listener: (state: AuthState) => void): () => void; + /** + * Store tokens in storage + */ + private storeTokens; + /** + * Clear all stored tokens + */ + private clearTokens; + /** + * Setup auto-refresh timer + */ + private setupAutoRefresh; + /** + * Notify all listeners of state change + */ + private notifyListeners; +} +/** + * Create a new Stuffle IAM client instance + */ +declare function createStuffleIAMClient(config: StuffleIAMConfig): StuffleIAMClient; + +export { type AuthState as A, type CallbackResult as C, type LoginOptions as L, type OIDCDiscovery as O, StuffleIAMClient as S, type TokenResponse as T, type User as U, type StuffleIAMConfig as a, type LogoutOptions as b, createStuffleIAMClient as c }; diff --git a/dist/client-CTKWBZ26.d.ts b/dist/client-CTKWBZ26.d.ts new file mode 100644 index 0000000..95e7c24 --- /dev/null +++ b/dist/client-CTKWBZ26.d.ts @@ -0,0 +1,185 @@ +/** + * Stuffle IAM SDK Types + */ +interface StuffleIAMConfig { + /** IAM server base URL (e.g., http://localhost:5000) */ + issuer: string; + /** OAuth2 client ID */ + clientId: string; + /** Redirect URI after login */ + redirectUri: string; + /** Requested scopes (default: openid profile email) */ + scopes?: string[]; + /** Post-logout redirect URI */ + postLogoutRedirectUri?: string; + /** Enable PKCE (default: true, recommended for SPAs) */ + usePkce?: boolean; + /** Storage type for tokens (default: sessionStorage) */ + storage?: 'localStorage' | 'sessionStorage' | 'memory'; + /** Auto-refresh tokens before expiry (default: true) */ + autoRefresh?: boolean; + /** Seconds before expiry to trigger refresh (default: 60) */ + refreshThreshold?: number; +} +interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; + id_token?: string; + scope?: string; +} +interface User { + sub: string; + email?: string; + email_verified?: boolean; + name?: string; + given_name?: string; + family_name?: string; + picture?: string; + username?: string; + roles?: string[]; + tenantId?: string; + [key: string]: unknown; +} +interface AuthState { + isAuthenticated: boolean; + isLoading: boolean; + user: User | null; + accessToken: string | null; + idToken: string | null; + error: Error | null; +} +interface LoginOptions { + /** Additional scopes to request */ + scopes?: string[]; + /** State parameter (auto-generated if not provided) */ + state?: string; + /** Nonce for ID token validation */ + nonce?: string; + /** Redirect to signup instead of login */ + signup?: boolean; + /** Prompt parameter (none, login, consent, select_account) */ + prompt?: 'none' | 'login' | 'consent' | 'select_account'; + /** Login hint (email or username) */ + loginHint?: string; +} +interface LogoutOptions { + /** Post-logout redirect URI */ + returnTo?: string; + /** Include id_token_hint in logout request */ + idTokenHint?: string; +} +interface CallbackResult { + success: boolean; + user?: User; + accessToken?: string; + idToken?: string; + refreshToken?: string; + error?: string; + errorDescription?: string; +} +interface OIDCDiscovery { + issuer: string; + authorization_endpoint: string; + token_endpoint: string; + userinfo_endpoint: string; + jwks_uri: string; + end_session_endpoint?: string; + registration_endpoint?: string; + scopes_supported: string[]; + response_types_supported: string[]; + grant_types_supported: string[]; + token_endpoint_auth_methods_supported: string[]; + code_challenge_methods_supported?: string[]; +} + +/** + * Stuffle IAM Client + * + * OIDC/OAuth2 client for browser-based applications. + * Supports authorization code flow with PKCE. + */ + +declare class StuffleIAMClient { + private config; + private storage; + private discovery; + private refreshTimer; + private listeners; + constructor(config: StuffleIAMConfig); + /** + * Fetch OIDC discovery document + */ + getDiscovery(): Promise; + /** + * Start login flow - redirects to authorization endpoint + */ + login(options?: LoginOptions): Promise; + /** + * Alias for login({ signup: true }) + */ + signup(options?: Omit): Promise; + /** + * Handle callback from authorization server + */ + handleCallback(url?: string): Promise; + /** + * Exchange authorization code for tokens + */ + private exchangeCode; + /** + * Refresh access token using refresh token + */ + refreshToken(): Promise; + /** + * Logout - end session + */ + logout(options?: LogoutOptions): Promise; + /** + * Get current access token (refreshes if needed) + */ + getAccessToken(): Promise; + /** + * Get current user from stored ID token + */ + getUser(): User | null; + /** + * Fetch user info from userinfo endpoint + */ + fetchUserInfo(accessToken?: string): Promise; + /** + * Check if user is authenticated + */ + isAuthenticated(): boolean; + /** + * Get current auth state + */ + getAuthState(): AuthState; + /** + * Subscribe to auth state changes + */ + subscribe(listener: (state: AuthState) => void): () => void; + /** + * Store tokens in storage + */ + private storeTokens; + /** + * Clear all stored tokens + */ + private clearTokens; + /** + * Setup auto-refresh timer + */ + private setupAutoRefresh; + /** + * Notify all listeners of state change + */ + private notifyListeners; +} +/** + * Create a new Stuffle IAM client instance + */ +declare function createStuffleIAMClient(config: StuffleIAMConfig): StuffleIAMClient; + +export { type AuthState as A, type CallbackResult as C, type LoginOptions as L, type OIDCDiscovery as O, StuffleIAMClient as S, type TokenResponse as T, type User as U, type StuffleIAMConfig as a, type LogoutOptions as b, createStuffleIAMClient as c }; diff --git a/dist/index.d.mts b/dist/index.d.mts new file mode 100644 index 0000000..104f8c9 --- /dev/null +++ b/dist/index.d.mts @@ -0,0 +1,41 @@ +export { A as AuthState, C as CallbackResult, L as LoginOptions, b as LogoutOptions, O as OIDCDiscovery, S as StuffleIAMClient, a as StuffleIAMConfig, T as TokenResponse, U as User, c as createStuffleIAMClient } from './client-CTKWBZ26.mjs'; + +/** + * Utility functions for PKCE and crypto operations + */ +/** + * Generate a random string for state/nonce + */ +declare function generateRandomString(length?: number): string; +/** + * Generate PKCE code verifier (43-128 characters) + */ +declare function generateCodeVerifier(): string; +/** + * Generate PKCE code challenge from verifier + */ +declare function generateCodeChallenge(verifier: string): Promise; +/** + * Decode JWT payload (without verification) + */ +declare function decodeJwtPayload>(token: string): T | null; +/** + * Check if token is expired + */ +declare function isTokenExpired(token: string, thresholdSeconds?: number): boolean; + +/** + * Token storage abstraction + */ +interface TokenStorage { + get(key: string): string | null; + set(key: string, value: string): void; + remove(key: string): void; + clear(): void; +} +/** + * Get storage adapter based on type + */ +declare function getStorage(type: 'localStorage' | 'sessionStorage' | 'memory'): TokenStorage; + +export { type TokenStorage, decodeJwtPayload, generateCodeChallenge, generateCodeVerifier, generateRandomString, getStorage, isTokenExpired }; diff --git a/dist/index.d.ts b/dist/index.d.ts new file mode 100644 index 0000000..02c43b2 --- /dev/null +++ b/dist/index.d.ts @@ -0,0 +1,41 @@ +export { A as AuthState, C as CallbackResult, L as LoginOptions, b as LogoutOptions, O as OIDCDiscovery, S as StuffleIAMClient, a as StuffleIAMConfig, T as TokenResponse, U as User, c as createStuffleIAMClient } from './client-CTKWBZ26.js'; + +/** + * Utility functions for PKCE and crypto operations + */ +/** + * Generate a random string for state/nonce + */ +declare function generateRandomString(length?: number): string; +/** + * Generate PKCE code verifier (43-128 characters) + */ +declare function generateCodeVerifier(): string; +/** + * Generate PKCE code challenge from verifier + */ +declare function generateCodeChallenge(verifier: string): Promise; +/** + * Decode JWT payload (without verification) + */ +declare function decodeJwtPayload>(token: string): T | null; +/** + * Check if token is expired + */ +declare function isTokenExpired(token: string, thresholdSeconds?: number): boolean; + +/** + * Token storage abstraction + */ +interface TokenStorage { + get(key: string): string | null; + set(key: string, value: string): void; + remove(key: string): void; + clear(): void; +} +/** + * Get storage adapter based on type + */ +declare function getStorage(type: 'localStorage' | 'sessionStorage' | 'memory'): TokenStorage; + +export { type TokenStorage, decodeJwtPayload, generateCodeChallenge, generateCodeVerifier, generateRandomString, getStorage, isTokenExpired }; diff --git a/dist/index.js b/dist/index.js new file mode 100644 index 0000000..d54a2a2 --- /dev/null +++ b/dist/index.js @@ -0,0 +1,545 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/index.ts +var src_exports = {}; +__export(src_exports, { + StuffleIAMClient: () => StuffleIAMClient, + createStuffleIAMClient: () => createStuffleIAMClient, + decodeJwtPayload: () => decodeJwtPayload, + generateCodeChallenge: () => generateCodeChallenge, + generateCodeVerifier: () => generateCodeVerifier, + generateRandomString: () => generateRandomString, + getStorage: () => getStorage, + isTokenExpired: () => isTokenExpired +}); +module.exports = __toCommonJS(src_exports); + +// src/utils.ts +function generateRandomString(length = 32) { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} +function generateCodeVerifier() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array); +} +async function generateCodeChallenge(verifier) { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest("SHA-256", data); + return base64UrlEncode(new Uint8Array(digest)); +} +function base64UrlEncode(buffer) { + let binary = ""; + for (let i = 0; i < buffer.length; i++) { + binary += String.fromCharCode(buffer[i]); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +function decodeJwtPayload(token) { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = parts[1]; + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(decoded); + } catch { + return null; + } +} +function isTokenExpired(token, thresholdSeconds = 0) { + const payload = decodeJwtPayload(token); + if (!payload?.exp) return true; + const now = Math.floor(Date.now() / 1e3); + return payload.exp - thresholdSeconds <= now; +} +function parseUrlParams(url) { + const params = {}; + const hashIndex = url.indexOf("#"); + const queryIndex = url.indexOf("?"); + let paramString = ""; + if (hashIndex !== -1) { + paramString = url.substring(hashIndex + 1); + } else if (queryIndex !== -1) { + paramString = url.substring(queryIndex + 1); + } + if (!paramString) return params; + const searchParams = new URLSearchParams(paramString); + searchParams.forEach((value, key) => { + params[key] = value; + }); + return params; +} +function buildUrl(base, params) { + const url = new URL(base); + Object.entries(params).forEach(([key, value]) => { + if (value !== void 0 && value !== null) { + url.searchParams.append(key, value); + } + }); + return url.toString(); +} + +// src/storage.ts +var STORAGE_PREFIX = "stuffle_iam_"; +var LocalStorageAdapter = class { + get(key) { + try { + return localStorage.getItem(STORAGE_PREFIX + key); + } catch { + return null; + } + } + set(key, value) { + try { + localStorage.setItem(STORAGE_PREFIX + key, value); + } catch { + console.warn("LocalStorage not available"); + } + } + remove(key) { + try { + localStorage.removeItem(STORAGE_PREFIX + key); + } catch { + } + } + clear() { + try { + Object.keys(localStorage).filter((k) => k.startsWith(STORAGE_PREFIX)).forEach((k) => localStorage.removeItem(k)); + } catch { + } + } +}; +var SessionStorageAdapter = class { + get(key) { + try { + return sessionStorage.getItem(STORAGE_PREFIX + key); + } catch { + return null; + } + } + set(key, value) { + try { + sessionStorage.setItem(STORAGE_PREFIX + key, value); + } catch { + console.warn("SessionStorage not available"); + } + } + remove(key) { + try { + sessionStorage.removeItem(STORAGE_PREFIX + key); + } catch { + } + } + clear() { + try { + Object.keys(sessionStorage).filter((k) => k.startsWith(STORAGE_PREFIX)).forEach((k) => sessionStorage.removeItem(k)); + } catch { + } + } +}; +var MemoryStorageAdapter = class { + constructor() { + this.store = /* @__PURE__ */ new Map(); + } + get(key) { + return this.store.get(key) ?? null; + } + set(key, value) { + this.store.set(key, value); + } + remove(key) { + this.store.delete(key); + } + clear() { + this.store.clear(); + } +}; +function getStorage(type) { + switch (type) { + case "localStorage": + return new LocalStorageAdapter(); + case "sessionStorage": + return new SessionStorageAdapter(); + case "memory": + return new MemoryStorageAdapter(); + default: + return new SessionStorageAdapter(); + } +} + +// src/client.ts +var STORAGE_KEYS = { + ACCESS_TOKEN: "access_token", + REFRESH_TOKEN: "refresh_token", + ID_TOKEN: "id_token", + CODE_VERIFIER: "code_verifier", + STATE: "state", + NONCE: "nonce", + USER: "user", + EXPIRES_AT: "expires_at" +}; +var StuffleIAMClient = class { + constructor(config) { + this.discovery = null; + this.refreshTimer = null; + this.listeners = /* @__PURE__ */ new Set(); + this.config = { + issuer: config.issuer.replace(/\/$/, ""), + // Remove trailing slash + clientId: config.clientId, + redirectUri: config.redirectUri, + scopes: config.scopes ?? ["openid", "profile", "email"], + postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri, + usePkce: config.usePkce ?? true, + storage: config.storage ?? "sessionStorage", + autoRefresh: config.autoRefresh ?? true, + refreshThreshold: config.refreshThreshold ?? 60 + }; + this.storage = getStorage(this.config.storage); + } + /** + * Fetch OIDC discovery document + */ + async getDiscovery() { + if (this.discovery) return this.discovery; + const response = await fetch( + `${this.config.issuer}/.well-known/openid-configuration` + ); + if (!response.ok) { + throw new Error(`Failed to fetch OIDC discovery: ${response.status}`); + } + this.discovery = await response.json(); + return this.discovery; + } + /** + * Start login flow - redirects to authorization endpoint + */ + async login(options = {}) { + const discovery = await this.getDiscovery(); + const state = options.state ?? generateRandomString(); + const nonce = options.nonce ?? generateRandomString(); + const scopes = [...this.config.scopes, ...options.scopes ?? []]; + this.storage.set(STORAGE_KEYS.STATE, state); + this.storage.set(STORAGE_KEYS.NONCE, nonce); + const params = { + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + response_type: "code", + scope: scopes.join(" "), + state, + nonce, + prompt: options.prompt, + login_hint: options.loginHint + }; + if (this.config.usePkce) { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier); + params.code_challenge = codeChallenge; + params.code_challenge_method = "S256"; + } + const endpoint = options.signup ? discovery.authorization_endpoint.replace("/authorize", "/authorize/signup") : discovery.authorization_endpoint; + const authUrl = buildUrl(endpoint, params); + window.location.href = authUrl; + } + /** + * Alias for login({ signup: true }) + */ + async signup(options = {}) { + return this.login({ ...options, signup: true }); + } + /** + * Handle callback from authorization server + */ + async handleCallback(url) { + const callbackUrl = url ?? window.location.href; + const params = parseUrlParams(callbackUrl); + if (params.error) { + return { + success: false, + error: params.error, + errorDescription: params.error_description + }; + } + const storedState = this.storage.get(STORAGE_KEYS.STATE); + if (!storedState || storedState !== params.state) { + return { + success: false, + error: "invalid_state", + errorDescription: "State mismatch - possible CSRF attack" + }; + } + if (!params.code) { + return { + success: false, + error: "missing_code", + errorDescription: "No authorization code received" + }; + } + try { + const tokens = await this.exchangeCode(params.code); + if (tokens.id_token) { + const payload = decodeJwtPayload(tokens.id_token); + const storedNonce = this.storage.get(STORAGE_KEYS.NONCE); + if (payload?.nonce !== storedNonce) { + return { + success: false, + error: "invalid_nonce", + errorDescription: "Nonce mismatch - possible replay attack" + }; + } + } + this.storeTokens(tokens); + this.storage.remove(STORAGE_KEYS.STATE); + this.storage.remove(STORAGE_KEYS.NONCE); + this.storage.remove(STORAGE_KEYS.CODE_VERIFIER); + const user = await this.fetchUserInfo(tokens.access_token); + if (this.config.autoRefresh && tokens.refresh_token) { + this.setupAutoRefresh(); + } + this.notifyListeners(); + return { + success: true, + user: user || void 0, + accessToken: tokens.access_token, + idToken: tokens.id_token, + refreshToken: tokens.refresh_token + }; + } catch (error) { + return { + success: false, + error: "token_exchange_failed", + errorDescription: error instanceof Error ? error.message : "Unknown error" + }; + } + } + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code) { + const discovery = await this.getDiscovery(); + const body = { + grant_type: "authorization_code", + client_id: this.config.clientId, + code, + redirect_uri: this.config.redirectUri + }; + if (this.config.usePkce) { + const codeVerifier = this.storage.get(STORAGE_KEYS.CODE_VERIFIER); + if (codeVerifier) { + body.code_verifier = codeVerifier; + } + } + const response = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams(body) + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error_description || error.error || "Token exchange failed"); + } + return response.json(); + } + /** + * Refresh access token using refresh token + */ + async refreshToken() { + const refreshToken = this.storage.get(STORAGE_KEYS.REFRESH_TOKEN); + if (!refreshToken) return null; + const discovery = await this.getDiscovery(); + const response = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: this.config.clientId, + refresh_token: refreshToken + }) + }); + if (!response.ok) { + this.clearTokens(); + this.notifyListeners(); + return null; + } + const tokens = await response.json(); + this.storeTokens(tokens); + this.notifyListeners(); + return tokens; + } + /** + * Logout - end session + */ + async logout(options = {}) { + const discovery = await this.getDiscovery(); + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + this.clearTokens(); + this.notifyListeners(); + if (discovery.end_session_endpoint) { + const params = { + post_logout_redirect_uri: options.returnTo ?? this.config.postLogoutRedirectUri, + id_token_hint: options.idTokenHint ?? idToken ?? void 0, + client_id: this.config.clientId + }; + const logoutUrl = buildUrl(discovery.end_session_endpoint, params); + window.location.href = logoutUrl; + } + } + /** + * Get current access token (refreshes if needed) + */ + async getAccessToken() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + if (!accessToken) return null; + if (isTokenExpired(accessToken, this.config.refreshThreshold)) { + const tokens = await this.refreshToken(); + return tokens?.access_token ?? null; + } + return accessToken; + } + /** + * Get current user from stored ID token + */ + getUser() { + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + if (!idToken) return null; + return decodeJwtPayload(idToken); + } + /** + * Fetch user info from userinfo endpoint + */ + async fetchUserInfo(accessToken) { + const token = accessToken ?? await this.getAccessToken(); + if (!token) return null; + const discovery = await this.getDiscovery(); + const response = await fetch(discovery.userinfo_endpoint, { + headers: { + Authorization: `Bearer ${token}` + } + }); + if (!response.ok) return null; + const user = await response.json(); + this.storage.set(STORAGE_KEYS.USER, JSON.stringify(user)); + return user; + } + /** + * Check if user is authenticated + */ + isAuthenticated() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + if (!accessToken) return false; + return !isTokenExpired(accessToken); + } + /** + * Get current auth state + */ + getAuthState() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + const user = this.getUser(); + return { + isAuthenticated: this.isAuthenticated(), + isLoading: false, + user, + accessToken, + idToken, + error: null + }; + } + /** + * Subscribe to auth state changes + */ + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + /** + * Store tokens in storage + */ + storeTokens(tokens) { + this.storage.set(STORAGE_KEYS.ACCESS_TOKEN, tokens.access_token); + if (tokens.refresh_token) { + this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, tokens.refresh_token); + } + if (tokens.id_token) { + this.storage.set(STORAGE_KEYS.ID_TOKEN, tokens.id_token); + } + const expiresAt = Date.now() + tokens.expires_in * 1e3; + this.storage.set(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString()); + } + /** + * Clear all stored tokens + */ + clearTokens() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.storage.clear(); + } + /** + * Setup auto-refresh timer + */ + setupAutoRefresh() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + const expiresAt = this.storage.get(STORAGE_KEYS.EXPIRES_AT); + if (!expiresAt) return; + const expiresAtMs = parseInt(expiresAt, 10); + const refreshAt = expiresAtMs - this.config.refreshThreshold * 1e3; + const delay = refreshAt - Date.now(); + if (delay > 0) { + this.refreshTimer = setTimeout(async () => { + await this.refreshToken(); + this.setupAutoRefresh(); + }, delay); + } + } + /** + * Notify all listeners of state change + */ + notifyListeners() { + const state = this.getAuthState(); + this.listeners.forEach((listener) => listener(state)); + } +}; +function createStuffleIAMClient(config) { + return new StuffleIAMClient(config); +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + StuffleIAMClient, + createStuffleIAMClient, + decodeJwtPayload, + generateCodeChallenge, + generateCodeVerifier, + generateRandomString, + getStorage, + isTokenExpired +}); +//# sourceMappingURL=index.js.map \ No newline at end of file diff --git a/dist/index.js.map b/dist/index.js.map new file mode 100644 index 0000000..1bb69f6 --- /dev/null +++ b/dist/index.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/index.ts","../src/utils.ts","../src/storage.ts","../src/client.ts"],"sourcesContent":["/**\n * Stuffle IAM SDK\n * \n * JavaScript/TypeScript SDK for integrating with Stuffle IAM.\n * Supports OIDC/OAuth2 authorization code flow with PKCE.\n * \n * @example\n * ```ts\n * import { createStuffleIAMClient } from '@stuffle/iam-sdk';\n * \n * const iam = createStuffleIAMClient({\n * issuer: 'http://localhost:5000',\n * clientId: 'my-app',\n * redirectUri: 'http://localhost:3000/callback',\n * });\n * \n * // Start login\n * await iam.login();\n * \n * // Handle callback\n * const result = await iam.handleCallback();\n * \n * // Get user\n * const user = iam.getUser();\n * ```\n */\n\nexport { StuffleIAMClient, createStuffleIAMClient } from './client';\nexport type {\n StuffleIAMConfig,\n TokenResponse,\n User,\n AuthState,\n LoginOptions,\n LogoutOptions,\n CallbackResult,\n OIDCDiscovery,\n} from './types';\nexport {\n generateRandomString,\n generateCodeVerifier,\n generateCodeChallenge,\n decodeJwtPayload,\n isTokenExpired,\n} from './utils';\nexport { getStorage, type TokenStorage } from './storage';\n","/**\n * Utility functions for PKCE and crypto operations\n */\n\n/**\n * Generate a random string for state/nonce\n */\nexport function generateRandomString(length: number = 32): string {\n const array = new Uint8Array(length);\n crypto.getRandomValues(array);\n return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * Generate PKCE code verifier (43-128 characters)\n */\nexport function generateCodeVerifier(): string {\n const array = new Uint8Array(32);\n crypto.getRandomValues(array);\n return base64UrlEncode(array);\n}\n\n/**\n * Generate PKCE code challenge from verifier\n */\nexport async function generateCodeChallenge(verifier: string): Promise {\n const encoder = new TextEncoder();\n const data = encoder.encode(verifier);\n const digest = await crypto.subtle.digest('SHA-256', data);\n return base64UrlEncode(new Uint8Array(digest));\n}\n\n/**\n * Base64 URL encode (no padding, URL-safe characters)\n */\nexport function base64UrlEncode(buffer: Uint8Array): string {\n let binary = '';\n for (let i = 0; i < buffer.length; i++) {\n binary += String.fromCharCode(buffer[i]);\n }\n return btoa(binary)\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n}\n\n/**\n * Decode JWT payload (without verification)\n */\nexport function decodeJwtPayload>(token: string): T | null {\n try {\n const parts = token.split('.');\n if (parts.length !== 3) return null;\n \n const payload = parts[1];\n const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));\n return JSON.parse(decoded) as T;\n } catch {\n return null;\n }\n}\n\n/**\n * Check if token is expired\n */\nexport function isTokenExpired(token: string, thresholdSeconds: number = 0): boolean {\n const payload = decodeJwtPayload<{ exp?: number }>(token);\n if (!payload?.exp) return true;\n \n const now = Math.floor(Date.now() / 1000);\n return payload.exp - thresholdSeconds <= now;\n}\n\n/**\n * Parse URL hash or query parameters\n */\nexport function parseUrlParams(url: string): Record {\n const params: Record = {};\n \n // Check hash first (implicit flow), then query (code flow)\n const hashIndex = url.indexOf('#');\n const queryIndex = url.indexOf('?');\n \n let paramString = '';\n if (hashIndex !== -1) {\n paramString = url.substring(hashIndex + 1);\n } else if (queryIndex !== -1) {\n paramString = url.substring(queryIndex + 1);\n }\n \n if (!paramString) return params;\n \n const searchParams = new URLSearchParams(paramString);\n searchParams.forEach((value, key) => {\n params[key] = value;\n });\n \n return params;\n}\n\n/**\n * Build URL with query parameters\n */\nexport function buildUrl(base: string, params: Record): string {\n const url = new URL(base);\n Object.entries(params).forEach(([key, value]) => {\n if (value !== undefined && value !== null) {\n url.searchParams.append(key, value);\n }\n });\n return url.toString();\n}\n","/**\n * Token storage abstraction\n */\n\nexport interface TokenStorage {\n get(key: string): string | null;\n set(key: string, value: string): void;\n remove(key: string): void;\n clear(): void;\n}\n\nconst STORAGE_PREFIX = 'stuffle_iam_';\n\n/**\n * LocalStorage implementation\n */\nexport class LocalStorageAdapter implements TokenStorage {\n get(key: string): string | null {\n try {\n return localStorage.getItem(STORAGE_PREFIX + key);\n } catch {\n return null;\n }\n }\n\n set(key: string, value: string): void {\n try {\n localStorage.setItem(STORAGE_PREFIX + key, value);\n } catch {\n console.warn('LocalStorage not available');\n }\n }\n\n remove(key: string): void {\n try {\n localStorage.removeItem(STORAGE_PREFIX + key);\n } catch {\n // Ignore\n }\n }\n\n clear(): void {\n try {\n Object.keys(localStorage)\n .filter(k => k.startsWith(STORAGE_PREFIX))\n .forEach(k => localStorage.removeItem(k));\n } catch {\n // Ignore\n }\n }\n}\n\n/**\n * SessionStorage implementation\n */\nexport class SessionStorageAdapter implements TokenStorage {\n get(key: string): string | null {\n try {\n return sessionStorage.getItem(STORAGE_PREFIX + key);\n } catch {\n return null;\n }\n }\n\n set(key: string, value: string): void {\n try {\n sessionStorage.setItem(STORAGE_PREFIX + key, value);\n } catch {\n console.warn('SessionStorage not available');\n }\n }\n\n remove(key: string): void {\n try {\n sessionStorage.removeItem(STORAGE_PREFIX + key);\n } catch {\n // Ignore\n }\n }\n\n clear(): void {\n try {\n Object.keys(sessionStorage)\n .filter(k => k.startsWith(STORAGE_PREFIX))\n .forEach(k => sessionStorage.removeItem(k));\n } catch {\n // Ignore\n }\n }\n}\n\n/**\n * In-memory storage (for SSR or when storage is unavailable)\n */\nexport class MemoryStorageAdapter implements TokenStorage {\n private store = new Map();\n\n get(key: string): string | null {\n return this.store.get(key) ?? null;\n }\n\n set(key: string, value: string): void {\n this.store.set(key, value);\n }\n\n remove(key: string): void {\n this.store.delete(key);\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n\n/**\n * Get storage adapter based on type\n */\nexport function getStorage(type: 'localStorage' | 'sessionStorage' | 'memory'): TokenStorage {\n switch (type) {\n case 'localStorage':\n return new LocalStorageAdapter();\n case 'sessionStorage':\n return new SessionStorageAdapter();\n case 'memory':\n return new MemoryStorageAdapter();\n default:\n return new SessionStorageAdapter();\n }\n}\n","/**\n * Stuffle IAM Client\n * \n * OIDC/OAuth2 client for browser-based applications.\n * Supports authorization code flow with PKCE.\n */\n\nimport type {\n StuffleIAMConfig,\n TokenResponse,\n User,\n AuthState,\n LoginOptions,\n LogoutOptions,\n CallbackResult,\n OIDCDiscovery,\n} from './types';\nimport {\n generateRandomString,\n generateCodeVerifier,\n generateCodeChallenge,\n decodeJwtPayload,\n isTokenExpired,\n parseUrlParams,\n buildUrl,\n} from './utils';\nimport { getStorage, type TokenStorage } from './storage';\n\nconst STORAGE_KEYS = {\n ACCESS_TOKEN: 'access_token',\n REFRESH_TOKEN: 'refresh_token',\n ID_TOKEN: 'id_token',\n CODE_VERIFIER: 'code_verifier',\n STATE: 'state',\n NONCE: 'nonce',\n USER: 'user',\n EXPIRES_AT: 'expires_at',\n};\n\nexport class StuffleIAMClient {\n private config: Required;\n private storage: TokenStorage;\n private discovery: OIDCDiscovery | null = null;\n private refreshTimer: ReturnType | null = null;\n private listeners: Set<(state: AuthState) => void> = new Set();\n\n constructor(config: StuffleIAMConfig) {\n this.config = {\n issuer: config.issuer.replace(/\\/$/, ''), // Remove trailing slash\n clientId: config.clientId,\n redirectUri: config.redirectUri,\n scopes: config.scopes ?? ['openid', 'profile', 'email'],\n postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri,\n usePkce: config.usePkce ?? true,\n storage: config.storage ?? 'sessionStorage',\n autoRefresh: config.autoRefresh ?? true,\n refreshThreshold: config.refreshThreshold ?? 60,\n };\n\n this.storage = getStorage(this.config.storage);\n }\n\n /**\n * Fetch OIDC discovery document\n */\n async getDiscovery(): Promise {\n if (this.discovery) return this.discovery;\n\n const response = await fetch(\n `${this.config.issuer}/.well-known/openid-configuration`\n );\n\n if (!response.ok) {\n throw new Error(`Failed to fetch OIDC discovery: ${response.status}`);\n }\n\n this.discovery = await response.json();\n return this.discovery!;\n }\n\n /**\n * Start login flow - redirects to authorization endpoint\n */\n async login(options: LoginOptions = {}): Promise {\n const discovery = await this.getDiscovery();\n\n const state = options.state ?? generateRandomString();\n const nonce = options.nonce ?? generateRandomString();\n const scopes = [...this.config.scopes, ...(options.scopes ?? [])];\n\n // Store state and nonce for callback validation\n this.storage.set(STORAGE_KEYS.STATE, state);\n this.storage.set(STORAGE_KEYS.NONCE, nonce);\n\n const params: Record = {\n client_id: this.config.clientId,\n redirect_uri: this.config.redirectUri,\n response_type: 'code',\n scope: scopes.join(' '),\n state,\n nonce,\n prompt: options.prompt,\n login_hint: options.loginHint,\n };\n\n // Add PKCE if enabled\n if (this.config.usePkce) {\n const codeVerifier = generateCodeVerifier();\n const codeChallenge = await generateCodeChallenge(codeVerifier);\n \n this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier);\n params.code_challenge = codeChallenge;\n params.code_challenge_method = 'S256';\n }\n\n // Use signup endpoint if requested\n const endpoint = options.signup\n ? discovery.authorization_endpoint.replace('/authorize', '/authorize/signup')\n : discovery.authorization_endpoint;\n\n const authUrl = buildUrl(endpoint, params);\n window.location.href = authUrl;\n }\n\n /**\n * Alias for login({ signup: true })\n */\n async signup(options: Omit = {}): Promise {\n return this.login({ ...options, signup: true });\n }\n\n /**\n * Handle callback from authorization server\n */\n async handleCallback(url?: string): Promise {\n const callbackUrl = url ?? window.location.href;\n const params = parseUrlParams(callbackUrl);\n\n // Check for errors\n if (params.error) {\n return {\n success: false,\n error: params.error,\n errorDescription: params.error_description,\n };\n }\n\n // Validate state\n const storedState = this.storage.get(STORAGE_KEYS.STATE);\n if (!storedState || storedState !== params.state) {\n return {\n success: false,\n error: 'invalid_state',\n errorDescription: 'State mismatch - possible CSRF attack',\n };\n }\n\n // Exchange code for tokens\n if (!params.code) {\n return {\n success: false,\n error: 'missing_code',\n errorDescription: 'No authorization code received',\n };\n }\n\n try {\n const tokens = await this.exchangeCode(params.code);\n \n // Validate nonce in ID token\n if (tokens.id_token) {\n const payload = decodeJwtPayload<{ nonce?: string }>(tokens.id_token);\n const storedNonce = this.storage.get(STORAGE_KEYS.NONCE);\n if (payload?.nonce !== storedNonce) {\n return {\n success: false,\n error: 'invalid_nonce',\n errorDescription: 'Nonce mismatch - possible replay attack',\n };\n }\n }\n\n // Store tokens\n this.storeTokens(tokens);\n\n // Clear temporary storage\n this.storage.remove(STORAGE_KEYS.STATE);\n this.storage.remove(STORAGE_KEYS.NONCE);\n this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);\n\n // Get user info\n const user = await this.fetchUserInfo(tokens.access_token);\n\n // Setup auto-refresh\n if (this.config.autoRefresh && tokens.refresh_token) {\n this.setupAutoRefresh();\n }\n\n // Notify listeners\n this.notifyListeners();\n\n return {\n success: true,\n user: user || undefined,\n accessToken: tokens.access_token,\n idToken: tokens.id_token,\n refreshToken: tokens.refresh_token,\n };\n } catch (error) {\n return {\n success: false,\n error: 'token_exchange_failed',\n errorDescription: error instanceof Error ? error.message : 'Unknown error',\n };\n }\n }\n\n /**\n * Exchange authorization code for tokens\n */\n private async exchangeCode(code: string): Promise {\n const discovery = await this.getDiscovery();\n\n const body: Record = {\n grant_type: 'authorization_code',\n client_id: this.config.clientId,\n code,\n redirect_uri: this.config.redirectUri,\n };\n\n // Add PKCE code verifier\n if (this.config.usePkce) {\n const codeVerifier = this.storage.get(STORAGE_KEYS.CODE_VERIFIER);\n if (codeVerifier) {\n body.code_verifier = codeVerifier;\n }\n }\n\n const response = await fetch(discovery.token_endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams(body),\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(error.error_description || error.error || 'Token exchange failed');\n }\n\n return response.json();\n }\n\n /**\n * Refresh access token using refresh token\n */\n async refreshToken(): Promise {\n const refreshToken = this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);\n if (!refreshToken) return null;\n\n const discovery = await this.getDiscovery();\n\n const response = await fetch(discovery.token_endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: this.config.clientId,\n refresh_token: refreshToken,\n }),\n });\n\n if (!response.ok) {\n // Refresh failed - clear tokens\n this.clearTokens();\n this.notifyListeners();\n return null;\n }\n\n const tokens: TokenResponse = await response.json();\n this.storeTokens(tokens);\n this.notifyListeners();\n\n return tokens;\n }\n\n /**\n * Logout - end session\n */\n async logout(options: LogoutOptions = {}): Promise {\n const discovery = await this.getDiscovery();\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n\n // Clear local tokens first\n this.clearTokens();\n this.notifyListeners();\n\n // Redirect to end session endpoint if available\n if (discovery.end_session_endpoint) {\n const params: Record = {\n post_logout_redirect_uri: options.returnTo ?? this.config.postLogoutRedirectUri,\n id_token_hint: options.idTokenHint ?? idToken ?? undefined,\n client_id: this.config.clientId,\n };\n\n const logoutUrl = buildUrl(discovery.end_session_endpoint, params);\n window.location.href = logoutUrl;\n }\n }\n\n /**\n * Get current access token (refreshes if needed)\n */\n async getAccessToken(): Promise {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n \n if (!accessToken) return null;\n\n // Check if token needs refresh\n if (isTokenExpired(accessToken, this.config.refreshThreshold)) {\n const tokens = await this.refreshToken();\n return tokens?.access_token ?? null;\n }\n\n return accessToken;\n }\n\n /**\n * Get current user from stored ID token\n */\n getUser(): User | null {\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n if (!idToken) return null;\n\n return decodeJwtPayload(idToken);\n }\n\n /**\n * Fetch user info from userinfo endpoint\n */\n async fetchUserInfo(accessToken?: string): Promise {\n const token = accessToken ?? await this.getAccessToken();\n if (!token) return null;\n\n const discovery = await this.getDiscovery();\n\n const response = await fetch(discovery.userinfo_endpoint, {\n headers: {\n Authorization: `Bearer ${token}`,\n },\n });\n\n if (!response.ok) return null;\n\n const user: User = await response.json();\n this.storage.set(STORAGE_KEYS.USER, JSON.stringify(user));\n return user;\n }\n\n /**\n * Check if user is authenticated\n */\n isAuthenticated(): boolean {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n if (!accessToken) return false;\n\n // Consider authenticated if token exists and not expired\n return !isTokenExpired(accessToken);\n }\n\n /**\n * Get current auth state\n */\n getAuthState(): AuthState {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n const user = this.getUser();\n\n return {\n isAuthenticated: this.isAuthenticated(),\n isLoading: false,\n user,\n accessToken,\n idToken,\n error: null,\n };\n }\n\n /**\n * Subscribe to auth state changes\n */\n subscribe(listener: (state: AuthState) => void): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n /**\n * Store tokens in storage\n */\n private storeTokens(tokens: TokenResponse): void {\n this.storage.set(STORAGE_KEYS.ACCESS_TOKEN, tokens.access_token);\n \n if (tokens.refresh_token) {\n this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, tokens.refresh_token);\n }\n \n if (tokens.id_token) {\n this.storage.set(STORAGE_KEYS.ID_TOKEN, tokens.id_token);\n }\n\n // Store expiry time\n const expiresAt = Date.now() + (tokens.expires_in * 1000);\n this.storage.set(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString());\n }\n\n /**\n * Clear all stored tokens\n */\n private clearTokens(): void {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer);\n this.refreshTimer = null;\n }\n\n this.storage.clear();\n }\n\n /**\n * Setup auto-refresh timer\n */\n private setupAutoRefresh(): void {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer);\n }\n\n const expiresAt = this.storage.get(STORAGE_KEYS.EXPIRES_AT);\n if (!expiresAt) return;\n\n const expiresAtMs = parseInt(expiresAt, 10);\n const refreshAt = expiresAtMs - (this.config.refreshThreshold * 1000);\n const delay = refreshAt - Date.now();\n\n if (delay > 0) {\n this.refreshTimer = setTimeout(async () => {\n await this.refreshToken();\n this.setupAutoRefresh();\n }, delay);\n }\n }\n\n /**\n * Notify all listeners of state change\n */\n private notifyListeners(): void {\n const state = this.getAuthState();\n this.listeners.forEach(listener => listener(state));\n }\n}\n\n/**\n * Create a new Stuffle IAM client instance\n */\nexport function createStuffleIAMClient(config: StuffleIAMConfig): StuffleIAMClient {\n return new StuffleIAMClient(config);\n}\n"],"mappings":"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","names":[]} \ No newline at end of file diff --git a/dist/index.mjs b/dist/index.mjs new file mode 100644 index 0000000..7454a9b --- /dev/null +++ b/dist/index.mjs @@ -0,0 +1,511 @@ +// src/utils.ts +function generateRandomString(length = 32) { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} +function generateCodeVerifier() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array); +} +async function generateCodeChallenge(verifier) { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest("SHA-256", data); + return base64UrlEncode(new Uint8Array(digest)); +} +function base64UrlEncode(buffer) { + let binary = ""; + for (let i = 0; i < buffer.length; i++) { + binary += String.fromCharCode(buffer[i]); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +function decodeJwtPayload(token) { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = parts[1]; + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(decoded); + } catch { + return null; + } +} +function isTokenExpired(token, thresholdSeconds = 0) { + const payload = decodeJwtPayload(token); + if (!payload?.exp) return true; + const now = Math.floor(Date.now() / 1e3); + return payload.exp - thresholdSeconds <= now; +} +function parseUrlParams(url) { + const params = {}; + const hashIndex = url.indexOf("#"); + const queryIndex = url.indexOf("?"); + let paramString = ""; + if (hashIndex !== -1) { + paramString = url.substring(hashIndex + 1); + } else if (queryIndex !== -1) { + paramString = url.substring(queryIndex + 1); + } + if (!paramString) return params; + const searchParams = new URLSearchParams(paramString); + searchParams.forEach((value, key) => { + params[key] = value; + }); + return params; +} +function buildUrl(base, params) { + const url = new URL(base); + Object.entries(params).forEach(([key, value]) => { + if (value !== void 0 && value !== null) { + url.searchParams.append(key, value); + } + }); + return url.toString(); +} + +// src/storage.ts +var STORAGE_PREFIX = "stuffle_iam_"; +var LocalStorageAdapter = class { + get(key) { + try { + return localStorage.getItem(STORAGE_PREFIX + key); + } catch { + return null; + } + } + set(key, value) { + try { + localStorage.setItem(STORAGE_PREFIX + key, value); + } catch { + console.warn("LocalStorage not available"); + } + } + remove(key) { + try { + localStorage.removeItem(STORAGE_PREFIX + key); + } catch { + } + } + clear() { + try { + Object.keys(localStorage).filter((k) => k.startsWith(STORAGE_PREFIX)).forEach((k) => localStorage.removeItem(k)); + } catch { + } + } +}; +var SessionStorageAdapter = class { + get(key) { + try { + return sessionStorage.getItem(STORAGE_PREFIX + key); + } catch { + return null; + } + } + set(key, value) { + try { + sessionStorage.setItem(STORAGE_PREFIX + key, value); + } catch { + console.warn("SessionStorage not available"); + } + } + remove(key) { + try { + sessionStorage.removeItem(STORAGE_PREFIX + key); + } catch { + } + } + clear() { + try { + Object.keys(sessionStorage).filter((k) => k.startsWith(STORAGE_PREFIX)).forEach((k) => sessionStorage.removeItem(k)); + } catch { + } + } +}; +var MemoryStorageAdapter = class { + constructor() { + this.store = /* @__PURE__ */ new Map(); + } + get(key) { + return this.store.get(key) ?? null; + } + set(key, value) { + this.store.set(key, value); + } + remove(key) { + this.store.delete(key); + } + clear() { + this.store.clear(); + } +}; +function getStorage(type) { + switch (type) { + case "localStorage": + return new LocalStorageAdapter(); + case "sessionStorage": + return new SessionStorageAdapter(); + case "memory": + return new MemoryStorageAdapter(); + default: + return new SessionStorageAdapter(); + } +} + +// src/client.ts +var STORAGE_KEYS = { + ACCESS_TOKEN: "access_token", + REFRESH_TOKEN: "refresh_token", + ID_TOKEN: "id_token", + CODE_VERIFIER: "code_verifier", + STATE: "state", + NONCE: "nonce", + USER: "user", + EXPIRES_AT: "expires_at" +}; +var StuffleIAMClient = class { + constructor(config) { + this.discovery = null; + this.refreshTimer = null; + this.listeners = /* @__PURE__ */ new Set(); + this.config = { + issuer: config.issuer.replace(/\/$/, ""), + // Remove trailing slash + clientId: config.clientId, + redirectUri: config.redirectUri, + scopes: config.scopes ?? ["openid", "profile", "email"], + postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri, + usePkce: config.usePkce ?? true, + storage: config.storage ?? "sessionStorage", + autoRefresh: config.autoRefresh ?? true, + refreshThreshold: config.refreshThreshold ?? 60 + }; + this.storage = getStorage(this.config.storage); + } + /** + * Fetch OIDC discovery document + */ + async getDiscovery() { + if (this.discovery) return this.discovery; + const response = await fetch( + `${this.config.issuer}/.well-known/openid-configuration` + ); + if (!response.ok) { + throw new Error(`Failed to fetch OIDC discovery: ${response.status}`); + } + this.discovery = await response.json(); + return this.discovery; + } + /** + * Start login flow - redirects to authorization endpoint + */ + async login(options = {}) { + const discovery = await this.getDiscovery(); + const state = options.state ?? generateRandomString(); + const nonce = options.nonce ?? generateRandomString(); + const scopes = [...this.config.scopes, ...options.scopes ?? []]; + this.storage.set(STORAGE_KEYS.STATE, state); + this.storage.set(STORAGE_KEYS.NONCE, nonce); + const params = { + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + response_type: "code", + scope: scopes.join(" "), + state, + nonce, + prompt: options.prompt, + login_hint: options.loginHint + }; + if (this.config.usePkce) { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier); + params.code_challenge = codeChallenge; + params.code_challenge_method = "S256"; + } + const endpoint = options.signup ? discovery.authorization_endpoint.replace("/authorize", "/authorize/signup") : discovery.authorization_endpoint; + const authUrl = buildUrl(endpoint, params); + window.location.href = authUrl; + } + /** + * Alias for login({ signup: true }) + */ + async signup(options = {}) { + return this.login({ ...options, signup: true }); + } + /** + * Handle callback from authorization server + */ + async handleCallback(url) { + const callbackUrl = url ?? window.location.href; + const params = parseUrlParams(callbackUrl); + if (params.error) { + return { + success: false, + error: params.error, + errorDescription: params.error_description + }; + } + const storedState = this.storage.get(STORAGE_KEYS.STATE); + if (!storedState || storedState !== params.state) { + return { + success: false, + error: "invalid_state", + errorDescription: "State mismatch - possible CSRF attack" + }; + } + if (!params.code) { + return { + success: false, + error: "missing_code", + errorDescription: "No authorization code received" + }; + } + try { + const tokens = await this.exchangeCode(params.code); + if (tokens.id_token) { + const payload = decodeJwtPayload(tokens.id_token); + const storedNonce = this.storage.get(STORAGE_KEYS.NONCE); + if (payload?.nonce !== storedNonce) { + return { + success: false, + error: "invalid_nonce", + errorDescription: "Nonce mismatch - possible replay attack" + }; + } + } + this.storeTokens(tokens); + this.storage.remove(STORAGE_KEYS.STATE); + this.storage.remove(STORAGE_KEYS.NONCE); + this.storage.remove(STORAGE_KEYS.CODE_VERIFIER); + const user = await this.fetchUserInfo(tokens.access_token); + if (this.config.autoRefresh && tokens.refresh_token) { + this.setupAutoRefresh(); + } + this.notifyListeners(); + return { + success: true, + user: user || void 0, + accessToken: tokens.access_token, + idToken: tokens.id_token, + refreshToken: tokens.refresh_token + }; + } catch (error) { + return { + success: false, + error: "token_exchange_failed", + errorDescription: error instanceof Error ? error.message : "Unknown error" + }; + } + } + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code) { + const discovery = await this.getDiscovery(); + const body = { + grant_type: "authorization_code", + client_id: this.config.clientId, + code, + redirect_uri: this.config.redirectUri + }; + if (this.config.usePkce) { + const codeVerifier = this.storage.get(STORAGE_KEYS.CODE_VERIFIER); + if (codeVerifier) { + body.code_verifier = codeVerifier; + } + } + const response = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams(body) + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error_description || error.error || "Token exchange failed"); + } + return response.json(); + } + /** + * Refresh access token using refresh token + */ + async refreshToken() { + const refreshToken = this.storage.get(STORAGE_KEYS.REFRESH_TOKEN); + if (!refreshToken) return null; + const discovery = await this.getDiscovery(); + const response = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: this.config.clientId, + refresh_token: refreshToken + }) + }); + if (!response.ok) { + this.clearTokens(); + this.notifyListeners(); + return null; + } + const tokens = await response.json(); + this.storeTokens(tokens); + this.notifyListeners(); + return tokens; + } + /** + * Logout - end session + */ + async logout(options = {}) { + const discovery = await this.getDiscovery(); + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + this.clearTokens(); + this.notifyListeners(); + if (discovery.end_session_endpoint) { + const params = { + post_logout_redirect_uri: options.returnTo ?? this.config.postLogoutRedirectUri, + id_token_hint: options.idTokenHint ?? idToken ?? void 0, + client_id: this.config.clientId + }; + const logoutUrl = buildUrl(discovery.end_session_endpoint, params); + window.location.href = logoutUrl; + } + } + /** + * Get current access token (refreshes if needed) + */ + async getAccessToken() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + if (!accessToken) return null; + if (isTokenExpired(accessToken, this.config.refreshThreshold)) { + const tokens = await this.refreshToken(); + return tokens?.access_token ?? null; + } + return accessToken; + } + /** + * Get current user from stored ID token + */ + getUser() { + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + if (!idToken) return null; + return decodeJwtPayload(idToken); + } + /** + * Fetch user info from userinfo endpoint + */ + async fetchUserInfo(accessToken) { + const token = accessToken ?? await this.getAccessToken(); + if (!token) return null; + const discovery = await this.getDiscovery(); + const response = await fetch(discovery.userinfo_endpoint, { + headers: { + Authorization: `Bearer ${token}` + } + }); + if (!response.ok) return null; + const user = await response.json(); + this.storage.set(STORAGE_KEYS.USER, JSON.stringify(user)); + return user; + } + /** + * Check if user is authenticated + */ + isAuthenticated() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + if (!accessToken) return false; + return !isTokenExpired(accessToken); + } + /** + * Get current auth state + */ + getAuthState() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + const user = this.getUser(); + return { + isAuthenticated: this.isAuthenticated(), + isLoading: false, + user, + accessToken, + idToken, + error: null + }; + } + /** + * Subscribe to auth state changes + */ + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + /** + * Store tokens in storage + */ + storeTokens(tokens) { + this.storage.set(STORAGE_KEYS.ACCESS_TOKEN, tokens.access_token); + if (tokens.refresh_token) { + this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, tokens.refresh_token); + } + if (tokens.id_token) { + this.storage.set(STORAGE_KEYS.ID_TOKEN, tokens.id_token); + } + const expiresAt = Date.now() + tokens.expires_in * 1e3; + this.storage.set(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString()); + } + /** + * Clear all stored tokens + */ + clearTokens() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.storage.clear(); + } + /** + * Setup auto-refresh timer + */ + setupAutoRefresh() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + const expiresAt = this.storage.get(STORAGE_KEYS.EXPIRES_AT); + if (!expiresAt) return; + const expiresAtMs = parseInt(expiresAt, 10); + const refreshAt = expiresAtMs - this.config.refreshThreshold * 1e3; + const delay = refreshAt - Date.now(); + if (delay > 0) { + this.refreshTimer = setTimeout(async () => { + await this.refreshToken(); + this.setupAutoRefresh(); + }, delay); + } + } + /** + * Notify all listeners of state change + */ + notifyListeners() { + const state = this.getAuthState(); + this.listeners.forEach((listener) => listener(state)); + } +}; +function createStuffleIAMClient(config) { + return new StuffleIAMClient(config); +} +export { + StuffleIAMClient, + createStuffleIAMClient, + decodeJwtPayload, + generateCodeChallenge, + generateCodeVerifier, + generateRandomString, + getStorage, + isTokenExpired +}; +//# sourceMappingURL=index.mjs.map \ No newline at end of file diff --git a/dist/index.mjs.map b/dist/index.mjs.map new file mode 100644 index 0000000..31945f2 --- /dev/null +++ b/dist/index.mjs.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/utils.ts","../src/storage.ts","../src/client.ts"],"sourcesContent":["/**\n * Utility functions for PKCE and crypto operations\n */\n\n/**\n * Generate a random string for state/nonce\n */\nexport function generateRandomString(length: number = 32): string {\n const array = new Uint8Array(length);\n crypto.getRandomValues(array);\n return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * Generate PKCE code verifier (43-128 characters)\n */\nexport function generateCodeVerifier(): string {\n const array = new Uint8Array(32);\n crypto.getRandomValues(array);\n return base64UrlEncode(array);\n}\n\n/**\n * Generate PKCE code challenge from verifier\n */\nexport async function generateCodeChallenge(verifier: string): Promise {\n const encoder = new TextEncoder();\n const data = encoder.encode(verifier);\n const digest = await crypto.subtle.digest('SHA-256', data);\n return base64UrlEncode(new Uint8Array(digest));\n}\n\n/**\n * Base64 URL encode (no padding, URL-safe characters)\n */\nexport function base64UrlEncode(buffer: Uint8Array): string {\n let binary = '';\n for (let i = 0; i < buffer.length; i++) {\n binary += String.fromCharCode(buffer[i]);\n }\n return btoa(binary)\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n}\n\n/**\n * Decode JWT payload (without verification)\n */\nexport function decodeJwtPayload>(token: string): T | null {\n try {\n const parts = token.split('.');\n if (parts.length !== 3) return null;\n \n const payload = parts[1];\n const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));\n return JSON.parse(decoded) as T;\n } catch {\n return null;\n }\n}\n\n/**\n * Check if token is expired\n */\nexport function isTokenExpired(token: string, thresholdSeconds: number = 0): boolean {\n const payload = decodeJwtPayload<{ exp?: number }>(token);\n if (!payload?.exp) return true;\n \n const now = Math.floor(Date.now() / 1000);\n return payload.exp - thresholdSeconds <= now;\n}\n\n/**\n * Parse URL hash or query parameters\n */\nexport function parseUrlParams(url: string): Record {\n const params: Record = {};\n \n // Check hash first (implicit flow), then query (code flow)\n const hashIndex = url.indexOf('#');\n const queryIndex = url.indexOf('?');\n \n let paramString = '';\n if (hashIndex !== -1) {\n paramString = url.substring(hashIndex + 1);\n } else if (queryIndex !== -1) {\n paramString = url.substring(queryIndex + 1);\n }\n \n if (!paramString) return params;\n \n const searchParams = new URLSearchParams(paramString);\n searchParams.forEach((value, key) => {\n params[key] = value;\n });\n \n return params;\n}\n\n/**\n * Build URL with query parameters\n */\nexport function buildUrl(base: string, params: Record): string {\n const url = new URL(base);\n Object.entries(params).forEach(([key, value]) => {\n if (value !== undefined && value !== null) {\n url.searchParams.append(key, value);\n }\n });\n return url.toString();\n}\n","/**\n * Token storage abstraction\n */\n\nexport interface TokenStorage {\n get(key: string): string | null;\n set(key: string, value: string): void;\n remove(key: string): void;\n clear(): void;\n}\n\nconst STORAGE_PREFIX = 'stuffle_iam_';\n\n/**\n * LocalStorage implementation\n */\nexport class LocalStorageAdapter implements TokenStorage {\n get(key: string): string | null {\n try {\n return localStorage.getItem(STORAGE_PREFIX + key);\n } catch {\n return null;\n }\n }\n\n set(key: string, value: string): void {\n try {\n localStorage.setItem(STORAGE_PREFIX + key, value);\n } catch {\n console.warn('LocalStorage not available');\n }\n }\n\n remove(key: string): void {\n try {\n localStorage.removeItem(STORAGE_PREFIX + key);\n } catch {\n // Ignore\n }\n }\n\n clear(): void {\n try {\n Object.keys(localStorage)\n .filter(k => k.startsWith(STORAGE_PREFIX))\n .forEach(k => localStorage.removeItem(k));\n } catch {\n // Ignore\n }\n }\n}\n\n/**\n * SessionStorage implementation\n */\nexport class SessionStorageAdapter implements TokenStorage {\n get(key: string): string | null {\n try {\n return sessionStorage.getItem(STORAGE_PREFIX + key);\n } catch {\n return null;\n }\n }\n\n set(key: string, value: string): void {\n try {\n sessionStorage.setItem(STORAGE_PREFIX + key, value);\n } catch {\n console.warn('SessionStorage not available');\n }\n }\n\n remove(key: string): void {\n try {\n sessionStorage.removeItem(STORAGE_PREFIX + key);\n } catch {\n // Ignore\n }\n }\n\n clear(): void {\n try {\n Object.keys(sessionStorage)\n .filter(k => k.startsWith(STORAGE_PREFIX))\n .forEach(k => sessionStorage.removeItem(k));\n } catch {\n // Ignore\n }\n }\n}\n\n/**\n * In-memory storage (for SSR or when storage is unavailable)\n */\nexport class MemoryStorageAdapter implements TokenStorage {\n private store = new Map();\n\n get(key: string): string | null {\n return this.store.get(key) ?? null;\n }\n\n set(key: string, value: string): void {\n this.store.set(key, value);\n }\n\n remove(key: string): void {\n this.store.delete(key);\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n\n/**\n * Get storage adapter based on type\n */\nexport function getStorage(type: 'localStorage' | 'sessionStorage' | 'memory'): TokenStorage {\n switch (type) {\n case 'localStorage':\n return new LocalStorageAdapter();\n case 'sessionStorage':\n return new SessionStorageAdapter();\n case 'memory':\n return new MemoryStorageAdapter();\n default:\n return new SessionStorageAdapter();\n }\n}\n","/**\n * Stuffle IAM Client\n * \n * OIDC/OAuth2 client for browser-based applications.\n * Supports authorization code flow with PKCE.\n */\n\nimport type {\n StuffleIAMConfig,\n TokenResponse,\n User,\n AuthState,\n LoginOptions,\n LogoutOptions,\n CallbackResult,\n OIDCDiscovery,\n} from './types';\nimport {\n generateRandomString,\n generateCodeVerifier,\n generateCodeChallenge,\n decodeJwtPayload,\n isTokenExpired,\n parseUrlParams,\n buildUrl,\n} from './utils';\nimport { getStorage, type TokenStorage } from './storage';\n\nconst STORAGE_KEYS = {\n ACCESS_TOKEN: 'access_token',\n REFRESH_TOKEN: 'refresh_token',\n ID_TOKEN: 'id_token',\n CODE_VERIFIER: 'code_verifier',\n STATE: 'state',\n NONCE: 'nonce',\n USER: 'user',\n EXPIRES_AT: 'expires_at',\n};\n\nexport class StuffleIAMClient {\n private config: Required;\n private storage: TokenStorage;\n private discovery: OIDCDiscovery | null = null;\n private refreshTimer: ReturnType | null = null;\n private listeners: Set<(state: AuthState) => void> = new Set();\n\n constructor(config: StuffleIAMConfig) {\n this.config = {\n issuer: config.issuer.replace(/\\/$/, ''), // Remove trailing slash\n clientId: config.clientId,\n redirectUri: config.redirectUri,\n scopes: config.scopes ?? ['openid', 'profile', 'email'],\n postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri,\n usePkce: config.usePkce ?? true,\n storage: config.storage ?? 'sessionStorage',\n autoRefresh: config.autoRefresh ?? true,\n refreshThreshold: config.refreshThreshold ?? 60,\n };\n\n this.storage = getStorage(this.config.storage);\n }\n\n /**\n * Fetch OIDC discovery document\n */\n async getDiscovery(): Promise {\n if (this.discovery) return this.discovery;\n\n const response = await fetch(\n `${this.config.issuer}/.well-known/openid-configuration`\n );\n\n if (!response.ok) {\n throw new Error(`Failed to fetch OIDC discovery: ${response.status}`);\n }\n\n this.discovery = await response.json();\n return this.discovery!;\n }\n\n /**\n * Start login flow - redirects to authorization endpoint\n */\n async login(options: LoginOptions = {}): Promise {\n const discovery = await this.getDiscovery();\n\n const state = options.state ?? generateRandomString();\n const nonce = options.nonce ?? generateRandomString();\n const scopes = [...this.config.scopes, ...(options.scopes ?? [])];\n\n // Store state and nonce for callback validation\n this.storage.set(STORAGE_KEYS.STATE, state);\n this.storage.set(STORAGE_KEYS.NONCE, nonce);\n\n const params: Record = {\n client_id: this.config.clientId,\n redirect_uri: this.config.redirectUri,\n response_type: 'code',\n scope: scopes.join(' '),\n state,\n nonce,\n prompt: options.prompt,\n login_hint: options.loginHint,\n };\n\n // Add PKCE if enabled\n if (this.config.usePkce) {\n const codeVerifier = generateCodeVerifier();\n const codeChallenge = await generateCodeChallenge(codeVerifier);\n \n this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier);\n params.code_challenge = codeChallenge;\n params.code_challenge_method = 'S256';\n }\n\n // Use signup endpoint if requested\n const endpoint = options.signup\n ? discovery.authorization_endpoint.replace('/authorize', '/authorize/signup')\n : discovery.authorization_endpoint;\n\n const authUrl = buildUrl(endpoint, params);\n window.location.href = authUrl;\n }\n\n /**\n * Alias for login({ signup: true })\n */\n async signup(options: Omit = {}): Promise {\n return this.login({ ...options, signup: true });\n }\n\n /**\n * Handle callback from authorization server\n */\n async handleCallback(url?: string): Promise {\n const callbackUrl = url ?? window.location.href;\n const params = parseUrlParams(callbackUrl);\n\n // Check for errors\n if (params.error) {\n return {\n success: false,\n error: params.error,\n errorDescription: params.error_description,\n };\n }\n\n // Validate state\n const storedState = this.storage.get(STORAGE_KEYS.STATE);\n if (!storedState || storedState !== params.state) {\n return {\n success: false,\n error: 'invalid_state',\n errorDescription: 'State mismatch - possible CSRF attack',\n };\n }\n\n // Exchange code for tokens\n if (!params.code) {\n return {\n success: false,\n error: 'missing_code',\n errorDescription: 'No authorization code received',\n };\n }\n\n try {\n const tokens = await this.exchangeCode(params.code);\n \n // Validate nonce in ID token\n if (tokens.id_token) {\n const payload = decodeJwtPayload<{ nonce?: string }>(tokens.id_token);\n const storedNonce = this.storage.get(STORAGE_KEYS.NONCE);\n if (payload?.nonce !== storedNonce) {\n return {\n success: false,\n error: 'invalid_nonce',\n errorDescription: 'Nonce mismatch - possible replay attack',\n };\n }\n }\n\n // Store tokens\n this.storeTokens(tokens);\n\n // Clear temporary storage\n this.storage.remove(STORAGE_KEYS.STATE);\n this.storage.remove(STORAGE_KEYS.NONCE);\n this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);\n\n // Get user info\n const user = await this.fetchUserInfo(tokens.access_token);\n\n // Setup auto-refresh\n if (this.config.autoRefresh && tokens.refresh_token) {\n this.setupAutoRefresh();\n }\n\n // Notify listeners\n this.notifyListeners();\n\n return {\n success: true,\n user: user || undefined,\n accessToken: tokens.access_token,\n idToken: tokens.id_token,\n refreshToken: tokens.refresh_token,\n };\n } catch (error) {\n return {\n success: false,\n error: 'token_exchange_failed',\n errorDescription: error instanceof Error ? error.message : 'Unknown error',\n };\n }\n }\n\n /**\n * Exchange authorization code for tokens\n */\n private async exchangeCode(code: string): Promise {\n const discovery = await this.getDiscovery();\n\n const body: Record = {\n grant_type: 'authorization_code',\n client_id: this.config.clientId,\n code,\n redirect_uri: this.config.redirectUri,\n };\n\n // Add PKCE code verifier\n if (this.config.usePkce) {\n const codeVerifier = this.storage.get(STORAGE_KEYS.CODE_VERIFIER);\n if (codeVerifier) {\n body.code_verifier = codeVerifier;\n }\n }\n\n const response = await fetch(discovery.token_endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams(body),\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(error.error_description || error.error || 'Token exchange failed');\n }\n\n return response.json();\n }\n\n /**\n * Refresh access token using refresh token\n */\n async refreshToken(): Promise {\n const refreshToken = this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);\n if (!refreshToken) return null;\n\n const discovery = await this.getDiscovery();\n\n const response = await fetch(discovery.token_endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: this.config.clientId,\n refresh_token: refreshToken,\n }),\n });\n\n if (!response.ok) {\n // Refresh failed - clear tokens\n this.clearTokens();\n this.notifyListeners();\n return null;\n }\n\n const tokens: TokenResponse = await response.json();\n this.storeTokens(tokens);\n this.notifyListeners();\n\n return tokens;\n }\n\n /**\n * Logout - end session\n */\n async logout(options: LogoutOptions = {}): Promise {\n const discovery = await this.getDiscovery();\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n\n // Clear local tokens first\n this.clearTokens();\n this.notifyListeners();\n\n // Redirect to end session endpoint if available\n if (discovery.end_session_endpoint) {\n const params: Record = {\n post_logout_redirect_uri: options.returnTo ?? this.config.postLogoutRedirectUri,\n id_token_hint: options.idTokenHint ?? idToken ?? undefined,\n client_id: this.config.clientId,\n };\n\n const logoutUrl = buildUrl(discovery.end_session_endpoint, params);\n window.location.href = logoutUrl;\n }\n }\n\n /**\n * Get current access token (refreshes if needed)\n */\n async getAccessToken(): Promise {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n \n if (!accessToken) return null;\n\n // Check if token needs refresh\n if (isTokenExpired(accessToken, this.config.refreshThreshold)) {\n const tokens = await this.refreshToken();\n return tokens?.access_token ?? null;\n }\n\n return accessToken;\n }\n\n /**\n * Get current user from stored ID token\n */\n getUser(): User | null {\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n if (!idToken) return null;\n\n return decodeJwtPayload(idToken);\n }\n\n /**\n * Fetch user info from userinfo endpoint\n */\n async fetchUserInfo(accessToken?: string): Promise {\n const token = accessToken ?? await this.getAccessToken();\n if (!token) return null;\n\n const discovery = await this.getDiscovery();\n\n const response = await fetch(discovery.userinfo_endpoint, {\n headers: {\n Authorization: `Bearer ${token}`,\n },\n });\n\n if (!response.ok) return null;\n\n const user: User = await response.json();\n this.storage.set(STORAGE_KEYS.USER, JSON.stringify(user));\n return user;\n }\n\n /**\n * Check if user is authenticated\n */\n isAuthenticated(): boolean {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n if (!accessToken) return false;\n\n // Consider authenticated if token exists and not expired\n return !isTokenExpired(accessToken);\n }\n\n /**\n * Get current auth state\n */\n getAuthState(): AuthState {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n const user = this.getUser();\n\n return {\n isAuthenticated: this.isAuthenticated(),\n isLoading: false,\n user,\n accessToken,\n idToken,\n error: null,\n };\n }\n\n /**\n * Subscribe to auth state changes\n */\n subscribe(listener: (state: AuthState) => void): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n /**\n * Store tokens in storage\n */\n private storeTokens(tokens: TokenResponse): void {\n this.storage.set(STORAGE_KEYS.ACCESS_TOKEN, tokens.access_token);\n \n if (tokens.refresh_token) {\n this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, tokens.refresh_token);\n }\n \n if (tokens.id_token) {\n this.storage.set(STORAGE_KEYS.ID_TOKEN, tokens.id_token);\n }\n\n // Store expiry time\n const expiresAt = Date.now() + (tokens.expires_in * 1000);\n this.storage.set(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString());\n }\n\n /**\n * Clear all stored tokens\n */\n private clearTokens(): void {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer);\n this.refreshTimer = null;\n }\n\n this.storage.clear();\n }\n\n /**\n * Setup auto-refresh timer\n */\n private setupAutoRefresh(): void {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer);\n }\n\n const expiresAt = this.storage.get(STORAGE_KEYS.EXPIRES_AT);\n if (!expiresAt) return;\n\n const expiresAtMs = parseInt(expiresAt, 10);\n const refreshAt = expiresAtMs - (this.config.refreshThreshold * 1000);\n const delay = refreshAt - Date.now();\n\n if (delay > 0) {\n this.refreshTimer = setTimeout(async () => {\n await this.refreshToken();\n this.setupAutoRefresh();\n }, delay);\n }\n }\n\n /**\n * Notify all listeners of state change\n */\n private notifyListeners(): void {\n const state = this.getAuthState();\n this.listeners.forEach(listener => listener(state));\n }\n}\n\n/**\n * Create a new Stuffle IAM client instance\n */\nexport function createStuffleIAMClient(config: StuffleIAMConfig): StuffleIAMClient {\n return new StuffleIAMClient(config);\n}\n"],"mappings":"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","names":[]} \ No newline at end of file diff --git a/dist/react.d.mts b/dist/react.d.mts new file mode 100644 index 0000000..43bf831 --- /dev/null +++ b/dist/react.d.mts @@ -0,0 +1,61 @@ +import * as react_jsx_runtime from 'react/jsx-runtime'; +import { ReactNode } from 'react'; +import { a as StuffleIAMConfig, C as CallbackResult, S as StuffleIAMClient, U as User, L as LoginOptions, b as LogoutOptions } from './client-CTKWBZ26.mjs'; +export { A as AuthState } from './client-CTKWBZ26.mjs'; + +interface StuffleIAMContextValue { + client: StuffleIAMClient; + isAuthenticated: boolean; + isLoading: boolean; + user: User | null; + accessToken: string | null; + error: Error | null; + login: (options?: LoginOptions) => Promise; + signup: (options?: Omit) => Promise; + logout: (options?: LogoutOptions) => Promise; + handleCallback: (url?: string) => Promise; + getAccessToken: () => Promise; +} +interface StuffleIAMProviderProps extends StuffleIAMConfig { + children: ReactNode; + /** Called after successful login callback */ + onLoginSuccess?: (result: CallbackResult) => void; + /** Called on login error */ + onLoginError?: (error: Error) => void; + /** Auto-handle callback on mount if URL has code/error */ + autoHandleCallback?: boolean; +} +declare function StuffleIAMProvider({ children, onLoginSuccess, onLoginError, autoHandleCallback, ...config }: StuffleIAMProviderProps): react_jsx_runtime.JSX.Element; +/** + * Hook to access auth state and methods + */ +declare function useAuth(): StuffleIAMContextValue; +/** + * Hook to get current user + */ +declare function useUser(): User | null; +/** + * Hook to check if user is authenticated + */ +declare function useIsAuthenticated(): boolean; +/** + * Hook to get access token (refreshes if needed) + */ +declare function useAccessToken(): () => Promise; +/** + * Hook to check if user has specific role(s) + */ +declare function useHasRole(roles: string | string[]): boolean; +/** + * Higher-order component for protected routes + */ +declare function withAuth

(WrappedComponent: React.ComponentType

, options?: { + /** Component to show while loading */ + LoadingComponent?: React.ComponentType; + /** Component to show if not authenticated */ + UnauthorizedComponent?: React.ComponentType; + /** Required roles */ + roles?: string[]; +}): (props: P) => react_jsx_runtime.JSX.Element | null; + +export { CallbackResult, LoginOptions, LogoutOptions, StuffleIAMConfig, StuffleIAMProvider, type StuffleIAMProviderProps, User, useAccessToken, useAuth, useHasRole, useIsAuthenticated, useUser, withAuth }; diff --git a/dist/react.d.ts b/dist/react.d.ts new file mode 100644 index 0000000..a1d274a --- /dev/null +++ b/dist/react.d.ts @@ -0,0 +1,61 @@ +import * as react_jsx_runtime from 'react/jsx-runtime'; +import { ReactNode } from 'react'; +import { a as StuffleIAMConfig, C as CallbackResult, S as StuffleIAMClient, U as User, L as LoginOptions, b as LogoutOptions } from './client-CTKWBZ26.js'; +export { A as AuthState } from './client-CTKWBZ26.js'; + +interface StuffleIAMContextValue { + client: StuffleIAMClient; + isAuthenticated: boolean; + isLoading: boolean; + user: User | null; + accessToken: string | null; + error: Error | null; + login: (options?: LoginOptions) => Promise; + signup: (options?: Omit) => Promise; + logout: (options?: LogoutOptions) => Promise; + handleCallback: (url?: string) => Promise; + getAccessToken: () => Promise; +} +interface StuffleIAMProviderProps extends StuffleIAMConfig { + children: ReactNode; + /** Called after successful login callback */ + onLoginSuccess?: (result: CallbackResult) => void; + /** Called on login error */ + onLoginError?: (error: Error) => void; + /** Auto-handle callback on mount if URL has code/error */ + autoHandleCallback?: boolean; +} +declare function StuffleIAMProvider({ children, onLoginSuccess, onLoginError, autoHandleCallback, ...config }: StuffleIAMProviderProps): react_jsx_runtime.JSX.Element; +/** + * Hook to access auth state and methods + */ +declare function useAuth(): StuffleIAMContextValue; +/** + * Hook to get current user + */ +declare function useUser(): User | null; +/** + * Hook to check if user is authenticated + */ +declare function useIsAuthenticated(): boolean; +/** + * Hook to get access token (refreshes if needed) + */ +declare function useAccessToken(): () => Promise; +/** + * Hook to check if user has specific role(s) + */ +declare function useHasRole(roles: string | string[]): boolean; +/** + * Higher-order component for protected routes + */ +declare function withAuth

(WrappedComponent: React.ComponentType

, options?: { + /** Component to show while loading */ + LoadingComponent?: React.ComponentType; + /** Component to show if not authenticated */ + UnauthorizedComponent?: React.ComponentType; + /** Required roles */ + roles?: string[]; +}): (props: P) => react_jsx_runtime.JSX.Element | null; + +export { CallbackResult, LoginOptions, LogoutOptions, StuffleIAMConfig, StuffleIAMProvider, type StuffleIAMProviderProps, User, useAccessToken, useAuth, useHasRole, useIsAuthenticated, useUser, withAuth }; diff --git a/dist/react.js b/dist/react.js new file mode 100644 index 0000000..5ba3f33 --- /dev/null +++ b/dist/react.js @@ -0,0 +1,671 @@ +"use strict"; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/react/index.tsx +var react_exports = {}; +__export(react_exports, { + StuffleIAMProvider: () => StuffleIAMProvider, + useAccessToken: () => useAccessToken, + useAuth: () => useAuth, + useHasRole: () => useHasRole, + useIsAuthenticated: () => useIsAuthenticated, + useUser: () => useUser, + withAuth: () => withAuth +}); +module.exports = __toCommonJS(react_exports); +var import_react = require("react"); + +// src/utils.ts +function generateRandomString(length = 32) { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} +function generateCodeVerifier() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array); +} +async function generateCodeChallenge(verifier) { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest("SHA-256", data); + return base64UrlEncode(new Uint8Array(digest)); +} +function base64UrlEncode(buffer) { + let binary = ""; + for (let i = 0; i < buffer.length; i++) { + binary += String.fromCharCode(buffer[i]); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +function decodeJwtPayload(token) { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = parts[1]; + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(decoded); + } catch { + return null; + } +} +function isTokenExpired(token, thresholdSeconds = 0) { + const payload = decodeJwtPayload(token); + if (!payload?.exp) return true; + const now = Math.floor(Date.now() / 1e3); + return payload.exp - thresholdSeconds <= now; +} +function parseUrlParams(url) { + const params = {}; + const hashIndex = url.indexOf("#"); + const queryIndex = url.indexOf("?"); + let paramString = ""; + if (hashIndex !== -1) { + paramString = url.substring(hashIndex + 1); + } else if (queryIndex !== -1) { + paramString = url.substring(queryIndex + 1); + } + if (!paramString) return params; + const searchParams = new URLSearchParams(paramString); + searchParams.forEach((value, key) => { + params[key] = value; + }); + return params; +} +function buildUrl(base, params) { + const url = new URL(base); + Object.entries(params).forEach(([key, value]) => { + if (value !== void 0 && value !== null) { + url.searchParams.append(key, value); + } + }); + return url.toString(); +} + +// src/storage.ts +var STORAGE_PREFIX = "stuffle_iam_"; +var LocalStorageAdapter = class { + get(key) { + try { + return localStorage.getItem(STORAGE_PREFIX + key); + } catch { + return null; + } + } + set(key, value) { + try { + localStorage.setItem(STORAGE_PREFIX + key, value); + } catch { + console.warn("LocalStorage not available"); + } + } + remove(key) { + try { + localStorage.removeItem(STORAGE_PREFIX + key); + } catch { + } + } + clear() { + try { + Object.keys(localStorage).filter((k) => k.startsWith(STORAGE_PREFIX)).forEach((k) => localStorage.removeItem(k)); + } catch { + } + } +}; +var SessionStorageAdapter = class { + get(key) { + try { + return sessionStorage.getItem(STORAGE_PREFIX + key); + } catch { + return null; + } + } + set(key, value) { + try { + sessionStorage.setItem(STORAGE_PREFIX + key, value); + } catch { + console.warn("SessionStorage not available"); + } + } + remove(key) { + try { + sessionStorage.removeItem(STORAGE_PREFIX + key); + } catch { + } + } + clear() { + try { + Object.keys(sessionStorage).filter((k) => k.startsWith(STORAGE_PREFIX)).forEach((k) => sessionStorage.removeItem(k)); + } catch { + } + } +}; +var MemoryStorageAdapter = class { + constructor() { + this.store = /* @__PURE__ */ new Map(); + } + get(key) { + return this.store.get(key) ?? null; + } + set(key, value) { + this.store.set(key, value); + } + remove(key) { + this.store.delete(key); + } + clear() { + this.store.clear(); + } +}; +function getStorage(type) { + switch (type) { + case "localStorage": + return new LocalStorageAdapter(); + case "sessionStorage": + return new SessionStorageAdapter(); + case "memory": + return new MemoryStorageAdapter(); + default: + return new SessionStorageAdapter(); + } +} + +// src/client.ts +var STORAGE_KEYS = { + ACCESS_TOKEN: "access_token", + REFRESH_TOKEN: "refresh_token", + ID_TOKEN: "id_token", + CODE_VERIFIER: "code_verifier", + STATE: "state", + NONCE: "nonce", + USER: "user", + EXPIRES_AT: "expires_at" +}; +var StuffleIAMClient = class { + constructor(config) { + this.discovery = null; + this.refreshTimer = null; + this.listeners = /* @__PURE__ */ new Set(); + this.config = { + issuer: config.issuer.replace(/\/$/, ""), + // Remove trailing slash + clientId: config.clientId, + redirectUri: config.redirectUri, + scopes: config.scopes ?? ["openid", "profile", "email"], + postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri, + usePkce: config.usePkce ?? true, + storage: config.storage ?? "sessionStorage", + autoRefresh: config.autoRefresh ?? true, + refreshThreshold: config.refreshThreshold ?? 60 + }; + this.storage = getStorage(this.config.storage); + } + /** + * Fetch OIDC discovery document + */ + async getDiscovery() { + if (this.discovery) return this.discovery; + const response = await fetch( + `${this.config.issuer}/.well-known/openid-configuration` + ); + if (!response.ok) { + throw new Error(`Failed to fetch OIDC discovery: ${response.status}`); + } + this.discovery = await response.json(); + return this.discovery; + } + /** + * Start login flow - redirects to authorization endpoint + */ + async login(options = {}) { + const discovery = await this.getDiscovery(); + const state = options.state ?? generateRandomString(); + const nonce = options.nonce ?? generateRandomString(); + const scopes = [...this.config.scopes, ...options.scopes ?? []]; + this.storage.set(STORAGE_KEYS.STATE, state); + this.storage.set(STORAGE_KEYS.NONCE, nonce); + const params = { + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + response_type: "code", + scope: scopes.join(" "), + state, + nonce, + prompt: options.prompt, + login_hint: options.loginHint + }; + if (this.config.usePkce) { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier); + params.code_challenge = codeChallenge; + params.code_challenge_method = "S256"; + } + const endpoint = options.signup ? discovery.authorization_endpoint.replace("/authorize", "/authorize/signup") : discovery.authorization_endpoint; + const authUrl = buildUrl(endpoint, params); + window.location.href = authUrl; + } + /** + * Alias for login({ signup: true }) + */ + async signup(options = {}) { + return this.login({ ...options, signup: true }); + } + /** + * Handle callback from authorization server + */ + async handleCallback(url) { + const callbackUrl = url ?? window.location.href; + const params = parseUrlParams(callbackUrl); + if (params.error) { + return { + success: false, + error: params.error, + errorDescription: params.error_description + }; + } + const storedState = this.storage.get(STORAGE_KEYS.STATE); + if (!storedState || storedState !== params.state) { + return { + success: false, + error: "invalid_state", + errorDescription: "State mismatch - possible CSRF attack" + }; + } + if (!params.code) { + return { + success: false, + error: "missing_code", + errorDescription: "No authorization code received" + }; + } + try { + const tokens = await this.exchangeCode(params.code); + if (tokens.id_token) { + const payload = decodeJwtPayload(tokens.id_token); + const storedNonce = this.storage.get(STORAGE_KEYS.NONCE); + if (payload?.nonce !== storedNonce) { + return { + success: false, + error: "invalid_nonce", + errorDescription: "Nonce mismatch - possible replay attack" + }; + } + } + this.storeTokens(tokens); + this.storage.remove(STORAGE_KEYS.STATE); + this.storage.remove(STORAGE_KEYS.NONCE); + this.storage.remove(STORAGE_KEYS.CODE_VERIFIER); + const user = await this.fetchUserInfo(tokens.access_token); + if (this.config.autoRefresh && tokens.refresh_token) { + this.setupAutoRefresh(); + } + this.notifyListeners(); + return { + success: true, + user: user || void 0, + accessToken: tokens.access_token, + idToken: tokens.id_token, + refreshToken: tokens.refresh_token + }; + } catch (error) { + return { + success: false, + error: "token_exchange_failed", + errorDescription: error instanceof Error ? error.message : "Unknown error" + }; + } + } + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code) { + const discovery = await this.getDiscovery(); + const body = { + grant_type: "authorization_code", + client_id: this.config.clientId, + code, + redirect_uri: this.config.redirectUri + }; + if (this.config.usePkce) { + const codeVerifier = this.storage.get(STORAGE_KEYS.CODE_VERIFIER); + if (codeVerifier) { + body.code_verifier = codeVerifier; + } + } + const response = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams(body) + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error_description || error.error || "Token exchange failed"); + } + return response.json(); + } + /** + * Refresh access token using refresh token + */ + async refreshToken() { + const refreshToken = this.storage.get(STORAGE_KEYS.REFRESH_TOKEN); + if (!refreshToken) return null; + const discovery = await this.getDiscovery(); + const response = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: this.config.clientId, + refresh_token: refreshToken + }) + }); + if (!response.ok) { + this.clearTokens(); + this.notifyListeners(); + return null; + } + const tokens = await response.json(); + this.storeTokens(tokens); + this.notifyListeners(); + return tokens; + } + /** + * Logout - end session + */ + async logout(options = {}) { + const discovery = await this.getDiscovery(); + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + this.clearTokens(); + this.notifyListeners(); + if (discovery.end_session_endpoint) { + const params = { + post_logout_redirect_uri: options.returnTo ?? this.config.postLogoutRedirectUri, + id_token_hint: options.idTokenHint ?? idToken ?? void 0, + client_id: this.config.clientId + }; + const logoutUrl = buildUrl(discovery.end_session_endpoint, params); + window.location.href = logoutUrl; + } + } + /** + * Get current access token (refreshes if needed) + */ + async getAccessToken() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + if (!accessToken) return null; + if (isTokenExpired(accessToken, this.config.refreshThreshold)) { + const tokens = await this.refreshToken(); + return tokens?.access_token ?? null; + } + return accessToken; + } + /** + * Get current user from stored ID token + */ + getUser() { + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + if (!idToken) return null; + return decodeJwtPayload(idToken); + } + /** + * Fetch user info from userinfo endpoint + */ + async fetchUserInfo(accessToken) { + const token = accessToken ?? await this.getAccessToken(); + if (!token) return null; + const discovery = await this.getDiscovery(); + const response = await fetch(discovery.userinfo_endpoint, { + headers: { + Authorization: `Bearer ${token}` + } + }); + if (!response.ok) return null; + const user = await response.json(); + this.storage.set(STORAGE_KEYS.USER, JSON.stringify(user)); + return user; + } + /** + * Check if user is authenticated + */ + isAuthenticated() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + if (!accessToken) return false; + return !isTokenExpired(accessToken); + } + /** + * Get current auth state + */ + getAuthState() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + const user = this.getUser(); + return { + isAuthenticated: this.isAuthenticated(), + isLoading: false, + user, + accessToken, + idToken, + error: null + }; + } + /** + * Subscribe to auth state changes + */ + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + /** + * Store tokens in storage + */ + storeTokens(tokens) { + this.storage.set(STORAGE_KEYS.ACCESS_TOKEN, tokens.access_token); + if (tokens.refresh_token) { + this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, tokens.refresh_token); + } + if (tokens.id_token) { + this.storage.set(STORAGE_KEYS.ID_TOKEN, tokens.id_token); + } + const expiresAt = Date.now() + tokens.expires_in * 1e3; + this.storage.set(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString()); + } + /** + * Clear all stored tokens + */ + clearTokens() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.storage.clear(); + } + /** + * Setup auto-refresh timer + */ + setupAutoRefresh() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + const expiresAt = this.storage.get(STORAGE_KEYS.EXPIRES_AT); + if (!expiresAt) return; + const expiresAtMs = parseInt(expiresAt, 10); + const refreshAt = expiresAtMs - this.config.refreshThreshold * 1e3; + const delay = refreshAt - Date.now(); + if (delay > 0) { + this.refreshTimer = setTimeout(async () => { + await this.refreshToken(); + this.setupAutoRefresh(); + }, delay); + } + } + /** + * Notify all listeners of state change + */ + notifyListeners() { + const state = this.getAuthState(); + this.listeners.forEach((listener) => listener(state)); + } +}; +function createStuffleIAMClient(config) { + return new StuffleIAMClient(config); +} + +// src/react/index.tsx +var import_jsx_runtime = require("react/jsx-runtime"); +var StuffleIAMContext = (0, import_react.createContext)(null); +function StuffleIAMProvider({ + children, + onLoginSuccess, + onLoginError, + autoHandleCallback = true, + ...config +}) { + const [client] = (0, import_react.useState)(() => createStuffleIAMClient(config)); + const [state, setState] = (0, import_react.useState)(() => ({ + isAuthenticated: false, + isLoading: true, + user: null, + accessToken: null, + idToken: null, + error: null + })); + (0, import_react.useEffect)(() => { + const unsubscribe = client.subscribe(setState); + const initialState = client.getAuthState(); + setState({ ...initialState, isLoading: false }); + return unsubscribe; + }, [client]); + (0, import_react.useEffect)(() => { + if (!autoHandleCallback) return; + const url = window.location.href; + const hasCode = url.includes("code="); + const hasError = url.includes("error="); + if (hasCode || hasError) { + setState((prev) => ({ ...prev, isLoading: true })); + client.handleCallback(url).then((result) => { + setState((prev) => ({ ...prev, isLoading: false })); + if (result.success) { + onLoginSuccess?.(result); + window.history.replaceState({}, "", window.location.pathname); + } else { + const error = new Error(result.errorDescription || result.error || "Login failed"); + onLoginError?.(error); + setState((prev) => ({ ...prev, error })); + } + }); + } + }, [client, autoHandleCallback, onLoginSuccess, onLoginError]); + const login = (0, import_react.useCallback)( + (options) => client.login(options), + [client] + ); + const signup = (0, import_react.useCallback)( + (options) => client.signup(options), + [client] + ); + const logout = (0, import_react.useCallback)( + (options) => client.logout(options), + [client] + ); + const handleCallback = (0, import_react.useCallback)( + (url) => client.handleCallback(url), + [client] + ); + const getAccessToken = (0, import_react.useCallback)( + () => client.getAccessToken(), + [client] + ); + const value = (0, import_react.useMemo)( + () => ({ + client, + isAuthenticated: state.isAuthenticated, + isLoading: state.isLoading, + user: state.user, + accessToken: state.accessToken, + error: state.error, + login, + signup, + logout, + handleCallback, + getAccessToken + }), + [client, state, login, signup, logout, handleCallback, getAccessToken] + ); + return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StuffleIAMContext.Provider, { value, children }); +} +function useAuth() { + const context = (0, import_react.useContext)(StuffleIAMContext); + if (!context) { + throw new Error("useAuth must be used within a StuffleIAMProvider"); + } + return context; +} +function useUser() { + const { user } = useAuth(); + return user; +} +function useIsAuthenticated() { + const { isAuthenticated } = useAuth(); + return isAuthenticated; +} +function useAccessToken() { + const { getAccessToken } = useAuth(); + return getAccessToken; +} +function useHasRole(roles) { + const { user } = useAuth(); + if (!user?.roles) return false; + const requiredRoles = Array.isArray(roles) ? roles : [roles]; + return requiredRoles.some((role) => user.roles?.includes(role)); +} +function withAuth(WrappedComponent, options) { + return function AuthenticatedComponent(props) { + const { isAuthenticated, isLoading, user } = useAuth(); + if (isLoading) { + return options?.LoadingComponent ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(options.LoadingComponent, {}) : null; + } + if (!isAuthenticated) { + return options?.UnauthorizedComponent ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(options.UnauthorizedComponent, {}) : null; + } + if (options?.roles && options.roles.length > 0) { + const hasRequiredRole = options.roles.some((role) => user?.roles?.includes(role)); + if (!hasRequiredRole) { + return options?.UnauthorizedComponent ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(options.UnauthorizedComponent, {}) : null; + } + } + return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(WrappedComponent, { ...props }); + }; +} +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + StuffleIAMProvider, + useAccessToken, + useAuth, + useHasRole, + useIsAuthenticated, + useUser, + withAuth +}); +//# sourceMappingURL=react.js.map \ No newline at end of file diff --git a/dist/react.js.map b/dist/react.js.map new file mode 100644 index 0000000..5d45f57 --- /dev/null +++ b/dist/react.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/react/index.tsx","../src/utils.ts","../src/storage.ts","../src/client.ts"],"sourcesContent":["/**\n * React bindings for Stuffle IAM SDK\n * \n * @example\n * ```tsx\n * import { StuffleIAMProvider, useAuth } from '@stuffle/iam-sdk/react';\n * \n * function App() {\n * return (\n * \n * \n * \n * );\n * }\n * \n * function MyApp() {\n * const { isAuthenticated, user, login, logout } = useAuth();\n * \n * if (!isAuthenticated) {\n * return ;\n * }\n * \n * return (\n *

\n *

Welcome, {user?.name}

\n * \n *
\n * );\n * }\n * ```\n */\n\nimport {\n createContext,\n useContext,\n useState,\n useEffect,\n useCallback,\n useMemo,\n type ReactNode,\n} from 'react';\nimport {\n StuffleIAMClient,\n createStuffleIAMClient,\n type StuffleIAMConfig,\n type AuthState,\n type User,\n type LoginOptions,\n type LogoutOptions,\n type CallbackResult,\n} from '../index';\n\n// =============================================================================\n// Context\n// =============================================================================\n\ninterface StuffleIAMContextValue {\n client: StuffleIAMClient;\n isAuthenticated: boolean;\n isLoading: boolean;\n user: User | null;\n accessToken: string | null;\n error: Error | null;\n login: (options?: LoginOptions) => Promise;\n signup: (options?: Omit) => Promise;\n logout: (options?: LogoutOptions) => Promise;\n handleCallback: (url?: string) => Promise;\n getAccessToken: () => Promise;\n}\n\nconst StuffleIAMContext = createContext(null);\n\n// =============================================================================\n// Provider\n// =============================================================================\n\nexport interface StuffleIAMProviderProps extends StuffleIAMConfig {\n children: ReactNode;\n /** Called after successful login callback */\n onLoginSuccess?: (result: CallbackResult) => void;\n /** Called on login error */\n onLoginError?: (error: Error) => void;\n /** Auto-handle callback on mount if URL has code/error */\n autoHandleCallback?: boolean;\n}\n\nexport function StuffleIAMProvider({\n children,\n onLoginSuccess,\n onLoginError,\n autoHandleCallback = true,\n ...config\n}: StuffleIAMProviderProps) {\n const [client] = useState(() => createStuffleIAMClient(config));\n const [state, setState] = useState(() => ({\n isAuthenticated: false,\n isLoading: true,\n user: null,\n accessToken: null,\n idToken: null,\n error: null,\n }));\n\n // Subscribe to auth state changes\n useEffect(() => {\n const unsubscribe = client.subscribe(setState);\n \n // Initialize state\n const initialState = client.getAuthState();\n setState({ ...initialState, isLoading: false });\n\n return unsubscribe;\n }, [client]);\n\n // Auto-handle callback\n useEffect(() => {\n if (!autoHandleCallback) return;\n\n const url = window.location.href;\n const hasCode = url.includes('code=');\n const hasError = url.includes('error=');\n\n if (hasCode || hasError) {\n setState(prev => ({ ...prev, isLoading: true }));\n \n client.handleCallback(url).then(result => {\n setState(prev => ({ ...prev, isLoading: false }));\n \n if (result.success) {\n onLoginSuccess?.(result);\n // Clean URL\n window.history.replaceState({}, '', window.location.pathname);\n } else {\n const error = new Error(result.errorDescription || result.error || 'Login failed');\n onLoginError?.(error);\n setState(prev => ({ ...prev, error }));\n }\n });\n }\n }, [client, autoHandleCallback, onLoginSuccess, onLoginError]);\n\n // Memoized methods\n const login = useCallback(\n (options?: LoginOptions) => client.login(options),\n [client]\n );\n\n const signup = useCallback(\n (options?: Omit) => client.signup(options),\n [client]\n );\n\n const logout = useCallback(\n (options?: LogoutOptions) => client.logout(options),\n [client]\n );\n\n const handleCallback = useCallback(\n (url?: string) => client.handleCallback(url),\n [client]\n );\n\n const getAccessToken = useCallback(\n () => client.getAccessToken(),\n [client]\n );\n\n const value = useMemo(\n () => ({\n client,\n isAuthenticated: state.isAuthenticated,\n isLoading: state.isLoading,\n user: state.user,\n accessToken: state.accessToken,\n error: state.error,\n login,\n signup,\n logout,\n handleCallback,\n getAccessToken,\n }),\n [client, state, login, signup, logout, handleCallback, getAccessToken]\n );\n\n return (\n \n {children}\n \n );\n}\n\n// =============================================================================\n// Hooks\n// =============================================================================\n\n/**\n * Hook to access auth state and methods\n */\nexport function useAuth() {\n const context = useContext(StuffleIAMContext);\n if (!context) {\n throw new Error('useAuth must be used within a StuffleIAMProvider');\n }\n return context;\n}\n\n/**\n * Hook to get current user\n */\nexport function useUser(): User | null {\n const { user } = useAuth();\n return user;\n}\n\n/**\n * Hook to check if user is authenticated\n */\nexport function useIsAuthenticated(): boolean {\n const { isAuthenticated } = useAuth();\n return isAuthenticated;\n}\n\n/**\n * Hook to get access token (refreshes if needed)\n */\nexport function useAccessToken(): () => Promise {\n const { getAccessToken } = useAuth();\n return getAccessToken;\n}\n\n/**\n * Hook to check if user has specific role(s)\n */\nexport function useHasRole(roles: string | string[]): boolean {\n const { user } = useAuth();\n if (!user?.roles) return false;\n \n const requiredRoles = Array.isArray(roles) ? roles : [roles];\n return requiredRoles.some(role => user.roles?.includes(role));\n}\n\n/**\n * Higher-order component for protected routes\n */\nexport function withAuth

(\n WrappedComponent: React.ComponentType

,\n options?: {\n /** Component to show while loading */\n LoadingComponent?: React.ComponentType;\n /** Component to show if not authenticated */\n UnauthorizedComponent?: React.ComponentType;\n /** Required roles */\n roles?: string[];\n }\n) {\n return function AuthenticatedComponent(props: P) {\n const { isAuthenticated, isLoading, user } = useAuth();\n\n if (isLoading) {\n return options?.LoadingComponent ? : null;\n }\n\n if (!isAuthenticated) {\n return options?.UnauthorizedComponent ? : null;\n }\n\n if (options?.roles && options.roles.length > 0) {\n const hasRequiredRole = options.roles.some(role => user?.roles?.includes(role));\n if (!hasRequiredRole) {\n return options?.UnauthorizedComponent ? : null;\n }\n }\n\n return ;\n };\n}\n\n// Re-export types\nexport type { StuffleIAMConfig, AuthState, User, LoginOptions, LogoutOptions, CallbackResult };\n","/**\n * Utility functions for PKCE and crypto operations\n */\n\n/**\n * Generate a random string for state/nonce\n */\nexport function generateRandomString(length: number = 32): string {\n const array = new Uint8Array(length);\n crypto.getRandomValues(array);\n return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * Generate PKCE code verifier (43-128 characters)\n */\nexport function generateCodeVerifier(): string {\n const array = new Uint8Array(32);\n crypto.getRandomValues(array);\n return base64UrlEncode(array);\n}\n\n/**\n * Generate PKCE code challenge from verifier\n */\nexport async function generateCodeChallenge(verifier: string): Promise {\n const encoder = new TextEncoder();\n const data = encoder.encode(verifier);\n const digest = await crypto.subtle.digest('SHA-256', data);\n return base64UrlEncode(new Uint8Array(digest));\n}\n\n/**\n * Base64 URL encode (no padding, URL-safe characters)\n */\nexport function base64UrlEncode(buffer: Uint8Array): string {\n let binary = '';\n for (let i = 0; i < buffer.length; i++) {\n binary += String.fromCharCode(buffer[i]);\n }\n return btoa(binary)\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n}\n\n/**\n * Decode JWT payload (without verification)\n */\nexport function decodeJwtPayload>(token: string): T | null {\n try {\n const parts = token.split('.');\n if (parts.length !== 3) return null;\n \n const payload = parts[1];\n const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));\n return JSON.parse(decoded) as T;\n } catch {\n return null;\n }\n}\n\n/**\n * Check if token is expired\n */\nexport function isTokenExpired(token: string, thresholdSeconds: number = 0): boolean {\n const payload = decodeJwtPayload<{ exp?: number }>(token);\n if (!payload?.exp) return true;\n \n const now = Math.floor(Date.now() / 1000);\n return payload.exp - thresholdSeconds <= now;\n}\n\n/**\n * Parse URL hash or query parameters\n */\nexport function parseUrlParams(url: string): Record {\n const params: Record = {};\n \n // Check hash first (implicit flow), then query (code flow)\n const hashIndex = url.indexOf('#');\n const queryIndex = url.indexOf('?');\n \n let paramString = '';\n if (hashIndex !== -1) {\n paramString = url.substring(hashIndex + 1);\n } else if (queryIndex !== -1) {\n paramString = url.substring(queryIndex + 1);\n }\n \n if (!paramString) return params;\n \n const searchParams = new URLSearchParams(paramString);\n searchParams.forEach((value, key) => {\n params[key] = value;\n });\n \n return params;\n}\n\n/**\n * Build URL with query parameters\n */\nexport function buildUrl(base: string, params: Record): string {\n const url = new URL(base);\n Object.entries(params).forEach(([key, value]) => {\n if (value !== undefined && value !== null) {\n url.searchParams.append(key, value);\n }\n });\n return url.toString();\n}\n","/**\n * Token storage abstraction\n */\n\nexport interface TokenStorage {\n get(key: string): string | null;\n set(key: string, value: string): void;\n remove(key: string): void;\n clear(): void;\n}\n\nconst STORAGE_PREFIX = 'stuffle_iam_';\n\n/**\n * LocalStorage implementation\n */\nexport class LocalStorageAdapter implements TokenStorage {\n get(key: string): string | null {\n try {\n return localStorage.getItem(STORAGE_PREFIX + key);\n } catch {\n return null;\n }\n }\n\n set(key: string, value: string): void {\n try {\n localStorage.setItem(STORAGE_PREFIX + key, value);\n } catch {\n console.warn('LocalStorage not available');\n }\n }\n\n remove(key: string): void {\n try {\n localStorage.removeItem(STORAGE_PREFIX + key);\n } catch {\n // Ignore\n }\n }\n\n clear(): void {\n try {\n Object.keys(localStorage)\n .filter(k => k.startsWith(STORAGE_PREFIX))\n .forEach(k => localStorage.removeItem(k));\n } catch {\n // Ignore\n }\n }\n}\n\n/**\n * SessionStorage implementation\n */\nexport class SessionStorageAdapter implements TokenStorage {\n get(key: string): string | null {\n try {\n return sessionStorage.getItem(STORAGE_PREFIX + key);\n } catch {\n return null;\n }\n }\n\n set(key: string, value: string): void {\n try {\n sessionStorage.setItem(STORAGE_PREFIX + key, value);\n } catch {\n console.warn('SessionStorage not available');\n }\n }\n\n remove(key: string): void {\n try {\n sessionStorage.removeItem(STORAGE_PREFIX + key);\n } catch {\n // Ignore\n }\n }\n\n clear(): void {\n try {\n Object.keys(sessionStorage)\n .filter(k => k.startsWith(STORAGE_PREFIX))\n .forEach(k => sessionStorage.removeItem(k));\n } catch {\n // Ignore\n }\n }\n}\n\n/**\n * In-memory storage (for SSR or when storage is unavailable)\n */\nexport class MemoryStorageAdapter implements TokenStorage {\n private store = new Map();\n\n get(key: string): string | null {\n return this.store.get(key) ?? null;\n }\n\n set(key: string, value: string): void {\n this.store.set(key, value);\n }\n\n remove(key: string): void {\n this.store.delete(key);\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n\n/**\n * Get storage adapter based on type\n */\nexport function getStorage(type: 'localStorage' | 'sessionStorage' | 'memory'): TokenStorage {\n switch (type) {\n case 'localStorage':\n return new LocalStorageAdapter();\n case 'sessionStorage':\n return new SessionStorageAdapter();\n case 'memory':\n return new MemoryStorageAdapter();\n default:\n return new SessionStorageAdapter();\n }\n}\n","/**\n * Stuffle IAM Client\n * \n * OIDC/OAuth2 client for browser-based applications.\n * Supports authorization code flow with PKCE.\n */\n\nimport type {\n StuffleIAMConfig,\n TokenResponse,\n User,\n AuthState,\n LoginOptions,\n LogoutOptions,\n CallbackResult,\n OIDCDiscovery,\n} from './types';\nimport {\n generateRandomString,\n generateCodeVerifier,\n generateCodeChallenge,\n decodeJwtPayload,\n isTokenExpired,\n parseUrlParams,\n buildUrl,\n} from './utils';\nimport { getStorage, type TokenStorage } from './storage';\n\nconst STORAGE_KEYS = {\n ACCESS_TOKEN: 'access_token',\n REFRESH_TOKEN: 'refresh_token',\n ID_TOKEN: 'id_token',\n CODE_VERIFIER: 'code_verifier',\n STATE: 'state',\n NONCE: 'nonce',\n USER: 'user',\n EXPIRES_AT: 'expires_at',\n};\n\nexport class StuffleIAMClient {\n private config: Required;\n private storage: TokenStorage;\n private discovery: OIDCDiscovery | null = null;\n private refreshTimer: ReturnType | null = null;\n private listeners: Set<(state: AuthState) => void> = new Set();\n\n constructor(config: StuffleIAMConfig) {\n this.config = {\n issuer: config.issuer.replace(/\\/$/, ''), // Remove trailing slash\n clientId: config.clientId,\n redirectUri: config.redirectUri,\n scopes: config.scopes ?? ['openid', 'profile', 'email'],\n postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri,\n usePkce: config.usePkce ?? true,\n storage: config.storage ?? 'sessionStorage',\n autoRefresh: config.autoRefresh ?? true,\n refreshThreshold: config.refreshThreshold ?? 60,\n };\n\n this.storage = getStorage(this.config.storage);\n }\n\n /**\n * Fetch OIDC discovery document\n */\n async getDiscovery(): Promise {\n if (this.discovery) return this.discovery;\n\n const response = await fetch(\n `${this.config.issuer}/.well-known/openid-configuration`\n );\n\n if (!response.ok) {\n throw new Error(`Failed to fetch OIDC discovery: ${response.status}`);\n }\n\n this.discovery = await response.json();\n return this.discovery!;\n }\n\n /**\n * Start login flow - redirects to authorization endpoint\n */\n async login(options: LoginOptions = {}): Promise {\n const discovery = await this.getDiscovery();\n\n const state = options.state ?? generateRandomString();\n const nonce = options.nonce ?? generateRandomString();\n const scopes = [...this.config.scopes, ...(options.scopes ?? [])];\n\n // Store state and nonce for callback validation\n this.storage.set(STORAGE_KEYS.STATE, state);\n this.storage.set(STORAGE_KEYS.NONCE, nonce);\n\n const params: Record = {\n client_id: this.config.clientId,\n redirect_uri: this.config.redirectUri,\n response_type: 'code',\n scope: scopes.join(' '),\n state,\n nonce,\n prompt: options.prompt,\n login_hint: options.loginHint,\n };\n\n // Add PKCE if enabled\n if (this.config.usePkce) {\n const codeVerifier = generateCodeVerifier();\n const codeChallenge = await generateCodeChallenge(codeVerifier);\n \n this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier);\n params.code_challenge = codeChallenge;\n params.code_challenge_method = 'S256';\n }\n\n // Use signup endpoint if requested\n const endpoint = options.signup\n ? discovery.authorization_endpoint.replace('/authorize', '/authorize/signup')\n : discovery.authorization_endpoint;\n\n const authUrl = buildUrl(endpoint, params);\n window.location.href = authUrl;\n }\n\n /**\n * Alias for login({ signup: true })\n */\n async signup(options: Omit = {}): Promise {\n return this.login({ ...options, signup: true });\n }\n\n /**\n * Handle callback from authorization server\n */\n async handleCallback(url?: string): Promise {\n const callbackUrl = url ?? window.location.href;\n const params = parseUrlParams(callbackUrl);\n\n // Check for errors\n if (params.error) {\n return {\n success: false,\n error: params.error,\n errorDescription: params.error_description,\n };\n }\n\n // Validate state\n const storedState = this.storage.get(STORAGE_KEYS.STATE);\n if (!storedState || storedState !== params.state) {\n return {\n success: false,\n error: 'invalid_state',\n errorDescription: 'State mismatch - possible CSRF attack',\n };\n }\n\n // Exchange code for tokens\n if (!params.code) {\n return {\n success: false,\n error: 'missing_code',\n errorDescription: 'No authorization code received',\n };\n }\n\n try {\n const tokens = await this.exchangeCode(params.code);\n \n // Validate nonce in ID token\n if (tokens.id_token) {\n const payload = decodeJwtPayload<{ nonce?: string }>(tokens.id_token);\n const storedNonce = this.storage.get(STORAGE_KEYS.NONCE);\n if (payload?.nonce !== storedNonce) {\n return {\n success: false,\n error: 'invalid_nonce',\n errorDescription: 'Nonce mismatch - possible replay attack',\n };\n }\n }\n\n // Store tokens\n this.storeTokens(tokens);\n\n // Clear temporary storage\n this.storage.remove(STORAGE_KEYS.STATE);\n this.storage.remove(STORAGE_KEYS.NONCE);\n this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);\n\n // Get user info\n const user = await this.fetchUserInfo(tokens.access_token);\n\n // Setup auto-refresh\n if (this.config.autoRefresh && tokens.refresh_token) {\n this.setupAutoRefresh();\n }\n\n // Notify listeners\n this.notifyListeners();\n\n return {\n success: true,\n user: user || undefined,\n accessToken: tokens.access_token,\n idToken: tokens.id_token,\n refreshToken: tokens.refresh_token,\n };\n } catch (error) {\n return {\n success: false,\n error: 'token_exchange_failed',\n errorDescription: error instanceof Error ? error.message : 'Unknown error',\n };\n }\n }\n\n /**\n * Exchange authorization code for tokens\n */\n private async exchangeCode(code: string): Promise {\n const discovery = await this.getDiscovery();\n\n const body: Record = {\n grant_type: 'authorization_code',\n client_id: this.config.clientId,\n code,\n redirect_uri: this.config.redirectUri,\n };\n\n // Add PKCE code verifier\n if (this.config.usePkce) {\n const codeVerifier = this.storage.get(STORAGE_KEYS.CODE_VERIFIER);\n if (codeVerifier) {\n body.code_verifier = codeVerifier;\n }\n }\n\n const response = await fetch(discovery.token_endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams(body),\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(error.error_description || error.error || 'Token exchange failed');\n }\n\n return response.json();\n }\n\n /**\n * Refresh access token using refresh token\n */\n async refreshToken(): Promise {\n const refreshToken = this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);\n if (!refreshToken) return null;\n\n const discovery = await this.getDiscovery();\n\n const response = await fetch(discovery.token_endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: this.config.clientId,\n refresh_token: refreshToken,\n }),\n });\n\n if (!response.ok) {\n // Refresh failed - clear tokens\n this.clearTokens();\n this.notifyListeners();\n return null;\n }\n\n const tokens: TokenResponse = await response.json();\n this.storeTokens(tokens);\n this.notifyListeners();\n\n return tokens;\n }\n\n /**\n * Logout - end session\n */\n async logout(options: LogoutOptions = {}): Promise {\n const discovery = await this.getDiscovery();\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n\n // Clear local tokens first\n this.clearTokens();\n this.notifyListeners();\n\n // Redirect to end session endpoint if available\n if (discovery.end_session_endpoint) {\n const params: Record = {\n post_logout_redirect_uri: options.returnTo ?? this.config.postLogoutRedirectUri,\n id_token_hint: options.idTokenHint ?? idToken ?? undefined,\n client_id: this.config.clientId,\n };\n\n const logoutUrl = buildUrl(discovery.end_session_endpoint, params);\n window.location.href = logoutUrl;\n }\n }\n\n /**\n * Get current access token (refreshes if needed)\n */\n async getAccessToken(): Promise {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n \n if (!accessToken) return null;\n\n // Check if token needs refresh\n if (isTokenExpired(accessToken, this.config.refreshThreshold)) {\n const tokens = await this.refreshToken();\n return tokens?.access_token ?? null;\n }\n\n return accessToken;\n }\n\n /**\n * Get current user from stored ID token\n */\n getUser(): User | null {\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n if (!idToken) return null;\n\n return decodeJwtPayload(idToken);\n }\n\n /**\n * Fetch user info from userinfo endpoint\n */\n async fetchUserInfo(accessToken?: string): Promise {\n const token = accessToken ?? await this.getAccessToken();\n if (!token) return null;\n\n const discovery = await this.getDiscovery();\n\n const response = await fetch(discovery.userinfo_endpoint, {\n headers: {\n Authorization: `Bearer ${token}`,\n },\n });\n\n if (!response.ok) return null;\n\n const user: User = await response.json();\n this.storage.set(STORAGE_KEYS.USER, JSON.stringify(user));\n return user;\n }\n\n /**\n * Check if user is authenticated\n */\n isAuthenticated(): boolean {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n if (!accessToken) return false;\n\n // Consider authenticated if token exists and not expired\n return !isTokenExpired(accessToken);\n }\n\n /**\n * Get current auth state\n */\n getAuthState(): AuthState {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n const user = this.getUser();\n\n return {\n isAuthenticated: this.isAuthenticated(),\n isLoading: false,\n user,\n accessToken,\n idToken,\n error: null,\n };\n }\n\n /**\n * Subscribe to auth state changes\n */\n subscribe(listener: (state: AuthState) => void): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n /**\n * Store tokens in storage\n */\n private storeTokens(tokens: TokenResponse): void {\n this.storage.set(STORAGE_KEYS.ACCESS_TOKEN, tokens.access_token);\n \n if (tokens.refresh_token) {\n this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, tokens.refresh_token);\n }\n \n if (tokens.id_token) {\n this.storage.set(STORAGE_KEYS.ID_TOKEN, tokens.id_token);\n }\n\n // Store expiry time\n const expiresAt = Date.now() + (tokens.expires_in * 1000);\n this.storage.set(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString());\n }\n\n /**\n * Clear all stored tokens\n */\n private clearTokens(): void {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer);\n this.refreshTimer = null;\n }\n\n this.storage.clear();\n }\n\n /**\n * Setup auto-refresh timer\n */\n private setupAutoRefresh(): void {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer);\n }\n\n const expiresAt = this.storage.get(STORAGE_KEYS.EXPIRES_AT);\n if (!expiresAt) return;\n\n const expiresAtMs = parseInt(expiresAt, 10);\n const refreshAt = expiresAtMs - (this.config.refreshThreshold * 1000);\n const delay = refreshAt - Date.now();\n\n if (delay > 0) {\n this.refreshTimer = setTimeout(async () => {\n await this.refreshToken();\n this.setupAutoRefresh();\n }, delay);\n }\n }\n\n /**\n * Notify all listeners of state change\n */\n private notifyListeners(): void {\n const state = this.getAuthState();\n this.listeners.forEach(listener => listener(state));\n }\n}\n\n/**\n * Create a new Stuffle IAM client instance\n */\nexport function createStuffleIAMClient(config: StuffleIAMConfig): StuffleIAMClient {\n return new StuffleIAMClient(config);\n}\n"],"mappings":"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","names":[]} \ No newline at end of file diff --git a/dist/react.mjs b/dist/react.mjs new file mode 100644 index 0000000..dcda85c --- /dev/null +++ b/dist/react.mjs @@ -0,0 +1,647 @@ +// src/react/index.tsx +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + useMemo +} from "react"; + +// src/utils.ts +function generateRandomString(length = 32) { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join(""); +} +function generateCodeVerifier() { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array); +} +async function generateCodeChallenge(verifier) { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest("SHA-256", data); + return base64UrlEncode(new Uint8Array(digest)); +} +function base64UrlEncode(buffer) { + let binary = ""; + for (let i = 0; i < buffer.length; i++) { + binary += String.fromCharCode(buffer[i]); + } + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} +function decodeJwtPayload(token) { + try { + const parts = token.split("."); + if (parts.length !== 3) return null; + const payload = parts[1]; + const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/")); + return JSON.parse(decoded); + } catch { + return null; + } +} +function isTokenExpired(token, thresholdSeconds = 0) { + const payload = decodeJwtPayload(token); + if (!payload?.exp) return true; + const now = Math.floor(Date.now() / 1e3); + return payload.exp - thresholdSeconds <= now; +} +function parseUrlParams(url) { + const params = {}; + const hashIndex = url.indexOf("#"); + const queryIndex = url.indexOf("?"); + let paramString = ""; + if (hashIndex !== -1) { + paramString = url.substring(hashIndex + 1); + } else if (queryIndex !== -1) { + paramString = url.substring(queryIndex + 1); + } + if (!paramString) return params; + const searchParams = new URLSearchParams(paramString); + searchParams.forEach((value, key) => { + params[key] = value; + }); + return params; +} +function buildUrl(base, params) { + const url = new URL(base); + Object.entries(params).forEach(([key, value]) => { + if (value !== void 0 && value !== null) { + url.searchParams.append(key, value); + } + }); + return url.toString(); +} + +// src/storage.ts +var STORAGE_PREFIX = "stuffle_iam_"; +var LocalStorageAdapter = class { + get(key) { + try { + return localStorage.getItem(STORAGE_PREFIX + key); + } catch { + return null; + } + } + set(key, value) { + try { + localStorage.setItem(STORAGE_PREFIX + key, value); + } catch { + console.warn("LocalStorage not available"); + } + } + remove(key) { + try { + localStorage.removeItem(STORAGE_PREFIX + key); + } catch { + } + } + clear() { + try { + Object.keys(localStorage).filter((k) => k.startsWith(STORAGE_PREFIX)).forEach((k) => localStorage.removeItem(k)); + } catch { + } + } +}; +var SessionStorageAdapter = class { + get(key) { + try { + return sessionStorage.getItem(STORAGE_PREFIX + key); + } catch { + return null; + } + } + set(key, value) { + try { + sessionStorage.setItem(STORAGE_PREFIX + key, value); + } catch { + console.warn("SessionStorage not available"); + } + } + remove(key) { + try { + sessionStorage.removeItem(STORAGE_PREFIX + key); + } catch { + } + } + clear() { + try { + Object.keys(sessionStorage).filter((k) => k.startsWith(STORAGE_PREFIX)).forEach((k) => sessionStorage.removeItem(k)); + } catch { + } + } +}; +var MemoryStorageAdapter = class { + constructor() { + this.store = /* @__PURE__ */ new Map(); + } + get(key) { + return this.store.get(key) ?? null; + } + set(key, value) { + this.store.set(key, value); + } + remove(key) { + this.store.delete(key); + } + clear() { + this.store.clear(); + } +}; +function getStorage(type) { + switch (type) { + case "localStorage": + return new LocalStorageAdapter(); + case "sessionStorage": + return new SessionStorageAdapter(); + case "memory": + return new MemoryStorageAdapter(); + default: + return new SessionStorageAdapter(); + } +} + +// src/client.ts +var STORAGE_KEYS = { + ACCESS_TOKEN: "access_token", + REFRESH_TOKEN: "refresh_token", + ID_TOKEN: "id_token", + CODE_VERIFIER: "code_verifier", + STATE: "state", + NONCE: "nonce", + USER: "user", + EXPIRES_AT: "expires_at" +}; +var StuffleIAMClient = class { + constructor(config) { + this.discovery = null; + this.refreshTimer = null; + this.listeners = /* @__PURE__ */ new Set(); + this.config = { + issuer: config.issuer.replace(/\/$/, ""), + // Remove trailing slash + clientId: config.clientId, + redirectUri: config.redirectUri, + scopes: config.scopes ?? ["openid", "profile", "email"], + postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri, + usePkce: config.usePkce ?? true, + storage: config.storage ?? "sessionStorage", + autoRefresh: config.autoRefresh ?? true, + refreshThreshold: config.refreshThreshold ?? 60 + }; + this.storage = getStorage(this.config.storage); + } + /** + * Fetch OIDC discovery document + */ + async getDiscovery() { + if (this.discovery) return this.discovery; + const response = await fetch( + `${this.config.issuer}/.well-known/openid-configuration` + ); + if (!response.ok) { + throw new Error(`Failed to fetch OIDC discovery: ${response.status}`); + } + this.discovery = await response.json(); + return this.discovery; + } + /** + * Start login flow - redirects to authorization endpoint + */ + async login(options = {}) { + const discovery = await this.getDiscovery(); + const state = options.state ?? generateRandomString(); + const nonce = options.nonce ?? generateRandomString(); + const scopes = [...this.config.scopes, ...options.scopes ?? []]; + this.storage.set(STORAGE_KEYS.STATE, state); + this.storage.set(STORAGE_KEYS.NONCE, nonce); + const params = { + client_id: this.config.clientId, + redirect_uri: this.config.redirectUri, + response_type: "code", + scope: scopes.join(" "), + state, + nonce, + prompt: options.prompt, + login_hint: options.loginHint + }; + if (this.config.usePkce) { + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); + this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier); + params.code_challenge = codeChallenge; + params.code_challenge_method = "S256"; + } + const endpoint = options.signup ? discovery.authorization_endpoint.replace("/authorize", "/authorize/signup") : discovery.authorization_endpoint; + const authUrl = buildUrl(endpoint, params); + window.location.href = authUrl; + } + /** + * Alias for login({ signup: true }) + */ + async signup(options = {}) { + return this.login({ ...options, signup: true }); + } + /** + * Handle callback from authorization server + */ + async handleCallback(url) { + const callbackUrl = url ?? window.location.href; + const params = parseUrlParams(callbackUrl); + if (params.error) { + return { + success: false, + error: params.error, + errorDescription: params.error_description + }; + } + const storedState = this.storage.get(STORAGE_KEYS.STATE); + if (!storedState || storedState !== params.state) { + return { + success: false, + error: "invalid_state", + errorDescription: "State mismatch - possible CSRF attack" + }; + } + if (!params.code) { + return { + success: false, + error: "missing_code", + errorDescription: "No authorization code received" + }; + } + try { + const tokens = await this.exchangeCode(params.code); + if (tokens.id_token) { + const payload = decodeJwtPayload(tokens.id_token); + const storedNonce = this.storage.get(STORAGE_KEYS.NONCE); + if (payload?.nonce !== storedNonce) { + return { + success: false, + error: "invalid_nonce", + errorDescription: "Nonce mismatch - possible replay attack" + }; + } + } + this.storeTokens(tokens); + this.storage.remove(STORAGE_KEYS.STATE); + this.storage.remove(STORAGE_KEYS.NONCE); + this.storage.remove(STORAGE_KEYS.CODE_VERIFIER); + const user = await this.fetchUserInfo(tokens.access_token); + if (this.config.autoRefresh && tokens.refresh_token) { + this.setupAutoRefresh(); + } + this.notifyListeners(); + return { + success: true, + user: user || void 0, + accessToken: tokens.access_token, + idToken: tokens.id_token, + refreshToken: tokens.refresh_token + }; + } catch (error) { + return { + success: false, + error: "token_exchange_failed", + errorDescription: error instanceof Error ? error.message : "Unknown error" + }; + } + } + /** + * Exchange authorization code for tokens + */ + async exchangeCode(code) { + const discovery = await this.getDiscovery(); + const body = { + grant_type: "authorization_code", + client_id: this.config.clientId, + code, + redirect_uri: this.config.redirectUri + }; + if (this.config.usePkce) { + const codeVerifier = this.storage.get(STORAGE_KEYS.CODE_VERIFIER); + if (codeVerifier) { + body.code_verifier = codeVerifier; + } + } + const response = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams(body) + }); + if (!response.ok) { + const error = await response.json().catch(() => ({})); + throw new Error(error.error_description || error.error || "Token exchange failed"); + } + return response.json(); + } + /** + * Refresh access token using refresh token + */ + async refreshToken() { + const refreshToken = this.storage.get(STORAGE_KEYS.REFRESH_TOKEN); + if (!refreshToken) return null; + const discovery = await this.getDiscovery(); + const response = await fetch(discovery.token_endpoint, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded" + }, + body: new URLSearchParams({ + grant_type: "refresh_token", + client_id: this.config.clientId, + refresh_token: refreshToken + }) + }); + if (!response.ok) { + this.clearTokens(); + this.notifyListeners(); + return null; + } + const tokens = await response.json(); + this.storeTokens(tokens); + this.notifyListeners(); + return tokens; + } + /** + * Logout - end session + */ + async logout(options = {}) { + const discovery = await this.getDiscovery(); + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + this.clearTokens(); + this.notifyListeners(); + if (discovery.end_session_endpoint) { + const params = { + post_logout_redirect_uri: options.returnTo ?? this.config.postLogoutRedirectUri, + id_token_hint: options.idTokenHint ?? idToken ?? void 0, + client_id: this.config.clientId + }; + const logoutUrl = buildUrl(discovery.end_session_endpoint, params); + window.location.href = logoutUrl; + } + } + /** + * Get current access token (refreshes if needed) + */ + async getAccessToken() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + if (!accessToken) return null; + if (isTokenExpired(accessToken, this.config.refreshThreshold)) { + const tokens = await this.refreshToken(); + return tokens?.access_token ?? null; + } + return accessToken; + } + /** + * Get current user from stored ID token + */ + getUser() { + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + if (!idToken) return null; + return decodeJwtPayload(idToken); + } + /** + * Fetch user info from userinfo endpoint + */ + async fetchUserInfo(accessToken) { + const token = accessToken ?? await this.getAccessToken(); + if (!token) return null; + const discovery = await this.getDiscovery(); + const response = await fetch(discovery.userinfo_endpoint, { + headers: { + Authorization: `Bearer ${token}` + } + }); + if (!response.ok) return null; + const user = await response.json(); + this.storage.set(STORAGE_KEYS.USER, JSON.stringify(user)); + return user; + } + /** + * Check if user is authenticated + */ + isAuthenticated() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + if (!accessToken) return false; + return !isTokenExpired(accessToken); + } + /** + * Get current auth state + */ + getAuthState() { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + const user = this.getUser(); + return { + isAuthenticated: this.isAuthenticated(), + isLoading: false, + user, + accessToken, + idToken, + error: null + }; + } + /** + * Subscribe to auth state changes + */ + subscribe(listener) { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + /** + * Store tokens in storage + */ + storeTokens(tokens) { + this.storage.set(STORAGE_KEYS.ACCESS_TOKEN, tokens.access_token); + if (tokens.refresh_token) { + this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, tokens.refresh_token); + } + if (tokens.id_token) { + this.storage.set(STORAGE_KEYS.ID_TOKEN, tokens.id_token); + } + const expiresAt = Date.now() + tokens.expires_in * 1e3; + this.storage.set(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString()); + } + /** + * Clear all stored tokens + */ + clearTokens() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + this.storage.clear(); + } + /** + * Setup auto-refresh timer + */ + setupAutoRefresh() { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + } + const expiresAt = this.storage.get(STORAGE_KEYS.EXPIRES_AT); + if (!expiresAt) return; + const expiresAtMs = parseInt(expiresAt, 10); + const refreshAt = expiresAtMs - this.config.refreshThreshold * 1e3; + const delay = refreshAt - Date.now(); + if (delay > 0) { + this.refreshTimer = setTimeout(async () => { + await this.refreshToken(); + this.setupAutoRefresh(); + }, delay); + } + } + /** + * Notify all listeners of state change + */ + notifyListeners() { + const state = this.getAuthState(); + this.listeners.forEach((listener) => listener(state)); + } +}; +function createStuffleIAMClient(config) { + return new StuffleIAMClient(config); +} + +// src/react/index.tsx +import { jsx } from "react/jsx-runtime"; +var StuffleIAMContext = createContext(null); +function StuffleIAMProvider({ + children, + onLoginSuccess, + onLoginError, + autoHandleCallback = true, + ...config +}) { + const [client] = useState(() => createStuffleIAMClient(config)); + const [state, setState] = useState(() => ({ + isAuthenticated: false, + isLoading: true, + user: null, + accessToken: null, + idToken: null, + error: null + })); + useEffect(() => { + const unsubscribe = client.subscribe(setState); + const initialState = client.getAuthState(); + setState({ ...initialState, isLoading: false }); + return unsubscribe; + }, [client]); + useEffect(() => { + if (!autoHandleCallback) return; + const url = window.location.href; + const hasCode = url.includes("code="); + const hasError = url.includes("error="); + if (hasCode || hasError) { + setState((prev) => ({ ...prev, isLoading: true })); + client.handleCallback(url).then((result) => { + setState((prev) => ({ ...prev, isLoading: false })); + if (result.success) { + onLoginSuccess?.(result); + window.history.replaceState({}, "", window.location.pathname); + } else { + const error = new Error(result.errorDescription || result.error || "Login failed"); + onLoginError?.(error); + setState((prev) => ({ ...prev, error })); + } + }); + } + }, [client, autoHandleCallback, onLoginSuccess, onLoginError]); + const login = useCallback( + (options) => client.login(options), + [client] + ); + const signup = useCallback( + (options) => client.signup(options), + [client] + ); + const logout = useCallback( + (options) => client.logout(options), + [client] + ); + const handleCallback = useCallback( + (url) => client.handleCallback(url), + [client] + ); + const getAccessToken = useCallback( + () => client.getAccessToken(), + [client] + ); + const value = useMemo( + () => ({ + client, + isAuthenticated: state.isAuthenticated, + isLoading: state.isLoading, + user: state.user, + accessToken: state.accessToken, + error: state.error, + login, + signup, + logout, + handleCallback, + getAccessToken + }), + [client, state, login, signup, logout, handleCallback, getAccessToken] + ); + return /* @__PURE__ */ jsx(StuffleIAMContext.Provider, { value, children }); +} +function useAuth() { + const context = useContext(StuffleIAMContext); + if (!context) { + throw new Error("useAuth must be used within a StuffleIAMProvider"); + } + return context; +} +function useUser() { + const { user } = useAuth(); + return user; +} +function useIsAuthenticated() { + const { isAuthenticated } = useAuth(); + return isAuthenticated; +} +function useAccessToken() { + const { getAccessToken } = useAuth(); + return getAccessToken; +} +function useHasRole(roles) { + const { user } = useAuth(); + if (!user?.roles) return false; + const requiredRoles = Array.isArray(roles) ? roles : [roles]; + return requiredRoles.some((role) => user.roles?.includes(role)); +} +function withAuth(WrappedComponent, options) { + return function AuthenticatedComponent(props) { + const { isAuthenticated, isLoading, user } = useAuth(); + if (isLoading) { + return options?.LoadingComponent ? /* @__PURE__ */ jsx(options.LoadingComponent, {}) : null; + } + if (!isAuthenticated) { + return options?.UnauthorizedComponent ? /* @__PURE__ */ jsx(options.UnauthorizedComponent, {}) : null; + } + if (options?.roles && options.roles.length > 0) { + const hasRequiredRole = options.roles.some((role) => user?.roles?.includes(role)); + if (!hasRequiredRole) { + return options?.UnauthorizedComponent ? /* @__PURE__ */ jsx(options.UnauthorizedComponent, {}) : null; + } + } + return /* @__PURE__ */ jsx(WrappedComponent, { ...props }); + }; +} +export { + StuffleIAMProvider, + useAccessToken, + useAuth, + useHasRole, + useIsAuthenticated, + useUser, + withAuth +}; +//# sourceMappingURL=react.mjs.map \ No newline at end of file diff --git a/dist/react.mjs.map b/dist/react.mjs.map new file mode 100644 index 0000000..a7fb073 --- /dev/null +++ b/dist/react.mjs.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/react/index.tsx","../src/utils.ts","../src/storage.ts","../src/client.ts"],"sourcesContent":["/**\n * React bindings for Stuffle IAM SDK\n * \n * @example\n * ```tsx\n * import { StuffleIAMProvider, useAuth } from '@stuffle/iam-sdk/react';\n * \n * function App() {\n * return (\n * \n * \n * \n * );\n * }\n * \n * function MyApp() {\n * const { isAuthenticated, user, login, logout } = useAuth();\n * \n * if (!isAuthenticated) {\n * return ;\n * }\n * \n * return (\n *

\n *

Welcome, {user?.name}

\n * \n *
\n * );\n * }\n * ```\n */\n\nimport {\n createContext,\n useContext,\n useState,\n useEffect,\n useCallback,\n useMemo,\n type ReactNode,\n} from 'react';\nimport {\n StuffleIAMClient,\n createStuffleIAMClient,\n type StuffleIAMConfig,\n type AuthState,\n type User,\n type LoginOptions,\n type LogoutOptions,\n type CallbackResult,\n} from '../index';\n\n// =============================================================================\n// Context\n// =============================================================================\n\ninterface StuffleIAMContextValue {\n client: StuffleIAMClient;\n isAuthenticated: boolean;\n isLoading: boolean;\n user: User | null;\n accessToken: string | null;\n error: Error | null;\n login: (options?: LoginOptions) => Promise;\n signup: (options?: Omit) => Promise;\n logout: (options?: LogoutOptions) => Promise;\n handleCallback: (url?: string) => Promise;\n getAccessToken: () => Promise;\n}\n\nconst StuffleIAMContext = createContext(null);\n\n// =============================================================================\n// Provider\n// =============================================================================\n\nexport interface StuffleIAMProviderProps extends StuffleIAMConfig {\n children: ReactNode;\n /** Called after successful login callback */\n onLoginSuccess?: (result: CallbackResult) => void;\n /** Called on login error */\n onLoginError?: (error: Error) => void;\n /** Auto-handle callback on mount if URL has code/error */\n autoHandleCallback?: boolean;\n}\n\nexport function StuffleIAMProvider({\n children,\n onLoginSuccess,\n onLoginError,\n autoHandleCallback = true,\n ...config\n}: StuffleIAMProviderProps) {\n const [client] = useState(() => createStuffleIAMClient(config));\n const [state, setState] = useState(() => ({\n isAuthenticated: false,\n isLoading: true,\n user: null,\n accessToken: null,\n idToken: null,\n error: null,\n }));\n\n // Subscribe to auth state changes\n useEffect(() => {\n const unsubscribe = client.subscribe(setState);\n \n // Initialize state\n const initialState = client.getAuthState();\n setState({ ...initialState, isLoading: false });\n\n return unsubscribe;\n }, [client]);\n\n // Auto-handle callback\n useEffect(() => {\n if (!autoHandleCallback) return;\n\n const url = window.location.href;\n const hasCode = url.includes('code=');\n const hasError = url.includes('error=');\n\n if (hasCode || hasError) {\n setState(prev => ({ ...prev, isLoading: true }));\n \n client.handleCallback(url).then(result => {\n setState(prev => ({ ...prev, isLoading: false }));\n \n if (result.success) {\n onLoginSuccess?.(result);\n // Clean URL\n window.history.replaceState({}, '', window.location.pathname);\n } else {\n const error = new Error(result.errorDescription || result.error || 'Login failed');\n onLoginError?.(error);\n setState(prev => ({ ...prev, error }));\n }\n });\n }\n }, [client, autoHandleCallback, onLoginSuccess, onLoginError]);\n\n // Memoized methods\n const login = useCallback(\n (options?: LoginOptions) => client.login(options),\n [client]\n );\n\n const signup = useCallback(\n (options?: Omit) => client.signup(options),\n [client]\n );\n\n const logout = useCallback(\n (options?: LogoutOptions) => client.logout(options),\n [client]\n );\n\n const handleCallback = useCallback(\n (url?: string) => client.handleCallback(url),\n [client]\n );\n\n const getAccessToken = useCallback(\n () => client.getAccessToken(),\n [client]\n );\n\n const value = useMemo(\n () => ({\n client,\n isAuthenticated: state.isAuthenticated,\n isLoading: state.isLoading,\n user: state.user,\n accessToken: state.accessToken,\n error: state.error,\n login,\n signup,\n logout,\n handleCallback,\n getAccessToken,\n }),\n [client, state, login, signup, logout, handleCallback, getAccessToken]\n );\n\n return (\n \n {children}\n \n );\n}\n\n// =============================================================================\n// Hooks\n// =============================================================================\n\n/**\n * Hook to access auth state and methods\n */\nexport function useAuth() {\n const context = useContext(StuffleIAMContext);\n if (!context) {\n throw new Error('useAuth must be used within a StuffleIAMProvider');\n }\n return context;\n}\n\n/**\n * Hook to get current user\n */\nexport function useUser(): User | null {\n const { user } = useAuth();\n return user;\n}\n\n/**\n * Hook to check if user is authenticated\n */\nexport function useIsAuthenticated(): boolean {\n const { isAuthenticated } = useAuth();\n return isAuthenticated;\n}\n\n/**\n * Hook to get access token (refreshes if needed)\n */\nexport function useAccessToken(): () => Promise {\n const { getAccessToken } = useAuth();\n return getAccessToken;\n}\n\n/**\n * Hook to check if user has specific role(s)\n */\nexport function useHasRole(roles: string | string[]): boolean {\n const { user } = useAuth();\n if (!user?.roles) return false;\n \n const requiredRoles = Array.isArray(roles) ? roles : [roles];\n return requiredRoles.some(role => user.roles?.includes(role));\n}\n\n/**\n * Higher-order component for protected routes\n */\nexport function withAuth

(\n WrappedComponent: React.ComponentType

,\n options?: {\n /** Component to show while loading */\n LoadingComponent?: React.ComponentType;\n /** Component to show if not authenticated */\n UnauthorizedComponent?: React.ComponentType;\n /** Required roles */\n roles?: string[];\n }\n) {\n return function AuthenticatedComponent(props: P) {\n const { isAuthenticated, isLoading, user } = useAuth();\n\n if (isLoading) {\n return options?.LoadingComponent ? : null;\n }\n\n if (!isAuthenticated) {\n return options?.UnauthorizedComponent ? : null;\n }\n\n if (options?.roles && options.roles.length > 0) {\n const hasRequiredRole = options.roles.some(role => user?.roles?.includes(role));\n if (!hasRequiredRole) {\n return options?.UnauthorizedComponent ? : null;\n }\n }\n\n return ;\n };\n}\n\n// Re-export types\nexport type { StuffleIAMConfig, AuthState, User, LoginOptions, LogoutOptions, CallbackResult };\n","/**\n * Utility functions for PKCE and crypto operations\n */\n\n/**\n * Generate a random string for state/nonce\n */\nexport function generateRandomString(length: number = 32): string {\n const array = new Uint8Array(length);\n crypto.getRandomValues(array);\n return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join('');\n}\n\n/**\n * Generate PKCE code verifier (43-128 characters)\n */\nexport function generateCodeVerifier(): string {\n const array = new Uint8Array(32);\n crypto.getRandomValues(array);\n return base64UrlEncode(array);\n}\n\n/**\n * Generate PKCE code challenge from verifier\n */\nexport async function generateCodeChallenge(verifier: string): Promise {\n const encoder = new TextEncoder();\n const data = encoder.encode(verifier);\n const digest = await crypto.subtle.digest('SHA-256', data);\n return base64UrlEncode(new Uint8Array(digest));\n}\n\n/**\n * Base64 URL encode (no padding, URL-safe characters)\n */\nexport function base64UrlEncode(buffer: Uint8Array): string {\n let binary = '';\n for (let i = 0; i < buffer.length; i++) {\n binary += String.fromCharCode(buffer[i]);\n }\n return btoa(binary)\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n}\n\n/**\n * Decode JWT payload (without verification)\n */\nexport function decodeJwtPayload>(token: string): T | null {\n try {\n const parts = token.split('.');\n if (parts.length !== 3) return null;\n \n const payload = parts[1];\n const decoded = atob(payload.replace(/-/g, '+').replace(/_/g, '/'));\n return JSON.parse(decoded) as T;\n } catch {\n return null;\n }\n}\n\n/**\n * Check if token is expired\n */\nexport function isTokenExpired(token: string, thresholdSeconds: number = 0): boolean {\n const payload = decodeJwtPayload<{ exp?: number }>(token);\n if (!payload?.exp) return true;\n \n const now = Math.floor(Date.now() / 1000);\n return payload.exp - thresholdSeconds <= now;\n}\n\n/**\n * Parse URL hash or query parameters\n */\nexport function parseUrlParams(url: string): Record {\n const params: Record = {};\n \n // Check hash first (implicit flow), then query (code flow)\n const hashIndex = url.indexOf('#');\n const queryIndex = url.indexOf('?');\n \n let paramString = '';\n if (hashIndex !== -1) {\n paramString = url.substring(hashIndex + 1);\n } else if (queryIndex !== -1) {\n paramString = url.substring(queryIndex + 1);\n }\n \n if (!paramString) return params;\n \n const searchParams = new URLSearchParams(paramString);\n searchParams.forEach((value, key) => {\n params[key] = value;\n });\n \n return params;\n}\n\n/**\n * Build URL with query parameters\n */\nexport function buildUrl(base: string, params: Record): string {\n const url = new URL(base);\n Object.entries(params).forEach(([key, value]) => {\n if (value !== undefined && value !== null) {\n url.searchParams.append(key, value);\n }\n });\n return url.toString();\n}\n","/**\n * Token storage abstraction\n */\n\nexport interface TokenStorage {\n get(key: string): string | null;\n set(key: string, value: string): void;\n remove(key: string): void;\n clear(): void;\n}\n\nconst STORAGE_PREFIX = 'stuffle_iam_';\n\n/**\n * LocalStorage implementation\n */\nexport class LocalStorageAdapter implements TokenStorage {\n get(key: string): string | null {\n try {\n return localStorage.getItem(STORAGE_PREFIX + key);\n } catch {\n return null;\n }\n }\n\n set(key: string, value: string): void {\n try {\n localStorage.setItem(STORAGE_PREFIX + key, value);\n } catch {\n console.warn('LocalStorage not available');\n }\n }\n\n remove(key: string): void {\n try {\n localStorage.removeItem(STORAGE_PREFIX + key);\n } catch {\n // Ignore\n }\n }\n\n clear(): void {\n try {\n Object.keys(localStorage)\n .filter(k => k.startsWith(STORAGE_PREFIX))\n .forEach(k => localStorage.removeItem(k));\n } catch {\n // Ignore\n }\n }\n}\n\n/**\n * SessionStorage implementation\n */\nexport class SessionStorageAdapter implements TokenStorage {\n get(key: string): string | null {\n try {\n return sessionStorage.getItem(STORAGE_PREFIX + key);\n } catch {\n return null;\n }\n }\n\n set(key: string, value: string): void {\n try {\n sessionStorage.setItem(STORAGE_PREFIX + key, value);\n } catch {\n console.warn('SessionStorage not available');\n }\n }\n\n remove(key: string): void {\n try {\n sessionStorage.removeItem(STORAGE_PREFIX + key);\n } catch {\n // Ignore\n }\n }\n\n clear(): void {\n try {\n Object.keys(sessionStorage)\n .filter(k => k.startsWith(STORAGE_PREFIX))\n .forEach(k => sessionStorage.removeItem(k));\n } catch {\n // Ignore\n }\n }\n}\n\n/**\n * In-memory storage (for SSR or when storage is unavailable)\n */\nexport class MemoryStorageAdapter implements TokenStorage {\n private store = new Map();\n\n get(key: string): string | null {\n return this.store.get(key) ?? null;\n }\n\n set(key: string, value: string): void {\n this.store.set(key, value);\n }\n\n remove(key: string): void {\n this.store.delete(key);\n }\n\n clear(): void {\n this.store.clear();\n }\n}\n\n/**\n * Get storage adapter based on type\n */\nexport function getStorage(type: 'localStorage' | 'sessionStorage' | 'memory'): TokenStorage {\n switch (type) {\n case 'localStorage':\n return new LocalStorageAdapter();\n case 'sessionStorage':\n return new SessionStorageAdapter();\n case 'memory':\n return new MemoryStorageAdapter();\n default:\n return new SessionStorageAdapter();\n }\n}\n","/**\n * Stuffle IAM Client\n * \n * OIDC/OAuth2 client for browser-based applications.\n * Supports authorization code flow with PKCE.\n */\n\nimport type {\n StuffleIAMConfig,\n TokenResponse,\n User,\n AuthState,\n LoginOptions,\n LogoutOptions,\n CallbackResult,\n OIDCDiscovery,\n} from './types';\nimport {\n generateRandomString,\n generateCodeVerifier,\n generateCodeChallenge,\n decodeJwtPayload,\n isTokenExpired,\n parseUrlParams,\n buildUrl,\n} from './utils';\nimport { getStorage, type TokenStorage } from './storage';\n\nconst STORAGE_KEYS = {\n ACCESS_TOKEN: 'access_token',\n REFRESH_TOKEN: 'refresh_token',\n ID_TOKEN: 'id_token',\n CODE_VERIFIER: 'code_verifier',\n STATE: 'state',\n NONCE: 'nonce',\n USER: 'user',\n EXPIRES_AT: 'expires_at',\n};\n\nexport class StuffleIAMClient {\n private config: Required;\n private storage: TokenStorage;\n private discovery: OIDCDiscovery | null = null;\n private refreshTimer: ReturnType | null = null;\n private listeners: Set<(state: AuthState) => void> = new Set();\n\n constructor(config: StuffleIAMConfig) {\n this.config = {\n issuer: config.issuer.replace(/\\/$/, ''), // Remove trailing slash\n clientId: config.clientId,\n redirectUri: config.redirectUri,\n scopes: config.scopes ?? ['openid', 'profile', 'email'],\n postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri,\n usePkce: config.usePkce ?? true,\n storage: config.storage ?? 'sessionStorage',\n autoRefresh: config.autoRefresh ?? true,\n refreshThreshold: config.refreshThreshold ?? 60,\n };\n\n this.storage = getStorage(this.config.storage);\n }\n\n /**\n * Fetch OIDC discovery document\n */\n async getDiscovery(): Promise {\n if (this.discovery) return this.discovery;\n\n const response = await fetch(\n `${this.config.issuer}/.well-known/openid-configuration`\n );\n\n if (!response.ok) {\n throw new Error(`Failed to fetch OIDC discovery: ${response.status}`);\n }\n\n this.discovery = await response.json();\n return this.discovery!;\n }\n\n /**\n * Start login flow - redirects to authorization endpoint\n */\n async login(options: LoginOptions = {}): Promise {\n const discovery = await this.getDiscovery();\n\n const state = options.state ?? generateRandomString();\n const nonce = options.nonce ?? generateRandomString();\n const scopes = [...this.config.scopes, ...(options.scopes ?? [])];\n\n // Store state and nonce for callback validation\n this.storage.set(STORAGE_KEYS.STATE, state);\n this.storage.set(STORAGE_KEYS.NONCE, nonce);\n\n const params: Record = {\n client_id: this.config.clientId,\n redirect_uri: this.config.redirectUri,\n response_type: 'code',\n scope: scopes.join(' '),\n state,\n nonce,\n prompt: options.prompt,\n login_hint: options.loginHint,\n };\n\n // Add PKCE if enabled\n if (this.config.usePkce) {\n const codeVerifier = generateCodeVerifier();\n const codeChallenge = await generateCodeChallenge(codeVerifier);\n \n this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier);\n params.code_challenge = codeChallenge;\n params.code_challenge_method = 'S256';\n }\n\n // Use signup endpoint if requested\n const endpoint = options.signup\n ? discovery.authorization_endpoint.replace('/authorize', '/authorize/signup')\n : discovery.authorization_endpoint;\n\n const authUrl = buildUrl(endpoint, params);\n window.location.href = authUrl;\n }\n\n /**\n * Alias for login({ signup: true })\n */\n async signup(options: Omit = {}): Promise {\n return this.login({ ...options, signup: true });\n }\n\n /**\n * Handle callback from authorization server\n */\n async handleCallback(url?: string): Promise {\n const callbackUrl = url ?? window.location.href;\n const params = parseUrlParams(callbackUrl);\n\n // Check for errors\n if (params.error) {\n return {\n success: false,\n error: params.error,\n errorDescription: params.error_description,\n };\n }\n\n // Validate state\n const storedState = this.storage.get(STORAGE_KEYS.STATE);\n if (!storedState || storedState !== params.state) {\n return {\n success: false,\n error: 'invalid_state',\n errorDescription: 'State mismatch - possible CSRF attack',\n };\n }\n\n // Exchange code for tokens\n if (!params.code) {\n return {\n success: false,\n error: 'missing_code',\n errorDescription: 'No authorization code received',\n };\n }\n\n try {\n const tokens = await this.exchangeCode(params.code);\n \n // Validate nonce in ID token\n if (tokens.id_token) {\n const payload = decodeJwtPayload<{ nonce?: string }>(tokens.id_token);\n const storedNonce = this.storage.get(STORAGE_KEYS.NONCE);\n if (payload?.nonce !== storedNonce) {\n return {\n success: false,\n error: 'invalid_nonce',\n errorDescription: 'Nonce mismatch - possible replay attack',\n };\n }\n }\n\n // Store tokens\n this.storeTokens(tokens);\n\n // Clear temporary storage\n this.storage.remove(STORAGE_KEYS.STATE);\n this.storage.remove(STORAGE_KEYS.NONCE);\n this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);\n\n // Get user info\n const user = await this.fetchUserInfo(tokens.access_token);\n\n // Setup auto-refresh\n if (this.config.autoRefresh && tokens.refresh_token) {\n this.setupAutoRefresh();\n }\n\n // Notify listeners\n this.notifyListeners();\n\n return {\n success: true,\n user: user || undefined,\n accessToken: tokens.access_token,\n idToken: tokens.id_token,\n refreshToken: tokens.refresh_token,\n };\n } catch (error) {\n return {\n success: false,\n error: 'token_exchange_failed',\n errorDescription: error instanceof Error ? error.message : 'Unknown error',\n };\n }\n }\n\n /**\n * Exchange authorization code for tokens\n */\n private async exchangeCode(code: string): Promise {\n const discovery = await this.getDiscovery();\n\n const body: Record = {\n grant_type: 'authorization_code',\n client_id: this.config.clientId,\n code,\n redirect_uri: this.config.redirectUri,\n };\n\n // Add PKCE code verifier\n if (this.config.usePkce) {\n const codeVerifier = this.storage.get(STORAGE_KEYS.CODE_VERIFIER);\n if (codeVerifier) {\n body.code_verifier = codeVerifier;\n }\n }\n\n const response = await fetch(discovery.token_endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams(body),\n });\n\n if (!response.ok) {\n const error = await response.json().catch(() => ({}));\n throw new Error(error.error_description || error.error || 'Token exchange failed');\n }\n\n return response.json();\n }\n\n /**\n * Refresh access token using refresh token\n */\n async refreshToken(): Promise {\n const refreshToken = this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);\n if (!refreshToken) return null;\n\n const discovery = await this.getDiscovery();\n\n const response = await fetch(discovery.token_endpoint, {\n method: 'POST',\n headers: {\n 'Content-Type': 'application/x-www-form-urlencoded',\n },\n body: new URLSearchParams({\n grant_type: 'refresh_token',\n client_id: this.config.clientId,\n refresh_token: refreshToken,\n }),\n });\n\n if (!response.ok) {\n // Refresh failed - clear tokens\n this.clearTokens();\n this.notifyListeners();\n return null;\n }\n\n const tokens: TokenResponse = await response.json();\n this.storeTokens(tokens);\n this.notifyListeners();\n\n return tokens;\n }\n\n /**\n * Logout - end session\n */\n async logout(options: LogoutOptions = {}): Promise {\n const discovery = await this.getDiscovery();\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n\n // Clear local tokens first\n this.clearTokens();\n this.notifyListeners();\n\n // Redirect to end session endpoint if available\n if (discovery.end_session_endpoint) {\n const params: Record = {\n post_logout_redirect_uri: options.returnTo ?? this.config.postLogoutRedirectUri,\n id_token_hint: options.idTokenHint ?? idToken ?? undefined,\n client_id: this.config.clientId,\n };\n\n const logoutUrl = buildUrl(discovery.end_session_endpoint, params);\n window.location.href = logoutUrl;\n }\n }\n\n /**\n * Get current access token (refreshes if needed)\n */\n async getAccessToken(): Promise {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n \n if (!accessToken) return null;\n\n // Check if token needs refresh\n if (isTokenExpired(accessToken, this.config.refreshThreshold)) {\n const tokens = await this.refreshToken();\n return tokens?.access_token ?? null;\n }\n\n return accessToken;\n }\n\n /**\n * Get current user from stored ID token\n */\n getUser(): User | null {\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n if (!idToken) return null;\n\n return decodeJwtPayload(idToken);\n }\n\n /**\n * Fetch user info from userinfo endpoint\n */\n async fetchUserInfo(accessToken?: string): Promise {\n const token = accessToken ?? await this.getAccessToken();\n if (!token) return null;\n\n const discovery = await this.getDiscovery();\n\n const response = await fetch(discovery.userinfo_endpoint, {\n headers: {\n Authorization: `Bearer ${token}`,\n },\n });\n\n if (!response.ok) return null;\n\n const user: User = await response.json();\n this.storage.set(STORAGE_KEYS.USER, JSON.stringify(user));\n return user;\n }\n\n /**\n * Check if user is authenticated\n */\n isAuthenticated(): boolean {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n if (!accessToken) return false;\n\n // Consider authenticated if token exists and not expired\n return !isTokenExpired(accessToken);\n }\n\n /**\n * Get current auth state\n */\n getAuthState(): AuthState {\n const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);\n const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);\n const user = this.getUser();\n\n return {\n isAuthenticated: this.isAuthenticated(),\n isLoading: false,\n user,\n accessToken,\n idToken,\n error: null,\n };\n }\n\n /**\n * Subscribe to auth state changes\n */\n subscribe(listener: (state: AuthState) => void): () => void {\n this.listeners.add(listener);\n return () => this.listeners.delete(listener);\n }\n\n /**\n * Store tokens in storage\n */\n private storeTokens(tokens: TokenResponse): void {\n this.storage.set(STORAGE_KEYS.ACCESS_TOKEN, tokens.access_token);\n \n if (tokens.refresh_token) {\n this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, tokens.refresh_token);\n }\n \n if (tokens.id_token) {\n this.storage.set(STORAGE_KEYS.ID_TOKEN, tokens.id_token);\n }\n\n // Store expiry time\n const expiresAt = Date.now() + (tokens.expires_in * 1000);\n this.storage.set(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString());\n }\n\n /**\n * Clear all stored tokens\n */\n private clearTokens(): void {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer);\n this.refreshTimer = null;\n }\n\n this.storage.clear();\n }\n\n /**\n * Setup auto-refresh timer\n */\n private setupAutoRefresh(): void {\n if (this.refreshTimer) {\n clearTimeout(this.refreshTimer);\n }\n\n const expiresAt = this.storage.get(STORAGE_KEYS.EXPIRES_AT);\n if (!expiresAt) return;\n\n const expiresAtMs = parseInt(expiresAt, 10);\n const refreshAt = expiresAtMs - (this.config.refreshThreshold * 1000);\n const delay = refreshAt - Date.now();\n\n if (delay > 0) {\n this.refreshTimer = setTimeout(async () => {\n await this.refreshToken();\n this.setupAutoRefresh();\n }, delay);\n }\n }\n\n /**\n * Notify all listeners of state change\n */\n private notifyListeners(): void {\n const state = this.getAuthState();\n this.listeners.forEach(listener => listener(state));\n }\n}\n\n/**\n * Create a new Stuffle IAM client instance\n */\nexport function createStuffleIAMClient(config: StuffleIAMConfig): StuffleIAMClient {\n return new StuffleIAMClient(config);\n}\n"],"mappings":"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","names":[]} \ No newline at end of file diff --git a/src/react/index.ts b/src/react/index.tsx similarity index 96% rename from src/react/index.ts rename to src/react/index.tsx index de9f754..262a920 100644 --- a/src/react/index.ts +++ b/src/react/index.tsx @@ -108,7 +108,7 @@ export function StuffleIAMProvider({ // Subscribe to auth state changes useEffect(() => { const unsubscribe = client.subscribe(setState); - + // Initialize state const initialState = client.getAuthState(); setState({ ...initialState, isLoading: false }); @@ -126,10 +126,10 @@ export function StuffleIAMProvider({ if (hasCode || hasError) { setState(prev => ({ ...prev, isLoading: true })); - + client.handleCallback(url).then(result => { setState(prev => ({ ...prev, isLoading: false })); - + if (result.success) { onLoginSuccess?.(result); // Clean URL @@ -238,7 +238,7 @@ export function useAccessToken(): () => Promise { export function useHasRole(roles: string | string[]): boolean { const { user } = useAuth(); if (!user?.roles) return false; - + const requiredRoles = Array.isArray(roles) ? roles : [roles]; return requiredRoles.some(role => user.roles?.includes(role)); } @@ -260,18 +260,21 @@ export function withAuth

( return function AuthenticatedComponent(props: P) { const { isAuthenticated, isLoading, user } = useAuth(); + const LoadingComp = options?.LoadingComponent; + const UnauthorizedComp = options?.UnauthorizedComponent; + if (isLoading) { - return options?.LoadingComponent ? : null; + return LoadingComp ? : null; } if (!isAuthenticated) { - return options?.UnauthorizedComponent ? : null; + return UnauthorizedComp ? : null; } if (options?.roles && options.roles.length > 0) { const hasRequiredRole = options.roles.some(role => user?.roles?.includes(role)); if (!hasRequiredRole) { - return options?.UnauthorizedComponent ? : null; + return UnauthorizedComp ? : null; } } diff --git a/tsup.config.ts b/tsup.config.ts index 731d7b2..936ed21 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -3,7 +3,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ entry: { index: 'src/index.ts', - react: 'src/react/index.ts', + react: 'src/react/index.tsx', }, format: ['cjs', 'esm'], dts: true, @@ -11,4 +11,7 @@ export default defineConfig({ splitting: false, sourcemap: true, external: ['react'], + esbuildOptions(options) { + options.jsx = 'automatic' + }, })