@shokirovr16/frontend-library 0.1.2
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/LICENSE +21 -0
- package/README.md +98 -0
- package/bin/cmfrt.js +69 -0
- package/package.json +47 -0
- package/src/auth/README.md +193 -0
- package/src/auth/core/AuthEngine.js +623 -0
- package/src/auth/core/OidcClient.js +79 -0
- package/src/auth/core/OidcDiscovery.js +17 -0
- package/src/auth/core/Pkce.js +18 -0
- package/src/auth/events/AuthEventBus.js +22 -0
- package/src/auth/http/authFetch.js +32 -0
- package/src/auth/http/createAuthHttpClient.js +42 -0
- package/src/auth/index.js +90 -0
- package/src/auth/permissions/ClaimsNormalizer.js +69 -0
- package/src/auth/permissions/permissions.js +26 -0
- package/src/auth/react/AuthProvider.js +34 -0
- package/src/auth/react/guards/RequireAuth.js +35 -0
- package/src/auth/react/guards/RequirePermission.js +16 -0
- package/src/auth/react/guards/withAuthGuard.js +12 -0
- package/src/auth/react/hooks/useRequireAuth.js +24 -0
- package/src/auth/react/index.js +6 -0
- package/src/auth/react/useAuth.js +29 -0
- package/src/auth/silent/silentCallback.js +42 -0
- package/src/auth/singleton.js +22 -0
- package/src/auth/storage/InMemoryTokenStore.js +56 -0
- package/src/auth/storage/TransactionStore.js +51 -0
- package/src/auth/sync/BroadcastChannelSync.js +29 -0
- package/src/auth/tenancy/TenantResolver.js +39 -0
- package/src/auth/types.js +113 -0
- package/src/auth/utils/base64url.js +15 -0
- package/src/auth/utils/jwt.js +26 -0
- package/src/auth/utils/random.js +13 -0
- package/src/auth/utils/url.js +27 -0
- package/src/commands/add.js +80 -0
- package/src/commands/init.js +113 -0
- package/src/commands/list.js +92 -0
- package/src/commands/remove.js +150 -0
- package/src/commands/status.js +96 -0
- package/src/commands/theme.js +47 -0
- package/src/commands/uninstall.js +198 -0
- package/src/commands/update.js +151 -0
- package/src/lib/config.js +55 -0
- package/src/lib/fs.js +13 -0
- package/src/lib/packageManager.js +30 -0
- package/src/lib/paths.js +14 -0
- package/src/lib/registry.js +11 -0
- package/src/lib/styles.js +223 -0
- package/src/lib/targets.js +15 -0
- package/src/lib/theme.js +102 -0
- package/templates/docs/cmfrt-doc.md +82 -0
- package/templates/lib/utils.js +6 -0
- package/templates/registry.json +42 -0
- package/templates/styles/theme.cjs +832 -0
- package/templates/styles/type-utilities.css +136 -0
- package/templates/styles/type-utility-classes.css +138 -0
- package/templates/styles/variables.css +1560 -0
- package/templates/styles/variables.json +6870 -0
- package/templates/ui/button.jsx +117 -0
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
import { createAuthEventBus } from '../events/AuthEventBus.js';
|
|
2
|
+
import { loadOidcDiscovery } from './OidcDiscovery.js';
|
|
3
|
+
import { createOidcClient } from './OidcClient.js';
|
|
4
|
+
import { generateCodeVerifier, codeChallengeS256 } from './Pkce.js';
|
|
5
|
+
import { createInMemoryTokenStore } from '../storage/InMemoryTokenStore.js';
|
|
6
|
+
import { createTransactionStore } from '../storage/TransactionStore.js';
|
|
7
|
+
import { randomString } from '../utils/random.js';
|
|
8
|
+
import { getCurrentUrl, parseUrlParams, stripQueryParams, toAbsoluteUrl } from '../utils/url.js';
|
|
9
|
+
import { normalizeClaims } from '../permissions/ClaimsNormalizer.js';
|
|
10
|
+
import * as perm from '../permissions/permissions.js';
|
|
11
|
+
import { createDefaultTenantResolver } from '../tenancy/TenantResolver.js';
|
|
12
|
+
import { createBroadcastChannelSync } from '../sync/BroadcastChannelSync.js';
|
|
13
|
+
import { isJwtExpired } from '../utils/jwt.js';
|
|
14
|
+
|
|
15
|
+
function nowSec() {
|
|
16
|
+
return Math.floor(Date.now() / 1000);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function isDebugEnabled() {
|
|
20
|
+
try {
|
|
21
|
+
return typeof window !== 'undefined' && /^(localhost|127\.0\.0\.1)$/.test(window.location.hostname);
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function debugLog(event, details) {
|
|
28
|
+
if (!isDebugEnabled()) return;
|
|
29
|
+
try {
|
|
30
|
+
const payload = { event, details: details || {}, ts: Date.now() };
|
|
31
|
+
if (typeof window !== 'undefined') {
|
|
32
|
+
window.__CMFRT_AUTH_DEBUG__ = window.__CMFRT_AUTH_DEBUG__ || [];
|
|
33
|
+
window.__CMFRT_AUTH_DEBUG__.push(payload);
|
|
34
|
+
}
|
|
35
|
+
// eslint-disable-next-line no-console
|
|
36
|
+
console.log(`[cmfrt/auth][debug] ${event}`, details || {});
|
|
37
|
+
} catch {
|
|
38
|
+
// ignore
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function defaultValidateAndNormalizeConfig(cfg) {
|
|
43
|
+
if (!cfg) throw new Error('[cmfrt/auth] runtime config is required.');
|
|
44
|
+
if (!cfg.tenantId) throw new Error('[cmfrt/auth] runtime config: tenantId is required.');
|
|
45
|
+
if (!cfg.issuer) throw new Error('[cmfrt/auth] runtime config: issuer is required.');
|
|
46
|
+
if (!cfg.clientId) throw new Error('[cmfrt/auth] runtime config: clientId is required.');
|
|
47
|
+
if (!cfg.redirectUri) throw new Error('[cmfrt/auth] runtime config: redirectUri is required.');
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
mode: 'spa-direct',
|
|
51
|
+
scopes: ['openid', 'profile', 'email'],
|
|
52
|
+
checkSsoPolicy: 'onLoad',
|
|
53
|
+
tokenPolicy: { minValiditySec: 30, refreshLeewaySec: 5 },
|
|
54
|
+
logoutPolicy: { frontChannel: true, broadcast: true },
|
|
55
|
+
claimsPolicy: {},
|
|
56
|
+
...cfg,
|
|
57
|
+
redirectUri: toAbsoluteUrl(cfg.redirectUri) || cfg.redirectUri,
|
|
58
|
+
postLogoutRedirectUri: cfg.postLogoutRedirectUri ? toAbsoluteUrl(cfg.postLogoutRedirectUri) : undefined,
|
|
59
|
+
silentCheckSsoRedirectUri: cfg.silentCheckSsoRedirectUri ? toAbsoluteUrl(cfg.silentCheckSsoRedirectUri) : undefined,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function createSnapshot({ status, tenantId, user, error }) {
|
|
64
|
+
return {
|
|
65
|
+
status,
|
|
66
|
+
tenantId: tenantId || null,
|
|
67
|
+
user: user || null,
|
|
68
|
+
isAuthenticated: !!user,
|
|
69
|
+
error: error || null,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function createAuthEngine(options) {
|
|
74
|
+
const eventBus = createAuthEventBus();
|
|
75
|
+
const stateListeners = new Set();
|
|
76
|
+
|
|
77
|
+
const tenantResolver = options?.tenantResolver || createDefaultTenantResolver();
|
|
78
|
+
const validateAndNormalizeConfig = options?.validateAndNormalizeConfig || defaultValidateAndNormalizeConfig;
|
|
79
|
+
|
|
80
|
+
let status = 'idle';
|
|
81
|
+
let tenantId = null;
|
|
82
|
+
let config = null;
|
|
83
|
+
let discovery = null;
|
|
84
|
+
let oidc = null;
|
|
85
|
+
let user = null;
|
|
86
|
+
let lastError = null;
|
|
87
|
+
|
|
88
|
+
const tokenStore = createInMemoryTokenStore();
|
|
89
|
+
let refreshPromise = null;
|
|
90
|
+
|
|
91
|
+
let transactionStore = createTransactionStore({ namespace: 'cmfrt.auth.txn' });
|
|
92
|
+
|
|
93
|
+
const channelName =
|
|
94
|
+
options?.broadcastChannelName || (typeof window !== 'undefined' ? `cmfrt.auth.${window.location.origin}` : 'cmfrt.auth');
|
|
95
|
+
const sync = createBroadcastChannelSync({
|
|
96
|
+
name: channelName,
|
|
97
|
+
onMessage: async (msg) => {
|
|
98
|
+
if (!msg || typeof msg !== 'object') return;
|
|
99
|
+
if (msg.type === 'LOGOUT' && msg.tenantId === tenantId) {
|
|
100
|
+
clearSession('broadcast_logout');
|
|
101
|
+
}
|
|
102
|
+
if (msg.type === 'TENANT_SWITCH' && msg.fromTenantId === tenantId) {
|
|
103
|
+
clearSession('broadcast_tenant_switch');
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
function emitEvent(event) {
|
|
109
|
+
const enriched = { ts: Date.now(), tenantId, ...event };
|
|
110
|
+
eventBus.emit(enriched);
|
|
111
|
+
options?.onEvent?.(enriched);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function notifySnapshot() {
|
|
115
|
+
const snapshot = getSnapshot();
|
|
116
|
+
for (const listener of stateListeners) {
|
|
117
|
+
try {
|
|
118
|
+
listener(snapshot);
|
|
119
|
+
} catch {
|
|
120
|
+
// best-effort
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function setStatus(next, err) {
|
|
126
|
+
status = next;
|
|
127
|
+
if (err) lastError = String(err?.message || err);
|
|
128
|
+
debugLog('status', { next, err: err ? String(err?.message || err) : null, tenantId, clientId: config?.clientId });
|
|
129
|
+
notifySnapshot();
|
|
130
|
+
emitEvent({ type: 'STATUS', status: next, error: err ? String(err?.message || err) : null });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function computeUser() {
|
|
134
|
+
const claims = tokenStore.getClaims();
|
|
135
|
+
const accessClaims = claims?.access || null;
|
|
136
|
+
const idClaims = claims?.id || null;
|
|
137
|
+
const normalized = normalizeClaims({
|
|
138
|
+
accessClaims,
|
|
139
|
+
idClaims,
|
|
140
|
+
clientId: config?.clientId,
|
|
141
|
+
claimsPolicy: config?.claimsPolicy,
|
|
142
|
+
});
|
|
143
|
+
user = normalized ? { ...normalized } : null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function loadRuntimeConfig() {
|
|
147
|
+
const runtimeConfig = options?.runtimeConfig;
|
|
148
|
+
if (typeof runtimeConfig === 'function') return runtimeConfig();
|
|
149
|
+
return runtimeConfig;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function init({ checkSso } = {}) {
|
|
153
|
+
setStatus('loading_config');
|
|
154
|
+
const t = await Promise.resolve(tenantResolver.resolve());
|
|
155
|
+
tenantId = t?.tenantId || 'default';
|
|
156
|
+
|
|
157
|
+
const rawCfg = await loadRuntimeConfig();
|
|
158
|
+
// Explicit runtime config must win over URL-derived tenant hints.
|
|
159
|
+
config = validateAndNormalizeConfig({ ...(t || {}), ...(rawCfg || {}) });
|
|
160
|
+
if (config.tenantId !== tenantId) tenantId = config.tenantId;
|
|
161
|
+
|
|
162
|
+
transactionStore = createTransactionStore({
|
|
163
|
+
namespace: `cmfrt.auth.txn.${config.tenantId}.${config.clientId}`,
|
|
164
|
+
});
|
|
165
|
+
debugLog('init.config', {
|
|
166
|
+
tenantId,
|
|
167
|
+
configTenantId: config.tenantId,
|
|
168
|
+
clientId: config.clientId,
|
|
169
|
+
redirectUri: config.redirectUri,
|
|
170
|
+
silentCheckSsoRedirectUri: config.silentCheckSsoRedirectUri,
|
|
171
|
+
checkSsoPolicy: config.checkSsoPolicy,
|
|
172
|
+
path: typeof window !== 'undefined' ? window.location.pathname : null,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
setStatus('discovering');
|
|
176
|
+
discovery = await loadOidcDiscovery(config.issuer);
|
|
177
|
+
oidc = createOidcClient({ config, discovery });
|
|
178
|
+
|
|
179
|
+
setStatus('ready');
|
|
180
|
+
emitEvent({ type: 'READY', config: safeConfigForEvents(config) });
|
|
181
|
+
|
|
182
|
+
const shouldCheckSso =
|
|
183
|
+
checkSso ?? (config.checkSsoPolicy === 'onLoad' && typeof window !== 'undefined' && !tokenStore.getAccessToken());
|
|
184
|
+
if (shouldCheckSso) {
|
|
185
|
+
try {
|
|
186
|
+
await checkSsoSilent();
|
|
187
|
+
} catch (e) {
|
|
188
|
+
// check-sso must never hard-fail init
|
|
189
|
+
emitEvent({ type: 'CHECK_SSO_FAILED', error: String(e?.message || e) });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (tokenStore.getAccessToken()) {
|
|
194
|
+
computeUser();
|
|
195
|
+
setStatus('authenticated');
|
|
196
|
+
} else {
|
|
197
|
+
setStatus('logged_out');
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function safeConfigForEvents(cfg) {
|
|
202
|
+
if (!cfg) return null;
|
|
203
|
+
const { issuer, tenantId: tId, clientId } = cfg;
|
|
204
|
+
return { issuer, tenantId: tId, clientId };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function getSnapshot() {
|
|
208
|
+
return createSnapshot({ status, tenantId, user, error: lastError });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function subscribe(listener) {
|
|
212
|
+
stateListeners.add(listener);
|
|
213
|
+
listener(getSnapshot());
|
|
214
|
+
return () => stateListeners.delete(listener);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function subscribeAuthEvents(listener) {
|
|
218
|
+
return eventBus.subscribe(listener);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function isAuthenticated() {
|
|
222
|
+
return !!user && !!tokenStore.getAccessToken();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function getAccessToken() {
|
|
226
|
+
return tokenStore.getAccessToken();
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function getIdToken() {
|
|
230
|
+
return tokenStore.getIdToken();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function getUser() {
|
|
234
|
+
return user;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function clearSession(reason = 'clear') {
|
|
238
|
+
tokenStore.clear();
|
|
239
|
+
user = null;
|
|
240
|
+
refreshPromise = null;
|
|
241
|
+
lastError = null;
|
|
242
|
+
transactionStore.clearAll();
|
|
243
|
+
setStatus('logged_out');
|
|
244
|
+
emitEvent({ type: 'SESSION_CLEARED', reason });
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
async function ensureFreshToken(minValiditySec) {
|
|
248
|
+
const token = tokenStore.getAccessToken();
|
|
249
|
+
if (!token) return;
|
|
250
|
+
const minValid = minValiditySec ?? config?.tokenPolicy?.minValiditySec ?? 30;
|
|
251
|
+
const leeway = config?.tokenPolicy?.refreshLeewaySec ?? 0;
|
|
252
|
+
const exp = tokenStore.getAccessTokenExpSec();
|
|
253
|
+
if (!exp) return refresh();
|
|
254
|
+
const remaining = exp - nowSec();
|
|
255
|
+
if (remaining > minValid) return;
|
|
256
|
+
return refresh({ leeway });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
async function refresh({ leeway = 0 } = {}) {
|
|
260
|
+
if (!oidc) throw new Error('[cmfrt/auth] refresh called before init.');
|
|
261
|
+
const refreshToken = tokenStore.getRefreshToken();
|
|
262
|
+
if (!refreshToken) throw new Error('[cmfrt/auth] No refresh token in memory.');
|
|
263
|
+
|
|
264
|
+
if (refreshPromise) return refreshPromise;
|
|
265
|
+
refreshPromise = (async () => {
|
|
266
|
+
setStatus('refreshing');
|
|
267
|
+
try {
|
|
268
|
+
// Prevent using a refresh token past its own expiry if present (best-effort).
|
|
269
|
+
if (isJwtExpired(refreshToken, nowSec(), leeway)) {
|
|
270
|
+
throw new Error('Refresh token expired');
|
|
271
|
+
}
|
|
272
|
+
const next = await oidc.refreshTokens({ refreshToken });
|
|
273
|
+
tokenStore.set(next);
|
|
274
|
+
computeUser();
|
|
275
|
+
setStatus('authenticated');
|
|
276
|
+
emitEvent({ type: 'TOKEN_REFRESHED' });
|
|
277
|
+
} catch (e) {
|
|
278
|
+
clearSession('refresh_failed');
|
|
279
|
+
emitEvent({ type: 'REFRESH_FAILED', error: String(e?.message || e) });
|
|
280
|
+
throw e;
|
|
281
|
+
} finally {
|
|
282
|
+
refreshPromise = null;
|
|
283
|
+
}
|
|
284
|
+
})();
|
|
285
|
+
return refreshPromise;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
async function login({ returnTo, prompt } = {}) {
|
|
289
|
+
if (!oidc || !config) throw new Error('[cmfrt/auth] login called before init.');
|
|
290
|
+
if (typeof window === 'undefined') throw new Error('[cmfrt/auth] login requires a browser environment.');
|
|
291
|
+
|
|
292
|
+
setStatus('authenticating');
|
|
293
|
+
|
|
294
|
+
const state = randomString(32);
|
|
295
|
+
const nonce = randomString(32);
|
|
296
|
+
const codeVerifier = generateCodeVerifier();
|
|
297
|
+
const codeChallenge = await codeChallengeS256(codeVerifier);
|
|
298
|
+
|
|
299
|
+
const effectiveReturnTo = returnTo || window.location.pathname + window.location.search + window.location.hash;
|
|
300
|
+
|
|
301
|
+
transactionStore.set('state', state);
|
|
302
|
+
transactionStore.set('nonce', nonce);
|
|
303
|
+
transactionStore.set('codeVerifier', codeVerifier);
|
|
304
|
+
transactionStore.set('returnTo', effectiveReturnTo);
|
|
305
|
+
transactionStore.set('createdAt', Date.now());
|
|
306
|
+
debugLog('login.transaction', {
|
|
307
|
+
state,
|
|
308
|
+
returnTo: effectiveReturnTo,
|
|
309
|
+
tenantId: config?.tenantId,
|
|
310
|
+
clientId: config?.clientId,
|
|
311
|
+
redirectUri: config?.redirectUri,
|
|
312
|
+
path: typeof window !== 'undefined' ? window.location.pathname : null,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
emitEvent({ type: 'LOGIN_REDIRECT', returnTo: effectiveReturnTo });
|
|
316
|
+
|
|
317
|
+
const url = oidc.buildAuthorizeUrl({
|
|
318
|
+
redirectUri: config.redirectUri,
|
|
319
|
+
scope: (config.scopes || []).join(' '),
|
|
320
|
+
state,
|
|
321
|
+
nonce,
|
|
322
|
+
codeChallenge,
|
|
323
|
+
prompt,
|
|
324
|
+
audience: config.apiAudience,
|
|
325
|
+
loginHint: undefined,
|
|
326
|
+
idpHint: undefined,
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
window.location.assign(url);
|
|
330
|
+
// never resolves (browser navigation)
|
|
331
|
+
return new Promise(() => {});
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
async function handleCallback({ url } = {}) {
|
|
335
|
+
if (!oidc || !config) throw new Error('[cmfrt/auth] handleCallback called before init.');
|
|
336
|
+
const callbackUrl = url || getCurrentUrl();
|
|
337
|
+
if (!callbackUrl) throw new Error('[cmfrt/auth] handleCallback requires url (browser).');
|
|
338
|
+
|
|
339
|
+
const params = parseUrlParams(callbackUrl);
|
|
340
|
+
const error = params.error;
|
|
341
|
+
const errorDescription = params.error_description;
|
|
342
|
+
if (error) {
|
|
343
|
+
setStatus('error', new Error(errorDescription || error));
|
|
344
|
+
emitEvent({ type: 'CALLBACK_ERROR', error, errorDescription });
|
|
345
|
+
throw new Error(`[cmfrt/auth] OIDC callback error: ${error} ${errorDescription || ''}`.trim());
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const code = params.code;
|
|
349
|
+
const state = params.state;
|
|
350
|
+
if (!code || !state) {
|
|
351
|
+
throw new Error('[cmfrt/auth] Missing code/state in callback.');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const expectedState = transactionStore.get('state');
|
|
355
|
+
const codeVerifier = transactionStore.get('codeVerifier');
|
|
356
|
+
const returnTo = transactionStore.get('returnTo');
|
|
357
|
+
debugLog('callback.transaction', {
|
|
358
|
+
url: callbackUrl,
|
|
359
|
+
state,
|
|
360
|
+
expectedState,
|
|
361
|
+
hasCodeVerifier: !!codeVerifier,
|
|
362
|
+
returnTo,
|
|
363
|
+
tenantId: config?.tenantId,
|
|
364
|
+
clientId: config?.clientId,
|
|
365
|
+
path: typeof window !== 'undefined' ? window.location.pathname : null,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
if (!expectedState || state !== expectedState) {
|
|
369
|
+
clearSession('state_mismatch');
|
|
370
|
+
debugLog('callback.state_mismatch', {
|
|
371
|
+
state,
|
|
372
|
+
expectedState,
|
|
373
|
+
tenantId: config?.tenantId,
|
|
374
|
+
clientId: config?.clientId,
|
|
375
|
+
});
|
|
376
|
+
throw new Error('[cmfrt/auth] State mismatch in callback.');
|
|
377
|
+
}
|
|
378
|
+
if (!codeVerifier) {
|
|
379
|
+
clearSession('missing_code_verifier');
|
|
380
|
+
throw new Error('[cmfrt/auth] Missing codeVerifier (sessionStorage cleared?).');
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
setStatus('authenticating');
|
|
384
|
+
|
|
385
|
+
const tokenResponse = await oidc.exchangeCodeForTokens({
|
|
386
|
+
code,
|
|
387
|
+
codeVerifier,
|
|
388
|
+
redirectUri: config.redirectUri,
|
|
389
|
+
});
|
|
390
|
+
tokenStore.set(tokenResponse);
|
|
391
|
+
computeUser();
|
|
392
|
+
|
|
393
|
+
transactionStore.clearAll();
|
|
394
|
+
|
|
395
|
+
setStatus('authenticated');
|
|
396
|
+
emitEvent({ type: 'LOGIN_SUCCESS' });
|
|
397
|
+
|
|
398
|
+
// Optional: clean URL to avoid leaking code in history.
|
|
399
|
+
if (typeof window !== 'undefined') {
|
|
400
|
+
try {
|
|
401
|
+
const clean = stripQueryParams(callbackUrl, ['code', 'state', 'session_state']);
|
|
402
|
+
window.history.replaceState({}, '', clean);
|
|
403
|
+
} catch {
|
|
404
|
+
// ignore
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
return { returnTo: returnTo || undefined };
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
async function checkSsoSilent() {
|
|
412
|
+
if (!oidc || !config) throw new Error('[cmfrt/auth] checkSsoSilent called before init.');
|
|
413
|
+
if (typeof window === 'undefined') return;
|
|
414
|
+
if (!config.silentCheckSsoRedirectUri) {
|
|
415
|
+
emitEvent({ type: 'CHECK_SSO_SKIPPED', reason: 'missing_silentCheckSsoRedirectUri' });
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const state = randomString(32);
|
|
420
|
+
const nonce = randomString(32);
|
|
421
|
+
const codeVerifier = generateCodeVerifier();
|
|
422
|
+
const codeChallenge = await codeChallengeS256(codeVerifier);
|
|
423
|
+
|
|
424
|
+
transactionStore.set('silentState', state);
|
|
425
|
+
transactionStore.set('silentCodeVerifier', codeVerifier);
|
|
426
|
+
transactionStore.set('silentNonce', nonce);
|
|
427
|
+
debugLog('silent_sso.start', {
|
|
428
|
+
silentState: state,
|
|
429
|
+
tenantId: config?.tenantId,
|
|
430
|
+
clientId: config?.clientId,
|
|
431
|
+
silentCheckSsoRedirectUri: config?.silentCheckSsoRedirectUri,
|
|
432
|
+
path: typeof window !== 'undefined' ? window.location.pathname : null,
|
|
433
|
+
});
|
|
434
|
+
|
|
435
|
+
const url = oidc.buildAuthorizeUrl({
|
|
436
|
+
redirectUri: config.silentCheckSsoRedirectUri,
|
|
437
|
+
scope: (config.scopes || []).join(' '),
|
|
438
|
+
state,
|
|
439
|
+
nonce,
|
|
440
|
+
codeChallenge,
|
|
441
|
+
prompt: 'none',
|
|
442
|
+
audience: config.apiAudience,
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
emitEvent({ type: 'CHECK_SSO_START' });
|
|
446
|
+
|
|
447
|
+
await runHiddenIFrameFlow({
|
|
448
|
+
url,
|
|
449
|
+
messageType: 'CMFRT_OIDC_SILENT_CALLBACK',
|
|
450
|
+
timeoutMs: 8000,
|
|
451
|
+
onMessage: async (payload) => {
|
|
452
|
+
const p = payload?.params || {};
|
|
453
|
+
if (p.error) {
|
|
454
|
+
emitEvent({ type: 'CHECK_SSO_RESULT', result: 'no_session', error: p.error });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
if (!p.code || !p.state) return;
|
|
458
|
+
if (p.state !== transactionStore.get('silentState')) {
|
|
459
|
+
debugLog('silent_sso.state_mismatch', {
|
|
460
|
+
state: p.state,
|
|
461
|
+
expectedState: transactionStore.get('silentState'),
|
|
462
|
+
tenantId: config?.tenantId,
|
|
463
|
+
clientId: config?.clientId,
|
|
464
|
+
});
|
|
465
|
+
emitEvent({ type: 'CHECK_SSO_RESULT', result: 'state_mismatch' });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
const silentVerifier = transactionStore.get('silentCodeVerifier');
|
|
469
|
+
const tokenResponse = await oidc.exchangeCodeForTokens({
|
|
470
|
+
code: p.code,
|
|
471
|
+
codeVerifier: silentVerifier,
|
|
472
|
+
redirectUri: config.silentCheckSsoRedirectUri,
|
|
473
|
+
});
|
|
474
|
+
tokenStore.set(tokenResponse);
|
|
475
|
+
computeUser();
|
|
476
|
+
setStatus('authenticated');
|
|
477
|
+
emitEvent({ type: 'CHECK_SSO_RESULT', result: 'authenticated' });
|
|
478
|
+
},
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
async function handleSilentCallbackMessage(event) {
|
|
483
|
+
// Optional external wiring if app prefers manual listener:
|
|
484
|
+
// window.addEventListener('message', (ev)=>engine.handleSilentCallbackMessage(ev))
|
|
485
|
+
const data = event?.data;
|
|
486
|
+
if (!data || data.type !== 'CMFRT_OIDC_SILENT_CALLBACK') return;
|
|
487
|
+
// No-op: checkSsoSilent registers its own listener via runHiddenIFrameFlow.
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
async function logout({ postLogoutRedirectUri } = {}) {
|
|
491
|
+
if (!oidc || !config) throw new Error('[cmfrt/auth] logout called before init.');
|
|
492
|
+
if (typeof window === 'undefined') return;
|
|
493
|
+
|
|
494
|
+
emitEvent({ type: 'LOGOUT_START' });
|
|
495
|
+
if (config.logoutPolicy?.broadcast) {
|
|
496
|
+
sync.post({ type: 'LOGOUT', tenantId: config.tenantId });
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const idTokenHint = tokenStore.getIdToken();
|
|
500
|
+
clearSession('logout');
|
|
501
|
+
|
|
502
|
+
if (config.logoutPolicy?.frontChannel) {
|
|
503
|
+
const url = oidc.buildLogoutUrl({
|
|
504
|
+
idTokenHint,
|
|
505
|
+
postLogoutRedirectUri: postLogoutRedirectUri || config.postLogoutRedirectUri || window.location.origin,
|
|
506
|
+
});
|
|
507
|
+
if (url) {
|
|
508
|
+
window.location.assign(url);
|
|
509
|
+
return new Promise(() => {});
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function setTenant(tenant) {
|
|
515
|
+
if (!tenant?.tenantId) throw new Error('[cmfrt/auth] setTenant requires tenantId.');
|
|
516
|
+
tenantId = tenant.tenantId;
|
|
517
|
+
emitEvent({ type: 'TENANT_SET', tenantId });
|
|
518
|
+
notifySnapshot();
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
async function switchTenant(tenant, opts = {}) {
|
|
522
|
+
if (!tenant?.tenantId) throw new Error('[cmfrt/auth] switchTenant requires tenantId.');
|
|
523
|
+
const from = tenantId;
|
|
524
|
+
if (from === tenant.tenantId) return;
|
|
525
|
+
|
|
526
|
+
emitEvent({ type: 'TENANT_SWITCH_START', fromTenantId: from, toTenantId: tenant.tenantId });
|
|
527
|
+
sync.post({ type: 'TENANT_SWITCH', fromTenantId: from, toTenantId: tenant.tenantId });
|
|
528
|
+
|
|
529
|
+
clearSession('tenant_switch');
|
|
530
|
+
tenantId = tenant.tenantId;
|
|
531
|
+
|
|
532
|
+
// Re-init with new tenant context.
|
|
533
|
+
await init({ checkSso: opts.checkSso ?? true });
|
|
534
|
+
emitEvent({ type: 'TENANT_SWITCH_DONE', fromTenantId: from, toTenantId: tenant.tenantId });
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
function hasRole(role) {
|
|
538
|
+
return perm.hasRole(user, role);
|
|
539
|
+
}
|
|
540
|
+
function hasAnyRole(roles) {
|
|
541
|
+
return perm.hasAnyRole(user, roles);
|
|
542
|
+
}
|
|
543
|
+
function hasAllRoles(roles) {
|
|
544
|
+
return perm.hasAllRoles(user, roles);
|
|
545
|
+
}
|
|
546
|
+
function hasPermission(permission) {
|
|
547
|
+
return perm.hasPermission(user, permission);
|
|
548
|
+
}
|
|
549
|
+
function can(permission) {
|
|
550
|
+
return perm.can(user, permission);
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
// Kick init automatically for the default-engine use-case.
|
|
554
|
+
// Apps can ignore and call `engine.init()` explicitly if they prefer.
|
|
555
|
+
init().catch((e) => setStatus('error', e));
|
|
556
|
+
|
|
557
|
+
return {
|
|
558
|
+
getSnapshot,
|
|
559
|
+
subscribe,
|
|
560
|
+
subscribeAuthEvents,
|
|
561
|
+
init,
|
|
562
|
+
login,
|
|
563
|
+
logout,
|
|
564
|
+
handleCallback,
|
|
565
|
+
handleSilentCallbackMessage,
|
|
566
|
+
ensureFreshToken,
|
|
567
|
+
refresh,
|
|
568
|
+
getAccessToken,
|
|
569
|
+
getIdToken,
|
|
570
|
+
isAuthenticated,
|
|
571
|
+
getUser,
|
|
572
|
+
clearSession,
|
|
573
|
+
setTenant,
|
|
574
|
+
switchTenant,
|
|
575
|
+
hasRole,
|
|
576
|
+
hasAnyRole,
|
|
577
|
+
hasAllRoles,
|
|
578
|
+
hasPermission,
|
|
579
|
+
can,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
async function runHiddenIFrameFlow({ url, messageType, timeoutMs, onMessage }) {
|
|
584
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
585
|
+
|
|
586
|
+
const iframe = document.createElement('iframe');
|
|
587
|
+
iframe.style.display = 'none';
|
|
588
|
+
iframe.setAttribute('aria-hidden', 'true');
|
|
589
|
+
iframe.src = url;
|
|
590
|
+
|
|
591
|
+
let done = false;
|
|
592
|
+
let timer = null;
|
|
593
|
+
|
|
594
|
+
const cleanup = () => {
|
|
595
|
+
if (done) return;
|
|
596
|
+
done = true;
|
|
597
|
+
if (timer) clearTimeout(timer);
|
|
598
|
+
window.removeEventListener('message', handleWindowMessage);
|
|
599
|
+
try {
|
|
600
|
+
iframe.remove();
|
|
601
|
+
} catch {
|
|
602
|
+
// ignore
|
|
603
|
+
}
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
const handleWindowMessage = async (ev) => {
|
|
607
|
+
const data = ev?.data;
|
|
608
|
+
if (!data || data.type !== messageType) return;
|
|
609
|
+
try {
|
|
610
|
+
await onMessage(data);
|
|
611
|
+
} finally {
|
|
612
|
+
cleanup();
|
|
613
|
+
}
|
|
614
|
+
};
|
|
615
|
+
|
|
616
|
+
window.addEventListener('message', handleWindowMessage);
|
|
617
|
+
document.body.appendChild(iframe);
|
|
618
|
+
|
|
619
|
+
timer = setTimeout(() => {
|
|
620
|
+
cleanup();
|
|
621
|
+
}, timeoutMs);
|
|
622
|
+
}
|
|
623
|
+
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal OIDC client for SPA-direct Authorization Code Flow + PKCE.
|
|
3
|
+
* Designed for Keycloak but uses standard OIDC discovery endpoints.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export function createOidcClient({ config, discovery, fetchImpl = fetch }) {
|
|
7
|
+
const issuer = config.issuer.replace(/\/+$/g, '');
|
|
8
|
+
const clientId = config.clientId;
|
|
9
|
+
|
|
10
|
+
function buildAuthorizeUrl(params) {
|
|
11
|
+
const url = new URL(discovery.authorization_endpoint);
|
|
12
|
+
url.searchParams.set('client_id', clientId);
|
|
13
|
+
url.searchParams.set('response_type', 'code');
|
|
14
|
+
url.searchParams.set('redirect_uri', params.redirectUri);
|
|
15
|
+
url.searchParams.set('scope', params.scope);
|
|
16
|
+
url.searchParams.set('state', params.state);
|
|
17
|
+
url.searchParams.set('code_challenge', params.codeChallenge);
|
|
18
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
19
|
+
if (params.nonce) url.searchParams.set('nonce', params.nonce);
|
|
20
|
+
if (params.prompt) url.searchParams.set('prompt', params.prompt);
|
|
21
|
+
if (params.audience) url.searchParams.set('audience', params.audience);
|
|
22
|
+
if (params.loginHint) url.searchParams.set('login_hint', params.loginHint);
|
|
23
|
+
if (params.idpHint) url.searchParams.set('kc_idp_hint', params.idpHint); // Keycloak-specific optional hint
|
|
24
|
+
return url.toString();
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async function tokenRequest(body) {
|
|
28
|
+
const res = await fetchImpl(discovery.token_endpoint, {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'content-type': 'application/x-www-form-urlencoded', accept: 'application/json' },
|
|
31
|
+
body: new URLSearchParams(body).toString(),
|
|
32
|
+
});
|
|
33
|
+
const json = await res.json().catch(() => ({}));
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const details = json?.error_description || json?.error || `${res.status} ${res.statusText}`;
|
|
36
|
+
throw new Error(`[cmfrt/auth] Token endpoint error: ${details}`);
|
|
37
|
+
}
|
|
38
|
+
return json;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
async function exchangeCodeForTokens({ code, codeVerifier, redirectUri }) {
|
|
42
|
+
return tokenRequest({
|
|
43
|
+
grant_type: 'authorization_code',
|
|
44
|
+
client_id: clientId,
|
|
45
|
+
code,
|
|
46
|
+
redirect_uri: redirectUri,
|
|
47
|
+
code_verifier: codeVerifier,
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function refreshTokens({ refreshToken }) {
|
|
52
|
+
return tokenRequest({
|
|
53
|
+
grant_type: 'refresh_token',
|
|
54
|
+
client_id: clientId,
|
|
55
|
+
refresh_token: refreshToken,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function buildLogoutUrl({ idTokenHint, postLogoutRedirectUri }) {
|
|
60
|
+
const endSession = discovery.end_session_endpoint || discovery.end_session_endpoint;
|
|
61
|
+
if (!endSession) return null;
|
|
62
|
+
const url = new URL(endSession);
|
|
63
|
+
if (idTokenHint) url.searchParams.set('id_token_hint', idTokenHint);
|
|
64
|
+
if (postLogoutRedirectUri) url.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
|
|
65
|
+
url.searchParams.set('client_id', clientId);
|
|
66
|
+
return url.toString();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
issuer,
|
|
71
|
+
clientId,
|
|
72
|
+
discovery,
|
|
73
|
+
buildAuthorizeUrl,
|
|
74
|
+
exchangeCodeForTokens,
|
|
75
|
+
refreshTokens,
|
|
76
|
+
buildLogoutUrl,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|