docs(auth): add Matrix SSO integration documentation

- Document OIDC endpoints and authentication flow
- Add Synapse configuration examples
- Include troubleshooting guide
- Remove debug logging from OIDC handlers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Till-JS 2026-01-29 12:57:48 +01:00
parent 1fcd5de8f3
commit b150a16497
3 changed files with 192 additions and 15 deletions

View file

@ -0,0 +1,191 @@
# Matrix SSO Integration
This document describes how Mana Core Auth provides Single Sign-On (SSO) for Matrix/Synapse using OpenID Connect (OIDC).
## Overview
Mana Core Auth acts as an **OIDC Provider** (Identity Provider), allowing Matrix Synapse to authenticate users via SSO. Users can sign in to Matrix using their Mana Core credentials.
```
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ Matrix Client │────▶│ Synapse │────▶│ Mana Core Auth │
│ (Element) │ │ (matrix.mana.how) │ │ (auth.mana.how) │
└─────────────────┘ └─────────────────┘ └─────────────────┘
│ │ │
│ 1. Click "SSO" │ │
│─────────────────────▶│ │
│ │ 2. Redirect to │
│ │ OIDC authorize │
│ │──────────────────────▶│
│ │ │
│ │ 3. Show login page │
│◀─────────────────────────────────────────────│
│ │ │
│ 4. User logs in │ │
│─────────────────────────────────────────────▶│
│ │ │
│ │ 5. Redirect with │
│ │ auth code │
│ │◀──────────────────────│
│ │ │
│ │ 6. Exchange code │
│ │ for tokens │
│ │──────────────────────▶│
│ │ │
│ 7. Login complete │◀──────────────────────│
│◀─────────────────────│ │
```
## OIDC Endpoints
Mana Core Auth exposes the following OIDC endpoints:
| Endpoint | URL | Description |
|----------|-----|-------------|
| Discovery | `https://auth.mana.how/.well-known/openid-configuration` | OIDC discovery document |
| Authorize | `https://auth.mana.how/api/auth/oauth2/authorize` | Authorization endpoint |
| Token | `https://auth.mana.how/api/auth/oauth2/token` | Token endpoint |
| UserInfo | `https://auth.mana.how/api/auth/oauth2/userinfo` | User info endpoint |
| JWKS | `https://auth.mana.how/api/auth/jwks` | JSON Web Key Set |
| Login | `https://auth.mana.how/login` | SSO login page |
## Synapse Configuration
The Matrix Synapse server is configured with OIDC in `docker/matrix/homeserver.yaml`:
```yaml
oidc_providers:
- idp_id: manacore
idp_name: "Mana Core"
idp_brand: "org.matrix.custom"
discover: true
issuer: "https://auth.mana.how"
client_id: "matrix-synapse"
client_secret: "<secret>"
scopes: ["openid", "profile", "email"]
user_mapping_provider:
config:
subject_claim: "sub"
localpart_template: "{{ user.email.split('@')[0] }}"
display_name_template: "{{ user.name }}"
email_template: "{{ user.email }}"
```
## OAuth Application Registration
The Matrix Synapse client is registered in the auth database:
```sql
INSERT INTO auth.oauth_applications (
id, name, client_id, client_secret, redirect_urls, type
) VALUES (
'matrix-synapse-client',
'Matrix Synapse',
'matrix-synapse',
'<hashed-secret>',
'["https://matrix.mana.how/_synapse/client/oidc/callback"]',
'web'
);
```
## Authentication Flow
1. **User initiates SSO**: User clicks "Sign in with Mana Core" on Element/Matrix client
2. **Synapse redirects**: Synapse redirects to Mana Core Auth's authorization endpoint
3. **Login page**: If not logged in, user sees the Mana Core login page
4. **User authenticates**: User enters email and password
5. **Authorization**: After successful login, user is redirected back to authorization endpoint
6. **Token exchange**: Synapse exchanges the authorization code for tokens
7. **User mapping**: Synapse creates/links the Matrix user based on OIDC claims
8. **Login complete**: User is logged into Matrix
## Claims Provided
The OIDC tokens include the following claims:
| Claim | Description |
|-------|-------------|
| `sub` | User ID |
| `email` | User's email address |
| `email_verified` | Whether email is verified |
| `name` | User's display name |
## Testing the Integration
### Test OIDC Discovery
```bash
curl https://auth.mana.how/.well-known/openid-configuration | jq
```
### Test Matrix SSO Redirect
```bash
curl -I "https://matrix.mana.how/_matrix/client/v3/login/sso/redirect/oidc-manacore?redirectUrl=https://element.mana.how"
```
### Check Matrix Login Methods
```bash
curl https://matrix.mana.how/_matrix/client/v3/login | jq '.flows[] | select(.type | contains("sso"))'
```
Expected output:
```json
{
"type": "m.login.sso",
"identity_providers": [
{
"id": "oidc-manacore",
"name": "Mana Core",
"brand": "org.matrix.custom"
}
]
}
```
## Troubleshooting
### JWKS Fetch Fails
If Synapse can't fetch JWKS:
1. Check JWKS endpoint: `curl https://auth.mana.how/api/auth/jwks`
2. Verify Synapse can reach auth service (network/DNS)
3. Check Synapse logs for OIDC errors
### Login Page Not Found
If the login page returns 404:
1. Check that `/login` is excluded from global prefix in `main.ts`
2. Verify `OidcLoginController` is registered in `AuthModule`
### Authorization Fails
If authorization returns errors:
1. Check client_id matches registered OAuth application
2. Verify redirect_uri matches exactly (including trailing slash)
3. Check that required scopes are requested
### Token Exchange Fails
If token exchange fails:
1. Check client_secret is correct
2. Verify token endpoint is accessible
3. Check Synapse logs for detailed error messages
## Security Considerations
1. **Client Secret**: The OAuth client secret is stored securely and should never be exposed
2. **HTTPS Only**: All OIDC endpoints use HTTPS
3. **Token Expiry**: ID tokens expire after 15 minutes
4. **PKCE**: Authorization code flow uses PKCE for added security
## Related Files
| File | Purpose |
|------|---------|
| `src/auth/better-auth.config.ts` | OIDC Provider plugin configuration |
| `src/auth/oidc.controller.ts` | OIDC endpoint routing |
| `src/auth/oidc-login.controller.ts` | SSO login page |
| `src/db/schema/auth.schema.ts` | OAuth tables (oauth_applications, etc.) |
| `docker/matrix/homeserver.yaml` | Synapse OIDC configuration |

