elsabro 2.2.0 → 3.7.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/README.md +668 -20
- package/agents/elsabro-orchestrator.md +113 -0
- package/bin/install.js +0 -0
- package/commands/elsabro/execute.md +223 -46
- package/commands/elsabro/start.md +34 -0
- package/commands/elsabro/verify-work.md +29 -0
- package/flows/development-flow.json +452 -0
- package/flows/quick-flow.json +118 -0
- package/hooks/confirm-destructive.sh +145 -0
- package/hooks/hooks-config.json +81 -0
- package/hooks/lint-check.sh +238 -0
- package/hooks/post-edit-test.sh +189 -0
- package/package.json +5 -3
- package/references/SYSTEM_INDEX.md +379 -5
- package/references/agent-marketplace.md +2274 -0
- package/references/agent-protocol.md +1126 -0
- package/references/ai-code-suggestions.md +2413 -0
- package/references/checkpointing.md +595 -0
- package/references/collaboration-patterns.md +851 -0
- package/references/collaborative-sessions.md +1081 -0
- package/references/configuration-management.md +1810 -0
- package/references/cost-tracking.md +1095 -0
- package/references/enterprise-sso.md +2001 -0
- package/references/error-contracts-tests.md +1171 -0
- package/references/error-contracts-v2.md +968 -0
- package/references/error-contracts.md +3102 -0
- package/references/event-driven.md +1031 -0
- package/references/flow-orchestration.md +940 -0
- package/references/flow-visualization.md +1557 -0
- package/references/ide-integrations.md +3513 -0
- package/references/interrupt-system.md +681 -0
- package/references/kubernetes-deployment.md +3099 -0
- package/references/memory-system.md +683 -0
- package/references/mobile-companion.md +3236 -0
- package/references/multi-llm-providers.md +2494 -0
- package/references/multi-project-memory.md +1182 -0
- package/references/observability.md +793 -0
- package/references/output-schemas.md +858 -0
- package/references/parallel-worktrees.md +293 -0
- package/references/performance-profiler.md +955 -0
- package/references/plugin-system.md +1526 -0
- package/references/prompt-management.md +292 -0
- package/references/sandbox-execution.md +303 -0
- package/references/security-system.md +1253 -0
- package/references/streaming.md +696 -0
- package/references/testing-framework.md +1151 -0
- package/references/time-travel.md +802 -0
- package/references/tool-registry.md +886 -0
- package/references/voice-commands.md +3296 -0
- package/scripts/setup-parallel-worktrees.sh +319 -0
- package/skills/memory-update.md +207 -0
- package/skills/review.md +331 -0
- package/skills/techdebt.md +289 -0
- package/skills/tutor.md +219 -0
- package/templates/.planning/notes/.gitkeep +0 -0
- package/templates/CLAUDE.md.template +48 -0
- package/templates/agent-marketplace-config.json +220 -0
- package/templates/agent-protocol-config.json +136 -0
- package/templates/ai-suggestions-config.json +100 -0
- package/templates/checkpoint-state.json +61 -0
- package/templates/collaboration-config.json +157 -0
- package/templates/collaborative-sessions-config.json +153 -0
- package/templates/configuration-config.json +245 -0
- package/templates/cost-tracking-config.json +148 -0
- package/templates/enterprise-sso-config.json +438 -0
- package/templates/error-handling-config.json +79 -2
- package/templates/events-config.json +148 -0
- package/templates/flow-visualization-config.json +196 -0
- package/templates/ide-integrations-config.json +442 -0
- package/templates/kubernetes-config.json +764 -0
- package/templates/memory-state.json +84 -0
- package/templates/mistakes.md.template +52 -0
- package/templates/mobile-companion-config.json +600 -0
- package/templates/multi-llm-config.json +544 -0
- package/templates/multi-project-memory-config.json +145 -0
- package/templates/observability-config.json +109 -0
- package/templates/patterns.md.template +114 -0
- package/templates/performance-profiler-config.json +125 -0
- package/templates/plugin-config.json +170 -0
- package/templates/prompt-management-config.json +86 -0
- package/templates/sandbox-config.json +185 -0
- package/templates/schemas-config.json +65 -0
- package/templates/security-config.json +120 -0
- package/templates/streaming-config.json +72 -0
- package/templates/testing-config.json +81 -0
- package/templates/timetravel-config.json +62 -0
- package/templates/tool-registry-config.json +109 -0
- package/templates/voice-commands-config.json +658 -0
|
@@ -0,0 +1,2001 @@
|
|
|
1
|
+
# Enterprise SSO System (v3.6)
|
|
2
|
+
|
|
3
|
+
Sistema de Single Sign-On empresarial con soporte para SAML 2.0, OpenID Connect, y multiples Identity Providers.
|
|
4
|
+
|
|
5
|
+
## Arquitectura
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
+-----------------------------------------------------------------------------+
|
|
9
|
+
| ENTERPRISE SSO SYSTEM |
|
|
10
|
+
+-----------------------------------------------------------------------------+
|
|
11
|
+
| |
|
|
12
|
+
| +---------------------------------------------------------------------+ |
|
|
13
|
+
| | SSO MANAGER | |
|
|
14
|
+
| | +---------------+ +---------------+ +---------------+ | |
|
|
15
|
+
| | | Session | | Token | | Single | | |
|
|
16
|
+
| | | Manager | | Service | | Logout | | |
|
|
17
|
+
| | +---------------+ +---------------+ +---------------+ | |
|
|
18
|
+
| +---------------------------------------------------------------------+ |
|
|
19
|
+
| | |
|
|
20
|
+
| v |
|
|
21
|
+
| +---------------------------------------------------------------------+ |
|
|
22
|
+
| | PROTOCOL PROVIDERS | |
|
|
23
|
+
| | +---------------------------+ +---------------------------+ | |
|
|
24
|
+
| | | SAML PROVIDER | | OIDC PROVIDER | | |
|
|
25
|
+
| | | +-------+ +----------+ | | +-------+ +----------+ | | |
|
|
26
|
+
| | | | SP | | Assertion| | | | Auth | | Token | | | |
|
|
27
|
+
| | | | Meta | | Validate | | | | Code | | Refresh | | | |
|
|
28
|
+
| | | +-------+ +----------+ | | +-------+ +----------+ | | |
|
|
29
|
+
| | +---------------------------+ +---------------------------+ | |
|
|
30
|
+
| +---------------------------------------------------------------------+ |
|
|
31
|
+
| | |
|
|
32
|
+
| v |
|
|
33
|
+
| +---------------------------------------------------------------------+ |
|
|
34
|
+
| | IDENTITY PROVIDERS | |
|
|
35
|
+
| | +--------+ +--------+ +--------+ +--------+ +--------+ | |
|
|
36
|
+
| | | Okta | | Azure | | Google | | Auth0 | |Keycloak| | |
|
|
37
|
+
| | | | | AD | | Work | | | | | | |
|
|
38
|
+
| | +--------+ +--------+ +--------+ +--------+ +--------+ | |
|
|
39
|
+
| +---------------------------------------------------------------------+ |
|
|
40
|
+
| | |
|
|
41
|
+
| v |
|
|
42
|
+
| +---------------------------------------------------------------------+ |
|
|
43
|
+
| | AUTHORIZATION INTEGRATION | |
|
|
44
|
+
| | +---------------+ +---------------+ +---------------+ | |
|
|
45
|
+
| | | Group/Role | | JIT | | SCIM 2.0 | | |
|
|
46
|
+
| | | Mapping | | Provisioning | | User Sync | | |
|
|
47
|
+
| | +---------------+ +---------------+ +---------------+ | |
|
|
48
|
+
| +---------------------------------------------------------------------+ |
|
|
49
|
+
| |
|
|
50
|
+
+-----------------------------------------------------------------------------+
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
---
|
|
54
|
+
|
|
55
|
+
## SSOManager
|
|
56
|
+
|
|
57
|
+
### API Principal
|
|
58
|
+
|
|
59
|
+
```typescript
|
|
60
|
+
interface SSOConfig {
|
|
61
|
+
enabled: boolean;
|
|
62
|
+
defaultProvider?: string;
|
|
63
|
+
sessionTimeout: number;
|
|
64
|
+
tokenEncryption: TokenEncryptionConfig;
|
|
65
|
+
singleLogout: boolean;
|
|
66
|
+
mfaRequired: boolean;
|
|
67
|
+
allowedDomains?: string[];
|
|
68
|
+
ipAllowlist?: string[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SSOSession {
|
|
72
|
+
id: string;
|
|
73
|
+
userId: string;
|
|
74
|
+
providerId: string;
|
|
75
|
+
protocol: 'saml' | 'oidc';
|
|
76
|
+
accessToken?: string;
|
|
77
|
+
refreshToken?: string;
|
|
78
|
+
idToken?: string;
|
|
79
|
+
expiresAt: string;
|
|
80
|
+
createdAt: string;
|
|
81
|
+
lastActivityAt: string;
|
|
82
|
+
attributes: UserAttributes;
|
|
83
|
+
mfaVerified: boolean;
|
|
84
|
+
ipAddress: string;
|
|
85
|
+
userAgent: string;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface UserAttributes {
|
|
89
|
+
email: string;
|
|
90
|
+
name?: string;
|
|
91
|
+
firstName?: string;
|
|
92
|
+
lastName?: string;
|
|
93
|
+
groups: string[];
|
|
94
|
+
roles: string[];
|
|
95
|
+
department?: string;
|
|
96
|
+
organization?: string;
|
|
97
|
+
customAttributes?: Record<string, unknown>;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
interface TokenEncryptionConfig {
|
|
101
|
+
algorithm: 'RS256' | 'RS384' | 'RS512' | 'ES256' | 'ES384' | 'ES512';
|
|
102
|
+
publicKey: string;
|
|
103
|
+
privateKey: string;
|
|
104
|
+
keyRotationDays: number;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
interface AuthenticationResult {
|
|
108
|
+
success: boolean;
|
|
109
|
+
session?: SSOSession;
|
|
110
|
+
redirectUrl?: string;
|
|
111
|
+
error?: SSOError;
|
|
112
|
+
mfaRequired?: boolean;
|
|
113
|
+
mfaChallenge?: MFAChallenge;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
interface SSOError {
|
|
117
|
+
code: string;
|
|
118
|
+
message: string;
|
|
119
|
+
providerId?: string;
|
|
120
|
+
details?: Record<string, unknown>;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
interface MFAChallenge {
|
|
124
|
+
type: 'totp' | 'sms' | 'email' | 'push';
|
|
125
|
+
challengeId: string;
|
|
126
|
+
expiresAt: string;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
class SSOManager {
|
|
130
|
+
private config: SSOConfig;
|
|
131
|
+
private providers: Map<string, IdentityProvider>;
|
|
132
|
+
private sessions: Map<string, SSOSession>;
|
|
133
|
+
private tokenService: TokenService;
|
|
134
|
+
|
|
135
|
+
constructor(config: SSOConfig) {
|
|
136
|
+
this.config = config;
|
|
137
|
+
this.providers = new Map();
|
|
138
|
+
this.sessions = new Map();
|
|
139
|
+
this.tokenService = new TokenService(config.tokenEncryption);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Register identity provider
|
|
143
|
+
registerProvider(provider: IdentityProvider): void {
|
|
144
|
+
this.providers.set(provider.id, provider);
|
|
145
|
+
|
|
146
|
+
AuditLogger.log({
|
|
147
|
+
action: 'sso.provider.registered',
|
|
148
|
+
resource: provider.id,
|
|
149
|
+
details: {
|
|
150
|
+
protocol: provider.protocol,
|
|
151
|
+
name: provider.name
|
|
152
|
+
}
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Unregister identity provider
|
|
157
|
+
unregisterProvider(providerId: string): boolean {
|
|
158
|
+
const removed = this.providers.delete(providerId);
|
|
159
|
+
|
|
160
|
+
if (removed) {
|
|
161
|
+
AuditLogger.log({
|
|
162
|
+
action: 'sso.provider.unregistered',
|
|
163
|
+
resource: providerId
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return removed;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Initiate SSO authentication
|
|
171
|
+
async initiateAuth(
|
|
172
|
+
providerId: string,
|
|
173
|
+
options?: AuthInitOptions
|
|
174
|
+
): Promise<AuthInitResult> {
|
|
175
|
+
const provider = this.providers.get(providerId);
|
|
176
|
+
if (!provider) {
|
|
177
|
+
throw new SSOError('PROVIDER_NOT_FOUND', `Provider not found: ${providerId}`);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Check IP allowlist
|
|
181
|
+
if (this.config.ipAllowlist && options?.ipAddress) {
|
|
182
|
+
if (!this.isIPAllowed(options.ipAddress)) {
|
|
183
|
+
AuditLogger.denied('sso.auth.ip_blocked', {
|
|
184
|
+
details: { ip: options.ipAddress, providerId }
|
|
185
|
+
});
|
|
186
|
+
throw new SSOError('IP_NOT_ALLOWED', 'IP address not in allowlist');
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Generate state for CSRF protection
|
|
191
|
+
const state = this.generateState();
|
|
192
|
+
const nonce = this.generateNonce();
|
|
193
|
+
|
|
194
|
+
// Get auth URL from provider
|
|
195
|
+
const authUrl = await provider.getAuthorizationUrl({
|
|
196
|
+
state,
|
|
197
|
+
nonce,
|
|
198
|
+
redirectUri: options?.redirectUri,
|
|
199
|
+
scope: options?.scope,
|
|
200
|
+
prompt: options?.prompt
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Store state for validation
|
|
204
|
+
await this.storeAuthState(state, {
|
|
205
|
+
providerId,
|
|
206
|
+
nonce,
|
|
207
|
+
initiatedAt: new Date().toISOString(),
|
|
208
|
+
ipAddress: options?.ipAddress,
|
|
209
|
+
userAgent: options?.userAgent
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
AuditLogger.log({
|
|
213
|
+
action: 'sso.auth.initiated',
|
|
214
|
+
details: { providerId, protocol: provider.protocol }
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
return {
|
|
218
|
+
authUrl,
|
|
219
|
+
state,
|
|
220
|
+
expiresIn: 600 // 10 minutes
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Handle SSO callback
|
|
225
|
+
async handleCallback(
|
|
226
|
+
providerId: string,
|
|
227
|
+
callbackData: CallbackData
|
|
228
|
+
): Promise<AuthenticationResult> {
|
|
229
|
+
const provider = this.providers.get(providerId);
|
|
230
|
+
if (!provider) {
|
|
231
|
+
return {
|
|
232
|
+
success: false,
|
|
233
|
+
error: { code: 'PROVIDER_NOT_FOUND', message: `Provider not found: ${providerId}` }
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Validate state
|
|
238
|
+
const authState = await this.getAuthState(callbackData.state);
|
|
239
|
+
if (!authState || authState.providerId !== providerId) {
|
|
240
|
+
AuditLogger.denied('sso.auth.invalid_state', {
|
|
241
|
+
details: { providerId, state: callbackData.state }
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
success: false,
|
|
245
|
+
error: { code: 'INVALID_STATE', message: 'Invalid or expired state' }
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
// Process callback based on protocol
|
|
251
|
+
const authResult = await provider.processCallback(callbackData, authState.nonce);
|
|
252
|
+
|
|
253
|
+
// Check domain allowlist
|
|
254
|
+
if (this.config.allowedDomains && authResult.attributes.email) {
|
|
255
|
+
const domain = authResult.attributes.email.split('@')[1];
|
|
256
|
+
if (!this.config.allowedDomains.includes(domain)) {
|
|
257
|
+
AuditLogger.denied('sso.auth.domain_blocked', {
|
|
258
|
+
details: { domain, providerId }
|
|
259
|
+
});
|
|
260
|
+
return {
|
|
261
|
+
success: false,
|
|
262
|
+
error: { code: 'DOMAIN_NOT_ALLOWED', message: 'Email domain not allowed' }
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Check if MFA is required
|
|
268
|
+
if (this.config.mfaRequired && !authResult.mfaVerified) {
|
|
269
|
+
const challenge = await this.initiateMFA(authResult);
|
|
270
|
+
return {
|
|
271
|
+
success: false,
|
|
272
|
+
mfaRequired: true,
|
|
273
|
+
mfaChallenge: challenge
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
// Create session
|
|
278
|
+
const session = await this.createSession(providerId, authResult, authState);
|
|
279
|
+
|
|
280
|
+
// Map IdP groups to ELSABRO roles
|
|
281
|
+
await this.mapGroupsToRoles(session);
|
|
282
|
+
|
|
283
|
+
// Just-in-time provisioning
|
|
284
|
+
await this.provisionUser(session);
|
|
285
|
+
|
|
286
|
+
AuditLogger.success('sso.auth.completed', {
|
|
287
|
+
principal: session.userId,
|
|
288
|
+
details: { providerId, protocol: provider.protocol }
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return {
|
|
292
|
+
success: true,
|
|
293
|
+
session,
|
|
294
|
+
redirectUrl: callbackData.redirectUri || '/dashboard'
|
|
295
|
+
};
|
|
296
|
+
} catch (error) {
|
|
297
|
+
AuditLogger.failure('sso.auth.failed', {
|
|
298
|
+
details: {
|
|
299
|
+
providerId,
|
|
300
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
success: false,
|
|
306
|
+
error: {
|
|
307
|
+
code: 'AUTH_FAILED',
|
|
308
|
+
message: error instanceof Error ? error.message : 'Authentication failed',
|
|
309
|
+
providerId
|
|
310
|
+
}
|
|
311
|
+
};
|
|
312
|
+
} finally {
|
|
313
|
+
// Clean up auth state
|
|
314
|
+
await this.deleteAuthState(callbackData.state);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// Get session by ID
|
|
319
|
+
async getSession(sessionId: string): Promise<SSOSession | null> {
|
|
320
|
+
const session = this.sessions.get(sessionId);
|
|
321
|
+
if (!session) return null;
|
|
322
|
+
|
|
323
|
+
// Check expiration
|
|
324
|
+
if (new Date(session.expiresAt) < new Date()) {
|
|
325
|
+
await this.destroySession(sessionId);
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Check timeout
|
|
330
|
+
const lastActivity = new Date(session.lastActivityAt);
|
|
331
|
+
const timeout = this.config.sessionTimeout * 1000;
|
|
332
|
+
if (Date.now() - lastActivity.getTime() > timeout) {
|
|
333
|
+
await this.destroySession(sessionId);
|
|
334
|
+
return null;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Update last activity
|
|
338
|
+
session.lastActivityAt = new Date().toISOString();
|
|
339
|
+
this.sessions.set(sessionId, session);
|
|
340
|
+
|
|
341
|
+
return session;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Refresh session tokens
|
|
345
|
+
async refreshSession(sessionId: string): Promise<SSOSession | null> {
|
|
346
|
+
const session = this.sessions.get(sessionId);
|
|
347
|
+
if (!session || !session.refreshToken) return null;
|
|
348
|
+
|
|
349
|
+
const provider = this.providers.get(session.providerId);
|
|
350
|
+
if (!provider) return null;
|
|
351
|
+
|
|
352
|
+
try {
|
|
353
|
+
const refreshed = await provider.refreshTokens(session.refreshToken);
|
|
354
|
+
|
|
355
|
+
session.accessToken = refreshed.accessToken;
|
|
356
|
+
session.refreshToken = refreshed.refreshToken || session.refreshToken;
|
|
357
|
+
session.idToken = refreshed.idToken;
|
|
358
|
+
session.expiresAt = refreshed.expiresAt;
|
|
359
|
+
session.lastActivityAt = new Date().toISOString();
|
|
360
|
+
|
|
361
|
+
this.sessions.set(sessionId, session);
|
|
362
|
+
|
|
363
|
+
AuditLogger.log({
|
|
364
|
+
action: 'sso.session.refreshed',
|
|
365
|
+
principal: session.userId,
|
|
366
|
+
resource: sessionId
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
return session;
|
|
370
|
+
} catch (error) {
|
|
371
|
+
AuditLogger.failure('sso.session.refresh_failed', {
|
|
372
|
+
principal: session.userId,
|
|
373
|
+
resource: sessionId
|
|
374
|
+
});
|
|
375
|
+
return null;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Single logout
|
|
380
|
+
async logout(sessionId: string): Promise<LogoutResult> {
|
|
381
|
+
const session = this.sessions.get(sessionId);
|
|
382
|
+
if (!session) {
|
|
383
|
+
return { success: true, logoutUrls: [] };
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
const logoutUrls: string[] = [];
|
|
387
|
+
|
|
388
|
+
if (this.config.singleLogout) {
|
|
389
|
+
const provider = this.providers.get(session.providerId);
|
|
390
|
+
if (provider && provider.getLogoutUrl) {
|
|
391
|
+
const logoutUrl = await provider.getLogoutUrl(session);
|
|
392
|
+
if (logoutUrl) {
|
|
393
|
+
logoutUrls.push(logoutUrl);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
await this.destroySession(sessionId);
|
|
399
|
+
|
|
400
|
+
AuditLogger.log({
|
|
401
|
+
action: 'sso.logout',
|
|
402
|
+
principal: session.userId,
|
|
403
|
+
details: { providerId: session.providerId, singleLogout: this.config.singleLogout }
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
return {
|
|
407
|
+
success: true,
|
|
408
|
+
logoutUrls
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// Destroy session
|
|
413
|
+
async destroySession(sessionId: string): Promise<void> {
|
|
414
|
+
const session = this.sessions.get(sessionId);
|
|
415
|
+
if (session) {
|
|
416
|
+
// Revoke tokens if possible
|
|
417
|
+
const provider = this.providers.get(session.providerId);
|
|
418
|
+
if (provider && session.accessToken) {
|
|
419
|
+
await provider.revokeToken?.(session.accessToken).catch(() => {});
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
this.sessions.delete(sessionId);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// List active sessions for user
|
|
426
|
+
listUserSessions(userId: string): SSOSession[] {
|
|
427
|
+
return Array.from(this.sessions.values())
|
|
428
|
+
.filter(s => s.userId === userId);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// Terminate all user sessions
|
|
432
|
+
async terminateUserSessions(userId: string): Promise<number> {
|
|
433
|
+
const sessions = this.listUserSessions(userId);
|
|
434
|
+
for (const session of sessions) {
|
|
435
|
+
await this.destroySession(session.id);
|
|
436
|
+
}
|
|
437
|
+
return sessions.length;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// Private methods
|
|
441
|
+
private async createSession(
|
|
442
|
+
providerId: string,
|
|
443
|
+
authResult: ProviderAuthResult,
|
|
444
|
+
authState: AuthState
|
|
445
|
+
): Promise<SSOSession> {
|
|
446
|
+
const sessionId = this.generateSessionId();
|
|
447
|
+
const expiresAt = new Date(Date.now() + this.config.sessionTimeout * 1000);
|
|
448
|
+
|
|
449
|
+
const session: SSOSession = {
|
|
450
|
+
id: sessionId,
|
|
451
|
+
userId: authResult.userId || authResult.attributes.email,
|
|
452
|
+
providerId,
|
|
453
|
+
protocol: authResult.protocol,
|
|
454
|
+
accessToken: authResult.accessToken,
|
|
455
|
+
refreshToken: authResult.refreshToken,
|
|
456
|
+
idToken: authResult.idToken,
|
|
457
|
+
expiresAt: expiresAt.toISOString(),
|
|
458
|
+
createdAt: new Date().toISOString(),
|
|
459
|
+
lastActivityAt: new Date().toISOString(),
|
|
460
|
+
attributes: authResult.attributes,
|
|
461
|
+
mfaVerified: authResult.mfaVerified || false,
|
|
462
|
+
ipAddress: authState.ipAddress || '',
|
|
463
|
+
userAgent: authState.userAgent || ''
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
// Encrypt sensitive tokens
|
|
467
|
+
if (session.accessToken) {
|
|
468
|
+
session.accessToken = await this.tokenService.encrypt(session.accessToken);
|
|
469
|
+
}
|
|
470
|
+
if (session.refreshToken) {
|
|
471
|
+
session.refreshToken = await this.tokenService.encrypt(session.refreshToken);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
this.sessions.set(sessionId, session);
|
|
475
|
+
return session;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
private async mapGroupsToRoles(session: SSOSession): Promise<void> {
|
|
479
|
+
// Get role mapping configuration
|
|
480
|
+
const provider = this.providers.get(session.providerId);
|
|
481
|
+
if (!provider?.roleMapping) return;
|
|
482
|
+
|
|
483
|
+
const mappedRoles: string[] = [];
|
|
484
|
+
for (const group of session.attributes.groups) {
|
|
485
|
+
const role = provider.roleMapping[group];
|
|
486
|
+
if (role) {
|
|
487
|
+
mappedRoles.push(role);
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
session.attributes.roles = [...new Set([...session.attributes.roles, ...mappedRoles])];
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
private async provisionUser(session: SSOSession): Promise<void> {
|
|
495
|
+
// JIT provisioning - create or update user in ELSABRO
|
|
496
|
+
EventBus.publish('sso.user.provisioned', {
|
|
497
|
+
userId: session.userId,
|
|
498
|
+
attributes: session.attributes,
|
|
499
|
+
providerId: session.providerId,
|
|
500
|
+
timestamp: new Date().toISOString()
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
private isIPAllowed(ip: string): boolean {
|
|
505
|
+
if (!this.config.ipAllowlist) return true;
|
|
506
|
+
return this.config.ipAllowlist.some(allowed => {
|
|
507
|
+
if (allowed.includes('/')) {
|
|
508
|
+
return this.matchCIDR(allowed, ip);
|
|
509
|
+
}
|
|
510
|
+
return allowed === ip;
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
private matchCIDR(cidr: string, ip: string): boolean {
|
|
515
|
+
const [network, bits] = cidr.split('/');
|
|
516
|
+
const mask = ~(Math.pow(2, 32 - parseInt(bits)) - 1);
|
|
517
|
+
const ipNum = this.ipToNum(ip);
|
|
518
|
+
const networkNum = this.ipToNum(network);
|
|
519
|
+
return (ipNum & mask) === (networkNum & mask);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
private ipToNum(ip: string): number {
|
|
523
|
+
return ip.split('.').reduce((acc, octet) => (acc << 8) + parseInt(octet), 0);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
private generateSessionId(): string {
|
|
527
|
+
return `sso_${Date.now()}_${crypto.randomBytes(16).toString('hex')}`;
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private generateState(): string {
|
|
531
|
+
return crypto.randomBytes(32).toString('base64url');
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
private generateNonce(): string {
|
|
535
|
+
return crypto.randomBytes(16).toString('base64url');
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
private async storeAuthState(state: string, data: AuthState): Promise<void> {
|
|
539
|
+
// Store in memory or cache with TTL
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
private async getAuthState(state: string): Promise<AuthState | null> {
|
|
543
|
+
// Retrieve from memory or cache
|
|
544
|
+
return null;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
private async deleteAuthState(state: string): Promise<void> {
|
|
548
|
+
// Delete from memory or cache
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
private async initiateMFA(authResult: ProviderAuthResult): Promise<MFAChallenge> {
|
|
552
|
+
// Initiate MFA challenge
|
|
553
|
+
return {
|
|
554
|
+
type: 'totp',
|
|
555
|
+
challengeId: crypto.randomBytes(16).toString('hex'),
|
|
556
|
+
expiresAt: new Date(Date.now() + 300000).toISOString()
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
---
|
|
563
|
+
|
|
564
|
+
## SAMLProvider
|
|
565
|
+
|
|
566
|
+
```typescript
|
|
567
|
+
interface SAMLConfig {
|
|
568
|
+
entityId: string;
|
|
569
|
+
assertionConsumerServiceUrl: string;
|
|
570
|
+
singleLogoutServiceUrl?: string;
|
|
571
|
+
certificate: string;
|
|
572
|
+
privateKey: string;
|
|
573
|
+
signAuthnRequests: boolean;
|
|
574
|
+
wantAssertionsSigned: boolean;
|
|
575
|
+
wantResponseSigned: boolean;
|
|
576
|
+
signatureAlgorithm: 'sha256' | 'sha384' | 'sha512';
|
|
577
|
+
identityProviderMetadataUrl?: string;
|
|
578
|
+
identityProviderMetadata?: SAMLIdPMetadata;
|
|
579
|
+
attributeMapping: SAMLAttributeMapping;
|
|
580
|
+
allowedClockSkewSeconds: number;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
interface SAMLIdPMetadata {
|
|
584
|
+
entityId: string;
|
|
585
|
+
singleSignOnUrl: string;
|
|
586
|
+
singleLogoutUrl?: string;
|
|
587
|
+
certificate: string;
|
|
588
|
+
nameIdFormat: string;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
interface SAMLAttributeMapping {
|
|
592
|
+
email: string;
|
|
593
|
+
name?: string;
|
|
594
|
+
firstName?: string;
|
|
595
|
+
lastName?: string;
|
|
596
|
+
groups?: string;
|
|
597
|
+
department?: string;
|
|
598
|
+
customMappings?: Record<string, string>;
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
interface SAMLAssertion {
|
|
602
|
+
issuer: string;
|
|
603
|
+
subject: {
|
|
604
|
+
nameId: string;
|
|
605
|
+
nameIdFormat: string;
|
|
606
|
+
};
|
|
607
|
+
conditions: {
|
|
608
|
+
notBefore: string;
|
|
609
|
+
notOnOrAfter: string;
|
|
610
|
+
audienceRestriction: string[];
|
|
611
|
+
};
|
|
612
|
+
authnStatement: {
|
|
613
|
+
authnInstant: string;
|
|
614
|
+
sessionIndex: string;
|
|
615
|
+
authnContext: string;
|
|
616
|
+
};
|
|
617
|
+
attributes: Record<string, string | string[]>;
|
|
618
|
+
signature?: SAMLSignature;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
interface SAMLSignature {
|
|
622
|
+
algorithm: string;
|
|
623
|
+
value: string;
|
|
624
|
+
certificate: string;
|
|
625
|
+
valid: boolean;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
class SAMLProvider implements IdentityProvider {
|
|
629
|
+
id: string;
|
|
630
|
+
name: string;
|
|
631
|
+
protocol: 'saml' = 'saml';
|
|
632
|
+
|
|
633
|
+
private config: SAMLConfig;
|
|
634
|
+
private idpMetadata: SAMLIdPMetadata;
|
|
635
|
+
roleMapping?: Record<string, string>;
|
|
636
|
+
|
|
637
|
+
constructor(id: string, name: string, config: SAMLConfig) {
|
|
638
|
+
this.id = id;
|
|
639
|
+
this.name = name;
|
|
640
|
+
this.config = config;
|
|
641
|
+
this.loadIdPMetadata();
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Load IdP metadata from URL or config
|
|
645
|
+
private async loadIdPMetadata(): Promise<void> {
|
|
646
|
+
if (this.config.identityProviderMetadataUrl) {
|
|
647
|
+
const response = await fetch(this.config.identityProviderMetadataUrl);
|
|
648
|
+
const xml = await response.text();
|
|
649
|
+
this.idpMetadata = this.parseMetadataXML(xml);
|
|
650
|
+
} else if (this.config.identityProviderMetadata) {
|
|
651
|
+
this.idpMetadata = this.config.identityProviderMetadata;
|
|
652
|
+
} else {
|
|
653
|
+
throw new Error('IdP metadata required');
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// Generate SP metadata XML
|
|
658
|
+
generateMetadata(): string {
|
|
659
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
660
|
+
<md:EntityDescriptor
|
|
661
|
+
xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata"
|
|
662
|
+
entityID="${this.config.entityId}">
|
|
663
|
+
<md:SPSSODescriptor
|
|
664
|
+
AuthnRequestsSigned="${this.config.signAuthnRequests}"
|
|
665
|
+
WantAssertionsSigned="${this.config.wantAssertionsSigned}"
|
|
666
|
+
protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
|
|
667
|
+
<md:KeyDescriptor use="signing">
|
|
668
|
+
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
|
669
|
+
<ds:X509Data>
|
|
670
|
+
<ds:X509Certificate>${this.config.certificate}</ds:X509Certificate>
|
|
671
|
+
</ds:X509Data>
|
|
672
|
+
</ds:KeyInfo>
|
|
673
|
+
</md:KeyDescriptor>
|
|
674
|
+
<md:KeyDescriptor use="encryption">
|
|
675
|
+
<ds:KeyInfo xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
|
|
676
|
+
<ds:X509Data>
|
|
677
|
+
<ds:X509Certificate>${this.config.certificate}</ds:X509Certificate>
|
|
678
|
+
</ds:X509Data>
|
|
679
|
+
</ds:KeyInfo>
|
|
680
|
+
</md:KeyDescriptor>
|
|
681
|
+
<md:SingleLogoutService
|
|
682
|
+
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
|
|
683
|
+
Location="${this.config.singleLogoutServiceUrl}"/>
|
|
684
|
+
<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>
|
|
685
|
+
<md:AssertionConsumerService
|
|
686
|
+
Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST"
|
|
687
|
+
Location="${this.config.assertionConsumerServiceUrl}"
|
|
688
|
+
index="0"
|
|
689
|
+
isDefault="true"/>
|
|
690
|
+
</md:SPSSODescriptor>
|
|
691
|
+
</md:EntityDescriptor>`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Get authorization URL (SAML AuthnRequest)
|
|
695
|
+
async getAuthorizationUrl(options: AuthOptions): Promise<string> {
|
|
696
|
+
const authnRequest = this.buildAuthnRequest(options);
|
|
697
|
+
const signedRequest = this.config.signAuthnRequests
|
|
698
|
+
? await this.signRequest(authnRequest)
|
|
699
|
+
: authnRequest;
|
|
700
|
+
|
|
701
|
+
const encodedRequest = Buffer.from(signedRequest).toString('base64');
|
|
702
|
+
const deflatedRequest = await this.deflate(encodedRequest);
|
|
703
|
+
|
|
704
|
+
const params = new URLSearchParams({
|
|
705
|
+
SAMLRequest: deflatedRequest,
|
|
706
|
+
RelayState: options.state || ''
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
return `${this.idpMetadata.singleSignOnUrl}?${params.toString()}`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// Process SAML callback (Response)
|
|
713
|
+
async processCallback(
|
|
714
|
+
callbackData: CallbackData,
|
|
715
|
+
expectedNonce?: string
|
|
716
|
+
): Promise<ProviderAuthResult> {
|
|
717
|
+
const samlResponse = callbackData.SAMLResponse;
|
|
718
|
+
if (!samlResponse) {
|
|
719
|
+
throw new Error('Missing SAML Response');
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
// Decode response
|
|
723
|
+
const decodedResponse = Buffer.from(samlResponse, 'base64').toString('utf8');
|
|
724
|
+
|
|
725
|
+
// Validate signature
|
|
726
|
+
if (this.config.wantResponseSigned) {
|
|
727
|
+
const signatureValid = await this.validateSignature(
|
|
728
|
+
decodedResponse,
|
|
729
|
+
this.idpMetadata.certificate
|
|
730
|
+
);
|
|
731
|
+
if (!signatureValid) {
|
|
732
|
+
throw new Error('Invalid SAML Response signature');
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
// Parse assertion
|
|
737
|
+
const assertion = this.parseAssertion(decodedResponse);
|
|
738
|
+
|
|
739
|
+
// Validate assertion
|
|
740
|
+
this.validateAssertion(assertion);
|
|
741
|
+
|
|
742
|
+
// Map attributes
|
|
743
|
+
const attributes = this.mapAttributes(assertion);
|
|
744
|
+
|
|
745
|
+
return {
|
|
746
|
+
protocol: 'saml',
|
|
747
|
+
userId: assertion.subject.nameId,
|
|
748
|
+
attributes,
|
|
749
|
+
sessionIndex: assertion.authnStatement.sessionIndex,
|
|
750
|
+
mfaVerified: this.checkMFAContext(assertion.authnStatement.authnContext)
|
|
751
|
+
};
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
// Get logout URL
|
|
755
|
+
async getLogoutUrl(session: SSOSession): Promise<string | null> {
|
|
756
|
+
if (!this.idpMetadata.singleLogoutUrl || !this.config.singleLogoutServiceUrl) {
|
|
757
|
+
return null;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
const logoutRequest = this.buildLogoutRequest(session);
|
|
761
|
+
const signedRequest = await this.signRequest(logoutRequest);
|
|
762
|
+
const encodedRequest = Buffer.from(signedRequest).toString('base64');
|
|
763
|
+
|
|
764
|
+
const params = new URLSearchParams({
|
|
765
|
+
SAMLRequest: encodedRequest
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
return `${this.idpMetadata.singleLogoutUrl}?${params.toString()}`;
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Validate SAML assertion
|
|
772
|
+
private validateAssertion(assertion: SAMLAssertion): void {
|
|
773
|
+
const now = new Date();
|
|
774
|
+
const clockSkew = this.config.allowedClockSkewSeconds * 1000;
|
|
775
|
+
|
|
776
|
+
// Check time conditions
|
|
777
|
+
const notBefore = new Date(assertion.conditions.notBefore);
|
|
778
|
+
const notOnOrAfter = new Date(assertion.conditions.notOnOrAfter);
|
|
779
|
+
|
|
780
|
+
if (now.getTime() < notBefore.getTime() - clockSkew) {
|
|
781
|
+
throw new Error('Assertion not yet valid');
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
if (now.getTime() > notOnOrAfter.getTime() + clockSkew) {
|
|
785
|
+
throw new Error('Assertion expired');
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// Check audience
|
|
789
|
+
if (!assertion.conditions.audienceRestriction.includes(this.config.entityId)) {
|
|
790
|
+
throw new Error('Invalid audience');
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// Validate signature if required
|
|
794
|
+
if (this.config.wantAssertionsSigned && assertion.signature) {
|
|
795
|
+
if (!assertion.signature.valid) {
|
|
796
|
+
throw new Error('Invalid assertion signature');
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
// Map SAML attributes to UserAttributes
|
|
802
|
+
private mapAttributes(assertion: SAMLAssertion): UserAttributes {
|
|
803
|
+
const mapping = this.config.attributeMapping;
|
|
804
|
+
const attrs = assertion.attributes;
|
|
805
|
+
|
|
806
|
+
const getValue = (key: string): string | undefined => {
|
|
807
|
+
const value = attrs[key];
|
|
808
|
+
return Array.isArray(value) ? value[0] : value;
|
|
809
|
+
};
|
|
810
|
+
|
|
811
|
+
const getArrayValue = (key: string): string[] => {
|
|
812
|
+
const value = attrs[key];
|
|
813
|
+
return Array.isArray(value) ? value : value ? [value] : [];
|
|
814
|
+
};
|
|
815
|
+
|
|
816
|
+
const attributes: UserAttributes = {
|
|
817
|
+
email: getValue(mapping.email) || assertion.subject.nameId,
|
|
818
|
+
name: mapping.name ? getValue(mapping.name) : undefined,
|
|
819
|
+
firstName: mapping.firstName ? getValue(mapping.firstName) : undefined,
|
|
820
|
+
lastName: mapping.lastName ? getValue(mapping.lastName) : undefined,
|
|
821
|
+
groups: mapping.groups ? getArrayValue(mapping.groups) : [],
|
|
822
|
+
roles: [],
|
|
823
|
+
department: mapping.department ? getValue(mapping.department) : undefined
|
|
824
|
+
};
|
|
825
|
+
|
|
826
|
+
// Map custom attributes
|
|
827
|
+
if (mapping.customMappings) {
|
|
828
|
+
attributes.customAttributes = {};
|
|
829
|
+
for (const [target, source] of Object.entries(mapping.customMappings)) {
|
|
830
|
+
attributes.customAttributes[target] = getValue(source);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
return attributes;
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
private checkMFAContext(authnContext: string): boolean {
|
|
838
|
+
const mfaContexts = [
|
|
839
|
+
'urn:oasis:names:tc:SAML:2.0:ac:classes:MobileTwoFactorContract',
|
|
840
|
+
'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorOTPSMS',
|
|
841
|
+
'urn:oasis:names:tc:SAML:2.0:ac:classes:SecondFactorIGTOKEN'
|
|
842
|
+
];
|
|
843
|
+
return mfaContexts.includes(authnContext);
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
private buildAuthnRequest(options: AuthOptions): string {
|
|
847
|
+
const id = `_${crypto.randomBytes(16).toString('hex')}`;
|
|
848
|
+
const issueInstant = new Date().toISOString();
|
|
849
|
+
|
|
850
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
851
|
+
<samlp:AuthnRequest
|
|
852
|
+
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
|
853
|
+
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
|
854
|
+
ID="${id}"
|
|
855
|
+
Version="2.0"
|
|
856
|
+
IssueInstant="${issueInstant}"
|
|
857
|
+
Destination="${this.idpMetadata.singleSignOnUrl}"
|
|
858
|
+
AssertionConsumerServiceURL="${this.config.assertionConsumerServiceUrl}"
|
|
859
|
+
ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">
|
|
860
|
+
<saml:Issuer>${this.config.entityId}</saml:Issuer>
|
|
861
|
+
<samlp:NameIDPolicy
|
|
862
|
+
Format="${this.idpMetadata.nameIdFormat}"
|
|
863
|
+
AllowCreate="true"/>
|
|
864
|
+
</samlp:AuthnRequest>`;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
private buildLogoutRequest(session: SSOSession): string {
|
|
868
|
+
const id = `_${crypto.randomBytes(16).toString('hex')}`;
|
|
869
|
+
const issueInstant = new Date().toISOString();
|
|
870
|
+
|
|
871
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
872
|
+
<samlp:LogoutRequest
|
|
873
|
+
xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol"
|
|
874
|
+
xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion"
|
|
875
|
+
ID="${id}"
|
|
876
|
+
Version="2.0"
|
|
877
|
+
IssueInstant="${issueInstant}"
|
|
878
|
+
Destination="${this.idpMetadata.singleLogoutUrl}">
|
|
879
|
+
<saml:Issuer>${this.config.entityId}</saml:Issuer>
|
|
880
|
+
<saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">
|
|
881
|
+
${session.userId}
|
|
882
|
+
</saml:NameID>
|
|
883
|
+
</samlp:LogoutRequest>`;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
private async signRequest(xml: string): Promise<string> {
|
|
887
|
+
// Sign XML with private key
|
|
888
|
+
// Implementation uses xmldsig
|
|
889
|
+
return xml;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
private async validateSignature(xml: string, certificate: string): Promise<boolean> {
|
|
893
|
+
// Validate XML signature
|
|
894
|
+
return true;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
private parseAssertion(xml: string): SAMLAssertion {
|
|
898
|
+
// Parse SAML Response XML and extract assertion
|
|
899
|
+
// Implementation uses xml2js or similar
|
|
900
|
+
return {} as SAMLAssertion;
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
private parseMetadataXML(xml: string): SAMLIdPMetadata {
|
|
904
|
+
// Parse IdP metadata XML
|
|
905
|
+
return {} as SAMLIdPMetadata;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
private async deflate(data: string): Promise<string> {
|
|
909
|
+
// Deflate and base64url encode
|
|
910
|
+
return data;
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
```
|
|
914
|
+
|
|
915
|
+
---
|
|
916
|
+
|
|
917
|
+
## OIDCProvider
|
|
918
|
+
|
|
919
|
+
```typescript
|
|
920
|
+
interface OIDCConfig {
|
|
921
|
+
clientId: string;
|
|
922
|
+
clientSecret: string;
|
|
923
|
+
issuer: string;
|
|
924
|
+
authorizationEndpoint?: string;
|
|
925
|
+
tokenEndpoint?: string;
|
|
926
|
+
userInfoEndpoint?: string;
|
|
927
|
+
jwksUri?: string;
|
|
928
|
+
endSessionEndpoint?: string;
|
|
929
|
+
scopes: string[];
|
|
930
|
+
responseType: 'code' | 'id_token' | 'code id_token';
|
|
931
|
+
responseMode?: 'query' | 'fragment' | 'form_post';
|
|
932
|
+
usePKCE: boolean;
|
|
933
|
+
redirectUri: string;
|
|
934
|
+
postLogoutRedirectUri?: string;
|
|
935
|
+
attributeMapping: OIDCAttributeMapping;
|
|
936
|
+
clockTolerance: number;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
interface OIDCAttributeMapping {
|
|
940
|
+
email: string;
|
|
941
|
+
name?: string;
|
|
942
|
+
firstName?: string;
|
|
943
|
+
lastName?: string;
|
|
944
|
+
groups?: string;
|
|
945
|
+
roles?: string;
|
|
946
|
+
customClaims?: Record<string, string>;
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
interface OIDCTokens {
|
|
950
|
+
accessToken: string;
|
|
951
|
+
refreshToken?: string;
|
|
952
|
+
idToken: string;
|
|
953
|
+
tokenType: string;
|
|
954
|
+
expiresIn: number;
|
|
955
|
+
scope: string;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
interface OIDCIdTokenClaims {
|
|
959
|
+
iss: string;
|
|
960
|
+
sub: string;
|
|
961
|
+
aud: string | string[];
|
|
962
|
+
exp: number;
|
|
963
|
+
iat: number;
|
|
964
|
+
nonce?: string;
|
|
965
|
+
auth_time?: number;
|
|
966
|
+
acr?: string;
|
|
967
|
+
amr?: string[];
|
|
968
|
+
[key: string]: unknown;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
interface PKCEChallenge {
|
|
972
|
+
verifier: string;
|
|
973
|
+
challenge: string;
|
|
974
|
+
method: 'S256';
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
class OIDCProvider implements IdentityProvider {
|
|
978
|
+
id: string;
|
|
979
|
+
name: string;
|
|
980
|
+
protocol: 'oidc' = 'oidc';
|
|
981
|
+
|
|
982
|
+
private config: OIDCConfig;
|
|
983
|
+
private discoveryDoc: OIDCDiscoveryDocument;
|
|
984
|
+
private jwks: JWKS;
|
|
985
|
+
roleMapping?: Record<string, string>;
|
|
986
|
+
|
|
987
|
+
constructor(id: string, name: string, config: OIDCConfig) {
|
|
988
|
+
this.id = id;
|
|
989
|
+
this.name = name;
|
|
990
|
+
this.config = config;
|
|
991
|
+
this.discoverEndpoints();
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// Discover OIDC endpoints from issuer
|
|
995
|
+
private async discoverEndpoints(): Promise<void> {
|
|
996
|
+
const discoveryUrl = `${this.config.issuer}/.well-known/openid-configuration`;
|
|
997
|
+
const response = await fetch(discoveryUrl);
|
|
998
|
+
this.discoveryDoc = await response.json();
|
|
999
|
+
|
|
1000
|
+
// Load JWKS for token validation
|
|
1001
|
+
if (this.discoveryDoc.jwks_uri) {
|
|
1002
|
+
const jwksResponse = await fetch(this.discoveryDoc.jwks_uri);
|
|
1003
|
+
this.jwks = await jwksResponse.json();
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// Get authorization URL
|
|
1008
|
+
async getAuthorizationUrl(options: AuthOptions): Promise<string> {
|
|
1009
|
+
const endpoint = this.config.authorizationEndpoint ||
|
|
1010
|
+
this.discoveryDoc.authorization_endpoint;
|
|
1011
|
+
|
|
1012
|
+
const params: Record<string, string> = {
|
|
1013
|
+
client_id: this.config.clientId,
|
|
1014
|
+
redirect_uri: options.redirectUri || this.config.redirectUri,
|
|
1015
|
+
response_type: this.config.responseType,
|
|
1016
|
+
scope: options.scope?.join(' ') || this.config.scopes.join(' '),
|
|
1017
|
+
state: options.state || '',
|
|
1018
|
+
nonce: options.nonce || ''
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
if (this.config.responseMode) {
|
|
1022
|
+
params.response_mode = this.config.responseMode;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
if (options.prompt) {
|
|
1026
|
+
params.prompt = options.prompt;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Add PKCE if enabled
|
|
1030
|
+
if (this.config.usePKCE) {
|
|
1031
|
+
const pkce = this.generatePKCE();
|
|
1032
|
+
params.code_challenge = pkce.challenge;
|
|
1033
|
+
params.code_challenge_method = pkce.method;
|
|
1034
|
+
// Store verifier for token exchange
|
|
1035
|
+
await this.storePKCEVerifier(options.state!, pkce.verifier);
|
|
1036
|
+
}
|
|
1037
|
+
|
|
1038
|
+
const queryString = new URLSearchParams(params).toString();
|
|
1039
|
+
return `${endpoint}?${queryString}`;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Process OIDC callback
|
|
1043
|
+
async processCallback(
|
|
1044
|
+
callbackData: CallbackData,
|
|
1045
|
+
expectedNonce?: string
|
|
1046
|
+
): Promise<ProviderAuthResult> {
|
|
1047
|
+
// Check for error response
|
|
1048
|
+
if (callbackData.error) {
|
|
1049
|
+
throw new Error(`OIDC error: ${callbackData.error} - ${callbackData.error_description}`);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
// Exchange authorization code for tokens
|
|
1053
|
+
const tokens = await this.exchangeCode(callbackData.code, callbackData.state);
|
|
1054
|
+
|
|
1055
|
+
// Validate ID token
|
|
1056
|
+
const claims = await this.validateIdToken(tokens.idToken, expectedNonce);
|
|
1057
|
+
|
|
1058
|
+
// Get user info if needed
|
|
1059
|
+
let userInfo: Record<string, unknown> = {};
|
|
1060
|
+
if (this.config.userInfoEndpoint || this.discoveryDoc.userinfo_endpoint) {
|
|
1061
|
+
userInfo = await this.getUserInfo(tokens.accessToken);
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
// Merge claims with userinfo
|
|
1065
|
+
const allClaims = { ...claims, ...userInfo };
|
|
1066
|
+
|
|
1067
|
+
// Map attributes
|
|
1068
|
+
const attributes = this.mapAttributes(allClaims);
|
|
1069
|
+
|
|
1070
|
+
return {
|
|
1071
|
+
protocol: 'oidc',
|
|
1072
|
+
userId: claims.sub,
|
|
1073
|
+
accessToken: tokens.accessToken,
|
|
1074
|
+
refreshToken: tokens.refreshToken,
|
|
1075
|
+
idToken: tokens.idToken,
|
|
1076
|
+
expiresAt: new Date(Date.now() + tokens.expiresIn * 1000).toISOString(),
|
|
1077
|
+
attributes,
|
|
1078
|
+
mfaVerified: this.checkMFAClaims(claims)
|
|
1079
|
+
};
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
// Exchange authorization code for tokens
|
|
1083
|
+
private async exchangeCode(code: string, state?: string): Promise<OIDCTokens> {
|
|
1084
|
+
const endpoint = this.config.tokenEndpoint || this.discoveryDoc.token_endpoint;
|
|
1085
|
+
|
|
1086
|
+
const params: Record<string, string> = {
|
|
1087
|
+
grant_type: 'authorization_code',
|
|
1088
|
+
client_id: this.config.clientId,
|
|
1089
|
+
client_secret: this.config.clientSecret,
|
|
1090
|
+
code,
|
|
1091
|
+
redirect_uri: this.config.redirectUri
|
|
1092
|
+
};
|
|
1093
|
+
|
|
1094
|
+
// Add PKCE verifier if used
|
|
1095
|
+
if (this.config.usePKCE && state) {
|
|
1096
|
+
const verifier = await this.getPKCEVerifier(state);
|
|
1097
|
+
if (verifier) {
|
|
1098
|
+
params.code_verifier = verifier;
|
|
1099
|
+
}
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
const response = await fetch(endpoint, {
|
|
1103
|
+
method: 'POST',
|
|
1104
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1105
|
+
body: new URLSearchParams(params).toString()
|
|
1106
|
+
});
|
|
1107
|
+
|
|
1108
|
+
if (!response.ok) {
|
|
1109
|
+
const error = await response.json();
|
|
1110
|
+
throw new Error(`Token exchange failed: ${error.error}`);
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
const data = await response.json();
|
|
1114
|
+
return {
|
|
1115
|
+
accessToken: data.access_token,
|
|
1116
|
+
refreshToken: data.refresh_token,
|
|
1117
|
+
idToken: data.id_token,
|
|
1118
|
+
tokenType: data.token_type,
|
|
1119
|
+
expiresIn: data.expires_in,
|
|
1120
|
+
scope: data.scope
|
|
1121
|
+
};
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
// Refresh tokens
|
|
1125
|
+
async refreshTokens(refreshToken: string): Promise<{
|
|
1126
|
+
accessToken: string;
|
|
1127
|
+
refreshToken?: string;
|
|
1128
|
+
idToken?: string;
|
|
1129
|
+
expiresAt: string;
|
|
1130
|
+
}> {
|
|
1131
|
+
const endpoint = this.config.tokenEndpoint || this.discoveryDoc.token_endpoint;
|
|
1132
|
+
|
|
1133
|
+
const params = {
|
|
1134
|
+
grant_type: 'refresh_token',
|
|
1135
|
+
client_id: this.config.clientId,
|
|
1136
|
+
client_secret: this.config.clientSecret,
|
|
1137
|
+
refresh_token: refreshToken
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
const response = await fetch(endpoint, {
|
|
1141
|
+
method: 'POST',
|
|
1142
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1143
|
+
body: new URLSearchParams(params).toString()
|
|
1144
|
+
});
|
|
1145
|
+
|
|
1146
|
+
if (!response.ok) {
|
|
1147
|
+
throw new Error('Token refresh failed');
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
const data = await response.json();
|
|
1151
|
+
return {
|
|
1152
|
+
accessToken: data.access_token,
|
|
1153
|
+
refreshToken: data.refresh_token,
|
|
1154
|
+
idToken: data.id_token,
|
|
1155
|
+
expiresAt: new Date(Date.now() + data.expires_in * 1000).toISOString()
|
|
1156
|
+
};
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// Revoke token
|
|
1160
|
+
async revokeToken(token: string): Promise<void> {
|
|
1161
|
+
if (!this.discoveryDoc.revocation_endpoint) return;
|
|
1162
|
+
|
|
1163
|
+
await fetch(this.discoveryDoc.revocation_endpoint, {
|
|
1164
|
+
method: 'POST',
|
|
1165
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
1166
|
+
body: new URLSearchParams({
|
|
1167
|
+
client_id: this.config.clientId,
|
|
1168
|
+
client_secret: this.config.clientSecret,
|
|
1169
|
+
token
|
|
1170
|
+
}).toString()
|
|
1171
|
+
});
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
// Get logout URL
|
|
1175
|
+
async getLogoutUrl(session: SSOSession): Promise<string | null> {
|
|
1176
|
+
const endpoint = this.config.endSessionEndpoint ||
|
|
1177
|
+
this.discoveryDoc.end_session_endpoint;
|
|
1178
|
+
if (!endpoint) return null;
|
|
1179
|
+
|
|
1180
|
+
const params: Record<string, string> = {
|
|
1181
|
+
client_id: this.config.clientId
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
if (session.idToken) {
|
|
1185
|
+
params.id_token_hint = session.idToken;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
if (this.config.postLogoutRedirectUri) {
|
|
1189
|
+
params.post_logout_redirect_uri = this.config.postLogoutRedirectUri;
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
return `${endpoint}?${new URLSearchParams(params).toString()}`;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
// Validate ID token
|
|
1196
|
+
private async validateIdToken(
|
|
1197
|
+
idToken: string,
|
|
1198
|
+
expectedNonce?: string
|
|
1199
|
+
): Promise<OIDCIdTokenClaims> {
|
|
1200
|
+
// Decode token
|
|
1201
|
+
const [headerB64, payloadB64, signature] = idToken.split('.');
|
|
1202
|
+
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
|
|
1203
|
+
const claims = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
1204
|
+
|
|
1205
|
+
// Find signing key
|
|
1206
|
+
const key = this.jwks.keys.find(k => k.kid === header.kid);
|
|
1207
|
+
if (!key) {
|
|
1208
|
+
throw new Error('Signing key not found');
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
// Verify signature
|
|
1212
|
+
const valid = await this.verifySignature(idToken, key, header.alg);
|
|
1213
|
+
if (!valid) {
|
|
1214
|
+
throw new Error('Invalid ID token signature');
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
// Validate claims
|
|
1218
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1219
|
+
|
|
1220
|
+
if (claims.iss !== this.config.issuer) {
|
|
1221
|
+
throw new Error('Invalid issuer');
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
const aud = Array.isArray(claims.aud) ? claims.aud : [claims.aud];
|
|
1225
|
+
if (!aud.includes(this.config.clientId)) {
|
|
1226
|
+
throw new Error('Invalid audience');
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
if (claims.exp < now - this.config.clockTolerance) {
|
|
1230
|
+
throw new Error('Token expired');
|
|
1231
|
+
}
|
|
1232
|
+
|
|
1233
|
+
if (claims.iat > now + this.config.clockTolerance) {
|
|
1234
|
+
throw new Error('Token issued in the future');
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (expectedNonce && claims.nonce !== expectedNonce) {
|
|
1238
|
+
throw new Error('Invalid nonce');
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
return claims;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
// Get user info from userinfo endpoint
|
|
1245
|
+
private async getUserInfo(accessToken: string): Promise<Record<string, unknown>> {
|
|
1246
|
+
const endpoint = this.config.userInfoEndpoint ||
|
|
1247
|
+
this.discoveryDoc.userinfo_endpoint;
|
|
1248
|
+
if (!endpoint) return {};
|
|
1249
|
+
|
|
1250
|
+
const response = await fetch(endpoint, {
|
|
1251
|
+
headers: { Authorization: `Bearer ${accessToken}` }
|
|
1252
|
+
});
|
|
1253
|
+
|
|
1254
|
+
if (!response.ok) {
|
|
1255
|
+
return {};
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
return response.json();
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Map OIDC claims to UserAttributes
|
|
1262
|
+
private mapAttributes(claims: Record<string, unknown>): UserAttributes {
|
|
1263
|
+
const mapping = this.config.attributeMapping;
|
|
1264
|
+
|
|
1265
|
+
const getValue = (key: string): string | undefined => {
|
|
1266
|
+
const value = claims[key];
|
|
1267
|
+
return typeof value === 'string' ? value : undefined;
|
|
1268
|
+
};
|
|
1269
|
+
|
|
1270
|
+
const getArrayValue = (key: string): string[] => {
|
|
1271
|
+
const value = claims[key];
|
|
1272
|
+
return Array.isArray(value) ? value : value ? [String(value)] : [];
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
const attributes: UserAttributes = {
|
|
1276
|
+
email: getValue(mapping.email) || '',
|
|
1277
|
+
name: mapping.name ? getValue(mapping.name) : undefined,
|
|
1278
|
+
firstName: mapping.firstName ? getValue(mapping.firstName) : undefined,
|
|
1279
|
+
lastName: mapping.lastName ? getValue(mapping.lastName) : undefined,
|
|
1280
|
+
groups: mapping.groups ? getArrayValue(mapping.groups) : [],
|
|
1281
|
+
roles: mapping.roles ? getArrayValue(mapping.roles) : []
|
|
1282
|
+
};
|
|
1283
|
+
|
|
1284
|
+
// Map custom claims
|
|
1285
|
+
if (mapping.customClaims) {
|
|
1286
|
+
attributes.customAttributes = {};
|
|
1287
|
+
for (const [target, source] of Object.entries(mapping.customClaims)) {
|
|
1288
|
+
attributes.customAttributes[target] = getValue(source);
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
return attributes;
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
private checkMFAClaims(claims: OIDCIdTokenClaims): boolean {
|
|
1296
|
+
// Check AMR (Authentication Methods References)
|
|
1297
|
+
if (claims.amr && Array.isArray(claims.amr)) {
|
|
1298
|
+
const mfaMethods = ['mfa', 'otp', 'sms', 'hwk', 'swk'];
|
|
1299
|
+
return claims.amr.some(m => mfaMethods.includes(m));
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// Check ACR (Authentication Context Class Reference)
|
|
1303
|
+
if (claims.acr) {
|
|
1304
|
+
const mfaAcrs = ['urn:mace:incommon:iap:silver', 'http://schemas.openid.net/pape/policies/2007/06/multi-factor'];
|
|
1305
|
+
return mfaAcrs.includes(claims.acr);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
return false;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1311
|
+
// Generate PKCE challenge
|
|
1312
|
+
private generatePKCE(): PKCEChallenge {
|
|
1313
|
+
const verifier = crypto.randomBytes(32).toString('base64url');
|
|
1314
|
+
const challenge = crypto
|
|
1315
|
+
.createHash('sha256')
|
|
1316
|
+
.update(verifier)
|
|
1317
|
+
.digest('base64url');
|
|
1318
|
+
|
|
1319
|
+
return { verifier, challenge, method: 'S256' };
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
private async storePKCEVerifier(state: string, verifier: string): Promise<void> {
|
|
1323
|
+
// Store in cache with TTL
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
private async getPKCEVerifier(state: string): Promise<string | null> {
|
|
1327
|
+
// Retrieve from cache
|
|
1328
|
+
return null;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
private async verifySignature(
|
|
1332
|
+
token: string,
|
|
1333
|
+
key: JWK,
|
|
1334
|
+
algorithm: string
|
|
1335
|
+
): Promise<boolean> {
|
|
1336
|
+
// Verify JWT signature using jose
|
|
1337
|
+
return true;
|
|
1338
|
+
}
|
|
1339
|
+
}
|
|
1340
|
+
```
|
|
1341
|
+
|
|
1342
|
+
---
|
|
1343
|
+
|
|
1344
|
+
## Identity Provider Configurations
|
|
1345
|
+
|
|
1346
|
+
### Okta
|
|
1347
|
+
|
|
1348
|
+
```typescript
|
|
1349
|
+
const oktaProvider = new OIDCProvider('okta', 'Okta', {
|
|
1350
|
+
clientId: process.env.OKTA_CLIENT_ID!,
|
|
1351
|
+
clientSecret: process.env.OKTA_CLIENT_SECRET!,
|
|
1352
|
+
issuer: `https://${process.env.OKTA_DOMAIN}/oauth2/default`,
|
|
1353
|
+
scopes: ['openid', 'profile', 'email', 'groups'],
|
|
1354
|
+
responseType: 'code',
|
|
1355
|
+
usePKCE: true,
|
|
1356
|
+
redirectUri: 'https://app.example.com/api/auth/callback/okta',
|
|
1357
|
+
attributeMapping: {
|
|
1358
|
+
email: 'email',
|
|
1359
|
+
name: 'name',
|
|
1360
|
+
firstName: 'given_name',
|
|
1361
|
+
lastName: 'family_name',
|
|
1362
|
+
groups: 'groups'
|
|
1363
|
+
},
|
|
1364
|
+
clockTolerance: 120
|
|
1365
|
+
});
|
|
1366
|
+
|
|
1367
|
+
oktaProvider.roleMapping = {
|
|
1368
|
+
'Everyone': 'user',
|
|
1369
|
+
'Engineering': 'developer',
|
|
1370
|
+
'Admins': 'admin',
|
|
1371
|
+
'Security': 'security-admin'
|
|
1372
|
+
};
|
|
1373
|
+
```
|
|
1374
|
+
|
|
1375
|
+
### Azure AD / Entra ID
|
|
1376
|
+
|
|
1377
|
+
```typescript
|
|
1378
|
+
const azureAdProvider = new OIDCProvider('azure-ad', 'Azure AD', {
|
|
1379
|
+
clientId: process.env.AZURE_CLIENT_ID!,
|
|
1380
|
+
clientSecret: process.env.AZURE_CLIENT_SECRET!,
|
|
1381
|
+
issuer: `https://login.microsoftonline.com/${process.env.AZURE_TENANT_ID}/v2.0`,
|
|
1382
|
+
scopes: ['openid', 'profile', 'email', 'User.Read', 'GroupMember.Read.All'],
|
|
1383
|
+
responseType: 'code',
|
|
1384
|
+
usePKCE: true,
|
|
1385
|
+
redirectUri: 'https://app.example.com/api/auth/callback/azure',
|
|
1386
|
+
attributeMapping: {
|
|
1387
|
+
email: 'email',
|
|
1388
|
+
name: 'name',
|
|
1389
|
+
firstName: 'given_name',
|
|
1390
|
+
lastName: 'family_name',
|
|
1391
|
+
groups: 'groups',
|
|
1392
|
+
customClaims: {
|
|
1393
|
+
department: 'department',
|
|
1394
|
+
jobTitle: 'jobTitle'
|
|
1395
|
+
}
|
|
1396
|
+
},
|
|
1397
|
+
clockTolerance: 120
|
|
1398
|
+
});
|
|
1399
|
+
|
|
1400
|
+
azureAdProvider.roleMapping = {
|
|
1401
|
+
'Global Administrators': 'admin',
|
|
1402
|
+
'Application Administrators': 'app-admin',
|
|
1403
|
+
'Developers': 'developer',
|
|
1404
|
+
'Users': 'user'
|
|
1405
|
+
};
|
|
1406
|
+
```
|
|
1407
|
+
|
|
1408
|
+
### Google Workspace
|
|
1409
|
+
|
|
1410
|
+
```typescript
|
|
1411
|
+
const googleProvider = new OIDCProvider('google', 'Google Workspace', {
|
|
1412
|
+
clientId: process.env.GOOGLE_CLIENT_ID!,
|
|
1413
|
+
clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
|
|
1414
|
+
issuer: 'https://accounts.google.com',
|
|
1415
|
+
scopes: [
|
|
1416
|
+
'openid',
|
|
1417
|
+
'profile',
|
|
1418
|
+
'email',
|
|
1419
|
+
'https://www.googleapis.com/auth/admin.directory.group.readonly'
|
|
1420
|
+
],
|
|
1421
|
+
responseType: 'code',
|
|
1422
|
+
usePKCE: true,
|
|
1423
|
+
redirectUri: 'https://app.example.com/api/auth/callback/google',
|
|
1424
|
+
attributeMapping: {
|
|
1425
|
+
email: 'email',
|
|
1426
|
+
name: 'name',
|
|
1427
|
+
firstName: 'given_name',
|
|
1428
|
+
lastName: 'family_name',
|
|
1429
|
+
customClaims: {
|
|
1430
|
+
picture: 'picture',
|
|
1431
|
+
hd: 'hd' // Hosted domain
|
|
1432
|
+
}
|
|
1433
|
+
},
|
|
1434
|
+
clockTolerance: 120
|
|
1435
|
+
});
|
|
1436
|
+
```
|
|
1437
|
+
|
|
1438
|
+
### Auth0
|
|
1439
|
+
|
|
1440
|
+
```typescript
|
|
1441
|
+
const auth0Provider = new OIDCProvider('auth0', 'Auth0', {
|
|
1442
|
+
clientId: process.env.AUTH0_CLIENT_ID!,
|
|
1443
|
+
clientSecret: process.env.AUTH0_CLIENT_SECRET!,
|
|
1444
|
+
issuer: `https://${process.env.AUTH0_DOMAIN}/`,
|
|
1445
|
+
scopes: ['openid', 'profile', 'email'],
|
|
1446
|
+
responseType: 'code',
|
|
1447
|
+
usePKCE: true,
|
|
1448
|
+
redirectUri: 'https://app.example.com/api/auth/callback/auth0',
|
|
1449
|
+
attributeMapping: {
|
|
1450
|
+
email: 'email',
|
|
1451
|
+
name: 'name',
|
|
1452
|
+
firstName: 'given_name',
|
|
1453
|
+
lastName: 'family_name',
|
|
1454
|
+
roles: 'https://app.example.com/roles',
|
|
1455
|
+
customClaims: {
|
|
1456
|
+
permissions: 'https://app.example.com/permissions'
|
|
1457
|
+
}
|
|
1458
|
+
},
|
|
1459
|
+
clockTolerance: 120
|
|
1460
|
+
});
|
|
1461
|
+
```
|
|
1462
|
+
|
|
1463
|
+
### Keycloak
|
|
1464
|
+
|
|
1465
|
+
```typescript
|
|
1466
|
+
const keycloakProvider = new OIDCProvider('keycloak', 'Keycloak', {
|
|
1467
|
+
clientId: process.env.KEYCLOAK_CLIENT_ID!,
|
|
1468
|
+
clientSecret: process.env.KEYCLOAK_CLIENT_SECRET!,
|
|
1469
|
+
issuer: `${process.env.KEYCLOAK_URL}/realms/${process.env.KEYCLOAK_REALM}`,
|
|
1470
|
+
scopes: ['openid', 'profile', 'email', 'roles'],
|
|
1471
|
+
responseType: 'code',
|
|
1472
|
+
usePKCE: true,
|
|
1473
|
+
redirectUri: 'https://app.example.com/api/auth/callback/keycloak',
|
|
1474
|
+
attributeMapping: {
|
|
1475
|
+
email: 'email',
|
|
1476
|
+
name: 'name',
|
|
1477
|
+
firstName: 'given_name',
|
|
1478
|
+
lastName: 'family_name',
|
|
1479
|
+
groups: 'groups',
|
|
1480
|
+
roles: 'realm_access.roles'
|
|
1481
|
+
},
|
|
1482
|
+
clockTolerance: 120
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
keycloakProvider.roleMapping = {
|
|
1486
|
+
'realm-admin': 'admin',
|
|
1487
|
+
'realm-user': 'user',
|
|
1488
|
+
'app-developer': 'developer'
|
|
1489
|
+
};
|
|
1490
|
+
```
|
|
1491
|
+
|
|
1492
|
+
### Generic SAML
|
|
1493
|
+
|
|
1494
|
+
```typescript
|
|
1495
|
+
const genericSamlProvider = new SAMLProvider('generic-saml', 'Corporate IdP', {
|
|
1496
|
+
entityId: 'https://app.example.com/saml/metadata',
|
|
1497
|
+
assertionConsumerServiceUrl: 'https://app.example.com/api/auth/callback/saml',
|
|
1498
|
+
singleLogoutServiceUrl: 'https://app.example.com/api/auth/logout/saml',
|
|
1499
|
+
certificate: process.env.SAML_SP_CERT!,
|
|
1500
|
+
privateKey: process.env.SAML_SP_KEY!,
|
|
1501
|
+
signAuthnRequests: true,
|
|
1502
|
+
wantAssertionsSigned: true,
|
|
1503
|
+
wantResponseSigned: true,
|
|
1504
|
+
signatureAlgorithm: 'sha256',
|
|
1505
|
+
identityProviderMetadataUrl: 'https://idp.example.com/saml/metadata',
|
|
1506
|
+
attributeMapping: {
|
|
1507
|
+
email: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress',
|
|
1508
|
+
name: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name',
|
|
1509
|
+
firstName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname',
|
|
1510
|
+
lastName: 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname',
|
|
1511
|
+
groups: 'http://schemas.xmlsoap.org/claims/Group'
|
|
1512
|
+
},
|
|
1513
|
+
allowedClockSkewSeconds: 120
|
|
1514
|
+
});
|
|
1515
|
+
```
|
|
1516
|
+
|
|
1517
|
+
---
|
|
1518
|
+
|
|
1519
|
+
## SCIM 2.0 User Sync
|
|
1520
|
+
|
|
1521
|
+
```typescript
|
|
1522
|
+
interface SCIMConfig {
|
|
1523
|
+
enabled: boolean;
|
|
1524
|
+
endpoint: string;
|
|
1525
|
+
bearerToken: string;
|
|
1526
|
+
syncInterval: number;
|
|
1527
|
+
provisionOnSync: boolean;
|
|
1528
|
+
deprovisionOnSync: boolean;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
interface SCIMUser {
|
|
1532
|
+
id: string;
|
|
1533
|
+
externalId: string;
|
|
1534
|
+
userName: string;
|
|
1535
|
+
name: {
|
|
1536
|
+
formatted: string;
|
|
1537
|
+
givenName: string;
|
|
1538
|
+
familyName: string;
|
|
1539
|
+
};
|
|
1540
|
+
emails: Array<{
|
|
1541
|
+
value: string;
|
|
1542
|
+
type: string;
|
|
1543
|
+
primary: boolean;
|
|
1544
|
+
}>;
|
|
1545
|
+
groups: Array<{
|
|
1546
|
+
value: string;
|
|
1547
|
+
display: string;
|
|
1548
|
+
}>;
|
|
1549
|
+
active: boolean;
|
|
1550
|
+
meta: {
|
|
1551
|
+
created: string;
|
|
1552
|
+
lastModified: string;
|
|
1553
|
+
};
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
class SCIMClient {
|
|
1557
|
+
private config: SCIMConfig;
|
|
1558
|
+
private headers: Record<string, string>;
|
|
1559
|
+
|
|
1560
|
+
constructor(config: SCIMConfig) {
|
|
1561
|
+
this.config = config;
|
|
1562
|
+
this.headers = {
|
|
1563
|
+
'Authorization': `Bearer ${config.bearerToken}`,
|
|
1564
|
+
'Content-Type': 'application/scim+json'
|
|
1565
|
+
};
|
|
1566
|
+
}
|
|
1567
|
+
|
|
1568
|
+
// List users
|
|
1569
|
+
async listUsers(filter?: string, startIndex: number = 1, count: number = 100): Promise<{
|
|
1570
|
+
totalResults: number;
|
|
1571
|
+
Resources: SCIMUser[];
|
|
1572
|
+
}> {
|
|
1573
|
+
const params = new URLSearchParams({
|
|
1574
|
+
startIndex: startIndex.toString(),
|
|
1575
|
+
count: count.toString()
|
|
1576
|
+
});
|
|
1577
|
+
|
|
1578
|
+
if (filter) {
|
|
1579
|
+
params.append('filter', filter);
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const response = await fetch(`${this.config.endpoint}/Users?${params}`, {
|
|
1583
|
+
headers: this.headers
|
|
1584
|
+
});
|
|
1585
|
+
|
|
1586
|
+
return response.json();
|
|
1587
|
+
}
|
|
1588
|
+
|
|
1589
|
+
// Get user by ID
|
|
1590
|
+
async getUser(id: string): Promise<SCIMUser> {
|
|
1591
|
+
const response = await fetch(`${this.config.endpoint}/Users/${id}`, {
|
|
1592
|
+
headers: this.headers
|
|
1593
|
+
});
|
|
1594
|
+
|
|
1595
|
+
return response.json();
|
|
1596
|
+
}
|
|
1597
|
+
|
|
1598
|
+
// Create user
|
|
1599
|
+
async createUser(user: Partial<SCIMUser>): Promise<SCIMUser> {
|
|
1600
|
+
const response = await fetch(`${this.config.endpoint}/Users`, {
|
|
1601
|
+
method: 'POST',
|
|
1602
|
+
headers: this.headers,
|
|
1603
|
+
body: JSON.stringify({
|
|
1604
|
+
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
|
|
1605
|
+
...user
|
|
1606
|
+
})
|
|
1607
|
+
});
|
|
1608
|
+
|
|
1609
|
+
return response.json();
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
// Update user
|
|
1613
|
+
async updateUser(id: string, user: Partial<SCIMUser>): Promise<SCIMUser> {
|
|
1614
|
+
const response = await fetch(`${this.config.endpoint}/Users/${id}`, {
|
|
1615
|
+
method: 'PUT',
|
|
1616
|
+
headers: this.headers,
|
|
1617
|
+
body: JSON.stringify({
|
|
1618
|
+
schemas: ['urn:ietf:params:scim:schemas:core:2.0:User'],
|
|
1619
|
+
...user
|
|
1620
|
+
})
|
|
1621
|
+
});
|
|
1622
|
+
|
|
1623
|
+
return response.json();
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// Delete user
|
|
1627
|
+
async deleteUser(id: string): Promise<void> {
|
|
1628
|
+
await fetch(`${this.config.endpoint}/Users/${id}`, {
|
|
1629
|
+
method: 'DELETE',
|
|
1630
|
+
headers: this.headers
|
|
1631
|
+
});
|
|
1632
|
+
}
|
|
1633
|
+
|
|
1634
|
+
// Sync users from IdP
|
|
1635
|
+
async syncUsers(): Promise<SyncResult> {
|
|
1636
|
+
const result: SyncResult = {
|
|
1637
|
+
created: 0,
|
|
1638
|
+
updated: 0,
|
|
1639
|
+
deactivated: 0,
|
|
1640
|
+
errors: []
|
|
1641
|
+
};
|
|
1642
|
+
|
|
1643
|
+
let startIndex = 1;
|
|
1644
|
+
const pageSize = 100;
|
|
1645
|
+
let hasMore = true;
|
|
1646
|
+
|
|
1647
|
+
while (hasMore) {
|
|
1648
|
+
const response = await this.listUsers(undefined, startIndex, pageSize);
|
|
1649
|
+
|
|
1650
|
+
for (const scimUser of response.Resources) {
|
|
1651
|
+
try {
|
|
1652
|
+
await this.syncUser(scimUser, result);
|
|
1653
|
+
} catch (error) {
|
|
1654
|
+
result.errors.push({
|
|
1655
|
+
userId: scimUser.id,
|
|
1656
|
+
error: error instanceof Error ? error.message : 'Unknown error'
|
|
1657
|
+
});
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
startIndex += pageSize;
|
|
1662
|
+
hasMore = startIndex < response.totalResults;
|
|
1663
|
+
}
|
|
1664
|
+
|
|
1665
|
+
AuditLogger.log({
|
|
1666
|
+
action: 'scim.sync.completed',
|
|
1667
|
+
details: result
|
|
1668
|
+
});
|
|
1669
|
+
|
|
1670
|
+
return result;
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
private async syncUser(scimUser: SCIMUser, result: SyncResult): Promise<void> {
|
|
1674
|
+
// Check if user exists in ELSABRO
|
|
1675
|
+
const existingUser = await this.findLocalUser(scimUser.userName);
|
|
1676
|
+
|
|
1677
|
+
if (existingUser) {
|
|
1678
|
+
if (scimUser.active) {
|
|
1679
|
+
await this.updateLocalUser(existingUser.id, scimUser);
|
|
1680
|
+
result.updated++;
|
|
1681
|
+
} else if (this.config.deprovisionOnSync) {
|
|
1682
|
+
await this.deactivateLocalUser(existingUser.id);
|
|
1683
|
+
result.deactivated++;
|
|
1684
|
+
}
|
|
1685
|
+
} else if (scimUser.active && this.config.provisionOnSync) {
|
|
1686
|
+
await this.createLocalUser(scimUser);
|
|
1687
|
+
result.created++;
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
private async findLocalUser(email: string): Promise<{ id: string } | null> {
|
|
1692
|
+
// Find user in local database
|
|
1693
|
+
return null;
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
private async createLocalUser(scimUser: SCIMUser): Promise<void> {
|
|
1697
|
+
// Create user in local database
|
|
1698
|
+
EventBus.publish('scim.user.created', {
|
|
1699
|
+
externalId: scimUser.externalId,
|
|
1700
|
+
email: scimUser.emails.find(e => e.primary)?.value
|
|
1701
|
+
});
|
|
1702
|
+
}
|
|
1703
|
+
|
|
1704
|
+
private async updateLocalUser(id: string, scimUser: SCIMUser): Promise<void> {
|
|
1705
|
+
// Update user in local database
|
|
1706
|
+
EventBus.publish('scim.user.updated', { id });
|
|
1707
|
+
}
|
|
1708
|
+
|
|
1709
|
+
private async deactivateLocalUser(id: string): Promise<void> {
|
|
1710
|
+
// Deactivate user in local database
|
|
1711
|
+
EventBus.publish('scim.user.deactivated', { id });
|
|
1712
|
+
}
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
interface SyncResult {
|
|
1716
|
+
created: number;
|
|
1717
|
+
updated: number;
|
|
1718
|
+
deactivated: number;
|
|
1719
|
+
errors: Array<{ userId: string; error: string }>;
|
|
1720
|
+
}
|
|
1721
|
+
```
|
|
1722
|
+
|
|
1723
|
+
---
|
|
1724
|
+
|
|
1725
|
+
## Token Service
|
|
1726
|
+
|
|
1727
|
+
```typescript
|
|
1728
|
+
interface TokenServiceConfig {
|
|
1729
|
+
algorithm: string;
|
|
1730
|
+
publicKey: string;
|
|
1731
|
+
privateKey: string;
|
|
1732
|
+
issuer: string;
|
|
1733
|
+
audience: string;
|
|
1734
|
+
accessTokenTTL: number;
|
|
1735
|
+
refreshTokenTTL: number;
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
class TokenService {
|
|
1739
|
+
private config: TokenServiceConfig;
|
|
1740
|
+
|
|
1741
|
+
constructor(config: TokenServiceConfig) {
|
|
1742
|
+
this.config = config;
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// Generate access token
|
|
1746
|
+
async generateAccessToken(payload: TokenPayload): Promise<string> {
|
|
1747
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1748
|
+
|
|
1749
|
+
const claims = {
|
|
1750
|
+
iss: this.config.issuer,
|
|
1751
|
+
aud: this.config.audience,
|
|
1752
|
+
sub: payload.userId,
|
|
1753
|
+
iat: now,
|
|
1754
|
+
exp: now + this.config.accessTokenTTL,
|
|
1755
|
+
jti: crypto.randomBytes(16).toString('hex'),
|
|
1756
|
+
...payload.claims
|
|
1757
|
+
};
|
|
1758
|
+
|
|
1759
|
+
return this.sign(claims);
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
// Generate refresh token
|
|
1763
|
+
async generateRefreshToken(payload: TokenPayload): Promise<string> {
|
|
1764
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1765
|
+
|
|
1766
|
+
const claims = {
|
|
1767
|
+
iss: this.config.issuer,
|
|
1768
|
+
aud: this.config.audience,
|
|
1769
|
+
sub: payload.userId,
|
|
1770
|
+
iat: now,
|
|
1771
|
+
exp: now + this.config.refreshTokenTTL,
|
|
1772
|
+
jti: crypto.randomBytes(16).toString('hex'),
|
|
1773
|
+
type: 'refresh'
|
|
1774
|
+
};
|
|
1775
|
+
|
|
1776
|
+
return this.sign(claims);
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// Verify token
|
|
1780
|
+
async verifyToken(token: string): Promise<TokenClaims> {
|
|
1781
|
+
const [headerB64, payloadB64, signature] = token.split('.');
|
|
1782
|
+
const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString());
|
|
1783
|
+
const claims = JSON.parse(Buffer.from(payloadB64, 'base64url').toString());
|
|
1784
|
+
|
|
1785
|
+
// Verify signature
|
|
1786
|
+
const valid = await this.verify(token);
|
|
1787
|
+
if (!valid) {
|
|
1788
|
+
throw new Error('Invalid token signature');
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// Verify claims
|
|
1792
|
+
const now = Math.floor(Date.now() / 1000);
|
|
1793
|
+
|
|
1794
|
+
if (claims.exp < now) {
|
|
1795
|
+
throw new Error('Token expired');
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
if (claims.iss !== this.config.issuer) {
|
|
1799
|
+
throw new Error('Invalid issuer');
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
if (claims.aud !== this.config.audience) {
|
|
1803
|
+
throw new Error('Invalid audience');
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
return claims;
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1809
|
+
// Encrypt sensitive data
|
|
1810
|
+
async encrypt(data: string): Promise<string> {
|
|
1811
|
+
const iv = crypto.randomBytes(16);
|
|
1812
|
+
const key = crypto.scryptSync(this.config.privateKey, 'salt', 32);
|
|
1813
|
+
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
|
1814
|
+
|
|
1815
|
+
let encrypted = cipher.update(data, 'utf8', 'base64');
|
|
1816
|
+
encrypted += cipher.final('base64');
|
|
1817
|
+
|
|
1818
|
+
const authTag = cipher.getAuthTag();
|
|
1819
|
+
|
|
1820
|
+
return JSON.stringify({
|
|
1821
|
+
iv: iv.toString('base64'),
|
|
1822
|
+
data: encrypted,
|
|
1823
|
+
tag: authTag.toString('base64')
|
|
1824
|
+
});
|
|
1825
|
+
}
|
|
1826
|
+
|
|
1827
|
+
// Decrypt sensitive data
|
|
1828
|
+
async decrypt(encryptedData: string): Promise<string> {
|
|
1829
|
+
const { iv, data, tag } = JSON.parse(encryptedData);
|
|
1830
|
+
const key = crypto.scryptSync(this.config.privateKey, 'salt', 32);
|
|
1831
|
+
|
|
1832
|
+
const decipher = crypto.createDecipheriv(
|
|
1833
|
+
'aes-256-gcm',
|
|
1834
|
+
key,
|
|
1835
|
+
Buffer.from(iv, 'base64')
|
|
1836
|
+
);
|
|
1837
|
+
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
|
1838
|
+
|
|
1839
|
+
let decrypted = decipher.update(data, 'base64', 'utf8');
|
|
1840
|
+
decrypted += decipher.final('utf8');
|
|
1841
|
+
|
|
1842
|
+
return decrypted;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
private async sign(claims: Record<string, unknown>): Promise<string> {
|
|
1846
|
+
// Sign JWT using jose library
|
|
1847
|
+
return '';
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
private async verify(token: string): Promise<boolean> {
|
|
1851
|
+
// Verify JWT signature
|
|
1852
|
+
return true;
|
|
1853
|
+
}
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
interface TokenPayload {
|
|
1857
|
+
userId: string;
|
|
1858
|
+
claims?: Record<string, unknown>;
|
|
1859
|
+
}
|
|
1860
|
+
|
|
1861
|
+
interface TokenClaims {
|
|
1862
|
+
iss: string;
|
|
1863
|
+
aud: string;
|
|
1864
|
+
sub: string;
|
|
1865
|
+
iat: number;
|
|
1866
|
+
exp: number;
|
|
1867
|
+
jti: string;
|
|
1868
|
+
[key: string]: unknown;
|
|
1869
|
+
}
|
|
1870
|
+
```
|
|
1871
|
+
|
|
1872
|
+
---
|
|
1873
|
+
|
|
1874
|
+
## Comandos CLI
|
|
1875
|
+
|
|
1876
|
+
```bash
|
|
1877
|
+
# Configurar SSO
|
|
1878
|
+
/elsabro:sso configure # Configuracion interactiva
|
|
1879
|
+
/elsabro:sso configure --provider okta # Configurar provider especifico
|
|
1880
|
+
/elsabro:sso configure --import metadata.xml # Importar metadata SAML
|
|
1881
|
+
|
|
1882
|
+
# Estado y testing
|
|
1883
|
+
/elsabro:sso status # Ver estado de todos los providers
|
|
1884
|
+
/elsabro:sso status --provider azure-ad # Estado de provider especifico
|
|
1885
|
+
/elsabro:sso test # Test de conectividad
|
|
1886
|
+
/elsabro:sso test --provider google # Test provider especifico
|
|
1887
|
+
/elsabro:sso test --flow # Test completo de auth flow
|
|
1888
|
+
|
|
1889
|
+
# Gestion de sesiones
|
|
1890
|
+
/elsabro:sso sessions # Listar sesiones activas
|
|
1891
|
+
/elsabro:sso sessions --user user@example.com # Sesiones de usuario
|
|
1892
|
+
/elsabro:sso sessions terminate --id session_id # Terminar sesion
|
|
1893
|
+
/elsabro:sso sessions terminate --user user@example.com # Terminar todas
|
|
1894
|
+
|
|
1895
|
+
# Sincronizacion SCIM
|
|
1896
|
+
/elsabro:sso scim sync # Sincronizar usuarios
|
|
1897
|
+
/elsabro:sso scim status # Estado de sincronizacion
|
|
1898
|
+
/elsabro:sso scim users # Listar usuarios sincronizados
|
|
1899
|
+
|
|
1900
|
+
# Metadata y certificados
|
|
1901
|
+
/elsabro:sso metadata # Generar SP metadata
|
|
1902
|
+
/elsabro:sso metadata --format xml # Formato XML
|
|
1903
|
+
/elsabro:sso certs rotate # Rotar certificados
|
|
1904
|
+
/elsabro:sso certs status # Estado de certificados
|
|
1905
|
+
|
|
1906
|
+
# Audit
|
|
1907
|
+
/elsabro:sso audit # Ver eventos de auth
|
|
1908
|
+
/elsabro:sso audit --from 2024-01-01 # Eventos desde fecha
|
|
1909
|
+
/elsabro:sso audit --provider okta # Eventos por provider
|
|
1910
|
+
/elsabro:sso audit --failures # Solo fallos de auth
|
|
1911
|
+
```
|
|
1912
|
+
|
|
1913
|
+
---
|
|
1914
|
+
|
|
1915
|
+
## Security Features
|
|
1916
|
+
|
|
1917
|
+
### IP Allowlisting
|
|
1918
|
+
|
|
1919
|
+
```typescript
|
|
1920
|
+
// Configurar allowlist por provider
|
|
1921
|
+
ssoManager.config.ipAllowlist = [
|
|
1922
|
+
'10.0.0.0/8', // Private network
|
|
1923
|
+
'192.168.0.0/16', // Private network
|
|
1924
|
+
'203.0.113.50', // Specific IP
|
|
1925
|
+
];
|
|
1926
|
+
```
|
|
1927
|
+
|
|
1928
|
+
### Session Policies
|
|
1929
|
+
|
|
1930
|
+
```typescript
|
|
1931
|
+
interface SessionPolicy {
|
|
1932
|
+
maxConcurrentSessions: number;
|
|
1933
|
+
absoluteTimeout: number; // Max session lifetime
|
|
1934
|
+
inactivityTimeout: number; // Idle timeout
|
|
1935
|
+
requireReauthFor: string[]; // Actions requiring reauth
|
|
1936
|
+
bindToIP: boolean; // Bind session to IP
|
|
1937
|
+
bindToUserAgent: boolean; // Bind session to UA
|
|
1938
|
+
}
|
|
1939
|
+
```
|
|
1940
|
+
|
|
1941
|
+
### MFA Enforcement
|
|
1942
|
+
|
|
1943
|
+
```typescript
|
|
1944
|
+
interface MFAPolicy {
|
|
1945
|
+
required: boolean;
|
|
1946
|
+
allowedMethods: ('totp' | 'sms' | 'email' | 'push' | 'webauthn')[];
|
|
1947
|
+
rememberDevice: boolean;
|
|
1948
|
+
rememberDeviceDays: number;
|
|
1949
|
+
requireForSensitiveActions: boolean;
|
|
1950
|
+
sensitiveActions: string[];
|
|
1951
|
+
}
|
|
1952
|
+
```
|
|
1953
|
+
|
|
1954
|
+
### Audit Events
|
|
1955
|
+
|
|
1956
|
+
| Event | Description |
|
|
1957
|
+
|-------|-------------|
|
|
1958
|
+
| `sso.auth.initiated` | Auth flow started |
|
|
1959
|
+
| `sso.auth.completed` | Auth successful |
|
|
1960
|
+
| `sso.auth.failed` | Auth failed |
|
|
1961
|
+
| `sso.auth.ip_blocked` | IP not in allowlist |
|
|
1962
|
+
| `sso.auth.domain_blocked` | Email domain not allowed |
|
|
1963
|
+
| `sso.session.created` | New session created |
|
|
1964
|
+
| `sso.session.refreshed` | Tokens refreshed |
|
|
1965
|
+
| `sso.session.terminated` | Session ended |
|
|
1966
|
+
| `sso.logout` | User logged out |
|
|
1967
|
+
| `sso.mfa.required` | MFA challenge sent |
|
|
1968
|
+
| `sso.mfa.verified` | MFA verified |
|
|
1969
|
+
| `sso.provider.registered` | Provider added |
|
|
1970
|
+
| `sso.scim.sync.completed` | User sync finished |
|
|
1971
|
+
|
|
1972
|
+
---
|
|
1973
|
+
|
|
1974
|
+
## Configuracion
|
|
1975
|
+
|
|
1976
|
+
```json
|
|
1977
|
+
{
|
|
1978
|
+
"sso": {
|
|
1979
|
+
"enabled": true,
|
|
1980
|
+
"defaultProvider": "okta",
|
|
1981
|
+
"sessionTimeout": 28800,
|
|
1982
|
+
"singleLogout": true,
|
|
1983
|
+
"mfaRequired": false,
|
|
1984
|
+
"allowedDomains": ["example.com", "corp.example.com"],
|
|
1985
|
+
"ipAllowlist": ["10.0.0.0/8"]
|
|
1986
|
+
}
|
|
1987
|
+
}
|
|
1988
|
+
```
|
|
1989
|
+
|
|
1990
|
+
---
|
|
1991
|
+
|
|
1992
|
+
## Changelog
|
|
1993
|
+
|
|
1994
|
+
- **v3.6.0**: Enterprise SSO System
|
|
1995
|
+
- SSOManager con multi-provider support
|
|
1996
|
+
- SAMLProvider con SAML 2.0 completo
|
|
1997
|
+
- OIDCProvider con PKCE support
|
|
1998
|
+
- Configuraciones para Okta, Azure AD, Google, Auth0, Keycloak
|
|
1999
|
+
- SCIM 2.0 para user sync
|
|
2000
|
+
- Token encryption y signing
|
|
2001
|
+
- CLI commands completos
|