From f7a5ee30b5a5187867775b676a6fd04321b495d7 Mon Sep 17 00:00:00 2001 From: mohiit1502 Date: Sun, 28 Dec 2025 18:59:30 +0530 Subject: [PATCH] First commit --- .DS_Store | Bin 0 -> 6148 bytes Jenkinsfile | 7 + README.md | 181 ++++++++++++++++++ package.json | 58 ++++++ src/client.ts | 468 +++++++++++++++++++++++++++++++++++++++++++++ src/index.ts | 46 +++++ src/react/index.ts | 283 +++++++++++++++++++++++++++ src/storage.ts | 129 +++++++++++++ src/types.ts | 103 ++++++++++ src/utils.ts | 112 +++++++++++ tsconfig.json | 18 ++ tsup.config.ts | 14 ++ 12 files changed, 1419 insertions(+) create mode 100644 .DS_Store create mode 100644 Jenkinsfile create mode 100644 README.md create mode 100644 package.json create mode 100644 src/client.ts create mode 100644 src/index.ts create mode 100644 src/react/index.ts create mode 100644 src/storage.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts create mode 100644 tsconfig.json create mode 100644 tsup.config.ts diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..851e3c64baabf1c9d03b32c11104b2bb187206cd GIT binary patch literal 6148 zcmeHKu}T9$5S=ww9N46^5f;H#$RDiX?CkObAqhkw$Azd^x{d#l+D5SP69jAjz;Cek z&CZzInyZ7z4D7z$c{4kA4{rCCh}`gQHX!N~QHjRrc+g?&?xYShm`TTLEarpJw5gXJ zi~Lm=KYKxEG^cC2&7c3_xy{v0RZSMn1ite9^W^I#zMbd%Y{NgUkDq??%d3_)Zj4mw z(G9I=npa)jZ<%(@%hQinT~qVwW?ONW&Fayb>zSDXrhqA63YY?{0M2Z-@=(xvQ@|83 z1vU!s{@~FVgJKj+TL-#y1ppRsC&QTU5}e}|gJKi}19OrJlvJlz3@7REdyNZ iam.login(); + +// Signup +document.getElementById('signup-btn').onclick = () => iam.signup(); + +// Handle callback (on /callback page) +const result = await iam.handleCallback(); +if (result.success) { + console.log('Logged in:', result.user); +} else { + console.error('Login failed:', result.error); +} + +// Get current user +const user = iam.getUser(); + +// Get access token (auto-refreshes if needed) +const token = await iam.getAccessToken(); + +// Logout +await iam.logout(); +``` + +### React + +```tsx +import { IAMProvider, useAuth } from '@armco/iam-client/react'; + +// Wrap your app +function App() { + return ( + { + console.log('Logged in:', result.user); + // Navigate to dashboard + }} + > + + + } /> + } /> + } /> + + + + ); +} + +// Use the hook +function Home() { + const { isAuthenticated, isLoading, user, login, signup, logout } = useAuth(); + + if (isLoading) return
Loading...
; + + if (!isAuthenticated) { + return ( +
+ + +
+ ); + } + + return ( +
+

Welcome, {user?.name || user?.email}

+ +
+ ); +} + +// Callback page (auto-handled by provider) +function Callback() { + const { isLoading, error } = useAuth(); + + if (isLoading) return
Processing login...
; + if (error) return
Error: {error.message}
; + + return ; +} +``` + +## Configuration + +```typescript +interface StuffleIAMConfig { + /** IAM server base URL */ + 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) */ + usePkce?: boolean; + /** Storage type (default: sessionStorage) */ + storage?: 'localStorage' | 'sessionStorage' | 'memory'; + /** Auto-refresh tokens (default: true) */ + autoRefresh?: boolean; + /** Seconds before expiry to refresh (default: 60) */ + refreshThreshold?: number; +} +``` + +## React Hooks + +| Hook | Description | +|------|-------------| +| `useAuth()` | Full auth context (user, login, logout, etc.) | +| `useUser()` | Current user object | +| `useIsAuthenticated()` | Boolean auth status | +| `useAccessToken()` | Function to get access token | +| `useHasRole(roles)` | Check if user has role(s) | + +## Protected Routes (HOC) + +```tsx +import { withAuth } from '@armco/iam-client/react'; + +const ProtectedPage = withAuth(MyComponent, { + LoadingComponent: () =>
Loading...
, + UnauthorizedComponent: () =>
Please login
, + roles: ['admin'], // Optional: require specific roles +}); +``` + +## API Reference + +### StuffleIAMClient Methods + +| Method | Description | +|--------|-------------| +| `login(options?)` | Start login flow | +| `signup(options?)` | Start signup flow | +| `logout(options?)` | End session | +| `handleCallback(url?)` | Process OAuth callback | +| `getUser()` | Get current user from ID token | +| `getAccessToken()` | Get access token (refreshes if needed) | +| `isAuthenticated()` | Check if user is logged in | +| `refreshToken()` | Manually refresh tokens | +| `subscribe(listener)` | Subscribe to auth state changes | + +## Development + +```bash +cd packages/sdk-js +npm install +npm run build +npm run dev # Watch mode +``` diff --git a/package.json b/package.json new file mode 100644 index 0000000..57a7b07 --- /dev/null +++ b/package.json @@ -0,0 +1,58 @@ +{ + "name": "@armco/iam-client", + "version": "0.1.0", + "description": "Browser/SPA client for IAM - OIDC/OAuth2 authentication with PKCE", + "main": "dist/index.js", + "module": "dist/index.mjs", + "types": "dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.mjs", + "require": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./react": { + "import": "./dist/react.mjs", + "require": "./dist/react.js", + "types": "./dist/react.d.ts" + } + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "typecheck": "tsc --noEmit", + "lint": "eslint src --ext .ts,.tsx", + "test": "vitest run", + "prepublishOnly": "npm run build" + }, + "keywords": [ + "iam", + "oauth2", + "oidc", + "authentication", + "armco", + "spa", + "pkce" + ], + "author": "Armco", + "license": "MIT", + "peerDependencies": { + "react": ">=17.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + } + }, + "devDependencies": { + "@types/node": "^20.10.0", + "@types/react": "^18.2.0", + "react": "^18.2.0", + "tsup": "^8.0.0", + "typescript": "^5.3.0", + "vitest": "^1.0.0" + } +} diff --git a/src/client.ts b/src/client.ts new file mode 100644 index 0000000..0cab965 --- /dev/null +++ b/src/client.ts @@ -0,0 +1,468 @@ +/** + * Stuffle IAM Client + * + * OIDC/OAuth2 client for browser-based applications. + * Supports authorization code flow with PKCE. + */ + +import type { + StuffleIAMConfig, + TokenResponse, + User, + AuthState, + LoginOptions, + LogoutOptions, + CallbackResult, + OIDCDiscovery, +} from './types'; +import { + generateRandomString, + generateCodeVerifier, + generateCodeChallenge, + decodeJwtPayload, + isTokenExpired, + parseUrlParams, + buildUrl, +} from './utils'; +import { getStorage, type TokenStorage } from './storage'; + +const 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', +}; + +export class StuffleIAMClient { + private config: Required; + private storage: TokenStorage; + private discovery: OIDCDiscovery | null = null; + private refreshTimer: ReturnType | null = null; + private listeners: Set<(state: AuthState) => void> = new Set(); + + constructor(config: StuffleIAMConfig) { + 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(): Promise { + 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: LoginOptions = {}): Promise { + const discovery = await this.getDiscovery(); + + const state = options.state ?? generateRandomString(); + const nonce = options.nonce ?? generateRandomString(); + const scopes = [...this.config.scopes, ...(options.scopes ?? [])]; + + // Store state and nonce for callback validation + this.storage.set(STORAGE_KEYS.STATE, state); + this.storage.set(STORAGE_KEYS.NONCE, nonce); + + const params: Record = { + 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, + }; + + // Add PKCE if enabled + 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'; + } + + // Use signup endpoint if requested + 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: Omit = {}): Promise { + return this.login({ ...options, signup: true }); + } + + /** + * Handle callback from authorization server + */ + async handleCallback(url?: string): Promise { + const callbackUrl = url ?? window.location.href; + const params = parseUrlParams(callbackUrl); + + // Check for errors + if (params.error) { + return { + success: false, + error: params.error, + errorDescription: params.error_description, + }; + } + + // Validate state + 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', + }; + } + + // Exchange code for tokens + if (!params.code) { + return { + success: false, + error: 'missing_code', + errorDescription: 'No authorization code received', + }; + } + + try { + const tokens = await this.exchangeCode(params.code); + + // Validate nonce in ID token + if (tokens.id_token) { + const payload = decodeJwtPayload<{ nonce?: string }>(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', + }; + } + } + + // Store tokens + this.storeTokens(tokens); + + // Clear temporary storage + this.storage.remove(STORAGE_KEYS.STATE); + this.storage.remove(STORAGE_KEYS.NONCE); + this.storage.remove(STORAGE_KEYS.CODE_VERIFIER); + + // Get user info + const user = await this.fetchUserInfo(tokens.access_token); + + // Setup auto-refresh + if (this.config.autoRefresh && tokens.refresh_token) { + this.setupAutoRefresh(); + } + + // Notify listeners + this.notifyListeners(); + + return { + success: true, + user: user || undefined, + 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 + */ + private async exchangeCode(code: string): Promise { + const discovery = await this.getDiscovery(); + + const body: Record = { + grant_type: 'authorization_code', + client_id: this.config.clientId, + code, + redirect_uri: this.config.redirectUri, + }; + + // Add PKCE code verifier + 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(): Promise { + 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) { + // Refresh failed - clear tokens + this.clearTokens(); + this.notifyListeners(); + return null; + } + + const tokens: TokenResponse = await response.json(); + this.storeTokens(tokens); + this.notifyListeners(); + + return tokens; + } + + /** + * Logout - end session + */ + async logout(options: LogoutOptions = {}): Promise { + const discovery = await this.getDiscovery(); + const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN); + + // Clear local tokens first + this.clearTokens(); + this.notifyListeners(); + + // Redirect to end session endpoint if available + if (discovery.end_session_endpoint) { + const params: Record = { + post_logout_redirect_uri: options.returnTo ?? this.config.postLogoutRedirectUri, + id_token_hint: options.idTokenHint ?? idToken ?? undefined, + 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(): Promise { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + + if (!accessToken) return null; + + // Check if token needs refresh + 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(): User | null { + 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?: string): Promise { + 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: User = await response.json(); + this.storage.set(STORAGE_KEYS.USER, JSON.stringify(user)); + return user; + } + + /** + * Check if user is authenticated + */ + isAuthenticated(): boolean { + const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN); + if (!accessToken) return false; + + // Consider authenticated if token exists and not expired + return !isTokenExpired(accessToken); + } + + /** + * Get current auth state + */ + getAuthState(): AuthState { + 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: (state: AuthState) => void): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } + + /** + * Store tokens in storage + */ + private storeTokens(tokens: TokenResponse): void { + 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); + } + + // Store expiry time + const expiresAt = Date.now() + (tokens.expires_in * 1000); + this.storage.set(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString()); + } + + /** + * Clear all stored tokens + */ + private clearTokens(): void { + if (this.refreshTimer) { + clearTimeout(this.refreshTimer); + this.refreshTimer = null; + } + + this.storage.clear(); + } + + /** + * Setup auto-refresh timer + */ + private setupAutoRefresh(): void { + 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 * 1000); + const delay = refreshAt - Date.now(); + + if (delay > 0) { + this.refreshTimer = setTimeout(async () => { + await this.refreshToken(); + this.setupAutoRefresh(); + }, delay); + } + } + + /** + * Notify all listeners of state change + */ + private notifyListeners(): void { + const state = this.getAuthState(); + this.listeners.forEach(listener => listener(state)); + } +} + +/** + * Create a new Stuffle IAM client instance + */ +export function createStuffleIAMClient(config: StuffleIAMConfig): StuffleIAMClient { + return new StuffleIAMClient(config); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..9a50374 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,46 @@ +/** + * Stuffle IAM SDK + * + * JavaScript/TypeScript SDK for integrating with Stuffle IAM. + * Supports OIDC/OAuth2 authorization code flow with PKCE. + * + * @example + * ```ts + * import { createStuffleIAMClient } from '@stuffle/iam-sdk'; + * + * const iam = createStuffleIAMClient({ + * issuer: 'http://localhost:5000', + * clientId: 'my-app', + * redirectUri: 'http://localhost:3000/callback', + * }); + * + * // Start login + * await iam.login(); + * + * // Handle callback + * const result = await iam.handleCallback(); + * + * // Get user + * const user = iam.getUser(); + * ``` + */ + +export { StuffleIAMClient, createStuffleIAMClient } from './client'; +export type { + StuffleIAMConfig, + TokenResponse, + User, + AuthState, + LoginOptions, + LogoutOptions, + CallbackResult, + OIDCDiscovery, +} from './types'; +export { + generateRandomString, + generateCodeVerifier, + generateCodeChallenge, + decodeJwtPayload, + isTokenExpired, +} from './utils'; +export { getStorage, type TokenStorage } from './storage'; diff --git a/src/react/index.ts b/src/react/index.ts new file mode 100644 index 0000000..de9f754 --- /dev/null +++ b/src/react/index.ts @@ -0,0 +1,283 @@ +/** + * React bindings for Stuffle IAM SDK + * + * @example + * ```tsx + * import { StuffleIAMProvider, useAuth } from '@stuffle/iam-sdk/react'; + * + * function App() { + * return ( + * + * + * + * ); + * } + * + * function MyApp() { + * const { isAuthenticated, user, login, logout } = useAuth(); + * + * if (!isAuthenticated) { + * return ; + * } + * + * return ( + *
+ *

Welcome, {user?.name}

+ * + *
+ * ); + * } + * ``` + */ + +import { + createContext, + useContext, + useState, + useEffect, + useCallback, + useMemo, + type ReactNode, +} from 'react'; +import { + StuffleIAMClient, + createStuffleIAMClient, + type StuffleIAMConfig, + type AuthState, + type User, + type LoginOptions, + type LogoutOptions, + type CallbackResult, +} from '../index'; + +// ============================================================================= +// Context +// ============================================================================= + +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; +} + +const StuffleIAMContext = createContext(null); + +// ============================================================================= +// Provider +// ============================================================================= + +export 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; +} + +export function StuffleIAMProvider({ + children, + onLoginSuccess, + onLoginError, + autoHandleCallback = true, + ...config +}: StuffleIAMProviderProps) { + const [client] = useState(() => createStuffleIAMClient(config)); + const [state, setState] = useState(() => ({ + isAuthenticated: false, + isLoading: true, + user: null, + accessToken: null, + idToken: null, + error: null, + })); + + // Subscribe to auth state changes + useEffect(() => { + const unsubscribe = client.subscribe(setState); + + // Initialize state + const initialState = client.getAuthState(); + setState({ ...initialState, isLoading: false }); + + return unsubscribe; + }, [client]); + + // Auto-handle callback + 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); + // Clean URL + 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]); + + // Memoized methods + const login = useCallback( + (options?: LoginOptions) => client.login(options), + [client] + ); + + const signup = useCallback( + (options?: Omit) => client.signup(options), + [client] + ); + + const logout = useCallback( + (options?: LogoutOptions) => client.logout(options), + [client] + ); + + const handleCallback = useCallback( + (url?: string) => 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 ( + + {children} + + ); +} + +// ============================================================================= +// Hooks +// ============================================================================= + +/** + * Hook to access auth state and methods + */ +export function useAuth() { + const context = useContext(StuffleIAMContext); + if (!context) { + throw new Error('useAuth must be used within a StuffleIAMProvider'); + } + return context; +} + +/** + * Hook to get current user + */ +export function useUser(): User | null { + const { user } = useAuth(); + return user; +} + +/** + * Hook to check if user is authenticated + */ +export function useIsAuthenticated(): boolean { + const { isAuthenticated } = useAuth(); + return isAuthenticated; +} + +/** + * Hook to get access token (refreshes if needed) + */ +export function useAccessToken(): () => Promise { + const { getAccessToken } = useAuth(); + return getAccessToken; +} + +/** + * Hook to check if user has specific role(s) + */ +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)); +} + +/** + * Higher-order component for protected routes + */ +export 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[]; + } +) { + return function AuthenticatedComponent(props: P) { + const { isAuthenticated, isLoading, user } = useAuth(); + + if (isLoading) { + return options?.LoadingComponent ? : null; + } + + if (!isAuthenticated) { + return 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 ? : null; + } + } + + return ; + }; +} + +// Re-export types +export type { StuffleIAMConfig, AuthState, User, LoginOptions, LogoutOptions, CallbackResult }; diff --git a/src/storage.ts b/src/storage.ts new file mode 100644 index 0000000..2925806 --- /dev/null +++ b/src/storage.ts @@ -0,0 +1,129 @@ +/** + * Token storage abstraction + */ + +export interface TokenStorage { + get(key: string): string | null; + set(key: string, value: string): void; + remove(key: string): void; + clear(): void; +} + +const STORAGE_PREFIX = 'stuffle_iam_'; + +/** + * LocalStorage implementation + */ +export class LocalStorageAdapter implements TokenStorage { + get(key: string): string | null { + try { + return localStorage.getItem(STORAGE_PREFIX + key); + } catch { + return null; + } + } + + set(key: string, value: string): void { + try { + localStorage.setItem(STORAGE_PREFIX + key, value); + } catch { + console.warn('LocalStorage not available'); + } + } + + remove(key: string): void { + try { + localStorage.removeItem(STORAGE_PREFIX + key); + } catch { + // Ignore + } + } + + clear(): void { + try { + Object.keys(localStorage) + .filter(k => k.startsWith(STORAGE_PREFIX)) + .forEach(k => localStorage.removeItem(k)); + } catch { + // Ignore + } + } +} + +/** + * SessionStorage implementation + */ +export class SessionStorageAdapter implements TokenStorage { + get(key: string): string | null { + try { + return sessionStorage.getItem(STORAGE_PREFIX + key); + } catch { + return null; + } + } + + set(key: string, value: string): void { + try { + sessionStorage.setItem(STORAGE_PREFIX + key, value); + } catch { + console.warn('SessionStorage not available'); + } + } + + remove(key: string): void { + try { + sessionStorage.removeItem(STORAGE_PREFIX + key); + } catch { + // Ignore + } + } + + clear(): void { + try { + Object.keys(sessionStorage) + .filter(k => k.startsWith(STORAGE_PREFIX)) + .forEach(k => sessionStorage.removeItem(k)); + } catch { + // Ignore + } + } +} + +/** + * In-memory storage (for SSR or when storage is unavailable) + */ +export class MemoryStorageAdapter implements TokenStorage { + private store = new Map(); + + get(key: string): string | null { + return this.store.get(key) ?? null; + } + + set(key: string, value: string): void { + this.store.set(key, value); + } + + remove(key: string): void { + this.store.delete(key); + } + + clear(): void { + this.store.clear(); + } +} + +/** + * Get storage adapter based on type + */ +export function getStorage(type: 'localStorage' | 'sessionStorage' | 'memory'): TokenStorage { + switch (type) { + case 'localStorage': + return new LocalStorageAdapter(); + case 'sessionStorage': + return new SessionStorageAdapter(); + case 'memory': + return new MemoryStorageAdapter(); + default: + return new SessionStorageAdapter(); + } +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..472b49c --- /dev/null +++ b/src/types.ts @@ -0,0 +1,103 @@ +/** + * Stuffle IAM SDK Types + */ + +export 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; +} + +export interface TokenResponse { + access_token: string; + token_type: string; + expires_in: number; + refresh_token?: string; + id_token?: string; + scope?: string; +} + +export 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; +} + +export interface AuthState { + isAuthenticated: boolean; + isLoading: boolean; + user: User | null; + accessToken: string | null; + idToken: string | null; + error: Error | null; +} + +export 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; +} + +export interface LogoutOptions { + /** Post-logout redirect URI */ + returnTo?: string; + /** Include id_token_hint in logout request */ + idTokenHint?: string; +} + +export interface CallbackResult { + success: boolean; + user?: User; + accessToken?: string; + idToken?: string; + refreshToken?: string; + error?: string; + errorDescription?: string; +} + +export 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[]; +} diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..0aa3934 --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,112 @@ +/** + * Utility functions for PKCE and crypto operations + */ + +/** + * Generate a random string for state/nonce + */ +export function generateRandomString(length: number = 32): string { + const array = new Uint8Array(length); + crypto.getRandomValues(array); + return Array.from(array, byte => byte.toString(16).padStart(2, '0')).join(''); +} + +/** + * Generate PKCE code verifier (43-128 characters) + */ +export function generateCodeVerifier(): string { + const array = new Uint8Array(32); + crypto.getRandomValues(array); + return base64UrlEncode(array); +} + +/** + * Generate PKCE code challenge from verifier + */ +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const digest = await crypto.subtle.digest('SHA-256', data); + return base64UrlEncode(new Uint8Array(digest)); +} + +/** + * Base64 URL encode (no padding, URL-safe characters) + */ +export function base64UrlEncode(buffer: Uint8Array): string { + let binary = ''; + for (let i = 0; i < buffer.length; i++) { + binary += String.fromCharCode(buffer[i]); + } + return btoa(binary) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); +} + +/** + * Decode JWT payload (without verification) + */ +export function decodeJwtPayload>(token: string): T | null { + 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) as T; + } catch { + return null; + } +} + +/** + * Check if token is expired + */ +export function isTokenExpired(token: string, thresholdSeconds: number = 0): boolean { + const payload = decodeJwtPayload<{ exp?: number }>(token); + if (!payload?.exp) return true; + + const now = Math.floor(Date.now() / 1000); + return payload.exp - thresholdSeconds <= now; +} + +/** + * Parse URL hash or query parameters + */ +export function parseUrlParams(url: string): Record { + const params: Record = {}; + + // Check hash first (implicit flow), then query (code flow) + 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; +} + +/** + * Build URL with query parameters + */ +export function buildUrl(base: string, params: Record): string { + const url = new URL(base); + Object.entries(params).forEach(([key, value]) => { + if (value !== undefined && value !== null) { + url.searchParams.append(key, value); + } + }); + return url.toString(); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..5460327 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "ESNext", + "moduleResolution": "bundler", + "lib": ["ES2020", "DOM"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "declaration": true, + "declarationMap": true, + "outDir": "./dist", + "rootDir": "./src", + "jsx": "react-jsx" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/tsup.config.ts b/tsup.config.ts new file mode 100644 index 0000000..731d7b2 --- /dev/null +++ b/tsup.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup' + +export default defineConfig({ + entry: { + index: 'src/index.ts', + react: 'src/react/index.ts', + }, + format: ['cjs', 'esm'], + dts: true, + clean: true, + splitting: false, + sourcemap: true, + external: ['react'], +})