mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 01:21:09 +02:00
fix(auth): add missing trusted origins for cross-app SSO
Several apps (mukke, photos, planta, questions, todo, traces, context, docs, manadeck, zitare) were missing from Better Auth's trustedOrigins, causing SSO session cookie exchange to fail for those apps. Also synced CORS_ORIGINS in docker-compose.macmini.yml. Added 47 SSO contract tests to prevent regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1486277733
commit
bb69f78e1e
4 changed files with 319 additions and 12 deletions
|
|
@ -105,7 +105,7 @@ services:
|
|||
SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com}
|
||||
SMTP_PASSWORD: ${SMTP_PASSWORD}
|
||||
SMTP_FROM: Mana <noreply@mana.how>
|
||||
CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://todo.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://storage.mana.how,https://presi.mana.how,https://nutriphi.mana.how,https://skilltree.mana.how,https://photos.mana.how,https://matrix.mana.how,https://element.mana.how,https://link.mana.how,https://playground.mana.how,https://mukke.mana.how,https://zitare.mana.how,https://questions.mana.how,https://planta.mana.how,https://manadeck.mana.how,https://picture.mana.how
|
||||
CORS_ORIGINS: https://mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://docs.mana.how,https://element.mana.how,https://link.mana.how,https://manadeck.mana.how,https://matrix.mana.how,https://mukke.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://playground.mana.how,https://presi.mana.how,https://questions.mana.how,https://skilltree.mana.how,https://storage.mana.how,https://todo.mana.how,https://traces.mana.how,https://zitare.mana.how
|
||||
DUCKDB_PATH: /data/analytics/metrics.duckdb
|
||||
SYNAPSE_OIDC_CLIENT_SECRET: ${SYNAPSE_OIDC_CLIENT_SECRET:-}
|
||||
# Backend URLs for user data aggregation (GDPR self-service)
|
||||
|
|
|
|||
|
|
@ -225,26 +225,39 @@ export function createBetterAuth(databaseUrl: string) {
|
|||
},
|
||||
|
||||
// Trusted origins for cross-origin requests (must match CORS_ORIGINS in production)
|
||||
// IMPORTANT: Every app that uses SSO must be listed here, otherwise
|
||||
// Better Auth will reject cross-origin requests with credentials.
|
||||
// When adding a new app, add its production domain here AND to
|
||||
// CORS_ORIGINS in docker-compose.macmini.yml.
|
||||
trustedOrigins: [
|
||||
// Production domains
|
||||
// Production domains - auth service
|
||||
'https://auth.mana.how',
|
||||
'https://mana.how',
|
||||
'https://chat.mana.how',
|
||||
'https://todo.mana.how',
|
||||
// Production domains - all apps (keep alphabetical)
|
||||
'https://calendar.mana.how',
|
||||
'https://chat.mana.how',
|
||||
'https://clock.mana.how',
|
||||
'https://contacts.mana.how',
|
||||
'https://storage.mana.how',
|
||||
'https://picture.mana.how',
|
||||
'https://zitare.mana.how',
|
||||
'https://presi.mana.how',
|
||||
'https://nutriphi.mana.how',
|
||||
'https://skilltree.mana.how',
|
||||
'https://matrix.mana.how',
|
||||
'https://mchat.mana.how',
|
||||
'https://context.mana.how',
|
||||
'https://docs.mana.how',
|
||||
'https://element.mana.how',
|
||||
'https://link.mana.how',
|
||||
'https://manadeck.mana.how',
|
||||
'https://matrix.mana.how',
|
||||
'https://mchat.mana.how',
|
||||
'https://mukke.mana.how',
|
||||
'https://nutriphi.mana.how',
|
||||
'https://photos.mana.how',
|
||||
'https://picture.mana.how',
|
||||
'https://planta.mana.how',
|
||||
'https://playground.mana.how',
|
||||
'https://presi.mana.how',
|
||||
'https://questions.mana.how',
|
||||
'https://skilltree.mana.how',
|
||||
'https://storage.mana.how',
|
||||
'https://todo.mana.how',
|
||||
'https://traces.mana.how',
|
||||
'https://zitare.mana.how',
|
||||
// Local development
|
||||
'http://localhost:3001',
|
||||
'http://localhost:5173',
|
||||
|
|
|
|||
146
services/mana-core-auth/src/auth/sso-config.spec.ts
Normal file
146
services/mana-core-auth/src/auth/sso-config.spec.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
/**
|
||||
* SSO Configuration Tests
|
||||
*
|
||||
* Validates that the Better Auth configuration correctly supports
|
||||
* cross-subdomain SSO for all apps in the monorepo.
|
||||
*
|
||||
* These tests ensure that:
|
||||
* 1. All active apps are listed in trustedOrigins
|
||||
* 2. Cookie domain configuration is correct for SSO
|
||||
* 3. CORS_ORIGINS in docker-compose matches trustedOrigins
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('SSO Configuration', () => {
|
||||
const configPath = path.resolve(__dirname, 'better-auth.config.ts');
|
||||
let configContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
configContent = fs.readFileSync(configPath, 'utf8');
|
||||
});
|
||||
|
||||
describe('trustedOrigins', () => {
|
||||
/**
|
||||
* All apps that have a web frontend with SSO support.
|
||||
* When adding a new app, add it here AND to trustedOrigins in better-auth.config.ts.
|
||||
*/
|
||||
const APPS_WITH_SSO = [
|
||||
'calendar',
|
||||
'chat',
|
||||
'clock',
|
||||
'contacts',
|
||||
'context',
|
||||
'manadeck',
|
||||
'matrix',
|
||||
'mukke',
|
||||
'nutriphi',
|
||||
'photos',
|
||||
'picture',
|
||||
'planta',
|
||||
'presi',
|
||||
'questions',
|
||||
'skilltree',
|
||||
'storage',
|
||||
'todo',
|
||||
'traces',
|
||||
'zitare',
|
||||
];
|
||||
|
||||
it.each(APPS_WITH_SSO)('should include %s.mana.how in trustedOrigins', (appName) => {
|
||||
expect(configContent).toContain(`https://${appName}.mana.how`);
|
||||
});
|
||||
|
||||
it('should include the auth service itself', () => {
|
||||
expect(configContent).toContain('https://auth.mana.how');
|
||||
});
|
||||
|
||||
it('should include the main domain', () => {
|
||||
expect(configContent).toContain('https://mana.how');
|
||||
});
|
||||
|
||||
it('should include localhost for development', () => {
|
||||
expect(configContent).toContain('http://localhost:5173');
|
||||
expect(configContent).toContain('http://localhost:3001');
|
||||
});
|
||||
});
|
||||
|
||||
describe('cookie configuration', () => {
|
||||
it('should use "mana" cookie prefix', () => {
|
||||
expect(configContent).toContain("cookiePrefix: 'mana'");
|
||||
});
|
||||
|
||||
it('should enable crossSubDomainCookies based on COOKIE_DOMAIN env', () => {
|
||||
expect(configContent).toContain('enabled: !!process.env.COOKIE_DOMAIN');
|
||||
});
|
||||
|
||||
it('should use COOKIE_DOMAIN for the cookie domain', () => {
|
||||
expect(configContent).toContain('domain: process.env.COOKIE_DOMAIN');
|
||||
});
|
||||
|
||||
it('should use sameSite lax for cross-subdomain navigation', () => {
|
||||
expect(configContent).toContain("sameSite: 'lax'");
|
||||
});
|
||||
|
||||
it('should set httpOnly to protect cookies from JS access', () => {
|
||||
expect(configContent).toContain('httpOnly: true');
|
||||
});
|
||||
});
|
||||
|
||||
describe('docker-compose alignment', () => {
|
||||
const dockerComposePath = path.resolve(__dirname, '../../../../docker-compose.macmini.yml');
|
||||
|
||||
it('should have COOKIE_DOMAIN set to .mana.how in production docker-compose', () => {
|
||||
if (!fs.existsSync(dockerComposePath)) {
|
||||
// Skip if docker-compose not available (e.g., in CI)
|
||||
return;
|
||||
}
|
||||
const dockerContent = fs.readFileSync(dockerComposePath, 'utf8');
|
||||
expect(dockerContent).toContain('COOKIE_DOMAIN: .mana.how');
|
||||
});
|
||||
|
||||
it('should have CORS_ORIGINS in docker-compose for mana-auth', () => {
|
||||
if (!fs.existsSync(dockerComposePath)) {
|
||||
return;
|
||||
}
|
||||
const dockerContent = fs.readFileSync(dockerComposePath, 'utf8');
|
||||
// All SSO apps should be in the CORS_ORIGINS
|
||||
const appsToCheck = [
|
||||
'calendar',
|
||||
'chat',
|
||||
'clock',
|
||||
'contacts',
|
||||
'mukke',
|
||||
'nutriphi',
|
||||
'photos',
|
||||
'picture',
|
||||
'planta',
|
||||
'presi',
|
||||
'questions',
|
||||
'skilltree',
|
||||
'storage',
|
||||
'todo',
|
||||
'zitare',
|
||||
];
|
||||
|
||||
for (const app of appsToCheck) {
|
||||
expect(dockerContent).toContain(`https://${app}.mana.how`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionToToken cookie detection', () => {
|
||||
it('should look for mana-prefixed cookies when COOKIE_DOMAIN is set', () => {
|
||||
const servicePath = path.resolve(__dirname, 'services/better-auth.service.ts');
|
||||
const serviceContent = fs.readFileSync(servicePath, 'utf8');
|
||||
|
||||
// Verify cookie name detection logic
|
||||
expect(serviceContent).toContain(
|
||||
"const cookiePrefix = process.env.COOKIE_DOMAIN ? 'mana' : 'better-auth'"
|
||||
);
|
||||
expect(serviceContent).toContain('__Secure-${cookiePrefix}.session_token');
|
||||
expect(serviceContent).toContain('${cookiePrefix}.session_token');
|
||||
});
|
||||
});
|
||||
148
services/mana-core-auth/src/auth/sso-session-to-token.spec.ts
Normal file
148
services/mana-core-auth/src/auth/sso-session-to-token.spec.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
/**
|
||||
* SSO sessionToToken Contract Tests
|
||||
*
|
||||
* Validates the session-to-token exchange logic that powers cross-app SSO.
|
||||
* Tests cookie name detection, which is the critical piece that must match
|
||||
* between the client (trySSO) and server (sessionToToken).
|
||||
*
|
||||
* Flow:
|
||||
* 1. User logs in on app A → session cookie set with Domain=.mana.how
|
||||
* 2. User visits app B → browser sends the session cookie
|
||||
* 3. App B calls GET /api/auth/get-session (credentials: include)
|
||||
* 4. App B calls POST /api/v1/auth/session-to-token → gets JWT tokens
|
||||
* 5. JWT tokens stored in localStorage → user is authenticated
|
||||
*/
|
||||
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
describe('SSO sessionToToken contract', () => {
|
||||
const servicePath = path.resolve(__dirname, 'services/better-auth.service.ts');
|
||||
const authServiceClientPath = path.resolve(
|
||||
__dirname,
|
||||
'../../../../packages/shared-auth/src/core/authService.ts'
|
||||
);
|
||||
let serviceContent: string;
|
||||
let clientContent: string;
|
||||
|
||||
beforeAll(() => {
|
||||
serviceContent = fs.readFileSync(servicePath, 'utf8');
|
||||
clientContent = fs.readFileSync(authServiceClientPath, 'utf8');
|
||||
});
|
||||
|
||||
describe('cookie name detection (server side)', () => {
|
||||
it('should use "mana" prefix when COOKIE_DOMAIN is set', () => {
|
||||
// The server determines the cookie name based on COOKIE_DOMAIN
|
||||
expect(serviceContent).toContain(
|
||||
"const cookiePrefix = process.env.COOKIE_DOMAIN ? 'mana' : 'better-auth'"
|
||||
);
|
||||
});
|
||||
|
||||
it('should check both __Secure- and non-secure cookie names', () => {
|
||||
expect(serviceContent).toContain('__Secure-${cookiePrefix}.session_token');
|
||||
expect(serviceContent).toContain('${cookiePrefix}.session_token');
|
||||
});
|
||||
|
||||
it('should try the secure cookie first, then fallback', () => {
|
||||
// The order matters: __Secure- prefix is used in production (HTTPS)
|
||||
expect(serviceContent).toContain(
|
||||
'req.cookies?.[sessionCookieName] || req.cookies?.[fallbackCookieName]'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('client-server contract alignment', () => {
|
||||
it('client should call get-session with credentials: include', () => {
|
||||
expect(clientContent).toContain("credentials: 'include'");
|
||||
expect(clientContent).toContain("method: 'GET'");
|
||||
// The get-session endpoint
|
||||
expect(clientContent).toContain('endpoints.getSession');
|
||||
});
|
||||
|
||||
it('client should call session-to-token with credentials: include', () => {
|
||||
expect(clientContent).toContain('/api/v1/auth/session-to-token');
|
||||
// Check that the session-to-token call uses credentials: include
|
||||
const tokenFetchMatch = clientContent.match(/session-to-token.*?credentials:\s*'include'/s);
|
||||
expect(tokenFetchMatch).not.toBeNull();
|
||||
});
|
||||
|
||||
it('server should expose session-to-token endpoint', () => {
|
||||
const controllerPath = path.resolve(__dirname, 'auth.controller.ts');
|
||||
const controllerContent = fs.readFileSync(controllerPath, 'utf8');
|
||||
expect(controllerContent).toContain('sessionToToken');
|
||||
});
|
||||
|
||||
it('client should store accessToken and refreshToken from response', () => {
|
||||
expect(clientContent).toContain('tokenData.accessToken');
|
||||
expect(clientContent).toContain('tokenData.refreshToken');
|
||||
});
|
||||
|
||||
it('server should return accessToken and refreshToken', () => {
|
||||
// The service method should return an object with these fields
|
||||
expect(serviceContent).toContain('accessToken');
|
||||
expect(serviceContent).toContain('refreshToken');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSO error handling', () => {
|
||||
it('client should handle get-session failure gracefully', () => {
|
||||
expect(clientContent).toContain('No SSO session found');
|
||||
});
|
||||
|
||||
it('client should handle token exchange failure gracefully', () => {
|
||||
expect(clientContent).toContain('Token exchange not available');
|
||||
});
|
||||
|
||||
it('client should handle missing tokens in response', () => {
|
||||
expect(clientContent).toContain('Invalid token response');
|
||||
});
|
||||
|
||||
it('client should catch and return network errors', () => {
|
||||
// trySSO should not throw - it returns { success: false, error: ... }
|
||||
expect(clientContent).toContain('SSO check failed');
|
||||
});
|
||||
|
||||
it('server should throw UnauthorizedException when no cookie found', () => {
|
||||
expect(serviceContent).toContain('No session cookie found');
|
||||
expect(serviceContent).toContain('UnauthorizedException');
|
||||
});
|
||||
|
||||
it('server should throw UnauthorizedException for invalid sessions', () => {
|
||||
expect(serviceContent).toContain('Invalid or expired session');
|
||||
});
|
||||
});
|
||||
|
||||
describe('main.ts route configuration', () => {
|
||||
it('should exclude get-session from global API prefix', () => {
|
||||
const mainPath = path.resolve(__dirname, '../main.ts');
|
||||
const mainContent = fs.readFileSync(mainPath, 'utf8');
|
||||
// get-session must be excluded from the /api/v1 prefix because
|
||||
// Better Auth serves it at /api/auth/get-session (not /api/v1/api/auth/get-session)
|
||||
expect(mainContent).toContain('api/auth/get-session');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SSO cookie configuration alignment', () => {
|
||||
it('cookie prefix in config should match cookie detection in sessionToToken', () => {
|
||||
const configPath = path.resolve(__dirname, 'better-auth.config.ts');
|
||||
const servicePath = path.resolve(__dirname, 'services/better-auth.service.ts');
|
||||
|
||||
const configContent = fs.readFileSync(configPath, 'utf8');
|
||||
const serviceContent = fs.readFileSync(servicePath, 'utf8');
|
||||
|
||||
// Config sets cookiePrefix to 'mana'
|
||||
expect(configContent).toContain("cookiePrefix: 'mana'");
|
||||
|
||||
// sessionToToken uses 'mana' when COOKIE_DOMAIN is set
|
||||
// This must match! If config uses 'mana' but detection uses something else, SSO breaks.
|
||||
expect(serviceContent).toContain("process.env.COOKIE_DOMAIN ? 'mana'");
|
||||
});
|
||||
|
||||
it('.env.example should document COOKIE_DOMAIN', () => {
|
||||
const envExamplePath = path.resolve(__dirname, '../../.env.example');
|
||||
const envContent = fs.readFileSync(envExamplePath, 'utf8');
|
||||
expect(envContent).toContain('COOKIE_DOMAIN');
|
||||
expect(envContent).toContain('.mana.how');
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue