@winstonsayno/mcp-gateway 6.3.0 → 6.5.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 +24 -0
- package/README.md +14 -0
- package/dist/config/loader.d.ts +4 -4
- package/dist/config/loader.d.ts.map +1 -1
- package/dist/config/loader.js +6 -27
- package/dist/config/loader.js.map +1 -1
- package/dist/features/identity.d.ts +212 -0
- package/dist/features/identity.d.ts.map +1 -0
- package/dist/features/identity.js +408 -0
- package/dist/features/identity.js.map +1 -0
- package/dist/features/index.d.ts +2 -0
- package/dist/features/index.d.ts.map +1 -1
- package/dist/features/index.js +2 -0
- package/dist/features/index.js.map +1 -1
- package/dist/features/policy-sim.d.ts +445 -0
- package/dist/features/policy-sim.d.ts.map +1 -0
- package/dist/features/policy-sim.js +165 -0
- package/dist/features/policy-sim.js.map +1 -0
- package/dist/gateway/features.d.ts +1 -1
- package/dist/gateway/features.d.ts.map +1 -1
- package/dist/gateway/features.js +1 -1
- package/dist/gateway/features.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/policy/rule-schema.d.ts +88 -0
- package/dist/policy/rule-schema.d.ts.map +1 -0
- package/dist/policy/rule-schema.js +30 -0
- package/dist/policy/rule-schema.js.map +1 -0
- package/dist/utils/types.d.ts +4 -0
- package/dist/utils/types.d.ts.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enterprise SSO and SCIM (6.4).
|
|
3
|
+
*
|
|
4
|
+
* - **SCIM 2.0** (RFC 7643 / 7644): identity providers (Okta, Entra ID, OneLogin, Google …) provision users and
|
|
5
|
+
* groups into the gateway at `/api/v1/admin/identity/scim/v2` — `Users` and `Groups` with create, read, list (with
|
|
6
|
+
* `filter`, `startIndex`, `count`), replace (`PUT`), `PATCH` (add / replace / remove, including group members) and
|
|
7
|
+
* delete, plus `ServiceProviderConfig`, `ResourceTypes` and `Schemas`. Authenticate the IdP with an operator key.
|
|
8
|
+
* - **Group → tenant roles:** `groupRoles` maps IdP group names to tenant memberships; a user's effective memberships
|
|
9
|
+
* combine SCIM group membership with the `groups` claim of their ID token. Deactivated users have none.
|
|
10
|
+
* - **OIDC SSO:** `POST /admin/identity/sso/verify` verifies an ID token (issuer, audience, signature against the
|
|
11
|
+
* issuer's JWKS or an inline `jwks`) and returns the user and memberships; `GET /admin/identity/sso/authorize-url`
|
|
12
|
+
* builds an authorization-code + PKCE (S256) login URL.
|
|
13
|
+
*
|
|
14
|
+
* ```yaml
|
|
15
|
+
* identity:
|
|
16
|
+
* oidc:
|
|
17
|
+
* issuer: https://acme.okta.com
|
|
18
|
+
* clientId: 0oa1example
|
|
19
|
+
* redirectUri: https://gateway.acme.com/sso/callback
|
|
20
|
+
* groupsClaim: groups
|
|
21
|
+
* groupRoles:
|
|
22
|
+
* - { group: Platform, tenant: platform, role: owner }
|
|
23
|
+
* - { group: Engineering, tenant: eng, role: admin }
|
|
24
|
+
* storePath: ./data/scim.json # optional: persist the directory
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @module features/identity
|
|
28
|
+
*/
|
|
29
|
+
import { z } from 'zod';
|
|
30
|
+
export declare const IdentitySchema: z.ZodObject<{
|
|
31
|
+
oidc: z.ZodOptional<z.ZodObject<{
|
|
32
|
+
issuer: z.ZodString;
|
|
33
|
+
clientId: z.ZodString;
|
|
34
|
+
redirectUri: z.ZodOptional<z.ZodString>;
|
|
35
|
+
scopes: z.ZodDefault<z.ZodArray<z.ZodString, "many">>;
|
|
36
|
+
groupsClaim: z.ZodDefault<z.ZodString>;
|
|
37
|
+
jwksUrl: z.ZodOptional<z.ZodString>;
|
|
38
|
+
jwks: z.ZodOptional<z.ZodObject<{
|
|
39
|
+
keys: z.ZodArray<z.ZodRecord<z.ZodString, z.ZodUnknown>, "many">;
|
|
40
|
+
}, "strip", z.ZodTypeAny, {
|
|
41
|
+
keys: Record<string, unknown>[];
|
|
42
|
+
}, {
|
|
43
|
+
keys: Record<string, unknown>[];
|
|
44
|
+
}>>;
|
|
45
|
+
authorizationEndpoint: z.ZodOptional<z.ZodString>;
|
|
46
|
+
}, "strict", z.ZodTypeAny, {
|
|
47
|
+
issuer: string;
|
|
48
|
+
clientId: string;
|
|
49
|
+
scopes: string[];
|
|
50
|
+
groupsClaim: string;
|
|
51
|
+
redirectUri?: string | undefined;
|
|
52
|
+
jwksUrl?: string | undefined;
|
|
53
|
+
jwks?: {
|
|
54
|
+
keys: Record<string, unknown>[];
|
|
55
|
+
} | undefined;
|
|
56
|
+
authorizationEndpoint?: string | undefined;
|
|
57
|
+
}, {
|
|
58
|
+
issuer: string;
|
|
59
|
+
clientId: string;
|
|
60
|
+
redirectUri?: string | undefined;
|
|
61
|
+
scopes?: string[] | undefined;
|
|
62
|
+
groupsClaim?: string | undefined;
|
|
63
|
+
jwksUrl?: string | undefined;
|
|
64
|
+
jwks?: {
|
|
65
|
+
keys: Record<string, unknown>[];
|
|
66
|
+
} | undefined;
|
|
67
|
+
authorizationEndpoint?: string | undefined;
|
|
68
|
+
}>>;
|
|
69
|
+
groupRoles: z.ZodDefault<z.ZodArray<z.ZodObject<{
|
|
70
|
+
group: z.ZodString;
|
|
71
|
+
tenant: z.ZodString;
|
|
72
|
+
role: z.ZodDefault<z.ZodEnum<["viewer", "admin", "owner"]>>;
|
|
73
|
+
}, "strict", z.ZodTypeAny, {
|
|
74
|
+
group: string;
|
|
75
|
+
tenant: string;
|
|
76
|
+
role: "admin" | "viewer" | "owner";
|
|
77
|
+
}, {
|
|
78
|
+
group: string;
|
|
79
|
+
tenant: string;
|
|
80
|
+
role?: "admin" | "viewer" | "owner" | undefined;
|
|
81
|
+
}>, "many">>;
|
|
82
|
+
storePath: z.ZodOptional<z.ZodString>;
|
|
83
|
+
}, "strict", z.ZodTypeAny, {
|
|
84
|
+
groupRoles: {
|
|
85
|
+
group: string;
|
|
86
|
+
tenant: string;
|
|
87
|
+
role: "admin" | "viewer" | "owner";
|
|
88
|
+
}[];
|
|
89
|
+
oidc?: {
|
|
90
|
+
issuer: string;
|
|
91
|
+
clientId: string;
|
|
92
|
+
scopes: string[];
|
|
93
|
+
groupsClaim: string;
|
|
94
|
+
redirectUri?: string | undefined;
|
|
95
|
+
jwksUrl?: string | undefined;
|
|
96
|
+
jwks?: {
|
|
97
|
+
keys: Record<string, unknown>[];
|
|
98
|
+
} | undefined;
|
|
99
|
+
authorizationEndpoint?: string | undefined;
|
|
100
|
+
} | undefined;
|
|
101
|
+
storePath?: string | undefined;
|
|
102
|
+
}, {
|
|
103
|
+
oidc?: {
|
|
104
|
+
issuer: string;
|
|
105
|
+
clientId: string;
|
|
106
|
+
redirectUri?: string | undefined;
|
|
107
|
+
scopes?: string[] | undefined;
|
|
108
|
+
groupsClaim?: string | undefined;
|
|
109
|
+
jwksUrl?: string | undefined;
|
|
110
|
+
jwks?: {
|
|
111
|
+
keys: Record<string, unknown>[];
|
|
112
|
+
} | undefined;
|
|
113
|
+
authorizationEndpoint?: string | undefined;
|
|
114
|
+
} | undefined;
|
|
115
|
+
groupRoles?: {
|
|
116
|
+
group: string;
|
|
117
|
+
tenant: string;
|
|
118
|
+
role?: "admin" | "viewer" | "owner" | undefined;
|
|
119
|
+
}[] | undefined;
|
|
120
|
+
storePath?: string | undefined;
|
|
121
|
+
}>;
|
|
122
|
+
export type IdentityConfig = z.input<typeof IdentitySchema>;
|
|
123
|
+
type Cfg = z.output<typeof IdentitySchema>;
|
|
124
|
+
export interface ScimUser {
|
|
125
|
+
schemas: string[];
|
|
126
|
+
id: string;
|
|
127
|
+
externalId?: string;
|
|
128
|
+
userName: string;
|
|
129
|
+
name?: {
|
|
130
|
+
givenName?: string;
|
|
131
|
+
familyName?: string;
|
|
132
|
+
formatted?: string;
|
|
133
|
+
};
|
|
134
|
+
displayName?: string;
|
|
135
|
+
emails?: Array<{
|
|
136
|
+
value: string;
|
|
137
|
+
primary?: boolean;
|
|
138
|
+
type?: string;
|
|
139
|
+
}>;
|
|
140
|
+
active: boolean;
|
|
141
|
+
meta: {
|
|
142
|
+
resourceType: 'User';
|
|
143
|
+
created: string;
|
|
144
|
+
lastModified: string;
|
|
145
|
+
version: string;
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
export interface ScimGroup {
|
|
149
|
+
schemas: string[];
|
|
150
|
+
id: string;
|
|
151
|
+
externalId?: string;
|
|
152
|
+
displayName: string;
|
|
153
|
+
members: Array<{
|
|
154
|
+
value: string;
|
|
155
|
+
display?: string;
|
|
156
|
+
}>;
|
|
157
|
+
meta: {
|
|
158
|
+
resourceType: 'Group';
|
|
159
|
+
created: string;
|
|
160
|
+
lastModified: string;
|
|
161
|
+
version: string;
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
export declare class ScimError extends Error {
|
|
165
|
+
readonly status: number;
|
|
166
|
+
readonly scimType?: string | undefined;
|
|
167
|
+
constructor(status: number, message: string, scimType?: string | undefined);
|
|
168
|
+
}
|
|
169
|
+
/** Evaluate a SCIM filter subset: `attr op "value"` joined by `and` / `or`; ops eq, ne, co, sw, ew, pr. */
|
|
170
|
+
export declare function scimFilter(filter: string): (r: Record<string, unknown>) => boolean;
|
|
171
|
+
/** In-memory SCIM directory with optional JSON persistence. */
|
|
172
|
+
export declare class ScimDirectory {
|
|
173
|
+
private readonly storePath?;
|
|
174
|
+
users: Map<string, ScimUser>;
|
|
175
|
+
groups: Map<string, ScimGroup>;
|
|
176
|
+
constructor(storePath?: string | undefined);
|
|
177
|
+
private save;
|
|
178
|
+
private meta;
|
|
179
|
+
private userFrom;
|
|
180
|
+
private groupFrom;
|
|
181
|
+
createUser(b: Record<string, unknown>): ScimUser;
|
|
182
|
+
replaceUser(id: string, b: Record<string, unknown>): ScimUser;
|
|
183
|
+
mustUser(id: string): ScimUser;
|
|
184
|
+
mustGroup(id: string): ScimGroup;
|
|
185
|
+
deleteUser(id: string): void;
|
|
186
|
+
createGroup(b: Record<string, unknown>): ScimGroup;
|
|
187
|
+
replaceGroup(id: string, b: Record<string, unknown>): ScimGroup;
|
|
188
|
+
deleteGroup(id: string): void;
|
|
189
|
+
/** RFC 7644 §3.5.2 PATCH (the subset IdPs send). */
|
|
190
|
+
patch(kind: 'User' | 'Group', id: string, body: Record<string, unknown>): ScimUser | ScimGroup;
|
|
191
|
+
groupsOf(userId: string): ScimGroup[];
|
|
192
|
+
findUser(login: string): ScimUser | undefined;
|
|
193
|
+
}
|
|
194
|
+
/** Effective tenant memberships for group names (highest role per tenant). */
|
|
195
|
+
export declare function resolveMemberships(groups: readonly string[], cfg: Cfg): Array<{
|
|
196
|
+
tenant: string;
|
|
197
|
+
role: 'viewer' | 'admin' | 'owner';
|
|
198
|
+
via: string;
|
|
199
|
+
}>;
|
|
200
|
+
/** Verify an OIDC ID token; returns its claims. */
|
|
201
|
+
export declare function verifyIdToken(token: string, oidc: NonNullable<Cfg['oidc']>): Promise<Record<string, unknown>>;
|
|
202
|
+
/** Authorization-code + PKCE login URL. */
|
|
203
|
+
export declare function authorizeUrl(oidc: NonNullable<Cfg['oidc']>, opts?: {
|
|
204
|
+
state?: string;
|
|
205
|
+
verifier?: string;
|
|
206
|
+
}): {
|
|
207
|
+
url: string;
|
|
208
|
+
state: string;
|
|
209
|
+
codeVerifier: string;
|
|
210
|
+
};
|
|
211
|
+
export {};
|
|
212
|
+
//# sourceMappingURL=identity.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"identity.d.ts","sourceRoot":"","sources":["../../src/features/identity.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Enterprise SSO and SCIM (6.4).
|
|
3
|
+
*
|
|
4
|
+
* - **SCIM 2.0** (RFC 7643 / 7644): identity providers (Okta, Entra ID, OneLogin, Google …) provision users and
|
|
5
|
+
* groups into the gateway at `/api/v1/admin/identity/scim/v2` — `Users` and `Groups` with create, read, list (with
|
|
6
|
+
* `filter`, `startIndex`, `count`), replace (`PUT`), `PATCH` (add / replace / remove, including group members) and
|
|
7
|
+
* delete, plus `ServiceProviderConfig`, `ResourceTypes` and `Schemas`. Authenticate the IdP with an operator key.
|
|
8
|
+
* - **Group → tenant roles:** `groupRoles` maps IdP group names to tenant memberships; a user's effective memberships
|
|
9
|
+
* combine SCIM group membership with the `groups` claim of their ID token. Deactivated users have none.
|
|
10
|
+
* - **OIDC SSO:** `POST /admin/identity/sso/verify` verifies an ID token (issuer, audience, signature against the
|
|
11
|
+
* issuer's JWKS or an inline `jwks`) and returns the user and memberships; `GET /admin/identity/sso/authorize-url`
|
|
12
|
+
* builds an authorization-code + PKCE (S256) login URL.
|
|
13
|
+
*
|
|
14
|
+
* ```yaml
|
|
15
|
+
* identity:
|
|
16
|
+
* oidc:
|
|
17
|
+
* issuer: https://acme.okta.com
|
|
18
|
+
* clientId: 0oa1example
|
|
19
|
+
* redirectUri: https://gateway.acme.com/sso/callback
|
|
20
|
+
* groupsClaim: groups
|
|
21
|
+
* groupRoles:
|
|
22
|
+
* - { group: Platform, tenant: platform, role: owner }
|
|
23
|
+
* - { group: Engineering, tenant: eng, role: admin }
|
|
24
|
+
* storePath: ./data/scim.json # optional: persist the directory
|
|
25
|
+
* ```
|
|
26
|
+
*
|
|
27
|
+
* @module features/identity
|
|
28
|
+
*/
|
|
29
|
+
import { createHash, randomBytes, randomUUID } from 'node:crypto';
|
|
30
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
31
|
+
import { dirname } from 'node:path';
|
|
32
|
+
import { createLocalJWKSet, createRemoteJWKSet, jwtVerify } from 'jose';
|
|
33
|
+
import { z } from 'zod';
|
|
34
|
+
import express from 'express';
|
|
35
|
+
import { registerFeature, objectBody, badRequest } from '../gateway/features.js';
|
|
36
|
+
const Role = z.enum(['viewer', 'admin', 'owner']);
|
|
37
|
+
export const IdentitySchema = z
|
|
38
|
+
.object({
|
|
39
|
+
oidc: z
|
|
40
|
+
.object({
|
|
41
|
+
issuer: z.string().url(),
|
|
42
|
+
clientId: z.string().min(1),
|
|
43
|
+
redirectUri: z.string().url().optional(),
|
|
44
|
+
scopes: z.array(z.string()).default(['openid', 'email', 'profile', 'groups']),
|
|
45
|
+
groupsClaim: z.string().default('groups'),
|
|
46
|
+
jwksUrl: z.string().url().optional(),
|
|
47
|
+
jwks: z.object({ keys: z.array(z.record(z.unknown())) }).optional(),
|
|
48
|
+
authorizationEndpoint: z.string().url().optional(),
|
|
49
|
+
})
|
|
50
|
+
.strict()
|
|
51
|
+
.optional(),
|
|
52
|
+
groupRoles: z.array(z.object({ group: z.string().min(1), tenant: z.string().min(1), role: Role.default('viewer') }).strict()).default([]),
|
|
53
|
+
storePath: z.string().optional(),
|
|
54
|
+
})
|
|
55
|
+
.strict();
|
|
56
|
+
const USER = 'urn:ietf:params:scim:schemas:core:2.0:User';
|
|
57
|
+
const GROUP = 'urn:ietf:params:scim:schemas:core:2.0:Group';
|
|
58
|
+
const LIST = 'urn:ietf:params:scim:api:messages:2.0:ListResponse';
|
|
59
|
+
const PATCHOP = 'urn:ietf:params:scim:api:messages:2.0:PatchOp';
|
|
60
|
+
const ERR = 'urn:ietf:params:scim:api:messages:2.0:Error';
|
|
61
|
+
export class ScimError extends Error {
|
|
62
|
+
status;
|
|
63
|
+
scimType;
|
|
64
|
+
constructor(status, message, scimType) {
|
|
65
|
+
super(message);
|
|
66
|
+
this.status = status;
|
|
67
|
+
this.scimType = scimType;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
/** Evaluate a SCIM filter subset: `attr op "value"` joined by `and` / `or`; ops eq, ne, co, sw, ew, pr. */
|
|
71
|
+
export function scimFilter(filter) {
|
|
72
|
+
const get = (r, path) => {
|
|
73
|
+
let cur = [r];
|
|
74
|
+
for (const p of path.split('.'))
|
|
75
|
+
cur = cur.flatMap((c) => (Array.isArray(c) ? c : [c])).map((c) => (c && typeof c === 'object' ? c[Object.keys(c).find((k) => k.toLowerCase() === p.toLowerCase()) ?? p] : undefined));
|
|
76
|
+
return cur.flatMap((c) => (Array.isArray(c) ? c : [c])).filter((c) => c !== undefined);
|
|
77
|
+
};
|
|
78
|
+
const terms = filter.split(/\s+or\s+/i).map((alt) => alt.split(/\s+and\s+/i).map((t) => {
|
|
79
|
+
const m = /^\s*([\w.]+)\s+(eq|ne|co|sw|ew|pr)(?:\s+(?:"((?:[^"\\]|\\.)*)"|(true|false)))?\s*$/i.exec(t);
|
|
80
|
+
if (!m)
|
|
81
|
+
throw new ScimError(400, `unsupported filter: ${t}`, 'invalidFilter');
|
|
82
|
+
const [, attr, op, s, b] = m;
|
|
83
|
+
const want = b !== undefined ? b === 'true' : s;
|
|
84
|
+
return (r) => {
|
|
85
|
+
const vals = get(r, attr.replace(/^emails$/i, 'emails.value'));
|
|
86
|
+
const o = op.toLowerCase();
|
|
87
|
+
if (o === 'pr')
|
|
88
|
+
return vals.length > 0;
|
|
89
|
+
const norm = (v) => (typeof v === 'string' ? v.toLowerCase() : v);
|
|
90
|
+
const w = norm(want);
|
|
91
|
+
const hit = vals.some((v) => {
|
|
92
|
+
const x = norm(v);
|
|
93
|
+
if (o === 'eq')
|
|
94
|
+
return x === w;
|
|
95
|
+
if (o === 'ne')
|
|
96
|
+
return x !== w;
|
|
97
|
+
if (typeof x !== 'string' || typeof w !== 'string')
|
|
98
|
+
return false;
|
|
99
|
+
return o === 'co' ? x.includes(w) : o === 'sw' ? x.startsWith(w) : x.endsWith(w);
|
|
100
|
+
});
|
|
101
|
+
return o === 'ne' ? vals.length === 0 || hit : hit;
|
|
102
|
+
};
|
|
103
|
+
}));
|
|
104
|
+
return (r) => terms.some((and) => and.every((t) => t(r)));
|
|
105
|
+
}
|
|
106
|
+
const now = () => new Date().toISOString();
|
|
107
|
+
/** In-memory SCIM directory with optional JSON persistence. */
|
|
108
|
+
export class ScimDirectory {
|
|
109
|
+
storePath;
|
|
110
|
+
users = new Map();
|
|
111
|
+
groups = new Map();
|
|
112
|
+
constructor(storePath) {
|
|
113
|
+
this.storePath = storePath;
|
|
114
|
+
if (storePath && existsSync(storePath)) {
|
|
115
|
+
const d = JSON.parse(readFileSync(storePath, 'utf8'));
|
|
116
|
+
for (const u of d.users ?? [])
|
|
117
|
+
this.users.set(u.id, u);
|
|
118
|
+
for (const g of d.groups ?? [])
|
|
119
|
+
this.groups.set(g.id, g);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
save() {
|
|
123
|
+
if (!this.storePath)
|
|
124
|
+
return;
|
|
125
|
+
mkdirSync(dirname(this.storePath), { recursive: true });
|
|
126
|
+
writeFileSync(this.storePath, JSON.stringify({ users: [...this.users.values()], groups: [...this.groups.values()] }, null, 2));
|
|
127
|
+
}
|
|
128
|
+
meta(resourceType, prev) {
|
|
129
|
+
const v = prev ? Number(prev.version.replace(/\D/g, '')) + 1 : 1;
|
|
130
|
+
return { resourceType, created: prev?.created ?? now(), lastModified: now(), version: `W/"${v}"` };
|
|
131
|
+
}
|
|
132
|
+
userFrom(b, id, prev) {
|
|
133
|
+
if (typeof b.userName !== 'string' || !b.userName)
|
|
134
|
+
throw new ScimError(400, 'userName is required', 'invalidValue');
|
|
135
|
+
const clash = [...this.users.values()].find((u) => u.id !== id && u.userName.toLowerCase() === b.userName.toLowerCase());
|
|
136
|
+
if (clash)
|
|
137
|
+
throw new ScimError(409, `userName ${b.userName} already exists`, 'uniqueness');
|
|
138
|
+
return {
|
|
139
|
+
schemas: [USER],
|
|
140
|
+
id,
|
|
141
|
+
...(typeof b.externalId === 'string' ? { externalId: b.externalId } : {}),
|
|
142
|
+
userName: b.userName,
|
|
143
|
+
...(b.name && typeof b.name === 'object' ? { name: b.name } : {}),
|
|
144
|
+
...(typeof b.displayName === 'string' ? { displayName: b.displayName } : {}),
|
|
145
|
+
...(Array.isArray(b.emails) ? { emails: b.emails } : {}),
|
|
146
|
+
active: b.active !== false,
|
|
147
|
+
meta: this.meta('User', prev?.meta),
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
groupFrom(b, id, prev) {
|
|
151
|
+
if (typeof b.displayName !== 'string' || !b.displayName)
|
|
152
|
+
throw new ScimError(400, 'displayName is required', 'invalidValue');
|
|
153
|
+
const members = Array.isArray(b.members) ? b.members.filter((m) => typeof m?.value === 'string').map((m) => ({ value: m.value, ...(typeof m.display === 'string' ? { display: m.display } : {}) })) : [];
|
|
154
|
+
return { schemas: [GROUP], id, ...(typeof b.externalId === 'string' ? { externalId: b.externalId } : {}), displayName: b.displayName, members, meta: this.meta('Group', prev?.meta) };
|
|
155
|
+
}
|
|
156
|
+
createUser(b) {
|
|
157
|
+
const u = this.userFrom(b, randomUUID());
|
|
158
|
+
this.users.set(u.id, u);
|
|
159
|
+
this.save();
|
|
160
|
+
return u;
|
|
161
|
+
}
|
|
162
|
+
replaceUser(id, b) {
|
|
163
|
+
const prev = this.mustUser(id);
|
|
164
|
+
const u = this.userFrom(b, id, prev);
|
|
165
|
+
this.users.set(id, u);
|
|
166
|
+
this.save();
|
|
167
|
+
return u;
|
|
168
|
+
}
|
|
169
|
+
mustUser(id) {
|
|
170
|
+
const u = this.users.get(id);
|
|
171
|
+
if (!u)
|
|
172
|
+
throw new ScimError(404, `User ${id} not found`);
|
|
173
|
+
return u;
|
|
174
|
+
}
|
|
175
|
+
mustGroup(id) {
|
|
176
|
+
const g = this.groups.get(id);
|
|
177
|
+
if (!g)
|
|
178
|
+
throw new ScimError(404, `Group ${id} not found`);
|
|
179
|
+
return g;
|
|
180
|
+
}
|
|
181
|
+
deleteUser(id) {
|
|
182
|
+
this.mustUser(id);
|
|
183
|
+
this.users.delete(id);
|
|
184
|
+
for (const g of this.groups.values())
|
|
185
|
+
g.members = g.members.filter((m) => m.value !== id);
|
|
186
|
+
this.save();
|
|
187
|
+
}
|
|
188
|
+
createGroup(b) {
|
|
189
|
+
const g = this.groupFrom(b, randomUUID());
|
|
190
|
+
this.groups.set(g.id, g);
|
|
191
|
+
this.save();
|
|
192
|
+
return g;
|
|
193
|
+
}
|
|
194
|
+
replaceGroup(id, b) {
|
|
195
|
+
const g = this.groupFrom(b, id, this.mustGroup(id));
|
|
196
|
+
this.groups.set(id, g);
|
|
197
|
+
this.save();
|
|
198
|
+
return g;
|
|
199
|
+
}
|
|
200
|
+
deleteGroup(id) {
|
|
201
|
+
this.mustGroup(id);
|
|
202
|
+
this.groups.delete(id);
|
|
203
|
+
this.save();
|
|
204
|
+
}
|
|
205
|
+
/** RFC 7644 §3.5.2 PATCH (the subset IdPs send). */
|
|
206
|
+
patch(kind, id, body) {
|
|
207
|
+
if (!Array.isArray(body.schemas) || !body.schemas.includes(PATCHOP) || !Array.isArray(body.Operations))
|
|
208
|
+
throw new ScimError(400, 'PatchOp body required', 'invalidSyntax');
|
|
209
|
+
const cur = structuredClone(kind === 'User' ? this.mustUser(id) : this.mustGroup(id));
|
|
210
|
+
for (const raw of body.Operations) {
|
|
211
|
+
const op = String(raw.op ?? '').toLowerCase();
|
|
212
|
+
const path = typeof raw.path === 'string' ? raw.path : undefined;
|
|
213
|
+
const value = raw.value;
|
|
214
|
+
if (!['add', 'replace', 'remove'].includes(op))
|
|
215
|
+
throw new ScimError(400, `bad op ${String(raw.op)}`, 'invalidSyntax');
|
|
216
|
+
const memberFilter = path && /^members\[value eq "([^"]+)"\]$/i.exec(path);
|
|
217
|
+
if (kind === 'Group' && (path?.toLowerCase() === 'members' || memberFilter)) {
|
|
218
|
+
const members = cur.members;
|
|
219
|
+
if (op === 'remove') {
|
|
220
|
+
const drop = memberFilter ? [memberFilter[1]] : Array.isArray(value) ? value.map((v) => v.value) : members.map((m) => m.value);
|
|
221
|
+
cur.members = members.filter((m) => !drop.includes(m.value));
|
|
222
|
+
}
|
|
223
|
+
else {
|
|
224
|
+
const add = (Array.isArray(value) ? value : [value]);
|
|
225
|
+
cur.members = op === 'replace' ? add : [...members, ...add.filter((a) => !members.some((m) => m.value === a.value))];
|
|
226
|
+
}
|
|
227
|
+
continue;
|
|
228
|
+
}
|
|
229
|
+
if (op === 'remove') {
|
|
230
|
+
if (!path)
|
|
231
|
+
throw new ScimError(400, 'remove needs a path', 'noTarget');
|
|
232
|
+
delete cur[path];
|
|
233
|
+
}
|
|
234
|
+
else if (path) {
|
|
235
|
+
const [a, b] = path.split('.');
|
|
236
|
+
if (b)
|
|
237
|
+
cur[a] = { ...(cur[a] ?? {}), [b]: value };
|
|
238
|
+
else
|
|
239
|
+
cur[path] = value;
|
|
240
|
+
}
|
|
241
|
+
else if (value && typeof value === 'object')
|
|
242
|
+
Object.assign(cur, value);
|
|
243
|
+
}
|
|
244
|
+
return kind === 'User' ? this.replaceUser(id, cur) : this.replaceGroup(id, cur);
|
|
245
|
+
}
|
|
246
|
+
groupsOf(userId) {
|
|
247
|
+
return [...this.groups.values()].filter((g) => g.members.some((m) => m.value === userId));
|
|
248
|
+
}
|
|
249
|
+
findUser(login) {
|
|
250
|
+
const l = login.toLowerCase();
|
|
251
|
+
return [...this.users.values()].find((u) => u.userName.toLowerCase() === l || u.emails?.some((e) => e.value.toLowerCase() === l));
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
const RANK = { viewer: 1, admin: 2, owner: 3 };
|
|
255
|
+
/** Effective tenant memberships for group names (highest role per tenant). */
|
|
256
|
+
export function resolveMemberships(groups, cfg) {
|
|
257
|
+
const out = new Map();
|
|
258
|
+
const lower = new Set(groups.map((g) => g.toLowerCase()));
|
|
259
|
+
for (const r of cfg.groupRoles) {
|
|
260
|
+
if (!lower.has(r.group.toLowerCase()))
|
|
261
|
+
continue;
|
|
262
|
+
const prev = out.get(r.tenant);
|
|
263
|
+
if (!prev || RANK[r.role] > RANK[prev.role])
|
|
264
|
+
out.set(r.tenant, { tenant: r.tenant, role: r.role, via: r.group });
|
|
265
|
+
}
|
|
266
|
+
return [...out.values()];
|
|
267
|
+
}
|
|
268
|
+
const jwksCache = new Map();
|
|
269
|
+
/** Verify an OIDC ID token; returns its claims. */
|
|
270
|
+
export async function verifyIdToken(token, oidc) {
|
|
271
|
+
let keys;
|
|
272
|
+
if (oidc.jwks)
|
|
273
|
+
keys = createLocalJWKSet(oidc.jwks);
|
|
274
|
+
else {
|
|
275
|
+
const url = oidc.jwksUrl ?? `${oidc.issuer.replace(/\/+$/, '')}/.well-known/jwks.json`;
|
|
276
|
+
keys = jwksCache.get(url) ?? createRemoteJWKSet(new URL(url));
|
|
277
|
+
jwksCache.set(url, keys);
|
|
278
|
+
}
|
|
279
|
+
const { payload } = await jwtVerify(token, keys, { issuer: oidc.issuer, audience: oidc.clientId });
|
|
280
|
+
return payload;
|
|
281
|
+
}
|
|
282
|
+
/** Authorization-code + PKCE login URL. */
|
|
283
|
+
export function authorizeUrl(oidc, opts = {}) {
|
|
284
|
+
const codeVerifier = opts.verifier ?? randomBytes(32).toString('base64url');
|
|
285
|
+
const state = opts.state ?? randomBytes(16).toString('base64url');
|
|
286
|
+
const u = new URL(oidc.authorizationEndpoint ?? `${oidc.issuer.replace(/\/+$/, '')}/authorize`);
|
|
287
|
+
u.searchParams.set('response_type', 'code');
|
|
288
|
+
u.searchParams.set('client_id', oidc.clientId);
|
|
289
|
+
if (oidc.redirectUri)
|
|
290
|
+
u.searchParams.set('redirect_uri', oidc.redirectUri);
|
|
291
|
+
u.searchParams.set('scope', oidc.scopes.join(' '));
|
|
292
|
+
u.searchParams.set('state', state);
|
|
293
|
+
u.searchParams.set('code_challenge', createHash('sha256').update(codeVerifier).digest('base64url'));
|
|
294
|
+
u.searchParams.set('code_challenge_method', 'S256');
|
|
295
|
+
return { url: u.toString(), state, codeVerifier };
|
|
296
|
+
}
|
|
297
|
+
const settings = (cfg) => IdentitySchema.parse(cfg.identity ?? {});
|
|
298
|
+
const directories = new Map();
|
|
299
|
+
const directoryFor = (cfg) => {
|
|
300
|
+
const k = cfg.storePath ?? '';
|
|
301
|
+
let d = directories.get(k);
|
|
302
|
+
if (!d)
|
|
303
|
+
directories.set(k, (d = new ScimDirectory(cfg.storePath)));
|
|
304
|
+
return d;
|
|
305
|
+
};
|
|
306
|
+
registerFeature({
|
|
307
|
+
id: 'identity',
|
|
308
|
+
since: '6.4.0',
|
|
309
|
+
summary: 'Enterprise SSO (OIDC ID tokens) and SCIM 2.0 user / group provisioning mapped to tenant roles',
|
|
310
|
+
mount: (router, ctx) => {
|
|
311
|
+
router.use(express.json({ type: ['application/scim+json'], limit: '1mb' }));
|
|
312
|
+
let own; // per-gateway directory when not persisted
|
|
313
|
+
const dir = () => {
|
|
314
|
+
const c = settings(ctx.config());
|
|
315
|
+
if (c.storePath)
|
|
316
|
+
return directoryFor(c);
|
|
317
|
+
return (own ??= new ScimDirectory());
|
|
318
|
+
};
|
|
319
|
+
const scim = (res, fn, status = 200) => {
|
|
320
|
+
try {
|
|
321
|
+
const out = fn();
|
|
322
|
+
res.status(status).type('application/scim+json');
|
|
323
|
+
if (out === undefined)
|
|
324
|
+
return void res.status(204).end();
|
|
325
|
+
res.send(JSON.stringify(out));
|
|
326
|
+
}
|
|
327
|
+
catch (e) {
|
|
328
|
+
if (!(e instanceof ScimError))
|
|
329
|
+
throw e;
|
|
330
|
+
res.status(e.status).type('application/scim+json').send(JSON.stringify({ schemas: [ERR], status: String(e.status), detail: e.message, ...(e.scimType ? { scimType: e.scimType } : {}) }));
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const body = (req) => {
|
|
334
|
+
const b = req.body;
|
|
335
|
+
if (!b || typeof b !== 'object' || Array.isArray(b))
|
|
336
|
+
throw new ScimError(400, 'JSON object body required', 'invalidSyntax');
|
|
337
|
+
return b;
|
|
338
|
+
};
|
|
339
|
+
const list = (req, all) => {
|
|
340
|
+
const f = typeof req.query.filter === 'string' && req.query.filter ? scimFilter(req.query.filter) : () => true;
|
|
341
|
+
const hits = all.filter((r) => f(r));
|
|
342
|
+
const start = Math.max(1, Number(req.query.startIndex) || 1);
|
|
343
|
+
const count = Math.min(Math.max(0, req.query.count === undefined ? 100 : Number(req.query.count) || 0), 1000);
|
|
344
|
+
const page = hits.slice(start - 1, start - 1 + count);
|
|
345
|
+
return { schemas: [LIST], totalResults: hits.length, startIndex: start, itemsPerPage: page.length, Resources: page };
|
|
346
|
+
};
|
|
347
|
+
const S = '/scim/v2';
|
|
348
|
+
router.get(`${S}/ServiceProviderConfig`, (_req, res) => scim(res, () => ({ schemas: ['urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig'], patch: { supported: true }, bulk: { supported: false, maxOperations: 0, maxPayloadSize: 0 }, filter: { supported: true, maxResults: 1000 }, changePassword: { supported: false }, sort: { supported: false }, etag: { supported: true }, authenticationSchemes: [{ type: 'oauthbearertoken', name: 'Bearer token', description: 'Operator API key' }] })));
|
|
349
|
+
router.get(`${S}/ResourceTypes`, (_req, res) => scim(res, () => ({ schemas: [LIST], totalResults: 2, Resources: [{ id: 'User', name: 'User', endpoint: '/Users', schema: USER }, { id: 'Group', name: 'Group', endpoint: '/Groups', schema: GROUP }] })));
|
|
350
|
+
router.get(`${S}/Schemas`, (_req, res) => scim(res, () => ({ schemas: [LIST], totalResults: 2, Resources: [{ id: USER, name: 'User' }, { id: GROUP, name: 'Group' }] })));
|
|
351
|
+
router.get(`${S}/Users`, (req, res) => scim(res, () => list(req, [...dir().users.values()])));
|
|
352
|
+
router.post(`${S}/Users`, (req, res) => scim(res, () => dir().createUser(body(req)), 201));
|
|
353
|
+
router.get(`${S}/Users/:id`, (req, res) => scim(res, () => dir().mustUser(req.params.id)));
|
|
354
|
+
router.put(`${S}/Users/:id`, (req, res) => scim(res, () => dir().replaceUser(req.params.id, body(req))));
|
|
355
|
+
router.patch(`${S}/Users/:id`, (req, res) => scim(res, () => dir().patch('User', req.params.id, body(req))));
|
|
356
|
+
router.delete(`${S}/Users/:id`, (req, res) => scim(res, () => void dir().deleteUser(req.params.id)));
|
|
357
|
+
router.get(`${S}/Groups`, (req, res) => scim(res, () => list(req, [...dir().groups.values()])));
|
|
358
|
+
router.post(`${S}/Groups`, (req, res) => scim(res, () => dir().createGroup(body(req)), 201));
|
|
359
|
+
router.get(`${S}/Groups/:id`, (req, res) => scim(res, () => dir().mustGroup(req.params.id)));
|
|
360
|
+
router.put(`${S}/Groups/:id`, (req, res) => scim(res, () => dir().replaceGroup(req.params.id, body(req))));
|
|
361
|
+
router.patch(`${S}/Groups/:id`, (req, res) => scim(res, () => dir().patch('Group', req.params.id, body(req))));
|
|
362
|
+
router.delete(`${S}/Groups/:id`, (req, res) => scim(res, () => void dir().deleteGroup(req.params.id)));
|
|
363
|
+
router.get('/', (_req, res) => {
|
|
364
|
+
const c = settings(ctx.config());
|
|
365
|
+
const d = dir();
|
|
366
|
+
res.json({ oidc: c.oidc ? { issuer: c.oidc.issuer, clientId: c.oidc.clientId, groupsClaim: c.oidc.groupsClaim } : null, groupRoles: c.groupRoles, users: d.users.size, activeUsers: [...d.users.values()].filter((u) => u.active).length, groups: d.groups.size, scimBase: '/api/v1/admin/identity/scim/v2' });
|
|
367
|
+
});
|
|
368
|
+
router.get('/memberships', (req, res) => {
|
|
369
|
+
const login = String(req.query.user ?? '');
|
|
370
|
+
const u = dir().findUser(login);
|
|
371
|
+
if (!u)
|
|
372
|
+
return void res.status(404).json({ error: 'Not Found', message: `no SCIM user "${login}"` });
|
|
373
|
+
const groups = dir().groupsOf(u.id).map((g) => g.displayName);
|
|
374
|
+
res.json({ user: u.userName, active: u.active, groups, memberships: u.active ? resolveMemberships(groups, settings(ctx.config())) : [] });
|
|
375
|
+
});
|
|
376
|
+
router.get('/sso/authorize-url', (_req, res) => {
|
|
377
|
+
const c = settings(ctx.config());
|
|
378
|
+
if (!c.oidc)
|
|
379
|
+
return void res.status(404).json({ error: 'Not Found', message: 'identity.oidc is not configured' });
|
|
380
|
+
res.json(authorizeUrl(c.oidc));
|
|
381
|
+
});
|
|
382
|
+
router.post('/sso/verify', async (req, res) => {
|
|
383
|
+
const b = objectBody(req, res);
|
|
384
|
+
if (!b)
|
|
385
|
+
return;
|
|
386
|
+
const c = settings(ctx.config());
|
|
387
|
+
if (!c.oidc)
|
|
388
|
+
return void res.status(404).json({ error: 'Not Found', message: 'identity.oidc is not configured' });
|
|
389
|
+
if (typeof b.idToken !== 'string')
|
|
390
|
+
return badRequest(res, '"idToken" is required');
|
|
391
|
+
let claims;
|
|
392
|
+
try {
|
|
393
|
+
claims = await verifyIdToken(b.idToken, c.oidc);
|
|
394
|
+
}
|
|
395
|
+
catch (e) {
|
|
396
|
+
return void res.status(401).json({ error: 'Unauthorized', message: `invalid ID token: ${e.message}` });
|
|
397
|
+
}
|
|
398
|
+
const login = String(claims.email ?? claims.preferred_username ?? claims.sub ?? '');
|
|
399
|
+
const u = dir().findUser(login);
|
|
400
|
+
if (u && !u.active)
|
|
401
|
+
return void res.status(403).json({ error: 'Forbidden', message: `user ${login} is deactivated` });
|
|
402
|
+
const claimGroups = Array.isArray(claims[c.oidc.groupsClaim]) ? claims[c.oidc.groupsClaim].map(String) : [];
|
|
403
|
+
const groups = [...new Set([...claimGroups, ...(u ? dir().groupsOf(u.id).map((g) => g.displayName) : [])])];
|
|
404
|
+
res.json({ subject: claims.sub, user: login, provisioned: !!u, groups, memberships: resolveMemberships(groups, c), clientId: `sso:${login}` });
|
|
405
|
+
});
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
//# sourceMappingURL=identity.js.map
|