@robelest/convex-auth 0.0.4-preview.13 → 0.0.4-preview.15

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 (323) hide show
  1. package/README.md +140 -9
  2. package/dist/bin.cjs +5957 -5478
  3. package/dist/client/index.d.ts +3 -7
  4. package/dist/client/index.d.ts.map +1 -1
  5. package/dist/client/index.js +27 -26
  6. package/dist/client/index.js.map +1 -1
  7. package/dist/component/_generated/api.d.ts +14 -0
  8. package/dist/component/_generated/api.d.ts.map +1 -1
  9. package/dist/component/_generated/api.js.map +1 -1
  10. package/dist/component/_generated/component.d.ts +1513 -3
  11. package/dist/component/_generated/component.d.ts.map +1 -1
  12. package/dist/component/convex.config.d.ts +2 -2
  13. package/dist/component/convex.config.d.ts.map +1 -1
  14. package/dist/component/model.d.ts +153 -0
  15. package/dist/component/model.d.ts.map +1 -0
  16. package/dist/component/model.js +327 -0
  17. package/dist/component/model.js.map +1 -0
  18. package/dist/component/providers/sso.d.ts +1 -1
  19. package/dist/component/public/enterprise.d.ts +49 -0
  20. package/dist/component/public/enterprise.d.ts.map +1 -0
  21. package/dist/component/public/enterprise.js +450 -0
  22. package/dist/component/public/enterprise.js.map +1 -0
  23. package/dist/component/public/factors.d.ts +52 -0
  24. package/dist/component/public/factors.d.ts.map +1 -0
  25. package/dist/component/public/factors.js +285 -0
  26. package/dist/component/public/factors.js.map +1 -0
  27. package/dist/component/public/groups.d.ts +118 -0
  28. package/dist/component/public/groups.d.ts.map +1 -0
  29. package/dist/component/public/groups.js +599 -0
  30. package/dist/component/public/groups.js.map +1 -0
  31. package/dist/component/public/identity.d.ts +93 -0
  32. package/dist/component/public/identity.d.ts.map +1 -0
  33. package/dist/component/public/identity.js +426 -0
  34. package/dist/component/public/identity.js.map +1 -0
  35. package/dist/component/public/keys.d.ts +41 -0
  36. package/dist/component/public/keys.d.ts.map +1 -0
  37. package/dist/component/public/keys.js +157 -0
  38. package/dist/component/public/keys.js.map +1 -0
  39. package/dist/component/public/shared.d.ts +26 -0
  40. package/dist/component/public/shared.d.ts.map +1 -0
  41. package/dist/component/public/shared.js +32 -0
  42. package/dist/component/public/shared.js.map +1 -0
  43. package/dist/component/public.d.ts +9 -321
  44. package/dist/component/public.d.ts.map +1 -1
  45. package/dist/component/public.js +6 -2145
  46. package/dist/component/schema.d.ts +368 -258
  47. package/dist/component/schema.js +23 -27
  48. package/dist/component/schema.js.map +1 -1
  49. package/dist/component/server/auth.d.ts +42 -7
  50. package/dist/component/server/auth.d.ts.map +1 -1
  51. package/dist/component/server/auth.js +70 -6
  52. package/dist/component/server/auth.js.map +1 -1
  53. package/dist/component/server/cookies.js +3 -0
  54. package/dist/component/server/cookies.js.map +1 -1
  55. package/dist/component/server/db.js +1 -0
  56. package/dist/component/server/db.js.map +1 -1
  57. package/dist/component/server/device.js +3 -1
  58. package/dist/component/server/device.js.map +1 -1
  59. package/dist/component/server/domains/core.js +466 -0
  60. package/dist/component/server/domains/core.js.map +1 -0
  61. package/dist/component/server/domains/sso.js +689 -0
  62. package/dist/component/server/domains/sso.js.map +1 -0
  63. package/dist/component/server/factory.d.ts +136 -0
  64. package/dist/component/server/factory.d.ts.map +1 -0
  65. package/dist/component/server/factory.js +1128 -0
  66. package/dist/component/server/factory.js.map +1 -0
  67. package/dist/component/server/fx.js +2 -1
  68. package/dist/component/server/fx.js.map +1 -1
  69. package/dist/component/server/http.js +287 -0
  70. package/dist/component/server/http.js.map +1 -0
  71. package/dist/component/server/identity.js +13 -0
  72. package/dist/component/server/identity.js.map +1 -0
  73. package/dist/component/server/keys.js +4 -0
  74. package/dist/component/server/keys.js.map +1 -1
  75. package/dist/component/server/mutations/account.js +1 -1
  76. package/dist/component/server/mutations/index.js +2 -2
  77. package/dist/component/server/mutations/index.js.map +1 -1
  78. package/dist/component/server/mutations/invalidate.js +1 -1
  79. package/dist/component/server/mutations/oauth.js +10 -7
  80. package/dist/component/server/mutations/oauth.js.map +1 -1
  81. package/dist/component/server/mutations/refresh.js +1 -1
  82. package/dist/component/server/mutations/register.js +1 -1
  83. package/dist/component/server/mutations/retrieve.js +1 -1
  84. package/dist/component/server/mutations/signature.js +1 -1
  85. package/dist/component/server/mutations/store.js +6 -3
  86. package/dist/component/server/mutations/store.js.map +1 -1
  87. package/dist/component/server/mutations/verify.js +1 -1
  88. package/dist/component/server/oauth.js +3 -0
  89. package/dist/component/server/oauth.js.map +1 -1
  90. package/dist/component/server/passkey.js +3 -2
  91. package/dist/component/server/passkey.js.map +1 -1
  92. package/dist/component/server/provider.js +2 -0
  93. package/dist/component/server/provider.js.map +1 -1
  94. package/dist/component/server/providers.js +3 -0
  95. package/dist/component/server/providers.js.map +1 -1
  96. package/dist/component/server/ratelimit.js +3 -0
  97. package/dist/component/server/ratelimit.js.map +1 -1
  98. package/dist/component/server/redirects.js +2 -0
  99. package/dist/component/server/redirects.js.map +1 -1
  100. package/dist/component/server/refresh.js +5 -0
  101. package/dist/component/server/refresh.js.map +1 -1
  102. package/dist/component/server/sessions.js +5 -0
  103. package/dist/component/server/sessions.js.map +1 -1
  104. package/dist/component/server/signin.js +2 -1
  105. package/dist/component/server/signin.js.map +1 -1
  106. package/dist/component/server/sso.js +166 -19
  107. package/dist/component/server/sso.js.map +1 -1
  108. package/dist/component/server/tokens.js +1 -0
  109. package/dist/component/server/tokens.js.map +1 -1
  110. package/dist/component/server/totp.js +4 -2
  111. package/dist/component/server/totp.js.map +1 -1
  112. package/dist/component/server/types.d.ts +50 -35
  113. package/dist/component/server/types.d.ts.map +1 -1
  114. package/dist/component/server/types.js.map +1 -1
  115. package/dist/component/server/users.js +1 -0
  116. package/dist/component/server/users.js.map +1 -1
  117. package/dist/component/server/utils.js +44 -2
  118. package/dist/component/server/utils.js.map +1 -1
  119. package/dist/providers/anonymous.d.ts +1 -1
  120. package/dist/providers/credentials.d.ts +1 -1
  121. package/dist/providers/password.d.ts +1 -1
  122. package/dist/providers/sso.d.ts +1 -1
  123. package/dist/providers/sso.js.map +1 -1
  124. package/dist/server/auth.d.ts +44 -9
  125. package/dist/server/auth.d.ts.map +1 -1
  126. package/dist/server/auth.js +70 -6
  127. package/dist/server/auth.js.map +1 -1
  128. package/dist/server/cookies.d.ts +1 -38
  129. package/dist/server/cookies.js +3 -0
  130. package/dist/server/cookies.js.map +1 -1
  131. package/dist/server/db.d.ts +1 -125
  132. package/dist/server/db.js +1 -0
  133. package/dist/server/db.js.map +1 -1
  134. package/dist/server/device.d.ts +1 -24
  135. package/dist/server/device.js +3 -1
  136. package/dist/server/device.js.map +1 -1
  137. package/dist/server/domains/core.d.ts +320 -0
  138. package/dist/server/domains/core.d.ts.map +1 -0
  139. package/dist/server/domains/core.js +466 -0
  140. package/dist/server/domains/core.js.map +1 -0
  141. package/dist/server/domains/sso.d.ts +340 -0
  142. package/dist/server/domains/sso.d.ts.map +1 -0
  143. package/dist/server/domains/sso.js +689 -0
  144. package/dist/server/domains/sso.js.map +1 -0
  145. package/dist/server/enterpriseValidators.d.ts +1 -0
  146. package/dist/server/enterpriseValidators.js +56 -0
  147. package/dist/server/enterpriseValidators.js.map +1 -0
  148. package/dist/server/factory.d.ts +136 -0
  149. package/dist/server/factory.d.ts.map +1 -0
  150. package/dist/server/factory.js +1128 -0
  151. package/dist/server/factory.js.map +1 -0
  152. package/dist/server/fx.d.ts +1 -16
  153. package/dist/server/fx.d.ts.map +1 -1
  154. package/dist/server/fx.js +1 -0
  155. package/dist/server/fx.js.map +1 -1
  156. package/dist/server/http.d.ts +59 -0
  157. package/dist/server/http.d.ts.map +1 -0
  158. package/dist/server/http.js +287 -0
  159. package/dist/server/http.js.map +1 -0
  160. package/dist/server/identity.d.ts +1 -0
  161. package/dist/server/identity.js +13 -0
  162. package/dist/server/identity.js.map +1 -0
  163. package/dist/server/index.d.ts +432 -1
  164. package/dist/server/index.d.ts.map +1 -1
  165. package/dist/server/index.js +486 -36
  166. package/dist/server/index.js.map +1 -1
  167. package/dist/server/keys.d.ts +1 -57
  168. package/dist/server/keys.js +4 -0
  169. package/dist/server/keys.js.map +1 -1
  170. package/dist/server/mutations/account.d.ts +7 -7
  171. package/dist/server/mutations/account.d.ts.map +1 -1
  172. package/dist/server/mutations/code.d.ts +13 -13
  173. package/dist/server/mutations/index.d.ts +107 -107
  174. package/dist/server/mutations/index.d.ts.map +1 -1
  175. package/dist/server/mutations/index.js +1 -1
  176. package/dist/server/mutations/index.js.map +1 -1
  177. package/dist/server/mutations/invalidate.d.ts +5 -5
  178. package/dist/server/mutations/oauth.d.ts +10 -10
  179. package/dist/server/mutations/oauth.d.ts.map +1 -1
  180. package/dist/server/mutations/oauth.js +9 -6
  181. package/dist/server/mutations/oauth.js.map +1 -1
  182. package/dist/server/mutations/refresh.d.ts +4 -4
  183. package/dist/server/mutations/register.d.ts +12 -12
  184. package/dist/server/mutations/register.d.ts.map +1 -1
  185. package/dist/server/mutations/retrieve.d.ts +1 -1
  186. package/dist/server/mutations/signature.d.ts +5 -5
  187. package/dist/server/mutations/signature.d.ts.map +1 -1
  188. package/dist/server/mutations/signin.d.ts +1 -1
  189. package/dist/server/mutations/signout.d.ts +1 -1
  190. package/dist/server/mutations/store.d.ts +3 -2
  191. package/dist/server/mutations/store.d.ts.map +1 -1
  192. package/dist/server/mutations/store.js +6 -3
  193. package/dist/server/mutations/store.js.map +1 -1
  194. package/dist/server/mutations/verifier.d.ts +1 -1
  195. package/dist/server/mutations/verify.d.ts +4 -4
  196. package/dist/server/oauth.d.ts +1 -59
  197. package/dist/server/oauth.js +3 -0
  198. package/dist/server/oauth.js.map +1 -1
  199. package/dist/server/passkey.d.ts.map +1 -1
  200. package/dist/server/passkey.js +3 -2
  201. package/dist/server/passkey.js.map +1 -1
  202. package/dist/server/provider.d.ts +1 -14
  203. package/dist/server/provider.d.ts.map +1 -1
  204. package/dist/server/provider.js +2 -0
  205. package/dist/server/provider.js.map +1 -1
  206. package/dist/server/providers.js +3 -0
  207. package/dist/server/providers.js.map +1 -1
  208. package/dist/server/ratelimit.d.ts +1 -22
  209. package/dist/server/ratelimit.js +3 -0
  210. package/dist/server/ratelimit.js.map +1 -1
  211. package/dist/server/redirects.d.ts +1 -10
  212. package/dist/server/redirects.js +2 -0
  213. package/dist/server/redirects.js.map +1 -1
  214. package/dist/server/refresh.d.ts +1 -37
  215. package/dist/server/refresh.js +5 -0
  216. package/dist/server/refresh.js.map +1 -1
  217. package/dist/server/sessions.d.ts +1 -28
  218. package/dist/server/sessions.js +5 -0
  219. package/dist/server/sessions.js.map +1 -1
  220. package/dist/server/signin.d.ts +1 -55
  221. package/dist/server/signin.js +2 -1
  222. package/dist/server/signin.js.map +1 -1
  223. package/dist/server/sso.d.ts +1 -348
  224. package/dist/server/sso.js +165 -18
  225. package/dist/server/sso.js.map +1 -1
  226. package/dist/server/templates.d.ts +1 -21
  227. package/dist/server/templates.js +1 -0
  228. package/dist/server/templates.js.map +1 -1
  229. package/dist/server/tokens.d.ts +1 -11
  230. package/dist/server/tokens.js +1 -0
  231. package/dist/server/tokens.js.map +1 -1
  232. package/dist/server/totp.d.ts +1 -23
  233. package/dist/server/totp.js +4 -2
  234. package/dist/server/totp.js.map +1 -1
  235. package/dist/server/types.d.ts +55 -71
  236. package/dist/server/types.d.ts.map +1 -1
  237. package/dist/server/types.js.map +1 -1
  238. package/dist/server/users.d.ts +1 -31
  239. package/dist/server/users.js +1 -0
  240. package/dist/server/users.js.map +1 -1
  241. package/dist/server/utils.d.ts +1 -27
  242. package/dist/server/utils.js +44 -2
  243. package/dist/server/utils.js.map +1 -1
  244. package/dist/server/version.d.ts +1 -1
  245. package/dist/server/version.js +1 -1
  246. package/dist/server/version.js.map +1 -1
  247. package/package.json +4 -5
  248. package/src/cli/bin.ts +5 -0
  249. package/src/cli/index.ts +22 -9
  250. package/src/cli/keys.ts +3 -0
  251. package/src/client/index.ts +36 -37
  252. package/src/component/_generated/api.ts +14 -0
  253. package/src/component/_generated/component.ts +1920 -3
  254. package/src/component/index.ts +2 -0
  255. package/src/component/model.ts +424 -0
  256. package/src/component/public/enterprise.ts +654 -0
  257. package/src/component/public/factors.ts +332 -0
  258. package/src/component/public/groups.ts +951 -0
  259. package/src/component/public/identity.ts +566 -0
  260. package/src/component/public/keys.ts +209 -0
  261. package/src/component/public/shared.ts +117 -0
  262. package/src/component/public.ts +5 -2965
  263. package/src/component/schema.ts +47 -57
  264. package/src/providers/sso.ts +1 -1
  265. package/src/server/auth.ts +192 -9
  266. package/src/server/cookies.ts +3 -0
  267. package/src/server/db.ts +3 -0
  268. package/src/server/device.ts +3 -1
  269. package/src/server/domains/core.ts +916 -0
  270. package/src/server/domains/sso.ts +1462 -0
  271. package/src/server/enterpriseValidators.ts +88 -0
  272. package/src/server/factory.ts +2168 -0
  273. package/src/server/fx.ts +1 -0
  274. package/src/server/http.ts +529 -0
  275. package/src/server/identity.ts +18 -0
  276. package/src/server/index.ts +712 -40
  277. package/src/server/keys.ts +4 -0
  278. package/src/server/mutations/index.ts +1 -1
  279. package/src/server/mutations/oauth.ts +36 -8
  280. package/src/server/mutations/store.ts +6 -3
  281. package/src/server/oauth.ts +6 -0
  282. package/src/server/passkey.ts +3 -2
  283. package/src/server/provider.ts +2 -0
  284. package/src/server/providers.ts +3 -0
  285. package/src/server/ratelimit.ts +3 -0
  286. package/src/server/redirects.ts +2 -0
  287. package/src/server/refresh.ts +5 -0
  288. package/src/server/sessions.ts +5 -0
  289. package/src/server/signin.ts +1 -0
  290. package/src/server/sso.ts +251 -17
  291. package/src/server/templates.ts +1 -0
  292. package/src/server/tokens.ts +1 -0
  293. package/src/server/totp.ts +4 -2
  294. package/src/server/types.ts +85 -77
  295. package/src/server/users.ts +1 -0
  296. package/src/server/utils.ts +71 -1
  297. package/src/server/version.ts +1 -1
  298. package/dist/component/public.js.map +0 -1
  299. package/dist/component/server/implementation.d.ts +0 -1264
  300. package/dist/component/server/implementation.d.ts.map +0 -1
  301. package/dist/component/server/implementation.js +0 -2365
  302. package/dist/component/server/implementation.js.map +0 -1
  303. package/dist/server/cookies.d.ts.map +0 -1
  304. package/dist/server/db.d.ts.map +0 -1
  305. package/dist/server/device.d.ts.map +0 -1
  306. package/dist/server/implementation.d.ts +0 -1264
  307. package/dist/server/implementation.d.ts.map +0 -1
  308. package/dist/server/implementation.js +0 -2365
  309. package/dist/server/implementation.js.map +0 -1
  310. package/dist/server/keys.d.ts.map +0 -1
  311. package/dist/server/oauth.d.ts.map +0 -1
  312. package/dist/server/ratelimit.d.ts.map +0 -1
  313. package/dist/server/redirects.d.ts.map +0 -1
  314. package/dist/server/refresh.d.ts.map +0 -1
  315. package/dist/server/sessions.d.ts.map +0 -1
  316. package/dist/server/signin.d.ts.map +0 -1
  317. package/dist/server/sso.d.ts.map +0 -1
  318. package/dist/server/templates.d.ts.map +0 -1
  319. package/dist/server/tokens.d.ts.map +0 -1
  320. package/dist/server/totp.d.ts.map +0 -1
  321. package/dist/server/users.d.ts.map +0 -1
  322. package/dist/server/utils.d.ts.map +0 -1
  323. package/src/server/implementation.ts +0 -5336
@@ -0,0 +1,1462 @@
1
+ import { GenericActionCtx, GenericDataModel } from "convex/server";
2
+
3
+ import { AuthError, Fx } from "../fx";
4
+ import type { EnterprisePolicyPatch } from "../types";
5
+
6
+ type ComponentCtx = Pick<
7
+ GenericActionCtx<GenericDataModel>,
8
+ "runQuery" | "runMutation"
9
+ >;
10
+ type ComponentReadCtx = Pick<GenericActionCtx<GenericDataModel>, "runQuery">;
11
+
12
+ /**
13
+ * Build the enterprise and SSO management domain.
14
+ */
15
+ export function createSsoDomain(deps: any) {
16
+ const {
17
+ config,
18
+ normalizeEnterprisePolicy,
19
+ normalizeDomain,
20
+ getEnterpriseSecret,
21
+ loadEnterpriseOrThrow,
22
+ validateEnterprisePolicy,
23
+ recordEnterpriseAuditEvent,
24
+ emitEnterpriseWebhookDeliveries,
25
+ enterpriseNotFoundError,
26
+ ENTERPRISE_OIDC_CLIENT_SECRET_KIND,
27
+ requireEnv,
28
+ generateRandomString,
29
+ INVITE_TOKEN_ALPHABET,
30
+ sha256,
31
+ encryptSecret,
32
+ upsertProtocolConfig,
33
+ parseSamlIdpMetadata,
34
+ createServiceProviderMetadata,
35
+ getSamlServiceProviderOptions,
36
+ getPublicOidcConfig,
37
+ withOidcSecretState,
38
+ getOidcConfig,
39
+ getEnterpriseOidcUrls,
40
+ enterpriseOidcProviderId,
41
+ getPolicyFromEnterprise,
42
+ patchEnterprisePolicy,
43
+ } = deps;
44
+
45
+ return {
46
+ connection: {
47
+ create: async (
48
+ ctx: ComponentCtx,
49
+ data: {
50
+ groupId: string;
51
+ slug?: string;
52
+ name?: string;
53
+ status?: "draft" | "active" | "disabled";
54
+ policy?: EnterprisePolicyPatch;
55
+ config?: Record<string, unknown>;
56
+ extend?: Record<string, unknown>;
57
+ },
58
+ ): Promise<string> => {
59
+ return (await ctx.runMutation(
60
+ config.component.public.enterpriseCreate,
61
+ {
62
+ ...data,
63
+ policy: normalizeEnterprisePolicy(data.policy),
64
+ },
65
+ )) as string;
66
+ },
67
+ get: async (ctx: ComponentReadCtx, enterpriseId: string) => {
68
+ return await ctx.runQuery(config.component.public.enterpriseGet, {
69
+ enterpriseId,
70
+ });
71
+ },
72
+ getByGroup: async (ctx: ComponentReadCtx, groupId: string) => {
73
+ return await ctx.runQuery(
74
+ config.component.public.enterpriseGetByGroup,
75
+ {
76
+ groupId,
77
+ },
78
+ );
79
+ },
80
+ getByDomain: async (ctx: ComponentReadCtx, domain: string) => {
81
+ return await ctx.runQuery(
82
+ config.component.public.enterpriseGetByDomain,
83
+ {
84
+ domain: normalizeDomain(domain),
85
+ },
86
+ );
87
+ },
88
+ list: async (
89
+ ctx: ComponentReadCtx,
90
+ opts?: {
91
+ where?: {
92
+ groupId?: string;
93
+ slug?: string;
94
+ status?: "draft" | "active" | "disabled";
95
+ };
96
+ limit?: number;
97
+ cursor?: string | null;
98
+ orderBy?: "_creationTime" | "name" | "slug" | "status";
99
+ order?: "asc" | "desc";
100
+ },
101
+ ) => {
102
+ return await ctx.runQuery(config.component.public.enterpriseList, {
103
+ where: opts?.where,
104
+ limit: opts?.limit,
105
+ cursor: opts?.cursor,
106
+ orderBy: opts?.orderBy,
107
+ order: opts?.order,
108
+ });
109
+ },
110
+ update: async (
111
+ ctx: ComponentCtx,
112
+ enterpriseId: string,
113
+ data: Record<string, unknown>,
114
+ ) => {
115
+ await ctx.runMutation(config.component.public.enterpriseUpdate, {
116
+ enterpriseId,
117
+ data,
118
+ });
119
+ },
120
+ delete: async (ctx: ComponentCtx, enterpriseId: string) => {
121
+ await ctx.runMutation(config.component.public.enterpriseDelete, {
122
+ enterpriseId,
123
+ });
124
+ },
125
+ /**
126
+ * Aggregate readiness status across all configured protocols for an
127
+ * enterprise connection.
128
+ *
129
+ * Returns a structured result indicating whether the connection is
130
+ * ready, with per-protocol checks so callers can surface actionable
131
+ * diagnostics without running full network validation.
132
+ */
133
+ status: async (ctx: ComponentReadCtx, enterpriseId: string) => {
134
+ const enterprise = await ctx.runQuery(
135
+ config.component.public.enterpriseGet,
136
+ { enterpriseId },
137
+ );
138
+ if (!enterprise) {
139
+ throw new AuthError(
140
+ "INVALID_PARAMETERS",
141
+ enterpriseNotFoundError,
142
+ ).toConvexError();
143
+ }
144
+ const policy = getPolicyFromEnterprise(enterprise);
145
+ const protocols = enterprise.config?.protocols ?? {};
146
+ const oidcConfig = protocols.oidc;
147
+ const oidcSecret = await getEnterpriseSecret(
148
+ ctx,
149
+ enterprise._id,
150
+ ENTERPRISE_OIDC_CLIENT_SECRET_KIND,
151
+ );
152
+ const samlConfig = protocols.saml;
153
+ const scimConfig = await ctx.runQuery(
154
+ config.component.public.enterpriseScimConfigGetByEnterprise,
155
+ { enterpriseId },
156
+ );
157
+ const domains = await ctx.runQuery(
158
+ config.component.public.enterpriseDomainList,
159
+ { enterpriseId },
160
+ );
161
+
162
+ const oidcReady =
163
+ oidcConfig?.enabled === true &&
164
+ typeof oidcConfig?.clientId === "string" &&
165
+ oidcConfig.clientId.length > 0 &&
166
+ oidcSecret !== null &&
167
+ (typeof oidcConfig?.issuer === "string" ||
168
+ typeof oidcConfig?.discoveryUrl === "string");
169
+ const samlReady =
170
+ samlConfig?.enabled === true &&
171
+ typeof samlConfig?.idp?.entityId === "string";
172
+ const scimReady =
173
+ scimConfig !== null &&
174
+ scimConfig !== undefined &&
175
+ (scimConfig as any).status === "active";
176
+
177
+ const ready =
178
+ enterprise.status === "active" && (oidcReady || samlReady);
179
+
180
+ return {
181
+ enterpriseId: enterprise._id,
182
+ status: enterprise.status,
183
+ ready,
184
+ domainCount: (domains as unknown[]).length,
185
+ protocols: {
186
+ oidc: {
187
+ configured: oidcReady,
188
+ ready: oidcReady,
189
+ clientId: oidcConfig?.clientId ?? null,
190
+ issuer: oidcConfig?.issuer ?? oidcConfig?.discoveryUrl ?? null,
191
+ },
192
+ saml: {
193
+ configured: samlReady,
194
+ ready: samlReady,
195
+ entityId: samlConfig?.idp?.entityId ?? null,
196
+ },
197
+ scim: {
198
+ configured: scimReady,
199
+ ready: scimReady,
200
+ basePath: scimConfig?.basePath ?? null,
201
+ deprovisionMode: policy.provisioning.deprovision.mode,
202
+ },
203
+ },
204
+ };
205
+ },
206
+ },
207
+ domain: {
208
+ add: async (
209
+ ctx: ComponentCtx,
210
+ data: {
211
+ enterpriseId: string;
212
+ groupId: string;
213
+ domain: string;
214
+ isPrimary?: boolean;
215
+ verifiedAt?: number;
216
+ },
217
+ ): Promise<string> => {
218
+ return (await ctx.runMutation(
219
+ config.component.public.enterpriseDomainAdd,
220
+ {
221
+ ...data,
222
+ domain: normalizeDomain(data.domain),
223
+ },
224
+ )) as string;
225
+ },
226
+ list: async (ctx: ComponentReadCtx, enterpriseId: string) => {
227
+ return await ctx.runQuery(
228
+ config.component.public.enterpriseDomainList,
229
+ {
230
+ enterpriseId,
231
+ },
232
+ );
233
+ },
234
+ validate: async (ctx: ComponentReadCtx, enterpriseId: string) => {
235
+ const enterprise = await ctx.runQuery(
236
+ config.component.public.enterpriseGet,
237
+ { enterpriseId },
238
+ );
239
+ if (enterprise === null) {
240
+ throw new AuthError(
241
+ "INVALID_PARAMETERS",
242
+ enterpriseNotFoundError,
243
+ ).toConvexError();
244
+ }
245
+
246
+ const domains = await ctx.runQuery(
247
+ config.component.public.enterpriseDomainList,
248
+ { enterpriseId },
249
+ );
250
+ const primaryDomains = domains.filter(
251
+ (domain: (typeof domains)[number]) => domain.isPrimary,
252
+ );
253
+ const verifiedDomains = domains.filter(
254
+ (domain: (typeof domains)[number]) => domain.verifiedAt !== undefined,
255
+ );
256
+
257
+ const warnings: string[] = [];
258
+ if (domains.length === 0) {
259
+ warnings.push("No domains configured.");
260
+ }
261
+ if (primaryDomains.length === 0 && domains.length > 0) {
262
+ warnings.push("No primary domain configured.");
263
+ }
264
+ if (primaryDomains.length > 1) {
265
+ warnings.push("Multiple primary domains configured.");
266
+ }
267
+ if (verifiedDomains.length === 0 && domains.length > 0) {
268
+ warnings.push("No verified domains yet.");
269
+ }
270
+
271
+ return {
272
+ enterpriseId,
273
+ ready:
274
+ enterprise.status === "active" &&
275
+ domains.length > 0 &&
276
+ primaryDomains.length === 1 &&
277
+ verifiedDomains.length > 0,
278
+ summary: {
279
+ domainCount: domains.length,
280
+ primaryCount: primaryDomains.length,
281
+ verifiedCount: verifiedDomains.length,
282
+ },
283
+ domains: domains.map((domain: (typeof domains)[number]) => ({
284
+ domainId: domain._id,
285
+ domain: domain.domain,
286
+ isPrimary: domain.isPrimary,
287
+ verified: domain.verifiedAt !== undefined,
288
+ verifiedAt: domain.verifiedAt ?? null,
289
+ })),
290
+ warnings,
291
+ };
292
+ },
293
+ remove: async (ctx: ComponentCtx, domainId: string) => {
294
+ await ctx.runMutation(config.component.public.enterpriseDomainDelete, {
295
+ domainId,
296
+ });
297
+ },
298
+ },
299
+ saml: {
300
+ configure: async <DataModel extends GenericDataModel>(
301
+ ctx: GenericActionCtx<DataModel>,
302
+ data: {
303
+ enterpriseId: string;
304
+ metadataXml?: string;
305
+ metadataUrl?: string;
306
+ domains?: string[];
307
+ signAuthnRequests?: boolean;
308
+ attributeMapping?: {
309
+ subject?: string;
310
+ email?: string;
311
+ name?: string;
312
+ firstName?: string;
313
+ lastName?: string;
314
+ };
315
+ sp?: {
316
+ entityId?: string;
317
+ acsUrl?: string;
318
+ sloUrl?: string;
319
+ signingCert?: string | string[];
320
+ encryptCert?: string | string[];
321
+ privateKey?: string;
322
+ privateKeyPass?: string;
323
+ encPrivateKey?: string;
324
+ encPrivateKeyPass?: string;
325
+ };
326
+ },
327
+ ) => {
328
+ return await Fx.run(
329
+ Fx.gen(function* () {
330
+ const enterprise = yield* Fx.from({
331
+ ok: () =>
332
+ ctx.runQuery(config.component.public.enterpriseGet, {
333
+ enterpriseId: data.enterpriseId,
334
+ }),
335
+ err: () =>
336
+ new AuthError("INTERNAL_ERROR", "Failed to load enterprise."),
337
+ }).pipe(
338
+ Fx.chain((ent) =>
339
+ ent === null
340
+ ? Fx.fail(
341
+ new AuthError(
342
+ "INVALID_PARAMETERS",
343
+ enterpriseNotFoundError,
344
+ ),
345
+ )
346
+ : Fx.succeed(ent),
347
+ ),
348
+ );
349
+ const metadataXml = yield* data.metadataXml
350
+ ? Fx.succeed(data.metadataXml)
351
+ : data.metadataUrl
352
+ ? Fx.defer(() =>
353
+ Fx.from({
354
+ ok: async () => {
355
+ const response = await fetch(data.metadataUrl!);
356
+ if (!response.ok) {
357
+ throw new Error(
358
+ `Failed to fetch SAML metadata: ${response.status}`,
359
+ );
360
+ }
361
+ return await response.text();
362
+ },
363
+ err: (error) =>
364
+ new AuthError(
365
+ "INVALID_PARAMETERS",
366
+ error instanceof Error
367
+ ? error.message
368
+ : "Failed to fetch SAML metadata",
369
+ ),
370
+ }),
371
+ ).pipe(
372
+ Fx.timeout(10_000),
373
+ Fx.retry(
374
+ Fx.retry.compose(
375
+ Fx.retry.jittered(Fx.retry.exponential(200)),
376
+ Fx.retry.recurs(2),
377
+ ),
378
+ ),
379
+ Fx.recover((error) =>
380
+ Fx.fail(
381
+ new AuthError(
382
+ "INVALID_PARAMETERS",
383
+ error instanceof Error
384
+ ? error.message
385
+ : "Failed to fetch SAML metadata",
386
+ ),
387
+ ),
388
+ ),
389
+ )
390
+ : Fx.fail(
391
+ new AuthError(
392
+ "INVALID_PARAMETERS",
393
+ "SAML registration requires metadataXml or metadataUrl.",
394
+ ),
395
+ );
396
+
397
+ const parsed = yield* Fx.from({
398
+ ok: () => parseSamlIdpMetadata(metadataXml),
399
+ err: () =>
400
+ new AuthError(
401
+ "INVALID_PARAMETERS",
402
+ "Failed to parse SAML metadata.",
403
+ ),
404
+ });
405
+
406
+ const baseConfig = upsertProtocolConfig(enterprise.config, "saml", {
407
+ enabled: true,
408
+ idp: {
409
+ metadataXml,
410
+ ...parsed,
411
+ },
412
+ sp: data.sp,
413
+ signAuthnRequests:
414
+ data.signAuthnRequests ?? parsed.wantsSignedAuthnRequests,
415
+ attributeMapping: data.attributeMapping,
416
+ });
417
+ const normalizedDomains = data.domains?.map(normalizeDomain);
418
+ const nextConfig = normalizedDomains
419
+ ? { ...baseConfig, domains: normalizedDomains }
420
+ : baseConfig;
421
+
422
+ yield* Fx.from({
423
+ ok: () =>
424
+ ctx.runMutation(config.component.public.enterpriseUpdate, {
425
+ enterpriseId: enterprise._id,
426
+ data: {
427
+ status: "active",
428
+ config: nextConfig,
429
+ },
430
+ }),
431
+ err: () =>
432
+ new AuthError(
433
+ "INTERNAL_ERROR",
434
+ "Failed to persist SAML registration.",
435
+ ),
436
+ });
437
+
438
+ if (normalizedDomains) {
439
+ for (const [index, domain] of normalizedDomains.entries()) {
440
+ yield* Fx.from({
441
+ ok: () =>
442
+ ctx.runMutation(
443
+ config.component.public.enterpriseDomainAdd,
444
+ {
445
+ enterpriseId: enterprise._id,
446
+ groupId: enterprise.groupId,
447
+ domain,
448
+ isPrimary: index === 0,
449
+ },
450
+ ),
451
+ err: () =>
452
+ new AuthError(
453
+ "INTERNAL_ERROR",
454
+ "Failed to persist enterprise domain.",
455
+ ),
456
+ });
457
+ }
458
+ }
459
+
460
+ yield* Fx.from({
461
+ ok: () =>
462
+ recordEnterpriseAuditEvent(ctx, {
463
+ enterpriseId: enterprise._id,
464
+ groupId: enterprise.groupId,
465
+ eventType: "enterprise.saml.registered",
466
+ actorType: "system",
467
+ subjectType: "enterprise_saml",
468
+ subjectId: enterprise._id,
469
+ ok: true,
470
+ metadata: {
471
+ metadataUrl: data.metadataUrl,
472
+ domains: normalizedDomains,
473
+ },
474
+ }),
475
+ err: () =>
476
+ new AuthError(
477
+ "INTERNAL_ERROR",
478
+ "Failed to record SAML registration audit event.",
479
+ ),
480
+ });
481
+
482
+ return {
483
+ enterpriseId: enterprise._id,
484
+ groupId: enterprise.groupId,
485
+ };
486
+ }).pipe(Fx.recover((e) => Fx.fatal(e.toConvexError()))),
487
+ );
488
+ },
489
+ metadata: async <DataModel extends GenericDataModel>(
490
+ ctx: GenericActionCtx<DataModel>,
491
+ opts: {
492
+ enterpriseId: string;
493
+ entityId?: string;
494
+ acsUrl?: string;
495
+ sloUrl?: string;
496
+ },
497
+ ) => {
498
+ const enterprise = await ctx.runQuery(
499
+ config.component.public.enterpriseGet,
500
+ {
501
+ enterpriseId: opts.enterpriseId,
502
+ },
503
+ );
504
+ if (!enterprise) {
505
+ throw new AuthError(
506
+ "INVALID_PARAMETERS",
507
+ "Enterprise not found.",
508
+ ).toConvexError();
509
+ }
510
+
511
+ return createServiceProviderMetadata(
512
+ getSamlServiceProviderOptions({
513
+ rootUrl: requireEnv("CONVEX_SITE_URL"),
514
+ source: { kind: "enterprise", id: enterprise._id },
515
+ config: enterprise.config,
516
+ overrides: {
517
+ entityId: opts.entityId,
518
+ acsUrl: opts.acsUrl,
519
+ sloUrl: opts.sloUrl,
520
+ },
521
+ }),
522
+ );
523
+ },
524
+ /**
525
+ * Validate the stored SAML config for an enterprise connection.
526
+ *
527
+ * Re-parses IdP metadata, checks signing cert presence, and verifies
528
+ * SP metadata can be generated. Returns a structured result with
529
+ * per-check details rather than throwing on first failure.
530
+ */
531
+ validate: async <DataModel extends GenericDataModel>(
532
+ ctx: GenericActionCtx<DataModel>,
533
+ enterpriseId: string,
534
+ ) => {
535
+ const checks: Array<{
536
+ name: string;
537
+ ok: boolean;
538
+ message?: string;
539
+ }> = [];
540
+
541
+ const enterprise = await ctx.runQuery(
542
+ config.component.public.enterpriseGet,
543
+ { enterpriseId },
544
+ );
545
+
546
+ if (!enterprise) {
547
+ return {
548
+ ok: false,
549
+ enterpriseId,
550
+ checks: [
551
+ {
552
+ name: "enterprise_exists",
553
+ ok: false,
554
+ message: "Enterprise not found.",
555
+ },
556
+ ],
557
+ };
558
+ }
559
+
560
+ const samlConfig = enterprise.config?.protocols?.saml;
561
+ const samlConfigured =
562
+ samlConfig?.enabled === true &&
563
+ typeof samlConfig?.idp?.metadataXml === "string";
564
+
565
+ checks.push({
566
+ name: "saml_configured",
567
+ ok: samlConfigured,
568
+ message: samlConfigured ? undefined : "SAML is not configured.",
569
+ });
570
+
571
+ const hasIdpMetadata =
572
+ typeof samlConfig?.idp?.metadataXml === "string" &&
573
+ samlConfig.idp.metadataXml.length > 0;
574
+ checks.push({
575
+ name: "idp_metadata_present",
576
+ ok: hasIdpMetadata,
577
+ message: hasIdpMetadata ? undefined : "IdP metadata XML is missing.",
578
+ });
579
+
580
+ const hasEntityId =
581
+ typeof samlConfig?.idp?.entityId === "string" &&
582
+ samlConfig.idp.entityId.length > 0;
583
+ checks.push({
584
+ name: "idp_entity_id",
585
+ ok: hasEntityId,
586
+ message: hasEntityId
587
+ ? undefined
588
+ : "IdP entityId could not be parsed from metadata.",
589
+ });
590
+
591
+ let spMetadataOk = false;
592
+ let spMetadataMessage: string | undefined;
593
+ if (samlConfigured) {
594
+ try {
595
+ createServiceProviderMetadata(
596
+ getSamlServiceProviderOptions({
597
+ rootUrl: requireEnv("CONVEX_SITE_URL"),
598
+ source: { kind: "enterprise", id: enterprise._id },
599
+ config: enterprise.config,
600
+ overrides: {},
601
+ }),
602
+ );
603
+ spMetadataOk = true;
604
+ } catch (e) {
605
+ spMetadataMessage =
606
+ e instanceof Error ? e.message : "SP metadata generation failed.";
607
+ }
608
+ } else {
609
+ spMetadataMessage = "Skipped — SAML not configured.";
610
+ }
611
+ checks.push({
612
+ name: "sp_metadata_generates",
613
+ ok: spMetadataOk,
614
+ message: spMetadataMessage,
615
+ });
616
+
617
+ return {
618
+ ok: checks.every((c) => c.ok),
619
+ enterpriseId: enterprise._id,
620
+ checks,
621
+ };
622
+ },
623
+ },
624
+ policy: {
625
+ get: async (ctx: ComponentReadCtx, enterpriseId: string) => {
626
+ const enterprise = await loadEnterpriseOrThrow(ctx, enterpriseId);
627
+ return getPolicyFromEnterprise(enterprise);
628
+ },
629
+ update: async (
630
+ ctx: ComponentCtx,
631
+ enterpriseId: string,
632
+ patch: EnterprisePolicyPatch,
633
+ ) => {
634
+ const enterprise = await loadEnterpriseOrThrow(ctx, enterpriseId);
635
+ const policy = patchEnterprisePolicy(enterprise.policy, patch);
636
+ await ctx.runMutation(config.component.public.enterpriseUpdate, {
637
+ enterpriseId,
638
+ data: { policy },
639
+ });
640
+ await recordEnterpriseAuditEvent(ctx, {
641
+ enterpriseId: enterprise._id,
642
+ groupId: enterprise.groupId,
643
+ eventType: "enterprise.policy.updated",
644
+ actorType: "system",
645
+ subjectType: "enterprise_policy",
646
+ subjectId: enterprise._id,
647
+ ok: true,
648
+ metadata: { version: policy.version },
649
+ });
650
+ return policy;
651
+ },
652
+ validate: async (ctx: ComponentReadCtx, enterpriseId: string) => {
653
+ const enterprise = await ctx.runQuery(
654
+ config.component.public.enterpriseGet,
655
+ { enterpriseId },
656
+ );
657
+ if (!enterprise) {
658
+ return {
659
+ ok: false,
660
+ enterpriseId,
661
+ checks: [
662
+ {
663
+ name: "enterprise_exists",
664
+ ok: false,
665
+ message: enterpriseNotFoundError,
666
+ },
667
+ ],
668
+ };
669
+ }
670
+ const policy = getPolicyFromEnterprise(enterprise);
671
+ const checks = validateEnterprisePolicy(policy);
672
+ return {
673
+ ok: checks.every((check: { ok: boolean }) => check.ok),
674
+ enterpriseId,
675
+ policy,
676
+ checks,
677
+ };
678
+ },
679
+ },
680
+ oidc: {
681
+ /**
682
+ * Register or update enterprise OIDC connection settings.
683
+ *
684
+ * Persists protocol config under `enterprise.config.protocols.oidc` and
685
+ * records an `enterprise.oidc.registered` audit event.
686
+ */
687
+ configure: async (
688
+ ctx: ComponentCtx,
689
+ data: {
690
+ enterpriseId: string;
691
+ issuer?: string;
692
+ discoveryUrl?: string;
693
+ clientId: string;
694
+ clientSecret?: string;
695
+ scopes?: string[];
696
+ authorizationParams?: Record<string, string>;
697
+ clockToleranceSeconds?: number;
698
+ strictIssuer?: boolean;
699
+ /**
700
+ * Map OIDC claim names to `user.extend` field names.
701
+ * Example: `{ department: "department", role: "job_title" }` means
702
+ * the OIDC `department` claim is stored as `user.extend.department`.
703
+ */
704
+ extraFields?: Record<string, string>;
705
+ },
706
+ ) => {
707
+ return await Fx.run(
708
+ Fx.gen(function* () {
709
+ yield* Fx.guard(
710
+ data.issuer === undefined && data.discoveryUrl === undefined,
711
+ Fx.fail(
712
+ new AuthError(
713
+ "INVALID_PARAMETERS",
714
+ "OIDC registration requires issuer or discoveryUrl.",
715
+ ),
716
+ ),
717
+ );
718
+
719
+ const enterprise = yield* Fx.from({
720
+ ok: () =>
721
+ ctx.runQuery(config.component.public.enterpriseGet, {
722
+ enterpriseId: data.enterpriseId,
723
+ }),
724
+ err: () =>
725
+ new AuthError("INTERNAL_ERROR", "Failed to load enterprise."),
726
+ }).pipe(
727
+ Fx.chain((ent) =>
728
+ ent === null
729
+ ? Fx.fail(
730
+ new AuthError(
731
+ "INVALID_PARAMETERS",
732
+ enterpriseNotFoundError,
733
+ ),
734
+ )
735
+ : Fx.succeed(ent),
736
+ ),
737
+ );
738
+ const nextConfig = upsertProtocolConfig(enterprise.config, "oidc", {
739
+ enabled: true,
740
+ issuer: data.issuer,
741
+ discoveryUrl: data.discoveryUrl,
742
+ clientId: data.clientId,
743
+ scopes: data.scopes ?? ["openid", "profile", "email"],
744
+ authorizationParams: data.authorizationParams,
745
+ clockToleranceSeconds: data.clockToleranceSeconds,
746
+ strictIssuer: data.strictIssuer,
747
+ extraFields: data.extraFields,
748
+ });
749
+
750
+ yield* Fx.from({
751
+ ok: () =>
752
+ ctx.runMutation(config.component.public.enterpriseUpdate, {
753
+ enterpriseId: data.enterpriseId,
754
+ data: { config: nextConfig },
755
+ }),
756
+ err: () =>
757
+ new AuthError(
758
+ "INTERNAL_ERROR",
759
+ "Failed to persist OIDC registration.",
760
+ ),
761
+ });
762
+
763
+ if (data.clientSecret !== undefined) {
764
+ const ciphertext = yield* Fx.from({
765
+ ok: () => encryptSecret(data.clientSecret!),
766
+ err: () =>
767
+ new AuthError(
768
+ "INTERNAL_ERROR",
769
+ "Failed to encrypt OIDC client secret.",
770
+ ),
771
+ });
772
+ yield* Fx.from({
773
+ ok: () =>
774
+ ctx.runMutation(
775
+ config.component.public.enterpriseSecretUpsert,
776
+ {
777
+ enterpriseId: data.enterpriseId,
778
+ groupId: enterprise.groupId,
779
+ kind: ENTERPRISE_OIDC_CLIENT_SECRET_KIND,
780
+ ciphertext,
781
+ updatedAt: Date.now(),
782
+ },
783
+ ),
784
+ err: () =>
785
+ new AuthError(
786
+ "INTERNAL_ERROR",
787
+ "Failed to persist OIDC client secret.",
788
+ ),
789
+ });
790
+ }
791
+
792
+ yield* Fx.from({
793
+ ok: () =>
794
+ recordEnterpriseAuditEvent(ctx, {
795
+ enterpriseId: data.enterpriseId,
796
+ groupId: enterprise.groupId,
797
+ eventType: "enterprise.oidc.registered",
798
+ actorType: "system",
799
+ subjectType: "enterprise_oidc",
800
+ subjectId: data.enterpriseId,
801
+ ok: true,
802
+ metadata: {
803
+ issuer: data.issuer,
804
+ discoveryUrl: data.discoveryUrl,
805
+ },
806
+ }),
807
+ err: () =>
808
+ new AuthError(
809
+ "INTERNAL_ERROR",
810
+ "Failed to record OIDC registration audit event.",
811
+ ),
812
+ });
813
+
814
+ const secret = yield* Fx.from({
815
+ ok: () =>
816
+ getEnterpriseSecret(
817
+ ctx,
818
+ data.enterpriseId,
819
+ ENTERPRISE_OIDC_CLIENT_SECRET_KIND,
820
+ ),
821
+ err: () =>
822
+ new AuthError(
823
+ "INTERNAL_ERROR",
824
+ "Failed to load OIDC secret metadata.",
825
+ ),
826
+ });
827
+
828
+ return withOidcSecretState(
829
+ getPublicOidcConfig(nextConfig),
830
+ secret !== null,
831
+ );
832
+ }).pipe(Fx.recover((e) => Fx.fatal(e.toConvexError()))),
833
+ );
834
+ },
835
+ /**
836
+ * Fetch the stored OIDC config for an enterprise.
837
+ */
838
+ get: async (ctx: ComponentReadCtx, enterpriseId: string) => {
839
+ return await Fx.run(
840
+ Fx.from({
841
+ ok: () =>
842
+ ctx.runQuery(config.component.public.enterpriseGet, {
843
+ enterpriseId,
844
+ }),
845
+ err: () =>
846
+ new AuthError("INTERNAL_ERROR", "Failed to load enterprise."),
847
+ }).pipe(
848
+ Fx.chain((ent) =>
849
+ ent === null
850
+ ? Fx.fail(
851
+ new AuthError(
852
+ "INVALID_PARAMETERS",
853
+ enterpriseNotFoundError,
854
+ ),
855
+ )
856
+ : Fx.succeed(ent),
857
+ ),
858
+ Fx.chain((enterprise) =>
859
+ Fx.from({
860
+ ok: async () => {
861
+ const secret = await getEnterpriseSecret(
862
+ ctx,
863
+ enterprise._id,
864
+ ENTERPRISE_OIDC_CLIENT_SECRET_KIND,
865
+ );
866
+ return withOidcSecretState(
867
+ getPublicOidcConfig(enterprise.config),
868
+ secret !== null,
869
+ );
870
+ },
871
+ err: () =>
872
+ new AuthError(
873
+ "INTERNAL_ERROR",
874
+ "Failed to load OIDC secret metadata.",
875
+ ),
876
+ }),
877
+ ),
878
+ Fx.recover((e) => Fx.fatal(e.toConvexError())),
879
+ ),
880
+ );
881
+ },
882
+ /**
883
+ * Resolve enterprise OIDC sign-in route from enterprise id, domain, or
884
+ * user email domain.
885
+ */
886
+ signIn: async (
887
+ ctx: ComponentReadCtx,
888
+ data: {
889
+ enterpriseId?: string;
890
+ email?: string;
891
+ domain?: string;
892
+ redirectTo?: string;
893
+ },
894
+ ) => {
895
+ return await Fx.run(
896
+ Fx.gen(function* () {
897
+ const enterprise =
898
+ data.enterpriseId !== undefined
899
+ ? yield* Fx.from({
900
+ ok: () =>
901
+ ctx.runQuery(config.component.public.enterpriseGet, {
902
+ enterpriseId: data.enterpriseId,
903
+ }),
904
+ err: () =>
905
+ new AuthError(
906
+ "INTERNAL_ERROR",
907
+ "Failed to load enterprise.",
908
+ ),
909
+ }).pipe(
910
+ Fx.chain((ent) =>
911
+ ent === null
912
+ ? Fx.fail(
913
+ new AuthError(
914
+ "INVALID_PARAMETERS",
915
+ enterpriseNotFoundError,
916
+ ),
917
+ )
918
+ : Fx.succeed(ent),
919
+ ),
920
+ )
921
+ : data.domain !== undefined || data.email !== undefined
922
+ ? yield* Fx.from({
923
+ ok: () =>
924
+ ctx.runQuery(
925
+ config.component.public.enterpriseGetByDomain,
926
+ {
927
+ domain: normalizeDomain(
928
+ data.domain ??
929
+ String(data.email).split("@").at(-1) ??
930
+ "",
931
+ ),
932
+ },
933
+ ),
934
+ err: () =>
935
+ new AuthError(
936
+ "INTERNAL_ERROR",
937
+ "Failed to resolve enterprise by domain.",
938
+ ),
939
+ }).pipe(
940
+ Fx.chain((result) =>
941
+ result?.enterprise
942
+ ? Fx.succeed(result.enterprise)
943
+ : Fx.fail(
944
+ new AuthError(
945
+ "INVALID_PARAMETERS",
946
+ "No enterprise OIDC connection matched the provided input.",
947
+ ),
948
+ ),
949
+ ),
950
+ )
951
+ : yield* Fx.fail(
952
+ new AuthError(
953
+ "INVALID_PARAMETERS",
954
+ "No enterprise OIDC connection matched the provided input.",
955
+ ),
956
+ );
957
+
958
+ yield* Fx.guard(
959
+ enterprise.status !== "active",
960
+ Fx.fail(
961
+ new AuthError(
962
+ "INVALID_PARAMETERS",
963
+ "Enterprise connection is not active.",
964
+ ),
965
+ ),
966
+ );
967
+
968
+ const oidc = getOidcConfig(enterprise.config);
969
+ yield* Fx.guard(
970
+ oidc.enabled !== true,
971
+ Fx.fail(
972
+ new AuthError(
973
+ "PROVIDER_NOT_CONFIGURED",
974
+ "OIDC is not configured for this enterprise.",
975
+ ),
976
+ ),
977
+ );
978
+
979
+ const urls = getEnterpriseOidcUrls({
980
+ rootUrl: requireEnv("CONVEX_SITE_URL"),
981
+ enterpriseId: enterprise._id,
982
+ });
983
+ return {
984
+ enterpriseId: enterprise._id,
985
+ providerId: enterpriseOidcProviderId(enterprise._id),
986
+ signInPath: urls.signInUrl,
987
+ callbackPath: urls.callbackUrl,
988
+ redirectTo: data.redirectTo,
989
+ };
990
+ }).pipe(Fx.recover((e) => Fx.fatal(e.toConvexError()))),
991
+ );
992
+ },
993
+ /**
994
+ * Validate the stored OIDC config for an enterprise connection.
995
+ *
996
+ * Fetches the OIDC discovery document from the configured issuer or
997
+ * discoveryUrl, verifies required fields are present, and checks that
998
+ * clientId is set. Returns a structured result with per-check details.
999
+ */
1000
+ validate: async (ctx: ComponentReadCtx, enterpriseId: string) => {
1001
+ const checks: Array<{
1002
+ name: string;
1003
+ ok: boolean;
1004
+ message?: string;
1005
+ }> = [];
1006
+
1007
+ const enterprise = await ctx.runQuery(
1008
+ config.component.public.enterpriseGet,
1009
+ { enterpriseId },
1010
+ );
1011
+
1012
+ if (!enterprise) {
1013
+ return {
1014
+ ok: false,
1015
+ enterpriseId,
1016
+ checks: [
1017
+ {
1018
+ name: "enterprise_exists",
1019
+ ok: false,
1020
+ message: "Enterprise not found.",
1021
+ },
1022
+ ],
1023
+ };
1024
+ }
1025
+
1026
+ const oidc = getOidcConfig(enterprise.config);
1027
+ const secret = await getEnterpriseSecret(
1028
+ ctx,
1029
+ enterprise._id,
1030
+ ENTERPRISE_OIDC_CLIENT_SECRET_KIND,
1031
+ );
1032
+ const oidcConfigured =
1033
+ oidc.enabled === true &&
1034
+ typeof oidc.clientId === "string" &&
1035
+ oidc.clientId.length > 0;
1036
+
1037
+ checks.push({
1038
+ name: "oidc_configured",
1039
+ ok: oidcConfigured,
1040
+ message: oidcConfigured ? undefined : "OIDC is not configured.",
1041
+ });
1042
+
1043
+ const hasClientId =
1044
+ typeof oidc.clientId === "string" && oidc.clientId.length > 0;
1045
+ checks.push({
1046
+ name: "client_id_present",
1047
+ ok: hasClientId,
1048
+ message: hasClientId ? undefined : "clientId is missing.",
1049
+ });
1050
+
1051
+ checks.push({
1052
+ name: "client_secret_stored",
1053
+ ok: secret !== null,
1054
+ message:
1055
+ secret !== null ? undefined : "OIDC client secret is missing.",
1056
+ });
1057
+
1058
+ const discoveryTarget = oidc.discoveryUrl ?? oidc.issuer;
1059
+ const hasDiscovery =
1060
+ typeof discoveryTarget === "string" && discoveryTarget.length > 0;
1061
+ checks.push({
1062
+ name: "issuer_or_discovery_url_present",
1063
+ ok: hasDiscovery,
1064
+ message: hasDiscovery
1065
+ ? undefined
1066
+ : "issuer or discoveryUrl is missing.",
1067
+ });
1068
+
1069
+ let discoveryOk = false;
1070
+ let discoveryMessage: string | undefined;
1071
+ if (hasDiscovery) {
1072
+ const discoveryUrl = oidc.discoveryUrl?.length
1073
+ ? oidc.discoveryUrl
1074
+ : `${oidc.issuer}/.well-known/openid-configuration`;
1075
+ try {
1076
+ const res = await fetch(discoveryUrl, {
1077
+ headers: { Accept: "application/json" },
1078
+ signal: AbortSignal.timeout(8_000),
1079
+ });
1080
+ if (!res.ok) {
1081
+ discoveryMessage = `Discovery endpoint returned ${res.status}.`;
1082
+ } else {
1083
+ const json = (await res.json()) as Record<string, unknown>;
1084
+ if (typeof json.issuer !== "string") {
1085
+ discoveryMessage =
1086
+ "Discovery document is missing issuer field.";
1087
+ } else if (typeof json.authorization_endpoint !== "string") {
1088
+ discoveryMessage =
1089
+ "Discovery document is missing authorization_endpoint.";
1090
+ } else {
1091
+ discoveryOk = true;
1092
+ }
1093
+ }
1094
+ } catch (e) {
1095
+ discoveryMessage =
1096
+ e instanceof Error
1097
+ ? `Discovery fetch failed: ${e.message}`
1098
+ : "Discovery fetch failed.";
1099
+ }
1100
+ } else {
1101
+ discoveryMessage = "Skipped — issuer or discoveryUrl not set.";
1102
+ }
1103
+ checks.push({
1104
+ name: "discovery_reachable",
1105
+ ok: discoveryOk,
1106
+ message: discoveryMessage,
1107
+ });
1108
+
1109
+ return {
1110
+ ok: checks.every((c) => c.ok),
1111
+ enterpriseId: enterprise._id,
1112
+ checks,
1113
+ };
1114
+ },
1115
+ },
1116
+ scim: {
1117
+ configure: async (
1118
+ ctx: ComponentCtx,
1119
+ data: {
1120
+ enterpriseId: string;
1121
+ basePath?: string;
1122
+ status?: "draft" | "active" | "disabled";
1123
+ },
1124
+ ) => {
1125
+ const enterprise = await ctx.runQuery(
1126
+ config.component.public.enterpriseGet,
1127
+ {
1128
+ enterpriseId: data.enterpriseId,
1129
+ },
1130
+ );
1131
+ if (enterprise === null) {
1132
+ throw new AuthError(
1133
+ "INVALID_PARAMETERS",
1134
+ "Enterprise not found.",
1135
+ ).toConvexError();
1136
+ }
1137
+ const rawToken = generateRandomString(48, INVITE_TOKEN_ALPHABET);
1138
+ const tokenHash = await sha256(rawToken);
1139
+ const configId = (await ctx.runMutation(
1140
+ config.component.public.enterpriseScimConfigUpsert,
1141
+ {
1142
+ enterpriseId: enterprise._id,
1143
+ groupId: enterprise.groupId,
1144
+ status: data.status ?? "active",
1145
+ basePath:
1146
+ data.basePath ??
1147
+ `${requireEnv("CONVEX_SITE_URL")}/api/auth/sso/${enterprise._id}/scim/v2`,
1148
+ tokenHash,
1149
+ lastRotatedAt: Date.now(),
1150
+ },
1151
+ )) as string;
1152
+ const auditEventId = await recordEnterpriseAuditEvent(ctx, {
1153
+ enterpriseId: enterprise._id,
1154
+ groupId: enterprise.groupId,
1155
+ eventType: "enterprise.scim.configured",
1156
+ actorType: "system",
1157
+ subjectType: "enterprise_scim",
1158
+ subjectId: configId,
1159
+ ok: true,
1160
+ });
1161
+ await emitEnterpriseWebhookDeliveries(ctx, {
1162
+ enterpriseId: enterprise._id,
1163
+ eventType: "enterprise.scim.configured",
1164
+ auditEventId,
1165
+ payload: { enterpriseId: enterprise._id, scimConfigId: configId },
1166
+ });
1167
+ return { token: rawToken, configId };
1168
+ },
1169
+ get: async (ctx: ComponentReadCtx, enterpriseId: string) => {
1170
+ return await ctx.runQuery(
1171
+ config.component.public.enterpriseScimConfigGetByEnterprise,
1172
+ { enterpriseId },
1173
+ );
1174
+ },
1175
+ getConfigByToken: async (ctx: ComponentReadCtx, token: string) => {
1176
+ return await ctx.runQuery(
1177
+ config.component.public.enterpriseScimConfigGetByTokenHash,
1178
+ { tokenHash: await sha256(token) },
1179
+ );
1180
+ },
1181
+ /**
1182
+ * Validate the stored SCIM config for an enterprise connection.
1183
+ *
1184
+ * Checks that a SCIM config record exists, is active, has a token
1185
+ * hash set, and has a non-empty basePath. Returns a structured result
1186
+ * with per-check details.
1187
+ */
1188
+ validate: async (ctx: ComponentReadCtx, enterpriseId: string) => {
1189
+ const checks: Array<{
1190
+ name: string;
1191
+ ok: boolean;
1192
+ message?: string;
1193
+ }> = [];
1194
+
1195
+ const enterprise = await ctx.runQuery(
1196
+ config.component.public.enterpriseGet,
1197
+ { enterpriseId },
1198
+ );
1199
+
1200
+ if (!enterprise) {
1201
+ return {
1202
+ ok: false,
1203
+ enterpriseId,
1204
+ checks: [
1205
+ {
1206
+ name: "enterprise_exists",
1207
+ ok: false,
1208
+ message: "Enterprise not found.",
1209
+ },
1210
+ ],
1211
+ };
1212
+ }
1213
+
1214
+ const policy = getPolicyFromEnterprise(enterprise);
1215
+
1216
+ const scimConfig = await ctx.runQuery(
1217
+ config.component.public.enterpriseScimConfigGetByEnterprise,
1218
+ { enterpriseId },
1219
+ );
1220
+
1221
+ const hasConfig = scimConfig !== null && scimConfig !== undefined;
1222
+ checks.push({
1223
+ name: "scim_config_exists",
1224
+ ok: hasConfig,
1225
+ message: hasConfig ? undefined : "SCIM has not been configured.",
1226
+ });
1227
+
1228
+ const isActive = hasConfig && (scimConfig as any).status === "active";
1229
+ checks.push({
1230
+ name: "scim_config_active",
1231
+ ok: isActive,
1232
+ message: isActive
1233
+ ? undefined
1234
+ : `SCIM config status is ${hasConfig ? (scimConfig as any).status : "unknown"}.`,
1235
+ });
1236
+
1237
+ const hasToken =
1238
+ hasConfig &&
1239
+ typeof (scimConfig as any).tokenHash === "string" &&
1240
+ (scimConfig as any).tokenHash.length > 0;
1241
+ checks.push({
1242
+ name: "token_hash_set",
1243
+ ok: hasToken,
1244
+ message: hasToken ? undefined : "SCIM bearer token has not been set.",
1245
+ });
1246
+
1247
+ const hasBasePath =
1248
+ hasConfig &&
1249
+ typeof (scimConfig as any).basePath === "string" &&
1250
+ (scimConfig as any).basePath.length > 0;
1251
+ checks.push({
1252
+ name: "base_path_set",
1253
+ ok: hasBasePath,
1254
+ message: hasBasePath ? undefined : "SCIM basePath is missing.",
1255
+ });
1256
+
1257
+ return {
1258
+ ok: checks.every((c) => c.ok),
1259
+ enterpriseId: enterprise._id,
1260
+ basePath: hasBasePath ? (scimConfig as any).basePath : null,
1261
+ deprovisionMode: policy.provisioning.deprovision.mode,
1262
+ checks,
1263
+ };
1264
+ },
1265
+ identity: {
1266
+ get: async (
1267
+ ctx: ComponentReadCtx,
1268
+ data: {
1269
+ enterpriseId: string;
1270
+ resourceType: "user" | "group";
1271
+ externalId: string;
1272
+ },
1273
+ ) => {
1274
+ return await ctx.runQuery(
1275
+ config.component.public.enterpriseScimIdentityGet,
1276
+ data,
1277
+ );
1278
+ },
1279
+ upsert: async (
1280
+ ctx: ComponentCtx,
1281
+ data: {
1282
+ enterpriseId: string;
1283
+ groupId: string;
1284
+ resourceType: "user" | "group";
1285
+ externalId: string;
1286
+ userId?: string;
1287
+ mappedGroupId?: string;
1288
+ active?: boolean;
1289
+ raw?: Record<string, unknown>;
1290
+ },
1291
+ ) => {
1292
+ return (await ctx.runMutation(
1293
+ config.component.public.enterpriseScimIdentityUpsert,
1294
+ { ...data, lastProvisionedAt: Date.now() },
1295
+ )) as string;
1296
+ },
1297
+ },
1298
+ },
1299
+ audit: {
1300
+ record: async (
1301
+ ctx: ComponentCtx,
1302
+ data: {
1303
+ enterpriseId: string;
1304
+ groupId: string;
1305
+ eventType: string;
1306
+ actorType: "user" | "system" | "scim" | "api_key" | "webhook";
1307
+ actorId?: string;
1308
+ subjectType: string;
1309
+ subjectId?: string;
1310
+ ok: boolean;
1311
+ requestId?: string;
1312
+ ip?: string;
1313
+ metadata?: Record<string, unknown>;
1314
+ },
1315
+ ) => {
1316
+ return await recordEnterpriseAuditEvent(ctx, data);
1317
+ },
1318
+ list: async (
1319
+ ctx: ComponentReadCtx,
1320
+ data: { enterpriseId?: string; groupId?: string; limit?: number },
1321
+ ) => {
1322
+ return await ctx.runQuery(
1323
+ config.component.public.enterpriseAuditEventList,
1324
+ data,
1325
+ );
1326
+ },
1327
+ },
1328
+ webhook: {
1329
+ endpoint: {
1330
+ create: async (
1331
+ ctx: ComponentCtx,
1332
+ data: {
1333
+ enterpriseId: string;
1334
+ url: string;
1335
+ secret: string;
1336
+ subscriptions: string[];
1337
+ createdByUserId?: string;
1338
+ },
1339
+ ) => {
1340
+ const enterprise = await ctx.runQuery(
1341
+ config.component.public.enterpriseGet,
1342
+ {
1343
+ enterpriseId: data.enterpriseId,
1344
+ },
1345
+ );
1346
+ if (enterprise === null) {
1347
+ throw new AuthError(
1348
+ "INVALID_PARAMETERS",
1349
+ "Enterprise not found.",
1350
+ ).toConvexError();
1351
+ }
1352
+ const secretHash = await sha256(data.secret);
1353
+ const endpointId = (await ctx.runMutation(
1354
+ config.component.public.enterpriseWebhookEndpointCreate,
1355
+ {
1356
+ enterpriseId: enterprise._id,
1357
+ groupId: enterprise.groupId,
1358
+ url: data.url,
1359
+ secretHash,
1360
+ subscriptions: data.subscriptions,
1361
+ createdByUserId: data.createdByUserId,
1362
+ },
1363
+ )) as string;
1364
+ await recordEnterpriseAuditEvent(ctx, {
1365
+ enterpriseId: enterprise._id,
1366
+ groupId: enterprise.groupId,
1367
+ eventType: "enterprise.webhook.endpoint.created",
1368
+ actorType: data.createdByUserId ? "user" : "system",
1369
+ actorId: data.createdByUserId,
1370
+ subjectType: "enterprise_webhook_endpoint",
1371
+ subjectId: endpointId,
1372
+ ok: true,
1373
+ });
1374
+ return { endpointId };
1375
+ },
1376
+ list: async (ctx: ComponentReadCtx, enterpriseId: string) => {
1377
+ return await ctx.runQuery(
1378
+ config.component.public.enterpriseWebhookEndpointList,
1379
+ { enterpriseId },
1380
+ );
1381
+ },
1382
+ disable: async (ctx: ComponentCtx, endpointId: string) => {
1383
+ await ctx.runMutation(
1384
+ config.component.public.enterpriseWebhookEndpointUpdate,
1385
+ { endpointId, data: { status: "disabled" } },
1386
+ );
1387
+ },
1388
+ },
1389
+ emit: async (
1390
+ ctx: ComponentCtx,
1391
+ data: {
1392
+ enterpriseId: string;
1393
+ eventType: string;
1394
+ payload: Record<string, unknown>;
1395
+ auditEventId?: string;
1396
+ },
1397
+ ) => {
1398
+ await emitEnterpriseWebhookDeliveries(ctx, data);
1399
+ },
1400
+ delivery: {
1401
+ list: async (
1402
+ ctx: ComponentReadCtx,
1403
+ data: { enterpriseId: string; limit?: number },
1404
+ ) => {
1405
+ return await ctx.runQuery(
1406
+ (config.component.public as any).enterpriseWebhookDeliveryList,
1407
+ data,
1408
+ );
1409
+ },
1410
+ listReady: async (ctx: ComponentReadCtx, limit?: number) => {
1411
+ return await ctx.runQuery(
1412
+ config.component.public.enterpriseWebhookDeliveryListReady,
1413
+ { now: Date.now(), limit },
1414
+ );
1415
+ },
1416
+ markDelivered: async (
1417
+ ctx: ComponentCtx,
1418
+ deliveryId: string,
1419
+ responseStatus?: number,
1420
+ ) => {
1421
+ await ctx.runMutation(
1422
+ config.component.public.enterpriseWebhookDeliveryPatch,
1423
+ {
1424
+ deliveryId,
1425
+ data: {
1426
+ status: "delivered",
1427
+ attemptCount: 1,
1428
+ lastAttemptAt: Date.now(),
1429
+ lastResponseStatus: responseStatus,
1430
+ },
1431
+ },
1432
+ );
1433
+ },
1434
+ markFailed: async (
1435
+ ctx: ComponentCtx,
1436
+ deliveryId: string,
1437
+ data: {
1438
+ attemptCount: number;
1439
+ responseStatus?: number;
1440
+ error?: string;
1441
+ retryAt?: number;
1442
+ },
1443
+ ) => {
1444
+ await ctx.runMutation(
1445
+ config.component.public.enterpriseWebhookDeliveryPatch,
1446
+ {
1447
+ deliveryId,
1448
+ data: {
1449
+ status: data.retryAt ? "pending" : "failed",
1450
+ attemptCount: data.attemptCount,
1451
+ lastAttemptAt: Date.now(),
1452
+ lastResponseStatus: data.responseStatus,
1453
+ lastError: data.error,
1454
+ nextAttemptAt: data.retryAt ?? Date.now(),
1455
+ },
1456
+ },
1457
+ );
1458
+ },
1459
+ },
1460
+ },
1461
+ };
1462
+ }