Skip to content

Instantly share code, notes, and snippets.

@NotActuallyTerry
Forked from Frando/README.md
Last active July 3, 2024 11:56
Show Gist options
  • Save NotActuallyTerry/a3fafc2877c2e5d61cdce11a08f50d8c to your computer and use it in GitHub Desktop.
Save NotActuallyTerry/a3fafc2877c2e5d61cdce11a08f50d8c 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;
let KC_TOKEN_TIME = 0;
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 || Date.now() > KC_TOKEN_TIME + Deno.env.get("KEYCLOAK_TOKEN_TTL")) {
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
KEYCLOAK_REALM=yourkeycloakrealm
KEYCLOAK_USERNAME=yourkeycloakuser
KEYCLOAK_PASSWORD=yourkeycloakpass
KEYCLOAK_TOKEN_TTL=60
@LM1LC3N7
Copy link

LM1LC3N7 commented Jun 5, 2024

Don't mind, I found the error. Unable to find matching target resource method means "incorrect URL" to login to Keycloak. Your example with /auth does not work for me. I had to remove it.

@NotActuallyTerry
Copy link
Author

Yep, that one's my fault! I'll update the example now

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