@checkstack/auth-saml-backend 0.1.0

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,35 @@
1
+ # @checkstack/auth-saml-backend
2
+
3
+ ## 0.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 10aa9fb: Add SAML 2.0 SSO support
8
+
9
+ - Added new `auth-saml-backend` plugin for SAML 2.0 Single Sign-On authentication
10
+ - Supports SP-initiated SSO with configurable IdP metadata (URL or manual configuration)
11
+ - Uses samlify library for SAML protocol handling
12
+ - Configurable attribute mapping for user email/name extraction
13
+ - Automatic user creation and updates via S2S Identity API
14
+ - Added SAML redirect handling in LoginPage for seamless SSO flow
15
+
16
+ - d94121b: Add group-to-role mapping for SAML and LDAP authentication
17
+
18
+ **Features:**
19
+
20
+ - SAML and LDAP users can now be automatically assigned Checkstack roles based on their directory group memberships
21
+ - Configure group mappings in the authentication strategy settings with dynamic role dropdowns
22
+ - Managed role sync: roles configured in mappings are fully synchronized (added when user gains group, removed when user leaves group)
23
+ - Unmanaged roles (manually assigned, not in any mapping) are preserved during sync
24
+ - Optional default role for all users from a directory
25
+
26
+ **Bug Fix:**
27
+
28
+ - Fixed `x-options-resolver` not working for fields inside arrays with `.default([])` in DynamicForm schemas
29
+
30
+ ### Patch Changes
31
+
32
+ - Updated dependencies [d94121b]
33
+ - @checkstack/backend-api@0.3.3
34
+ - @checkstack/auth-backend@0.4.0
35
+ - @checkstack/auth-common@0.5.0
package/package.json ADDED
@@ -0,0 +1,32 @@
1
+ {
2
+ "name": "@checkstack/auth-saml-backend",
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "main": "src/index.ts",
6
+ "exports": {
7
+ ".": "./src/index.ts"
8
+ },
9
+ "scripts": {
10
+ "typecheck": "tsc --noEmit"
11
+ },
12
+ "dependencies": {
13
+ "@checkstack/backend-api": "workspace:*",
14
+ "@checkstack/auth-backend": "workspace:*",
15
+ "@checkstack/auth-common": "workspace:*",
16
+ "@checkstack/common": "workspace:*",
17
+ "better-auth": "^1.4.9",
18
+ "samlify": "^2.8.11",
19
+ "zod": "^4.0.0"
20
+ },
21
+ "devDependencies": {
22
+ "@checkstack/tsconfig": "workspace:*",
23
+ "@checkstack/test-utils-backend": "workspace:*",
24
+ "typescript": "^5.7.2"
25
+ },
26
+ "plugin": {
27
+ "id": "auth-saml-backend",
28
+ "name": "@checkstack/auth-saml-backend",
29
+ "type": "backend",
30
+ "displayName": "SAML Authentication (Backend)"
31
+ }
32
+ }
package/src/helpers.ts ADDED
@@ -0,0 +1,33 @@
1
+ /**
2
+ * Helper to extract attribute value from SAML assertion
3
+ * Handles both single values and arrays (takes first element)
4
+ */
5
+ export const extractAttribute = ({
6
+ attributes,
7
+ attributeName,
8
+ }: {
9
+ attributes: Record<string, unknown>;
10
+ attributeName: string;
11
+ }): string | undefined => {
12
+ const value = attributes[attributeName];
13
+ if (typeof value === "string") return value;
14
+ if (Array.isArray(value) && value.length > 0) return String(value[0]);
15
+ return undefined;
16
+ };
17
+
18
+ /**
19
+ * Helper to extract groups from SAML assertion (multi-valued attribute)
20
+ * Returns all groups as an array of strings
21
+ */
22
+ export const extractGroups = ({
23
+ attributes,
24
+ groupAttribute,
25
+ }: {
26
+ attributes: Record<string, unknown>;
27
+ groupAttribute: string;
28
+ }): string[] => {
29
+ const value = attributes[groupAttribute];
30
+ if (typeof value === "string") return [value];
31
+ if (Array.isArray(value)) return value.map(String);
32
+ return [];
33
+ };
@@ -0,0 +1,263 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { z } from "zod";
3
+ import { configString, configBoolean } from "@checkstack/backend-api";
4
+ import { extractAttribute, extractGroups } from "./helpers";
5
+
6
+ // Re-create the config schema for testing
7
+ const samlConfigV1 = z.object({
8
+ idpMetadataUrl: configString({}).url().optional(),
9
+ idpMetadata: configString({}).optional(),
10
+ idpEntityId: configString({}).optional(),
11
+ idpSingleSignOnUrl: configString({}).url().optional(),
12
+ idpCertificate: configString({ "x-secret": true }).optional(),
13
+ spEntityId: configString({}).default("checkstack"),
14
+ spPrivateKey: configString({ "x-secret": true }).optional(),
15
+ spCertificate: configString({}).optional(),
16
+ attributeMapping: z
17
+ .object({
18
+ email: configString({})
19
+ .default(
20
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
21
+ )
22
+ .describe("SAML attribute for email address"),
23
+ name: configString({})
24
+ .default("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")
25
+ .describe("SAML attribute for display name"),
26
+ firstName: configString({}).optional(),
27
+ lastName: configString({}).optional(),
28
+ })
29
+ .default({
30
+ email:
31
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
32
+ name: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
33
+ }),
34
+ wantAssertionsSigned: configBoolean({}).default(true),
35
+ signAuthnRequest: configBoolean({}).default(false),
36
+ });
37
+
38
+ describe("SAML Configuration Schema", () => {
39
+ describe("validation", () => {
40
+ it("should accept valid config with metadata URL", () => {
41
+ const config = {
42
+ idpMetadataUrl: "https://idp.example.com/metadata",
43
+ spEntityId: "my-app",
44
+ };
45
+
46
+ const result = samlConfigV1.safeParse(config);
47
+ expect(result.success).toBe(true);
48
+ if (result.success) {
49
+ expect(result.data.idpMetadataUrl).toBe(
50
+ "https://idp.example.com/metadata",
51
+ );
52
+ expect(result.data.spEntityId).toBe("my-app");
53
+ }
54
+ });
55
+
56
+ it("should accept valid config with manual IdP settings", () => {
57
+ const config = {
58
+ idpSingleSignOnUrl: "https://idp.example.com/sso",
59
+ idpCertificate:
60
+ "-----BEGIN CERTIFICATE-----\\nMIIC...\\n-----END CERTIFICATE-----",
61
+ idpEntityId: "https://idp.example.com",
62
+ spEntityId: "my-app",
63
+ };
64
+
65
+ const result = samlConfigV1.safeParse(config);
66
+ expect(result.success).toBe(true);
67
+ });
68
+
69
+ it("should apply default values", () => {
70
+ const config = {};
71
+
72
+ const result = samlConfigV1.safeParse(config);
73
+ expect(result.success).toBe(true);
74
+ if (result.success) {
75
+ expect(result.data.spEntityId).toBe("checkstack");
76
+ expect(result.data.wantAssertionsSigned).toBe(true);
77
+ expect(result.data.signAuthnRequest).toBe(false);
78
+ expect(result.data.attributeMapping.email).toBe(
79
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
80
+ );
81
+ expect(result.data.attributeMapping.name).toBe(
82
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
83
+ );
84
+ }
85
+ });
86
+
87
+ it("should reject invalid IdP metadata URL", () => {
88
+ const config = {
89
+ idpMetadataUrl: "not-a-valid-url",
90
+ };
91
+
92
+ const result = samlConfigV1.safeParse(config);
93
+ expect(result.success).toBe(false);
94
+ });
95
+
96
+ it("should reject invalid IdP SSO URL", () => {
97
+ const config = {
98
+ idpSingleSignOnUrl: "not-a-valid-url",
99
+ };
100
+
101
+ const result = samlConfigV1.safeParse(config);
102
+ expect(result.success).toBe(false);
103
+ });
104
+ });
105
+
106
+ describe("attribute mapping", () => {
107
+ it("should accept custom attribute mappings", () => {
108
+ const config = {
109
+ attributeMapping: {
110
+ email: "mail",
111
+ name: "displayName",
112
+ firstName: "givenName",
113
+ lastName: "sn",
114
+ },
115
+ };
116
+
117
+ const result = samlConfigV1.safeParse(config);
118
+ expect(result.success).toBe(true);
119
+ if (result.success) {
120
+ expect(result.data.attributeMapping.email).toBe("mail");
121
+ expect(result.data.attributeMapping.name).toBe("displayName");
122
+ expect(result.data.attributeMapping.firstName).toBe("givenName");
123
+ expect(result.data.attributeMapping.lastName).toBe("sn");
124
+ }
125
+ });
126
+ });
127
+ });
128
+
129
+ describe("extractAttribute helper", () => {
130
+ it("should extract string values", () => {
131
+ const attributes = { email: "user@example.com" };
132
+ const result = extractAttribute({
133
+ attributes,
134
+ attributeName: "email",
135
+ });
136
+ expect(result).toBe("user@example.com");
137
+ });
138
+
139
+ it("should extract first element from arrays", () => {
140
+ const attributes = { email: ["user@example.com", "other@example.com"] };
141
+ const result = extractAttribute({
142
+ attributes,
143
+ attributeName: "email",
144
+ });
145
+ expect(result).toBe("user@example.com");
146
+ });
147
+
148
+ it("should return undefined for missing attributes", () => {
149
+ const attributes = {};
150
+ const result = extractAttribute({
151
+ attributes,
152
+ attributeName: "email",
153
+ });
154
+ expect(result).toBeUndefined();
155
+ });
156
+
157
+ it("should return undefined for empty arrays", () => {
158
+ const attributes = { email: [] };
159
+ const result = extractAttribute({
160
+ attributes,
161
+ attributeName: "email",
162
+ });
163
+ expect(result).toBeUndefined();
164
+ });
165
+
166
+ it("should convert non-string values to string", () => {
167
+ const attributes = { id: [12345] };
168
+ const result = extractAttribute({
169
+ attributes,
170
+ attributeName: "id",
171
+ });
172
+ expect(result).toBe("12345");
173
+ });
174
+ });
175
+
176
+ describe("extractGroups helper", () => {
177
+ it("should extract single group as array", () => {
178
+ const attributes = {
179
+ "http://schemas.xmlsoap.org/claims/Group": "Developers",
180
+ };
181
+ const result = extractGroups({
182
+ attributes,
183
+ groupAttribute: "http://schemas.xmlsoap.org/claims/Group",
184
+ });
185
+ expect(result).toEqual(["Developers"]);
186
+ });
187
+
188
+ it("should extract multiple groups from array", () => {
189
+ const attributes = {
190
+ "http://schemas.xmlsoap.org/claims/Group": [
191
+ "Developers",
192
+ "Admins",
193
+ "Users",
194
+ ],
195
+ };
196
+ const result = extractGroups({
197
+ attributes,
198
+ groupAttribute: "http://schemas.xmlsoap.org/claims/Group",
199
+ });
200
+ expect(result).toEqual(["Developers", "Admins", "Users"]);
201
+ });
202
+
203
+ it("should return empty array for missing group attribute", () => {
204
+ const attributes = {
205
+ email: "user@example.com",
206
+ };
207
+ const result = extractGroups({
208
+ attributes,
209
+ groupAttribute: "http://schemas.xmlsoap.org/claims/Group",
210
+ });
211
+ expect(result).toEqual([]);
212
+ });
213
+
214
+ it("should return empty array for empty group array", () => {
215
+ const attributes = {
216
+ "http://schemas.xmlsoap.org/claims/Group": [],
217
+ };
218
+ const result = extractGroups({
219
+ attributes,
220
+ groupAttribute: "http://schemas.xmlsoap.org/claims/Group",
221
+ });
222
+ expect(result).toEqual([]);
223
+ });
224
+
225
+ it("should convert non-string group values to strings", () => {
226
+ const attributes = {
227
+ groups: [123, "Developers", true],
228
+ };
229
+ const result = extractGroups({
230
+ attributes,
231
+ groupAttribute: "groups",
232
+ });
233
+ expect(result).toEqual(["123", "Developers", "true"]);
234
+ });
235
+
236
+ it("should handle undefined/null values gracefully", () => {
237
+ const attributes = {
238
+ groups: undefined,
239
+ };
240
+ const result = extractGroups({
241
+ attributes,
242
+ groupAttribute: "groups",
243
+ });
244
+ expect(result).toEqual([]);
245
+ });
246
+
247
+ it("should handle complex group DNs from AD/LDAP", () => {
248
+ const attributes = {
249
+ memberOf: [
250
+ "CN=Developers,OU=Groups,DC=example,DC=com",
251
+ "CN=All-Users,OU=Groups,DC=example,DC=com",
252
+ ],
253
+ };
254
+ const result = extractGroups({
255
+ attributes,
256
+ groupAttribute: "memberOf",
257
+ });
258
+ expect(result).toEqual([
259
+ "CN=Developers,OU=Groups,DC=example,DC=com",
260
+ "CN=All-Users,OU=Groups,DC=example,DC=com",
261
+ ]);
262
+ });
263
+ });
package/src/index.ts ADDED
@@ -0,0 +1,595 @@
1
+ import {
2
+ createBackendPlugin,
3
+ type AuthStrategy,
4
+ configString,
5
+ coreServices,
6
+ configBoolean,
7
+ } from "@checkstack/backend-api";
8
+ import { pluginMetadata } from "./plugin-metadata";
9
+ import {
10
+ betterAuthExtensionPoint,
11
+ redirectToAuthError,
12
+ } from "@checkstack/auth-backend";
13
+ import { AuthApi } from "@checkstack/auth-common";
14
+ import { z } from "zod";
15
+ import { hashPassword } from "better-auth/crypto";
16
+ import * as samlify from "samlify";
17
+ import { extractAttribute, extractGroups } from "./helpers";
18
+
19
+ // SAML Configuration Schema V1
20
+ const _samlConfigV1 = z.object({
21
+ // Identity Provider configuration
22
+ idpMetadataUrl: configString({})
23
+ .url()
24
+ .optional()
25
+ .describe(
26
+ "URL to fetch IdP metadata XML (optional if providing metadata directly)",
27
+ ),
28
+ idpMetadata: configString({})
29
+ .optional()
30
+ .describe("IdP metadata XML content (used if URL is not provided)"),
31
+ idpEntityId: configString({})
32
+ .optional()
33
+ .describe("IdP Entity ID (extracted from metadata if not provided)"),
34
+ idpSingleSignOnUrl: configString({})
35
+ .url()
36
+ .optional()
37
+ .describe("IdP SSO URL (extracted from metadata if not provided)"),
38
+ idpCertificate: configString({ "x-secret": true })
39
+ .optional()
40
+ .describe("IdP X.509 certificate for signature validation (PEM format)"),
41
+
42
+ // Service Provider configuration
43
+ spEntityId: configString({})
44
+ .default("checkstack")
45
+ .describe("Service Provider Entity ID (your application identifier)"),
46
+ spPrivateKey: configString({ "x-secret": true })
47
+ .optional()
48
+ .describe("SP private key for signing requests (PEM format)"),
49
+ spCertificate: configString({})
50
+ .optional()
51
+ .describe("SP public certificate (PEM format)"),
52
+
53
+ // Attribute mapping
54
+ attributeMapping: z
55
+ .object({
56
+ email: configString({})
57
+ .default(
58
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
59
+ )
60
+ .describe("SAML attribute for email address"),
61
+ name: configString({})
62
+ .default("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")
63
+ .describe("SAML attribute for display name"),
64
+ firstName: configString({})
65
+ .default(
66
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
67
+ )
68
+ .describe("SAML attribute for first name")
69
+ .optional(),
70
+ lastName: configString({})
71
+ .default(
72
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
73
+ )
74
+ .describe("SAML attribute for last name")
75
+ .optional(),
76
+ })
77
+ .default({
78
+ email:
79
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
80
+ name: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
81
+ })
82
+ .describe("Map SAML attributes to user fields"),
83
+
84
+ // Security options
85
+ wantAssertionsSigned: configBoolean({})
86
+ .default(true)
87
+ .describe("Require signed SAML assertions"),
88
+ signAuthnRequest: configBoolean({})
89
+ .default(false)
90
+ .describe("Sign authentication requests sent to IdP"),
91
+ });
92
+
93
+ // SAML Configuration Schema V2 - Adds group-to-role mapping
94
+ const samlConfigV2 = z.object({
95
+ // Identity Provider configuration
96
+ idpMetadataUrl: configString({})
97
+ .url()
98
+ .optional()
99
+ .describe(
100
+ "URL to fetch IdP metadata XML (optional if providing metadata directly)",
101
+ ),
102
+ idpMetadata: configString({})
103
+ .optional()
104
+ .describe("IdP metadata XML content (used if URL is not provided)"),
105
+ idpEntityId: configString({})
106
+ .optional()
107
+ .describe("IdP Entity ID (extracted from metadata if not provided)"),
108
+ idpSingleSignOnUrl: configString({})
109
+ .url()
110
+ .optional()
111
+ .describe("IdP SSO URL (extracted from metadata if not provided)"),
112
+ idpCertificate: configString({ "x-secret": true })
113
+ .optional()
114
+ .describe("IdP X.509 certificate for signature validation (PEM format)"),
115
+
116
+ // Service Provider configuration
117
+ spEntityId: configString({})
118
+ .default("checkstack")
119
+ .describe("Service Provider Entity ID (your application identifier)"),
120
+ spPrivateKey: configString({ "x-secret": true })
121
+ .optional()
122
+ .describe("SP private key for signing requests (PEM format)"),
123
+ spCertificate: configString({})
124
+ .optional()
125
+ .describe("SP public certificate (PEM format)"),
126
+
127
+ // Attribute mapping
128
+ attributeMapping: z
129
+ .object({
130
+ email: configString({})
131
+ .default(
132
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
133
+ )
134
+ .describe("SAML attribute for email address"),
135
+ name: configString({})
136
+ .default("http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name")
137
+ .describe("SAML attribute for display name"),
138
+ firstName: configString({})
139
+ .default(
140
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/givenname",
141
+ )
142
+ .describe("SAML attribute for first name")
143
+ .optional(),
144
+ lastName: configString({})
145
+ .default(
146
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/surname",
147
+ )
148
+ .describe("SAML attribute for last name")
149
+ .optional(),
150
+ })
151
+ .default({
152
+ email:
153
+ "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
154
+ name: "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
155
+ })
156
+ .describe("Map SAML attributes to user fields"),
157
+
158
+ // Group to Role Mapping
159
+ groupMapping: z
160
+ .object({
161
+ enabled: configBoolean({})
162
+ .default(false)
163
+ .describe("Enable group-to-role mapping"),
164
+ groupAttribute: configString({})
165
+ .default("http://schemas.xmlsoap.org/claims/Group")
166
+ .describe("SAML attribute containing group memberships"),
167
+ mappings: z
168
+ .array(
169
+ z.object({
170
+ directoryGroup: configString({}).describe(
171
+ "Directory group name or DN",
172
+ ),
173
+ checkstackRole: configString({
174
+ "x-options-resolver": "roleOptions",
175
+ }).describe("Checkstack role ID to assign"),
176
+ }),
177
+ )
178
+ .default([])
179
+ .describe("Map directory groups to Checkstack roles"),
180
+ defaultRole: configString({
181
+ "x-options-resolver": "roleOptions",
182
+ })
183
+ .optional()
184
+ .describe("Default role assigned to all SAML users (optional)"),
185
+ })
186
+ .default({
187
+ enabled: false,
188
+ groupAttribute: "http://schemas.xmlsoap.org/claims/Group",
189
+ mappings: [],
190
+ })
191
+ .describe("Map SAML groups to Checkstack roles"),
192
+
193
+ // Security options
194
+ wantAssertionsSigned: configBoolean({})
195
+ .default(true)
196
+ .describe("Require signed SAML assertions"),
197
+ signAuthnRequest: configBoolean({})
198
+ .default(false)
199
+ .describe("Sign authentication requests sent to IdP"),
200
+ });
201
+
202
+ type SamlConfig = z.infer<typeof samlConfigV2>;
203
+
204
+ // SAML Strategy Definition
205
+ const samlStrategy: AuthStrategy<SamlConfig> = {
206
+ id: "saml",
207
+ displayName: "SAML SSO",
208
+ description: "Enterprise Single Sign-On via SAML 2.0",
209
+ icon: "KeyRound",
210
+ configVersion: 2,
211
+ configSchema: samlConfigV2,
212
+ migrations: [
213
+ {
214
+ description: "Add group-to-role mapping configuration",
215
+ fromVersion: 1,
216
+ toVersion: 2,
217
+ migrate: (oldConfig: z.infer<typeof _samlConfigV1>) => ({
218
+ ...oldConfig,
219
+ groupMapping: {
220
+ enabled: false,
221
+ groupAttribute: "http://schemas.xmlsoap.org/claims/Group",
222
+ mappings: [],
223
+ },
224
+ }),
225
+ },
226
+ ],
227
+ requiresManualRegistration: false,
228
+ adminInstructions: `
229
+ ## SAML SSO Configuration
230
+
231
+ Configure SAML 2.0 Single Sign-On to allow users to authenticate via your organization's Identity Provider:
232
+
233
+ ### Option 1: Using IdP Metadata URL (Recommended)
234
+ 1. Copy your IdP's metadata URL from your identity provider (Okta, Azure AD, OneLogin, ADFS)
235
+ 2. Paste it in the **IdP Metadata URL** field
236
+ 3. Set your **SP Entity ID** (a unique identifier for this application)
237
+
238
+ ### Option 2: Manual Configuration
239
+ 1. Enter the **IdP SSO URL** from your identity provider
240
+ 2. Paste the **IdP Certificate** (X.509 format, PEM encoded)
241
+ 3. Set the **IdP Entity ID** if different from the SSO URL
242
+
243
+ ### Service Provider Setup
244
+ Configure your IdP with these values:
245
+ - **SP Entity ID**: Your configured entity ID (default: \`checkstack\`)
246
+ - **ACS URL**: \`https://yourdomain.com/api/auth-saml/saml/acs\`
247
+ - **SP Metadata**: \`https://yourdomain.com/api/auth-saml/saml/metadata\`
248
+
249
+ ### Attribute Mapping
250
+ Map SAML attributes from your IdP to user fields:
251
+ - **Email**: Usually \`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress\`
252
+ - **Name**: Usually \`http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name\`
253
+
254
+ ### Group to Role Mapping
255
+ Map SAML groups to Checkstack roles for automatic role assignment:
256
+ 1. Enable **Group to Role Mapping**
257
+ 2. Set the **Group Attribute** (the SAML claim containing group memberships)
258
+ 3. Add mappings from directory groups to Checkstack roles
259
+ 4. Optionally set a **Default Role** for all SAML users
260
+
261
+ > **Tip**: Most IdPs use standard claim URIs. Consult your IdP documentation for specific attribute names.
262
+ `.trim(),
263
+ };
264
+
265
+ export default createBackendPlugin({
266
+ metadata: pluginMetadata,
267
+ register(env) {
268
+ // Register the SAML strategy
269
+ const extensionPoint = env.getExtensionPoint(betterAuthExtensionPoint);
270
+ extensionPoint.addStrategy(samlStrategy);
271
+
272
+ // Register init logic for SAML endpoints
273
+ env.registerInit({
274
+ deps: {
275
+ rpc: coreServices.rpc,
276
+ logger: coreServices.logger,
277
+ config: coreServices.config,
278
+ rpcClient: coreServices.rpcClient,
279
+ },
280
+ init: async ({ rpc, logger, config, rpcClient }) => {
281
+ logger.debug("[auth-saml-backend] Initializing SAML authentication...");
282
+
283
+ // Create auth client once for reuse
284
+ const authClient = rpcClient.forPlugin(AuthApi);
285
+
286
+ // Helper to create SP/IdP instances from current config
287
+ // Note: Instances are created fresh per request to ensure config changes
288
+ // propagate immediately across all horizontally scaled instances
289
+ const getSamlInstances = async (): Promise<{
290
+ sp: samlify.ServiceProviderInstance;
291
+ idp: samlify.IdentityProviderInstance;
292
+ }> => {
293
+ const samlConfig = await config.get("saml", samlConfigV2, 2);
294
+
295
+ if (!samlConfig) {
296
+ throw new Error("SAML configuration not found");
297
+ }
298
+
299
+ // Determine IdP metadata source
300
+ let idpMetadata: string | undefined = samlConfig.idpMetadata;
301
+
302
+ if (!idpMetadata && samlConfig.idpMetadataUrl) {
303
+ // Fetch metadata from URL
304
+ try {
305
+ const response = await fetch(samlConfig.idpMetadataUrl);
306
+ if (!response.ok) {
307
+ throw new Error(
308
+ `Failed to fetch IdP metadata: ${response.status}`,
309
+ );
310
+ }
311
+ idpMetadata = await response.text();
312
+ } catch (error) {
313
+ logger.error("Failed to fetch IdP metadata:", error);
314
+ throw new Error("Failed to fetch IdP metadata from URL");
315
+ }
316
+ }
317
+
318
+ // Build the base URL from environment or request context
319
+ const baseUrl =
320
+ process.env.PUBLIC_URL ||
321
+ process.env.BASE_URL ||
322
+ "http://localhost:3000";
323
+ const acsUrl = `${baseUrl}/api/auth-saml/saml/acs`;
324
+
325
+ // Create Service Provider
326
+ const spConfig: Parameters<typeof samlify.ServiceProvider>[0] = {
327
+ entityID: samlConfig.spEntityId,
328
+ assertionConsumerService: [
329
+ {
330
+ Binding: samlify.Constants.namespace.binding.post,
331
+ Location: acsUrl,
332
+ },
333
+ ],
334
+ wantAssertionsSigned: samlConfig.wantAssertionsSigned,
335
+ authnRequestsSigned: samlConfig.signAuthnRequest,
336
+ };
337
+
338
+ if (samlConfig.spPrivateKey) {
339
+ spConfig.privateKey = samlConfig.spPrivateKey;
340
+ }
341
+ if (samlConfig.spCertificate) {
342
+ spConfig.signingCert = samlConfig.spCertificate;
343
+ }
344
+
345
+ const sp = samlify.ServiceProvider(spConfig);
346
+
347
+ // Create Identity Provider
348
+ let idp: samlify.IdentityProviderInstance;
349
+ if (idpMetadata) {
350
+ idp = samlify.IdentityProvider({
351
+ metadata: idpMetadata,
352
+ });
353
+ } else if (
354
+ samlConfig.idpSingleSignOnUrl &&
355
+ samlConfig.idpCertificate
356
+ ) {
357
+ idp = samlify.IdentityProvider({
358
+ entityID: samlConfig.idpEntityId || samlConfig.idpSingleSignOnUrl,
359
+ singleSignOnService: [
360
+ {
361
+ Binding: samlify.Constants.namespace.binding.redirect,
362
+ Location: samlConfig.idpSingleSignOnUrl,
363
+ },
364
+ ],
365
+ signingCert: samlConfig.idpCertificate,
366
+ });
367
+ } else {
368
+ throw new Error(
369
+ "IdP configuration incomplete: provide metadata URL/XML or manual SSO URL + certificate",
370
+ );
371
+ }
372
+
373
+ return { sp, idp };
374
+ };
375
+
376
+ // Helper function to sync user via RPC
377
+ const syncUser = async ({
378
+ nameId,
379
+ attributes,
380
+ }: {
381
+ nameId: string;
382
+ attributes: Record<string, unknown>;
383
+ }): Promise<{ userId: string; email: string; name: string }> => {
384
+ const samlConfig = await config.get("saml", samlConfigV2, 2);
385
+ if (!samlConfig) {
386
+ throw new Error("SAML configuration not found");
387
+ }
388
+
389
+ // Extract user info from SAML attributes
390
+ const mapping = samlConfig.attributeMapping;
391
+ const email =
392
+ extractAttribute({ attributes, attributeName: mapping.email }) ||
393
+ nameId;
394
+
395
+ // Build name from available attributes
396
+ let name: string;
397
+ const extractedName = extractAttribute({
398
+ attributes,
399
+ attributeName: mapping.name,
400
+ });
401
+ if (extractedName) {
402
+ name = extractedName;
403
+ } else if (mapping.firstName && mapping.lastName) {
404
+ const firstName = extractAttribute({
405
+ attributes,
406
+ attributeName: mapping.firstName,
407
+ });
408
+ const lastName = extractAttribute({
409
+ attributes,
410
+ attributeName: mapping.lastName,
411
+ });
412
+ name =
413
+ firstName && lastName
414
+ ? `${firstName} ${lastName}`
415
+ : email.split("@")[0];
416
+ } else {
417
+ name = email.split("@")[0];
418
+ }
419
+
420
+ // Extract groups and map to roles if enabled
421
+ let syncRoles: string[] | undefined;
422
+ let managedRoleIds: string[] | undefined;
423
+ if (samlConfig.groupMapping?.enabled) {
424
+ const groups = extractGroups({
425
+ attributes,
426
+ groupAttribute: samlConfig.groupMapping.groupAttribute,
427
+ });
428
+
429
+ // Map groups to roles
430
+ const mappedRoles = samlConfig.groupMapping.mappings
431
+ .filter((m) => groups.includes(m.directoryGroup))
432
+ .map((m) => m.checkstackRole);
433
+
434
+ // Add default role if configured
435
+ if (samlConfig.groupMapping.defaultRole) {
436
+ mappedRoles.push(samlConfig.groupMapping.defaultRole);
437
+ }
438
+
439
+ // Deduplicate roles
440
+ syncRoles = [...new Set(mappedRoles)];
441
+
442
+ // Collect all managed role IDs (all roles in mappings + default)
443
+ // These are roles controlled by directory - will be removed if user leaves groups
444
+ const allManagedRoles = samlConfig.groupMapping.mappings.map(
445
+ (m) => m.checkstackRole,
446
+ );
447
+ if (samlConfig.groupMapping.defaultRole) {
448
+ allManagedRoles.push(samlConfig.groupMapping.defaultRole);
449
+ }
450
+ managedRoleIds = [...new Set(allManagedRoles)];
451
+
452
+ if (syncRoles.length > 0) {
453
+ logger.debug(
454
+ `SAML user ${email} will be assigned roles: ${syncRoles.join(", ")}`,
455
+ );
456
+ }
457
+ }
458
+
459
+ // Use RPC to upsert user - always create/update SAML users
460
+ const hashedPassword = await hashPassword(crypto.randomUUID());
461
+
462
+ const { userId, created } = await authClient.upsertExternalUser({
463
+ email,
464
+ name,
465
+ providerId: "saml",
466
+ accountId: nameId,
467
+ password: hashedPassword,
468
+ autoUpdateUser: true,
469
+ syncRoles,
470
+ managedRoleIds,
471
+ });
472
+
473
+ if (created) {
474
+ logger.info(`Created new user from SAML: ${email}`);
475
+ } else {
476
+ logger.debug(`Updated SAML user: ${email}`);
477
+ }
478
+
479
+ return { userId, email, name };
480
+ };
481
+
482
+ // SSO initiation endpoint: /saml/login
483
+ rpc.registerHttpHandler(async () => {
484
+ try {
485
+ const { sp, idp } = await getSamlInstances();
486
+
487
+ // Create login request
488
+ const { context } = sp.createLoginRequest(idp, "redirect");
489
+
490
+ // Redirect to IdP
491
+ return new Response(undefined, {
492
+ status: 302,
493
+ headers: {
494
+ Location: context,
495
+ },
496
+ });
497
+ } catch (error) {
498
+ logger.error("SAML login initiation failed:", error);
499
+ return redirectToAuthError(
500
+ error instanceof Error
501
+ ? error.message
502
+ : "Failed to initiate SAML login",
503
+ );
504
+ }
505
+ }, "saml/login");
506
+
507
+ // Assertion Consumer Service: /saml/acs
508
+ rpc.registerHttpHandler(async (req: Request) => {
509
+ try {
510
+ const { sp, idp } = await getSamlInstances();
511
+
512
+ // Parse the POST body
513
+ const formData = await req.formData();
514
+ const samlResponse = formData.get("SAMLResponse");
515
+
516
+ if (!samlResponse || typeof samlResponse !== "string") {
517
+ return redirectToAuthError("Missing SAML response");
518
+ }
519
+
520
+ // Parse and validate the SAML response
521
+ const parseResult = await sp.parseLoginResponse(idp, "post", {
522
+ body: { SAMLResponse: samlResponse },
523
+ });
524
+
525
+ if (!parseResult.extract) {
526
+ return redirectToAuthError("Failed to parse SAML assertion");
527
+ }
528
+
529
+ const { nameID, attributes } = parseResult.extract;
530
+
531
+ if (!nameID) {
532
+ return redirectToAuthError("Missing NameID in SAML assertion");
533
+ }
534
+
535
+ // Sync user to database
536
+ const { userId, email } = await syncUser({
537
+ nameId: nameID,
538
+ attributes: attributes ?? {},
539
+ });
540
+
541
+ // Create session via RPC
542
+ const sessionToken = crypto.randomUUID();
543
+ const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000); // 7 days
544
+
545
+ await authClient.createSession({
546
+ userId,
547
+ token: sessionToken,
548
+ expiresAt,
549
+ });
550
+
551
+ logger.info(`Created session for SAML user: ${email}`);
552
+
553
+ // Redirect to home with session cookie
554
+ return new Response(undefined, {
555
+ status: 302,
556
+ headers: {
557
+ Location: "/",
558
+ "Set-Cookie": `better-auth.session_token=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${
559
+ 7 * 24 * 60 * 60
560
+ }`,
561
+ },
562
+ });
563
+ } catch (error) {
564
+ logger.error("SAML ACS error:", error);
565
+ const message =
566
+ error instanceof Error
567
+ ? error.message
568
+ : "SAML authentication failed";
569
+ return redirectToAuthError(message);
570
+ }
571
+ }, "saml/acs");
572
+
573
+ // SP Metadata endpoint: /saml/metadata
574
+ rpc.registerHttpHandler(async () => {
575
+ try {
576
+ const { sp } = await getSamlInstances();
577
+ const metadata = sp.getMetadata();
578
+
579
+ return new Response(metadata, {
580
+ status: 200,
581
+ headers: {
582
+ "Content-Type": "application/xml",
583
+ },
584
+ });
585
+ } catch (error) {
586
+ logger.error("Failed to generate SP metadata:", error);
587
+ return new Response("Failed to generate metadata", { status: 500 });
588
+ }
589
+ }, "saml/metadata");
590
+
591
+ logger.debug("✅ SAML authentication initialized");
592
+ },
593
+ });
594
+ },
595
+ });
@@ -0,0 +1,9 @@
1
+ import { definePluginMetadata } from "@checkstack/common";
2
+
3
+ /**
4
+ * Plugin metadata for the Auth SAML backend.
5
+ * This is the single source of truth for the plugin ID.
6
+ */
7
+ export const pluginMetadata = definePluginMetadata({
8
+ pluginId: "auth-saml",
9
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "@checkstack/tsconfig/backend.json"
3
+ }