First commit
Some checks failed
armco-org/iam-client-sdk/pipeline/head There was a failure building this commit

This commit is contained in:
2025-12-28 18:59:30 +05:30
commit f7a5ee30b5
12 changed files with 1419 additions and 0 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

7
Jenkinsfile vendored Normal file
View File

@@ -0,0 +1,7 @@
@Library('jenkins-shared') _
kanikoPipeline(
repoName: 'iam-client-sdk',
branch: env.BRANCH_NAME ?: 'main',
isNpmLib: true
)

181
README.md Normal file
View File

@@ -0,0 +1,181 @@
# @armco/iam-client
Browser/SPA client for IAM - OIDC/OAuth2 authentication with PKCE.
## Installation
```bash
npm install @armco/iam-client
```
## Quick Start
### Vanilla JavaScript/TypeScript
```typescript
import { createIAMClient } from '@armco/iam-client';
const iam = createIAMClient({
issuer: 'http://localhost:5000',
clientId: 'my-app',
redirectUri: 'http://localhost:3000/callback',
scopes: ['openid', 'profile', 'email', 'offline_access'],
});
// Login
document.getElementById('login-btn').onclick = () => 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 (
<IAMProvider
issuer="http://localhost:5000"
clientId="my-app"
redirectUri="http://localhost:3000/callback"
onLoginSuccess={(result) => {
console.log('Logged in:', result.user);
// Navigate to dashboard
}}
>
<Router>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/callback" element={<Callback />} />
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Router>
</StuffleIAMProvider>
);
}
// Use the hook
function Home() {
const { isAuthenticated, isLoading, user, login, signup, logout } = useAuth();
if (isLoading) return <div>Loading...</div>;
if (!isAuthenticated) {
return (
<div>
<button onClick={() => login()}>Login</button>
<button onClick={() => signup()}>Sign Up</button>
</div>
);
}
return (
<div>
<p>Welcome, {user?.name || user?.email}</p>
<button onClick={() => logout()}>Logout</button>
</div>
);
}
// Callback page (auto-handled by provider)
function Callback() {
const { isLoading, error } = useAuth();
if (isLoading) return <div>Processing login...</div>;
if (error) return <div>Error: {error.message}</div>;
return <Navigate to="/dashboard" />;
}
```
## 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: () => <div>Loading...</div>,
UnauthorizedComponent: () => <div>Please login</div>,
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
```

58
package.json Normal file
View File

@@ -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"
}
}

468
src/client.ts Normal file
View File

@@ -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<StuffleIAMConfig>;
private storage: TokenStorage;
private discovery: OIDCDiscovery | null = null;
private refreshTimer: ReturnType<typeof setTimeout> | 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<OIDCDiscovery> {
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<void> {
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<string, string | undefined> = {
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<LoginOptions, 'signup'> = {}): Promise<void> {
return this.login({ ...options, signup: true });
}
/**
* Handle callback from authorization server
*/
async handleCallback(url?: string): Promise<CallbackResult> {
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<TokenResponse> {
const discovery = await this.getDiscovery();
const body: Record<string, string> = {
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<TokenResponse | null> {
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<void> {
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<string, string | undefined> = {
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<string | null> {
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<User>(idToken);
}
/**
* Fetch user info from userinfo endpoint
*/
async fetchUserInfo(accessToken?: string): Promise<User | null> {
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);
}

46
src/index.ts Normal file
View File

@@ -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';

283
src/react/index.ts Normal file
View File

@@ -0,0 +1,283 @@
/**
* React bindings for Stuffle IAM SDK
*
* @example
* ```tsx
* import { StuffleIAMProvider, useAuth } from '@stuffle/iam-sdk/react';
*
* function App() {
* return (
* <StuffleIAMProvider
* issuer="http://localhost:5000"
* clientId="my-app"
* redirectUri="http://localhost:3000/callback"
* >
* <MyApp />
* </StuffleIAMProvider>
* );
* }
*
* function MyApp() {
* const { isAuthenticated, user, login, logout } = useAuth();
*
* if (!isAuthenticated) {
* return <button onClick={() => login()}>Login</button>;
* }
*
* return (
* <div>
* <p>Welcome, {user?.name}</p>
* <button onClick={() => logout()}>Logout</button>
* </div>
* );
* }
* ```
*/
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<void>;
signup: (options?: Omit<LoginOptions, 'signup'>) => Promise<void>;
logout: (options?: LogoutOptions) => Promise<void>;
handleCallback: (url?: string) => Promise<CallbackResult>;
getAccessToken: () => Promise<string | null>;
}
const StuffleIAMContext = createContext<StuffleIAMContextValue | null>(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<AuthState>(() => ({
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<LoginOptions, 'signup'>) => 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<StuffleIAMContextValue>(
() => ({
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 (
<StuffleIAMContext.Provider value={value}>
{children}
</StuffleIAMContext.Provider>
);
}
// =============================================================================
// 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<string | null> {
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<P extends object>(
WrappedComponent: React.ComponentType<P>,
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 ? <options.LoadingComponent /> : null;
}
if (!isAuthenticated) {
return options?.UnauthorizedComponent ? <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 ? <options.UnauthorizedComponent /> : null;
}
}
return <WrappedComponent {...props} />;
};
}
// Re-export types
export type { StuffleIAMConfig, AuthState, User, LoginOptions, LogoutOptions, CallbackResult };

129
src/storage.ts Normal file
View File

@@ -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<string, string>();
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();
}
}

103
src/types.ts Normal file
View File

@@ -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[];
}

112
src/utils.ts Normal file
View File

@@ -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<string> {
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<T = Record<string, unknown>>(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<string, string> {
const params: Record<string, string> = {};
// 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, string | undefined>): 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();
}

18
tsconfig.json Normal file
View File

@@ -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"]
}

14
tsup.config.ts Normal file
View File

@@ -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'],
})