@open-mercato/enterprise 0.4.6-develop-15c18897fc → 0.4.6-develop-34aa847ce6

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 (195) hide show
  1. package/dist/index.js +1 -1
  2. package/dist/index.js.map +2 -2
  3. package/dist/modules/sso/acl.js +11 -0
  4. package/dist/modules/sso/acl.js.map +7 -0
  5. package/dist/modules/sso/api/admin-context.js +27 -0
  6. package/dist/modules/sso/api/admin-context.js.map +7 -0
  7. package/dist/modules/sso/api/callback/oidc/route.js +103 -0
  8. package/dist/modules/sso/api/callback/oidc/route.js.map +7 -0
  9. package/dist/modules/sso/api/config/[id]/activate/route.js +49 -0
  10. package/dist/modules/sso/api/config/[id]/activate/route.js.map +7 -0
  11. package/dist/modules/sso/api/config/[id]/domains/route.js +96 -0
  12. package/dist/modules/sso/api/config/[id]/domains/route.js.map +7 -0
  13. package/dist/modules/sso/api/config/[id]/route.js +103 -0
  14. package/dist/modules/sso/api/config/[id]/route.js.map +7 -0
  15. package/dist/modules/sso/api/config/[id]/test/route.js +41 -0
  16. package/dist/modules/sso/api/config/[id]/test/route.js.map +7 -0
  17. package/dist/modules/sso/api/config/route.js +83 -0
  18. package/dist/modules/sso/api/config/route.js.map +7 -0
  19. package/dist/modules/sso/api/error-handler.js +28 -0
  20. package/dist/modules/sso/api/error-handler.js.map +7 -0
  21. package/dist/modules/sso/api/hrd/route.js +52 -0
  22. package/dist/modules/sso/api/hrd/route.js.map +7 -0
  23. package/dist/modules/sso/api/initiate/route.js +66 -0
  24. package/dist/modules/sso/api/initiate/route.js.map +7 -0
  25. package/dist/modules/sso/api/scim/context.js +68 -0
  26. package/dist/modules/sso/api/scim/context.js.map +7 -0
  27. package/dist/modules/sso/api/scim/logs/route.js +65 -0
  28. package/dist/modules/sso/api/scim/logs/route.js.map +7 -0
  29. package/dist/modules/sso/api/scim/tokens/[id]/route.js +42 -0
  30. package/dist/modules/sso/api/scim/tokens/[id]/route.js.map +7 -0
  31. package/dist/modules/sso/api/scim/tokens/route.js +83 -0
  32. package/dist/modules/sso/api/scim/tokens/route.js.map +7 -0
  33. package/dist/modules/sso/api/scim/v2/ServiceProviderConfig/route.js +42 -0
  34. package/dist/modules/sso/api/scim/v2/ServiceProviderConfig/route.js.map +7 -0
  35. package/dist/modules/sso/api/scim/v2/Users/[id]/route.js +94 -0
  36. package/dist/modules/sso/api/scim/v2/Users/[id]/route.js.map +7 -0
  37. package/dist/modules/sso/api/scim/v2/Users/route.js +86 -0
  38. package/dist/modules/sso/api/scim/v2/Users/route.js.map +7 -0
  39. package/dist/modules/sso/backend/page.js +173 -0
  40. package/dist/modules/sso/backend/page.js.map +7 -0
  41. package/dist/modules/sso/backend/page.meta.js +31 -0
  42. package/dist/modules/sso/backend/page.meta.js.map +7 -0
  43. package/dist/modules/sso/backend/sso/config/[id]/page.js +749 -0
  44. package/dist/modules/sso/backend/sso/config/[id]/page.js.map +7 -0
  45. package/dist/modules/sso/backend/sso/config/[id]/page.meta.js +19 -0
  46. package/dist/modules/sso/backend/sso/config/[id]/page.meta.js.map +7 -0
  47. package/dist/modules/sso/backend/sso/config/new/page.js +381 -0
  48. package/dist/modules/sso/backend/sso/config/new/page.js.map +7 -0
  49. package/dist/modules/sso/backend/sso/config/new/page.meta.js +19 -0
  50. package/dist/modules/sso/backend/sso/config/new/page.meta.js.map +7 -0
  51. package/dist/modules/sso/data/entities.js +299 -0
  52. package/dist/modules/sso/data/entities.js.map +7 -0
  53. package/dist/modules/sso/data/validators.js +114 -0
  54. package/dist/modules/sso/data/validators.js.map +7 -0
  55. package/dist/modules/sso/di.js +26 -0
  56. package/dist/modules/sso/di.js.map +7 -0
  57. package/dist/modules/sso/events.js +24 -0
  58. package/dist/modules/sso/events.js.map +7 -0
  59. package/dist/modules/sso/i18n/de.json +146 -0
  60. package/dist/modules/sso/i18n/en.json +146 -0
  61. package/dist/modules/sso/i18n/es.json +146 -0
  62. package/dist/modules/sso/i18n/pl.json +146 -0
  63. package/dist/modules/sso/index.js +11 -0
  64. package/dist/modules/sso/index.js.map +7 -0
  65. package/dist/modules/sso/lib/domains.js +30 -0
  66. package/dist/modules/sso/lib/domains.js.map +7 -0
  67. package/dist/modules/sso/lib/oidc-provider.js +140 -0
  68. package/dist/modules/sso/lib/oidc-provider.js.map +7 -0
  69. package/dist/modules/sso/lib/registry.js +15 -0
  70. package/dist/modules/sso/lib/registry.js.map +7 -0
  71. package/dist/modules/sso/lib/scim-filter.js +43 -0
  72. package/dist/modules/sso/lib/scim-filter.js.map +7 -0
  73. package/dist/modules/sso/lib/scim-mapper.js +49 -0
  74. package/dist/modules/sso/lib/scim-mapper.js.map +7 -0
  75. package/dist/modules/sso/lib/scim-patch.js +63 -0
  76. package/dist/modules/sso/lib/scim-patch.js.map +7 -0
  77. package/dist/modules/sso/lib/scim-response.js +34 -0
  78. package/dist/modules/sso/lib/scim-response.js.map +7 -0
  79. package/dist/modules/sso/lib/scim-utils.js +9 -0
  80. package/dist/modules/sso/lib/scim-utils.js.map +7 -0
  81. package/dist/modules/sso/lib/state-cookie.js +67 -0
  82. package/dist/modules/sso/lib/state-cookie.js.map +7 -0
  83. package/dist/modules/sso/lib/types.js +1 -0
  84. package/dist/modules/sso/lib/types.js.map +7 -0
  85. package/dist/modules/sso/migrations/Migration20260219000000_sso.js +20 -0
  86. package/dist/modules/sso/migrations/Migration20260219000000_sso.js.map +7 -0
  87. package/dist/modules/sso/migrations/Migration20260222000000_sso_add_name.js +13 -0
  88. package/dist/modules/sso/migrations/Migration20260222000000_sso_add_name.js.map +7 -0
  89. package/dist/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.js +15 -0
  90. package/dist/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.js.map +7 -0
  91. package/dist/modules/sso/migrations/Migration20260223000000_scim_tables.js +22 -0
  92. package/dist/modules/sso/migrations/Migration20260223000000_scim_tables.js.map +7 -0
  93. package/dist/modules/sso/migrations/Migration20260224000000_sso_external_id.js +15 -0
  94. package/dist/modules/sso/migrations/Migration20260224000000_sso_external_id.js.map +7 -0
  95. package/dist/modules/sso/migrations/Migration20260224100000_sso_role_grants.js +17 -0
  96. package/dist/modules/sso/migrations/Migration20260224100000_sso_role_grants.js.map +7 -0
  97. package/dist/modules/sso/migrations/Migration20260224200000_drop_default_role_id.js +13 -0
  98. package/dist/modules/sso/migrations/Migration20260224200000_drop_default_role_id.js.map +7 -0
  99. package/dist/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.js +23 -0
  100. package/dist/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.js.map +7 -0
  101. package/dist/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.js +14 -0
  102. package/dist/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.js.map +7 -0
  103. package/dist/modules/sso/services/accountLinkingService.js +298 -0
  104. package/dist/modules/sso/services/accountLinkingService.js.map +7 -0
  105. package/dist/modules/sso/services/hrdService.js +18 -0
  106. package/dist/modules/sso/services/hrdService.js.map +7 -0
  107. package/dist/modules/sso/services/scimService.js +372 -0
  108. package/dist/modules/sso/services/scimService.js.map +7 -0
  109. package/dist/modules/sso/services/scimTokenService.js +94 -0
  110. package/dist/modules/sso/services/scimTokenService.js.map +7 -0
  111. package/dist/modules/sso/services/ssoConfigService.js +254 -0
  112. package/dist/modules/sso/services/ssoConfigService.js.map +7 -0
  113. package/dist/modules/sso/services/ssoService.js +125 -0
  114. package/dist/modules/sso/services/ssoService.js.map +7 -0
  115. package/dist/modules/sso/setup.js +47 -0
  116. package/dist/modules/sso/setup.js.map +7 -0
  117. package/dist/modules/sso/subscribers/user-deleted-cleanup.js +21 -0
  118. package/dist/modules/sso/subscribers/user-deleted-cleanup.js.map +7 -0
  119. package/dist/modules/sso/widgets/injection/login-sso/widget.client.js +106 -0
  120. package/dist/modules/sso/widgets/injection/login-sso/widget.client.js.map +7 -0
  121. package/dist/modules/sso/widgets/injection/login-sso/widget.js +16 -0
  122. package/dist/modules/sso/widgets/injection/login-sso/widget.js.map +7 -0
  123. package/dist/modules/sso/widgets/injection-table.js +14 -0
  124. package/dist/modules/sso/widgets/injection-table.js.map +7 -0
  125. package/package.json +5 -4
  126. package/src/index.ts +1 -1
  127. package/src/modules/sso/acl.ts +7 -0
  128. package/src/modules/sso/api/admin-context.ts +36 -0
  129. package/src/modules/sso/api/callback/oidc/route.ts +115 -0
  130. package/src/modules/sso/api/config/[id]/activate/route.ts +53 -0
  131. package/src/modules/sso/api/config/[id]/domains/route.ts +107 -0
  132. package/src/modules/sso/api/config/[id]/route.ts +114 -0
  133. package/src/modules/sso/api/config/[id]/test/route.ts +44 -0
  134. package/src/modules/sso/api/config/route.ts +88 -0
  135. package/src/modules/sso/api/error-handler.ts +36 -0
  136. package/src/modules/sso/api/hrd/route.ts +55 -0
  137. package/src/modules/sso/api/initiate/route.ts +70 -0
  138. package/src/modules/sso/api/scim/context.ts +85 -0
  139. package/src/modules/sso/api/scim/logs/route.ts +69 -0
  140. package/src/modules/sso/api/scim/tokens/[id]/route.ts +45 -0
  141. package/src/modules/sso/api/scim/tokens/route.ts +89 -0
  142. package/src/modules/sso/api/scim/v2/ServiceProviderConfig/route.ts +40 -0
  143. package/src/modules/sso/api/scim/v2/Users/[id]/route.ts +103 -0
  144. package/src/modules/sso/api/scim/v2/Users/route.ts +94 -0
  145. package/src/modules/sso/backend/page.meta.ts +29 -0
  146. package/src/modules/sso/backend/page.tsx +232 -0
  147. package/src/modules/sso/backend/sso/config/[id]/page.meta.ts +15 -0
  148. package/src/modules/sso/backend/sso/config/[id]/page.tsx +1024 -0
  149. package/src/modules/sso/backend/sso/config/new/page.meta.ts +15 -0
  150. package/src/modules/sso/backend/sso/config/new/page.tsx +463 -0
  151. package/src/modules/sso/data/entities.ts +240 -0
  152. package/src/modules/sso/data/validators.ts +140 -0
  153. package/src/modules/sso/di.ts +25 -0
  154. package/src/modules/sso/docs/entra-id-setup.md +281 -0
  155. package/src/modules/sso/docs/google-workspace-setup.md +174 -0
  156. package/src/modules/sso/docs/sso-overview.md +218 -0
  157. package/src/modules/sso/docs/sso-security-audit-2026-02-27.md +118 -0
  158. package/src/modules/sso/docs/zitadel-setup.md +195 -0
  159. package/src/modules/sso/events.ts +21 -0
  160. package/src/modules/sso/i18n/de.json +146 -0
  161. package/src/modules/sso/i18n/en.json +146 -0
  162. package/src/modules/sso/i18n/es.json +146 -0
  163. package/src/modules/sso/i18n/pl.json +146 -0
  164. package/src/modules/sso/index.ts +7 -0
  165. package/src/modules/sso/lib/domains.ts +31 -0
  166. package/src/modules/sso/lib/oidc-provider.ts +196 -0
  167. package/src/modules/sso/lib/registry.ts +13 -0
  168. package/src/modules/sso/lib/scim-filter.ts +62 -0
  169. package/src/modules/sso/lib/scim-mapper.ts +88 -0
  170. package/src/modules/sso/lib/scim-patch.ts +88 -0
  171. package/src/modules/sso/lib/scim-response.ts +40 -0
  172. package/src/modules/sso/lib/scim-utils.ts +5 -0
  173. package/src/modules/sso/lib/state-cookie.ts +79 -0
  174. package/src/modules/sso/lib/types.ts +50 -0
  175. package/src/modules/sso/migrations/.snapshot-open-mercato.json +912 -0
  176. package/src/modules/sso/migrations/Migration20260219000000_sso.ts +21 -0
  177. package/src/modules/sso/migrations/Migration20260222000000_sso_add_name.ts +13 -0
  178. package/src/modules/sso/migrations/Migration20260222000001_sso_partial_unique_org.ts +15 -0
  179. package/src/modules/sso/migrations/Migration20260223000000_scim_tables.ts +24 -0
  180. package/src/modules/sso/migrations/Migration20260224000000_sso_external_id.ts +15 -0
  181. package/src/modules/sso/migrations/Migration20260224100000_sso_role_grants.ts +18 -0
  182. package/src/modules/sso/migrations/Migration20260224200000_drop_default_role_id.ts +13 -0
  183. package/src/modules/sso/migrations/Migration20260225000000_sso_identities_partial_unique.ts +25 -0
  184. package/src/modules/sso/migrations/Migration20260305000000_sso_role_grants_org_id.ts +14 -0
  185. package/src/modules/sso/services/accountLinkingService.ts +386 -0
  186. package/src/modules/sso/services/hrdService.ts +22 -0
  187. package/src/modules/sso/services/scimService.ts +461 -0
  188. package/src/modules/sso/services/scimTokenService.ts +136 -0
  189. package/src/modules/sso/services/ssoConfigService.ts +337 -0
  190. package/src/modules/sso/services/ssoService.ts +167 -0
  191. package/src/modules/sso/setup.ts +56 -0
  192. package/src/modules/sso/subscribers/user-deleted-cleanup.ts +33 -0
  193. package/src/modules/sso/widgets/injection/login-sso/widget.client.tsx +130 -0
  194. package/src/modules/sso/widgets/injection/login-sso/widget.ts +16 -0
  195. package/src/modules/sso/widgets/injection-table.ts +12 -0
@@ -0,0 +1,88 @@
1
+ import type { User } from '@open-mercato/core/modules/auth/data/entities'
2
+ import type { SsoIdentity, SsoUserDeactivation } from '../data/entities'
3
+ import { coerceBoolean } from './scim-utils'
4
+
5
+ const SCIM_USER_SCHEMA = 'urn:ietf:params:scim:schemas:core:2.0:User'
6
+
7
+ export interface ScimUserResource {
8
+ schemas: string[]
9
+ id: string
10
+ externalId?: string
11
+ userName: string
12
+ displayName?: string
13
+ name?: { givenName?: string; familyName?: string; formatted?: string }
14
+ emails?: Array<{ value: string; primary: boolean; type: string }>
15
+ active: boolean
16
+ meta: {
17
+ resourceType: string
18
+ created: string
19
+ lastModified: string
20
+ location: string
21
+ }
22
+ }
23
+
24
+ export function toScimUserResource(
25
+ user: User,
26
+ identity: SsoIdentity,
27
+ baseUrl: string,
28
+ deactivation?: SsoUserDeactivation | null,
29
+ ): ScimUserResource {
30
+ const isActive = !deactivation || deactivation.reactivatedAt != null
31
+
32
+ const nameParts = (user.name ?? '').split(' ')
33
+ const givenName = nameParts[0] || undefined
34
+ const familyName = nameParts.length > 1 ? nameParts.slice(1).join(' ') : undefined
35
+
36
+ return {
37
+ schemas: [SCIM_USER_SCHEMA],
38
+ id: identity.id,
39
+ ...(identity.externalId ? { externalId: identity.externalId } : {}),
40
+ userName: identity.idpEmail,
41
+ displayName: user.name ?? undefined,
42
+ name: (givenName || familyName) ? { givenName, familyName, formatted: user.name ?? undefined } : undefined,
43
+ emails: [{ value: identity.idpEmail, primary: true, type: 'work' }],
44
+ active: isActive,
45
+ meta: {
46
+ resourceType: 'User',
47
+ created: identity.createdAt.toISOString(),
48
+ lastModified: identity.updatedAt.toISOString(),
49
+ location: `${baseUrl}/api/sso/scim/v2/Users/${identity.id}`,
50
+ },
51
+ }
52
+ }
53
+
54
+ export interface ScimUserPayload {
55
+ userName?: string
56
+ externalId?: string
57
+ displayName?: string
58
+ givenName?: string
59
+ familyName?: string
60
+ email?: string
61
+ active?: boolean
62
+ }
63
+
64
+ export function fromScimUserPayload(payload: Record<string, unknown>): ScimUserPayload {
65
+ const result: ScimUserPayload = {}
66
+
67
+ if (typeof payload.userName === 'string') result.userName = payload.userName
68
+ if (typeof payload.externalId === 'string') result.externalId = payload.externalId
69
+ if (typeof payload.displayName === 'string') result.displayName = payload.displayName
70
+
71
+ if (payload.active !== undefined) {
72
+ result.active = coerceBoolean(payload.active)
73
+ }
74
+
75
+ const name = payload.name as Record<string, unknown> | undefined
76
+ if (name && typeof name === 'object') {
77
+ if (typeof name.givenName === 'string') result.givenName = name.givenName
78
+ if (typeof name.familyName === 'string') result.familyName = name.familyName
79
+ }
80
+
81
+ const emails = payload.emails as Array<Record<string, unknown>> | undefined
82
+ if (Array.isArray(emails) && emails.length > 0) {
83
+ const primary = emails.find((e) => e.primary === true) ?? emails[0]
84
+ if (typeof primary?.value === 'string') result.email = primary.value
85
+ }
86
+
87
+ return result
88
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * SCIM PatchOp parser with Entra ID quirks:
3
+ * - Case-insensitive `op` (e.g., "Replace" vs "replace")
4
+ * - Boolean leniency ("False"/"True" strings → boolean)
5
+ * - Strict attribute allowlist
6
+ */
7
+
8
+ import { coerceBoolean } from './scim-utils'
9
+
10
+ export interface ScimPatchOperation {
11
+ op: string
12
+ path?: string
13
+ value?: unknown
14
+ }
15
+
16
+ const ALLOWED_PATHS = new Set([
17
+ 'active',
18
+ 'displayname',
19
+ 'name.givenname',
20
+ 'name.familyname',
21
+ 'username',
22
+ 'externalid',
23
+ ])
24
+
25
+ export function parseScimPatchOperations(body: Record<string, unknown>): ScimPatchOperation[] {
26
+ const operations = body.Operations as Array<Record<string, unknown>> | undefined
27
+ if (!Array.isArray(operations)) {
28
+ throw new ScimPatchError('PatchOp body must contain Operations array')
29
+ }
30
+
31
+ return operations.map((rawOp) => {
32
+ const op = String(rawOp.op ?? '').toLowerCase()
33
+ if (!['add', 'replace', 'remove'].includes(op)) {
34
+ throw new ScimPatchError(`Unsupported SCIM PatchOp: ${rawOp.op}`)
35
+ }
36
+
37
+ const path = rawOp.path ? String(rawOp.path) : undefined
38
+
39
+ // Validate path against allowlist if present
40
+ if (path) {
41
+ const normalizedPath = path.toLowerCase()
42
+ if (!ALLOWED_PATHS.has(normalizedPath)) {
43
+ // Silently ignore unsupported attributes (Entra sends many)
44
+ return { op, path, value: undefined }
45
+ }
46
+ }
47
+
48
+ const value = normalizePatchValue(rawOp.value, path)
49
+
50
+ return { op, path, value }
51
+ }).filter((op) => {
52
+ // Filter out no-ops (unsupported paths where value was set to undefined)
53
+ if (op.op !== 'remove' && op.value === undefined && op.path) return false
54
+ return true
55
+ })
56
+ }
57
+
58
+ function normalizePatchValue(value: unknown, path?: string): unknown {
59
+ if (value === undefined || value === null) return value
60
+
61
+ // Handle boolean leniency for the `active` attribute
62
+ if (path && path.toLowerCase() === 'active') {
63
+ return coerceBoolean(value)
64
+ }
65
+
66
+ // Handle value objects (no-path operations)
67
+ if (!path && typeof value === 'object' && value !== null) {
68
+ const obj = value as Record<string, unknown>
69
+ const normalized: Record<string, unknown> = {}
70
+ for (const [key, val] of Object.entries(obj)) {
71
+ if (key.toLowerCase() === 'active') {
72
+ normalized[key] = coerceBoolean(val)
73
+ } else {
74
+ normalized[key] = val
75
+ }
76
+ }
77
+ return normalized
78
+ }
79
+
80
+ return value
81
+ }
82
+
83
+ export class ScimPatchError extends Error {
84
+ constructor(message: string) {
85
+ super(message)
86
+ this.name = 'ScimPatchError'
87
+ }
88
+ }
@@ -0,0 +1,40 @@
1
+ export const SCIM_CONTENT_TYPE = 'application/scim+json'
2
+
3
+ const SCIM_ERROR_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:Error'
4
+ const SCIM_LIST_SCHEMA = 'urn:ietf:params:scim:api:messages:2.0:ListResponse'
5
+
6
+ export function scimJson(data: unknown, status = 200): Response {
7
+ return new Response(JSON.stringify(data), {
8
+ status,
9
+ headers: { 'Content-Type': SCIM_CONTENT_TYPE },
10
+ })
11
+ }
12
+
13
+ export function buildScimError(
14
+ status: number,
15
+ detail: string,
16
+ scimType?: string,
17
+ ): Record<string, unknown> {
18
+ const body: Record<string, unknown> = {
19
+ schemas: [SCIM_ERROR_SCHEMA],
20
+ status: String(status),
21
+ detail,
22
+ }
23
+ if (scimType) body.scimType = scimType
24
+ return body
25
+ }
26
+
27
+ export function buildListResponse(
28
+ resources: unknown[],
29
+ totalResults: number,
30
+ startIndex: number,
31
+ itemsPerPage: number,
32
+ ): Record<string, unknown> {
33
+ return {
34
+ schemas: [SCIM_LIST_SCHEMA],
35
+ totalResults,
36
+ startIndex,
37
+ itemsPerPage,
38
+ Resources: resources,
39
+ }
40
+ }
@@ -0,0 +1,5 @@
1
+ export function coerceBoolean(value: unknown): boolean {
2
+ if (typeof value === 'boolean') return value
3
+ if (typeof value === 'string') return value.toLowerCase() === 'true'
4
+ return Boolean(value)
5
+ }
@@ -0,0 +1,79 @@
1
+ import crypto from 'node:crypto'
2
+ import type { SsoFlowState } from './types'
3
+
4
+ const ALGORITHM = 'aes-256-gcm'
5
+ const IV_LENGTH = 12
6
+ const TAG_LENGTH = 16
7
+ const TTL_MS = 5 * 60 * 1000
8
+ const HKDF_SALT = Buffer.from('open-mercato-sso-state-v1')
9
+ const HKDF_INFO = Buffer.from('sso-state-cookie')
10
+
11
+ function deriveKey(secret: string): Buffer {
12
+ return Buffer.from(crypto.hkdfSync('sha256', secret, HKDF_SALT, HKDF_INFO, 32))
13
+ }
14
+
15
+ function getSecret(): string {
16
+ const secret = process.env.SSO_STATE_SECRET || process.env.JWT_SECRET
17
+ if (!secret) throw new Error('SSO_STATE_SECRET or JWT_SECRET must be set')
18
+ return secret
19
+ }
20
+
21
+ export function encryptStateCookie(payload: SsoFlowState): string {
22
+ const secret = getSecret()
23
+ const key = deriveKey(secret)
24
+ const iv = crypto.randomBytes(IV_LENGTH)
25
+ const json = JSON.stringify(payload)
26
+
27
+ const cipher = crypto.createCipheriv(ALGORITHM, key, iv)
28
+ const ciphertext = Buffer.concat([cipher.update(json, 'utf8'), cipher.final()])
29
+ const tag = cipher.getAuthTag()
30
+
31
+ const combined = Buffer.concat([iv, tag, ciphertext])
32
+ return combined.toString('base64url')
33
+ }
34
+
35
+ export function decryptStateCookie(cookie: string): SsoFlowState | null {
36
+ try {
37
+ const secret = getSecret()
38
+ const key = deriveKey(secret)
39
+ const combined = Buffer.from(cookie, 'base64url')
40
+
41
+ if (combined.length < IV_LENGTH + TAG_LENGTH) return null
42
+
43
+ const iv = combined.subarray(0, IV_LENGTH)
44
+ const tag = combined.subarray(IV_LENGTH, IV_LENGTH + TAG_LENGTH)
45
+ const ciphertext = combined.subarray(IV_LENGTH + TAG_LENGTH)
46
+
47
+ const decipher = crypto.createDecipheriv(ALGORITHM, key, iv)
48
+ decipher.setAuthTag(tag)
49
+ const decrypted = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8')
50
+
51
+ const payload = JSON.parse(decrypted) as SsoFlowState
52
+ if (payload.expiresAt < Date.now()) return null
53
+
54
+ return payload
55
+ } catch {
56
+ return null
57
+ }
58
+ }
59
+
60
+ export function createFlowState(params: {
61
+ configId: string
62
+ returnUrl: string
63
+ }): { state: SsoFlowState; codeVerifier: string } {
64
+ const state = crypto.randomBytes(32).toString('base64url')
65
+ const nonce = crypto.randomBytes(16).toString('base64url')
66
+ const codeVerifier = crypto.randomBytes(32).toString('base64url')
67
+
68
+ return {
69
+ state: {
70
+ state,
71
+ nonce,
72
+ codeVerifier,
73
+ configId: params.configId,
74
+ returnUrl: params.returnUrl,
75
+ expiresAt: Date.now() + TTL_MS,
76
+ },
77
+ codeVerifier,
78
+ }
79
+ }
@@ -0,0 +1,50 @@
1
+ import type { SsoConfig } from '../data/entities'
2
+
3
+ export interface SsoProtocolProvider {
4
+ readonly protocol: 'oidc' | 'saml'
5
+
6
+ buildAuthUrl(
7
+ config: SsoConfig,
8
+ params: {
9
+ state: string
10
+ nonce: string
11
+ redirectUri: string
12
+ codeVerifier?: string
13
+ clientSecret?: string
14
+ },
15
+ ): Promise<string>
16
+
17
+ handleCallback(
18
+ config: SsoConfig,
19
+ params: {
20
+ callbackParams: Record<string, string>
21
+ redirectUri: string
22
+ expectedState: string
23
+ expectedNonce: string
24
+ codeVerifier?: string
25
+ clientSecret?: string
26
+ },
27
+ ): Promise<SsoIdentityPayload>
28
+
29
+ validateConfig(
30
+ config: SsoConfig,
31
+ params?: { clientSecret?: string },
32
+ ): Promise<{ ok: boolean; error?: string }>
33
+ }
34
+
35
+ export interface SsoIdentityPayload {
36
+ subject: string
37
+ email: string
38
+ emailVerified: boolean
39
+ name?: string
40
+ groups?: string[]
41
+ }
42
+
43
+ export interface SsoFlowState {
44
+ state: string
45
+ nonce: string
46
+ codeVerifier: string
47
+ configId: string
48
+ returnUrl: string
49
+ expiresAt: number
50
+ }