Syncing Microsoft Entra groups to Outline

Keith Ng

Outline is a really cool wiki application that we use for tech documentation at my church. It looks elegant, it’s really easy to use and it supports single sign-on!

However, one of the core features that it’s missing (in my opinion) is syncing groups from the identity provider. Users are provisioned when they sign in for the first time, and if you use Outline groups for assigning permissions to ‘collections’ - a high-level topic, it can be a real hassle to add the user to the appropriate groups after they’ve successfully logged in.

We use Microsoft Entra, or formerly Azure Active Directory, as our identity provider and maintain group memberships there (and Active Directory syncs its groups there too). Having to manually maintain group memberships in multiple third-party services to match memberships in Entra is simply not feasible.

SCIM is supposedly on the way soon, but will seemingly be locked behind a paywall - Enterprise Edition.

After doing a bit of research, someone else has raised the same issue and there have been PRs for group assignments and syncing before (#2568, #3785) but none have been merged and successfully implemented. The OP ended up going with a different approach tailored for their Keycloak setup, utilising webhooks and the Outline API.

For my solution, I have essentially taken their approach but adapted it to work with the Microsoft Graph API and group memberships in Microsoft Entra. The original script was also limited to 100 groups in Outline due to the way it queried group data from the Outline API - this is no longer the case below.

I assigned my Entra application User.Read.All and Group.Read.All application permissions so that it can fetch group membership data from MS Graph.

Frando (the original author and GitHub issue poster) has a short writeup about deployment on their Gist. Make sure to give it a star!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
import { serve } from "https://deno.land/[email protected]/http/server.ts";
import * as hex from "https://deno.land/[email protected]/encoding/hex.ts";
import Logger from "https://deno.land/x/[email protected]/logger.ts";

const logger = new Logger();
const encoder = new TextEncoder();
const OUTLINE_SIGNING_KEY = await crypto.subtle.importKey(
"raw",
encoder.encode(Deno.env.get("WEBHOOK_SECRET")),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);

let MG_TOKEN = null;
let MG_TOKEN_EXPIRY_TIME = null;

async function handler(req: Request): Response {
const url = new URL(req.url);
if (!(url.pathname === "/webhook" && req.method === "POST")) {
return new Response("Invalid request", { status: 400 });
}
const body = await req.text();

try {
await validateSignature(req.headers.get("Outline-Signature"), body);
const payload = JSON.parse(body);
const model = payload.payload.model;
if (payload.event === "users.signin") {
try {
logger.info(`Processing sign in for user ${model.name} (${model.id})`);
await handleSignin(payload.payload.model);
} catch (err) {
logger.error(
`Failed to process sign in for user ${model.name} (${model.id}): `,
err,
);
throw err;
}
}
return new Response("OK", {
status: 200,
});
} catch (err) {
logger.warn(`Invalid request: `, err);
return new Response("Invalid request", {
status: 400,
});
}
}

logger.info("Listening on http://localhost:8000");
serve(handler);

async function handleSignin(model: any) {
const userId = model.id;
const { data: outlineUser } = await outlineRequest("/users.info", {
id: userId,
});

let outlineUserGroupsNames = [];
let outlineUserGroupsOffset = 0;
const outlineUserGroupsLimit = 100;
while (true) {
const outlineUserGroupsRes = await outlineRequest("/groups.list", {
offset: outlineUserGroupsOffset,
limit: outlineUserGroupsLimit,
userId,
});
const { data: { groups: outlineUserGroupsData } } = outlineUserGroupsRes;
if (outlineUserGroupsData.length) {
outlineUserGroupsNames = outlineUserGroupsNames.concat(outlineUserGroupsData.map((group) => group.name));
outlineUserGroupsOffset += outlineUserGroupsLimit;
} else {
break;
}
}

let outlineAllGroups = [];
let outlineAllGroupsNames = [];
let outlineAllGroupsOffset = 0;
const outlineAllGroupsLimit = 100;
while (true) {
const outlineAllGroupsRes = await outlineRequest("/groups.list", {
offset: outlineAllGroupsOffset,
limit: outlineAllGroupsLimit,
});
const { data: { groups: outlineAllGroupsData } } = outlineAllGroupsRes;
if (outlineAllGroupsData.length) {
outlineAllGroups = outlineAllGroups.concat(outlineAllGroupsData);
outlineAllGroupsNames = outlineAllGroupsNames.concat(outlineAllGroupsData.map((group) => group.name));
outlineAllGroupsOffset += outlineAllGroupsLimit;
} else {
break;
}
}

const aadGroups = await graphRequest(
`/users/${outlineUser.email}/transitiveMemberOf/microsoft.graph.group`,
);
const aadGroupsNames = aadGroups.value.map((g) => (`${g.displayName} (${g.id.split("-").pop()})`));

const groupsToCreate = aadGroupsNames.filter((g) =>
!outlineAllGroupsNames.includes(g)
);
const groupsToLeave = outlineUserGroupsNames.filter((g) =>
!aadGroupsNames.includes(g)
);
const groupsToJoin = aadGroupsNames.filter((g) =>
!outlineUserGroupsNames.includes(g)
);

if (!groupsToCreate.length && !groupsToLeave.length && !groupsToJoin.length) {
logger.info(`Updating user ${outlineUser.name} (${outlineUser.email})`);
logger.info('No changes');
return;
}

logger.info(`Updating user ${outlineUser.name} (${outlineUser.email})`);
if (groupsToCreate.length) {
logger.info(`Creating groups: ${groupsToCreate}`);
}
if (groupsToJoin.length) {
logger.info(`Joining groups: ${groupsToJoin}`);
}
if (groupsToLeave.length) {
logger.info(`Leaving groups: ${groupsToLeave}`);
}

for (const name of groupsToCreate) {
try {
const { data } = await outlineRequest("/groups.create", { name });
outlineAllGroups.push(data);
} catch (err) {
logger.warn(`Failed to create group ${name}: `, err);
}
}
for (const name of groupsToJoin) {
const group = outlineAllGroups.find((g) => g.name === name);
if (!group) throw new Error("Invalid group: " + name);
await outlineRequest("/groups.add_user", { id: group.id, userId });
}
for (const name of groupsToLeave) {
const group = outlineAllGroups.find((g) => g.name === name);
if (!group) throw new Error("Invalid group: " + name);
await outlineRequest("/groups.remove_user", { id: group.id, userId });
}
}

async function outlineRequest(path: string, body: any): Promise<Response> {
const url = Deno.env.get("OUTLINE_ENDPOINT") + path;
if (body) body = JSON.stringify(body);
const response = await fetch(
url,
{
body,
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
Authorization: `Bearer ${Deno.env.get("OUTLINE_API_TOKEN")}`,
},
},
);
const text = await response.text();
try {
const json = JSON.parse(text);
if (!response.ok || !json.ok) {
throw new Error(json.error + ": " + json.message);
}
return json;
} catch (err) {
throw new Error("Invalid response: " + text);
}
}

async function graphRequest(path: string, body: any): Promise<Response> {
if (!MG_TOKEN || Date.now() >= MG_TOKEN_EXPIRY_TIME) {
const url = `https://login.microsoftonline.com/${Deno.env.get("AAD_TENANT_ID")}/oauth2/v2.0/token`;
const data = new URLSearchParams();
data.append("client_id", Deno.env.get("AAD_CLIENT_ID"));
data.append("client_secret", Deno.env.get("AAD_CLIENT_SECRET"));
data.append("scope", "https://graph.microsoft.com/.default");
data.append("grant_type", "client_credentials");
const headers = new Headers();
headers.append("Content-Type", "application/x-www-form-urlencoded");
const res = await fetch(url, {
method: "POST",
body: data.toString(),
headers,
});
if (res.ok) {
const data = JSON.parse(await res.text());
MG_TOKEN = data.access_token;
MG_TOKEN_EXPIRY_TIME = Math.floor(Date.now() / 1000) + data.expires_in;
logger.info("Authentication to Microsoft Graph successful");
logger.info(`Microsoft Graph access token expiring at ${MG_TOKEN_EXPIRY_TIME} (${data.expires_in} seconds)`);
} else {
const text = await res.text();
logger.error(`Authentication to Microsoft Graph failed: ${text}`);
throw new Error("Microsoft Graph request failed: " + text);
}
}

const url = "https://graph.microsoft.com/v1.0" + path;
if (body) body = JSON.stringify(body);
const method = body ? "POST" : "GET";
const headers = new Headers();
headers.append("Authorization", `Bearer ${MG_TOKEN}`);
headers.append("Accept", "application/json");
if (method === "POST") {
headers.append("Content-Type", "application/json");
}
const response = await fetch(
url,
{
body,
method,
headers,
},
);
const json = await response.json();
return json;
}

async function validateSignature(outlineSignature: string, payload: string) {
const [_, signTimestamp, signatureHex] = outlineSignature.match(
/^t=([0-9]+),s=([0-9a-f]+)$/,
);
const payloadData = `${signTimestamp}.${payload}`;
const payloadBuf = encoder.encode(payloadData);
const signatureBuf = hex.decode(encoder.encode(signatureHex));
const result = await crypto.subtle.verify(
"HMAC",
OUTLINE_SIGNING_KEY,
signatureBuf,
payloadBuf,
);
if (result !== true) {
throw new Error("Invalid signature");
}
return true;
}
1
2
3
4
5
6
WEBHOOK_SECRET=ol_whs_yourwebhooksecret
OUTLINE_API_TOKEN=ol_api_yourapitoken
OUTLINE_ENDPOINT=https://outline.yoursite.org/api
AAD_TENANT_ID=yourtenantid
AAD_CLIENT_ID=yourclientid
AAD_CLIENT_SECRET=yourclientsecret
Comments
On this page
Syncing Microsoft Entra groups to Outline