@avantmedia/id-react 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@avantmedia/id-react",
3
+ "version": "0.0.1",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "types": "./dist/index.d.ts",
7
+ "private": false,
8
+ "publishConfig": {
9
+ "access": "public"
10
+ },
11
+ "exports": {
12
+ ".": {
13
+ "import": "./dist/index.js",
14
+ "types": "./dist/index.d.ts"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "tsc",
19
+ "dev": "tsc --watch"
20
+ },
21
+ "peerDependencies": {
22
+ "react": "^18.0.0 || ^19.0.0"
23
+ },
24
+ "devDependencies": {
25
+ "@types/react": "^18.3.0",
26
+ "react": "^18.3.0",
27
+ "typescript": "^5.7.3"
28
+ }
29
+ }
package/src/hooks.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { useAuthContext } from "./provider";
2
+ import type { AuthState } from "./types";
3
+
4
+ /**
5
+ * Hook to access auth state and actions
6
+ *
7
+ * @example
8
+ * ```tsx
9
+ * function App() {
10
+ * const { user, isLoading, login, logout } = useAuth();
11
+ *
12
+ * if (isLoading) return <div>Loading...</div>;
13
+ * if (!user) return <button onClick={login}>Login</button>;
14
+ *
15
+ * return (
16
+ * <div>
17
+ * Hello {user.email}!
18
+ * <button onClick={logout}>Logout</button>
19
+ * </div>
20
+ * );
21
+ * }
22
+ * ```
23
+ */
24
+ export function useAuth(): AuthState {
25
+ const { config, ...authState } = useAuthContext();
26
+ return authState;
27
+ }
package/src/index.ts ADDED
@@ -0,0 +1,19 @@
1
+ // Components
2
+ export { AvantIdProvider } from "./provider";
3
+
4
+ // Hooks
5
+ export { useAuth } from "./hooks";
6
+
7
+ // Types
8
+ export type {
9
+ AvantIdConfig,
10
+ User,
11
+ TokenResponse,
12
+ AuthState,
13
+ } from "./types";
14
+
15
+ // PKCE utilities (for advanced use cases)
16
+ export {
17
+ generateCodeVerifier,
18
+ generateCodeChallenge,
19
+ } from "./pkce";
package/src/pkce.ts ADDED
@@ -0,0 +1,78 @@
1
+ /**
2
+ * PKCE (Proof Key for Code Exchange) utilities
3
+ * Used to secure the OAuth2 authorization code flow for SPAs
4
+ */
5
+
6
+ /**
7
+ * Generate a cryptographically random code verifier
8
+ * Must be between 43 and 128 characters
9
+ */
10
+ export function generateCodeVerifier(): string {
11
+ const array = new Uint8Array(32);
12
+ crypto.getRandomValues(array);
13
+ return base64UrlEncode(array);
14
+ }
15
+
16
+ /**
17
+ * Generate code challenge from verifier using S256 method
18
+ * SHA256 hash of verifier, base64url encoded
19
+ */
20
+ export async function generateCodeChallenge(verifier: string): Promise<string> {
21
+ const encoder = new TextEncoder();
22
+ const data = encoder.encode(verifier);
23
+ const hash = await crypto.subtle.digest("SHA-256", data);
24
+ return base64UrlEncode(new Uint8Array(hash));
25
+ }
26
+
27
+ /**
28
+ * Base64url encode (URL-safe base64 without padding)
29
+ */
30
+ function base64UrlEncode(buffer: Uint8Array): string {
31
+ const base64 = btoa(String.fromCharCode(...buffer));
32
+ return base64
33
+ .replace(/\+/g, "-")
34
+ .replace(/\//g, "_")
35
+ .replace(/=+$/, "");
36
+ }
37
+
38
+ /**
39
+ * Storage keys for PKCE state
40
+ */
41
+ const VERIFIER_KEY = "avant_id_pkce_verifier";
42
+ const STATE_KEY = "avant_id_oauth_state";
43
+
44
+ /**
45
+ * Store code verifier in sessionStorage for later retrieval
46
+ */
47
+ export function storeCodeVerifier(verifier: string): void {
48
+ sessionStorage.setItem(VERIFIER_KEY, verifier);
49
+ }
50
+
51
+ /**
52
+ * Retrieve and clear stored code verifier
53
+ */
54
+ export function retrieveCodeVerifier(): string | null {
55
+ const verifier = sessionStorage.getItem(VERIFIER_KEY);
56
+ sessionStorage.removeItem(VERIFIER_KEY);
57
+ return verifier;
58
+ }
59
+
60
+ /**
61
+ * Generate and store OAuth state parameter for CSRF protection
62
+ */
63
+ export function generateState(): string {
64
+ const array = new Uint8Array(16);
65
+ crypto.getRandomValues(array);
66
+ const state = base64UrlEncode(array);
67
+ sessionStorage.setItem(STATE_KEY, state);
68
+ return state;
69
+ }
70
+
71
+ /**
72
+ * Verify state parameter matches stored value
73
+ */
74
+ export function verifyState(state: string): boolean {
75
+ const storedState = sessionStorage.getItem(STATE_KEY);
76
+ sessionStorage.removeItem(STATE_KEY);
77
+ return storedState === state;
78
+ }
@@ -0,0 +1,282 @@
1
+ import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from "react";
2
+ import type { AvantIdConfig, User, TokenResponse, AuthContextState } from "./types";
3
+ import {
4
+ generateCodeVerifier,
5
+ generateCodeChallenge,
6
+ storeCodeVerifier,
7
+ retrieveCodeVerifier,
8
+ generateState,
9
+ verifyState,
10
+ } from "./pkce";
11
+
12
+ const AuthContext = createContext<AuthContextState | null>(null);
13
+
14
+ // Token storage keys
15
+ const ACCESS_TOKEN_KEY = "avant_id_access_token";
16
+ const REFRESH_TOKEN_KEY = "avant_id_refresh_token";
17
+ const TOKEN_EXPIRY_KEY = "avant_id_token_expiry";
18
+
19
+ interface AvantIdProviderProps {
20
+ children: ReactNode;
21
+ config: AvantIdConfig;
22
+ }
23
+
24
+ export function AvantIdProvider({ children, config }: AvantIdProviderProps) {
25
+ const [user, setUser] = useState<User | null>(null);
26
+ const [isLoading, setIsLoading] = useState(true);
27
+ const [accessToken, setAccessToken] = useState<string | null>(null);
28
+ const [refreshToken, setRefreshToken] = useState<string | null>(null);
29
+ const [tokenExpiry, setTokenExpiry] = useState<number | null>(null);
30
+
31
+ // Normalize domain to ensure it has protocol
32
+ const baseUrl = config.domain.startsWith("http")
33
+ ? config.domain
34
+ : `https://${config.domain}`;
35
+
36
+ // Default redirect URI
37
+ const redirectUri = config.redirectUri || `${window.location.origin}/callback`;
38
+
39
+ // Store tokens
40
+ const storeTokens = useCallback((tokens: TokenResponse) => {
41
+ const expiry = Date.now() + tokens.expires_in * 1000;
42
+
43
+ setAccessToken(tokens.access_token);
44
+ setRefreshToken(tokens.refresh_token);
45
+ setTokenExpiry(expiry);
46
+
47
+ // Always store access token in memory, optionally persist refresh token
48
+ if (config.persistSession) {
49
+ localStorage.setItem(REFRESH_TOKEN_KEY, tokens.refresh_token);
50
+ localStorage.setItem(TOKEN_EXPIRY_KEY, expiry.toString());
51
+ }
52
+ }, [config.persistSession]);
53
+
54
+ // Clear tokens
55
+ const clearTokens = useCallback(() => {
56
+ setAccessToken(null);
57
+ setRefreshToken(null);
58
+ setTokenExpiry(null);
59
+ setUser(null);
60
+
61
+ localStorage.removeItem(ACCESS_TOKEN_KEY);
62
+ localStorage.removeItem(REFRESH_TOKEN_KEY);
63
+ localStorage.removeItem(TOKEN_EXPIRY_KEY);
64
+ }, []);
65
+
66
+ // Fetch user profile with access token
67
+ const fetchUser = useCallback(async (token: string): Promise<User | null> => {
68
+ try {
69
+ const response = await fetch(`${baseUrl}/auth/me`, {
70
+ headers: { Authorization: `Bearer ${token}` },
71
+ });
72
+
73
+ if (!response.ok) {
74
+ return null;
75
+ }
76
+
77
+ return await response.json();
78
+ } catch {
79
+ return null;
80
+ }
81
+ }, [baseUrl]);
82
+
83
+ // Refresh access token using refresh token
84
+ const refreshAccessToken = useCallback(async (token: string): Promise<TokenResponse | null> => {
85
+ try {
86
+ const response = await fetch(`${baseUrl}/auth/refresh`, {
87
+ method: "POST",
88
+ headers: { "Content-Type": "application/json" },
89
+ body: JSON.stringify({ refreshToken: token }),
90
+ });
91
+
92
+ if (!response.ok) {
93
+ return null;
94
+ }
95
+
96
+ return await response.json();
97
+ } catch {
98
+ return null;
99
+ }
100
+ }, [baseUrl]);
101
+
102
+ // Get access token, refreshing if needed
103
+ const getAccessToken = useCallback(async (): Promise<string | null> => {
104
+ // Check if current token is still valid (with 30s buffer)
105
+ if (accessToken && tokenExpiry && Date.now() < tokenExpiry - 30000) {
106
+ return accessToken;
107
+ }
108
+
109
+ // Try to refresh
110
+ if (refreshToken) {
111
+ const tokens = await refreshAccessToken(refreshToken);
112
+ if (tokens) {
113
+ storeTokens(tokens);
114
+ return tokens.access_token;
115
+ }
116
+ }
117
+
118
+ // No valid token
119
+ clearTokens();
120
+ return null;
121
+ }, [accessToken, tokenExpiry, refreshToken, refreshAccessToken, storeTokens, clearTokens]);
122
+
123
+ // Exchange authorization code for tokens
124
+ const exchangeCode = useCallback(async (code: string, codeVerifier: string): Promise<boolean> => {
125
+ try {
126
+ const response = await fetch(`${baseUrl}/oauth/token`, {
127
+ method: "POST",
128
+ headers: { "Content-Type": "application/json" },
129
+ body: JSON.stringify({
130
+ grant_type: "authorization_code",
131
+ code,
132
+ code_verifier: codeVerifier,
133
+ client_id: config.clientId,
134
+ redirect_uri: redirectUri,
135
+ }),
136
+ });
137
+
138
+ if (!response.ok) {
139
+ console.error("Token exchange failed:", await response.text());
140
+ return false;
141
+ }
142
+
143
+ const tokens: TokenResponse = await response.json();
144
+ storeTokens(tokens);
145
+
146
+ // Fetch user profile
147
+ const userProfile = await fetchUser(tokens.access_token);
148
+ if (userProfile) {
149
+ setUser(userProfile);
150
+ }
151
+
152
+ return true;
153
+ } catch (err) {
154
+ console.error("Token exchange error:", err);
155
+ return false;
156
+ }
157
+ }, [baseUrl, config.clientId, redirectUri, storeTokens, fetchUser]);
158
+
159
+ // Login - redirect to Avant ID
160
+ const login = useCallback(async () => {
161
+ const verifier = generateCodeVerifier();
162
+ const challenge = await generateCodeChallenge(verifier);
163
+ const state = generateState();
164
+
165
+ // Store verifier for later
166
+ storeCodeVerifier(verifier);
167
+
168
+ // Build authorization URL
169
+ const params = new URLSearchParams({
170
+ client_id: config.clientId,
171
+ redirect_uri: redirectUri,
172
+ response_type: "code",
173
+ code_challenge: challenge,
174
+ code_challenge_method: "S256",
175
+ state,
176
+ });
177
+
178
+ window.location.href = `${baseUrl}/oauth/authorize?${params.toString()}`;
179
+ }, [config.clientId, redirectUri, baseUrl]);
180
+
181
+ // Logout
182
+ const logout = useCallback(async () => {
183
+ // Optionally call logout endpoint to revoke refresh token
184
+ if (refreshToken) {
185
+ try {
186
+ await fetch(`${baseUrl}/auth/logout`, {
187
+ method: "POST",
188
+ headers: { "Content-Type": "application/json" },
189
+ body: JSON.stringify({ refreshToken }),
190
+ });
191
+ } catch {
192
+ // Ignore errors, still clear local state
193
+ }
194
+ }
195
+
196
+ clearTokens();
197
+ }, [refreshToken, baseUrl, clearTokens]);
198
+
199
+ // Handle OAuth callback on mount
200
+ useEffect(() => {
201
+ const handleCallback = async () => {
202
+ const params = new URLSearchParams(window.location.search);
203
+ const code = params.get("code");
204
+ const state = params.get("state");
205
+ const error = params.get("error");
206
+
207
+ if (error) {
208
+ console.error("OAuth error:", error, params.get("error_description"));
209
+ setIsLoading(false);
210
+ return;
211
+ }
212
+
213
+ if (code && state) {
214
+ // Verify state
215
+ if (!verifyState(state)) {
216
+ console.error("State mismatch - possible CSRF attack");
217
+ setIsLoading(false);
218
+ return;
219
+ }
220
+
221
+ // Get stored code verifier
222
+ const codeVerifier = retrieveCodeVerifier();
223
+ if (!codeVerifier) {
224
+ console.error("No code verifier found");
225
+ setIsLoading(false);
226
+ return;
227
+ }
228
+
229
+ // Exchange code for tokens
230
+ const success = await exchangeCode(code, codeVerifier);
231
+
232
+ // Clean up URL
233
+ window.history.replaceState({}, "", window.location.pathname);
234
+
235
+ setIsLoading(false);
236
+ return;
237
+ }
238
+
239
+ // No callback params - check for existing session
240
+ if (config.persistSession) {
241
+ const storedRefresh = localStorage.getItem(REFRESH_TOKEN_KEY);
242
+ if (storedRefresh) {
243
+ setRefreshToken(storedRefresh);
244
+ const tokens = await refreshAccessToken(storedRefresh);
245
+ if (tokens) {
246
+ storeTokens(tokens);
247
+ const userProfile = await fetchUser(tokens.access_token);
248
+ if (userProfile) {
249
+ setUser(userProfile);
250
+ }
251
+ } else {
252
+ clearTokens();
253
+ }
254
+ }
255
+ }
256
+
257
+ setIsLoading(false);
258
+ };
259
+
260
+ handleCallback();
261
+ }, [config.persistSession, exchangeCode, refreshAccessToken, storeTokens, fetchUser, clearTokens]);
262
+
263
+ const value: AuthContextState = {
264
+ user,
265
+ isLoading,
266
+ isAuthenticated: !!user,
267
+ login,
268
+ logout,
269
+ getAccessToken,
270
+ config,
271
+ };
272
+
273
+ return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
274
+ }
275
+
276
+ export function useAuthContext(): AuthContextState {
277
+ const context = useContext(AuthContext);
278
+ if (!context) {
279
+ throw new Error("useAuth must be used within an AvantIdProvider");
280
+ }
281
+ return context;
282
+ }
package/src/types.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Configuration for the AvantIdProvider
3
+ */
4
+ export interface AvantIdConfig {
5
+ /** The domain of the Avant ID server (e.g., "auth.example.com" or "http://localhost:16000") */
6
+ domain: string;
7
+ /** The client ID of your application */
8
+ clientId: string;
9
+ /** The redirect URI for OAuth callbacks (defaults to current origin + /callback) */
10
+ redirectUri?: string;
11
+ /** Whether to persist refresh token in localStorage (default: false) */
12
+ persistSession?: boolean;
13
+ }
14
+
15
+ /**
16
+ * User object returned by the auth context
17
+ */
18
+ export interface User {
19
+ id: string;
20
+ email: string;
21
+ name?: string;
22
+ emailVerified?: boolean;
23
+ permissions?: Record<string, string>;
24
+ }
25
+
26
+ /**
27
+ * Token response from the OAuth token endpoint
28
+ */
29
+ export interface TokenResponse {
30
+ access_token: string;
31
+ refresh_token: string;
32
+ token_type: string;
33
+ expires_in: number;
34
+ }
35
+
36
+ /**
37
+ * Auth state exposed by useAuth hook
38
+ */
39
+ export interface AuthState {
40
+ /** Current user or null if not authenticated */
41
+ user: User | null;
42
+ /** Whether the SDK is initializing/checking auth state */
43
+ isLoading: boolean;
44
+ /** Whether the user is authenticated */
45
+ isAuthenticated: boolean;
46
+ /** Redirect to Avant ID login page */
47
+ login: () => Promise<void>;
48
+ /** Clear local auth state and optionally revoke tokens */
49
+ logout: () => Promise<void>;
50
+ /** Get the current access token (refreshes if expired) */
51
+ getAccessToken: () => Promise<string | null>;
52
+ }
53
+
54
+ /**
55
+ * Internal auth context state
56
+ */
57
+ export interface AuthContextState extends AuthState {
58
+ config: AvantIdConfig;
59
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,10 @@
1
+ {
2
+ "extends": "../../tsconfig.json",
3
+ "compilerOptions": {
4
+ "outDir": "./dist",
5
+ "rootDir": "./src",
6
+ "jsx": "react-jsx",
7
+ "lib": ["ES2020", "DOM", "DOM.Iterable"]
8
+ },
9
+ "include": ["src/**/*"]
10
+ }