From 78b7c6abbf6ddeeb53f6774eaa986de64fb23a36 Mon Sep 17 00:00:00 2001 From: Bryan Brooks Date: Wed, 11 Mar 2026 22:51:10 -0500 Subject: [PATCH] feat: Matrix login notifications for OIDC clients Sends fire-and-forget login notifications to a Matrix room when users authenticate to configured OIDC clients. Uses the Matrix Client-Server API via an atauth-bot account. Configurable per-client via env vars. Co-Authored-By: Claude Opus 4.6 --- gateway/src/index.ts | 12 +++++- gateway/src/routes/oidc/authorize.ts | 24 +++++++++++- gateway/src/routes/oidc/index.ts | 6 ++- gateway/src/utils/webhook.ts | 55 ++++++++++++++++++++++++++++ 4 files changed, 93 insertions(+), 4 deletions(-) create mode 100644 gateway/src/utils/webhook.ts diff --git a/gateway/src/index.ts b/gateway/src/index.ts index 0864479..0c8eb19 100644 --- a/gateway/src/index.ts +++ b/gateway/src/index.ts @@ -31,6 +31,7 @@ import { createProxyAuthRoutes } from './routes/proxy-auth.js'; import { createUserProfileRoutes } from './routes/user-profile.js'; import { authRateLimit, apiRateLimit, adminRateLimit } from './middleware/rateLimit.js'; import { HttpError } from './utils/errors.js'; +import type { WebhookConfig } from './utils/webhook.js'; // Configuration from environment const config = { @@ -91,6 +92,15 @@ const config = { codeExpiry: parseInt(process.env.EMAIL_CODE_EXPIRY || '900', 10), // 15 minutes }, + // Webhook / login notification configuration + webhook: { + enabled: process.env.WEBHOOK_ENABLED === 'true', + matrixHomeserverUrl: process.env.WEBHOOK_MATRIX_HOMESERVER_URL || '', + matrixAccessToken: process.env.WEBHOOK_MATRIX_ACCESS_TOKEN || '', + matrixRoomId: process.env.WEBHOOK_MATRIX_ROOM_ID || '', + loginNotifyClients: (process.env.WEBHOOK_LOGIN_NOTIFY_CLIENTS || '').split(',').filter(Boolean), + } satisfies WebhookConfig, + // Forward-auth proxy configuration forwardAuth: { enabled: process.env.FORWARD_AUTH_ENABLED === 'true', @@ -282,7 +292,7 @@ async function main(): Promise { // OIDC routes (if enabled) if (oidcService) { - const { wellKnownRouter, oauthRouter } = createOIDCRouter(db, oidcService, oauth, passkeyService); + const { wellKnownRouter, oauthRouter } = createOIDCRouter(db, oidcService, oauth, passkeyService, config.webhook); app.use('/.well-known', wellKnownRouter); app.use('/oauth', authRateLimit, oauthRouter); console.log('OIDC routes enabled'); diff --git a/gateway/src/routes/oidc/authorize.ts b/gateway/src/routes/oidc/authorize.ts index 4b029fa..e2694b6 100644 --- a/gateway/src/routes/oidc/authorize.ts +++ b/gateway/src/routes/oidc/authorize.ts @@ -13,12 +13,14 @@ import type { PasskeyService } from '../../services/passkey.js'; import { parseScopes, hasOpenIdScope, validateScopes } from '../../services/oidc/claims.js'; import { isValidCodeChallengeMethod } from '../../services/oidc/pkce.js'; import { checkAccess } from '../../utils/access-check.js'; +import { notifyLogin, type WebhookConfig } from '../../utils/webhook.js'; export function createAuthorizeRouter( db: DatabaseService, oidcService: OIDCService, oauthService: OAuthService, - passkeyService?: PasskeyService | null + passkeyService?: PasskeyService | null, + webhookConfig?: WebhookConfig ): Router { const router = Router(); @@ -606,6 +608,16 @@ export function createAuthorizeRouter( // Update the authorization code with the user's identity db.updateAuthorizationCodeUser(auth_code, result.did, result.handle); + // Send login notification (fire-and-forget) + if (webhookConfig?.enabled && webhookConfig.loginNotifyClients.includes(authData.client_id)) { + notifyLogin(webhookConfig, { + client_id: authData.client_id, + client_name: oidcClient?.name || authData.client_id, + did: result.did, + handle: result.handle, + }).catch(err => console.error('[Webhook] notification failed:', err.message)); + } + // Build the redirect URL back to the original client const clientRedirectUrl = new URL(authData.redirect_uri); clientRedirectUrl.searchParams.set('code', auth_code); @@ -736,6 +748,16 @@ export function createAuthorizeRouter( } } + // Send login notification (fire-and-forget) + if (webhookConfig?.enabled && webhookConfig.loginNotifyClients.includes(authData.client_id)) { + notifyLogin(webhookConfig, { + client_id: authData.client_id, + client_name: oidcClient?.name || authData.client_id, + did, + handle, + }).catch(err => console.error('[Webhook] notification failed:', err.message)); + } + // Build the redirect URL back to the original client const clientRedirectUrl = new URL(authData.redirect_uri); clientRedirectUrl.searchParams.set('code', oidcAuthCode); diff --git a/gateway/src/routes/oidc/index.ts b/gateway/src/routes/oidc/index.ts index ae9b071..6886370 100644 --- a/gateway/src/routes/oidc/index.ts +++ b/gateway/src/routes/oidc/index.ts @@ -9,6 +9,7 @@ import type { DatabaseService } from '../../services/database.js'; import type { OIDCService } from '../../services/oidc/index.js'; import type { OAuthService } from '../../services/oauth.js'; import type { PasskeyService } from '../../services/passkey.js'; +import type { WebhookConfig } from '../../utils/webhook.js'; import { createDiscoveryRouter } from './discovery.js'; import { createAuthorizeRouter } from './authorize.js'; @@ -21,7 +22,8 @@ export function createOIDCRouter( db: DatabaseService, oidcService: OIDCService, oauthService: OAuthService, - passkeyService?: PasskeyService | null + passkeyService?: PasskeyService | null, + webhookConfig?: WebhookConfig ): { wellKnownRouter: Router; oauthRouter: Router } { // Discovery endpoints go under /.well-known const wellKnownRouter = createDiscoveryRouter(oidcService); @@ -30,7 +32,7 @@ export function createOIDCRouter( const oauthRouter = Router(); // Mount sub-routers - const authorizeRouter = createAuthorizeRouter(db, oidcService, oauthService, passkeyService); + const authorizeRouter = createAuthorizeRouter(db, oidcService, oauthService, passkeyService, webhookConfig); const tokenRouter = createTokenRouter(db, oidcService); const userInfoRouter = createUserInfoRouter(db, oidcService); const revokeRouter = createRevokeRouter(db); diff --git a/gateway/src/utils/webhook.ts b/gateway/src/utils/webhook.ts new file mode 100644 index 0000000..2ff3a7e --- /dev/null +++ b/gateway/src/utils/webhook.ts @@ -0,0 +1,55 @@ +/** + * Login Webhook Notifications + * + * Sends login events to a Matrix room via the Client-Server API. + * Fire-and-forget — errors are logged but never block the auth flow. + */ + +export interface WebhookConfig { + enabled: boolean; + /** Matrix homeserver URL (e.g., http://synapse.matrix.svc.cluster.local:8008) */ + matrixHomeserverUrl: string; + /** Matrix bot access token */ + matrixAccessToken: string; + /** Matrix room ID to post to */ + matrixRoomId: string; + /** Client IDs to send login notifications for */ + loginNotifyClients: string[]; +} + +export interface LoginEvent { + client_id: string; + client_name: string; + did: string; + handle: string; +} + +let txnCounter = 0; + +export async function notifyLogin(config: WebhookConfig, event: LoginEvent): Promise { + const text = `Login: ${event.handle} authenticated to ${event.client_name}`; + const html = `Login: ${event.handle} (${event.did}) → ${event.client_name}`; + const txnId = `atauth-${Date.now()}-${++txnCounter}`; + const roomId = encodeURIComponent(config.matrixRoomId); + + const url = `${config.matrixHomeserverUrl}/_matrix/client/v3/rooms/${roomId}/send/m.room.message/${txnId}`; + + const resp = await fetch(url, { + method: 'PUT', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.matrixAccessToken}`, + }, + body: JSON.stringify({ + msgtype: 'm.text', + body: text, + format: 'org.matrix.custom.html', + formatted_body: html, + }), + signal: AbortSignal.timeout(5000), + }); + + if (!resp.ok) { + throw new Error(`Matrix API returned ${resp.status}: ${await resp.text()}`); + } +} -- 2.51.2