View file

@ -129,10 +129,8 @@ export class OidcController {
* Handle OIDC request by forwarding to Better Auth
*/
private async handleOidcRequest(req: Request, res: Response) {
console.log('[OIDC Controller] Handling request:', req.method, req.originalUrl);
try {
const response = await this.betterAuthService.handleOidcRequest(req);
console.log('[OIDC Controller] Better Auth response status:', response.status);
// Set status code
res.status(response.status || HttpStatus.OK);

View file

@ -1211,7 +1211,6 @@ export class BetterAuthService {
headers: Record<string, string>;
body: unknown;
}> {
console.log('[handleOidcRequest] Received request:', req.method, req.originalUrl);
try {
// Map incoming paths to Better Auth's expected paths
let mappedPath = req.originalUrl;
@ -1229,15 +1228,12 @@ export class BetterAuthService {
mappedPath = mappedPath.replace('/api/oidc/', '/api/auth/oauth2/');
}
console.log('[handleOidcRequest] Mapped path:', mappedPath);
// Convert Express request to Fetch Request
const url = new URL(
mappedPath,
this.configService.get<string>('BASE_URL') ||
`http://localhost:${this.configService.get<number>('PORT') || 3001}`
);
console.log('[handleOidcRequest] Constructed URL:', url.toString());
const headers = new Headers();
for (const [key, value] of Object.entries(req.headers)) {
@ -1255,11 +1251,6 @@ export class BetterAuthService {
// Call Better Auth's handler
const response = await this.auth.handler(fetchRequest);
console.log('[handleOidcRequest] Better Auth status:', response.status);
console.log(
'[handleOidcRequest] Better Auth headers:',
Object.fromEntries(response.headers.entries())
);
// Convert Response to our format
const responseHeaders: Record<string, string> = {};
@ -1267,18 +1258,15 @@ export class BetterAuthService {
responseHeaders[key] = value;
});
// Get body - handle empty responses
// Get body - handle empty responses gracefully
let body: unknown;
const contentType = response.headers.get('content-type');
const textBody = await response.text();
console.log('[handleOidcRequest] Response body length:', textBody.length);
console.log('[handleOidcRequest] Response body preview:', textBody.substring(0, 500));
if (contentType?.includes('application/json') && textBody.length > 0) {
try {
body = JSON.parse(textBody);
} catch {
console.warn('[handleOidcRequest] Failed to parse JSON, using text body');
body = textBody;
}
} else {