@revealui/auth 0.5.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/react/useSignIn.d.ts +1 -1
- package/dist/react/useSignIn.d.ts.map +1 -1
- package/dist/react/useSignIn.js +7 -3
- package/dist/server/audit-storage.d.ts +5 -2
- package/dist/server/audit-storage.d.ts.map +1 -1
- package/dist/server/audit-storage.js +5 -2
- package/dist/server/auth.d.ts.map +1 -1
- package/dist/server/auth.js +68 -10
- package/dist/server/index.d.ts +2 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +3 -0
- package/dist/server/platform-roles.d.ts +42 -0
- package/dist/server/platform-roles.d.ts.map +1 -0
- package/dist/server/platform-roles.js +65 -0
- package/dist/server/session.js +1 -1
- package/dist/server/sso/__tests__/helpers/mock-oidc-idp.d.ts +31 -0
- package/dist/server/sso/__tests__/helpers/mock-oidc-idp.d.ts.map +1 -0
- package/dist/server/sso/__tests__/helpers/mock-oidc-idp.js +115 -0
- package/dist/server/sso/__tests__/helpers/mock-saml-idp.d.ts +28 -0
- package/dist/server/sso/__tests__/helpers/mock-saml-idp.d.ts.map +1 -0
- package/dist/server/sso/__tests__/helpers/mock-saml-idp.js +150 -0
- package/dist/server/sso/index.d.ts +13 -0
- package/dist/server/sso/index.d.ts.map +1 -0
- package/dist/server/sso/index.js +12 -0
- package/dist/server/sso/jit.d.ts +39 -0
- package/dist/server/sso/jit.d.ts.map +1 -0
- package/dist/server/sso/jit.js +141 -0
- package/dist/server/sso/oidc.d.ts +137 -0
- package/dist/server/sso/oidc.d.ts.map +1 -0
- package/dist/server/sso/oidc.js +345 -0
- package/dist/server/sso/roles.d.ts +48 -0
- package/dist/server/sso/roles.d.ts.map +1 -0
- package/dist/server/sso/roles.js +109 -0
- package/dist/server/sso/saml.d.ts +99 -0
- package/dist/server/sso/saml.d.ts.map +1 -0
- package/dist/server/sso/saml.js +392 -0
- package/dist/server/sso/state.d.ts +46 -0
- package/dist/server/sso/state.d.ts.map +1 -0
- package/dist/server/sso/state.js +101 -0
- package/package.json +9 -5
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OIDC discovery + id_token validation (GAP-464 Phase 2).
|
|
3
|
+
*
|
|
4
|
+
* Security hardlines:
|
|
5
|
+
* - Never accept id_token without cryptographic signature validation (JWKS / key).
|
|
6
|
+
* - Validate issuer, audience (client_id), and exp on every token.
|
|
7
|
+
*/
|
|
8
|
+
import { createRemoteJWKSet, jwtVerify, } from 'jose';
|
|
9
|
+
/** Asymmetric algorithms used by enterprise IdPs; `none` is never allowed. */
|
|
10
|
+
const ID_TOKEN_ALGORITHMS = [
|
|
11
|
+
'RS256',
|
|
12
|
+
'RS384',
|
|
13
|
+
'RS512',
|
|
14
|
+
'ES256',
|
|
15
|
+
'ES384',
|
|
16
|
+
'ES512',
|
|
17
|
+
'PS256',
|
|
18
|
+
'PS384',
|
|
19
|
+
'PS512',
|
|
20
|
+
'EdDSA',
|
|
21
|
+
];
|
|
22
|
+
const DISCOVERY_REQUIRED = [
|
|
23
|
+
'issuer',
|
|
24
|
+
'authorization_endpoint',
|
|
25
|
+
'token_endpoint',
|
|
26
|
+
'jwks_uri',
|
|
27
|
+
];
|
|
28
|
+
/** Strip trailing `/` without regex (CodeQL: avoid poly ReDoS on uncontrolled issuer). */
|
|
29
|
+
function normalizeIssuer(issuer) {
|
|
30
|
+
let end = issuer.length;
|
|
31
|
+
while (end > 0 && issuer.charCodeAt(end - 1) === 47 /* '/' */) {
|
|
32
|
+
end -= 1;
|
|
33
|
+
}
|
|
34
|
+
return end === issuer.length ? issuer : issuer.slice(0, end);
|
|
35
|
+
}
|
|
36
|
+
function isNonEmptyString(value) {
|
|
37
|
+
return typeof value === 'string' && value.length > 0;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Fetch and parse an OIDC discovery document (openid-configuration).
|
|
41
|
+
*/
|
|
42
|
+
export async function fetchOidcDiscovery(discoveryUrl, options = {}) {
|
|
43
|
+
if (!isNonEmptyString(discoveryUrl)) {
|
|
44
|
+
return {
|
|
45
|
+
ok: false,
|
|
46
|
+
reason: 'missing_required_fields',
|
|
47
|
+
message: 'discoveryUrl is required',
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
51
|
+
const timeoutMs = options.timeoutMs ?? 10_000;
|
|
52
|
+
const controller = new AbortController();
|
|
53
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
54
|
+
let response;
|
|
55
|
+
try {
|
|
56
|
+
response = await fetchImpl(discoveryUrl, {
|
|
57
|
+
method: 'GET',
|
|
58
|
+
headers: { Accept: 'application/json' },
|
|
59
|
+
signal: controller.signal,
|
|
60
|
+
redirect: 'follow',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
catch (err) {
|
|
64
|
+
clearTimeout(timer);
|
|
65
|
+
const message = err instanceof Error ? err.message : 'discovery fetch failed';
|
|
66
|
+
return { ok: false, reason: 'fetch_failed', message };
|
|
67
|
+
}
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
if (!response.ok) {
|
|
70
|
+
return {
|
|
71
|
+
ok: false,
|
|
72
|
+
reason: 'fetch_failed',
|
|
73
|
+
message: `discovery HTTP ${response.status}`,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
let body;
|
|
77
|
+
try {
|
|
78
|
+
body = await response.json();
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return { ok: false, reason: 'invalid_json', message: 'discovery response is not JSON' };
|
|
82
|
+
}
|
|
83
|
+
if (!body || typeof body !== 'object') {
|
|
84
|
+
return { ok: false, reason: 'invalid_json', message: 'discovery response is not an object' };
|
|
85
|
+
}
|
|
86
|
+
const record = body;
|
|
87
|
+
for (const key of DISCOVERY_REQUIRED) {
|
|
88
|
+
if (!isNonEmptyString(record[key])) {
|
|
89
|
+
return {
|
|
90
|
+
ok: false,
|
|
91
|
+
reason: 'missing_required_fields',
|
|
92
|
+
message: `discovery document missing ${key}`,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
const document = {
|
|
97
|
+
issuer: record.issuer,
|
|
98
|
+
authorization_endpoint: record.authorization_endpoint,
|
|
99
|
+
token_endpoint: record.token_endpoint,
|
|
100
|
+
jwks_uri: record.jwks_uri,
|
|
101
|
+
};
|
|
102
|
+
if (isNonEmptyString(record.userinfo_endpoint)) {
|
|
103
|
+
document.userinfo_endpoint = record.userinfo_endpoint;
|
|
104
|
+
}
|
|
105
|
+
if (isNonEmptyString(record.end_session_endpoint)) {
|
|
106
|
+
document.end_session_endpoint = record.end_session_endpoint;
|
|
107
|
+
}
|
|
108
|
+
if (Array.isArray(record.scopes_supported)) {
|
|
109
|
+
document.scopes_supported = record.scopes_supported.filter((s) => typeof s === 'string');
|
|
110
|
+
}
|
|
111
|
+
if (Array.isArray(record.response_types_supported)) {
|
|
112
|
+
document.response_types_supported = record.response_types_supported.filter((s) => typeof s === 'string');
|
|
113
|
+
}
|
|
114
|
+
if (Array.isArray(record.code_challenge_methods_supported)) {
|
|
115
|
+
document.code_challenge_methods_supported = record.code_challenge_methods_supported.filter((s) => typeof s === 'string');
|
|
116
|
+
}
|
|
117
|
+
if (options.expectedIssuer) {
|
|
118
|
+
if (normalizeIssuer(document.issuer) !== normalizeIssuer(options.expectedIssuer)) {
|
|
119
|
+
return {
|
|
120
|
+
ok: false,
|
|
121
|
+
reason: 'issuer_mismatch',
|
|
122
|
+
message: `discovery issuer "${document.issuer}" does not match expected "${options.expectedIssuer}"`,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { ok: true, document };
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Build the OIDC authorization redirect URL (code + PKCE S256).
|
|
130
|
+
*/
|
|
131
|
+
export function buildOidcAuthorizationUrl(input) {
|
|
132
|
+
const url = new URL(input.authorizationEndpoint);
|
|
133
|
+
url.searchParams.set('response_type', 'code');
|
|
134
|
+
url.searchParams.set('client_id', input.clientId);
|
|
135
|
+
url.searchParams.set('redirect_uri', input.redirectUri);
|
|
136
|
+
url.searchParams.set('scope', input.scope ?? 'openid email profile');
|
|
137
|
+
url.searchParams.set('state', input.state);
|
|
138
|
+
url.searchParams.set('code_challenge', input.codeChallenge);
|
|
139
|
+
url.searchParams.set('code_challenge_method', 'S256');
|
|
140
|
+
if (input.nonce) {
|
|
141
|
+
url.searchParams.set('nonce', input.nonce);
|
|
142
|
+
}
|
|
143
|
+
return url.toString();
|
|
144
|
+
}
|
|
145
|
+
function mapJoseError(err) {
|
|
146
|
+
const message = err instanceof Error ? err.message : 'id_token validation failed';
|
|
147
|
+
const code = err &&
|
|
148
|
+
typeof err === 'object' &&
|
|
149
|
+
'code' in err &&
|
|
150
|
+
typeof err.code === 'string'
|
|
151
|
+
? err.code
|
|
152
|
+
: '';
|
|
153
|
+
const claim = err &&
|
|
154
|
+
typeof err === 'object' &&
|
|
155
|
+
'claim' in err &&
|
|
156
|
+
typeof err.claim === 'string'
|
|
157
|
+
? err.claim
|
|
158
|
+
: '';
|
|
159
|
+
// Prefer jose error codes / claim names over message regex (avoids "exp" matching "expected")
|
|
160
|
+
if (code === 'ERR_JWT_EXPIRED' || claim === 'exp') {
|
|
161
|
+
return { reason: 'expired', message };
|
|
162
|
+
}
|
|
163
|
+
if (code === 'ERR_JWT_CLAIM_VALIDATION_FAILED' || claim) {
|
|
164
|
+
if (claim === 'iss' || /"iss"/i.test(message)) {
|
|
165
|
+
return { reason: 'invalid_issuer', message };
|
|
166
|
+
}
|
|
167
|
+
if (claim === 'aud' || /"aud"/i.test(message)) {
|
|
168
|
+
return { reason: 'invalid_audience', message };
|
|
169
|
+
}
|
|
170
|
+
if (claim === 'nbf' || /"nbf"/i.test(message)) {
|
|
171
|
+
return { reason: 'not_yet_valid', message };
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (code === 'ERR_JWS_SIGNATURE_VERIFICATION_FAILED' ||
|
|
175
|
+
code === 'ERR_JWS_INVALID' ||
|
|
176
|
+
/signature verification failed|jws signature/i.test(message)) {
|
|
177
|
+
return { reason: 'invalid_signature', message };
|
|
178
|
+
}
|
|
179
|
+
if (code === 'ERR_JWT_INVALID' || /compact jws|invalid token/i.test(message)) {
|
|
180
|
+
return { reason: 'malformed', message };
|
|
181
|
+
}
|
|
182
|
+
return { reason: 'invalid_signature', message };
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Validate an OIDC id_token: signature (JWKS), issuer, audience, exp.
|
|
186
|
+
*
|
|
187
|
+
* Hardline: `jwks` is required. Callers must not pass a no-op key or skip verify.
|
|
188
|
+
*/
|
|
189
|
+
export async function validateOidcIdToken(options) {
|
|
190
|
+
const { idToken, issuer, clientId, jwks, clockToleranceSeconds = 30 } = options;
|
|
191
|
+
if (!isNonEmptyString(idToken)) {
|
|
192
|
+
return { ok: false, reason: 'missing_token', message: 'id_token is required' };
|
|
193
|
+
}
|
|
194
|
+
if (jwks == null) {
|
|
195
|
+
return {
|
|
196
|
+
ok: false,
|
|
197
|
+
reason: 'missing_key',
|
|
198
|
+
message: 'JWKS / verification key is required; unsigned id_tokens are rejected',
|
|
199
|
+
};
|
|
200
|
+
}
|
|
201
|
+
if (!isNonEmptyString(issuer)) {
|
|
202
|
+
return { ok: false, reason: 'invalid_issuer', message: 'expected issuer is required' };
|
|
203
|
+
}
|
|
204
|
+
if (!isNonEmptyString(clientId)) {
|
|
205
|
+
return { ok: false, reason: 'invalid_audience', message: 'clientId (audience) is required' };
|
|
206
|
+
}
|
|
207
|
+
// Prefer asymmetric algorithms used by enterprise IdPs; reject `none`
|
|
208
|
+
const verifyOptions = {
|
|
209
|
+
issuer: normalizeIssuer(issuer),
|
|
210
|
+
audience: clientId,
|
|
211
|
+
clockTolerance: clockToleranceSeconds,
|
|
212
|
+
algorithms: ID_TOKEN_ALGORITHMS,
|
|
213
|
+
};
|
|
214
|
+
let payload;
|
|
215
|
+
try {
|
|
216
|
+
// jose types KeyLike and JWTVerifyGetKey as separate overloads — narrow first
|
|
217
|
+
let verified;
|
|
218
|
+
if (typeof jwks === 'function') {
|
|
219
|
+
verified = await jwtVerify(idToken, jwks, verifyOptions);
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
verified = await jwtVerify(idToken, jwks, verifyOptions);
|
|
223
|
+
}
|
|
224
|
+
payload = verified.payload;
|
|
225
|
+
}
|
|
226
|
+
catch (err) {
|
|
227
|
+
return { ok: false, ...mapJoseError(err) };
|
|
228
|
+
}
|
|
229
|
+
if (!isNonEmptyString(payload.sub)) {
|
|
230
|
+
return { ok: false, reason: 'missing_sub', message: 'id_token missing sub claim' };
|
|
231
|
+
}
|
|
232
|
+
const claims = {
|
|
233
|
+
sub: payload.sub,
|
|
234
|
+
payload,
|
|
235
|
+
};
|
|
236
|
+
if (typeof payload.email === 'string' && payload.email.length > 0) {
|
|
237
|
+
claims.email = payload.email;
|
|
238
|
+
}
|
|
239
|
+
if (typeof payload.email_verified === 'boolean') {
|
|
240
|
+
claims.emailVerified = payload.email_verified;
|
|
241
|
+
}
|
|
242
|
+
else if (payload.email_verified === 'true') {
|
|
243
|
+
claims.emailVerified = true;
|
|
244
|
+
}
|
|
245
|
+
else if (payload.email_verified === 'false') {
|
|
246
|
+
claims.emailVerified = false;
|
|
247
|
+
}
|
|
248
|
+
if (typeof payload.name === 'string' && payload.name.length > 0) {
|
|
249
|
+
claims.name = payload.name;
|
|
250
|
+
}
|
|
251
|
+
if (typeof payload.preferred_username === 'string' && payload.preferred_username.length > 0) {
|
|
252
|
+
claims.preferredUsername = payload.preferred_username;
|
|
253
|
+
}
|
|
254
|
+
return { ok: true, claims };
|
|
255
|
+
}
|
|
256
|
+
/**
|
|
257
|
+
* Create a remote JWKS key resolver from a discovery `jwks_uri`.
|
|
258
|
+
* Thin wrapper so route code does not import jose directly.
|
|
259
|
+
*/
|
|
260
|
+
export function createOidcRemoteJwkSet(jwksUri) {
|
|
261
|
+
if (!isNonEmptyString(jwksUri)) {
|
|
262
|
+
throw new Error('jwksUri is required');
|
|
263
|
+
}
|
|
264
|
+
return createRemoteJWKSet(new URL(jwksUri));
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Exchange an OIDC authorization code for tokens (PKCE + client_secret).
|
|
268
|
+
*
|
|
269
|
+
* POSTs `application/x-www-form-urlencoded`. Rejects responses without `id_token`.
|
|
270
|
+
* Never logs client_secret or tokens.
|
|
271
|
+
*/
|
|
272
|
+
export async function exchangeOidcCode(input) {
|
|
273
|
+
const { tokenEndpoint, clientId, clientSecret, code, redirectUri, codeVerifier, fetchImpl = fetch, timeoutMs = 10_000, } = input;
|
|
274
|
+
if (!(isNonEmptyString(tokenEndpoint) &&
|
|
275
|
+
isNonEmptyString(clientId) &&
|
|
276
|
+
isNonEmptyString(clientSecret) &&
|
|
277
|
+
isNonEmptyString(code) &&
|
|
278
|
+
isNonEmptyString(redirectUri) &&
|
|
279
|
+
isNonEmptyString(codeVerifier))) {
|
|
280
|
+
return {
|
|
281
|
+
ok: false,
|
|
282
|
+
reason: 'missing_params',
|
|
283
|
+
message: 'tokenEndpoint, clientId, clientSecret, code, redirectUri, and codeVerifier are required',
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
const body = new URLSearchParams({
|
|
287
|
+
grant_type: 'authorization_code',
|
|
288
|
+
code,
|
|
289
|
+
redirect_uri: redirectUri,
|
|
290
|
+
client_id: clientId,
|
|
291
|
+
client_secret: clientSecret,
|
|
292
|
+
code_verifier: codeVerifier,
|
|
293
|
+
});
|
|
294
|
+
const controller = new AbortController();
|
|
295
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
296
|
+
let response;
|
|
297
|
+
try {
|
|
298
|
+
response = await fetchImpl(tokenEndpoint, {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
headers: {
|
|
301
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
302
|
+
Accept: 'application/json',
|
|
303
|
+
},
|
|
304
|
+
body,
|
|
305
|
+
signal: controller.signal,
|
|
306
|
+
redirect: 'error',
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
catch (err) {
|
|
310
|
+
clearTimeout(timer);
|
|
311
|
+
const message = err instanceof Error ? err.message : 'token exchange fetch failed';
|
|
312
|
+
return { ok: false, reason: 'fetch_failed', message };
|
|
313
|
+
}
|
|
314
|
+
clearTimeout(timer);
|
|
315
|
+
if (!response.ok) {
|
|
316
|
+
return {
|
|
317
|
+
ok: false,
|
|
318
|
+
reason: 'fetch_failed',
|
|
319
|
+
message: `token endpoint HTTP ${response.status}`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
let parsed;
|
|
323
|
+
try {
|
|
324
|
+
parsed = await response.json();
|
|
325
|
+
}
|
|
326
|
+
catch {
|
|
327
|
+
return { ok: false, reason: 'invalid_json', message: 'token response is not JSON' };
|
|
328
|
+
}
|
|
329
|
+
if (!parsed || typeof parsed !== 'object') {
|
|
330
|
+
return { ok: false, reason: 'invalid_json', message: 'token response is not an object' };
|
|
331
|
+
}
|
|
332
|
+
const record = parsed;
|
|
333
|
+
if (!isNonEmptyString(record.id_token)) {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
reason: 'missing_id_token',
|
|
337
|
+
message: 'token response missing id_token',
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
const tokens = { id_token: record.id_token };
|
|
341
|
+
if (isNonEmptyString(record.access_token)) {
|
|
342
|
+
tokens.access_token = record.access_token;
|
|
343
|
+
}
|
|
344
|
+
return { ok: true, tokens };
|
|
345
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSO group → role mapping (GAP-464).
|
|
3
|
+
*
|
|
4
|
+
* Security hardlines:
|
|
5
|
+
* - Map only via explicit group_role_map hits.
|
|
6
|
+
* - Empty mapped set + require_group_match → reject.
|
|
7
|
+
* - Never grant `admin` from an unmapped group (unmapped groups are ignored).
|
|
8
|
+
* - When no map hits and require_group_match is false → default_role.
|
|
9
|
+
*/
|
|
10
|
+
export interface MapSsoGroupsInput {
|
|
11
|
+
/** Raw IdP claims (id_token payload or SAML attribute bag) */
|
|
12
|
+
claims: Record<string, unknown>;
|
|
13
|
+
/** Claim key that holds group membership (default on providers: `groups`) */
|
|
14
|
+
groupClaim: string;
|
|
15
|
+
/** IdP group name → RevealUI role */
|
|
16
|
+
groupRoleMap: Record<string, string>;
|
|
17
|
+
/** Used when no groups map and requireGroupMatch is false */
|
|
18
|
+
defaultRole: string;
|
|
19
|
+
/** When true, login fails unless at least one group maps to a role */
|
|
20
|
+
requireGroupMatch: boolean;
|
|
21
|
+
}
|
|
22
|
+
export type MapSsoGroupsFailureReason = 'require_group_match' | 'invalid_default_role';
|
|
23
|
+
export type MapSsoGroupsResult = {
|
|
24
|
+
ok: true;
|
|
25
|
+
role: string;
|
|
26
|
+
/** Groups present on the token that hit group_role_map */
|
|
27
|
+
matchedGroups: string[];
|
|
28
|
+
/** All groups extracted from the claim (mapped + unmapped) */
|
|
29
|
+
groups: string[];
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
reason: MapSsoGroupsFailureReason;
|
|
33
|
+
message: string;
|
|
34
|
+
groups: string[];
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* Extract a string array of groups from a claim value.
|
|
38
|
+
* Accepts string[], a single string, or a space/comma-separated string.
|
|
39
|
+
*/
|
|
40
|
+
export declare function extractGroupsFromClaim(claims: Record<string, unknown>, groupClaim: string): string[];
|
|
41
|
+
/**
|
|
42
|
+
* Resolve a single RevealUI role from IdP groups + provider mapping config.
|
|
43
|
+
*
|
|
44
|
+
* Unmapped groups never contribute a role (including never implying admin).
|
|
45
|
+
* Only explicit map values and the configured default_role assign roles.
|
|
46
|
+
*/
|
|
47
|
+
export declare function mapSsoGroupsToRole(input: MapSsoGroupsInput): MapSsoGroupsResult;
|
|
48
|
+
//# sourceMappingURL=roles.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"roles.d.ts","sourceRoot":"","sources":["../../../src/server/sso/roles.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAEH,MAAM,WAAW,iBAAiB;IAChC,8DAA8D;IAC9D,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAChC,6EAA6E;IAC7E,UAAU,EAAE,MAAM,CAAC;IACnB,qCAAqC;IACrC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACrC,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC;IACpB,sEAAsE;IACtE,iBAAiB,EAAE,OAAO,CAAC;CAC5B;AAED,MAAM,MAAM,yBAAyB,GAAG,qBAAqB,GAAG,sBAAsB,CAAC;AAEvF,MAAM,MAAM,kBAAkB,GAC1B;IACE,EAAE,EAAE,IAAI,CAAC;IACT,IAAI,EAAE,MAAM,CAAC;IACb,0DAA0D;IAC1D,aAAa,EAAE,MAAM,EAAE,CAAC;IACxB,8DAA8D;IAC9D,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB,GACD;IACE,EAAE,EAAE,KAAK,CAAC;IACV,MAAM,EAAE,yBAAyB,CAAC;IAClC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB,CAAC;AAWN;;;GAGG;AACH,wBAAgB,sBAAsB,CACpC,MAAM,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAC/B,UAAU,EAAE,MAAM,GACjB,MAAM,EAAE,CAyBV;AAgBD;;;;;GAKG;AACH,wBAAgB,kBAAkB,CAAC,KAAK,EAAE,iBAAiB,GAAG,kBAAkB,CAiD/E"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SSO group → role mapping (GAP-464).
|
|
3
|
+
*
|
|
4
|
+
* Security hardlines:
|
|
5
|
+
* - Map only via explicit group_role_map hits.
|
|
6
|
+
* - Empty mapped set + require_group_match → reject.
|
|
7
|
+
* - Never grant `admin` from an unmapped group (unmapped groups are ignored).
|
|
8
|
+
* - When no map hits and require_group_match is false → default_role.
|
|
9
|
+
*/
|
|
10
|
+
/** Privilege rank for resolving multiple mapped roles (higher wins). */
|
|
11
|
+
const ROLE_RANK = {
|
|
12
|
+
viewer: 1,
|
|
13
|
+
member: 2,
|
|
14
|
+
editor: 3,
|
|
15
|
+
admin: 4,
|
|
16
|
+
owner: 5,
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* Extract a string array of groups from a claim value.
|
|
20
|
+
* Accepts string[], a single string, or a space/comma-separated string.
|
|
21
|
+
*/
|
|
22
|
+
export function extractGroupsFromClaim(claims, groupClaim) {
|
|
23
|
+
const raw = claims[groupClaim];
|
|
24
|
+
if (raw == null)
|
|
25
|
+
return [];
|
|
26
|
+
if (Array.isArray(raw)) {
|
|
27
|
+
return raw
|
|
28
|
+
.filter((g) => typeof g === 'string' && g.length > 0)
|
|
29
|
+
.map((g) => g.trim())
|
|
30
|
+
.filter((g) => g.length > 0);
|
|
31
|
+
}
|
|
32
|
+
if (typeof raw === 'string') {
|
|
33
|
+
const trimmed = raw.trim();
|
|
34
|
+
if (!trimmed)
|
|
35
|
+
return [];
|
|
36
|
+
// Single group name, or space/comma-separated list from some IdPs
|
|
37
|
+
if (trimmed.includes(',') || trimmed.includes(' ')) {
|
|
38
|
+
return trimmed
|
|
39
|
+
.split(/[,\s]+/)
|
|
40
|
+
.map((g) => g.trim())
|
|
41
|
+
.filter((g) => g.length > 0);
|
|
42
|
+
}
|
|
43
|
+
return [trimmed];
|
|
44
|
+
}
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
function pickHighestRole(roles) {
|
|
48
|
+
let best = roles[0];
|
|
49
|
+
let bestRank = ROLE_RANK[best] ?? 0;
|
|
50
|
+
for (let i = 1; i < roles.length; i++) {
|
|
51
|
+
const role = roles[i];
|
|
52
|
+
const rank = ROLE_RANK[role] ?? 0;
|
|
53
|
+
if (rank > bestRank) {
|
|
54
|
+
best = role;
|
|
55
|
+
bestRank = rank;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return best;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Resolve a single RevealUI role from IdP groups + provider mapping config.
|
|
62
|
+
*
|
|
63
|
+
* Unmapped groups never contribute a role (including never implying admin).
|
|
64
|
+
* Only explicit map values and the configured default_role assign roles.
|
|
65
|
+
*/
|
|
66
|
+
export function mapSsoGroupsToRole(input) {
|
|
67
|
+
const { claims, groupClaim, groupRoleMap, defaultRole, requireGroupMatch } = input;
|
|
68
|
+
if (!defaultRole || typeof defaultRole !== 'string') {
|
|
69
|
+
return {
|
|
70
|
+
ok: false,
|
|
71
|
+
reason: 'invalid_default_role',
|
|
72
|
+
message: 'default_role must be a non-empty string',
|
|
73
|
+
groups: [],
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const groups = extractGroupsFromClaim(claims, groupClaim);
|
|
77
|
+
const matchedGroups = [];
|
|
78
|
+
const mappedRoles = [];
|
|
79
|
+
for (const group of groups) {
|
|
80
|
+
const mapped = groupRoleMap[group];
|
|
81
|
+
if (typeof mapped === 'string' && mapped.length > 0) {
|
|
82
|
+
matchedGroups.push(group);
|
|
83
|
+
mappedRoles.push(mapped);
|
|
84
|
+
}
|
|
85
|
+
// Unmapped groups intentionally ignored — never grant admin (or any role) from them
|
|
86
|
+
}
|
|
87
|
+
if (mappedRoles.length > 0) {
|
|
88
|
+
return {
|
|
89
|
+
ok: true,
|
|
90
|
+
role: pickHighestRole(mappedRoles),
|
|
91
|
+
matchedGroups,
|
|
92
|
+
groups,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
if (requireGroupMatch) {
|
|
96
|
+
return {
|
|
97
|
+
ok: false,
|
|
98
|
+
reason: 'require_group_match',
|
|
99
|
+
message: 'No IdP groups matched group_role_map and require_group_match is enabled',
|
|
100
|
+
groups,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
ok: true,
|
|
105
|
+
role: defaultRole,
|
|
106
|
+
matchedGroups: [],
|
|
107
|
+
groups,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enterprise SAML 2.0 SP helpers (GAP-464 Phase 3).
|
|
3
|
+
*
|
|
4
|
+
* Wraps @node-saml/node-saml so HTTP routes get typed ok/err results matching
|
|
5
|
+
* the OIDC pure layer. Hardlines:
|
|
6
|
+
* - Never accept a Response without IdP certificate material (signature path).
|
|
7
|
+
* - SP-initiated AuthnRequest only for MVP (IdP-initiated is a follow-up).
|
|
8
|
+
* - InResponseTo checked when present (replay resistance).
|
|
9
|
+
*/
|
|
10
|
+
import { type Profile } from '@node-saml/node-saml';
|
|
11
|
+
export interface SamlSpConfig {
|
|
12
|
+
/** Service Provider entity ID (audience) */
|
|
13
|
+
spEntityId: string;
|
|
14
|
+
/** ACS callback URL (POST) */
|
|
15
|
+
callbackUrl: string;
|
|
16
|
+
/** IdP SingleSignOnService HTTP-Redirect or HTTP-POST location */
|
|
17
|
+
entryPoint: string;
|
|
18
|
+
/**
|
|
19
|
+
* IdP signing certificate PEM (CERTIFICATE or PUBLIC KEY).
|
|
20
|
+
* REQUIRED for validation — unsigned responses are rejected.
|
|
21
|
+
*/
|
|
22
|
+
idpCertPem: string;
|
|
23
|
+
/** Optional SP signing private key PEM (signed AuthnRequest) */
|
|
24
|
+
spPrivateKeyPem?: string;
|
|
25
|
+
/** Optional SP public cert PEM (metadata + request signing) */
|
|
26
|
+
spPublicCertPem?: string;
|
|
27
|
+
/** Clock skew for NotOnOrAfter (ms, default 5 minutes) */
|
|
28
|
+
acceptedClockSkewMs?: number;
|
|
29
|
+
}
|
|
30
|
+
export type ParseIdpMetadataFailureReason = 'missing_xml' | 'missing_entity_id' | 'missing_sso_url' | 'missing_cert';
|
|
31
|
+
export type ParseIdpMetadataResult = {
|
|
32
|
+
ok: true;
|
|
33
|
+
entityId: string;
|
|
34
|
+
entryPoint: string;
|
|
35
|
+
idpCertPem: string;
|
|
36
|
+
} | {
|
|
37
|
+
ok: false;
|
|
38
|
+
reason: ParseIdpMetadataFailureReason;
|
|
39
|
+
message: string;
|
|
40
|
+
};
|
|
41
|
+
export type BuildSamlAuthorizeUrlFailureReason = 'missing_config' | 'missing_cert' | 'build_failed';
|
|
42
|
+
export type BuildSamlAuthorizeUrlResult = {
|
|
43
|
+
ok: true;
|
|
44
|
+
url: string;
|
|
45
|
+
} | {
|
|
46
|
+
ok: false;
|
|
47
|
+
reason: BuildSamlAuthorizeUrlFailureReason;
|
|
48
|
+
message: string;
|
|
49
|
+
};
|
|
50
|
+
export type ValidateSamlResponseFailureReason = 'missing_response' | 'missing_cert' | 'invalid_signature' | 'expired' | 'audience_mismatch' | 'replay' | 'missing_name_id' | 'logged_out' | 'validation_failed';
|
|
51
|
+
export interface ValidatedSamlAssertion {
|
|
52
|
+
/** NameID value (maps to SSO subject) */
|
|
53
|
+
subject: string;
|
|
54
|
+
email?: string;
|
|
55
|
+
name?: string;
|
|
56
|
+
/** Attribute bag for group mapping (keys as claim names) */
|
|
57
|
+
attributes: Record<string, unknown>;
|
|
58
|
+
/** Full node-saml profile for advanced callers */
|
|
59
|
+
profile: Profile;
|
|
60
|
+
}
|
|
61
|
+
export type ValidateSamlResponseResult = {
|
|
62
|
+
ok: true;
|
|
63
|
+
assertion: ValidatedSamlAssertion;
|
|
64
|
+
} | {
|
|
65
|
+
ok: false;
|
|
66
|
+
reason: ValidateSamlResponseFailureReason;
|
|
67
|
+
message: string;
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Normalize a certificate string to PEM CERTIFICATE block if bare base64.
|
|
71
|
+
*/
|
|
72
|
+
export declare function normalizeIdpCertPem(cert: string): string;
|
|
73
|
+
/**
|
|
74
|
+
* Parse IdP metadata XML for entityID, SSO entry point, and signing cert.
|
|
75
|
+
* Uses linear string scans (no authored regex) for CodeQL / no-regex hardline.
|
|
76
|
+
*/
|
|
77
|
+
export declare function parseIdpMetadataXml(xml: string): ParseIdpMetadataResult;
|
|
78
|
+
/**
|
|
79
|
+
* Generate SP metadata XML for customer IdP configuration.
|
|
80
|
+
*/
|
|
81
|
+
export declare function buildSamlSpMetadata(config: SamlSpConfig): string;
|
|
82
|
+
/**
|
|
83
|
+
* Build SP-initiated AuthnRequest redirect URL (HTTP-Redirect binding).
|
|
84
|
+
* `relayState` should be the signed SSO state token (CSRF + account binding).
|
|
85
|
+
*/
|
|
86
|
+
export declare function buildSamlAuthorizeUrl(config: SamlSpConfig, relayState: string): Promise<BuildSamlAuthorizeUrlResult>;
|
|
87
|
+
/**
|
|
88
|
+
* Validate a SAMLResponse from HTTP-POST binding.
|
|
89
|
+
* Requires IdP cert; rejects unsigned / bad-signature responses.
|
|
90
|
+
*/
|
|
91
|
+
export declare function validateSamlPostResponse(config: SamlSpConfig, samlResponseBase64: string): Promise<ValidateSamlResponseResult>;
|
|
92
|
+
/**
|
|
93
|
+
* Fetch and parse IdP metadata from a URL (test-connection + seed entryPoint/cert).
|
|
94
|
+
*/
|
|
95
|
+
export declare function fetchIdpMetadata(metadataUrl: string, options?: {
|
|
96
|
+
fetchImpl?: typeof fetch;
|
|
97
|
+
timeoutMs?: number;
|
|
98
|
+
}): Promise<ParseIdpMetadataResult>;
|
|
99
|
+
//# sourceMappingURL=saml.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"saml.d.ts","sourceRoot":"","sources":["../../../src/server/sso/saml.ts"],"names":[],"mappings":"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"}
|