@_mustachio/openauth 0.12.0 → 0.13.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/dist/esm/domain/scim/discovery.js +206 -0
- package/dist/esm/domain/scim/filter.js +177 -0
- package/dist/esm/domain/scim/handle.js +338 -0
- package/dist/esm/domain/scim/patch.js +568 -0
- package/dist/esm/domain/scim/resource.js +256 -0
- package/dist/esm/http/handlers/scim.js +59 -0
- package/dist/esm/http/middleware/tenant.js +25 -0
- package/dist/esm/http/router.js +3 -0
- package/dist/esm/index.js +1 -0
- package/dist/esm/methods/saml-sp/acs.js +44 -20
- package/dist/esm/methods/saml-sp/attributes.js +2 -0
- package/dist/esm/methods/saml-sp/authnrequest.js +2 -2
- package/dist/esm/methods/saml-sp/factory.js +12 -1
- package/dist/esm/methods/saml-sp/metadata.js +5 -3
- package/dist/esm/methods/saml-sp/saml-instance.js +13 -3
- package/dist/esm/methods/saml-sp/slo-initiate.js +2 -2
- package/dist/esm/methods/saml-sp/sls.js +2 -2
- package/dist/esm/ports/scim-directory.js +0 -0
- package/dist/esm/types/error.js +5 -0
- package/dist/esm/types/scim.js +0 -0
- package/dist/types/domain/scim/discovery.d.ts +11 -0
- package/dist/types/domain/scim/discovery.d.ts.map +1 -0
- package/dist/types/domain/scim/filter.d.ts +51 -0
- package/dist/types/domain/scim/filter.d.ts.map +1 -0
- package/dist/types/domain/scim/handle.d.ts +35 -0
- package/dist/types/domain/scim/handle.d.ts.map +1 -0
- package/dist/types/domain/scim/patch.d.ts +73 -0
- package/dist/types/domain/scim/patch.d.ts.map +1 -0
- package/dist/types/domain/scim/resource.d.ts +56 -0
- package/dist/types/domain/scim/resource.d.ts.map +1 -0
- package/dist/types/http/context.d.ts +3 -0
- package/dist/types/http/context.d.ts.map +1 -1
- package/dist/types/http/handlers/method-route.d.ts.map +1 -1
- package/dist/types/http/handlers/scim.d.ts +3 -0
- package/dist/types/http/handlers/scim.d.ts.map +1 -0
- package/dist/types/http/middleware/tenant.d.ts.map +1 -1
- package/dist/types/http/router.d.ts.map +1 -1
- package/dist/types/index.d.ts +2 -0
- package/dist/types/index.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/acs.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/attributes.d.ts +5 -0
- package/dist/types/methods/saml-sp/attributes.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/authnrequest.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/factory.d.ts +217 -1
- package/dist/types/methods/saml-sp/factory.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/metadata.d.ts +8 -0
- package/dist/types/methods/saml-sp/metadata.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/parse-idp-metadata.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/saml-instance.d.ts +12 -0
- package/dist/types/methods/saml-sp/saml-instance.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/slo-initiate.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/sls.d.ts.map +1 -1
- package/dist/types/methods/saml-sp/types.d.ts +95 -0
- package/dist/types/methods/saml-sp/types.d.ts.map +1 -1
- package/dist/types/ports/scim-directory.d.ts +104 -0
- package/dist/types/ports/scim-directory.d.ts.map +1 -0
- package/dist/types/ports/session-store.d.ts.map +1 -1
- package/dist/types/types/error.d.ts +5 -0
- package/dist/types/types/error.d.ts.map +1 -1
- package/dist/types/types/idp.d.ts +12 -0
- package/dist/types/types/idp.d.ts.map +1 -1
- package/dist/types/types/scim.d.ts +269 -0
- package/dist/types/types/scim.d.ts.map +1 -0
- package/dist/types/types/tenant.d.ts +10 -0
- package/dist/types/types/tenant.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/domain/scim/discovery.ts +262 -0
- package/src/domain/scim/filter.ts +295 -0
- package/src/domain/scim/handle.ts +597 -0
- package/src/domain/scim/patch.ts +807 -0
- package/src/domain/scim/resource.ts +358 -0
- package/src/http/context.ts +3 -0
- package/src/http/handlers/authorize.ts +0 -1
- package/src/http/handlers/method-route.ts +1 -4
- package/src/http/handlers/scim.ts +107 -0
- package/src/http/middleware/tenant.ts +47 -1
- package/src/http/router.ts +9 -0
- package/src/index.ts +20 -0
- package/src/methods/saml-sp/acs.ts +99 -49
- package/src/methods/saml-sp/attributes.ts +12 -3
- package/src/methods/saml-sp/authnrequest.ts +3 -2
- package/src/methods/saml-sp/factory.ts +24 -3
- package/src/methods/saml-sp/metadata.ts +19 -8
- package/src/methods/saml-sp/parse-idp-metadata.ts +3 -9
- package/src/methods/saml-sp/saml-instance.ts +42 -5
- package/src/methods/saml-sp/slo-initiate.ts +6 -3
- package/src/methods/saml-sp/sls.ts +3 -2
- package/src/methods/saml-sp/types.ts +95 -0
- package/src/ports/CONSISTENCY.md +28 -25
- package/src/ports/scim-directory.ts +166 -0
- package/src/ports/session-store.ts +1 -5
- package/src/types/error.ts +11 -0
- package/src/types/idp.ts +12 -0
- package/src/types/scim.ts +280 -0
- package/src/types/tenant.ts +10 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
// src/domain/scim/resource.ts
|
|
2
|
+
import { err, ok } from "../../types/result";
|
|
3
|
+
var SCIM_USER_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:User";
|
|
4
|
+
var SCIM_GROUP_SCHEMA = "urn:ietf:params:scim:schemas:core:2.0:Group";
|
|
5
|
+
var SCIM_ENTERPRISE_SCHEMA = "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User";
|
|
6
|
+
var SCIM_LIST_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:ListResponse";
|
|
7
|
+
var SCIM_ERROR_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:Error";
|
|
8
|
+
var SCIM_PATCH_SCHEMA = "urn:ietf:params:scim:api:messages:2.0:PatchOp";
|
|
9
|
+
function scimErrorBody(status, detail, scimType) {
|
|
10
|
+
return {
|
|
11
|
+
schemas: [SCIM_ERROR_SCHEMA],
|
|
12
|
+
status: String(status),
|
|
13
|
+
...scimType !== undefined ? { scimType } : {},
|
|
14
|
+
detail
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
var isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
18
|
+
var asString = (v) => typeof v === "string" && v.length > 0 ? v : undefined;
|
|
19
|
+
function parseName(v) {
|
|
20
|
+
if (!isRecord(v))
|
|
21
|
+
return;
|
|
22
|
+
const out = {};
|
|
23
|
+
for (const k of [
|
|
24
|
+
"formatted",
|
|
25
|
+
"familyName",
|
|
26
|
+
"givenName",
|
|
27
|
+
"middleName",
|
|
28
|
+
"honorificPrefix",
|
|
29
|
+
"honorificSuffix"
|
|
30
|
+
]) {
|
|
31
|
+
const s = asString(v[k]);
|
|
32
|
+
if (s !== undefined)
|
|
33
|
+
out[k] = s;
|
|
34
|
+
}
|
|
35
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
36
|
+
}
|
|
37
|
+
function parseMultiValues(v) {
|
|
38
|
+
if (!Array.isArray(v))
|
|
39
|
+
return;
|
|
40
|
+
const out = [];
|
|
41
|
+
for (const entry of v) {
|
|
42
|
+
if (!isRecord(entry))
|
|
43
|
+
continue;
|
|
44
|
+
const value = asString(entry["value"]);
|
|
45
|
+
if (value === undefined)
|
|
46
|
+
continue;
|
|
47
|
+
const type = asString(entry["type"]);
|
|
48
|
+
const primary = entry["primary"];
|
|
49
|
+
out.push({
|
|
50
|
+
value,
|
|
51
|
+
...type !== undefined ? { type } : {},
|
|
52
|
+
...typeof primary === "boolean" ? { primary } : {}
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
return out.length > 0 ? out : undefined;
|
|
56
|
+
}
|
|
57
|
+
function parseEnterprise(v) {
|
|
58
|
+
if (!isRecord(v))
|
|
59
|
+
return;
|
|
60
|
+
const out = {};
|
|
61
|
+
for (const k of [
|
|
62
|
+
"employeeNumber",
|
|
63
|
+
"costCenter",
|
|
64
|
+
"organization",
|
|
65
|
+
"division",
|
|
66
|
+
"department"
|
|
67
|
+
]) {
|
|
68
|
+
const s = asString(v[k]);
|
|
69
|
+
if (s !== undefined)
|
|
70
|
+
out[k] = s;
|
|
71
|
+
}
|
|
72
|
+
const mgr = v["manager"];
|
|
73
|
+
if (isRecord(mgr)) {
|
|
74
|
+
const value = asString(mgr["value"]);
|
|
75
|
+
const displayName = asString(mgr["displayName"]);
|
|
76
|
+
if (value !== undefined || displayName !== undefined) {
|
|
77
|
+
out.manager = {
|
|
78
|
+
...value !== undefined ? { value } : {},
|
|
79
|
+
...displayName !== undefined ? { displayName } : {}
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
84
|
+
}
|
|
85
|
+
function parseUserWrite(body) {
|
|
86
|
+
if (!isRecord(body)) {
|
|
87
|
+
return err({
|
|
88
|
+
status: 400,
|
|
89
|
+
scimType: "invalidSyntax",
|
|
90
|
+
detail: "request body must be a JSON object"
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
if (body["password"] !== undefined) {
|
|
94
|
+
return err({
|
|
95
|
+
status: 400,
|
|
96
|
+
scimType: "invalidValue",
|
|
97
|
+
detail: "this Service Provider does not accept 'password' over SCIM; " + "credentials are managed by the authentication methods, not the " + "directory feed"
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
const userName = asString(body["userName"]);
|
|
101
|
+
if (userName === undefined) {
|
|
102
|
+
return err({
|
|
103
|
+
status: 400,
|
|
104
|
+
scimType: "invalidValue",
|
|
105
|
+
detail: "userName is required and must be a non-empty string"
|
|
106
|
+
});
|
|
107
|
+
}
|
|
108
|
+
const activeRaw = body["active"];
|
|
109
|
+
if (activeRaw !== undefined && typeof activeRaw !== "boolean") {
|
|
110
|
+
return err({
|
|
111
|
+
status: 400,
|
|
112
|
+
scimType: "invalidValue",
|
|
113
|
+
detail: "active must be a boolean"
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
const externalId = asString(body["externalId"]);
|
|
117
|
+
const displayName = asString(body["displayName"]);
|
|
118
|
+
const name = parseName(body["name"]);
|
|
119
|
+
const emails = parseMultiValues(body["emails"]);
|
|
120
|
+
const phoneNumbers = parseMultiValues(body["phoneNumbers"]);
|
|
121
|
+
const enterprise = parseEnterprise(body[SCIM_ENTERPRISE_SCHEMA]);
|
|
122
|
+
return ok({
|
|
123
|
+
userName,
|
|
124
|
+
active: activeRaw === undefined ? true : activeRaw,
|
|
125
|
+
...externalId !== undefined ? { externalId } : {},
|
|
126
|
+
...displayName !== undefined ? { displayName } : {},
|
|
127
|
+
...name !== undefined ? { name } : {},
|
|
128
|
+
...emails !== undefined ? { emails } : {},
|
|
129
|
+
...phoneNumbers !== undefined ? { phoneNumbers } : {},
|
|
130
|
+
...enterprise !== undefined ? { enterprise } : {}
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
var iso = (ms) => ms === undefined ? undefined : new Date(ms).toISOString();
|
|
134
|
+
function serializeUser(user, baseUrl) {
|
|
135
|
+
const schemas = [SCIM_USER_SCHEMA];
|
|
136
|
+
if (user.enterprise !== undefined)
|
|
137
|
+
schemas.push(SCIM_ENTERPRISE_SCHEMA);
|
|
138
|
+
const created = iso(user.createdAt);
|
|
139
|
+
const lastModified = iso(user.updatedAt);
|
|
140
|
+
return {
|
|
141
|
+
schemas,
|
|
142
|
+
id: user.id,
|
|
143
|
+
...user.externalId !== undefined ? { externalId: user.externalId } : {},
|
|
144
|
+
userName: user.userName,
|
|
145
|
+
...user.name !== undefined ? { name: user.name } : {},
|
|
146
|
+
...user.displayName !== undefined ? { displayName: user.displayName } : {},
|
|
147
|
+
...user.emails !== undefined ? { emails: user.emails } : {},
|
|
148
|
+
...user.phoneNumbers !== undefined ? { phoneNumbers: user.phoneNumbers } : {},
|
|
149
|
+
active: user.active,
|
|
150
|
+
...user.enterprise !== undefined ? { [SCIM_ENTERPRISE_SCHEMA]: user.enterprise } : {},
|
|
151
|
+
meta: {
|
|
152
|
+
resourceType: "User",
|
|
153
|
+
...created !== undefined ? { created } : {},
|
|
154
|
+
...lastModified !== undefined ? { lastModified } : {},
|
|
155
|
+
location: `${baseUrl}/Users/${encodeURIComponent(user.id)}`
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function serializeList(users, totalResults, startIndex, baseUrl) {
|
|
160
|
+
return {
|
|
161
|
+
schemas: [SCIM_LIST_SCHEMA],
|
|
162
|
+
totalResults,
|
|
163
|
+
startIndex,
|
|
164
|
+
itemsPerPage: users.length,
|
|
165
|
+
Resources: users.map((u) => serializeUser(u, baseUrl))
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
function parseMembers(v) {
|
|
169
|
+
const list = Array.isArray(v) ? v : isRecord(v) ? [v] : undefined;
|
|
170
|
+
if (list === undefined)
|
|
171
|
+
return;
|
|
172
|
+
const out = [];
|
|
173
|
+
for (const entry of list) {
|
|
174
|
+
if (!isRecord(entry))
|
|
175
|
+
continue;
|
|
176
|
+
const value = asString(entry["value"]);
|
|
177
|
+
if (value === undefined)
|
|
178
|
+
continue;
|
|
179
|
+
const display = asString(entry["display"]);
|
|
180
|
+
out.push({ value, ...display !== undefined ? { display } : {} });
|
|
181
|
+
}
|
|
182
|
+
return out;
|
|
183
|
+
}
|
|
184
|
+
function parseGroupWrite(body) {
|
|
185
|
+
if (!isRecord(body)) {
|
|
186
|
+
return err({
|
|
187
|
+
status: 400,
|
|
188
|
+
scimType: "invalidSyntax",
|
|
189
|
+
detail: "request body must be a JSON object"
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
const displayName = asString(body["displayName"]);
|
|
193
|
+
if (displayName === undefined) {
|
|
194
|
+
return err({
|
|
195
|
+
status: 400,
|
|
196
|
+
scimType: "invalidValue",
|
|
197
|
+
detail: "displayName is required and must be a non-empty string"
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
const externalId = asString(body["externalId"]);
|
|
201
|
+
const members = parseMembers(body["members"]);
|
|
202
|
+
return ok({
|
|
203
|
+
displayName,
|
|
204
|
+
...externalId !== undefined ? { externalId } : {},
|
|
205
|
+
...members !== undefined ? { members } : {}
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
function serializeGroup(group, baseUrl) {
|
|
209
|
+
const created = iso(group.createdAt);
|
|
210
|
+
const lastModified = iso(group.updatedAt);
|
|
211
|
+
return {
|
|
212
|
+
schemas: [SCIM_GROUP_SCHEMA],
|
|
213
|
+
id: group.id,
|
|
214
|
+
...group.externalId !== undefined ? { externalId: group.externalId } : {},
|
|
215
|
+
displayName: group.displayName,
|
|
216
|
+
...group.members !== undefined ? {
|
|
217
|
+
members: group.members.map((m) => ({
|
|
218
|
+
value: m.value,
|
|
219
|
+
...m.display !== undefined ? { display: m.display } : {},
|
|
220
|
+
$ref: `${baseUrl}/Users/${encodeURIComponent(m.value)}`,
|
|
221
|
+
type: "User"
|
|
222
|
+
}))
|
|
223
|
+
} : {},
|
|
224
|
+
meta: {
|
|
225
|
+
resourceType: "Group",
|
|
226
|
+
...created !== undefined ? { created } : {},
|
|
227
|
+
...lastModified !== undefined ? { lastModified } : {},
|
|
228
|
+
location: `${baseUrl}/Groups/${encodeURIComponent(group.id)}`
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
function serializeGroupList(groups, totalResults, startIndex, baseUrl) {
|
|
233
|
+
return {
|
|
234
|
+
schemas: [SCIM_LIST_SCHEMA],
|
|
235
|
+
totalResults,
|
|
236
|
+
startIndex,
|
|
237
|
+
itemsPerPage: groups.length,
|
|
238
|
+
Resources: groups.map((g) => serializeGroup(g, baseUrl))
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
export {
|
|
242
|
+
SCIM_ENTERPRISE_SCHEMA,
|
|
243
|
+
SCIM_ERROR_SCHEMA,
|
|
244
|
+
SCIM_GROUP_SCHEMA,
|
|
245
|
+
SCIM_LIST_SCHEMA,
|
|
246
|
+
SCIM_PATCH_SCHEMA,
|
|
247
|
+
SCIM_USER_SCHEMA,
|
|
248
|
+
parseGroupWrite,
|
|
249
|
+
parseMembers,
|
|
250
|
+
parseUserWrite,
|
|
251
|
+
scimErrorBody,
|
|
252
|
+
serializeGroup,
|
|
253
|
+
serializeGroupList,
|
|
254
|
+
serializeList,
|
|
255
|
+
serializeUser
|
|
256
|
+
};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// src/http/handlers/scim.ts
|
|
2
|
+
import { handleScimRequest } from "../../domain/scim/handle";
|
|
3
|
+
import { scimErrorBody } from "../../domain/scim/resource";
|
|
4
|
+
var SCIM_CONTENT_TYPE = "application/scim+json;charset=utf-8";
|
|
5
|
+
var MOUNT = "/scim/v2";
|
|
6
|
+
function scimResponse(status, body, extra = {}) {
|
|
7
|
+
if (body === null || status === 204) {
|
|
8
|
+
return new Response(null, { status, headers: extra });
|
|
9
|
+
}
|
|
10
|
+
return new Response(JSON.stringify(body), {
|
|
11
|
+
status,
|
|
12
|
+
headers: {
|
|
13
|
+
"content-type": SCIM_CONTENT_TYPE,
|
|
14
|
+
"cache-control": "no-store",
|
|
15
|
+
...extra
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
function makeScimHandler(deps) {
|
|
20
|
+
return async (c) => {
|
|
21
|
+
if (!deps.scimDirectory) {
|
|
22
|
+
return scimResponse(501, scimErrorBody(501, "SCIM is not configured on this deployment (no scimDirectory " + "was supplied to createIdP)"));
|
|
23
|
+
}
|
|
24
|
+
const tenant = c.get("tenant");
|
|
25
|
+
if (!tenant) {
|
|
26
|
+
return scimResponse(403, scimErrorBody(403, "SCIM provisioning is not enabled for this tenant"));
|
|
27
|
+
}
|
|
28
|
+
const url = new URL(c.req.url);
|
|
29
|
+
const path = url.pathname.startsWith(MOUNT) ? url.pathname.slice(MOUNT.length) : url.pathname;
|
|
30
|
+
const method = c.req.method.toUpperCase();
|
|
31
|
+
let body = null;
|
|
32
|
+
if (method === "POST" || method === "PUT" || method === "PATCH") {
|
|
33
|
+
const raw = await c.req.raw.text();
|
|
34
|
+
if (raw.length > 0) {
|
|
35
|
+
try {
|
|
36
|
+
body = JSON.parse(raw);
|
|
37
|
+
} catch {
|
|
38
|
+
return scimResponse(400, scimErrorBody(400, "request body is not valid JSON", "invalidSyntax"));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const issuer = c.get("issuerUrl");
|
|
43
|
+
const base = issuer.endsWith("/") ? issuer.slice(0, -1) : issuer;
|
|
44
|
+
const result = await handleScimRequest({
|
|
45
|
+
tenant,
|
|
46
|
+
method,
|
|
47
|
+
path,
|
|
48
|
+
query: url.searchParams,
|
|
49
|
+
body,
|
|
50
|
+
authorization: c.req.header("authorization") ?? null,
|
|
51
|
+
baseUrl: `${base}${MOUNT}`,
|
|
52
|
+
directory: deps.scimDirectory
|
|
53
|
+
});
|
|
54
|
+
return scimResponse(result.status, result.body, result.headers ?? {});
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
export {
|
|
58
|
+
makeScimHandler
|
|
59
|
+
};
|
|
@@ -7,6 +7,25 @@ import {
|
|
|
7
7
|
tokenEndpointErrorResponse
|
|
8
8
|
} from "../errors";
|
|
9
9
|
import { authError } from "../../types/error";
|
|
10
|
+
function isScimPath(pathname) {
|
|
11
|
+
return pathname === "/scim/v2" || pathname.startsWith("/scim/v2/");
|
|
12
|
+
}
|
|
13
|
+
function scimTenantErrorResponse(error) {
|
|
14
|
+
const operational = error.code === "server_error" || error.code === "internal_error";
|
|
15
|
+
const status = operational ? 500 : 403;
|
|
16
|
+
const detail = operational ? "the configuration store backing this endpoint failed the request" : "SCIM provisioning is not enabled for this tenant";
|
|
17
|
+
return new Response(JSON.stringify({
|
|
18
|
+
schemas: ["urn:ietf:params:scim:api:messages:2.0:Error"],
|
|
19
|
+
status: String(status),
|
|
20
|
+
detail
|
|
21
|
+
}), {
|
|
22
|
+
status,
|
|
23
|
+
headers: {
|
|
24
|
+
"content-type": "application/scim+json;charset=utf-8",
|
|
25
|
+
"cache-control": "no-store"
|
|
26
|
+
}
|
|
27
|
+
});
|
|
28
|
+
}
|
|
10
29
|
function isCallbackPath(pathname) {
|
|
11
30
|
return pathname.startsWith("/cb/");
|
|
12
31
|
}
|
|
@@ -41,6 +60,9 @@ function tenantMiddleware(deps) {
|
|
|
41
60
|
if (url.pathname === "/authorize") {
|
|
42
61
|
return authorizeDirectErrorResponse(resolved.error);
|
|
43
62
|
}
|
|
63
|
+
if (isScimPath(url.pathname)) {
|
|
64
|
+
return scimTenantErrorResponse(resolved.error);
|
|
65
|
+
}
|
|
44
66
|
return tokenEndpointErrorResponse(resolved.error);
|
|
45
67
|
}
|
|
46
68
|
const tenantId = resolved.value;
|
|
@@ -50,6 +72,9 @@ function tenantMiddleware(deps) {
|
|
|
50
72
|
if (url.pathname === "/authorize") {
|
|
51
73
|
return authorizeDirectErrorResponse(err);
|
|
52
74
|
}
|
|
75
|
+
if (isScimPath(url.pathname)) {
|
|
76
|
+
return scimTenantErrorResponse(cfg.error);
|
|
77
|
+
}
|
|
53
78
|
return tokenEndpointErrorResponse(err);
|
|
54
79
|
}
|
|
55
80
|
const custom = deps.buildCustomContext ? await deps.buildCustomContext(c.req.raw) : {};
|
package/dist/esm/http/router.js
CHANGED
|
@@ -7,6 +7,7 @@ import { makeDiscoveryHandler, makeJwksHandler } from "./handlers/metadata";
|
|
|
7
7
|
import { makeMethodRouteHandler } from "./handlers/method-route";
|
|
8
8
|
import { makeParHandler } from "./handlers/par";
|
|
9
9
|
import { makeRegisterHandler } from "./handlers/register";
|
|
10
|
+
import { makeScimHandler } from "./handlers/scim";
|
|
10
11
|
import { makeIntrospectHandler, makeRevokeHandler } from "./handlers/revocation";
|
|
11
12
|
import { makeTokenHandler } from "./handlers/token";
|
|
12
13
|
import { makeUserinfoHandler } from "./handlers/userinfo";
|
|
@@ -34,6 +35,8 @@ function buildRouter(deps) {
|
|
|
34
35
|
app.use("/end_session", tenantMiddleware(deps));
|
|
35
36
|
app.get("/end_session", makeEndSessionHandler(deps));
|
|
36
37
|
app.post("/end_session", makeEndSessionHandler(deps));
|
|
38
|
+
app.use("/scim/v2/*", tenantMiddleware(deps));
|
|
39
|
+
app.all("/scim/v2/*", makeScimHandler(deps));
|
|
37
40
|
app.use("/par", tenantMiddleware(deps));
|
|
38
41
|
app.post("/par", makeParHandler(deps));
|
|
39
42
|
app.use("/register", tenantMiddleware(deps));
|
package/dist/esm/index.js
CHANGED
|
@@ -58,6 +58,7 @@ function createIdP(opts) {
|
|
|
58
58
|
sessionStore: opts.sessionStore,
|
|
59
59
|
keyStore: opts.keyStore,
|
|
60
60
|
...opts.methodStore ? { methodStore: opts.methodStore } : {},
|
|
61
|
+
...opts.scimDirectory ? { scimDirectory: opts.scimDirectory } : {},
|
|
61
62
|
...auditLog ? { auditLog } : {},
|
|
62
63
|
methodCache,
|
|
63
64
|
stateKeys: opts.stateKeys,
|
|
@@ -2,24 +2,31 @@
|
|
|
2
2
|
import xmldom from "@xmldom/xmldom";
|
|
3
3
|
import { authError } from "../../types/error";
|
|
4
4
|
import { mapProfile } from "./attributes";
|
|
5
|
-
import { buildSamlInstance,
|
|
5
|
+
import { buildSamlInstance, resolveSpEntityId } from "./saml-instance";
|
|
6
6
|
var { DOMParser } = xmldom;
|
|
7
|
-
function
|
|
7
|
+
function parseVerifiedAssertion(profile) {
|
|
8
8
|
if (typeof profile.getAssertionXml !== "function") {
|
|
9
9
|
return {
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
failure: {
|
|
11
|
+
kind: "error",
|
|
12
|
+
error: authError.internalError("saml-sp: node-saml profile exposes no getAssertionXml(); cannot " + "perform the Recipient binding check. Refusing to authenticate.")
|
|
13
|
+
}
|
|
12
14
|
};
|
|
13
15
|
}
|
|
14
|
-
let doc;
|
|
15
16
|
try {
|
|
16
|
-
|
|
17
|
+
return {
|
|
18
|
+
doc: new DOMParser().parseFromString(profile.getAssertionXml(), "text/xml")
|
|
19
|
+
};
|
|
17
20
|
} catch (e) {
|
|
18
21
|
return {
|
|
19
|
-
|
|
20
|
-
|
|
22
|
+
failure: {
|
|
23
|
+
kind: "error",
|
|
24
|
+
error: authError.internalError("saml-sp: failed to parse the verified assertion", e)
|
|
25
|
+
}
|
|
21
26
|
};
|
|
22
27
|
}
|
|
28
|
+
}
|
|
29
|
+
function checkRecipient(doc, acsUrl) {
|
|
23
30
|
const nodes = doc.getElementsByTagNameNS("*", "SubjectConfirmationData");
|
|
24
31
|
const recipients = [];
|
|
25
32
|
for (let i = 0;i < nodes.length; i++) {
|
|
@@ -41,15 +48,7 @@ function checkRecipient(profile, acsUrl) {
|
|
|
41
48
|
}
|
|
42
49
|
return null;
|
|
43
50
|
}
|
|
44
|
-
function extractReplayInfo(
|
|
45
|
-
if (typeof profile.getAssertionXml !== "function")
|
|
46
|
-
return null;
|
|
47
|
-
let doc;
|
|
48
|
-
try {
|
|
49
|
-
doc = new DOMParser().parseFromString(profile.getAssertionXml(), "text/xml");
|
|
50
|
-
} catch {
|
|
51
|
-
return null;
|
|
52
|
-
}
|
|
51
|
+
function extractReplayInfo(doc) {
|
|
53
52
|
const root = doc.documentElement;
|
|
54
53
|
const assertionId = root?.getAttribute("ID") ?? "";
|
|
55
54
|
if (!assertionId)
|
|
@@ -68,6 +67,25 @@ function extractReplayInfo(profile) {
|
|
|
68
67
|
}
|
|
69
68
|
return { assertionId, notOnOrAfterMs: earliest };
|
|
70
69
|
}
|
|
70
|
+
function extractAuthnStatement(doc) {
|
|
71
|
+
const out = {};
|
|
72
|
+
const stmt = doc.getElementsByTagNameNS("*", "AuthnStatement")[0];
|
|
73
|
+
if (!stmt)
|
|
74
|
+
return out;
|
|
75
|
+
const instant = stmt.getAttribute("AuthnInstant");
|
|
76
|
+
if (instant)
|
|
77
|
+
out.authnInstant = instant;
|
|
78
|
+
const sessionExpiry = stmt.getAttribute("SessionNotOnOrAfter");
|
|
79
|
+
if (sessionExpiry) {
|
|
80
|
+
const ms = Date.parse(sessionExpiry);
|
|
81
|
+
if (!Number.isNaN(ms))
|
|
82
|
+
out.sessionNotOnOrAfter = ms;
|
|
83
|
+
}
|
|
84
|
+
const ref = stmt.getElementsByTagNameNS("*", "AuthnContextClassRef")[0]?.textContent?.trim();
|
|
85
|
+
if (ref)
|
|
86
|
+
out.authnContextClassRef = ref;
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
71
89
|
async function consumeAssertion(ctx, methodId, config) {
|
|
72
90
|
const idpInitiated = ctx.flow === null;
|
|
73
91
|
let spEntityId;
|
|
@@ -85,7 +103,7 @@ async function consumeAssertion(ctx, methodId, config) {
|
|
|
85
103
|
error: authError.internalError("saml-sp: IdP-initiated ACS dispatched without issuer context")
|
|
86
104
|
};
|
|
87
105
|
}
|
|
88
|
-
spEntityId =
|
|
106
|
+
spEntityId = resolveSpEntityId(config, ctx.dispatch.issuerUrl, ctx.tenant.id, methodId);
|
|
89
107
|
acsUrl = ctx.dispatch.callbackUrl;
|
|
90
108
|
} else {
|
|
91
109
|
const state = ctx.methodState;
|
|
@@ -148,11 +166,15 @@ async function consumeAssertion(ctx, methodId, config) {
|
|
|
148
166
|
if (!profile || !profile.nameID) {
|
|
149
167
|
return { kind: "denied", reason: "assertion produced no usable subject" };
|
|
150
168
|
}
|
|
151
|
-
const
|
|
169
|
+
const parsed = parseVerifiedAssertion(profile);
|
|
170
|
+
if ("failure" in parsed)
|
|
171
|
+
return parsed.failure;
|
|
172
|
+
const assertionDoc = parsed.doc;
|
|
173
|
+
const recipientFailure = checkRecipient(assertionDoc, acsUrl);
|
|
152
174
|
if (recipientFailure)
|
|
153
175
|
return recipientFailure;
|
|
154
176
|
if (idpInitiated) {
|
|
155
|
-
const replay = extractReplayInfo(
|
|
177
|
+
const replay = extractReplayInfo(assertionDoc);
|
|
156
178
|
if (!replay) {
|
|
157
179
|
return {
|
|
158
180
|
kind: "error",
|
|
@@ -179,10 +201,12 @@ async function consumeAssertion(ctx, methodId, config) {
|
|
|
179
201
|
};
|
|
180
202
|
}
|
|
181
203
|
}
|
|
204
|
+
const authnStatement = extractAuthnStatement(assertionDoc);
|
|
182
205
|
const verified = {
|
|
183
206
|
nameID: profile.nameID,
|
|
184
207
|
nameIDFormat: profile.nameIDFormat ?? "",
|
|
185
208
|
...profile.sessionIndex !== undefined ? { sessionIndex: profile.sessionIndex } : {},
|
|
209
|
+
...authnStatement,
|
|
186
210
|
attributes: profile.attributes ?? {},
|
|
187
211
|
responseXml: profile.getSamlResponseXml?.() ?? ""
|
|
188
212
|
};
|
|
@@ -66,6 +66,8 @@ function mapProfile(profile, mapping) {
|
|
|
66
66
|
attributes,
|
|
67
67
|
...profile.sessionIndex !== undefined ? { sessionIndex: profile.sessionIndex } : {},
|
|
68
68
|
authnInstant: Number.isNaN(authnInstant) ? Date.now() : authnInstant,
|
|
69
|
+
...profile.sessionNotOnOrAfter !== undefined ? { sessionNotOnOrAfter: profile.sessionNotOnOrAfter } : {},
|
|
70
|
+
...profile.authnContextClassRef !== undefined ? { authnContextClassRef: profile.authnContextClassRef } : {},
|
|
69
71
|
raw: { responseXml: profile.responseXml }
|
|
70
72
|
}
|
|
71
73
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/methods/saml-sp/authnrequest.ts
|
|
2
2
|
import { authError } from "../../types/error";
|
|
3
3
|
import { isErr } from "../../types/result";
|
|
4
|
-
import { buildSamlInstance,
|
|
4
|
+
import { buildSamlInstance, resolveSpEntityId } from "./saml-instance";
|
|
5
5
|
async function buildAuthnRequestRedirect(ctx, methodId, config) {
|
|
6
6
|
if (!ctx.dispatch) {
|
|
7
7
|
return {
|
|
@@ -15,7 +15,7 @@ async function buildAuthnRequestRedirect(ctx, methodId, config) {
|
|
|
15
15
|
error: authError.internalError("saml-sp: signAuthnRequest is true but signingKey is missing " + "(config schema should have rejected this).")
|
|
16
16
|
};
|
|
17
17
|
}
|
|
18
|
-
const spEntityId =
|
|
18
|
+
const spEntityId = resolveSpEntityId(config, ctx.dispatch.issuerUrl, ctx.tenant.id, methodId);
|
|
19
19
|
const acsUrl = ctx.dispatch.callbackUrl;
|
|
20
20
|
const probe = await ctx.methodScratch.put("authnrequest-scratch-probe", "1", 1000);
|
|
21
21
|
if (isErr(probe)) {
|
|
@@ -48,13 +48,24 @@ var samlSpConfigSchema = z.object({
|
|
|
48
48
|
certPem: z.string().min(1)
|
|
49
49
|
}).optional(),
|
|
50
50
|
idpInitiated: idpInitiatedSchema.optional(),
|
|
51
|
-
clockSkewSeconds: z.number().int().nonnegative().optional()
|
|
51
|
+
clockSkewSeconds: z.number().int().nonnegative().optional(),
|
|
52
|
+
spEntityId: z.string().min(1).optional(),
|
|
53
|
+
forceAuthn: z.boolean().optional(),
|
|
54
|
+
requestedAuthnContext: z.object({
|
|
55
|
+
classRefs: z.array(z.string().min(1)).min(1),
|
|
56
|
+
comparison: z.enum(["exact", "minimum", "maximum", "better"]).optional()
|
|
57
|
+
}).optional(),
|
|
58
|
+
requireSignedAssertion: z.boolean().optional(),
|
|
59
|
+
requireSignedResponse: z.boolean().optional()
|
|
52
60
|
}).refine((c) => !c.signAuthnRequest || c.signingKey !== undefined, {
|
|
53
61
|
message: "signingKey is required when signAuthnRequest is true",
|
|
54
62
|
path: ["signingKey"]
|
|
55
63
|
}).refine((c) => !c.allowEncryptedAssertions || c.decryptionKey !== undefined, {
|
|
56
64
|
message: "decryptionKey is required when allowEncryptedAssertions is true",
|
|
57
65
|
path: ["decryptionKey"]
|
|
66
|
+
}).refine((c) => (c.requireSignedAssertion ?? true) || c.requireSignedResponse === true, {
|
|
67
|
+
message: "requireSignedAssertion may only be false when requireSignedResponse " + "is true — at least one signature is mandatory",
|
|
68
|
+
path: ["requireSignedAssertion"]
|
|
58
69
|
});
|
|
59
70
|
var samlSpFactory = {
|
|
60
71
|
kind: "saml-sp",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// src/methods/saml-sp/metadata.ts
|
|
2
2
|
import { authError } from "../../types/error";
|
|
3
|
-
import {
|
|
3
|
+
import { resolveSpEntityId } from "./saml-instance";
|
|
4
4
|
var NAME_ID_FORMAT_URN = {
|
|
5
5
|
persistent: "urn:oasis:names:tc:SAML:2.0:nameid-format:persistent",
|
|
6
6
|
transient: "urn:oasis:names:tc:SAML:2.0:nameid-format:transient",
|
|
@@ -19,6 +19,7 @@ function buildSpMetadataXml(input) {
|
|
|
19
19
|
const entityId = xmlEscape(input.spEntityId);
|
|
20
20
|
const acs = xmlEscape(input.acsUrl);
|
|
21
21
|
const signed = input.signingCertPem !== undefined;
|
|
22
|
+
const wantAssertionsSigned = input.wantAssertionsSigned ?? true;
|
|
22
23
|
const nameIdLine = input.nameIdFormat !== undefined ? `
|
|
23
24
|
<md:NameIDFormat>${NAME_ID_FORMAT_URN[input.nameIdFormat]}</md:NameIDFormat>` : "";
|
|
24
25
|
const x509 = (cert) => `<ds:KeyInfo><ds:X509Data><ds:X509Certificate>` + certBody(cert) + `</ds:X509Certificate></ds:X509Data></ds:KeyInfo>`;
|
|
@@ -30,7 +31,7 @@ function buildSpMetadataXml(input) {
|
|
|
30
31
|
<md:SingleLogoutService ` + `Binding="${HTTP_POST}" Location="${xmlEscape(input.slsUrl)}"/>` : "";
|
|
31
32
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
32
33
|
` + `<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" ` + `xmlns:ds="http://www.w3.org/2000/09/xmldsig#" ` + `entityID="${entityId}">
|
|
33
|
-
` + ` <md:SPSSODescriptor AuthnRequestsSigned="${signed}" ` + `WantAssertionsSigned="
|
|
34
|
+
` + ` <md:SPSSODescriptor AuthnRequestsSigned="${signed}" ` + `WantAssertionsSigned="${wantAssertionsSigned}" ` + `protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">` + keyDescriptor + sls + nameIdLine + `
|
|
34
35
|
<md:AssertionConsumerService ` + `Binding="${HTTP_POST}" Location="${acs}" index="0" isDefault="true"/>` + `
|
|
35
36
|
</md:SPSSODescriptor>
|
|
36
37
|
` + `</md:EntityDescriptor>
|
|
@@ -43,7 +44,7 @@ async function buildSpMetadata(ctx, methodId, config) {
|
|
|
43
44
|
error: authError.internalError("saml-sp: metadata route dispatched without issuer context " + "(ctx.dispatch is null)")
|
|
44
45
|
};
|
|
45
46
|
}
|
|
46
|
-
const spEntityId =
|
|
47
|
+
const spEntityId = resolveSpEntityId(config, ctx.dispatch.issuerUrl, ctx.tenant.id, methodId);
|
|
47
48
|
const cb = new URL(ctx.dispatch.callbackUrl);
|
|
48
49
|
const slsUrl = `${cb.protocol}//${cb.host}/m/${methodId}/sls`;
|
|
49
50
|
const xml = buildSpMetadataXml({
|
|
@@ -51,6 +52,7 @@ async function buildSpMetadata(ctx, methodId, config) {
|
|
|
51
52
|
acsUrl: ctx.dispatch.callbackUrl,
|
|
52
53
|
...config.idp.sloUrl ? { slsUrl } : {},
|
|
53
54
|
...config.idp.nameIdFormat !== undefined ? { nameIdFormat: config.idp.nameIdFormat } : {},
|
|
55
|
+
wantAssertionsSigned: config.requireSignedAssertion ?? true,
|
|
54
56
|
...config.signAuthnRequest && config.signingKey ? { signingCertPem: config.signingKey.certPem } : {},
|
|
55
57
|
...config.allowEncryptedAssertions && config.decryptionKey ? { encryptionCertPem: config.decryptionKey.certPem } : {}
|
|
56
58
|
});
|
|
@@ -14,6 +14,9 @@ function deriveSpEntityId(issuerUrl, tenantId, methodId) {
|
|
|
14
14
|
const base = issuerUrl.endsWith("/") ? issuerUrl.slice(0, -1) : issuerUrl;
|
|
15
15
|
return `${base}/${tenantId}/${methodId}`;
|
|
16
16
|
}
|
|
17
|
+
function resolveSpEntityId(config, issuerUrl, tenantId, methodId) {
|
|
18
|
+
return config.spEntityId ?? deriveSpEntityId(issuerUrl, tenantId, methodId);
|
|
19
|
+
}
|
|
17
20
|
function buildSamlInstance(config, binding, nowMs) {
|
|
18
21
|
const activeCerts = selectActiveCertPems(config.idp.signingCerts, nowMs);
|
|
19
22
|
if (activeCerts.length === 0) {
|
|
@@ -29,9 +32,15 @@ function buildSamlInstance(config, binding, nowMs) {
|
|
|
29
32
|
audience: binding.spEntityId,
|
|
30
33
|
identifierFormat,
|
|
31
34
|
validateInResponseTo: binding.logout ? ValidateInResponseTo.never : binding.idpInitiated ? ValidateInResponseTo.ifPresent : ValidateInResponseTo.always,
|
|
32
|
-
wantAssertionsSigned: true,
|
|
33
|
-
wantAuthnResponseSigned: false,
|
|
35
|
+
wantAssertionsSigned: config.requireSignedAssertion ?? true,
|
|
36
|
+
wantAuthnResponseSigned: config.requireSignedResponse ?? false,
|
|
34
37
|
acceptedClockSkewMs: (config.clockSkewSeconds ?? 60) * 1000,
|
|
38
|
+
forceAuthn: config.forceAuthn ?? false,
|
|
39
|
+
...config.requestedAuthnContext ? {
|
|
40
|
+
disableRequestedAuthnContext: false,
|
|
41
|
+
authnContext: [...config.requestedAuthnContext.classRefs],
|
|
42
|
+
racComparison: config.requestedAuthnContext.comparison ?? "exact"
|
|
43
|
+
} : { disableRequestedAuthnContext: true },
|
|
35
44
|
cacheProvider: methodScratchCacheProvider(binding.scratch, IN_RESPONSE_TO_TTL_MS),
|
|
36
45
|
...binding.signing ? {
|
|
37
46
|
privateKey: binding.signing.privateKeyPem,
|
|
@@ -45,5 +54,6 @@ function buildSamlInstance(config, binding, nowMs) {
|
|
|
45
54
|
export {
|
|
46
55
|
NAME_ID_FORMAT_URN,
|
|
47
56
|
buildSamlInstance,
|
|
48
|
-
deriveSpEntityId
|
|
57
|
+
deriveSpEntityId,
|
|
58
|
+
resolveSpEntityId
|
|
49
59
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { authError } from "../../types/error";
|
|
3
3
|
import {
|
|
4
4
|
buildSamlInstance,
|
|
5
|
-
|
|
5
|
+
resolveSpEntityId,
|
|
6
6
|
NAME_ID_FORMAT_URN
|
|
7
7
|
} from "./saml-instance";
|
|
8
8
|
async function initiateSpLogout(ctx, methodId, config) {
|
|
@@ -44,7 +44,7 @@ async function initiateSpLogout(ctx, methodId, config) {
|
|
|
44
44
|
};
|
|
45
45
|
}
|
|
46
46
|
const nameIDFormat = nameIdFormatParam !== null ? NAME_ID_FORMAT_URN[nameIdFormatParam] : config.idp.nameIdFormat !== undefined ? NAME_ID_FORMAT_URN[config.idp.nameIdFormat] : NAME_ID_FORMAT_URN.persistent;
|
|
47
|
-
const spEntityId =
|
|
47
|
+
const spEntityId = resolveSpEntityId(config, ctx.dispatch.issuerUrl, ctx.tenant.id, methodId);
|
|
48
48
|
let redirectUrl;
|
|
49
49
|
try {
|
|
50
50
|
const saml = buildSamlInstance(config, {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// src/methods/saml-sp/sls.ts
|
|
2
2
|
import { authError } from "../../types/error";
|
|
3
3
|
import { isErr } from "../../types/result";
|
|
4
|
-
import { buildSamlInstance,
|
|
4
|
+
import { buildSamlInstance, resolveSpEntityId } from "./saml-instance";
|
|
5
5
|
var SLO_REPLAY_HORIZON_MS = 10 * 60000;
|
|
6
6
|
async function consumeSls(ctx, methodId, config) {
|
|
7
7
|
if (!ctx.dispatch) {
|
|
@@ -16,7 +16,7 @@ async function consumeSls(ctx, methodId, config) {
|
|
|
16
16
|
error: authError.internalError("saml-sp: /sls reached with no idp.sloUrl configured")
|
|
17
17
|
};
|
|
18
18
|
}
|
|
19
|
-
const spEntityId =
|
|
19
|
+
const spEntityId = resolveSpEntityId(config, ctx.dispatch.issuerUrl, ctx.tenant.id, methodId);
|
|
20
20
|
const url = new URL(ctx.request.url);
|
|
21
21
|
const originalQuery = url.search.replace(/^\?/, "");
|
|
22
22
|
let samlRequest;
|