First commit
Some checks failed
armco-org/iam-client-sdk/pipeline/head There was a failure building this commit
Some checks failed
armco-org/iam-client-sdk/pipeline/head There was a failure building this commit
This commit is contained in:
7
Jenkinsfile
vendored
Normal file
7
Jenkinsfile
vendored
Normal 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
181
README.md
Normal 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
58
package.json
Normal 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
468
src/client.ts
Normal 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
46
src/index.ts
Normal 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
283
src/react/index.ts
Normal 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
129
src/storage.ts
Normal 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
103
src/types.ts
Normal 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
112
src/utils.ts
Normal 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
18
tsconfig.json
Normal 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
14
tsup.config.ts
Normal 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'],
|
||||
})
|
||||
Reference in New Issue
Block a user