Skip to content

Instantly share code, notes, and snippets.

@Frando
Last active August 19, 2024 10:04
Show Gist options
  • Save Frando/aa561ca7e6c72ab64b5d17df911c0b1f to your computer and use it in GitHub Desktop.
Save Frando/aa561ca7e6c72ab64b5d17df911c0b1f to your computer and use it in GitHub Desktop.
Sync Outline groups from Keycloak via webhooks

Sync groups from Keycloak to Outline

Usage

Assuming you have a self-hosted Outline Wiki and Keycloak setup running, do the following:

  • Add the webhooks service to your docker-compose.yml
  • Save the server.ts to webhooks/server.ts (relative to your compose file)
  • Save webhooks.env into the same folder as your compose file
  • Via your webserver configuration add a subdomain, eg webhooks.wiki.yoursite.org to forward to your webhook service port 8000
  • Navigate to the webhooks configuration page of your Outline site and add a new webhook.
    • Enter https://webhooks.wiki.yoursite.org/webhooks as the webhooks URL.
    • Enable the users.signinevent
    • Copy the Webhook secret and insert into the webhooks.env for WEBHOOK_SECRET
  • Create a new API token for a user on your outline instance with sufficient permission to edit group membership
  • Fill out all fields in the webhooks.env

Once everything is filled out, start the webhooks service with docker compose up -d webhooks. Now, whenever a user logs in, their groups are synced with the groups assigned to them in Keycloak.

Some notes:

  • Users are matched between Outline and Keycloak via their email address
  • If a user has groups assigned in Keycloak that do not exist in Outline, they are created in Outline
  • Afterwards, the groups of a user are synchronized by joining and leaving groups in Outline as required
version: "3"
services:
webhooks:
image: denoland/deno:alpine
volumes:
- ./webhooks:/app
command: run --allow-net --allow-env /app/server.ts
expose:
- 8000
env_file:
webhook.env
import { serve } from "https://deno.land/std@0.175.0/http/server.ts";
import * as hex from "https://deno.land/std@0.175.0/encoding/hex.ts";
import Logger from "https://deno.land/x/logger@v1.1.1/logger.ts";
const logger = new Logger();
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const toHexString = (buf) => decoder.decode(hex.encode(new Uint8Array(buf)));
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 KC_TOKEN = null;
const KC_MASTER_URL = `${Deno.env.get("KEYCLOAK_ENDPOINT")}/realms/master`;
const KC_URL = `${Deno.env.get("KEYCLOAK_ENDPOINT")}/admin/realms/${
Deno.env.get("KEYCLOAK_REALM")
}`;
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(`handle signin for user ${model.name} (${model.id})`);
await handleSignin(payload.payload.model);
} catch (err) {
logger.error(
`failed to handle signin 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,
});
const outlineUserGroupsRes = await outlineRequest("/groups.list", {
offset: 0,
limit: 100,
userId,
});
const { data: { groups: outlineUserGroups, groupMemberships } } =
outlineUserGroupsRes;
const outlineUserGroupsNames = outlineUserGroups.map((group) => group.name);
const outlineAllGroupsRes = await outlineRequest("/groups.list", {
offset: 0,
limit: 100,
});
const { data: { groups: outlineAllGroups } } = outlineAllGroupsRes;
const outlineAllGroupsNames = outlineAllGroups.map((group) => group.name);
const keycloakParams = new URLSearchParams();
keycloakParams.append("email", outlineUser.email);
const keycloakUserRes = await keycloakRequest(`/users?${keycloakParams}`);
if (!keycloakUserRes || !Array.isArray(keycloakUserRes)) {
throw new Error("Invalid keycloak response for user query");
}
const keyloakUser = keycloakUserRes[0];
if (!keyloakUser) {
throw new Error(`User ${outlineUser.email} not found in Keycloak realm`);
}
const keycloakGroups = await keycloakRequest(
`/users/${keyloakUser.id}/groups`,
);
const keycloakGroupsNames = keycloakGroups.map((g) => g.name);
const groupsToCreate = keycloakGroupsNames.filter((g) =>
!outlineAllGroupsNames.includes(g)
);
const groupsToLeave = outlineUserGroupsNames.filter((g) =>
!keycloakGroupsNames.includes(g)
);
const groupsToJoin = keycloakGroupsNames.filter((g) =>
!outlineUserGroupsNames.includes(g)
);
if (!groupsToCreate.length && !groupsToLeave.length && !groupsToJoin.length) {
logger.info(` update user ${outlineUser.email}: no changes needed`);
return;
}
logger.info(
` update user ${outlineUser.name} - leave (${groupsToLeave}), join (${groupsToJoin}) create (${groupsToCreate})`,
);
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 keycloakRequest(path: string, body: any): Promise<Response> {
if (!KC_TOKEN) {
const url = `${KC_MASTER_URL}/protocol/openid-connect/token`;
const data = new URLSearchParams();
data.append("client_id", "admin-cli");
data.append("username", Deno.env.get("KEYCLOAK_USERNAME"));
data.append("password", Deno.env.get("KEYCLOAK_PASSWORD"));
data.append("grant_type", "password");
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());
logger.info("Login to Keycloak successful");
KC_TOKEN = data.access_token;
} else {
const text = await res.text();
logger.error(`Login to Keycloak failed: ${text}`);
throw new Error("Keycloak request failed: " + text);
}
}
const url = KC_URL + path;
if (body) body = JSON.stringify(body);
const method = body ? "POST" : "GET";
const headers = new Headers();
headers.append("Authorization", `Bearer ${KC_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;
}
WEBHOOK_SECRET=ol_whs_yourwebhooksecret
OUTLINE_API_TOKEN=ol_api_yourapitoken
OUTLINE_ENDPOINT=https://outline.yoursite.org/api
KEYCLOAK_ENDPOINT=https://keycloak.yoursite.org/auth
KEYCLOAK_REALM=yourkeycloakrealm
KEYCLOAK_USERNAME=yourkeycloakuser
KEYCLOAK_PASSWORD=yourkeycloakpass
@NotActuallyTerry
Copy link

An FYI for anyone using Keycloak v22 (and I think slightly older), leave auth off the end of KEYCLOAK_ENDPOINT
Otherwise, this works perfectly!

@NotActuallyTerry
Copy link

I've encountered an issue where this won't refresh the token grabbed from Keycloak. I forked this gist & added in a fix, which probably can be done in a tidier way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment