@revealui/auth 0.4.10 → 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.
Files changed (42) hide show
  1. package/dist/react/useSignIn.d.ts +1 -1
  2. package/dist/react/useSignIn.d.ts.map +1 -1
  3. package/dist/react/useSignIn.js +7 -3
  4. package/dist/server/audit-storage.d.ts +122 -0
  5. package/dist/server/audit-storage.d.ts.map +1 -0
  6. package/dist/server/audit-storage.js +298 -0
  7. package/dist/server/auth.d.ts.map +1 -1
  8. package/dist/server/auth.js +68 -10
  9. package/dist/server/index.d.ts +2 -0
  10. package/dist/server/index.d.ts.map +1 -1
  11. package/dist/server/index.js +9 -0
  12. package/dist/server/platform-roles.d.ts +42 -0
  13. package/dist/server/platform-roles.d.ts.map +1 -0
  14. package/dist/server/platform-roles.js +65 -0
  15. package/dist/server/session.js +1 -1
  16. package/dist/server/sso/__tests__/helpers/mock-oidc-idp.d.ts +31 -0
  17. package/dist/server/sso/__tests__/helpers/mock-oidc-idp.d.ts.map +1 -0
  18. package/dist/server/sso/__tests__/helpers/mock-oidc-idp.js +115 -0
  19. package/dist/server/sso/__tests__/helpers/mock-saml-idp.d.ts +28 -0
  20. package/dist/server/sso/__tests__/helpers/mock-saml-idp.d.ts.map +1 -0
  21. package/dist/server/sso/__tests__/helpers/mock-saml-idp.js +150 -0
  22. package/dist/server/sso/index.d.ts +13 -0
  23. package/dist/server/sso/index.d.ts.map +1 -0
  24. package/dist/server/sso/index.js +12 -0
  25. package/dist/server/sso/jit.d.ts +39 -0
  26. package/dist/server/sso/jit.d.ts.map +1 -0
  27. package/dist/server/sso/jit.js +141 -0
  28. package/dist/server/sso/oidc.d.ts +137 -0
  29. package/dist/server/sso/oidc.d.ts.map +1 -0
  30. package/dist/server/sso/oidc.js +345 -0
  31. package/dist/server/sso/roles.d.ts +48 -0
  32. package/dist/server/sso/roles.d.ts.map +1 -0
  33. package/dist/server/sso/roles.js +109 -0
  34. package/dist/server/sso/saml.d.ts +99 -0
  35. package/dist/server/sso/saml.d.ts.map +1 -0
  36. package/dist/server/sso/saml.js +392 -0
  37. package/dist/server/sso/state.d.ts +46 -0
  38. package/dist/server/sso/state.d.ts.map +1 -0
  39. package/dist/server/sso/state.js +101 -0
  40. package/dist/utils/database.d.ts +1 -1
  41. package/dist/utils/database.d.ts.map +1 -1
  42. package/package.json +15 -6
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Enterprise SSO JIT user upsert (GAP-464).
3
+ *
4
+ * Flow:
5
+ * 1. Lookup sso_identities by (providerId, subject) → existing user
6
+ * 2. Else if verified email: find users by email → link identity
7
+ * (enterprise SSO intentionally links by verified email from id_token;
8
+ * email is used only when emailVerified === true)
9
+ * 3. Else create user with password null and a sanitized role
10
+ * 4. Insert sso_identities; ensure account_memberships row as 'member'
11
+ *
12
+ * Never defaults new users to admin. Membership role is always 'member'
13
+ * (account ACL); mapped SSO role is stored on users.role after allowlist.
14
+ */
15
+ import { logger } from '@revealui/core/observability/logger';
16
+ import { getClient } from '@revealui/db/client';
17
+ import { accountMemberships, ssoIdentities, users } from '@revealui/db/schema';
18
+ import { and, eq, isNull } from 'drizzle-orm';
19
+ /** Roles allowed on users.role for SSO-provisioned humans. */
20
+ const ALLOWED_USER_ROLES = new Set(['viewer', 'editor', 'contributor', 'admin', 'owner']);
21
+ /**
22
+ * Map SSO/group roles onto the users table allowlist.
23
+ * `member` (membership vocabulary) → `viewer` on the user row.
24
+ * Unknown roles fail closed to `viewer` (never admin).
25
+ */
26
+ export function normalizeSsoUserRole(role) {
27
+ if (role === 'member')
28
+ return 'viewer';
29
+ if (ALLOWED_USER_ROLES.has(role))
30
+ return role;
31
+ return 'viewer';
32
+ }
33
+ /**
34
+ * Upsert a user from a validated SSO identity and ensure account membership.
35
+ */
36
+ export async function upsertSsoUser(input) {
37
+ const { providerId, accountId, subject, email, emailVerified, name, role } = input;
38
+ if (!(providerId && accountId && subject)) {
39
+ throw new Error('providerId, accountId, and subject are required for SSO JIT');
40
+ }
41
+ const db = getClient();
42
+ const userRole = normalizeSsoUserRole(role);
43
+ const displayName = typeof name === 'string' && name.trim().length > 0 ? name.trim() : (email ?? 'SSO User');
44
+ // 1. Existing federated identity
45
+ const [existingIdentity] = await db
46
+ .select()
47
+ .from(ssoIdentities)
48
+ .where(and(eq(ssoIdentities.providerId, providerId), eq(ssoIdentities.subject, subject)))
49
+ .limit(1);
50
+ if (existingIdentity) {
51
+ const [user] = await db
52
+ .select()
53
+ .from(users)
54
+ .where(and(eq(users.id, existingIdentity.userId), isNull(users.deletedAt)))
55
+ .limit(1);
56
+ if (!user) {
57
+ logger.error('sso_identities row references missing user', {
58
+ identityId: existingIdentity.id,
59
+ userId: existingIdentity.userId,
60
+ });
61
+ throw new Error('SSO identity references a deleted user');
62
+ }
63
+ // Refresh email on identity if we have a verified one
64
+ if (email && emailVerified === true && existingIdentity.email !== email) {
65
+ await db
66
+ .update(ssoIdentities)
67
+ .set({ email, updatedAt: new Date() })
68
+ .where(eq(ssoIdentities.id, existingIdentity.id));
69
+ }
70
+ await ensureAccountMembership(db, accountId, user.id);
71
+ return user;
72
+ }
73
+ // 2. Link by verified email (enterprise SSO intentional JIT)
74
+ let userId;
75
+ let isNewUser = false;
76
+ if (email && emailVerified === true) {
77
+ const [existingUser] = await db
78
+ .select()
79
+ .from(users)
80
+ .where(and(eq(users.email, email), isNull(users.deletedAt)))
81
+ .limit(1);
82
+ if (existingUser) {
83
+ userId = existingUser.id;
84
+ logger.info('Linking SSO identity to existing user by verified email', {
85
+ userId,
86
+ providerId,
87
+ });
88
+ }
89
+ else {
90
+ isNewUser = true;
91
+ userId = crypto.randomUUID();
92
+ }
93
+ }
94
+ else {
95
+ isNewUser = true;
96
+ userId = crypto.randomUUID();
97
+ }
98
+ // 3. Create user (password null — federated only)
99
+ if (isNewUser) {
100
+ await db.insert(users).values({
101
+ id: userId,
102
+ name: displayName,
103
+ email: email && emailVerified === true ? email : (email ?? null),
104
+ password: null,
105
+ role: userRole,
106
+ status: 'active',
107
+ emailVerified: emailVerified === true,
108
+ emailVerifiedAt: emailVerified === true ? new Date() : null,
109
+ });
110
+ }
111
+ // 4. Insert identity link
112
+ await db.insert(ssoIdentities).values({
113
+ id: crypto.randomUUID(),
114
+ userId,
115
+ providerId,
116
+ subject,
117
+ email: email ?? null,
118
+ });
119
+ await ensureAccountMembership(db, accountId, userId);
120
+ const [user] = await db.select().from(users).where(eq(users.id, userId)).limit(1);
121
+ if (!user) {
122
+ throw new Error('Failed to fetch upserted SSO user');
123
+ }
124
+ return user;
125
+ }
126
+ async function ensureAccountMembership(db, accountId, userId) {
127
+ const [existing] = await db
128
+ .select({ id: accountMemberships.id })
129
+ .from(accountMemberships)
130
+ .where(and(eq(accountMemberships.accountId, accountId), eq(accountMemberships.userId, userId)))
131
+ .limit(1);
132
+ if (existing)
133
+ return;
134
+ await db.insert(accountMemberships).values({
135
+ id: crypto.randomUUID(),
136
+ accountId,
137
+ userId,
138
+ role: 'member',
139
+ status: 'active',
140
+ });
141
+ }
@@ -0,0 +1,137 @@
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 { type JWTPayload, type JWTVerifyGetKey, type KeyLike } from 'jose';
9
+ export interface OidcDiscoveryDocument {
10
+ issuer: string;
11
+ authorization_endpoint: string;
12
+ token_endpoint: string;
13
+ jwks_uri: string;
14
+ userinfo_endpoint?: string;
15
+ end_session_endpoint?: string;
16
+ scopes_supported?: string[];
17
+ response_types_supported?: string[];
18
+ code_challenge_methods_supported?: string[];
19
+ }
20
+ export type OidcDiscoveryFailureReason = 'fetch_failed' | 'invalid_json' | 'missing_required_fields' | 'issuer_mismatch';
21
+ export type FetchOidcDiscoveryResult = {
22
+ ok: true;
23
+ document: OidcDiscoveryDocument;
24
+ } | {
25
+ ok: false;
26
+ reason: OidcDiscoveryFailureReason;
27
+ message: string;
28
+ };
29
+ export interface FetchOidcDiscoveryOptions {
30
+ /**
31
+ * Optional expected issuer. When set, the document's `issuer` must match
32
+ * (after trailing-slash normalization).
33
+ */
34
+ expectedIssuer?: string;
35
+ /** Injectable fetch for tests (defaults to global fetch) */
36
+ fetchImpl?: typeof fetch;
37
+ /** Request timeout ms (default 10_000) */
38
+ timeoutMs?: number;
39
+ }
40
+ /**
41
+ * Fetch and parse an OIDC discovery document (openid-configuration).
42
+ */
43
+ export declare function fetchOidcDiscovery(discoveryUrl: string, options?: FetchOidcDiscoveryOptions): Promise<FetchOidcDiscoveryResult>;
44
+ export interface BuildOidcAuthorizationUrlInput {
45
+ authorizationEndpoint: string;
46
+ clientId: string;
47
+ redirectUri: string;
48
+ state: string;
49
+ codeChallenge: string;
50
+ /** Default: openid email profile */
51
+ scope?: string;
52
+ /** Optional OIDC nonce (distinct from SSO state nonce) */
53
+ nonce?: string;
54
+ }
55
+ /**
56
+ * Build the OIDC authorization redirect URL (code + PKCE S256).
57
+ */
58
+ export declare function buildOidcAuthorizationUrl(input: BuildOidcAuthorizationUrlInput): string;
59
+ export type ValidateIdTokenFailureReason = 'missing_token' | 'missing_key' | 'invalid_signature' | 'invalid_issuer' | 'invalid_audience' | 'expired' | 'not_yet_valid' | 'missing_sub' | 'malformed';
60
+ export interface ValidatedIdTokenClaims {
61
+ sub: string;
62
+ email?: string;
63
+ emailVerified?: boolean;
64
+ name?: string;
65
+ preferredUsername?: string;
66
+ /** Full verified JWT payload (includes groups, custom claims, etc.) */
67
+ payload: JWTPayload;
68
+ }
69
+ export type ValidateIdTokenResult = {
70
+ ok: true;
71
+ claims: ValidatedIdTokenClaims;
72
+ } | {
73
+ ok: false;
74
+ reason: ValidateIdTokenFailureReason;
75
+ message: string;
76
+ };
77
+ export interface ValidateOidcIdTokenOptions {
78
+ idToken: string;
79
+ /** Expected `iss` (must match provider.issuer) */
80
+ issuer: string;
81
+ /** Expected `aud` (OIDC client_id) */
82
+ clientId: string;
83
+ /**
84
+ * Key material for signature verification.
85
+ * Pass a remote JWKS getter (`createRemoteJWKSet(new URL(jwks_uri))`),
86
+ * a local JWK set, or a single KeyLike from tests.
87
+ * REQUIRED — unsigned tokens are never accepted.
88
+ */
89
+ jwks: JWTVerifyGetKey | KeyLike | Uint8Array;
90
+ /** Clock skew tolerance in seconds (default 30) */
91
+ clockToleranceSeconds?: number;
92
+ }
93
+ /**
94
+ * Validate an OIDC id_token: signature (JWKS), issuer, audience, exp.
95
+ *
96
+ * Hardline: `jwks` is required. Callers must not pass a no-op key or skip verify.
97
+ */
98
+ export declare function validateOidcIdToken(options: ValidateOidcIdTokenOptions): Promise<ValidateIdTokenResult>;
99
+ /**
100
+ * Create a remote JWKS key resolver from a discovery `jwks_uri`.
101
+ * Thin wrapper so route code does not import jose directly.
102
+ */
103
+ export declare function createOidcRemoteJwkSet(jwksUri: string): JWTVerifyGetKey;
104
+ export type ExchangeOidcCodeFailureReason = 'missing_params' | 'fetch_failed' | 'invalid_json' | 'missing_id_token';
105
+ export interface ExchangeOidcCodeInput {
106
+ tokenEndpoint: string;
107
+ clientId: string;
108
+ clientSecret: string;
109
+ code: string;
110
+ redirectUri: string;
111
+ codeVerifier: string;
112
+ /** Injectable fetch for tests (defaults to global fetch) */
113
+ fetchImpl?: typeof fetch;
114
+ /** Request timeout ms (default 10_000) */
115
+ timeoutMs?: number;
116
+ }
117
+ export interface ExchangeOidcCodeSuccess {
118
+ id_token: string;
119
+ access_token?: string;
120
+ }
121
+ export type ExchangeOidcCodeResult = {
122
+ ok: true;
123
+ tokens: ExchangeOidcCodeSuccess;
124
+ } | {
125
+ ok: false;
126
+ reason: ExchangeOidcCodeFailureReason;
127
+ message: string;
128
+ };
129
+ /**
130
+ * Exchange an OIDC authorization code for tokens (PKCE + client_secret).
131
+ *
132
+ * POSTs `application/x-www-form-urlencoded`. Rejects responses without `id_token`.
133
+ * Never logs client_secret or tokens.
134
+ */
135
+ export declare function exchangeOidcCode(input: ExchangeOidcCodeInput): Promise<ExchangeOidcCodeResult>;
136
+ export type { JWTPayload, JWTVerifyGetKey, KeyLike };
137
+ //# sourceMappingURL=oidc.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"oidc.d.ts","sourceRoot":"","sources":["../../../src/server/sso/oidc.ts"],"names":[],"mappings":"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"}
@@ -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"}