@revealui/auth 0.5.0 → 0.5.2

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,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('&', '&')
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"}
@@ -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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"}