diff --git a/docker-compose.macmini.yml b/docker-compose.macmini.yml index c8625a029..99381a504 100644 --- a/docker-compose.macmini.yml +++ b/docker-compose.macmini.yml @@ -105,7 +105,7 @@ services: SMTP_USER: ${SMTP_USER:-94cde5002@smtp-brevo.com} SMTP_PASSWORD: ${SMTP_PASSWORD} SMTP_FROM: Mana - 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) diff --git a/services/mana-core-auth/src/auth/better-auth.config.ts b/services/mana-core-auth/src/auth/better-auth.config.ts index c90a11bb4..bc8b05d1c 100644 --- a/services/mana-core-auth/src/auth/better-auth.config.ts +++ b/services/mana-core-auth/src/auth/better-auth.config.ts @@ -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', diff --git a/services/mana-core-auth/src/auth/sso-config.spec.ts b/services/mana-core-auth/src/auth/sso-config.spec.ts new file mode 100644 index 000000000..e62625d5e --- /dev/null +++ b/services/mana-core-auth/src/auth/sso-config.spec.ts @@ -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'); + }); +}); diff --git a/services/mana-core-auth/src/auth/sso-session-to-token.spec.ts b/services/mana-core-auth/src/auth/sso-session-to-token.spec.ts new file mode 100644 index 000000000..0fd400d2d --- /dev/null +++ b/services/mana-core-auth/src/auth/sso-session-to-token.spec.ts @@ -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'); + }); +});