Skip to content

Commit 9a161ae

Browse files
committed
feat: SCIM 2.0 research
1 parent 93bbdc8 commit 9a161ae

File tree

10 files changed

+322
-9
lines changed

10 files changed

+322
-9
lines changed

codegen.json

+1
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
"RemoveFeatureFlagOwnerSuccess": "./types/RemoveFeatureFlagOwnerSuccess#RemoveFeatureFlagOwnerSuccessSource",
3232
"RetrospectiveMeeting": "../../postgres/types/Meeting#RetrospectiveMeeting",
3333
"SAML": "./types/SAML#SAMLSource",
34+
"ScimCreateUserSuccess": "./types/ScimCreateUserSuccess#ScimCreateUserSuccessSource",
3435
"SetIsFreeMeetingTemplateSuccess": "./types/SetIsFreeMeetingTemplateSuccess#SetIsFreeMeetingTemplateSuccessSource",
3536
"SignupsPayload": "./types/SignupsPayload#SignupsPayloadSource",
3637
"StartTrialSuccess": "./types/StartTrialSuccess#StartTrialSuccessSource",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import {USER_PREFERRED_NAME_LIMIT} from '../../../postgres/constants'
2+
import User from '../../../database/types/User'
3+
import {MutationResolvers} from '../resolverTypes'
4+
import generateUID from '../../../generateUID'
5+
import {generateIdenticon} from '../../private/mutations/helpers/generateIdenticon'
6+
import {AuthIdentityTypeEnum} from '../../../../client/types/constEnums'
7+
import AuthIdentityLocal from '../../../database/types/AuthIdentityLocal'
8+
import bootstrapNewUser from '../../mutations/helpers/bootstrapNewUser'
9+
10+
const scimCreateUser: MutationResolvers['scimCreateUser'] = async (
11+
_source,
12+
{email: denormEmail, preferredName},
13+
{dataLoader}
14+
) => {
15+
const email = denormEmail.toLowerCase().trim()
16+
if (email.length > USER_PREFERRED_NAME_LIMIT) {
17+
return {error: {message: 'Email is too long'}}
18+
}
19+
20+
const userId = `local|${generateUID()}`
21+
const newUser = new User({
22+
id: userId,
23+
preferredName,
24+
email,
25+
picture: await generateIdenticon(userId, preferredName),
26+
identities: []
27+
})
28+
const identityId = `${userId}:${AuthIdentityTypeEnum.LOCAL}`
29+
newUser.identities.push(new AuthIdentityLocal({hashedPassword: 'foo', id: identityId, isEmailVerified: true}))
30+
await bootstrapNewUser(newUser, false, dataLoader)
31+
32+
return {
33+
userId: newUser.id,
34+
isNewUser: true
35+
}
36+
}
37+
38+
export default scimCreateUser

packages/server/graphql/private/typeDefs/Mutation.graphql

+5
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,11 @@ type Mutation {
436436
samlName: ID!
437437
): UserLogInPayload!
438438

