@svadmin/sso 0.9.0 → 0.11.0
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/dist/auth-helpers.d.ts +23 -0
- package/dist/auth-helpers.js +97 -0
- package/dist/auth-provider.d.ts +68 -0
- package/dist/auth-provider.js +555 -0
- package/dist/authenticated-fetch.d.ts +9 -0
- package/dist/authenticated-fetch.js +71 -0
- package/dist/errors.d.ts +19 -0
- package/dist/errors.js +86 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +14 -0
- package/dist/presets.d.ts +81 -0
- package/dist/presets.js +165 -0
- package/dist/session-manager.d.ts +50 -0
- package/dist/session-manager.js +566 -0
- package/package.json +11 -11
- package/src/auth-provider.ts +0 -485
- package/src/index.ts +0 -22
- package/src/presets.ts +0 -179
package/src/auth-provider.ts
DELETED
|
@@ -1,485 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @svadmin/sso — OIDC/OAuth2 SSO AuthProvider for svadmin.
|
|
3
|
-
*
|
|
4
|
-
* Supports any OIDC-compliant Identity Provider:
|
|
5
|
-
* - Okta
|
|
6
|
-
* - Azure AD / Entra ID
|
|
7
|
-
* - Amazon Cognito
|
|
8
|
-
* - Keycloak
|
|
9
|
-
* - Google Workspace
|
|
10
|
-
* - Auth0
|
|
11
|
-
* - Any custom OIDC server
|
|
12
|
-
*
|
|
13
|
-
* Uses the Authorization Code Flow with PKCE (no client secret needed in browser).
|
|
14
|
-
*
|
|
15
|
-
* @example
|
|
16
|
-
* ```ts
|
|
17
|
-
* import { createSSOAuthProvider } from '@svadmin/sso';
|
|
18
|
-
* import { setAuthProvider } from '@svadmin/core';
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
*
|
|
22
|
-
* const authProvider = createSSOAuthProvider({
|
|
23
|
-
* issuer: 'https://your-tenant.okta.com',
|
|
24
|
-
* clientId: 'your-client-id',
|
|
25
|
-
* redirectUri: window.location.origin + '/callback',
|
|
26
|
-
* });
|
|
27
|
-
*
|
|
28
|
-
* setAuthProvider(authProvider);
|
|
29
|
-
* ```
|
|
30
|
-
*/
|
|
31
|
-
|
|
32
|
-
import type { AuthProvider, Identity } from '@svadmin/core';
|
|
33
|
-
|
|
34
|
-
// Safe base64url decode for JWT payloads
|
|
35
|
-
function decodeJwtPayload(token: string): Record<string, unknown> | null {
|
|
36
|
-
if (!token) return null;
|
|
37
|
-
const parts = token.split('.');
|
|
38
|
-
if (parts.length < 2) return null;
|
|
39
|
-
const payloadStr = parts[1];
|
|
40
|
-
const padded = payloadStr + '='.repeat((4 - (payloadStr.length % 4)) % 4);
|
|
41
|
-
const binString = atob(padded.replace(/-/g, '+').replace(/_/g, '/'));
|
|
42
|
-
const bytes = Uint8Array.from(binString, (m) => m.codePointAt(0) ?? 0);
|
|
43
|
-
return JSON.parse(new TextDecoder().decode(bytes));
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
// ─── Types ────────────────────────────────────────────────────
|
|
48
|
-
|
|
49
|
-
export interface SSOConfig {
|
|
50
|
-
/** OIDC Issuer URL (e.g., 'https://your-tenant.okta.com') */
|
|
51
|
-
issuer: string;
|
|
52
|
-
/** OAuth2 Client ID */
|
|
53
|
-
clientId: string;
|
|
54
|
-
/** Redirect URI after login (e.g., '/callback') */
|
|
55
|
-
redirectUri: string;
|
|
56
|
-
/** OAuth2 scopes. Default: ['openid', 'profile', 'email'] */
|
|
57
|
-
scopes?: string[];
|
|
58
|
-
/** Where to redirect after logout */
|
|
59
|
-
postLogoutRedirectUri?: string;
|
|
60
|
-
/** Token storage backend. Default: 'local' (localStorage) */
|
|
61
|
-
storage?: 'local' | 'session' | TokenStorage;
|
|
62
|
-
/** Custom identity mapper — transform OIDC userinfo to svadmin Identity */
|
|
63
|
-
mapIdentity?: (userinfo: Record<string, unknown>) => Identity;
|
|
64
|
-
/** Auto-refresh tokens before expiry. Default: true */
|
|
65
|
-
autoRefresh?: boolean;
|
|
66
|
-
/** Seconds before expiry to trigger refresh. Default: 60 */
|
|
67
|
-
refreshBuffer?: number;
|
|
68
|
-
/** Additional authorization request parameters, e.g. audience or prompt. */
|
|
69
|
-
authorizationParams?: Record<string, string>;
|
|
70
|
-
/**
|
|
71
|
-
* Manual OAuth2 endpoints for providers that don't support OIDC discovery
|
|
72
|
-
* (e.g., GitHub). When provided, OIDC auto-discovery is skipped.
|
|
73
|
-
*/
|
|
74
|
-
manualEndpoints?: {
|
|
75
|
-
authorization_endpoint: string;
|
|
76
|
-
token_endpoint: string;
|
|
77
|
-
userinfo_endpoint: string;
|
|
78
|
-
end_session_endpoint?: string;
|
|
79
|
-
};
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export interface TokenStorage {
|
|
83
|
-
getItem: (key: string) => string | null;
|
|
84
|
-
setItem: (key: string, value: string) => void;
|
|
85
|
-
removeItem: (key: string) => void;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
export interface SSOAuthProvider extends AuthProvider {
|
|
89
|
-
getAccessToken: () => Promise<string | null>;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
interface OIDCConfig {
|
|
93
|
-
authorization_endpoint: string;
|
|
94
|
-
token_endpoint: string;
|
|
95
|
-
userinfo_endpoint: string;
|
|
96
|
-
end_session_endpoint?: string;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
interface TokenSet {
|
|
100
|
-
access_token: string;
|
|
101
|
-
id_token?: string;
|
|
102
|
-
refresh_token?: string;
|
|
103
|
-
expires_at?: number;
|
|
104
|
-
token_type: string;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
// ─── PKCE Utilities ───────────────────────────────────────────
|
|
108
|
-
|
|
109
|
-
function generateRandomString(length: number): string {
|
|
110
|
-
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
|
|
111
|
-
const values = crypto.getRandomValues(new Uint8Array(length));
|
|
112
|
-
return Array.from(values, (v) => chars[v % chars.length]).join('');
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
async function sha256(plain: string): Promise<ArrayBuffer> {
|
|
116
|
-
const encoder = new TextEncoder();
|
|
117
|
-
return crypto.subtle.digest('SHA-256', encoder.encode(plain));
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function base64urlEncode(buffer: ArrayBuffer): string {
|
|
121
|
-
const bytes = new Uint8Array(buffer);
|
|
122
|
-
let binary = '';
|
|
123
|
-
for (const byte of bytes) binary += String.fromCharCode(byte);
|
|
124
|
-
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
async function createPKCEChallenge(): Promise<{ verifier: string; challenge: string }> {
|
|
128
|
-
const verifier = generateRandomString(64);
|
|
129
|
-
const hashed = await sha256(verifier);
|
|
130
|
-
const challenge = base64urlEncode(hashed);
|
|
131
|
-
return { verifier, challenge };
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
// ─── Storage Helper ───────────────────────────────────────────
|
|
135
|
-
|
|
136
|
-
const STORAGE_PREFIX = 'svadmin_sso_';
|
|
137
|
-
|
|
138
|
-
function getStorage(config: SSOConfig): TokenStorage {
|
|
139
|
-
if (config.storage && typeof config.storage === 'object') return config.storage;
|
|
140
|
-
return {
|
|
141
|
-
getItem: (key) => typeof window !== 'undefined' ? (config.storage === 'session' ? sessionStorage : localStorage).getItem(key) : null,
|
|
142
|
-
setItem: (key, value) => { if (typeof window !== 'undefined') (config.storage === 'session' ? sessionStorage : localStorage).setItem(key, value); },
|
|
143
|
-
removeItem: (key) => { if (typeof window !== 'undefined') (config.storage === 'session' ? sessionStorage : localStorage).removeItem(key); },
|
|
144
|
-
};
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// ─── Provider ─────────────────────────────────────────────────
|
|
148
|
-
|
|
149
|
-
/**
|
|
150
|
-
* Create an OIDC/OAuth2 SSO AuthProvider.
|
|
151
|
-
*/
|
|
152
|
-
export function createSSOAuthProvider(config: SSOConfig): SSOAuthProvider {
|
|
153
|
-
const scopes = config.scopes ?? ['openid', 'profile', 'email'];
|
|
154
|
-
const autoRefresh = config.autoRefresh ?? true;
|
|
155
|
-
const refreshBuffer = config.refreshBuffer ?? 60;
|
|
156
|
-
const storage = getStorage(config);
|
|
157
|
-
|
|
158
|
-
let oidcConfig: OIDCConfig | null = null;
|
|
159
|
-
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
160
|
-
|
|
161
|
-
// ── OIDC Discovery ───────────────────────────────────────
|
|
162
|
-
|
|
163
|
-
async function discover(): Promise<OIDCConfig> {
|
|
164
|
-
if (oidcConfig) return oidcConfig;
|
|
165
|
-
|
|
166
|
-
// Use manual endpoints if provided (e.g., GitHub)
|
|
167
|
-
if (config.manualEndpoints) {
|
|
168
|
-
oidcConfig = config.manualEndpoints;
|
|
169
|
-
return oidcConfig;
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
const url = `${config.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
|
|
173
|
-
const res = await fetch(url);
|
|
174
|
-
if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status} ${res.statusText}`);
|
|
175
|
-
oidcConfig = await res.json() as OIDCConfig;
|
|
176
|
-
return oidcConfig;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
// ── Token Management ─────────────────────────────────────
|
|
180
|
-
|
|
181
|
-
function getTokens(): TokenSet | null {
|
|
182
|
-
const raw = storage.getItem(`${STORAGE_PREFIX}tokens`);
|
|
183
|
-
if (!raw) return null;
|
|
184
|
-
try { return JSON.parse(raw) as TokenSet; }
|
|
185
|
-
catch { return null; }
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function setTokens(tokens: TokenSet): void {
|
|
189
|
-
storage.setItem(`${STORAGE_PREFIX}tokens`, JSON.stringify(tokens));
|
|
190
|
-
if (autoRefresh && tokens.refresh_token && tokens.expires_at) {
|
|
191
|
-
scheduleRefresh(tokens);
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
function clearTokens(): void {
|
|
196
|
-
storage.removeItem(`${STORAGE_PREFIX}tokens`);
|
|
197
|
-
storage.removeItem(`${STORAGE_PREFIX}pkce_verifier`);
|
|
198
|
-
storage.removeItem(`${STORAGE_PREFIX}state`);
|
|
199
|
-
if (refreshTimer) clearTimeout(refreshTimer);
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
function scheduleRefresh(tokens: TokenSet): void {
|
|
203
|
-
if (refreshTimer) clearTimeout(refreshTimer);
|
|
204
|
-
if (!tokens.expires_at || !tokens.refresh_token) return;
|
|
205
|
-
const now = Math.floor(Date.now() / 1000);
|
|
206
|
-
const delay = Math.max(0, (tokens.expires_at - now - refreshBuffer)) * 1000;
|
|
207
|
-
const refreshToken = tokens.refresh_token;
|
|
208
|
-
refreshTimer = setTimeout(() => refreshAccessToken(refreshToken), delay);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
async function refreshAccessToken(refreshToken: string): Promise<void> {
|
|
212
|
-
try {
|
|
213
|
-
const cfg = await discover();
|
|
214
|
-
const body = new URLSearchParams({
|
|
215
|
-
grant_type: 'refresh_token',
|
|
216
|
-
client_id: config.clientId,
|
|
217
|
-
refresh_token: refreshToken,
|
|
218
|
-
});
|
|
219
|
-
const res = await fetch(cfg.token_endpoint, {
|
|
220
|
-
method: 'POST',
|
|
221
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
222
|
-
body,
|
|
223
|
-
});
|
|
224
|
-
if (!res.ok) { clearTokens(); return; }
|
|
225
|
-
const data = await res.json();
|
|
226
|
-
if (!data.access_token) {
|
|
227
|
-
clearTokens();
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
const newTokens: TokenSet = {
|
|
231
|
-
access_token: data.access_token,
|
|
232
|
-
id_token: data.id_token,
|
|
233
|
-
refresh_token: data.refresh_token ?? refreshToken,
|
|
234
|
-
expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined,
|
|
235
|
-
token_type: data.token_type ?? 'Bearer',
|
|
236
|
-
};
|
|
237
|
-
setTokens(newTokens);
|
|
238
|
-
} catch {
|
|
239
|
-
clearTokens();
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// ── Token Exchange ───────────────────────────────────────
|
|
244
|
-
|
|
245
|
-
async function exchangeCode(code: string): Promise<TokenSet> {
|
|
246
|
-
const cfg = await discover();
|
|
247
|
-
const verifier = storage.getItem(`${STORAGE_PREFIX}pkce_verifier`);
|
|
248
|
-
const body = new URLSearchParams({
|
|
249
|
-
grant_type: 'authorization_code',
|
|
250
|
-
client_id: config.clientId,
|
|
251
|
-
code,
|
|
252
|
-
redirect_uri: config.redirectUri,
|
|
253
|
-
...(verifier ? { code_verifier: verifier } : {}),
|
|
254
|
-
});
|
|
255
|
-
const res = await fetch(cfg.token_endpoint, {
|
|
256
|
-
method: 'POST',
|
|
257
|
-
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
258
|
-
body,
|
|
259
|
-
});
|
|
260
|
-
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
|
|
261
|
-
const data = await res.json();
|
|
262
|
-
if (!data.access_token) throw new Error('Token exchange failed: missing access_token');
|
|
263
|
-
const tokens: TokenSet = {
|
|
264
|
-
access_token: data.access_token,
|
|
265
|
-
id_token: data.id_token,
|
|
266
|
-
refresh_token: data.refresh_token,
|
|
267
|
-
expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined,
|
|
268
|
-
token_type: data.token_type ?? 'Bearer',
|
|
269
|
-
};
|
|
270
|
-
setTokens(tokens);
|
|
271
|
-
storage.removeItem(`${STORAGE_PREFIX}pkce_verifier`);
|
|
272
|
-
storage.removeItem(`${STORAGE_PREFIX}state`);
|
|
273
|
-
return tokens;
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
// ── Default Identity Mapper ──────────────────────────────
|
|
277
|
-
|
|
278
|
-
function defaultMapIdentity(userinfo: Record<string, unknown>): Identity {
|
|
279
|
-
return {
|
|
280
|
-
id: (userinfo.sub as string) ?? '',
|
|
281
|
-
name: (userinfo.name as string) ?? (userinfo.preferred_username as string) ?? '',
|
|
282
|
-
avatar: (userinfo.picture as string) ?? undefined,
|
|
283
|
-
email: (userinfo.email as string) ?? undefined,
|
|
284
|
-
};
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const mapIdentity = config.mapIdentity ?? defaultMapIdentity;
|
|
288
|
-
|
|
289
|
-
// ── AuthProvider Implementation ──────────────────────────
|
|
290
|
-
|
|
291
|
-
return {
|
|
292
|
-
async login() {
|
|
293
|
-
if (typeof window === 'undefined') {
|
|
294
|
-
return { success: false, error: { message: 'SSO login requires a browser environment' } };
|
|
295
|
-
}
|
|
296
|
-
try {
|
|
297
|
-
const cfg = await discover();
|
|
298
|
-
const { verifier, challenge } = await createPKCEChallenge();
|
|
299
|
-
const state = generateRandomString(32);
|
|
300
|
-
|
|
301
|
-
storage.setItem(`${STORAGE_PREFIX}pkce_verifier`, verifier);
|
|
302
|
-
storage.setItem(`${STORAGE_PREFIX}state`, state);
|
|
303
|
-
|
|
304
|
-
const params = new URLSearchParams({
|
|
305
|
-
response_type: 'code',
|
|
306
|
-
client_id: config.clientId,
|
|
307
|
-
redirect_uri: config.redirectUri,
|
|
308
|
-
scope: scopes.join(' '),
|
|
309
|
-
state,
|
|
310
|
-
code_challenge: challenge,
|
|
311
|
-
code_challenge_method: 'S256',
|
|
312
|
-
...config.authorizationParams,
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
// Redirect to authorization endpoint
|
|
316
|
-
window.location.href = `${cfg.authorization_endpoint}?${params}`;
|
|
317
|
-
|
|
318
|
-
// Won't actually reach here due to redirect
|
|
319
|
-
return { success: true };
|
|
320
|
-
} catch (err) {
|
|
321
|
-
return { success: false, error: { message: err instanceof Error ? err.message : 'SSO login failed' } };
|
|
322
|
-
}
|
|
323
|
-
},
|
|
324
|
-
|
|
325
|
-
async logout() {
|
|
326
|
-
const cfg = await discover();
|
|
327
|
-
const tokens = getTokens();
|
|
328
|
-
clearTokens();
|
|
329
|
-
|
|
330
|
-
if (typeof window !== 'undefined' && cfg.end_session_endpoint && tokens?.id_token) {
|
|
331
|
-
const params = new URLSearchParams({
|
|
332
|
-
id_token_hint: tokens.id_token,
|
|
333
|
-
...(config.postLogoutRedirectUri
|
|
334
|
-
? { post_logout_redirect_uri: config.postLogoutRedirectUri }
|
|
335
|
-
: {}),
|
|
336
|
-
});
|
|
337
|
-
window.location.href = `${cfg.end_session_endpoint}?${params}`;
|
|
338
|
-
return { success: true };
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
return {
|
|
342
|
-
success: true,
|
|
343
|
-
redirectTo: config.postLogoutRedirectUri ?? '/',
|
|
344
|
-
};
|
|
345
|
-
},
|
|
346
|
-
|
|
347
|
-
async check() {
|
|
348
|
-
// SSR: no tokens, no callback — return unauthenticated
|
|
349
|
-
if (typeof window === 'undefined') {
|
|
350
|
-
const tokens = getTokens();
|
|
351
|
-
if (tokens && (!tokens.expires_at || tokens.expires_at > Math.floor(Date.now() / 1000))) {
|
|
352
|
-
return { authenticated: true };
|
|
353
|
-
}
|
|
354
|
-
return { authenticated: false, logout: true };
|
|
355
|
-
}
|
|
356
|
-
|
|
357
|
-
// Handle callback — exchange authorization code for tokens
|
|
358
|
-
const url = new URL(window.location.href);
|
|
359
|
-
const callbackError = url.searchParams.get('error');
|
|
360
|
-
if (callbackError) {
|
|
361
|
-
clearTokens();
|
|
362
|
-
return {
|
|
363
|
-
authenticated: false,
|
|
364
|
-
error: {
|
|
365
|
-
message: url.searchParams.get('error_description') ?? callbackError,
|
|
366
|
-
name: callbackError,
|
|
367
|
-
},
|
|
368
|
-
logout: true,
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
const code = url.searchParams.get('code');
|
|
373
|
-
const state = url.searchParams.get('state');
|
|
374
|
-
|
|
375
|
-
if (code) {
|
|
376
|
-
const savedState = storage.getItem(`${STORAGE_PREFIX}state`);
|
|
377
|
-
if (!state || !savedState || state !== savedState) {
|
|
378
|
-
clearTokens();
|
|
379
|
-
return { authenticated: false, error: { message: 'State mismatch' } };
|
|
380
|
-
}
|
|
381
|
-
try {
|
|
382
|
-
await exchangeCode(code);
|
|
383
|
-
// Clean URL
|
|
384
|
-
url.searchParams.delete('code');
|
|
385
|
-
url.searchParams.delete('state');
|
|
386
|
-
window.history.replaceState({}, '', `${url.pathname}${url.search}${url.hash}`);
|
|
387
|
-
return { authenticated: true };
|
|
388
|
-
} catch (err) {
|
|
389
|
-
return { authenticated: false, error: { message: String(err) } };
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
// Check existing tokens
|
|
394
|
-
const tokens = getTokens();
|
|
395
|
-
if (!tokens) {
|
|
396
|
-
return { authenticated: false, logout: true };
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
// Check expiry
|
|
400
|
-
if (tokens.expires_at && tokens.expires_at < Math.floor(Date.now() / 1000)) {
|
|
401
|
-
if (tokens.refresh_token) {
|
|
402
|
-
await refreshAccessToken(tokens.refresh_token);
|
|
403
|
-
const refreshed = getTokens();
|
|
404
|
-
if (refreshed) return { authenticated: true };
|
|
405
|
-
}
|
|
406
|
-
clearTokens();
|
|
407
|
-
return { authenticated: false, logout: true };
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// Schedule refresh if not already done
|
|
411
|
-
if (autoRefresh && tokens.refresh_token && tokens.expires_at) {
|
|
412
|
-
scheduleRefresh(tokens);
|
|
413
|
-
}
|
|
414
|
-
|
|
415
|
-
return { authenticated: true };
|
|
416
|
-
},
|
|
417
|
-
|
|
418
|
-
async getIdentity(): Promise<Identity | null> {
|
|
419
|
-
const tokens = getTokens();
|
|
420
|
-
if (!tokens) return null;
|
|
421
|
-
|
|
422
|
-
try {
|
|
423
|
-
const cfg = await discover();
|
|
424
|
-
const res = await fetch(cfg.userinfo_endpoint, {
|
|
425
|
-
headers: { Authorization: `${tokens.token_type} ${tokens.access_token}` },
|
|
426
|
-
});
|
|
427
|
-
if (!res.ok) return null;
|
|
428
|
-
const userinfo = await res.json();
|
|
429
|
-
return mapIdentity(userinfo);
|
|
430
|
-
} catch {
|
|
431
|
-
return null;
|
|
432
|
-
}
|
|
433
|
-
},
|
|
434
|
-
|
|
435
|
-
async getPermissions() {
|
|
436
|
-
const tokens = getTokens();
|
|
437
|
-
if (!tokens) return null;
|
|
438
|
-
|
|
439
|
-
// Try to extract roles/permissions from the ID token
|
|
440
|
-
if (tokens.id_token) {
|
|
441
|
-
try {
|
|
442
|
-
const payload = decodeJwtPayload(tokens.id_token);
|
|
443
|
-
if (!payload) return null;
|
|
444
|
-
return payload.roles ?? payload.groups ?? payload.permissions ?? null;
|
|
445
|
-
} catch {
|
|
446
|
-
return null;
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
return null;
|
|
451
|
-
},
|
|
452
|
-
|
|
453
|
-
async getAccessToken() {
|
|
454
|
-
const tokens = getTokens();
|
|
455
|
-
if (!tokens) return null;
|
|
456
|
-
|
|
457
|
-
if (tokens.expires_at && tokens.expires_at < Math.floor(Date.now() / 1000)) {
|
|
458
|
-
if (!tokens.refresh_token) {
|
|
459
|
-
clearTokens();
|
|
460
|
-
return null;
|
|
461
|
-
}
|
|
462
|
-
await refreshAccessToken(tokens.refresh_token);
|
|
463
|
-
return getTokens()?.access_token ?? null;
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
return tokens.access_token;
|
|
467
|
-
},
|
|
468
|
-
|
|
469
|
-
async onError(error) {
|
|
470
|
-
if (typeof error === 'object' && error !== null && 'statusCode' in error) {
|
|
471
|
-
const statusCode = (error as { statusCode: number }).statusCode;
|
|
472
|
-
if (statusCode === 401 || statusCode === 403) {
|
|
473
|
-
return { logout: true, redirectTo: '/login' };
|
|
474
|
-
}
|
|
475
|
-
}
|
|
476
|
-
if (typeof error === 'object' && error !== null && 'status' in error) {
|
|
477
|
-
const status = (error as { status: number }).status;
|
|
478
|
-
if (status === 401 || status === 403) {
|
|
479
|
-
return { logout: true, redirectTo: '/login' };
|
|
480
|
-
}
|
|
481
|
-
}
|
|
482
|
-
return {};
|
|
483
|
-
},
|
|
484
|
-
};
|
|
485
|
-
}
|
package/src/index.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @svadmin/sso — OIDC/OAuth2 SSO AuthProvider plugin
|
|
3
|
-
*
|
|
4
|
-
* Usage:
|
|
5
|
-
* ```ts
|
|
6
|
-
* import { createSSOAuthProvider, createGoogleAuth } from '@svadmin/sso';
|
|
7
|
-
* ```
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
export { createSSOAuthProvider } from './auth-provider';
|
|
11
|
-
export type { SSOAuthProvider, SSOConfig, TokenStorage } from './auth-provider';
|
|
12
|
-
|
|
13
|
-
// Social login presets
|
|
14
|
-
export {
|
|
15
|
-
createGoogleAuth,
|
|
16
|
-
createMicrosoftAuth,
|
|
17
|
-
createGitHubAuth,
|
|
18
|
-
createGitLabAuth,
|
|
19
|
-
createKeycloakAuth,
|
|
20
|
-
createAuth0Auth,
|
|
21
|
-
} from './presets';
|
|
22
|
-
export type { PresetOptions } from './presets';
|
package/src/presets.ts
DELETED
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Social login presets — pre-configured SSO providers for common identity platforms.
|
|
3
|
-
*
|
|
4
|
-
* Each factory function returns an AuthProvider via `createSSOAuthProvider()`
|
|
5
|
-
* with the correct issuer and default scopes for that platform.
|
|
6
|
-
*
|
|
7
|
-
* @example
|
|
8
|
-
* ```ts
|
|
9
|
-
* import { createGoogleAuth } from '@svadmin/sso';
|
|
10
|
-
* import { setAuthProvider } from '@svadmin/core';
|
|
11
|
-
*
|
|
12
|
-
* setAuthProvider(createGoogleAuth('your-client-id'));
|
|
13
|
-
* ```
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import { createSSOAuthProvider, type SSOConfig } from './auth-provider';
|
|
17
|
-
|
|
18
|
-
// ─── Shared helpers ──────────────────────────────────────────
|
|
19
|
-
|
|
20
|
-
type PresetOptions = Omit<SSOConfig, 'issuer' | 'clientId'>;
|
|
21
|
-
|
|
22
|
-
function resolveRedirectUri(opts?: PresetOptions): string {
|
|
23
|
-
return opts?.redirectUri ?? (typeof window !== 'undefined' ? `${window.location.origin}/callback` : '/callback');
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
// ─── Google ──────────────────────────────────────────────────
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Google Sign-In via OIDC (OpenID Connect).
|
|
30
|
-
*
|
|
31
|
-
* Prerequisites:
|
|
32
|
-
* 1. Create OAuth2 credentials at https://console.cloud.google.com/apis/credentials
|
|
33
|
-
* 2. Add authorized redirect URIs (e.g., `http://localhost:5173/callback`)
|
|
34
|
-
*
|
|
35
|
-
* @param clientId - Google OAuth2 Client ID
|
|
36
|
-
*/
|
|
37
|
-
export function createGoogleAuth(clientId: string, opts?: PresetOptions) {
|
|
38
|
-
return createSSOAuthProvider({
|
|
39
|
-
issuer: 'https://accounts.google.com',
|
|
40
|
-
clientId,
|
|
41
|
-
scopes: ['openid', 'profile', 'email'],
|
|
42
|
-
redirectUri: resolveRedirectUri(opts),
|
|
43
|
-
...opts,
|
|
44
|
-
});
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
// ─── Microsoft / Azure AD (Entra ID) ────────────────────────
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Microsoft / Azure AD Sign-In via OIDC.
|
|
51
|
-
*
|
|
52
|
-
* @param clientId - Azure App Registration Client ID
|
|
53
|
-
* @param tenantId - Azure tenant ID. Default: 'common' (multi-tenant)
|
|
54
|
-
*/
|
|
55
|
-
export function createMicrosoftAuth(
|
|
56
|
-
clientId: string,
|
|
57
|
-
tenantId = 'common',
|
|
58
|
-
opts?: PresetOptions,
|
|
59
|
-
) {
|
|
60
|
-
return createSSOAuthProvider({
|
|
61
|
-
issuer: `https://login.microsoftonline.com/${tenantId}/v2.0`,
|
|
62
|
-
clientId,
|
|
63
|
-
scopes: ['openid', 'profile', 'email'],
|
|
64
|
-
redirectUri: resolveRedirectUri(opts),
|
|
65
|
-
...opts,
|
|
66
|
-
});
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
// ─── GitHub ──────────────────────────────────────────────────
|
|
70
|
-
|
|
71
|
-
/**
|
|
72
|
-
* GitHub Sign-In via OAuth2.
|
|
73
|
-
*
|
|
74
|
-
* Note: GitHub does NOT support OIDC discovery, so we manually specify endpoints.
|
|
75
|
-
* The `issuer` field is used for identification only.
|
|
76
|
-
*
|
|
77
|
-
* Prerequisites:
|
|
78
|
-
* 1. Create an OAuth App at https://github.com/settings/developers
|
|
79
|
-
* 2. Set the callback URL to your redirect URI
|
|
80
|
-
*
|
|
81
|
-
* @param clientId - GitHub OAuth App Client ID
|
|
82
|
-
*/
|
|
83
|
-
export function createGitHubAuth(clientId: string, opts?: PresetOptions) {
|
|
84
|
-
// GitHub uses standard OAuth2, not OIDC — requires manual endpoint config.
|
|
85
|
-
// We leverage the SSO provider but override the discovery with manual endpoints.
|
|
86
|
-
const redirectUri = resolveRedirectUri(opts);
|
|
87
|
-
|
|
88
|
-
return createSSOAuthProvider({
|
|
89
|
-
issuer: 'https://github.com',
|
|
90
|
-
clientId,
|
|
91
|
-
scopes: ['read:user', 'user:email'],
|
|
92
|
-
redirectUri,
|
|
93
|
-
// GitHub doesn't support OIDC discovery, provide manual endpoints
|
|
94
|
-
manualEndpoints: {
|
|
95
|
-
authorization_endpoint: 'https://github.com/login/oauth/authorize',
|
|
96
|
-
token_endpoint: 'https://github.com/login/oauth/access_token',
|
|
97
|
-
userinfo_endpoint: 'https://api.github.com/user',
|
|
98
|
-
},
|
|
99
|
-
mapIdentity: (userinfo) => ({
|
|
100
|
-
id: String(userinfo.id ?? ''),
|
|
101
|
-
name: (userinfo.name as string) ?? (userinfo.login as string) ?? '',
|
|
102
|
-
avatar: (userinfo.avatar_url as string) ?? undefined,
|
|
103
|
-
email: (userinfo.email as string) ?? undefined,
|
|
104
|
-
}),
|
|
105
|
-
...opts,
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
// ─── GitLab ──────────────────────────────────────────────────
|
|
110
|
-
|
|
111
|
-
/**
|
|
112
|
-
* GitLab Sign-In via OIDC.
|
|
113
|
-
*
|
|
114
|
-
* @param clientId - GitLab Application ID
|
|
115
|
-
* @param instanceUrl - GitLab instance URL. Default: 'https://gitlab.com'
|
|
116
|
-
*/
|
|
117
|
-
export function createGitLabAuth(
|
|
118
|
-
clientId: string,
|
|
119
|
-
instanceUrl = 'https://gitlab.com',
|
|
120
|
-
opts?: PresetOptions,
|
|
121
|
-
) {
|
|
122
|
-
return createSSOAuthProvider({
|
|
123
|
-
issuer: instanceUrl,
|
|
124
|
-
clientId,
|
|
125
|
-
scopes: ['openid', 'profile', 'email'],
|
|
126
|
-
redirectUri: resolveRedirectUri(opts),
|
|
127
|
-
...opts,
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// ─── Keycloak ────────────────────────────────────────────────
|
|
132
|
-
|
|
133
|
-
/**
|
|
134
|
-
* Keycloak Sign-In via OIDC.
|
|
135
|
-
*
|
|
136
|
-
* @param baseUrl - Keycloak base URL (e.g., 'https://keycloak.example.com')
|
|
137
|
-
* @param realm - Keycloak realm name
|
|
138
|
-
* @param clientId - Keycloak Client ID
|
|
139
|
-
*/
|
|
140
|
-
export function createKeycloakAuth(
|
|
141
|
-
baseUrl: string,
|
|
142
|
-
realm: string,
|
|
143
|
-
clientId: string,
|
|
144
|
-
opts?: PresetOptions,
|
|
145
|
-
) {
|
|
146
|
-
return createSSOAuthProvider({
|
|
147
|
-
issuer: `${baseUrl.replace(/\/$/, '')}/realms/${realm}`,
|
|
148
|
-
clientId,
|
|
149
|
-
scopes: ['openid', 'profile', 'email'],
|
|
150
|
-
redirectUri: resolveRedirectUri(opts),
|
|
151
|
-
...opts,
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// ─── Auth0 ───────────────────────────────────────────────────
|
|
156
|
-
|
|
157
|
-
/**
|
|
158
|
-
* Auth0 Sign-In via OIDC.
|
|
159
|
-
*
|
|
160
|
-
* @param domain - Auth0 domain (e.g., 'your-tenant.auth0.com')
|
|
161
|
-
* @param clientId - Auth0 Client ID
|
|
162
|
-
*/
|
|
163
|
-
export function createAuth0Auth(
|
|
164
|
-
domain: string,
|
|
165
|
-
clientId: string,
|
|
166
|
-
opts?: PresetOptions,
|
|
167
|
-
) {
|
|
168
|
-
return createSSOAuthProvider({
|
|
169
|
-
issuer: `https://${domain.replace(/^https?:\/\//, '').replace(/\/$/, '')}`,
|
|
170
|
-
clientId,
|
|
171
|
-
scopes: ['openid', 'profile', 'email'],
|
|
172
|
-
redirectUri: resolveRedirectUri(opts),
|
|
173
|
-
...opts,
|
|
174
|
-
});
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
// ─── Type exports ────────────────────────────────────────────
|
|
178
|
-
|
|
179
|
-
export type { PresetOptions };
|