/** * OIDC Provider Controller * * Exposes Better Auth's OIDC Provider endpoints for external services * like Matrix/Synapse to use SSO authentication. * * Better Auth exposes OIDC endpoints at /api/auth/oauth2/* paths. * This controller provides routes at both: * - /api/auth/oauth2/* (native Better Auth paths from discovery document) * - /api/oidc/* (alternative paths for convenience) * * Endpoints: * - GET /.well-known/openid-configuration - OIDC Discovery * - GET /api/auth/oauth2/authorize - Authorization endpoint * - POST /api/auth/oauth2/token - Token endpoint * - GET /api/auth/oauth2/userinfo - UserInfo endpoint * - GET /api/auth/jwks - JWKS endpoint */ import { Controller, Get, Post, All, Req, Res, HttpStatus } from '@nestjs/common'; import { Request, Response } from 'express'; import { BetterAuthService } from './services/better-auth.service'; import { MatrixSessionService } from './services/matrix-session.service'; import { LoggerService } from '../common/logger'; @Controller() export class OidcController { private readonly logger: LoggerService; constructor( private readonly betterAuthService: BetterAuthService, private readonly matrixSessionService: MatrixSessionService, loggerService: LoggerService ) { this.logger = loggerService.setContext('OidcController'); } /** * OIDC Discovery Document * * Returns the OpenID Connect discovery document with all endpoints. */ @Get('.well-known/openid-configuration') async getOpenIdConfiguration(@Req() req: Request, @Res() res: Response) { return this.handleOidcRequest(req, res); } // ============================================ // Better Auth Native OAuth2 Endpoints // These match the paths in the discovery document // ============================================ /** * Authorization Endpoint (Better Auth native path) */ @Get('api/auth/oauth2/authorize') async authorizeOauth2(@Req() req: Request, @Res() res: Response) { this.logger.debug('OIDC authorize request', { clientId: req.query.client_id }); return this.handleOidcRequest(req, res); } /** * Token Endpoint (Better Auth native path) * * Accepts both JSON and form-urlencoded body (OAuth2 spec requires form-urlencoded). * The body-parser middleware in main.ts parses form data into req.body object. */ @Post('api/auth/oauth2/token') async tokenOauth2(@Req() req: Request, @Res() res: Response) { return this.handleOidcRequest(req, res); } /** * UserInfo Endpoint (Better Auth native path) * * When Matrix/Synapse calls this endpoint, we automatically create * the Matrix user link so bots can recognize the user without * requiring a separate !login command. */ @Get('api/auth/oauth2/userinfo') async userinfoOauth2(@Req() req: Request, @Res() res: Response) { return this.handleOidcRequestWithMatrixLink(req, res); } /** * JWKS Endpoint (Better Auth native path) */ @Get('api/auth/jwks') async jwksAuth(@Req() req: Request, @Res() res: Response) { return this.handleOidcRequest(req, res); } /** * Catch-all for other Better Auth OAuth2 endpoints */ @All('api/auth/oauth2/*') async catchAllOauth2(@Req() req: Request, @Res() res: Response) { return this.handleOidcRequest(req, res); } /** * Better Auth Sign-in Endpoint * * This endpoint is needed for OIDC login flow. * When users log in via the /login page, it posts to this endpoint * which sets the session cookie needed for the OAuth2 flow. */ @Post('api/auth/sign-in/email') async signInEmail(@Req() req: Request, @Res() res: Response) { return this.handleBetterAuthRequest(req, res); } /** * Handle Better Auth requests by forwarding to Better Auth's handler * This is a simpler handler that just passes through to Better Auth */ private async handleBetterAuthRequest(req: Request, res: Response) { try { const baseUrl = process.env.BASE_URL || 'http://localhost:3001'; const url = new URL(req.originalUrl, baseUrl); const headers = new Headers(); for (const [key, value] of Object.entries(req.headers)) { if (value && typeof value === 'string') { headers.set(key, value); } else if (Array.isArray(value)) { headers.set(key, value[0]); } } // Create Fetch Request const fetchRequest = new Request(url.toString(), { method: req.method, headers, body: req.method !== 'GET' && req.method !== 'HEAD' ? JSON.stringify(req.body) : undefined, }); // Get Better Auth handler and call it directly const handler = this.betterAuthService.getHandler(); const response = await handler(fetchRequest); // Copy status res.status(response.status); // Copy headers including Set-Cookie for session response.headers.forEach((value: string, key: string) => { // Handle multiple Set-Cookie headers if (key.toLowerCase() === 'set-cookie') { res.append(key, value); } else { res.setHeader(key, value); } }); // Handle redirects if (response.status === 302 || response.status === 301) { const location = response.headers.get('location'); if (location) { return res.redirect(response.status, location); } } // Return body const body = await response.text(); if (body) { return res.send(body); } return res.end(); } catch (error) { this.logger.error('OIDC request failed', error instanceof Error ? error.stack : undefined); return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Internal server error', }); } } // ============================================ // Alternative /api/oidc/* paths // For backwards compatibility and convenience // ============================================ /** * Authorization Endpoint (alternative path) */ @Get('api/oidc/authorize') async authorize(@Req() req: Request, @Res() res: Response) { return this.handleOidcRequest(req, res); } /** * Token Endpoint (alternative path) */ @Post('api/oidc/token') async token(@Req() req: Request, @Res() res: Response) { return this.handleOidcRequest(req, res); } /** * UserInfo Endpoint (alternative path) * * When Matrix/Synapse calls this endpoint, we automatically create * the Matrix user link so bots can recognize the user. */ @Get('api/oidc/userinfo') async userinfo(@Req() req: Request, @Res() res: Response) { return this.handleOidcRequestWithMatrixLink(req, res); } /** * JWKS Endpoint (alternative path) */ @Get('api/oidc/jwks') async jwks(@Req() req: Request, @Res() res: Response) { return this.handleOidcRequest(req, res); } /** * Catch-all for other OIDC endpoints (alternative path) */ @All('api/oidc/*') async catchAll(@Req() req: Request, @Res() res: Response) { return this.handleOidcRequest(req, res); } /** * Handle OIDC request by forwarding to Better Auth */ private async handleOidcRequest(req: Request, res: Response) { try { const response = await this.betterAuthService.handleOidcRequest(req); // Set status code res.status(response.status || HttpStatus.OK); // Copy headers from Better Auth response if (response.headers) { for (const [key, value] of Object.entries(response.headers)) { if (value) { res.setHeader(key, value as string); } } } // Handle redirects if (response.status === 302 || response.status === 301) { const location = response.headers?.location || response.headers?.Location; if (location) { return res.redirect(response.status, location as string); } } // Return body if (response.body) { return res.send(response.body); } return res.end(); } catch (error) { this.logger.error( 'OIDC alternative path request failed', error instanceof Error ? error.stack : undefined ); return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Internal server error', }); } } /** * Handle OIDC userinfo request with automatic Matrix user linking * * This method forwards the request to Better Auth, and if successful, * automatically creates a Matrix user link so bots can recognize * the user without requiring a separate !login command. */ private async handleOidcRequestWithMatrixLink(req: Request, res: Response) { try { const response = await this.betterAuthService.handleOidcRequest(req); // Set status code res.status(response.status || HttpStatus.OK); // Copy headers from Better Auth response if (response.headers) { for (const [key, value] of Object.entries(response.headers)) { if (value) { res.setHeader(key, value as string); } } } // If userinfo was successful, create the Matrix user link if (response.status === 200 && response.body) { try { const userInfo = response.body as { sub?: string; email?: string }; if (userInfo.sub && userInfo.email) { // Create Matrix user link asynchronously (don't block the response) this.matrixSessionService .autoLinkOnOidcLogin(userInfo.sub, userInfo.email) .catch((err) => { this.logger.warn('Failed to auto-link Matrix user on OIDC login', { error: err instanceof Error ? err.message : 'Unknown error', }); }); } } catch (linkError) { // Log but don't fail the request this.logger.warn('Error parsing userinfo for Matrix link', { error: linkError instanceof Error ? linkError.message : 'Unknown error', }); } } // Return body if (response.body) { return res.send(response.body); } return res.end(); } catch (error) { this.logger.error( 'OIDC userinfo request failed', error instanceof Error ? error.stack : undefined ); return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json({ error: 'server_error', error_description: 'Internal server error', }); } } }