Skip to content

Instantly share code, notes, and snippets.

@Altavion
Last active June 26, 2025 09:18
Show Gist options
  • Save Altavion/45565e0635d20fa3860f3bbe9969b4bc to your computer and use it in GitHub Desktop.
Save Altavion/45565e0635d20fa3860f3bbe9969b4bc to your computer and use it in GitHub Desktop.
Sync groups from Authentik to Outline via webhooks

Sync groups from Authentik to Outline via webhooks

Description

This script builds upon the work of Frando for synchronizing groups from Keycloak to Outline, adapting it to work with Authentik as the identity provider. It sets up a webhook server using Deno to process users.signin events from Outline, securely validate these requests via HMAC signatures, and automatically synchronize user group memberships and user display names between Authentik and Outline.

Key Features

  • Group Synchronization: Automatically updates user group memberships in Outline to match the groups assigned in Authentik.
  • Name Synchronization: Keeps user display names in Outline in sync with their names in Authentik.
  • Dynamic Group Creation: Creates missing groups in Outline if they exist in Authentik but are not yet present in Outline.
  • User Matching: Matches users between Authentik and Outline using their email addresses.
  • Secure Webhooks: Uses HMAC signatures to verify the authenticity and integrity of webhook requests.

This script adapts the original Keycloak-based implementation to work with Authentik's API and configuration structure.

For more details on why this functionality is not built into Outline, see the this: outline/outline#3785 outline/outline#3779


