Skip to content

Commit cf91096

Browse files
committed
feat: oidc groups mapping
1 parent 5d871be commit cf91096

5 files changed

Lines changed: 73 additions & 0 deletions

File tree

.env.sample

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,9 @@ OIDC_DISPLAY_NAME=OpenID Connect
165165
# Space separated auth scopes.
166166
OIDC_SCOPES=openid profile email
167167

168+
# Specify which claim to derive group memberships from
169+
# Supports any valid JSON path with the JWT payload
170+
OIDC_GROUPS_CLAIM=
168171

169172
# ––––––––––––––––––––––––––––––––––––––
170173
# –––––––––––––– EMAIL –––––––––––––––

app.json

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,10 @@
109109
"value": "openid profile email",
110110
"required": false
111111
},
112+
"OIDC_GROUPS_CLAIM": {
113+
"description": "Specify which claim to derive group memberships from. Supports any valid JSON path with the JWT payload",
114+
"required": false
115+
},
112116
"SLACK_CLIENT_ID": {
113117
"description": "See https://api.slack.com/apps to create a new Slack app. You must configure at least one of Slack or Google to control login.",
114118
"required": false

plugins/oidc/server/auth/oidcRouter.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,9 @@ export function createOIDCRouter(
187187
}
188188

189189
const ctx = createContext({ ip: context.ip });
190+
const groups = env.OIDC_GROUPS_CLAIM
191+
? (get(profile, env.OIDC_GROUPS_CLAIM, []) as string[])
192+
: undefined;
190193
const result = await accountProvisioner(ctx, {
191194
team: {
192195
teamId: team?.id,
@@ -199,6 +202,7 @@ export function createOIDCRouter(
199202
email,
200203
avatarUrl,
201204
},
205+
groups,
202206
authenticationProvider: {
203207
name: config.id,
204208
providerId,

server/commands/accountProvisioner.ts

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import path from "path";
22
import { readFile } from "fs-extra";
33
import invariant from "invariant";
4+
import { Op } from "sequelize";
45
import { CollectionPermission, UserRole } from "@shared/types";
56
import WelcomeEmail from "@server/emails/templates/WelcomeEmail";
67
import env from "@server/env";
@@ -13,6 +14,8 @@ import {
1314
AuthenticationProvider,
1415
Collection,
1516
Document,
17+
Group,
18+
GroupUser,
1619
Team,
1720
User,
1821
} from "@server/models";
@@ -51,6 +54,8 @@ type Props = {
5154
/** The public url of an image representing the team */
5255
avatarUrl?: string | null;
5356
};
57+
/** Groups the user should be member of */
58+
groups?: string[];
5459
/** Details of the authentication provider being used */
5560
authenticationProvider: {
5661
/** The name of the authentication provider, eg "google" */
@@ -85,6 +90,7 @@ async function accountProvisioner(
8590
{
8691
user: userParams,
8792
team: teamParams,
93+
groups: groupNames,
8894
authenticationProvider: authenticationProviderParams,
8995
authentication: authenticationParams,
9096
}: Props
@@ -191,6 +197,10 @@ async function accountProvisioner(
191197
}
192198
}
193199

200+
if (groupNames !== undefined) {
201+
await reconciliateUserGroups(groupNames, user, team);
202+
}
203+
194204
return {
195205
user,
196206
team,
@@ -257,3 +267,47 @@ async function provisionFirstCollection(team: Team, user: User) {
257267
export default traceFunction({
258268
spanName: "accountProvisioner",
259269
})(accountProvisioner);
270+
271+
async function reconciliateUserGroups(
272+
groupNames: string[],
273+
user: User,
274+
team: Team
275+
) {
276+
const groups = await Promise.all(
277+
groupNames.map(async (groupName) => {
278+
// Get existing group
279+
let group = await Group.findOne({
280+
where: {
281+
name: { [Op.iLike]: groupName },
282+
teamId: team.id,
283+
},
284+
});
285+
// Create group if it doesn't exist
286+
group ??= await Group.create({
287+
name: groupName,
288+
teamId: user.teamId,
289+
createdById: user.id,
290+
});
291+
// Add user to group
292+
await GroupUser.findOrCreate({
293+
where: {
294+
groupId: group.id,
295+
userId: user.id,
296+
},
297+
defaults: {
298+
createdById: user.id,
299+
},
300+
});
301+
return group;
302+
})
303+
);
304+
// Remove user from groups they are no longer a member of
305+
await GroupUser.destroy({
306+
where: {
307+
userId: user.id,
308+
groupId: {
309+
[Op.notIn]: groups.map((group) => group.id),
310+
},
311+
},
312+
});
313+
}

server/env.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -493,6 +493,14 @@ export class Environment {
493493
*/
494494
public DD_SERVICE = environment.DD_SERVICE ?? "outline";
495495

496+
/**
497+
* The OIDC profile field that lists group memberships.
498+
*/
499+
@IsOptional()
500+
public OIDC_GROUPS_CLAIM = this.toOptionalString(
501+
process.env.OIDC_GROUPS_CLAIM
502+
);
503+
496504
/**
497505
* A string representing the version of the software.
498506
*

0 commit comments

Comments
 (0)