feat(mana-core-auth): auto-link Matrix users on OIDC login

When users log into Matrix via OIDC (Sign in with Mana Core), their
Matrix user ID is now automatically linked to their Mana account.
This enables automatic bot authentication without requiring a
separate !login command.

- Add autoLinkOnOidcLogin() method to MatrixSessionService
- Hook into OIDC userinfo endpoint to create links automatically
- Calculate Matrix user ID from email using Synapse's template

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-02-02 16:50:28 +01:00
parent 75937d6ce9
commit 85df234ff2
2 changed files with 143 additions and 2 deletions

View file

@ -20,6 +20,7 @@
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()
@ -28,6 +29,7 @@ export class OidcController {
constructor(
private readonly betterAuthService: BetterAuthService,
private readonly matrixSessionService: MatrixSessionService,
loggerService: LoggerService
) {
this.logger = loggerService.setContext('OidcController');
@ -70,10 +72,14 @@ export class OidcController {
/**
* 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.handleOidcRequest(req, res);
return this.handleOidcRequestWithMatrixLink(req, res);
}
/**
@ -193,10 +199,13 @@ export class OidcController {
/**
* 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.handleOidcRequest(req, res);
return this.handleOidcRequestWithMatrixLink(req, res);
}
/**
@ -259,4 +268,67 @@ export class OidcController {
});
}
}
/**
* 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',
});
}
}
}

View file

@ -188,4 +188,73 @@ export class MatrixSessionService {
return links.length > 0;
}
/**
* Auto-link Matrix user during OIDC login
*
* Called when a user logs into Matrix via OIDC (Sign in with Mana Core).
* Creates the Matrix user link automatically so bots can recognize them.
*
* @param manaUserId - Mana Core Auth user ID
* @param email - User's email address
* @param matrixDomain - Matrix homeserver domain (default: matrix.mana.how)
*/
async autoLinkOnOidcLogin(
manaUserId: string,
email: string,
matrixDomain = 'matrix.mana.how'
): Promise<void> {
try {
// Calculate Matrix user ID from email using Synapse's template:
// localpart_template: "{{ user.email.split('@')[0] }}"
const localpart = email.split('@')[0].toLowerCase();
const matrixUserId = `@${localpart}:${matrixDomain}`;
// Check if link already exists
const existing = await this.db
.select()
.from(matrixUserLinks)
.where(eq(matrixUserLinks.matrixUserId, matrixUserId))
.limit(1);
if (existing.length > 0) {
// Update existing link (in case user ID changed)
if (existing[0].userId !== manaUserId) {
await this.db
.update(matrixUserLinks)
.set({
userId: manaUserId,
email,
lastUsedAt: new Date(),
})
.where(eq(matrixUserLinks.matrixUserId, matrixUserId));
this.logger.log(`Updated Matrix auto-link: ${matrixUserId} -> ${manaUserId}`);
} else {
// Just update lastUsedAt
await this.db
.update(matrixUserLinks)
.set({ lastUsedAt: new Date() })
.where(eq(matrixUserLinks.matrixUserId, matrixUserId));
}
return;
}
// Create new link
await this.db.insert(matrixUserLinks).values({
id: nanoid(),
matrixUserId,
userId: manaUserId,
email,
linkedAt: new Date(),
});
this.logger.log(`Created Matrix auto-link on OIDC login: ${matrixUserId} -> ${manaUserId}`);
} catch (error) {
// Log but don't throw - this is a best-effort operation
this.logger.error(
'Failed to auto-link Matrix user on OIDC login',
error instanceof Error ? error.stack : undefined
);
}
}
}