Deployment Steps

  1. Add to Docker Compose: Include the webhook service in your docker-compose.yml file. Ensure the service points to the webhooks-server.ts script and exposes port 8000.

  2. Add the Script: Save the webhooks-server.ts script in your project directory (e.g., webhooks/webhook-server.ts).

  3. Configure a Subdomain: Set up a subdomain (e.g., webhooks.wiki.yoursite.org) in your web server or configure a reverse proxy (e.g. Traefik) to forward traffic to the webhook service on port 8000.

  4. Set Up Webhooks in Outline:

  5. Generate an API Token in Outline:

    • Create an API token for a user with permissions to manage group memberships.
    • Set the token as the OUTLINE_API_TOKEN environment variable.
  6. Set Up an API Token in Authentik:

    • Navigate to Directory > Tokens and App Passwords in the Authentik dashboard.
    • Click Create, then select the API Token radio button.
    • Save the token and close the creation dialog.
    • Click Actions next to the newly created token and copy the token value.
    • Set this token as the AUTHENTIK_API_TOKEN environment variable.
  7. Configure Environment Variables:

    • Ensure the following variables are set either as environment variables or directly in your Docker setup:
      • WEBHOOK_SECRET (a randomly generated secret string that you create to secure webhook requests)
      • AUTHENTIK_API_TOKEN
      • AUTHENTIK_ENDPOINT (e.g., https://authentik.yoursite.org/api/v3)
      • OUTLINE_API_TOKEN
      • OUTLINE_ENDPOINT (e.g., https://wiki.yoursite.org/api)
  8. Start the Service: Launch the webhook service using docker compose up -d webhooks.

Whenever a user signs in to Outline, the script ensures their group memberships are automatically synchronized with those assigned in Authentik.


Notes

  • Users are matched by their email addresses between Authentik and Outline.
  • User display names are automatically synchronized from Authentik to Outline during sign-in.
  • If a group exists in Authentik but not in Outline, the script creates it in Outline.
  • Groups no longer assigned to a user in Authentik are removed in Outline to ensure accuracy.

This adaptation preserves the original concept while making it fully compatible with Authentik.

services:
outline:
image: docker.getoutline.com/outlinewiki/outline:latest
# ... (as usual)
# Sync groups from Authentik to Outline
webhooks:
image: denoland/deno:alpine
volumes:
- ./webhooks/webhook-server.ts:/app/server.ts:ro
command: run --allow-net --allow-env /app/server.ts
restart: unless-stopped
expose:
- 8000
environment:
WEBHOOK_SECRET: "<your-webhook-secret>"
AUTHENTIK_ENDPOINT: "https://<authentik-domain>"
AUTHENTIK_API_TOKEN: "<your-authentik-api-token>"
OUTLINE_ENDPOINT: "https://<outline-domain>/api"
OUTLINE_API_TOKEN: "<your-outline-api-token>"
DEBUG: "<true-or-false>"
// webhooks-server.ts
// Synchronize groups and user name between Authentik and Outline
//
// This script establishes a webhook server to listen for events from Outline,
// verifies the requests using HMAC signatures, and synchronizes group membership
// for users based on data retrieved from Authentik.
//
// The implementation builds upon and is inspired by the work available at:
// https://gist.github.com/Frando/aa561ca7e6c72ab64b5d17df911c0b1f
import { serve } from "https://deno.land/[email protected]/http/server.ts";
import * as hex from "https://deno.land/[email protected]/encoding/hex.ts";
import * as log from "https://deno.land/[email protected]/log/mod.ts";
/// Read the DEBUG flag from environment variables
console.log(`DEBUG flag: ${Deno.env.get("DEBUG")}`);
const DEBUG = (Deno.env.get("DEBUG") || "").toLowerCase() === "true";
// ANSI color codes for terminal with balanced brightness for black backgrounds
const COLORS = {
reset: "\x1b[0m",
green: "\x1b[92m", // INFO: Bright green
cyan: "\x1b[96m", // DEBUG: Bright cyan
yellow: "\x1b[93m", // WARNING: Bright yellow
red: "\x1b[91m", // ERROR: Bright red
white: "\x1b[97m", // DEFAULT: Bright white
};
// Custom formatter for log messages
function customFormatter(logRecord: log.LogRecord): string {
const timestamp = new Date().toISOString().replace("T", " ").slice(0, 19); // Format timestamp
const levelName = logRecord.levelName.padEnd(7); // Pad level name to ensure alignment
const msg = logRecord.msg;
// Apply color based on log level
let color;
switch (logRecord.levelName) {
case "INFO":
color = COLORS.green;
break;
case "DEBUG":
color = COLORS.cyan;
break;
case "WARNING":
color = COLORS.yellow;
break;
case "ERROR":
color = COLORS.red;
break;
default:
color = COLORS.white;
}
return `${color}${timestamp} ${levelName}:${COLORS.reset} ${msg}`;
}
// Configure the logger dynamically based on the DEBUG flag
await log.setup({
handlers: {
console: new log.handlers.ConsoleHandler(DEBUG ? "DEBUG" : "INFO", { // Respect DEBUG flag
formatter: customFormatter,
}),
},
loggers: {
default: {
level: DEBUG ? "DEBUG" : "INFO", // Dynamically set log level
handlers: ["console"],
},
},
});
const logger = log.getLogger();
// Initialize TextEncoder and TextDecoder
const encoder = new TextEncoder();
const decoder = new TextDecoder();
const toHexString = (buf: ArrayBuffer) => decoder.decode(hex.encode(new Uint8Array(buf)));
// Import the webhook secret as an HMAC key for signature verification
const WEBHOOK_SECRET = Deno.env.get("WEBHOOK_SECRET") || "";
const OUTLINE_SIGNING_KEY = await crypto.subtle.importKey(
"raw",
encoder.encode(WEBHOOK_SECRET),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign", "verify"],
);
// Authentik Configuration Variables
const AK_API_TOKEN = Deno.env.get("AUTHENTIK_API_TOKEN") || "";
const AK_API_URL = `${Deno.env.get("AUTHENTIK_ENDPOINT")}/api/v3/`;
// Outline Configuration Variables
const OUTLINE_ENDPOINT = Deno.env.get("OUTLINE_ENDPOINT") || "";
const OUTLINE_API_TOKEN = Deno.env.get("OUTLINE_API_TOKEN") || "";
/**
* Extracts the request source (IP address) from the headers or connection info.
* @param req - The incoming Request object.
* @returns The source IP address if known, otherwise "unknown".
*/
function getRequestSource(req: Request): string {
// Check for the X-Forwarded-For header (commonly used behind proxies)
const forwardedFor = req.headers.get("x-forwarded-for");
if (forwardedFor) {
return forwardedFor.split(",")[0].trim(); // Use the first IP in the list
}
// Fallback: Extract from the request's connection info if available
// This part requires you to access connection information via the server
return "unknown"; // Placeholder for when no IP info is available
}
async function handler(req: Request): Promise<Response> {
const url = new URL(req.url);
// Log every incoming request with method and path
logger.info(`Incoming Request: ${req.method} ${url.pathname} from ${getRequestSource(req)}`);
// Only handle POST requests to /webhook
if (url.pathname !== "/webhook" || req.method !== "POST") {
logger.warning(`Invalid Request Path or Method: ${req.method} ${url.pathname}`);
return new Response("Invalid request", { status: 400 });
}
let body: string;
try {
body = await req.text();
} catch (err) {
logger.error(`Error reading request body: ${(err as Error).message}`);
return new Response("Invalid request body", { status: 400 });
}
try {
await validateSignature(req.headers.get("Outline-Signature") || "", body);
const payload = JSON.parse(body);
const model = payload.payload.model;
logger.debug(`Parsed Payload: Event=${payload.event}, User=${model.name} (${model.id})`);
if (payload.event === "users.signin") {
try {
logger.info(`Handling signin for user ${model.name} (${model.id})`);
await handleSignin(payload.payload.model);
logger.info(`Successfully handled signin for user ${model.name} (${model.id})`);
} catch (err) {
logger.error(
`Failed to handle signin for user ${model.name} (${model.id}): ${(err as Error).message}`,
);
// Optionally, you can return a 500 status to indicate server error
return new Response("Internal Server Error", { status: 500 });
}
} else {
logger.warning(`Unhandled event type: ${payload.event}`);
return new Response("Unhandled event type", { status: 400 });
}
return new Response("OK", { status: 200 });
} catch (err) {
logger.warning(`Invalid request: ${(err as Error).message}`);
return new Response("Invalid request", { status: 400 });
}
}
logger.info("Listening on http://0.0.0.0:8000");
serve(handler, { hostname: "0.0.0.0", port: 8000 });
/**
* Handles user signin events by synchronizing user groups between Outline Wiki and Authentik.
* @param model - The user model from the Outline payload.
*/
async function handleSignin(model: any): Promise<void> {
const userId = model.id; // Outline UUID
logger.debug(`Fetching user information from Outline for userId: ${userId}`);
// Fetch user information from Outline
const outlineUserRes = await outlineRequest("/users.info", { id: userId });
const outlineUser = outlineUserRes.data;
logger.info(`Fetched Outline user: ${outlineUser.name} (${outlineUser.email})`);
const userEmail = outlineUser.email;
logger.debug(`Fetching groups for userId: ${userId}`);
const outlineUserGroupsRes = await outlineRequest("/groups.list", {
offset: "0",
limit: "100",
userId: userId,
});
const outlineUserGroups = outlineUserGroupsRes.data.groups;
const outlineUserGroupsNames = outlineUserGroups.map((group: any) => group.name);
logger.debug(`User's current groups in Outline: ${outlineUserGroupsNames.join(", ")}`);
logger.debug(`Fetching all groups from Outline`);
const outlineAllGroupsRes = await outlineRequest("/groups.list", {
offset: "0",
limit: "100",
});
const outlineAllGroups = outlineAllGroupsRes.data.groups;
const outlineAllGroupsNames = outlineAllGroups.map((group: any) => group.name);
logger.debug(`All existing groups in Outline: ${outlineAllGroupsNames.join(", ")}`);
logger.debug(`Fetching user information from Authentik for email: ${userEmail}`);
const authentikUserSearchRes = await authentikRequest("/core/users/", {
queryParams: { email: userEmail },
});
if (
!authentikUserSearchRes ||
!Array.isArray(authentikUserSearchRes.results) ||
authentikUserSearchRes.results.length === 0
) {
throw new Error(`User ${userEmail} not found in Authentik`);
}
const akUser = authentikUserSearchRes.results[0];
const akUserId = akUser.pk; // Ensure pk is used
logger.info(`Fetched Authentik user: ${akUser.name} (${akUser.email}), ID: ${akUserId}`);
// Sync user name if it differs
if (akUser.name && akUser.name !== outlineUser.name) {
try {
logger.debug(`Updating user name from "${outlineUser.name}" to "${akUser.name}"`);
await outlineRequest("/users.update", {
id: userId,
name: akUser.name
});
logger.info(`Successfully updated user name to "${akUser.name}"`);
} catch (err) {
logger.warning(`Failed to update user name: ${(err as Error).message}`);
}
} else {
logger.debug(`User name in Outline "${outlineUser.name}" matches user name in Authentik "${akUser.name}" - no update required`);
}
// **Removed the separate API call to /core/users/:id/groups/**
// Instead, use the groups_obj field from the akUser
if (
!akUser.groups_obj ||
!Array.isArray(akUser.groups_obj) ||
akUser.groups_obj.length === 0
) {
logger.debug(`No groups found for user ${userEmail} in Authentik`);
}
const akGroups = akUser.groups_obj.map((g: any) => g.name);
logger.debug(`User's groups in Authentik: ${akGroups.join(", ")}`);
// Determine groups to create, join, or leave
const groupsToCreate = akGroups.filter((g: string) => !outlineAllGroupsNames.includes(g));
const groupsToLeave = outlineUserGroupsNames.filter((g: string) => !akGroups.includes(g));
const groupsToJoin = akGroups.filter((g: string) => !outlineUserGroupsNames.includes(g));
logger.debug(
`Groups to create: ${groupsToCreate.join(", ") || "None"}, ` +
`Groups to join: ${groupsToJoin.join(", ") || "None"}, ` +
`Groups to leave: ${groupsToLeave.join(", ") || "None"}`
);
if (!groupsToCreate.length && !groupsToLeave.length && !groupsToJoin.length) {
logger.debug(`No group changes required for user ${userEmail}`);
return;
}
// Create missing groups in Outline
for (const name of groupsToCreate) {
try {
logger.debug(`Creating missing group in Outline: ${name}`);
const { data } = await outlineRequest("/groups.create", { name });
outlineAllGroups.push(data);
outlineAllGroupsNames.push(name); // Update the list of all group names
logger.debug(`Successfully created group: ${name}`);
} catch (err: any) {
logger.warning(`Failed to create group ${name}: ${(err as Error).message}`);
}
}
// Add user to new groups in Outline
for (const name of groupsToJoin) {
const group = outlineAllGroups.find((g: any) => g.name === name);
if (!group) {
logger.error(`Group ${name} not found in Outline`);
throw new Error("Invalid group: " + name);
}
try {
logger.debug(`Adding user ${userEmail} to group: ${name}`);
await outlineRequest("/groups.add_user", { id: group.id, userId: userId });
logger.debug(`Successfully added user to group: ${name}`);
} catch (err: any) {
logger.warning(`Failed to add user to group ${name}: ${(err as Error).message}`);
}
}
// Remove user from groups in Outline
for (const name of groupsToLeave) {
const group = outlineAllGroups.find((g: any) => g.name === name);
if (!group) {
logger.error(`Group ${name} not found in Outline`);
throw new Error("Invalid group: " + name);
}
try {
logger.debug(`Removing user ${userEmail} from group: ${name}`);
await outlineRequest("/groups.remove_user", { id: group.id, userId: userId });
logger.debug(`Successfully removed user from group: ${name}`);
} catch (err: any) {
logger.warning(`Failed to remove user from group ${name}: ${(err as Error).message}`);
}
}
}
/**
* Makes authenticated requests to the Outline API.
* @param path - The API endpoint path.
* @param body - The request payload.
* @returns The JSON response from Outline.
*/
async function outlineRequest(path: string, body: any): Promise<any> {
const url = OUTLINE_ENDPOINT + path;
logger.debug(`Making request to Outline API: ${url} with body: ${JSON.stringify(body)}`);
let options: RequestInit = {
method: body ? "POST" : "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${OUTLINE_API_TOKEN}`,
},
};
if (body) {
options.body = JSON.stringify(body);
options.headers = {
...options.headers,
"Content-Type": "application/json",
};
}
try {
const response = await fetch(url, options);
const text = await response.text();
logger.debug(`Received response from Outline API: ${text}`);
const json = JSON.parse(text);
if (!response.ok || !json.ok) {
throw new Error(json.error + ": " + json.message);
}
return json;
} catch (err) {
logger.error(`Error during Outline API request to ${url}: ${(err as Error).message}`);
throw new Error("Invalid response: " + (err as Error).message);
}
}
/**
* Makes authenticated requests to the Authentik API.
* @param path - The API endpoint path.
* @returns The JSON response from Authentik.
*/
interface AuthentikRequestOptions {
queryParams?: Record<string, string>;
}
async function authentikRequest(path: string, options: AuthentikRequestOptions = {}): Promise<any> {
if (!AK_API_TOKEN) {
logger.error("Authentik API token is not set");
throw new Error("Authentik API token is not set");
}
// Ensure single slash between base URL and path
const baseUrl = AK_API_URL.endsWith('/') ? AK_API_URL.slice(0, -1) : AK_API_URL;
const sanitizedPath = path.startsWith('/') ? path : `/${path}`;
let url = `${baseUrl}${sanitizedPath}`;
// Append query parameters if any
if (options.queryParams) {
const queryString = new URLSearchParams(options.queryParams).toString();
url += `?${queryString}`;
}
logger.debug(`Making request to Authentik API: ${url}`);
try {
const response = await fetch(url, {
method: "GET",
headers: {
Accept: "application/json",
Authorization: `Bearer ${AK_API_TOKEN}`,
},
});
const text = await response.text();
logger.debug(`Received response from Authentik API: ${text}`);
if (!response.ok) {
throw new Error(`Authentik API error: ${response.status} ${response.statusText}`);
}
const json = JSON.parse(text);
return json;
} catch (err) {
logger.error(`Error during Authentik API request to ${url}: ${(err as Error).message}`);
throw new Error("Authentik request failed: " + (err as Error).message);
}
}
/**
* Validates the HMAC signature of incoming webhook requests.
* @param outlineSignature - The signature from the request headers.
* @param payload - The raw request payload.
* @returns A boolean indicating whether the signature is valid.
*/
async function validateSignature(outlineSignature: string, payload: string): Promise<boolean> {
const signaturePattern = /^t=(\d+),s=([0-9a-f]+)$/;
const match = outlineSignature.match(signaturePattern);
if (!match) {
throw new Error("Signature does not match the expected format");
}
const [, signTimestamp, signatureHex] = match;
const payloadData = `${signTimestamp}.${payload}`;
const payloadBuf = encoder.encode(payloadData);
const signatureBuf = hex.decode(encoder.encode(signatureHex));
const isValid = await crypto.subtle.verify(
"HMAC",
OUTLINE_SIGNING_KEY,
signatureBuf,
payloadBuf,
);
if (!isValid) {
throw new Error("Invalid signature");
}
logger.debug("Signature validation successful");
return true;
}
@autoiue
Copy link

autoiue commented Feb 14, 2025

Hey !

Very useful script, thanks very much !

I think I found a bug however: line 186 and 199 you may want to remove queryParams and put the fields at the top of the JSON object as outline is not expecting them inside a queryParams

I forked your gist and put it to the test, it seems to do the trick.

@Altavion
Copy link
Author

I think I found a bug however: line 186 and 199 you may want to remove queryParams and put the fields at the top of the JSON object as outline is not expecting them inside a queryParams

Good catch! All fixed now, thanks!

@davidvorkauf
Copy link

Hey, super useful, thanks for the work! :)

I noticed in line 91 that "/api/v3/" gets added to the url. In the Setup documentation you mention so set the AUTHENTIK_ENDPOINT to something like "https://authentik.yoursite.org/api/v3". This results in the URL "https://authentik.yoursite.org/api/v3/api/v3/" which fails.

Maybe its possible to add this to the documentation to remove "/api/v3" from the domain?

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