@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 +35 -0
- package/package.json +32 -0
- package/src/helpers.ts +33 -0
- package/src/index.test.ts +263 -0
- package/src/index.ts +595 -0
- package/src/plugin-metadata.ts +9 -0
- package/tsconfig.json +3 -0
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
|
+
});
|
package/tsconfig.json
ADDED