@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.
Files changed (40) 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 +5 -2
  5. package/dist/server/audit-storage.d.ts.map +1 -1
  6. package/dist/server/audit-storage.js +5 -2
  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 +3 -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/package.json +9 -5
@@ -0,0 +1,392 @@
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 { SAML, ValidateInResponseTo } from '@node-saml/node-saml';
11
+ // ---------------------------------------------------------------------------
12
+ // PEM helpers
13
+ // ---------------------------------------------------------------------------
14
+ function isNonEmptyString(value) {
15
+ return typeof value === 'string' && value.trim().length > 0;
16
+ }
17
+ /**
18
+ * Normalize a certificate string to PEM CERTIFICATE block if bare base64.
19
+ */
20
+ export function normalizeIdpCertPem(cert) {
21
+ const trimmed = cert.trim();
22
+ if (trimmed.includes('BEGIN CERTIFICATE') || trimmed.includes('BEGIN PUBLIC KEY')) {
23
+ return trimmed;
24
+ }
25
+ // Strip whitespace from bare base64
26
+ let compact = '';
27
+ for (const ch of trimmed) {
28
+ if (ch !== ' ' && ch !== '\n' && ch !== '\r' && ch !== '\t') {
29
+ compact += ch;
30
+ }
31
+ }
32
+ const lines = [];
33
+ for (let i = 0; i < compact.length; i += 64) {
34
+ lines.push(compact.slice(i, i + 64));
35
+ }
36
+ return `-----BEGIN CERTIFICATE-----\n${lines.join('\n')}\n-----END CERTIFICATE-----`;
37
+ }
38
+ // ---------------------------------------------------------------------------
39
+ // IdP metadata parse (string scans; no authored regex)
40
+ // ---------------------------------------------------------------------------
41
+ function extractXmlAttribute(xml, tagHint, attrName) {
42
+ // Find a tag containing tagHint, then attrName="..."
43
+ let searchFrom = 0;
44
+ while (searchFrom < xml.length) {
45
+ const tagStart = xml.indexOf('<', searchFrom);
46
+ if (tagStart === -1)
47
+ return null;
48
+ const tagEnd = xml.indexOf('>', tagStart);
49
+ if (tagEnd === -1)
50
+ return null;
51
+ const tag = xml.slice(tagStart, tagEnd + 1);
52
+ if (tag.includes(tagHint) && !tag.startsWith('</') && !tag.startsWith('<!--')) {
53
+ const attrKey = `${attrName}="`;
54
+ const attrPos = tag.indexOf(attrKey);
55
+ if (attrPos !== -1) {
56
+ const valueStart = attrPos + attrKey.length;
57
+ const valueEnd = tag.indexOf('"', valueStart);
58
+ if (valueEnd !== -1) {
59
+ return tag.slice(valueStart, valueEnd);
60
+ }
61
+ }
62
+ }
63
+ searchFrom = tagEnd + 1;
64
+ }
65
+ return null;
66
+ }
67
+ function extractFirstX509Certificate(xml) {
68
+ const open = '<X509Certificate>';
69
+ const openAlt = '<ds:X509Certificate>';
70
+ let start = xml.indexOf(open);
71
+ let openLen = open.length;
72
+ if (start === -1) {
73
+ start = xml.indexOf(openAlt);
74
+ openLen = openAlt.length;
75
+ }
76
+ if (start === -1)
77
+ return null;
78
+ const contentStart = start + openLen;
79
+ const close = xml.indexOf('</', contentStart);
80
+ if (close === -1)
81
+ return null;
82
+ return xml.slice(contentStart, close).trim();
83
+ }
84
+ function extractSsoLocation(xml) {
85
+ // Prefer HTTP-Redirect SingleSignOnService Location
86
+ let searchFrom = 0;
87
+ let fallback = null;
88
+ while (searchFrom < xml.length) {
89
+ const tagStart = xml.indexOf('SingleSignOnService', searchFrom);
90
+ if (tagStart === -1)
91
+ break;
92
+ // Walk back to '<'
93
+ let open = tagStart;
94
+ while (open > 0 && xml[open] !== '<')
95
+ open--;
96
+ const tagEnd = xml.indexOf('>', tagStart);
97
+ if (tagEnd === -1)
98
+ break;
99
+ const tag = xml.slice(open, tagEnd + 1);
100
+ const bindingKey = 'Binding="';
101
+ const locKey = 'Location="';
102
+ const bindingPos = tag.indexOf(bindingKey);
103
+ const locPos = tag.indexOf(locKey);
104
+ if (locPos !== -1) {
105
+ const valueStart = locPos + locKey.length;
106
+ const valueEnd = tag.indexOf('"', valueStart);
107
+ if (valueEnd !== -1) {
108
+ const location = tag.slice(valueStart, valueEnd);
109
+ if (bindingPos !== -1) {
110
+ const bStart = bindingPos + bindingKey.length;
111
+ const bEnd = tag.indexOf('"', bStart);
112
+ const binding = bEnd !== -1 ? tag.slice(bStart, bEnd) : '';
113
+ if (binding.includes('HTTP-Redirect')) {
114
+ return location;
115
+ }
116
+ if (!fallback)
117
+ fallback = location;
118
+ }
119
+ else if (!fallback) {
120
+ fallback = location;
121
+ }
122
+ }
123
+ }
124
+ searchFrom = tagEnd + 1;
125
+ }
126
+ return fallback;
127
+ }
128
+ /**
129
+ * Parse IdP metadata XML for entityID, SSO entry point, and signing cert.
130
+ * Uses linear string scans (no authored regex) for CodeQL / no-regex hardline.
131
+ */
132
+ export function parseIdpMetadataXml(xml) {
133
+ if (!isNonEmptyString(xml)) {
134
+ return { ok: false, reason: 'missing_xml', message: 'IdP metadata XML is required' };
135
+ }
136
+ const entityId = extractXmlAttribute(xml, 'EntityDescriptor', 'entityID') ||
137
+ extractXmlAttribute(xml, 'md:EntityDescriptor', 'entityID');
138
+ if (!entityId) {
139
+ return {
140
+ ok: false,
141
+ reason: 'missing_entity_id',
142
+ message: 'IdP metadata missing EntityDescriptor entityID',
143
+ };
144
+ }
145
+ const entryPoint = extractSsoLocation(xml);
146
+ if (!entryPoint) {
147
+ return {
148
+ ok: false,
149
+ reason: 'missing_sso_url',
150
+ message: 'IdP metadata missing SingleSignOnService Location',
151
+ };
152
+ }
153
+ const rawCert = extractFirstX509Certificate(xml);
154
+ if (!rawCert) {
155
+ return {
156
+ ok: false,
157
+ reason: 'missing_cert',
158
+ message: 'IdP metadata missing X509Certificate',
159
+ };
160
+ }
161
+ return {
162
+ ok: true,
163
+ entityId,
164
+ entryPoint,
165
+ idpCertPem: normalizeIdpCertPem(rawCert),
166
+ };
167
+ }
168
+ // ---------------------------------------------------------------------------
169
+ // SAML service construction
170
+ // ---------------------------------------------------------------------------
171
+ function createSamlInstance(config) {
172
+ const idpCert = normalizeIdpCertPem(config.idpCertPem);
173
+ const options = {
174
+ callbackUrl: config.callbackUrl,
175
+ entryPoint: config.entryPoint,
176
+ issuer: config.spEntityId,
177
+ idpCert,
178
+ audience: config.spEntityId,
179
+ wantAssertionsSigned: true,
180
+ wantAuthnResponseSigned: true,
181
+ validateInResponseTo: ValidateInResponseTo.ifPresent,
182
+ acceptedClockSkewMs: config.acceptedClockSkewMs ?? 5 * 60 * 1000,
183
+ identifierFormat: 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
184
+ };
185
+ if (config.spPrivateKeyPem) {
186
+ options.privateKey = config.spPrivateKeyPem;
187
+ }
188
+ if (config.spPublicCertPem) {
189
+ options.publicCert = config.spPublicCertPem;
190
+ }
191
+ return new SAML(options);
192
+ }
193
+ // ---------------------------------------------------------------------------
194
+ // SP metadata + AuthnRequest redirect
195
+ // ---------------------------------------------------------------------------
196
+ /**
197
+ * Generate SP metadata XML for customer IdP configuration.
198
+ */
199
+ export function buildSamlSpMetadata(config) {
200
+ if (!isNonEmptyString(config.idpCertPem)) {
201
+ throw new Error('idpCertPem is required to construct SP metadata service');
202
+ }
203
+ const saml = createSamlInstance(config);
204
+ // decryptionCert null when no encryption; publicCert optional for signing
205
+ return saml.generateServiceProviderMetadata(null, config.spPublicCertPem ? config.spPublicCertPem : null);
206
+ }
207
+ /**
208
+ * Build SP-initiated AuthnRequest redirect URL (HTTP-Redirect binding).
209
+ * `relayState` should be the signed SSO state token (CSRF + account binding).
210
+ */
211
+ export async function buildSamlAuthorizeUrl(config, relayState) {
212
+ if (!(isNonEmptyString(config.callbackUrl) && isNonEmptyString(config.entryPoint))) {
213
+ return {
214
+ ok: false,
215
+ reason: 'missing_config',
216
+ message: 'callbackUrl and entryPoint are required',
217
+ };
218
+ }
219
+ if (!isNonEmptyString(config.idpCertPem)) {
220
+ return {
221
+ ok: false,
222
+ reason: 'missing_cert',
223
+ message: 'IdP signing certificate is required',
224
+ };
225
+ }
226
+ if (!isNonEmptyString(config.spEntityId)) {
227
+ return {
228
+ ok: false,
229
+ reason: 'missing_config',
230
+ message: 'spEntityId is required',
231
+ };
232
+ }
233
+ try {
234
+ const saml = createSamlInstance(config);
235
+ const url = await saml.getAuthorizeUrlAsync(relayState, undefined, {});
236
+ return { ok: true, url };
237
+ }
238
+ catch (err) {
239
+ return {
240
+ ok: false,
241
+ reason: 'build_failed',
242
+ message: err instanceof Error ? err.message : 'Failed to build SAML AuthnRequest URL',
243
+ };
244
+ }
245
+ }
246
+ // ---------------------------------------------------------------------------
247
+ // Response validation
248
+ // ---------------------------------------------------------------------------
249
+ function mapValidateError(err) {
250
+ const message = err instanceof Error ? err.message : 'SAML response validation failed';
251
+ const lower = message.toLowerCase();
252
+ if (lower.includes('signature') || lower.includes('invalid document')) {
253
+ return { reason: 'invalid_signature', message };
254
+ }
255
+ if (lower.includes('expired') || lower.includes('notonorafter') || lower.includes('not before')) {
256
+ return { reason: 'expired', message };
257
+ }
258
+ if (lower.includes('audience')) {
259
+ return { reason: 'audience_mismatch', message };
260
+ }
261
+ if (lower.includes('inresponseto') || lower.includes('replay')) {
262
+ return { reason: 'replay', message };
263
+ }
264
+ return { reason: 'validation_failed', message };
265
+ }
266
+ function profileToAssertion(profile) {
267
+ const subject = typeof profile.nameID === 'string' ? profile.nameID.trim() : '';
268
+ if (!subject)
269
+ return null;
270
+ const attributes = {};
271
+ // Profile is a claim bag ([attributeName: string]: unknown) plus fixed fields
272
+ for (const [key, value] of Object.entries(profile)) {
273
+ if (key === 'getAssertionXml' || key === 'getAssertion' || key === 'getSamlResponseXml') {
274
+ continue;
275
+ }
276
+ if (typeof value === 'function')
277
+ continue;
278
+ attributes[key] = value;
279
+ }
280
+ if (typeof profile.email === 'string') {
281
+ attributes.email = profile.email;
282
+ }
283
+ const msGroups = profile['http://schemas.microsoft.com/ws/2008/06/identity/claims/groups'];
284
+ if (Array.isArray(msGroups)) {
285
+ attributes.groups = msGroups;
286
+ }
287
+ if (typeof profile.mail === 'string') {
288
+ attributes.mail = profile.mail;
289
+ }
290
+ const email = (typeof profile.email === 'string' && profile.email) ||
291
+ (typeof profile.mail === 'string' && profile.mail) ||
292
+ (subject.includes('@') ? subject : undefined);
293
+ const displayName = profile.displayName;
294
+ const cn = profile.cn;
295
+ const name = (typeof displayName === 'string' && displayName) || (typeof cn === 'string' && cn) || undefined;
296
+ return {
297
+ subject,
298
+ email,
299
+ name,
300
+ attributes,
301
+ profile,
302
+ };
303
+ }
304
+ /**
305
+ * Validate a SAMLResponse from HTTP-POST binding.
306
+ * Requires IdP cert; rejects unsigned / bad-signature responses.
307
+ */
308
+ export async function validateSamlPostResponse(config, samlResponseBase64) {
309
+ if (!isNonEmptyString(samlResponseBase64)) {
310
+ return {
311
+ ok: false,
312
+ reason: 'missing_response',
313
+ message: 'SAMLResponse is required',
314
+ };
315
+ }
316
+ if (!isNonEmptyString(config.idpCertPem)) {
317
+ return {
318
+ ok: false,
319
+ reason: 'missing_cert',
320
+ message: 'IdP signing certificate is required; unsigned responses are rejected',
321
+ };
322
+ }
323
+ try {
324
+ const saml = createSamlInstance(config);
325
+ const { profile, loggedOut } = await saml.validatePostResponseAsync({
326
+ SAMLResponse: samlResponseBase64,
327
+ });
328
+ if (loggedOut) {
329
+ return {
330
+ ok: false,
331
+ reason: 'logged_out',
332
+ message: 'SAML response was a logout response',
333
+ };
334
+ }
335
+ if (!profile) {
336
+ return {
337
+ ok: false,
338
+ reason: 'validation_failed',
339
+ message: 'SAML validation returned no profile',
340
+ };
341
+ }
342
+ const assertion = profileToAssertion(profile);
343
+ if (!assertion) {
344
+ return {
345
+ ok: false,
346
+ reason: 'missing_name_id',
347
+ message: 'SAML assertion missing NameID / subject',
348
+ };
349
+ }
350
+ return { ok: true, assertion };
351
+ }
352
+ catch (err) {
353
+ const mapped = mapValidateError(err);
354
+ return { ok: false, reason: mapped.reason, message: mapped.message };
355
+ }
356
+ }
357
+ /**
358
+ * Fetch and parse IdP metadata from a URL (test-connection + seed entryPoint/cert).
359
+ */
360
+ export async function fetchIdpMetadata(metadataUrl, options) {
361
+ if (!isNonEmptyString(metadataUrl)) {
362
+ return { ok: false, reason: 'missing_xml', message: 'metadata URL is required' };
363
+ }
364
+ const fetchImpl = options?.fetchImpl ?? fetch;
365
+ const timeoutMs = options?.timeoutMs ?? 10_000;
366
+ try {
367
+ const controller = new AbortController();
368
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
369
+ try {
370
+ const res = await fetchImpl(metadataUrl, { signal: controller.signal });
371
+ if (!res.ok) {
372
+ return {
373
+ ok: false,
374
+ reason: 'missing_xml',
375
+ message: `IdP metadata fetch failed with HTTP ${res.status}`,
376
+ };
377
+ }
378
+ const text = await res.text();
379
+ return parseIdpMetadataXml(text);
380
+ }
381
+ finally {
382
+ clearTimeout(timer);
383
+ }
384
+ }
385
+ catch (err) {
386
+ return {
387
+ ok: false,
388
+ reason: 'missing_xml',
389
+ message: err instanceof Error ? err.message : 'IdP metadata fetch failed',
390
+ };
391
+ }
392
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Enterprise SSO signed state cookie (GAP-464).
3
+ *
4
+ * Separate from social OAuth generateOAuthState / verifyOAuthState so enterprise
5
+ * IdP bindings (accountId + providerId + PKCE) never overload provider enums.
6
+ *
7
+ * Cookie value: `<state>.<hmac-hex>` where state is base64url(JSON payload)
8
+ * and HMAC-SHA256 is over the state string using REVEALUI_SECRET.
9
+ */
10
+ export interface SsoStatePayload {
11
+ accountId: string;
12
+ providerId: string;
13
+ redirectTo: string;
14
+ nonce: string;
15
+ codeVerifier: string;
16
+ }
17
+ export interface GenerateSsoStateInput {
18
+ accountId: string;
19
+ providerId: string;
20
+ redirectTo: string;
21
+ }
22
+ export interface GenerateSsoStateResult {
23
+ /** Opaque state query param sent to the IdP */
24
+ state: string;
25
+ /** Value for the httpOnly SSO state cookie (`state.hmac`) */
26
+ cookieValue: string;
27
+ /** S256 PKCE code_challenge for the authorization request */
28
+ codeChallenge: string;
29
+ }
30
+ export interface VerifiedSsoState {
31
+ accountId: string;
32
+ providerId: string;
33
+ redirectTo: string;
34
+ nonce: string;
35
+ codeVerifier: string;
36
+ }
37
+ /**
38
+ * Generate a signed SSO state token with PKCE verifier.
39
+ */
40
+ export declare function generateSsoState(input: GenerateSsoStateInput): GenerateSsoStateResult;
41
+ /**
42
+ * Verify a signed SSO state token from the callback.
43
+ * Returns null on any integrity / shape failure (does not throw for bad input).
44
+ */
45
+ export declare function verifySsoState(state: string | null | undefined, cookieValue: string | null | undefined): VerifiedSsoState | null;
46
+ //# sourceMappingURL=state.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"state.d.ts","sourceRoot":"","sources":["../../../src/server/sso/state.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AAIH,MAAM,WAAW,eAAe;IAC9B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;CACtB;AAED,MAAM,WAAW,qBAAqB;IACpC,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,sBAAsB;IACrC,+CAA+C;IAC/C,KAAK,EAAE,MAAM,CAAC;IACd,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC;IACpB,6DAA6D;IAC7D,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC;IAClB,UAAU,EAAE,MAAM,CAAC;IACnB,UAAU,EAAE,MAAM,CAAC;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,YAAY,EAAE,MAAM,CAAC;CACtB;AAaD;;GAEG;AACH,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,qBAAqB,GAAG,sBAAsB,CA2BrF;AAED;;;GAGG;AACH,wBAAgB,cAAc,CAC5B,KAAK,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,EAChC,WAAW,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GACrC,gBAAgB,GAAG,IAAI,CAyDzB"}
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Enterprise SSO signed state cookie (GAP-464).
3
+ *
4
+ * Separate from social OAuth generateOAuthState / verifyOAuthState so enterprise
5
+ * IdP bindings (accountId + providerId + PKCE) never overload provider enums.
6
+ *
7
+ * Cookie value: `<state>.<hmac-hex>` where state is base64url(JSON payload)
8
+ * and HMAC-SHA256 is over the state string using REVEALUI_SECRET.
9
+ */
10
+ import crypto from 'node:crypto';
11
+ function requireSecret() {
12
+ const secret = process.env.REVEALUI_SECRET;
13
+ if (!secret) {
14
+ throw new Error('REVEALUI_SECRET is required for SSO state signing. ' +
15
+ 'Set it in your environment variables.');
16
+ }
17
+ return secret;
18
+ }
19
+ /**
20
+ * Generate a signed SSO state token with PKCE verifier.
21
+ */
22
+ export function generateSsoState(input) {
23
+ const { accountId, providerId, redirectTo } = input;
24
+ if (!(accountId && providerId)) {
25
+ throw new Error('accountId and providerId are required for SSO state');
26
+ }
27
+ const nonce = crypto.randomBytes(16).toString('hex');
28
+ const codeVerifier = crypto.randomBytes(32).toString('base64url');
29
+ const codeChallenge = crypto.createHash('sha256').update(codeVerifier).digest('base64url');
30
+ const payload = {
31
+ accountId,
32
+ providerId,
33
+ redirectTo,
34
+ nonce,
35
+ codeVerifier,
36
+ };
37
+ const state = Buffer.from(JSON.stringify(payload)).toString('base64url');
38
+ const secret = requireSecret();
39
+ // lgtm[js/insufficient-password-hash] - HMAC-SHA256 for SSO CSRF state, not password hashing
40
+ const hmac = crypto.createHmac('sha256', secret).update(state).digest('hex');
41
+ return {
42
+ state,
43
+ cookieValue: `${state}.${hmac}`,
44
+ codeChallenge,
45
+ };
46
+ }
47
+ /**
48
+ * Verify a signed SSO state token from the callback.
49
+ * Returns null on any integrity / shape failure (does not throw for bad input).
50
+ */
51
+ export function verifySsoState(state, cookieValue) {
52
+ if (!(state && cookieValue))
53
+ return null;
54
+ const dotIdx = cookieValue.lastIndexOf('.');
55
+ if (dotIdx === -1)
56
+ return null;
57
+ const storedState = cookieValue.substring(0, dotIdx);
58
+ const storedHmac = cookieValue.substring(dotIdx + 1);
59
+ if (storedState.length !== state.length ||
60
+ !crypto.timingSafeEqual(Buffer.from(storedState), Buffer.from(state))) {
61
+ return null;
62
+ }
63
+ const secret = requireSecret();
64
+ // lgtm[js/insufficient-password-hash] - HMAC-SHA256 for SSO CSRF state, not password hashing
65
+ const expectedHmac = crypto.createHmac('sha256', secret).update(state).digest('hex');
66
+ // Both are hex-encoded SHA-256 HMACs — must be exactly 64 hex characters.
67
+ if (storedHmac.length !== 64 || expectedHmac.length !== 64)
68
+ return null;
69
+ try {
70
+ if (!crypto.timingSafeEqual(Buffer.from(storedHmac, 'hex'), Buffer.from(expectedHmac, 'hex'))) {
71
+ return null;
72
+ }
73
+ }
74
+ catch {
75
+ return null;
76
+ }
77
+ try {
78
+ const parsed = JSON.parse(Buffer.from(state, 'base64url').toString());
79
+ if (typeof parsed.accountId !== 'string' ||
80
+ typeof parsed.providerId !== 'string' ||
81
+ typeof parsed.redirectTo !== 'string' ||
82
+ typeof parsed.nonce !== 'string' ||
83
+ typeof parsed.codeVerifier !== 'string' ||
84
+ !parsed.accountId ||
85
+ !parsed.providerId ||
86
+ !parsed.nonce ||
87
+ !parsed.codeVerifier) {
88
+ return null;
89
+ }
90
+ return {
91
+ accountId: parsed.accountId,
92
+ providerId: parsed.providerId,
93
+ redirectTo: parsed.redirectTo,
94
+ nonce: parsed.nonce,
95
+ codeVerifier: parsed.codeVerifier,
96
+ };
97
+ }
98
+ catch {
99
+ return null;
100
+ }
101
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@revealui/auth",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Database-backed session auth for Hono and Next.js — bcrypt, OAuth, brute-force protection, rate limiting, password reset. Ships with RevealUI.",
5
5
  "keywords": [
6
6
  "auth",
@@ -10,15 +10,17 @@
10
10
  ],
11
11
  "license": "MIT",
12
12
  "dependencies": {
13
+ "@node-saml/node-saml": "5.1.0",
13
14
  "@simplewebauthn/server": "^13.3.2",
14
15
  "bcryptjs": "^3.0.3",
15
16
  "drizzle-orm": "^0.45.2",
17
+ "jose": "^5.10.0",
16
18
  "zod": "^4.4.3",
17
19
  "@revealui/config": "0.6.0",
18
- "@revealui/contracts": "0.8.1",
19
- "@revealui/core": "0.12.2",
20
+ "@revealui/contracts": "0.8.2",
21
+ "@revealui/core": "0.12.4",
20
22
  "@revealui/db": "0.10.0",
21
- "@revealui/security": "0.6.0"
23
+ "@revealui/security": "0.6.1"
22
24
  },
23
25
  "devDependencies": {
24
26
  "@simplewebauthn/browser": "^13.3.0",
@@ -27,9 +29,11 @@
27
29
  "@types/react": "^19.2.17",
28
30
  "@vitest/coverage-v8": "^4.1.10",
29
31
  "happy-dom": "^20.10.6",
30
- "react": "^19.2.7",
32
+ "react": "19.2.8",
33
+ "selfsigned": "2.4.1",
31
34
  "typescript": "^6.0.3",
32
35
  "vitest": "^4.1.10",
36
+ "xml-crypto": "6.1.2",
33
37
  "@revealui/dev": "0.1.0"
34
38
  },
35
39
  "engines": {