@svadmin/sso 0.10.0 → 0.11.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/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 +585 -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 +51 -0
- package/dist/session-manager.js +581 -0
- package/package.json +11 -11
- package/src/auth-helpers.ts +0 -114
- package/src/auth-provider.ts +0 -485
- package/src/index.ts +0 -34
- package/src/presets.ts +0 -204
package/src/auth-helpers.ts
DELETED
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 浏览器端 OIDC/OAuth2 认证辅助函数。
|
|
3
|
-
*
|
|
4
|
-
* 提供 PKCE 生成、OIDC Discovery 端点解析、重定向校验、
|
|
5
|
-
* 反向代理 origin 恢复等通用工具。
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/** 将 Uint8Array 编码为 base64url 格式 (纯 Web API) */
|
|
9
|
-
function base64url(array: Uint8Array): string {
|
|
10
|
-
let binary = "";
|
|
11
|
-
for (const byte of array) binary += String.fromCharCode(byte);
|
|
12
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
/** 生成随机的 OAuth state 值 */
|
|
16
|
-
export function generateState(): string {
|
|
17
|
-
const array = new Uint8Array(16);
|
|
18
|
-
crypto.getRandomValues(array);
|
|
19
|
-
return base64url(array);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** 生成 PKCE code verifier */
|
|
23
|
-
export function generateVerifier(): string {
|
|
24
|
-
const array = new Uint8Array(32);
|
|
25
|
-
crypto.getRandomValues(array);
|
|
26
|
-
return base64url(array);
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
/** 基于 code verifier 算得 code challenge (S256) */
|
|
30
|
-
export async function generateChallenge(verifier: string): Promise<string> {
|
|
31
|
-
const encoder = new TextEncoder();
|
|
32
|
-
const data = encoder.encode(verifier);
|
|
33
|
-
const hash = await crypto.subtle.digest("SHA-256", data);
|
|
34
|
-
return base64url(new Uint8Array(hash));
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
/**
|
|
38
|
-
* 校验 returnTo 重定向地址,防止开放重定向漏洞。
|
|
39
|
-
* 只允许同源相对路径(必须以 / 开头且非 // 开头)。
|
|
40
|
-
*/
|
|
41
|
-
export function isValidReturnTo(url: string): boolean {
|
|
42
|
-
if (!url) return false;
|
|
43
|
-
if (url.includes("\\")) return false;
|
|
44
|
-
return /^\/[^/].*/.test(url) || url === "/";
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
/** 从反向代理头恢复公网 origin */
|
|
48
|
-
export function getForwardedOrigin(req: Request, fallbackUrl: URL): string {
|
|
49
|
-
const forwardedProto = req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim();
|
|
50
|
-
const forwardedHost = req.headers.get("x-forwarded-host")?.split(",")[0]?.trim();
|
|
51
|
-
const host = forwardedHost || req.headers.get("host") || fallbackUrl.host;
|
|
52
|
-
const protocol = (forwardedProto || fallbackUrl.protocol.replace(/:$/, "")).replace(/:$/, "");
|
|
53
|
-
return `${protocol}://${host}`;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
interface OidcDiscoveryDocument {
|
|
57
|
-
authorization_endpoint?: string;
|
|
58
|
-
token_endpoint?: string;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** 通过 OIDC Discovery 解析授权端点 URL */
|
|
62
|
-
export async function resolveAuthorizeUrl(
|
|
63
|
-
issuer: string,
|
|
64
|
-
overrideUrl = "",
|
|
65
|
-
fetcher: typeof fetch = fetch,
|
|
66
|
-
): Promise<string> {
|
|
67
|
-
const normalizedIssuer = issuer.replace(/\/$/, "");
|
|
68
|
-
const normalizedOverride = overrideUrl.trim();
|
|
69
|
-
|
|
70
|
-
if (normalizedOverride) return normalizedOverride;
|
|
71
|
-
|
|
72
|
-
if (normalizedIssuer) {
|
|
73
|
-
try {
|
|
74
|
-
const response = await fetcher(`${normalizedIssuer}/.well-known/openid-configuration`);
|
|
75
|
-
if (response.ok) {
|
|
76
|
-
const discovery = await response.json() as OidcDiscoveryDocument;
|
|
77
|
-
if (discovery.authorization_endpoint) return discovery.authorization_endpoint;
|
|
78
|
-
}
|
|
79
|
-
} catch {
|
|
80
|
-
// 忽略 discovery 失败,走 fallback
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
if (normalizedIssuer) return `${normalizedIssuer}/oauth/authorize`;
|
|
85
|
-
|
|
86
|
-
return "";
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/** 通过 OIDC Discovery 解析 token 端点 URL */
|
|
90
|
-
export async function resolveTokenUrl(
|
|
91
|
-
issuer: string,
|
|
92
|
-
overrideUrlOrFetcher: string | typeof fetch = "",
|
|
93
|
-
fetcherOverride: typeof fetch = fetch,
|
|
94
|
-
): Promise<string> {
|
|
95
|
-
const normalizedIssuer = issuer.replace(/\/$/, "");
|
|
96
|
-
const fetcher = typeof overrideUrlOrFetcher === "function" ? overrideUrlOrFetcher : fetcherOverride;
|
|
97
|
-
const overrideUrl = typeof overrideUrlOrFetcher === "string" ? overrideUrlOrFetcher : "";
|
|
98
|
-
const normalizedOverride = overrideUrl.trim();
|
|
99
|
-
|
|
100
|
-
if (normalizedOverride) return normalizedOverride;
|
|
101
|
-
if (!normalizedIssuer) return "";
|
|
102
|
-
|
|
103
|
-
try {
|
|
104
|
-
const response = await fetcher(`${normalizedIssuer}/.well-known/openid-configuration`);
|
|
105
|
-
if (response.ok) {
|
|
106
|
-
const discovery = await response.json() as OidcDiscoveryDocument;
|
|
107
|
-
if (discovery.token_endpoint) return discovery.token_endpoint;
|
|
108
|
-
}
|
|
109
|
-
} catch {
|
|
110
|
-
// 忽略 discovery 失败
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return `${normalizedIssuer}/oauth/token`;
|
|
114
|
-
}
|
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,34 +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
|
-
createSupauthAuth,
|
|
22
|
-
} from './presets';
|
|
23
|
-
export type { PresetOptions } from './presets';
|
|
24
|
-
|
|
25
|
-
// Auth helpers (PKCE, OIDC discovery, redirect validation)
|
|
26
|
-
export {
|
|
27
|
-
generateState,
|
|
28
|
-
generateVerifier,
|
|
29
|
-
generateChallenge,
|
|
30
|
-
isValidReturnTo,
|
|
31
|
-
getForwardedOrigin,
|
|
32
|
-
resolveAuthorizeUrl,
|
|
33
|
-
resolveTokenUrl,
|
|
34
|
-
} from './auth-helpers';
|