@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,42 @@
1
+ /**
2
+ * Platform role plane (users.role + _json.roles) — durable bounds.
3
+ *
4
+ * Orthogonal to billing:
5
+ * - account_memberships.role (owner|admin|member) — workspace seat
6
+ * - account_entitlements.tier — paid features
7
+ *
8
+ * Super-admin is platform operator only (lives in _json.roles, never as
9
+ * the sole meaning of "first user of a customer account").
10
+ *
11
+ * Account membership owner of a hosted workspace gets CMS shell access
12
+ * via users.role = admin so proxy isAdminRole + /settings (API keys) work.
13
+ * That is NOT super-admin.
14
+ */
15
+ import type { Database } from '@revealui/db/client';
16
+ /** DB column values that already pass isAdminRole / shell admin cookie gate. */
17
+ export declare const PLATFORM_SHELL_ADMIN_ROLES: ReadonlySet<string>;
18
+ /**
19
+ * Cookie/proxy admin set (isAdminRole). Includes super-admin for cookie
20
+ * compatibility; super-admin must not be written to users.role (CHECK forbids it).
21
+ */
22
+ export declare const PLATFORM_SHELL_ADMIN_COOKIE_ROLES: ReadonlySet<string>;
23
+ /**
24
+ * When a user becomes (or is) billing account owner, ensure they can use
25
+ * workspace admin surfaces. Promote non-admin DB roles to `admin`.
26
+ * Never demote owner/admin. Never assign super-admin.
27
+ */
28
+ export declare function platformRoleForAccountOwner(currentRole: string): string;
29
+ export declare function isPlatformShellAdminRole(role: string | null | undefined): boolean;
30
+ /**
31
+ * Promote users.role to admin when the user is (or becomes) an account
32
+ * membership owner and currently lacks shell admin. Idempotent; never demotes.
33
+ *
34
+ * Call sites: personal-account provision at signup, Stripe ensureHostedAccount,
35
+ * grant-entitlement --create-account, OAuth hosted provision.
36
+ */
37
+ export declare function ensureAccountOwnerPlatformAdmin(db: Database, userId: string): Promise<{
38
+ previousRole: string;
39
+ nextRole: string;
40
+ updated: boolean;
41
+ } | null>;
42
+ //# sourceMappingURL=platform-roles.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"platform-roles.d.ts","sourceRoot":"","sources":["../../src/server/platform-roles.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,qBAAqB,CAAC;AAIpD,gFAAgF;AAChF,eAAO,MAAM,0BAA0B,EAAE,WAAW,CAAC,MAAM,CAA+B,CAAC;AAE3F;;;GAGG;AACH,eAAO,MAAM,iCAAiC,EAAE,WAAW,CAAC,MAAM,CAIhE,CAAC;AAEH;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,WAAW,EAAE,MAAM,GAAG,MAAM,CAKvE;AAED,wBAAgB,wBAAwB,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,GAAG,SAAS,GAAG,OAAO,CAEjF;AAED;;;;;;GAMG;AACH,wBAAsB,+BAA+B,CACnD,EAAE,EAAE,QAAQ,EACZ,MAAM,EAAE,MAAM,GACb,OAAO,CAAC;IAAE,YAAY,EAAE,MAAM,CAAC;IAAC,QAAQ,EAAE,MAAM,CAAC;IAAC,OAAO,EAAE,OAAO,CAAA;CAAE,GAAG,IAAI,CAAC,CAmB9E"}
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Platform role plane (users.role + _json.roles) — durable bounds.
3
+ *
4
+ * Orthogonal to billing:
5
+ * - account_memberships.role (owner|admin|member) — workspace seat
6
+ * - account_entitlements.tier — paid features
7
+ *
8
+ * Super-admin is platform operator only (lives in _json.roles, never as
9
+ * the sole meaning of "first user of a customer account").
10
+ *
11
+ * Account membership owner of a hosted workspace gets CMS shell access
12
+ * via users.role = admin so proxy isAdminRole + /settings (API keys) work.
13
+ * That is NOT super-admin.
14
+ */
15
+ import { users } from '@revealui/db/schema';
16
+ import { eq } from 'drizzle-orm';
17
+ /** DB column values that already pass isAdminRole / shell admin cookie gate. */
18
+ export const PLATFORM_SHELL_ADMIN_ROLES = new Set(['owner', 'admin']);
19
+ /**
20
+ * Cookie/proxy admin set (isAdminRole). Includes super-admin for cookie
21
+ * compatibility; super-admin must not be written to users.role (CHECK forbids it).
22
+ */
23
+ export const PLATFORM_SHELL_ADMIN_COOKIE_ROLES = new Set([
24
+ 'owner',
25
+ 'admin',
26
+ 'super-admin',
27
+ ]);
28
+ /**
29
+ * When a user becomes (or is) billing account owner, ensure they can use
30
+ * workspace admin surfaces. Promote non-admin DB roles to `admin`.
31
+ * Never demote owner/admin. Never assign super-admin.
32
+ */
33
+ export function platformRoleForAccountOwner(currentRole) {
34
+ if (PLATFORM_SHELL_ADMIN_ROLES.has(currentRole)) {
35
+ return currentRole;
36
+ }
37
+ return 'admin';
38
+ }
39
+ export function isPlatformShellAdminRole(role) {
40
+ return role != null && PLATFORM_SHELL_ADMIN_COOKIE_ROLES.has(role);
41
+ }
42
+ /**
43
+ * Promote users.role to admin when the user is (or becomes) an account
44
+ * membership owner and currently lacks shell admin. Idempotent; never demotes.
45
+ *
46
+ * Call sites: personal-account provision at signup, Stripe ensureHostedAccount,
47
+ * grant-entitlement --create-account, OAuth hosted provision.
48
+ */
49
+ export async function ensureAccountOwnerPlatformAdmin(db, userId) {
50
+ const [row] = await db
51
+ .select({ id: users.id, role: users.role })
52
+ .from(users)
53
+ .where(eq(users.id, userId))
54
+ .limit(1);
55
+ if (!row) {
56
+ return null;
57
+ }
58
+ const previousRole = row.role;
59
+ const nextRole = platformRoleForAccountOwner(previousRole);
60
+ if (nextRole === previousRole) {
61
+ return { previousRole, nextRole, updated: false };
62
+ }
63
+ await db.update(users).set({ role: nextRole }).where(eq(users.id, userId));
64
+ return { previousRole, nextRole, updated: true };
65
+ }
@@ -129,7 +129,7 @@ export async function getSession(headers, requestContext) {
129
129
  if (requestContext) {
130
130
  const bindingError = validateSessionBinding(session, requestContext);
131
131
  if (bindingError) {
132
- logger.warn('Session binding violation - invalidating session', {
132
+ logger.error('Session binding violation - invalidating session', {
133
133
  sessionId: session.id,
134
134
  reason: bindingError,
135
135
  });
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Minimal mock OIDC IdP for GAP-464 integration tests.
3
+ * Real RSA keys + jose-signed id_tokens; discovery/token/jwks via fetchImpl.
4
+ */
5
+ import { type KeyLike } from 'jose';
6
+ export interface MockOidcIdpOptions {
7
+ issuer?: string;
8
+ clientId?: string;
9
+ clientSecret?: string;
10
+ }
11
+ export interface MockOidcIdp {
12
+ issuer: string;
13
+ clientId: string;
14
+ clientSecret: string;
15
+ discoveryUrl: string;
16
+ authorizationEndpoint: string;
17
+ tokenEndpoint: string;
18
+ jwksUri: string;
19
+ /** Local KeyLike for direct validateOidcIdToken tests */
20
+ publicKey: KeyLike;
21
+ signIdToken: (claims?: Record<string, unknown>, overrides?: {
22
+ issuer?: string;
23
+ audience?: string;
24
+ expSeconds?: number;
25
+ privateKey?: KeyLike;
26
+ }) => Promise<string>;
27
+ /** fetchImpl that routes discovery, jwks, and token endpoints */
28
+ fetchImpl: typeof fetch;
29
+ }
30
+ export declare function createMockOidcIdp(options?: MockOidcIdpOptions): Promise<MockOidcIdp>;
31
+ //# sourceMappingURL=mock-oidc-idp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mock-oidc-idp.d.ts","sourceRoot":"","sources":["../../../../../src/server/sso/__tests__/helpers/mock-oidc-idp.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAGH,OAAO,EAA2C,KAAK,OAAO,EAAW,MAAM,MAAM,CAAC;AAEtF,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAED,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,MAAM,CAAC;IACf,QAAQ,EAAE,MAAM,CAAC;IACjB,YAAY,EAAE,MAAM,CAAC;IACrB,YAAY,EAAE,MAAM,CAAC;IACrB,qBAAqB,EAAE,MAAM,CAAC;IAC9B,aAAa,EAAE,MAAM,CAAC;IACtB,OAAO,EAAE,MAAM,CAAC;IAChB,yDAAyD;IACzD,SAAS,EAAE,OAAO,CAAC;IACnB,WAAW,EAAE,CACX,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAChC,SAAS,CAAC,EAAE;QACV,MAAM,CAAC,EAAE,MAAM,CAAC;QAChB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,UAAU,CAAC,EAAE,OAAO,CAAC;KACtB,KACE,OAAO,CAAC,MAAM,CAAC,CAAC;IACrB,iEAAiE;IACjE,SAAS,EAAE,OAAO,KAAK,CAAC;CACzB;AAED,wBAAsB,iBAAiB,CAAC,OAAO,GAAE,kBAAuB,GAAG,OAAO,CAAC,WAAW,CAAC,CAwI9F"}
@@ -0,0 +1,115 @@
1
+ /**
2
+ * Minimal mock OIDC IdP for GAP-464 integration tests.
3
+ * Real RSA keys + jose-signed id_tokens; discovery/token/jwks via fetchImpl.
4
+ */
5
+ import { generateKeyPairSync } from 'node:crypto';
6
+ import { exportJWK, importPKCS8, SignJWT } from 'jose';
7
+ export async function createMockOidcIdp(options = {}) {
8
+ const issuer = options.issuer ?? 'https://mock-idp.example.com';
9
+ const clientId = options.clientId ?? 'revealui-sso-client';
10
+ const clientSecret = options.clientSecret ?? 'mock-client-secret';
11
+ const { publicKey: pubPem, privateKey: privPem } = generateKeyPairSync('rsa', {
12
+ modulusLength: 2048,
13
+ publicKeyEncoding: { type: 'spki', format: 'pem' },
14
+ privateKeyEncoding: { type: 'pkcs8', format: 'pem' },
15
+ });
16
+ const privateKey = await importPKCS8(privPem, 'RS256');
17
+ const { createPublicKey } = await import('node:crypto');
18
+ const pub = createPublicKey(pubPem);
19
+ const jwk = await exportJWK(pub);
20
+ jwk.alg = 'RS256';
21
+ jwk.use = 'sig';
22
+ jwk.kid = 'mock-idp-1';
23
+ const publicKey = (await (await import('jose')).importJWK({ ...jwk, alg: 'RS256' }, 'RS256'));
24
+ const discoveryUrl = `${issuer}/.well-known/openid-configuration`;
25
+ const authorizationEndpoint = `${issuer}/authorize`;
26
+ const tokenEndpoint = `${issuer}/token`;
27
+ const jwksUri = `${issuer}/jwks`;
28
+ async function signIdToken(claims = {}, overrides) {
29
+ const now = Math.floor(Date.now() / 1000);
30
+ const exp = overrides?.expSeconds ?? now + 3600;
31
+ const sub = typeof claims.sub === 'string' ? claims.sub : 'user-sub-1';
32
+ const payload = { ...claims, sub };
33
+ return new SignJWT(payload)
34
+ .setProtectedHeader({ alg: 'RS256', kid: 'mock-idp-1' })
35
+ .setIssuer(overrides?.issuer ?? issuer)
36
+ .setAudience(overrides?.audience ?? clientId)
37
+ .setIssuedAt(now)
38
+ .setExpirationTime(exp)
39
+ .setSubject(sub)
40
+ .sign(overrides?.privateKey ?? privateKey);
41
+ }
42
+ const discoveryDoc = {
43
+ issuer,
44
+ authorization_endpoint: authorizationEndpoint,
45
+ token_endpoint: tokenEndpoint,
46
+ jwks_uri: jwksUri,
47
+ response_types_supported: ['code'],
48
+ code_challenge_methods_supported: ['S256'],
49
+ scopes_supported: ['openid', 'email', 'profile'],
50
+ };
51
+ const jwksDoc = { keys: [jwk] };
52
+ const fetchImpl = (async (input, init) => {
53
+ const url = typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url;
54
+ if (url === discoveryUrl || url.startsWith(`${discoveryUrl}?`)) {
55
+ return new Response(JSON.stringify(discoveryDoc), {
56
+ status: 200,
57
+ headers: { 'Content-Type': 'application/json' },
58
+ });
59
+ }
60
+ if (url === jwksUri || url.startsWith(`${jwksUri}?`)) {
61
+ return new Response(JSON.stringify(jwksDoc), {
62
+ status: 200,
63
+ headers: { 'Content-Type': 'application/json' },
64
+ });
65
+ }
66
+ if (url === tokenEndpoint || url.startsWith(`${tokenEndpoint}?`)) {
67
+ if ((init?.method ?? 'GET').toUpperCase() !== 'POST') {
68
+ return new Response('method not allowed', { status: 405 });
69
+ }
70
+ // undici may pass URLSearchParams; avoid cross-realm instanceof
71
+ const rawBody = init?.body;
72
+ const bodyText = rawBody == null
73
+ ? ''
74
+ : typeof rawBody === 'string'
75
+ ? rawBody
76
+ : typeof rawBody.toString === 'function'
77
+ ? rawBody.toString()
78
+ : '';
79
+ const params = new URLSearchParams(bodyText);
80
+ // Accept any non-empty code; mock IdP does not track issued codes
81
+ if (!params.get('code') || params.get('grant_type') !== 'authorization_code') {
82
+ return new Response(JSON.stringify({ error: 'invalid_request', body: bodyText }), {
83
+ status: 400,
84
+ });
85
+ }
86
+ if (params.get('client_id') !== clientId || params.get('client_secret') !== clientSecret) {
87
+ return new Response(JSON.stringify({ error: 'invalid_client' }), { status: 401 });
88
+ }
89
+ const idToken = await signIdToken({
90
+ email: 'alice@example.com',
91
+ email_verified: true,
92
+ name: 'Alice Example',
93
+ groups: ['Engineering', 'Staff'],
94
+ });
95
+ return new Response(JSON.stringify({
96
+ id_token: idToken,
97
+ access_token: 'mock-access',
98
+ token_type: 'Bearer',
99
+ }), { status: 200, headers: { 'Content-Type': 'application/json' } });
100
+ }
101
+ return new Response(`not found: ${url}`, { status: 404 });
102
+ });
103
+ return {
104
+ issuer,
105
+ clientId,
106
+ clientSecret,
107
+ discoveryUrl,
108
+ authorizationEndpoint,
109
+ tokenEndpoint,
110
+ jwksUri,
111
+ publicKey,
112
+ signIdToken,
113
+ fetchImpl,
114
+ };
115
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * Minimal mock SAML IdP for GAP-464 integration tests.
3
+ * Self-signed X509 + xml-crypto enveloped signatures on Response + Assertion.
4
+ */
5
+ export interface MockSamlIdp {
6
+ entityId: string;
7
+ ssoUrl: string;
8
+ certPem: string;
9
+ privateKeyPem: string;
10
+ /** Build IdP metadata XML consumed by parseIdpMetadataXml */
11
+ metadataXml: string;
12
+ /**
13
+ * Build a base64 SAMLResponse (HTTP-POST) signed by this IdP.
14
+ * When sign is false, returns an unsigned Response for reject tests.
15
+ */
16
+ buildPostResponse: (input: {
17
+ spEntityId: string;
18
+ acsUrl: string;
19
+ nameId: string;
20
+ groups?: string[];
21
+ sign?: boolean;
22
+ notOnOrAfterOffsetMs?: number;
23
+ }) => string;
24
+ }
25
+ export declare function createMockSamlIdp(options?: {
26
+ entityId?: string;
27
+ }): MockSamlIdp;
28
+ //# sourceMappingURL=mock-saml-idp.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mock-saml-idp.d.ts","sourceRoot":"","sources":["../../../../../src/server/sso/__tests__/helpers/mock-saml-idp.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAMH,MAAM,WAAW,WAAW;IAC1B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,OAAO,EAAE,MAAM,CAAC;IAChB,aAAa,EAAE,MAAM,CAAC;IACtB,6DAA6D;IAC7D,WAAW,EAAE,MAAM,CAAC;IACpB;;;OAGG;IACH,iBAAiB,EAAE,CAAC,KAAK,EAAE;QACzB,UAAU,EAAE,MAAM,CAAC;QACnB,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,EAAE,MAAM,CAAC;QACf,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;QAClB,IAAI,CAAC,EAAE,OAAO,CAAC;QACf,oBAAoB,CAAC,EAAE,MAAM,CAAC;KAC/B,KAAK,MAAM,CAAC;CACd;AAsBD,wBAAgB,iBAAiB,CAAC,OAAO,CAAC,EAAE;IAAE,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,WAAW,CA2J9E"}
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Minimal mock SAML IdP for GAP-464 integration tests.
3
+ * Self-signed X509 + xml-crypto enveloped signatures on Response + Assertion.
4
+ */
5
+ import { randomBytes } from 'node:crypto';
6
+ import selfsigned from 'selfsigned';
7
+ import { SignedXml } from 'xml-crypto';
8
+ function stripPemHeaders(pem) {
9
+ const lines = pem.split('\n');
10
+ const body = [];
11
+ for (const line of lines) {
12
+ const t = line.trim();
13
+ if (!t || t.startsWith('-----'))
14
+ continue;
15
+ body.push(t);
16
+ }
17
+ return body.join('');
18
+ }
19
+ function escapeXml(value) {
20
+ return value
21
+ .replaceAll('&', '&amp;')
22
+ .replaceAll('<', '&lt;')
23
+ .replaceAll('>', '&gt;')
24
+ .replaceAll('"', '&quot;')
25
+ .replaceAll("'", '&apos;');
26
+ }
27
+ export function createMockSamlIdp(options) {
28
+ const entityId = options?.entityId ?? 'https://saml-idp.example.com';
29
+ const ssoUrl = `${entityId}/sso`;
30
+ const attrs = [{ name: 'commonName', value: 'mock-saml-idp' }];
31
+ const pems = selfsigned.generate(attrs, {
32
+ keySize: 2048,
33
+ days: 1,
34
+ algorithm: 'sha256',
35
+ });
36
+ const idpPrivateKeyPem = pems.private;
37
+ const certPem = pems.cert;
38
+ const certBody = stripPemHeaders(certPem);
39
+ const metadataXml = `<?xml version="1.0"?>
40
+ <EntityDescriptor entityID="${escapeXml(entityId)}" xmlns="urn:oasis:names:tc:SAML:2.0:metadata">
41
+ <IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
42
+ <KeyDescriptor use="signing">
43
+ <KeyInfo xmlns="http://www.w3.org/2000/09/xmldsig#">
44
+ <X509Data>
45
+ <X509Certificate>${certBody}</X509Certificate>
46
+ </X509Data>
47
+ </KeyInfo>
48
+ </KeyDescriptor>
49
+ <SingleSignOnService
50
+ Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
51
+ Location="${escapeXml(ssoUrl)}" />
52
+ </IDPSSODescriptor>
53
+ </EntityDescriptor>`;
54
+ function buildUnsignedAssertion(input) {
55
+ const groupAttrs = input.groups
56
+ .map((g) => `<saml:AttributeValue xmlns:xs="http://www.w3.org/2001/XMLSchema" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:type="xs:string">${escapeXml(g)}</saml:AttributeValue>`)
57
+ .join('');
58
+ return `<saml:Assertion xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="${input.assertionId}" IssueInstant="${input.issueInstant}">
59
+ <saml:Issuer>${escapeXml(entityId)}</saml:Issuer>
60
+ <saml:Subject>
61
+ <saml:NameID Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress">${escapeXml(input.nameId)}</saml:NameID>
62
+ <saml:SubjectConfirmation Method="urn:oasis:names:tc:SAML:2.0:cm:bearer">
63
+ <saml:SubjectConfirmationData NotOnOrAfter="${input.notOnOrAfter}" Recipient="${escapeXml(input.acsUrl)}" />
64
+ </saml:SubjectConfirmation>
65
+ </saml:Subject>
66
+ <saml:Conditions NotBefore="${input.issueInstant}" NotOnOrAfter="${input.notOnOrAfter}">
67
+ <saml:AudienceRestriction>
68
+ <saml:Audience>${escapeXml(input.spEntityId)}</saml:Audience>
69
+ </saml:AudienceRestriction>
70
+ </saml:Conditions>
71
+ <saml:AuthnStatement AuthnInstant="${input.issueInstant}" SessionIndex="_session1">
72
+ <saml:AuthnContext>
73
+ <saml:AuthnContextClassRef>urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport</saml:AuthnContextClassRef>
74
+ </saml:AuthnContext>
75
+ </saml:AuthnStatement>
76
+ <saml:AttributeStatement>
77
+ <saml:Attribute Name="email">
78
+ <saml:AttributeValue>${escapeXml(input.nameId)}</saml:AttributeValue>
79
+ </saml:Attribute>
80
+ <saml:Attribute Name="groups">${groupAttrs}</saml:Attribute>
81
+ </saml:AttributeStatement>
82
+ </saml:Assertion>`;
83
+ }
84
+ function signXmlEnveloped(xml, idAttr) {
85
+ // Place Signature immediately after Issuer (valid for both Assertion and Response)
86
+ const issuerPath = "/*[local-name()='Assertion' or local-name()='Response']/*[local-name()='Issuer']";
87
+ const refPath = `//*[@ID='${idAttr}']`;
88
+ const sig = new SignedXml({
89
+ privateKey: idpPrivateKeyPem,
90
+ publicCert: certPem,
91
+ });
92
+ sig.addReference({
93
+ xpath: refPath,
94
+ transforms: [
95
+ 'http://www.w3.org/2000/09/xmldsig#enveloped-signature',
96
+ 'http://www.w3.org/2001/10/xml-exc-c14n#',
97
+ ],
98
+ digestAlgorithm: 'http://www.w3.org/2001/04/xmlenc#sha256',
99
+ });
100
+ sig.signatureAlgorithm = 'http://www.w3.org/2001/04/xmldsig-more#rsa-sha256';
101
+ sig.canonicalizationAlgorithm = 'http://www.w3.org/2001/10/xml-exc-c14n#';
102
+ sig.computeSignature(xml, {
103
+ location: { reference: issuerPath, action: 'after' },
104
+ prefix: 'ds',
105
+ });
106
+ return sig.getSignedXml();
107
+ }
108
+ function buildPostResponse(input) {
109
+ const shouldSign = input.sign !== false;
110
+ const now = new Date();
111
+ const issueInstant = now.toISOString().replace(/\.\d{3}Z$/, 'Z');
112
+ const notOnOrAfter = new Date(now.getTime() + (input.notOnOrAfterOffsetMs ?? 5 * 60 * 1000))
113
+ .toISOString()
114
+ .replace(/\.\d{3}Z$/, 'Z');
115
+ const responseId = `_resp_${randomBytes(8).toString('hex')}`;
116
+ const assertionId = `_assert_${randomBytes(8).toString('hex')}`;
117
+ const groups = input.groups ?? ['Engineering'];
118
+ let assertion = buildUnsignedAssertion({
119
+ spEntityId: input.spEntityId,
120
+ acsUrl: input.acsUrl,
121
+ nameId: input.nameId,
122
+ groups,
123
+ issueInstant,
124
+ notOnOrAfter,
125
+ assertionId,
126
+ });
127
+ if (shouldSign) {
128
+ assertion = signXmlEnveloped(assertion, assertionId);
129
+ }
130
+ let response = `<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" Version="2.0" ID="${responseId}" IssueInstant="${issueInstant}" Destination="${escapeXml(input.acsUrl)}">
131
+ <saml:Issuer>${escapeXml(entityId)}</saml:Issuer>
132
+ <samlp:Status>
133
+ <samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/>
134
+ </samlp:Status>
135
+ ${assertion}
136
+ </samlp:Response>`;
137
+ if (shouldSign) {
138
+ response = signXmlEnveloped(response, responseId);
139
+ }
140
+ return Buffer.from(response, 'utf8').toString('base64');
141
+ }
142
+ return {
143
+ entityId,
144
+ ssoUrl,
145
+ certPem,
146
+ privateKeyPem: idpPrivateKeyPem,
147
+ metadataXml,
148
+ buildPostResponse,
149
+ };
150
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Enterprise SSO pure layer (GAP-464).
3
+ *
4
+ * OIDC discovery + id_token validation, code exchange, SAML SP helpers,
5
+ * signed SSO state, group→role mapping, JIT user upsert. HTTP routes +
6
+ * account entitlement gate live in apps/server.
7
+ */
8
+ export { normalizeSsoUserRole, type UpsertSsoUserInput, upsertSsoUser, } from './jit.js';
9
+ export { type BuildOidcAuthorizationUrlInput, buildOidcAuthorizationUrl, createOidcRemoteJwkSet, type ExchangeOidcCodeFailureReason, type ExchangeOidcCodeInput, type ExchangeOidcCodeResult, type ExchangeOidcCodeSuccess, exchangeOidcCode, type FetchOidcDiscoveryOptions, type FetchOidcDiscoveryResult, fetchOidcDiscovery, type JWTPayload, type JWTVerifyGetKey, type KeyLike, type OidcDiscoveryDocument, type OidcDiscoveryFailureReason, type ValidatedIdTokenClaims, type ValidateIdTokenFailureReason, type ValidateIdTokenResult, type ValidateOidcIdTokenOptions, validateOidcIdToken, } from './oidc.js';
10
+ export { extractGroupsFromClaim, type MapSsoGroupsFailureReason, type MapSsoGroupsInput, type MapSsoGroupsResult, mapSsoGroupsToRole, } from './roles.js';
11
+ export { type BuildSamlAuthorizeUrlFailureReason, type BuildSamlAuthorizeUrlResult, buildSamlAuthorizeUrl, buildSamlSpMetadata, fetchIdpMetadata, normalizeIdpCertPem, type ParseIdpMetadataFailureReason, type ParseIdpMetadataResult, parseIdpMetadataXml, type SamlSpConfig, type ValidatedSamlAssertion, type ValidateSamlResponseFailureReason, type ValidateSamlResponseResult, validateSamlPostResponse, } from './saml.js';
12
+ export { type GenerateSsoStateInput, type GenerateSsoStateResult, generateSsoState, type SsoStatePayload, type VerifiedSsoState, verifySsoState, } from './state.js';
13
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/server/sso/index.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,EACL,oBAAoB,EACpB,KAAK,kBAAkB,EACvB,aAAa,GACd,MAAM,UAAU,CAAC;AAClB,OAAO,EACL,KAAK,8BAA8B,EACnC,yBAAyB,EACzB,sBAAsB,EACtB,KAAK,6BAA6B,EAClC,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,KAAK,uBAAuB,EAC5B,gBAAgB,EAChB,KAAK,yBAAyB,EAC9B,KAAK,wBAAwB,EAC7B,kBAAkB,EAClB,KAAK,UAAU,EACf,KAAK,eAAe,EACpB,KAAK,OAAO,EACZ,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,EAC3B,KAAK,4BAA4B,EACjC,KAAK,qBAAqB,EAC1B,KAAK,0BAA0B,EAC/B,mBAAmB,GACpB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,sBAAsB,EACtB,KAAK,yBAAyB,EAC9B,KAAK,iBAAiB,EACtB,KAAK,kBAAkB,EACvB,kBAAkB,GACnB,MAAM,YAAY,CAAC;AACpB,OAAO,EACL,KAAK,kCAAkC,EACvC,KAAK,2BAA2B,EAChC,qBAAqB,EACrB,mBAAmB,EACnB,gBAAgB,EAChB,mBAAmB,EACnB,KAAK,6BAA6B,EAClC,KAAK,sBAAsB,EAC3B,mBAAmB,EACnB,KAAK,YAAY,EACjB,KAAK,sBAAsB,EAC3B,KAAK,iCAAiC,EACtC,KAAK,0BAA0B,EAC/B,wBAAwB,GACzB,MAAM,WAAW,CAAC;AACnB,OAAO,EACL,KAAK,qBAAqB,EAC1B,KAAK,sBAAsB,EAC3B,gBAAgB,EAChB,KAAK,eAAe,EACpB,KAAK,gBAAgB,EACrB,cAAc,GACf,MAAM,YAAY,CAAC"}
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Enterprise SSO pure layer (GAP-464).
3
+ *
4
+ * OIDC discovery + id_token validation, code exchange, SAML SP helpers,
5
+ * signed SSO state, group→role mapping, JIT user upsert. HTTP routes +
6
+ * account entitlement gate live in apps/server.
7
+ */
8
+ export { normalizeSsoUserRole, upsertSsoUser, } from './jit.js';
9
+ export { buildOidcAuthorizationUrl, createOidcRemoteJwkSet, exchangeOidcCode, fetchOidcDiscovery, validateOidcIdToken, } from './oidc.js';
10
+ export { extractGroupsFromClaim, mapSsoGroupsToRole, } from './roles.js';
11
+ export { buildSamlAuthorizeUrl, buildSamlSpMetadata, fetchIdpMetadata, normalizeIdpCertPem, parseIdpMetadataXml, validateSamlPostResponse, } from './saml.js';
12
+ export { generateSsoState, verifySsoState, } from './state.js';
@@ -0,0 +1,39 @@
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 type { User } from '../../types.js';
16
+ export interface UpsertSsoUserInput {
17
+ providerId: string;
18
+ /** Provider's account_id — membership is attached here */
19
+ accountId: string;
20
+ /** IdP subject (`sub`) */
21
+ subject: string;
22
+ email?: string;
23
+ /** Only link/create with email when IdP asserts email_verified */
24
+ emailVerified?: boolean;
25
+ name?: string;
26
+ /** Role from mapSsoGroupsToRole (already forbid unmapped admin) */
27
+ role: string;
28
+ }
29
+ /**
30
+ * Map SSO/group roles onto the users table allowlist.
31
+ * `member` (membership vocabulary) → `viewer` on the user row.
32
+ * Unknown roles fail closed to `viewer` (never admin).
33
+ */
34
+ export declare function normalizeSsoUserRole(role: string): string;
35
+ /**
36
+ * Upsert a user from a validated SSO identity and ensure account membership.
37
+ */
38
+ export declare function upsertSsoUser(input: UpsertSsoUserInput): Promise<User>;
39
+ //# sourceMappingURL=jit.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"jit.d.ts","sourceRoot":"","sources":["../../../src/server/sso/jit.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAMH,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,gBAAgB,CAAC;AAK3C,MAAM,WAAW,kBAAkB;IACjC,UAAU,EAAE,MAAM,CAAC;IACnB,0DAA0D;IAC1D,SAAS,EAAE,MAAM,CAAC;IAClB,0BAA0B;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kEAAkE;IAClE,aAAa,CAAC,EAAE,OAAO,CAAC;IACxB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,mEAAmE;IACnE,IAAI,EAAE,MAAM,CAAC;CACd;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAIzD;AAED;;GAEG;AACH,wBAAsB,aAAa,CAAC,KAAK,EAAE,kBAAkB,GAAG,OAAO,CAAC,IAAI,CAAC,CAsG5E"}