439+
scimCreateUser(
440+
email: ID!
441+
preferredName: String!
442+
): ScimCreateUserPayload!
443+
439444
"""
440445
Log in with email from a trusted Mattermost
441446
"""
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""
2+
Return type of ScimCreateUser
3+
"""
4+
union ScimCreateUserPayload = ErrorPayload | ScimCreateUserSuccess
5+
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
"""
2+
Generic payload when a user was created via SCIM
3+
"""
4+
type ScimCreateUserSuccess {
5+
userId: ID
6+
7+
"""
8+
if a new user is created
9+
"""
10+
isNewUser: Boolean
11+
12+
"""
13+
the newly created user, or the existing user
14+
"""
15+
user: User
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import {ScimCreateUserSuccessResolvers} from '../resolverTypes'
2+
3+
export type ScimCreateUserSuccessSource = {
4+
userId: string
5+
isNewUser: boolean
6+
}
7+
8+
const ScimCreateUserSuccess: ScimCreateUserSuccessResolvers = {
9+
user: ({userId}, _args, {dataLoader}) => {
10+
return dataLoader.get('users').loadNonNull(userId)
11+
}
12+
}
13+
14+
export default ScimCreateUserSuccess

packages/server/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,7 @@
139139
"rrule-rust": "^2.0.2",
140140
"samlify": "^2.8.2",
141141
"sanitize-html": "^2.13.0",
142+
"scimmy": "^1.3.5",
142143
"sharp": "^0.32.6",
143144
"string-similarity": "^3.0.0",
144145
"stripe": "^9.13.0",

packages/server/scim/SCIMHandler.ts

+226
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,226 @@
1+
import {HttpRequest, HttpResponse} from 'uWebSockets.js'
2+
import uWSAsyncHandler from '../graphql/uWSAsyncHandler'
3+
import parseBody from '../parseBody'
4+
import querystring from 'querystring'
5+
import publishWebhookGQL from '../utils/publishWebhookGQL'
6+
import SCIMMY from 'scimmy'
7+
8+
const ROUTE_PREFIX = '/scim'
9+
const PROTO = process.env.PROTO
10+
const HOST = process.env.HOST
11+
const PORT = Number(__PRODUCTION__ ? process.env.PORT : process.env.SOCKET_PORT)
12+
13+
const ROUTE = `${PROTO}://${HOST}${ROUTE_PREFIX}`
14+
15+
const createUser = `
16+
mutation ScimCreateUser($email: ID!, $preferredName: String!) {
17+
scimCreateUser(email: $email, preferredName: $preferredName) {
18+
... on ErrorPayload {
19+
error {
20+
message
21+
}
22+
}
23+
...on ScimCreateUserSuccess {
24+
user {
25+
id
26+
email
27+
}
28+
}
29+
}
30+
}
31+
`
32+
33+
const getUser = `
34+
query User($userId: ID!) {
35+
user(userId: $userId) {
36+
id
37+
email
38+
}
39+
}
40+
`
41+
42+
// With both shorthand and full syntax
43+
SCIMMY.Config.set({
44+
//documentationUri: "https://example.com/docs/scim.html",
45+
patch: false,
46+
filter: false,
47+
bulk: false,/*{
48+
supported: true,
49+
maxPayloadSize: 2097152
50+
},*/
51+
authenticationSchemes: {
52+
name: 'OAuth Bearer Token',
53+
description: 'Authentication scheme using the OAuth Bearer Token Standard',
54+
specUri: 'http://www.rfc-editor.org/info/rfc6750',
55+
type: 'oauthbearertoken'
56+
}
57+
});
58+
59+
// Basic usage with provided resource type implementations
60+
SCIMMY.Resources.declare(SCIMMY.Resources.User)
61+
.ingress(async (_resource, data) => {
62+
console.log('GEORG User ingress', data.id, data.userName)
63+
const { userName, displayName, emails } = data
64+
const email = (emails?.find((email) => email.primary) ?? emails?.[0])?.value
65+
console.log('GEORG User ingress', displayName, userName, email)
66+
67+
const newUser = await publishWebhookGQL(createUser, {
68+
email,
69+
preferredName: displayName ?? userName
70+
})
71+
console.log('GEORG User ingress newUser', newUser)
72+
const {id, email: normalizedEmail} = (newUser as any).data.scimCreateUser.user
73+
return {
74+
id,
75+
userName: normalizedEmail
76+
}
77+
})
78+
.egress(async (_resource, userId) => {
79+
console.log('GEORG User egress', userId)
80+
const user = await publishWebhookGQL(getUser, {userId})
81+
console.log('GEORG User egress', user)
82+
const {id, email} = (user as any).data.user
83+
console.log('GEORG User egress', id, email)
84+
return {
85+
id,
86+
userName: email
87+
}
88+
})
89+
.degress((resource) => {
90+
console.log('GEORG User degress', resource)
91+
});
92+
93+
SCIMMY.Resources.Schema.basepath(ROUTE)
94+
SCIMMY.Resources.ResourceType.basepath(ROUTE)
95+
SCIMMY.Resources.ServiceProviderConfig.basepath(ROUTE)
96+
for (let Resource of Object.values(SCIMMY.Resources.declared())) {
97+
Resource.basepath(ROUTE)
98+
}
99+
100+
const SCIMHandler = uWSAsyncHandler(async (res: HttpResponse, req: HttpRequest) => {
101+
const method = req.getMethod();
102+
const url = req.getUrl(); // Full URL path
103+
const query = req.getQuery();
104+
const parsedQuery = Object.fromEntries(new URLSearchParams(query).entries())
105+
106+
console.log(`GEORG [${method}] ${url}`);
107+
108+
if (url === "/scim/ServiceProviderConfig" && method === "get") {
109+
res.writeHeader("Content-Type", "application/json").end(JSON.stringify(await new SCIMMY.Resources.ServiceProviderConfig(req.getQuery()).read()));
110+
return
111+
}
112+
113+
if (url.startsWith("/scim/ResourceTypes") && method === "get") {
114+
const idMatch = url.match(/^\/scim\/ResourceTypes\/(.+)$/);
115+
if (!idMatch) {
116+
try {
117+
const resourceTypes = await new SCIMMY.Resources.ResourceType(query).read()
118+
res.writeHeader("Content-Type", "application/scim+json").end(JSON.stringify(resourceTypes))
119+
} catch (err) {
120+
res.writeStatus("400 Bad Request").end(JSON.stringify({ error: String(err) }))
121+
}
122+
}
123+
else {
124+
const id = querystring.unescape(idMatch[1] ?? '');
125+
try {
126+
const resourceTypes = await new SCIMMY.Resources.ResourceType(id, parsedQuery).read()
127+
res.writeHeader("Content-Type", "application/scim+json").end(JSON.stringify(resourceTypes))
128+
} catch (err) {
129+
res.writeStatus("400 Bad Request").end(JSON.stringify({ error: String(err) }))
130+
}
131+
}
132+
return
133+
}
134+
135+
if (url.startsWith("/scim/Schemas") && method === "get") {
136+
const idMatch = url.match(/^\/scim\/Schemas\/(.+)$/);
137+
if (!idMatch) {
138+
try {
139+
const resourceTypes = await new SCIMMY.Resources.Schema(query).read()
140+
res.writeHeader("Content-Type", "application/scim+json").end(JSON.stringify(resourceTypes))
141+
} catch (err) {
142+
res.writeStatus("400 Bad Request").end(JSON.stringify({ error: String(err) }))
143+
}
144+
}
145+
else {
146+
const id = querystring.unescape(idMatch[1] ?? '');
147+
try {
148+
const resourceTypes = await new SCIMMY.Resources.Schema(id, parsedQuery).read()
149+
res.writeHeader("Content-Type", "application/scim+json").end(JSON.stringify(resourceTypes))
150+
} catch (err) {
151+
res.writeStatus("400 Bad Request").end(JSON.stringify({ error: String(err) }))
152+
}
153+
}
154+
return
155+
}
156+
157+
if (url.startsWith("/scim/Users")) {
158+
const idMatch = url.match(/^\/scim\/Users\/(.+)$/);
159+
if (!idMatch) {
160+
if (method === "get") {
161+
const users = await new SCIMMY.Resources.User(query).read();
162+
res.writeHeader("Content-Type", "application/scim+json").end(JSON.stringify(users));
163+
return
164+
}
165+
166+
if (method === "post") {
167+
const body = await parseBody({res})
168+
if (body === null) {
169+
res.writeStatus("400 Bad Request")
170+
return
171+
}
172+
try {
173+
const user = await new SCIMMY.Resources.User(query).write(body)
174+
res.writeStatus("201 Created").writeHeader("Content-Type", "application/scim+json").end(JSON.stringify(user));
175+
} catch (err) {
176+
res.writeStatus("400 Bad Request").end(JSON.stringify({ error: String(err) }));
177+
}
178+
return
179+
}
180+
} else {
181+
const id = querystring.unescape(idMatch[1] ?? '');
182+
console.log('GEORG User id', id)
183+
184+
if (method === "get") {
185+
try {
186+
const user = await new SCIMMY.Resources.User(query).read(id)
187+
res.writeHeader("Content-Type", "application/scim+json")
188+
res.end(JSON.stringify(user))
189+
} catch (err) {
190+
res.writeStatus("404 Not Found").end(JSON.stringify({ error: "User not found" }))
191+
}
192+
return
193+
}
194+
195+
if (method === "patch") {
196+
const body = await parseBody({res})
197+
if (body === null) {
198+
res.writeStatus("400 Bad Request")
199+
return
200+
}
201+
try {
202+
const updatedUser = await new SCIMMY.Resources.User(id, parsedQuery).patch(body as any);
203+
res.writeHeader("Content-Type", "application/scim+json").end(JSON.stringify(updatedUser));
204+
} catch (err) {
205+
res.writeStatus("400 Bad Request").end(JSON.stringify({ error: String(err) }));
206+
}
207+
return
208+
}
209+
210+
if (method === "delete") {
211+
try {
212+
await new SCIMMY.Resources.User(id).dispose();
213+
res.writeStatus("204 No Content").end();
214+
} catch (err) {
215+
res.writeStatus("404 Not Found").end(JSON.stringify({ error: "User not found" }));
216+
}
217+
return
218+
}
219+
}
220+
}
221+
222+
res.writeStatus("404 Not Found").end(JSON.stringify({ error: "Endpoint not found" }));
223+
return
224+
})
225+
226+
export default SCIMHandler

packages/server/server.ts

+2
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import SSEPingHandler from './sse/SSEPingHandler'
2525
import {createStaticFileHandler} from './staticFileHandler'
2626
import {Logger} from './utils/Logger'
2727
import SAMLHandler from './utils/SAMLHandler'
28+
import SCIMHandler from './scim/SCIMHandler'
2829

2930
export const RECONNECT_WINDOW = process.env.WEB_SERVER_RECONNECT_WINDOW
3031
? parseInt(process.env.WEB_SERVER_RECONNECT_WINDOW, 10) * 1000
@@ -72,6 +73,7 @@ uws
7273
.post('/graphql', httpGraphQLHandler)
7374
.post('/intranet-graphql', intranetGraphQLHandler)
7475
.post('/saml/:domain', SAMLHandler)
76+
.any('/scim/*', SCIMHandler)
7577
.ws('/*', {
7678
compression: SHARED_COMPRESSOR,
7779
idleTimeout: 0,

yarn.lock

+14-9
Original file line numberDiff line numberDiff line change
@@ -9922,9 +9922,9 @@ available-typed-arrays@^1.0.7:
99229922
possible-typed-array-names "^1.0.0"
99239923

99249924
axios@^1.0.0, axios@^1.6.0, axios@^1.7.4, axios@^1.7.8:
9925-
version "1.8.2"
9926-
resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.2.tgz#fabe06e241dfe83071d4edfbcaa7b1c3a40f7979"
9927-
integrity sha512-ls4GYBm5aig9vWx8AWDSGLpnpDQRtWAfrjU+EuytuODrFBkqesN2RkOQCBzrA1RQNHw1SmRMSDDDSwzNAYQ6Rg==
9925+
version "1.8.4"
9926+
resolved "https://registry.yarnpkg.com/axios/-/axios-1.8.4.tgz#78990bb4bc63d2cae072952d374835950a82f447"
9927+
integrity sha512-eBSYY4Y68NNlHbHBMdeDmKNtDgXWhQsJcGqzO3iLUM0GraQFSS9cVgPX5I9b3lbdFKyYoAEGAZF1DwhTaljNAw==
99289928
dependencies:
99299929
follow-redirects "^1.15.6"
99309930
form-data "^4.0.0"
@@ -10612,9 +10612,9 @@ camelize@^1.0.0:
1061210612
integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==
1061310613

1061410614
caniuse-lite@^1.0.30001646, caniuse-lite@^1.0.30001669, caniuse-lite@~1.0.0:
10615-
version "1.0.30001702"
10616-
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001702.tgz#cde16fa8adaa066c04aec2967b6cde46354644c4"
10617-
integrity sha512-LoPe/D7zioC0REI5W73PeR1e1MLCipRGq/VkovJnd6Df+QVqT+vT33OXCp8QUd7kA7RZrHWxb1B36OQKI/0gOA==
10615+
version "1.0.30001706"
10616+
resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001706.tgz#902c3f896f4b2968031c3a546ab2ef8b465a2c8f"
10617+
integrity sha512-3ZczoTApMAZwPKYWmwVbQMFpXBDds3/0VciVoUwPUbldlYyVLmRVuRs/PcUZtHpbLRpzzDvrvnFuREsGt6lUug==
1061810618

1061910619
capital-case@^1.0.4:
1062010620
version "1.0.4"
@@ -17943,9 +17943,9 @@ nan@^2.19.0, nan@^2.20.0:
1794317943
integrity sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==
1794417944

1794517945
nanoid@^2.0.0, nanoid@^3.1.31, nanoid@^3.3.7, nanoid@^3.3.8:
17946-
version "3.3.9"
17947-
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.9.tgz#e0097d8e026b3343ff053e9ccd407360a03f503a"
17948-
integrity sha512-SppoicMGpZvbF1l3z4x7No3OlIjP7QJvC9XR7AhZr1kL133KHnKPztkKDc+Ir4aJ/1VhTySrtKhrsycmrMQfvg==
17946+
version "3.3.11"
17947+
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b"
17948+
integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==
1794917949

1795017950
napi-build-utils@^1.0.1:
1795117951
version "1.0.2"
@@ -21520,6 +21520,11 @@ schema-utils@^4.0.0, schema-utils@^4.2.0:
2152021520
ajv-formats "^2.1.1"
2152121521
ajv-keywords "^5.1.0"
2152221522

21523+
scimmy@^1.3.5:
21524+
version "1.3.5"
21525+
resolved "https://registry.yarnpkg.com/scimmy/-/scimmy-1.3.5.tgz#bd73f8fa8af46dcfeb32d342b504f56ce85f1320"
21526+
integrity sha512-JTrUOoqH1gMH2zZhgk01hGgY7cH9v4qUli5b3OGVVOzjAwY8h4Z2mSNH8kXjW2pz8ypzpiRuMEtFGBaWQWJz7w==
21527+
2152321528
scuid@^1.1.0:
2152421529
version "1.1.0"
2152521530
resolved "https://registry.yarnpkg.com/scuid/-/scuid-1.1.0.tgz#d3f9f920956e737a60f72d0e4ad280bf324d5dab"

0 commit comments

Comments
 (0)