|
// 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; |
|
} |
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 queryParamsI forked your gist and put it to the test, it seems to do the trick.