@armco/iam-client 0.1.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/README.md +181 -0
- package/dist/client-CTKWBZ26.d.mts +185 -0
- package/dist/client-CTKWBZ26.d.ts +185 -0
- package/dist/index.d.mts +41 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +545 -0
- package/dist/index.js.map +1 -0
- package/dist/index.mjs +511 -0
- package/dist/index.mjs.map +1 -0
- package/dist/react.d.mts +61 -0
- package/dist/react.d.ts +61 -0
- package/dist/react.js +673 -0
- package/dist/react.js.map +1 -0
- package/dist/react.mjs +649 -0
- package/dist/react.mjs.map +1 -0
- package/package.json +60 -0
package/dist/react.mjs
ADDED
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
// src/react/index.tsx
|
|
2
|
+
import {
|
|
3
|
+
createContext,
|
|
4
|
+
useContext,
|
|
5
|
+
useState,
|
|
6
|
+
useEffect,
|
|
7
|
+
useCallback,
|
|
8
|
+
useMemo
|
|
9
|
+
} from "react";
|
|
10
|
+
|
|
11
|
+
// src/utils.ts
|
|
12
|
+
function generateRandomString(length = 32) {
|
|
13
|
+
const array = new Uint8Array(length);
|
|
14
|
+
crypto.getRandomValues(array);
|
|
15
|
+
return Array.from(array, (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
16
|
+
}
|
|
17
|
+
function generateCodeVerifier() {
|
|
18
|
+
const array = new Uint8Array(32);
|
|
19
|
+
crypto.getRandomValues(array);
|
|
20
|
+
return base64UrlEncode(array);
|
|
21
|
+
}
|
|
22
|
+
async function generateCodeChallenge(verifier) {
|
|
23
|
+
const encoder = new TextEncoder();
|
|
24
|
+
const data = encoder.encode(verifier);
|
|
25
|
+
const digest = await crypto.subtle.digest("SHA-256", data);
|
|
26
|
+
return base64UrlEncode(new Uint8Array(digest));
|
|
27
|
+
}
|
|
28
|
+
function base64UrlEncode(buffer) {
|
|
29
|
+
let binary = "";
|
|
30
|
+
for (let i = 0; i < buffer.length; i++) {
|
|
31
|
+
binary += String.fromCharCode(buffer[i]);
|
|
32
|
+
}
|
|
33
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
34
|
+
}
|
|
35
|
+
function decodeJwtPayload(token) {
|
|
36
|
+
try {
|
|
37
|
+
const parts = token.split(".");
|
|
38
|
+
if (parts.length !== 3) return null;
|
|
39
|
+
const payload = parts[1];
|
|
40
|
+
const decoded = atob(payload.replace(/-/g, "+").replace(/_/g, "/"));
|
|
41
|
+
return JSON.parse(decoded);
|
|
42
|
+
} catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
function isTokenExpired(token, thresholdSeconds = 0) {
|
|
47
|
+
const payload = decodeJwtPayload(token);
|
|
48
|
+
if (!payload?.exp) return true;
|
|
49
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
50
|
+
return payload.exp - thresholdSeconds <= now;
|
|
51
|
+
}
|
|
52
|
+
function parseUrlParams(url) {
|
|
53
|
+
const params = {};
|
|
54
|
+
const hashIndex = url.indexOf("#");
|
|
55
|
+
const queryIndex = url.indexOf("?");
|
|
56
|
+
let paramString = "";
|
|
57
|
+
if (hashIndex !== -1) {
|
|
58
|
+
paramString = url.substring(hashIndex + 1);
|
|
59
|
+
} else if (queryIndex !== -1) {
|
|
60
|
+
paramString = url.substring(queryIndex + 1);
|
|
61
|
+
}
|
|
62
|
+
if (!paramString) return params;
|
|
63
|
+
const searchParams = new URLSearchParams(paramString);
|
|
64
|
+
searchParams.forEach((value, key) => {
|
|
65
|
+
params[key] = value;
|
|
66
|
+
});
|
|
67
|
+
return params;
|
|
68
|
+
}
|
|
69
|
+
function buildUrl(base, params) {
|
|
70
|
+
const url = new URL(base);
|
|
71
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
72
|
+
if (value !== void 0 && value !== null) {
|
|
73
|
+
url.searchParams.append(key, value);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
return url.toString();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// src/storage.ts
|
|
80
|
+
var STORAGE_PREFIX = "stuffle_iam_";
|
|
81
|
+
var LocalStorageAdapter = class {
|
|
82
|
+
get(key) {
|
|
83
|
+
try {
|
|
84
|
+
return localStorage.getItem(STORAGE_PREFIX + key);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
set(key, value) {
|
|
90
|
+
try {
|
|
91
|
+
localStorage.setItem(STORAGE_PREFIX + key, value);
|
|
92
|
+
} catch {
|
|
93
|
+
console.warn("LocalStorage not available");
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
remove(key) {
|
|
97
|
+
try {
|
|
98
|
+
localStorage.removeItem(STORAGE_PREFIX + key);
|
|
99
|
+
} catch {
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
clear() {
|
|
103
|
+
try {
|
|
104
|
+
Object.keys(localStorage).filter((k) => k.startsWith(STORAGE_PREFIX)).forEach((k) => localStorage.removeItem(k));
|
|
105
|
+
} catch {
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
};
|
|
109
|
+
var SessionStorageAdapter = class {
|
|
110
|
+
get(key) {
|
|
111
|
+
try {
|
|
112
|
+
return sessionStorage.getItem(STORAGE_PREFIX + key);
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
set(key, value) {
|
|
118
|
+
try {
|
|
119
|
+
sessionStorage.setItem(STORAGE_PREFIX + key, value);
|
|
120
|
+
} catch {
|
|
121
|
+
console.warn("SessionStorage not available");
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
remove(key) {
|
|
125
|
+
try {
|
|
126
|
+
sessionStorage.removeItem(STORAGE_PREFIX + key);
|
|
127
|
+
} catch {
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
clear() {
|
|
131
|
+
try {
|
|
132
|
+
Object.keys(sessionStorage).filter((k) => k.startsWith(STORAGE_PREFIX)).forEach((k) => sessionStorage.removeItem(k));
|
|
133
|
+
} catch {
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
var MemoryStorageAdapter = class {
|
|
138
|
+
constructor() {
|
|
139
|
+
this.store = /* @__PURE__ */ new Map();
|
|
140
|
+
}
|
|
141
|
+
get(key) {
|
|
142
|
+
return this.store.get(key) ?? null;
|
|
143
|
+
}
|
|
144
|
+
set(key, value) {
|
|
145
|
+
this.store.set(key, value);
|
|
146
|
+
}
|
|
147
|
+
remove(key) {
|
|
148
|
+
this.store.delete(key);
|
|
149
|
+
}
|
|
150
|
+
clear() {
|
|
151
|
+
this.store.clear();
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
function getStorage(type) {
|
|
155
|
+
switch (type) {
|
|
156
|
+
case "localStorage":
|
|
157
|
+
return new LocalStorageAdapter();
|
|
158
|
+
case "sessionStorage":
|
|
159
|
+
return new SessionStorageAdapter();
|
|
160
|
+
case "memory":
|
|
161
|
+
return new MemoryStorageAdapter();
|
|
162
|
+
default:
|
|
163
|
+
return new SessionStorageAdapter();
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// src/client.ts
|
|
168
|
+
var STORAGE_KEYS = {
|
|
169
|
+
ACCESS_TOKEN: "access_token",
|
|
170
|
+
REFRESH_TOKEN: "refresh_token",
|
|
171
|
+
ID_TOKEN: "id_token",
|
|
172
|
+
CODE_VERIFIER: "code_verifier",
|
|
173
|
+
STATE: "state",
|
|
174
|
+
NONCE: "nonce",
|
|
175
|
+
USER: "user",
|
|
176
|
+
EXPIRES_AT: "expires_at"
|
|
177
|
+
};
|
|
178
|
+
var StuffleIAMClient = class {
|
|
179
|
+
constructor(config) {
|
|
180
|
+
this.discovery = null;
|
|
181
|
+
this.refreshTimer = null;
|
|
182
|
+
this.listeners = /* @__PURE__ */ new Set();
|
|
183
|
+
this.config = {
|
|
184
|
+
issuer: config.issuer.replace(/\/$/, ""),
|
|
185
|
+
// Remove trailing slash
|
|
186
|
+
clientId: config.clientId,
|
|
187
|
+
redirectUri: config.redirectUri,
|
|
188
|
+
scopes: config.scopes ?? ["openid", "profile", "email"],
|
|
189
|
+
postLogoutRedirectUri: config.postLogoutRedirectUri ?? config.redirectUri,
|
|
190
|
+
usePkce: config.usePkce ?? true,
|
|
191
|
+
storage: config.storage ?? "sessionStorage",
|
|
192
|
+
autoRefresh: config.autoRefresh ?? true,
|
|
193
|
+
refreshThreshold: config.refreshThreshold ?? 60
|
|
194
|
+
};
|
|
195
|
+
this.storage = getStorage(this.config.storage);
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Fetch OIDC discovery document
|
|
199
|
+
*/
|
|
200
|
+
async getDiscovery() {
|
|
201
|
+
if (this.discovery) return this.discovery;
|
|
202
|
+
const response = await fetch(
|
|
203
|
+
`${this.config.issuer}/.well-known/openid-configuration`
|
|
204
|
+
);
|
|
205
|
+
if (!response.ok) {
|
|
206
|
+
throw new Error(`Failed to fetch OIDC discovery: ${response.status}`);
|
|
207
|
+
}
|
|
208
|
+
this.discovery = await response.json();
|
|
209
|
+
return this.discovery;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Start login flow - redirects to authorization endpoint
|
|
213
|
+
*/
|
|
214
|
+
async login(options = {}) {
|
|
215
|
+
const discovery = await this.getDiscovery();
|
|
216
|
+
const state = options.state ?? generateRandomString();
|
|
217
|
+
const nonce = options.nonce ?? generateRandomString();
|
|
218
|
+
const scopes = [...this.config.scopes, ...options.scopes ?? []];
|
|
219
|
+
this.storage.set(STORAGE_KEYS.STATE, state);
|
|
220
|
+
this.storage.set(STORAGE_KEYS.NONCE, nonce);
|
|
221
|
+
const params = {
|
|
222
|
+
client_id: this.config.clientId,
|
|
223
|
+
redirect_uri: this.config.redirectUri,
|
|
224
|
+
response_type: "code",
|
|
225
|
+
scope: scopes.join(" "),
|
|
226
|
+
state,
|
|
227
|
+
nonce,
|
|
228
|
+
prompt: options.prompt,
|
|
229
|
+
login_hint: options.loginHint
|
|
230
|
+
};
|
|
231
|
+
if (this.config.usePkce) {
|
|
232
|
+
const codeVerifier = generateCodeVerifier();
|
|
233
|
+
const codeChallenge = await generateCodeChallenge(codeVerifier);
|
|
234
|
+
this.storage.set(STORAGE_KEYS.CODE_VERIFIER, codeVerifier);
|
|
235
|
+
params.code_challenge = codeChallenge;
|
|
236
|
+
params.code_challenge_method = "S256";
|
|
237
|
+
}
|
|
238
|
+
const endpoint = options.signup ? discovery.authorization_endpoint.replace("/authorize", "/authorize/signup") : discovery.authorization_endpoint;
|
|
239
|
+
const authUrl = buildUrl(endpoint, params);
|
|
240
|
+
window.location.href = authUrl;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Alias for login({ signup: true })
|
|
244
|
+
*/
|
|
245
|
+
async signup(options = {}) {
|
|
246
|
+
return this.login({ ...options, signup: true });
|
|
247
|
+
}
|
|
248
|
+
/**
|
|
249
|
+
* Handle callback from authorization server
|
|
250
|
+
*/
|
|
251
|
+
async handleCallback(url) {
|
|
252
|
+
const callbackUrl = url ?? window.location.href;
|
|
253
|
+
const params = parseUrlParams(callbackUrl);
|
|
254
|
+
if (params.error) {
|
|
255
|
+
return {
|
|
256
|
+
success: false,
|
|
257
|
+
error: params.error,
|
|
258
|
+
errorDescription: params.error_description
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
const storedState = this.storage.get(STORAGE_KEYS.STATE);
|
|
262
|
+
if (!storedState || storedState !== params.state) {
|
|
263
|
+
return {
|
|
264
|
+
success: false,
|
|
265
|
+
error: "invalid_state",
|
|
266
|
+
errorDescription: "State mismatch - possible CSRF attack"
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
if (!params.code) {
|
|
270
|
+
return {
|
|
271
|
+
success: false,
|
|
272
|
+
error: "missing_code",
|
|
273
|
+
errorDescription: "No authorization code received"
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
try {
|
|
277
|
+
const tokens = await this.exchangeCode(params.code);
|
|
278
|
+
if (tokens.id_token) {
|
|
279
|
+
const payload = decodeJwtPayload(tokens.id_token);
|
|
280
|
+
const storedNonce = this.storage.get(STORAGE_KEYS.NONCE);
|
|
281
|
+
if (payload?.nonce !== storedNonce) {
|
|
282
|
+
return {
|
|
283
|
+
success: false,
|
|
284
|
+
error: "invalid_nonce",
|
|
285
|
+
errorDescription: "Nonce mismatch - possible replay attack"
|
|
286
|
+
};
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
this.storeTokens(tokens);
|
|
290
|
+
this.storage.remove(STORAGE_KEYS.STATE);
|
|
291
|
+
this.storage.remove(STORAGE_KEYS.NONCE);
|
|
292
|
+
this.storage.remove(STORAGE_KEYS.CODE_VERIFIER);
|
|
293
|
+
const user = await this.fetchUserInfo(tokens.access_token);
|
|
294
|
+
if (this.config.autoRefresh && tokens.refresh_token) {
|
|
295
|
+
this.setupAutoRefresh();
|
|
296
|
+
}
|
|
297
|
+
this.notifyListeners();
|
|
298
|
+
return {
|
|
299
|
+
success: true,
|
|
300
|
+
user: user || void 0,
|
|
301
|
+
accessToken: tokens.access_token,
|
|
302
|
+
idToken: tokens.id_token,
|
|
303
|
+
refreshToken: tokens.refresh_token
|
|
304
|
+
};
|
|
305
|
+
} catch (error) {
|
|
306
|
+
return {
|
|
307
|
+
success: false,
|
|
308
|
+
error: "token_exchange_failed",
|
|
309
|
+
errorDescription: error instanceof Error ? error.message : "Unknown error"
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Exchange authorization code for tokens
|
|
315
|
+
*/
|
|
316
|
+
async exchangeCode(code) {
|
|
317
|
+
const discovery = await this.getDiscovery();
|
|
318
|
+
const body = {
|
|
319
|
+
grant_type: "authorization_code",
|
|
320
|
+
client_id: this.config.clientId,
|
|
321
|
+
code,
|
|
322
|
+
redirect_uri: this.config.redirectUri
|
|
323
|
+
};
|
|
324
|
+
if (this.config.usePkce) {
|
|
325
|
+
const codeVerifier = this.storage.get(STORAGE_KEYS.CODE_VERIFIER);
|
|
326
|
+
if (codeVerifier) {
|
|
327
|
+
body.code_verifier = codeVerifier;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
const response = await fetch(discovery.token_endpoint, {
|
|
331
|
+
method: "POST",
|
|
332
|
+
headers: {
|
|
333
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
334
|
+
},
|
|
335
|
+
body: new URLSearchParams(body)
|
|
336
|
+
});
|
|
337
|
+
if (!response.ok) {
|
|
338
|
+
const error = await response.json().catch(() => ({}));
|
|
339
|
+
throw new Error(error.error_description || error.error || "Token exchange failed");
|
|
340
|
+
}
|
|
341
|
+
return response.json();
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Refresh access token using refresh token
|
|
345
|
+
*/
|
|
346
|
+
async refreshToken() {
|
|
347
|
+
const refreshToken = this.storage.get(STORAGE_KEYS.REFRESH_TOKEN);
|
|
348
|
+
if (!refreshToken) return null;
|
|
349
|
+
const discovery = await this.getDiscovery();
|
|
350
|
+
const response = await fetch(discovery.token_endpoint, {
|
|
351
|
+
method: "POST",
|
|
352
|
+
headers: {
|
|
353
|
+
"Content-Type": "application/x-www-form-urlencoded"
|
|
354
|
+
},
|
|
355
|
+
body: new URLSearchParams({
|
|
356
|
+
grant_type: "refresh_token",
|
|
357
|
+
client_id: this.config.clientId,
|
|
358
|
+
refresh_token: refreshToken
|
|
359
|
+
})
|
|
360
|
+
});
|
|
361
|
+
if (!response.ok) {
|
|
362
|
+
this.clearTokens();
|
|
363
|
+
this.notifyListeners();
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const tokens = await response.json();
|
|
367
|
+
this.storeTokens(tokens);
|
|
368
|
+
this.notifyListeners();
|
|
369
|
+
return tokens;
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* Logout - end session
|
|
373
|
+
*/
|
|
374
|
+
async logout(options = {}) {
|
|
375
|
+
const discovery = await this.getDiscovery();
|
|
376
|
+
const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);
|
|
377
|
+
this.clearTokens();
|
|
378
|
+
this.notifyListeners();
|
|
379
|
+
if (discovery.end_session_endpoint) {
|
|
380
|
+
const params = {
|
|
381
|
+
post_logout_redirect_uri: options.returnTo ?? this.config.postLogoutRedirectUri,
|
|
382
|
+
id_token_hint: options.idTokenHint ?? idToken ?? void 0,
|
|
383
|
+
client_id: this.config.clientId
|
|
384
|
+
};
|
|
385
|
+
const logoutUrl = buildUrl(discovery.end_session_endpoint, params);
|
|
386
|
+
window.location.href = logoutUrl;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
/**
|
|
390
|
+
* Get current access token (refreshes if needed)
|
|
391
|
+
*/
|
|
392
|
+
async getAccessToken() {
|
|
393
|
+
const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);
|
|
394
|
+
if (!accessToken) return null;
|
|
395
|
+
if (isTokenExpired(accessToken, this.config.refreshThreshold)) {
|
|
396
|
+
const tokens = await this.refreshToken();
|
|
397
|
+
return tokens?.access_token ?? null;
|
|
398
|
+
}
|
|
399
|
+
return accessToken;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* Get current user from stored ID token
|
|
403
|
+
*/
|
|
404
|
+
getUser() {
|
|
405
|
+
const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);
|
|
406
|
+
if (!idToken) return null;
|
|
407
|
+
return decodeJwtPayload(idToken);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* Fetch user info from userinfo endpoint
|
|
411
|
+
*/
|
|
412
|
+
async fetchUserInfo(accessToken) {
|
|
413
|
+
const token = accessToken ?? await this.getAccessToken();
|
|
414
|
+
if (!token) return null;
|
|
415
|
+
const discovery = await this.getDiscovery();
|
|
416
|
+
const response = await fetch(discovery.userinfo_endpoint, {
|
|
417
|
+
headers: {
|
|
418
|
+
Authorization: `Bearer ${token}`
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
if (!response.ok) return null;
|
|
422
|
+
const user = await response.json();
|
|
423
|
+
this.storage.set(STORAGE_KEYS.USER, JSON.stringify(user));
|
|
424
|
+
return user;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Check if user is authenticated
|
|
428
|
+
*/
|
|
429
|
+
isAuthenticated() {
|
|
430
|
+
const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);
|
|
431
|
+
if (!accessToken) return false;
|
|
432
|
+
return !isTokenExpired(accessToken);
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
435
|
+
* Get current auth state
|
|
436
|
+
*/
|
|
437
|
+
getAuthState() {
|
|
438
|
+
const accessToken = this.storage.get(STORAGE_KEYS.ACCESS_TOKEN);
|
|
439
|
+
const idToken = this.storage.get(STORAGE_KEYS.ID_TOKEN);
|
|
440
|
+
const user = this.getUser();
|
|
441
|
+
return {
|
|
442
|
+
isAuthenticated: this.isAuthenticated(),
|
|
443
|
+
isLoading: false,
|
|
444
|
+
user,
|
|
445
|
+
accessToken,
|
|
446
|
+
idToken,
|
|
447
|
+
error: null
|
|
448
|
+
};
|
|
449
|
+
}
|
|
450
|
+
/**
|
|
451
|
+
* Subscribe to auth state changes
|
|
452
|
+
*/
|
|
453
|
+
subscribe(listener) {
|
|
454
|
+
this.listeners.add(listener);
|
|
455
|
+
return () => this.listeners.delete(listener);
|
|
456
|
+
}
|
|
457
|
+
/**
|
|
458
|
+
* Store tokens in storage
|
|
459
|
+
*/
|
|
460
|
+
storeTokens(tokens) {
|
|
461
|
+
this.storage.set(STORAGE_KEYS.ACCESS_TOKEN, tokens.access_token);
|
|
462
|
+
if (tokens.refresh_token) {
|
|
463
|
+
this.storage.set(STORAGE_KEYS.REFRESH_TOKEN, tokens.refresh_token);
|
|
464
|
+
}
|
|
465
|
+
if (tokens.id_token) {
|
|
466
|
+
this.storage.set(STORAGE_KEYS.ID_TOKEN, tokens.id_token);
|
|
467
|
+
}
|
|
468
|
+
const expiresAt = Date.now() + tokens.expires_in * 1e3;
|
|
469
|
+
this.storage.set(STORAGE_KEYS.EXPIRES_AT, expiresAt.toString());
|
|
470
|
+
}
|
|
471
|
+
/**
|
|
472
|
+
* Clear all stored tokens
|
|
473
|
+
*/
|
|
474
|
+
clearTokens() {
|
|
475
|
+
if (this.refreshTimer) {
|
|
476
|
+
clearTimeout(this.refreshTimer);
|
|
477
|
+
this.refreshTimer = null;
|
|
478
|
+
}
|
|
479
|
+
this.storage.clear();
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Setup auto-refresh timer
|
|
483
|
+
*/
|
|
484
|
+
setupAutoRefresh() {
|
|
485
|
+
if (this.refreshTimer) {
|
|
486
|
+
clearTimeout(this.refreshTimer);
|
|
487
|
+
}
|
|
488
|
+
const expiresAt = this.storage.get(STORAGE_KEYS.EXPIRES_AT);
|
|
489
|
+
if (!expiresAt) return;
|
|
490
|
+
const expiresAtMs = parseInt(expiresAt, 10);
|
|
491
|
+
const refreshAt = expiresAtMs - this.config.refreshThreshold * 1e3;
|
|
492
|
+
const delay = refreshAt - Date.now();
|
|
493
|
+
if (delay > 0) {
|
|
494
|
+
this.refreshTimer = setTimeout(async () => {
|
|
495
|
+
await this.refreshToken();
|
|
496
|
+
this.setupAutoRefresh();
|
|
497
|
+
}, delay);
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
/**
|
|
501
|
+
* Notify all listeners of state change
|
|
502
|
+
*/
|
|
503
|
+
notifyListeners() {
|
|
504
|
+
const state = this.getAuthState();
|
|
505
|
+
this.listeners.forEach((listener) => listener(state));
|
|
506
|
+
}
|
|
507
|
+
};
|
|
508
|
+
function createStuffleIAMClient(config) {
|
|
509
|
+
return new StuffleIAMClient(config);
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// src/react/index.tsx
|
|
513
|
+
import { jsx } from "react/jsx-runtime";
|
|
514
|
+
var StuffleIAMContext = createContext(null);
|
|
515
|
+
function StuffleIAMProvider({
|
|
516
|
+
children,
|
|
517
|
+
onLoginSuccess,
|
|
518
|
+
onLoginError,
|
|
519
|
+
autoHandleCallback = true,
|
|
520
|
+
...config
|
|
521
|
+
}) {
|
|
522
|
+
const [client] = useState(() => createStuffleIAMClient(config));
|
|
523
|
+
const [state, setState] = useState(() => ({
|
|
524
|
+
isAuthenticated: false,
|
|
525
|
+
isLoading: true,
|
|
526
|
+
user: null,
|
|
527
|
+
accessToken: null,
|
|
528
|
+
idToken: null,
|
|
529
|
+
error: null
|
|
530
|
+
}));
|
|
531
|
+
useEffect(() => {
|
|
532
|
+
const unsubscribe = client.subscribe(setState);
|
|
533
|
+
const initialState = client.getAuthState();
|
|
534
|
+
setState({ ...initialState, isLoading: false });
|
|
535
|
+
return unsubscribe;
|
|
536
|
+
}, [client]);
|
|
537
|
+
useEffect(() => {
|
|
538
|
+
if (!autoHandleCallback) return;
|
|
539
|
+
const url = window.location.href;
|
|
540
|
+
const hasCode = url.includes("code=");
|
|
541
|
+
const hasError = url.includes("error=");
|
|
542
|
+
if (hasCode || hasError) {
|
|
543
|
+
setState((prev) => ({ ...prev, isLoading: true }));
|
|
544
|
+
client.handleCallback(url).then((result) => {
|
|
545
|
+
setState((prev) => ({ ...prev, isLoading: false }));
|
|
546
|
+
if (result.success) {
|
|
547
|
+
onLoginSuccess?.(result);
|
|
548
|
+
window.history.replaceState({}, "", window.location.pathname);
|
|
549
|
+
} else {
|
|
550
|
+
const error = new Error(result.errorDescription || result.error || "Login failed");
|
|
551
|
+
onLoginError?.(error);
|
|
552
|
+
setState((prev) => ({ ...prev, error }));
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
}
|
|
556
|
+
}, [client, autoHandleCallback, onLoginSuccess, onLoginError]);
|
|
557
|
+
const login = useCallback(
|
|
558
|
+
(options) => client.login(options),
|
|
559
|
+
[client]
|
|
560
|
+
);
|
|
561
|
+
const signup = useCallback(
|
|
562
|
+
(options) => client.signup(options),
|
|
563
|
+
[client]
|
|
564
|
+
);
|
|
565
|
+
const logout = useCallback(
|
|
566
|
+
(options) => client.logout(options),
|
|
567
|
+
[client]
|
|
568
|
+
);
|
|
569
|
+
const handleCallback = useCallback(
|
|
570
|
+
(url) => client.handleCallback(url),
|
|
571
|
+
[client]
|
|
572
|
+
);
|
|
573
|
+
const getAccessToken = useCallback(
|
|
574
|
+
() => client.getAccessToken(),
|
|
575
|
+
[client]
|
|
576
|
+
);
|
|
577
|
+
const value = useMemo(
|
|
578
|
+
() => ({
|
|
579
|
+
client,
|
|
580
|
+
isAuthenticated: state.isAuthenticated,
|
|
581
|
+
isLoading: state.isLoading,
|
|
582
|
+
user: state.user,
|
|
583
|
+
accessToken: state.accessToken,
|
|
584
|
+
error: state.error,
|
|
585
|
+
login,
|
|
586
|
+
signup,
|
|
587
|
+
logout,
|
|
588
|
+
handleCallback,
|
|
589
|
+
getAccessToken
|
|
590
|
+
}),
|
|
591
|
+
[client, state, login, signup, logout, handleCallback, getAccessToken]
|
|
592
|
+
);
|
|
593
|
+
return /* @__PURE__ */ jsx(StuffleIAMContext.Provider, { value, children });
|
|
594
|
+
}
|
|
595
|
+
function useAuth() {
|
|
596
|
+
const context = useContext(StuffleIAMContext);
|
|
597
|
+
if (!context) {
|
|
598
|
+
throw new Error("useAuth must be used within a StuffleIAMProvider");
|
|
599
|
+
}
|
|
600
|
+
return context;
|
|
601
|
+
}
|
|
602
|
+
function useUser() {
|
|
603
|
+
const { user } = useAuth();
|
|
604
|
+
return user;
|
|
605
|
+
}
|
|
606
|
+
function useIsAuthenticated() {
|
|
607
|
+
const { isAuthenticated } = useAuth();
|
|
608
|
+
return isAuthenticated;
|
|
609
|
+
}
|
|
610
|
+
function useAccessToken() {
|
|
611
|
+
const { getAccessToken } = useAuth();
|
|
612
|
+
return getAccessToken;
|
|
613
|
+
}
|
|
614
|
+
function useHasRole(roles) {
|
|
615
|
+
const { user } = useAuth();
|
|
616
|
+
if (!user?.roles) return false;
|
|
617
|
+
const requiredRoles = Array.isArray(roles) ? roles : [roles];
|
|
618
|
+
return requiredRoles.some((role) => user.roles?.includes(role));
|
|
619
|
+
}
|
|
620
|
+
function withAuth(WrappedComponent, options) {
|
|
621
|
+
return function AuthenticatedComponent(props) {
|
|
622
|
+
const { isAuthenticated, isLoading, user } = useAuth();
|
|
623
|
+
const LoadingComp = options?.LoadingComponent;
|
|
624
|
+
const UnauthorizedComp = options?.UnauthorizedComponent;
|
|
625
|
+
if (isLoading) {
|
|
626
|
+
return LoadingComp ? /* @__PURE__ */ jsx(LoadingComp, {}) : null;
|
|
627
|
+
}
|
|
628
|
+
if (!isAuthenticated) {
|
|
629
|
+
return UnauthorizedComp ? /* @__PURE__ */ jsx(UnauthorizedComp, {}) : null;
|
|
630
|
+
}
|
|
631
|
+
if (options?.roles && options.roles.length > 0) {
|
|
632
|
+
const hasRequiredRole = options.roles.some((role) => user?.roles?.includes(role));
|
|
633
|
+
if (!hasRequiredRole) {
|
|
634
|
+
return UnauthorizedComp ? /* @__PURE__ */ jsx(UnauthorizedComp, {}) : null;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
return /* @__PURE__ */ jsx(WrappedComponent, { ...props });
|
|
638
|
+
};
|
|
639
|
+
}
|
|
640
|
+
export {
|
|
641
|
+
StuffleIAMProvider,
|
|
642
|
+
useAccessToken,
|
|
643
|
+
useAuth,
|
|
644
|
+
useHasRole,
|
|
645
|
+
useIsAuthenticated,
|
|
646
|
+
useUser,
|
|
647
|
+
withAuth
|
|
648
|
+
};
|
|
649
|
+
//# sourceMappingURL=react.mjs.map
|