@svadmin/sso 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@svadmin/sso",
3
+ "version": "0.4.1",
4
+ "type": "module",
5
+ "description": "OIDC/OAuth2 SSO AuthProvider plugin for svadmin — supports Okta, Azure AD, Cognito, Keycloak, Google",
6
+ "license": "MIT",
7
+ "keywords": ["svadmin", "sso", "oidc", "oauth2", "okta", "azure-ad", "cognito", "keycloak"],
8
+ "repository": {
9
+ "type": "git",
10
+ "url": "https://github.com/zuohuadong/svadmin",
11
+ "directory": "packages/sso"
12
+ },
13
+ "exports": {
14
+ ".": {
15
+ "types": "./src/index.ts",
16
+ "import": "./src/index.ts"
17
+ }
18
+ },
19
+ "main": "./src/index.ts",
20
+ "types": "./src/index.ts",
21
+ "files": ["src"],
22
+ "peerDependencies": {
23
+ "@svadmin/core": ">=0.4.0"
24
+ },
25
+ "devDependencies": {
26
+ "typescript": "^5.7.0"
27
+ }
28
+ }
@@ -0,0 +1,405 @@
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
+ * const authProvider = createSSOAuthProvider({
21
+ * issuer: 'https://your-tenant.okta.com',
22
+ * clientId: 'your-client-id',
23
+ * redirectUri: window.location.origin + '/callback',
24
+ * });
25
+ *
26
+ * setAuthProvider(authProvider);
27
+ * ```
28
+ */
29
+
30
+ import type { AuthProvider, Identity } from '@svadmin/core';
31
+
32
+ // ─── Types ────────────────────────────────────────────────────
33
+
34
+ export interface SSOConfig {
35
+ /** OIDC Issuer URL (e.g., 'https://your-tenant.okta.com') */
36
+ issuer: string;
37
+ /** OAuth2 Client ID */
38
+ clientId: string;
39
+ /** Redirect URI after login (e.g., '/callback') */
40
+ redirectUri: string;
41
+ /** OAuth2 scopes. Default: ['openid', 'profile', 'email'] */
42
+ scopes?: string[];
43
+ /** Where to redirect after logout */
44
+ postLogoutRedirectUri?: string;
45
+ /** Token storage backend. Default: 'local' (localStorage) */
46
+ storage?: 'local' | 'session' | TokenStorage;
47
+ /** Custom identity mapper — transform OIDC userinfo to svadmin Identity */
48
+ mapIdentity?: (userinfo: Record<string, unknown>) => Identity;
49
+ /** Auto-refresh tokens before expiry. Default: true */
50
+ autoRefresh?: boolean;
51
+ /** Seconds before expiry to trigger refresh. Default: 60 */
52
+ refreshBuffer?: number;
53
+ /**
54
+ * Manual OAuth2 endpoints for providers that don't support OIDC discovery
55
+ * (e.g., GitHub). When provided, OIDC auto-discovery is skipped.
56
+ */
57
+ manualEndpoints?: {
58
+ authorization_endpoint: string;
59
+ token_endpoint: string;
60
+ userinfo_endpoint: string;
61
+ end_session_endpoint?: string;
62
+ };
63
+ }
64
+
65
+ export interface TokenStorage {
66
+ getItem: (key: string) => string | null;
67
+ setItem: (key: string, value: string) => void;
68
+ removeItem: (key: string) => void;
69
+ }
70
+
71
+ interface OIDCConfig {
72
+ authorization_endpoint: string;
73
+ token_endpoint: string;
74
+ userinfo_endpoint: string;
75
+ end_session_endpoint?: string;
76
+ }
77
+
78
+ interface TokenSet {
79
+ access_token: string;
80
+ id_token?: string;
81
+ refresh_token?: string;
82
+ expires_at?: number;
83
+ token_type: string;
84
+ }
85
+
86
+ // ─── PKCE Utilities ───────────────────────────────────────────
87
+
88
+ function generateRandomString(length: number): string {
89
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~';
90
+ const values = crypto.getRandomValues(new Uint8Array(length));
91
+ return Array.from(values, (v) => chars[v % chars.length]).join('');
92
+ }
93
+
94
+ async function sha256(plain: string): Promise<ArrayBuffer> {
95
+ const encoder = new TextEncoder();
96
+ return crypto.subtle.digest('SHA-256', encoder.encode(plain));
97
+ }
98
+
99
+ function base64urlEncode(buffer: ArrayBuffer): string {
100
+ const bytes = new Uint8Array(buffer);
101
+ let binary = '';
102
+ for (const byte of bytes) binary += String.fromCharCode(byte);
103
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
104
+ }
105
+
106
+ async function createPKCEChallenge(): Promise<{ verifier: string; challenge: string }> {
107
+ const verifier = generateRandomString(64);
108
+ const hashed = await sha256(verifier);
109
+ const challenge = base64urlEncode(hashed);
110
+ return { verifier, challenge };
111
+ }
112
+
113
+ // ─── Storage Helper ───────────────────────────────────────────
114
+
115
+ const STORAGE_PREFIX = 'svadmin_sso_';
116
+
117
+ function getStorage(config: SSOConfig): TokenStorage {
118
+ if (config.storage && typeof config.storage === 'object') return config.storage;
119
+ const backend = config.storage === 'session' ? sessionStorage : localStorage;
120
+ return {
121
+ getItem: (key) => backend.getItem(key),
122
+ setItem: (key, value) => backend.setItem(key, value),
123
+ removeItem: (key) => backend.removeItem(key),
124
+ };
125
+ }
126
+
127
+ // ─── Provider ─────────────────────────────────────────────────
128
+
129
+ /**
130
+ * Create an OIDC/OAuth2 SSO AuthProvider.
131
+ */
132
+ export function createSSOAuthProvider(config: SSOConfig): AuthProvider {
133
+ const scopes = config.scopes ?? ['openid', 'profile', 'email'];
134
+ const autoRefresh = config.autoRefresh ?? true;
135
+ const refreshBuffer = config.refreshBuffer ?? 60;
136
+ const storage = getStorage(config);
137
+
138
+ let oidcConfig: OIDCConfig | null = null;
139
+ let refreshTimer: ReturnType<typeof setTimeout> | null = null;
140
+
141
+ // ── OIDC Discovery ───────────────────────────────────────
142
+
143
+ async function discover(): Promise<OIDCConfig> {
144
+ if (oidcConfig) return oidcConfig;
145
+
146
+ // Use manual endpoints if provided (e.g., GitHub)
147
+ if (config.manualEndpoints) {
148
+ oidcConfig = config.manualEndpoints;
149
+ return oidcConfig;
150
+ }
151
+
152
+ const url = `${config.issuer.replace(/\/$/, '')}/.well-known/openid-configuration`;
153
+ const res = await fetch(url);
154
+ if (!res.ok) throw new Error(`OIDC discovery failed: ${res.status} ${res.statusText}`);
155
+ oidcConfig = await res.json() as OIDCConfig;
156
+ return oidcConfig;
157
+ }
158
+
159
+ // ── Token Management ─────────────────────────────────────
160
+
161
+ function getTokens(): TokenSet | null {
162
+ const raw = storage.getItem(`${STORAGE_PREFIX}tokens`);
163
+ if (!raw) return null;
164
+ try { return JSON.parse(raw) as TokenSet; }
165
+ catch { return null; }
166
+ }
167
+
168
+ function setTokens(tokens: TokenSet): void {
169
+ storage.setItem(`${STORAGE_PREFIX}tokens`, JSON.stringify(tokens));
170
+ if (autoRefresh && tokens.refresh_token && tokens.expires_at) {
171
+ scheduleRefresh(tokens);
172
+ }
173
+ }
174
+
175
+ function clearTokens(): void {
176
+ storage.removeItem(`${STORAGE_PREFIX}tokens`);
177
+ storage.removeItem(`${STORAGE_PREFIX}pkce_verifier`);
178
+ storage.removeItem(`${STORAGE_PREFIX}state`);
179
+ if (refreshTimer) clearTimeout(refreshTimer);
180
+ }
181
+
182
+ function scheduleRefresh(tokens: TokenSet): void {
183
+ if (refreshTimer) clearTimeout(refreshTimer);
184
+ if (!tokens.expires_at || !tokens.refresh_token) return;
185
+ const now = Math.floor(Date.now() / 1000);
186
+ const delay = Math.max(0, (tokens.expires_at - now - refreshBuffer)) * 1000;
187
+ refreshTimer = setTimeout(() => refreshAccessToken(tokens.refresh_token!), delay);
188
+ }
189
+
190
+ async function refreshAccessToken(refreshToken: string): Promise<void> {
191
+ try {
192
+ const cfg = await discover();
193
+ const body = new URLSearchParams({
194
+ grant_type: 'refresh_token',
195
+ client_id: config.clientId,
196
+ refresh_token: refreshToken,
197
+ });
198
+ const res = await fetch(cfg.token_endpoint, {
199
+ method: 'POST',
200
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
201
+ body,
202
+ });
203
+ if (!res.ok) { clearTokens(); return; }
204
+ const data = await res.json();
205
+ const newTokens: TokenSet = {
206
+ access_token: data.access_token,
207
+ id_token: data.id_token,
208
+ refresh_token: data.refresh_token ?? refreshToken,
209
+ expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined,
210
+ token_type: data.token_type ?? 'Bearer',
211
+ };
212
+ setTokens(newTokens);
213
+ } catch {
214
+ clearTokens();
215
+ }
216
+ }
217
+
218
+ // ── Token Exchange ───────────────────────────────────────
219
+
220
+ async function exchangeCode(code: string): Promise<TokenSet> {
221
+ const cfg = await discover();
222
+ const verifier = storage.getItem(`${STORAGE_PREFIX}pkce_verifier`);
223
+ const body = new URLSearchParams({
224
+ grant_type: 'authorization_code',
225
+ client_id: config.clientId,
226
+ code,
227
+ redirect_uri: config.redirectUri,
228
+ ...(verifier ? { code_verifier: verifier } : {}),
229
+ });
230
+ const res = await fetch(cfg.token_endpoint, {
231
+ method: 'POST',
232
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
233
+ body,
234
+ });
235
+ if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
236
+ const data = await res.json();
237
+ const tokens: TokenSet = {
238
+ access_token: data.access_token,
239
+ id_token: data.id_token,
240
+ refresh_token: data.refresh_token,
241
+ expires_at: data.expires_in ? Math.floor(Date.now() / 1000) + data.expires_in : undefined,
242
+ token_type: data.token_type ?? 'Bearer',
243
+ };
244
+ setTokens(tokens);
245
+ storage.removeItem(`${STORAGE_PREFIX}pkce_verifier`);
246
+ storage.removeItem(`${STORAGE_PREFIX}state`);
247
+ return tokens;
248
+ }
249
+
250
+ // ── Default Identity Mapper ──────────────────────────────
251
+
252
+ function defaultMapIdentity(userinfo: Record<string, unknown>): Identity {
253
+ return {
254
+ id: (userinfo.sub as string) ?? '',
255
+ name: (userinfo.name as string) ?? (userinfo.preferred_username as string) ?? '',
256
+ avatar: (userinfo.picture as string) ?? undefined,
257
+ email: (userinfo.email as string) ?? undefined,
258
+ };
259
+ }
260
+
261
+ const mapIdentity = config.mapIdentity ?? defaultMapIdentity;
262
+
263
+ // ── AuthProvider Implementation ──────────────────────────
264
+
265
+ return {
266
+ async login() {
267
+ const cfg = await discover();
268
+ const { verifier, challenge } = await createPKCEChallenge();
269
+ const state = generateRandomString(32);
270
+
271
+ storage.setItem(`${STORAGE_PREFIX}pkce_verifier`, verifier);
272
+ storage.setItem(`${STORAGE_PREFIX}state`, state);
273
+
274
+ const params = new URLSearchParams({
275
+ response_type: 'code',
276
+ client_id: config.clientId,
277
+ redirect_uri: config.redirectUri,
278
+ scope: scopes.join(' '),
279
+ state,
280
+ code_challenge: challenge,
281
+ code_challenge_method: 'S256',
282
+ });
283
+
284
+ // Redirect to authorization endpoint
285
+ window.location.href = `${cfg.authorization_endpoint}?${params}`;
286
+
287
+ // Won't actually reach here due to redirect
288
+ return { success: true };
289
+ },
290
+
291
+ async logout() {
292
+ const cfg = await discover();
293
+ const tokens = getTokens();
294
+ clearTokens();
295
+
296
+ if (cfg.end_session_endpoint && tokens?.id_token) {
297
+ const params = new URLSearchParams({
298
+ id_token_hint: tokens.id_token,
299
+ ...(config.postLogoutRedirectUri
300
+ ? { post_logout_redirect_uri: config.postLogoutRedirectUri }
301
+ : {}),
302
+ });
303
+ window.location.href = `${cfg.end_session_endpoint}?${params}`;
304
+ return { success: true };
305
+ }
306
+
307
+ return {
308
+ success: true,
309
+ redirectTo: config.postLogoutRedirectUri ?? '/',
310
+ };
311
+ },
312
+
313
+ async check() {
314
+ // Handle callback — exchange authorization code for tokens
315
+ const url = new URL(window.location.href);
316
+ const code = url.searchParams.get('code');
317
+ const state = url.searchParams.get('state');
318
+
319
+ if (code) {
320
+ const savedState = storage.getItem(`${STORAGE_PREFIX}state`);
321
+ if (state && savedState && state !== savedState) {
322
+ return { authenticated: false, error: { message: 'State mismatch' } };
323
+ }
324
+ try {
325
+ await exchangeCode(code);
326
+ // Clean URL
327
+ url.searchParams.delete('code');
328
+ url.searchParams.delete('state');
329
+ window.history.replaceState({}, '', url.pathname);
330
+ return { authenticated: true };
331
+ } catch (err) {
332
+ return { authenticated: false, error: { message: String(err) } };
333
+ }
334
+ }
335
+
336
+ // Check existing tokens
337
+ const tokens = getTokens();
338
+ if (!tokens) {
339
+ return { authenticated: false, logout: true };
340
+ }
341
+
342
+ // Check expiry
343
+ if (tokens.expires_at && tokens.expires_at < Math.floor(Date.now() / 1000)) {
344
+ if (tokens.refresh_token) {
345
+ await refreshAccessToken(tokens.refresh_token);
346
+ const refreshed = getTokens();
347
+ if (refreshed) return { authenticated: true };
348
+ }
349
+ clearTokens();
350
+ return { authenticated: false, logout: true };
351
+ }
352
+
353
+ // Schedule refresh if not already done
354
+ if (autoRefresh && tokens.refresh_token && tokens.expires_at) {
355
+ scheduleRefresh(tokens);
356
+ }
357
+
358
+ return { authenticated: true };
359
+ },
360
+
361
+ async getIdentity(): Promise<Identity | null> {
362
+ const tokens = getTokens();
363
+ if (!tokens) return null;
364
+
365
+ try {
366
+ const cfg = await discover();
367
+ const res = await fetch(cfg.userinfo_endpoint, {
368
+ headers: { Authorization: `${tokens.token_type} ${tokens.access_token}` },
369
+ });
370
+ if (!res.ok) return null;
371
+ const userinfo = await res.json();
372
+ return mapIdentity(userinfo);
373
+ } catch {
374
+ return null;
375
+ }
376
+ },
377
+
378
+ async getPermissions() {
379
+ const tokens = getTokens();
380
+ if (!tokens) return null;
381
+
382
+ // Try to extract roles/permissions from the ID token
383
+ if (tokens.id_token) {
384
+ try {
385
+ const payload = JSON.parse(atob(tokens.id_token.split('.')[1]));
386
+ return payload.roles ?? payload.groups ?? payload.permissions ?? null;
387
+ } catch {
388
+ return null;
389
+ }
390
+ }
391
+
392
+ return null;
393
+ },
394
+
395
+ async onError(error) {
396
+ if (typeof error === 'object' && error !== null && 'statusCode' in error) {
397
+ const statusCode = (error as { statusCode: number }).statusCode;
398
+ if (statusCode === 401 || statusCode === 403) {
399
+ return { logout: true, redirectTo: '/login' };
400
+ }
401
+ }
402
+ return {};
403
+ },
404
+ };
405
+ }
package/src/index.ts ADDED
@@ -0,0 +1,22 @@
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 { 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';
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @svadmin/sso — Presets Unit Tests
3
+ */
4
+ import { describe, test, expect } from 'bun:test';
5
+ import type { TokenStorage } from './auth-provider';
6
+
7
+ // In-memory storage for tests (localStorage not available in Bun test)
8
+ function createMemoryStorage(): TokenStorage {
9
+ const store = new Map<string, string>();
10
+ return {
11
+ getItem: (key: string) => store.get(key) ?? null,
12
+ setItem: (key: string, value: string) => { store.set(key, value); },
13
+ removeItem: (key: string) => { store.delete(key); },
14
+ };
15
+ }
16
+
17
+ describe('SSO presets', () => {
18
+ // Import the module to verify it compiles correctly
19
+ test('presets module exports all factories', async () => {
20
+ const presets = await import('./presets');
21
+ expect(typeof presets.createGoogleAuth).toBe('function');
22
+ expect(typeof presets.createMicrosoftAuth).toBe('function');
23
+ expect(typeof presets.createGitHubAuth).toBe('function');
24
+ expect(typeof presets.createGitLabAuth).toBe('function');
25
+ expect(typeof presets.createKeycloakAuth).toBe('function');
26
+ expect(typeof presets.createAuth0Auth).toBe('function');
27
+ });
28
+
29
+ test('createGoogleAuth returns AuthProvider', async () => {
30
+ const { createGoogleAuth } = await import('./presets');
31
+ const provider = createGoogleAuth('test-client-id', {
32
+ redirectUri: 'http://localhost/callback',
33
+ storage: createMemoryStorage(),
34
+ });
35
+ expect(provider).toBeTruthy();
36
+ expect(typeof provider.login).toBe('function');
37
+ expect(typeof provider.logout).toBe('function');
38
+ expect(typeof provider.check).toBe('function');
39
+ expect(typeof provider.getIdentity).toBe('function');
40
+ });
41
+
42
+ test('createMicrosoftAuth accepts tenantId', async () => {
43
+ const { createMicrosoftAuth } = await import('./presets');
44
+ const provider = createMicrosoftAuth('test-id', 'my-tenant', {
45
+ redirectUri: 'http://localhost/callback',
46
+ storage: createMemoryStorage(),
47
+ });
48
+ expect(provider).toBeTruthy();
49
+ expect(typeof provider.login).toBe('function');
50
+ });
51
+
52
+ test('createGitHubAuth returns AuthProvider', async () => {
53
+ const { createGitHubAuth } = await import('./presets');
54
+ const provider = createGitHubAuth('test-github-id', {
55
+ redirectUri: 'http://localhost/callback',
56
+ storage: createMemoryStorage(),
57
+ });
58
+ expect(provider).toBeTruthy();
59
+ expect(typeof provider.login).toBe('function');
60
+ expect(typeof provider.getIdentity).toBe('function');
61
+ });
62
+
63
+ test('createKeycloakAuth returns AuthProvider', async () => {
64
+ const { createKeycloakAuth } = await import('./presets');
65
+ const provider = createKeycloakAuth('https://kc.example.com', 'my-realm', 'client-id', {
66
+ redirectUri: 'http://localhost/callback',
67
+ storage: createMemoryStorage(),
68
+ });
69
+ expect(provider).toBeTruthy();
70
+ expect(typeof provider.login).toBe('function');
71
+ });
72
+
73
+ test('createAuth0Auth returns AuthProvider', async () => {
74
+ const { createAuth0Auth } = await import('./presets');
75
+ const provider = createAuth0Auth('my-tenant.auth0.com', 'client-id', {
76
+ redirectUri: 'http://localhost/callback',
77
+ storage: createMemoryStorage(),
78
+ });
79
+ expect(provider).toBeTruthy();
80
+ expect(typeof provider.login).toBe('function');
81
+ });
82
+
83
+ test('createGitLabAuth returns AuthProvider', async () => {
84
+ const { createGitLabAuth } = await import('./presets');
85
+ const provider = createGitLabAuth('test-id', 'https://gitlab.example.com', {
86
+ redirectUri: 'http://localhost/callback',
87
+ storage: createMemoryStorage(),
88
+ });
89
+ expect(provider).toBeTruthy();
90
+ expect(typeof provider.login).toBe('function');
91
+ });
92
+ });
package/src/presets.ts ADDED
@@ -0,0 +1,179 @@
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 };