diff --git a/.github/workflows/oidc-plumbing-e2e.yml b/.github/workflows/oidc-plumbing-e2e.yml index ae840f49..d6ec6d35 100644 --- a/.github/workflows/oidc-plumbing-e2e.yml +++ b/.github/workflows/oidc-plumbing-e2e.yml @@ -509,6 +509,66 @@ jobs: - name: Add authentik-server to /etc/hosts run: echo "127.0.0.1 authentik-server" | sudo tee -a /etc/hosts + # Pre-seed the platform projection for the Authentik user: a users row plus + # a personal org + owner membership. + # + # The authenticated shell sits behind WorkspaceProvisioningGate, which only + # renders once the user has a personal org. A real login provisions that + # org asynchronously (fire-and-forget self-heal), but in CI Permit is a + # no-op and Kafka is absent, so that path cannot be relied on — the gate + # would spin and the sign-in spec would fail for reasons unrelated to auth. + # + # Seeding by EMAIL is what makes this work: syncUserToDatabase matches on + # email and generates its own uuid, so the row we insert here is adopted by + # the login rather than duplicated. (The password lives in Authentik — this + # row holds no credential.) + # Mirrors ensurePersonalOrg() — kept in step with the equivalent seed in + # .github/workflows/e2e.yml, which is the reference for these table/column + # names (organization_memberships, provisioning_state, joined_at, …). + # No password_hash: the credential lives in Authentik, not here. + - name: Seed platform projection (user + personal org) for the E2E user + run: | + docker compose -f docker-compose.e2e.yml exec -T postgres \ + psql -v ON_ERROR_STOP=1 -U e2e -d fuzefront_platform <<'SQL' + DO $$ + DECLARE uid uuid; + oid uuid := gen_random_uuid(); + BEGIN + SELECT id INTO uid FROM users WHERE email = 'e2e@test.local'; + IF uid IS NULL THEN + uid := gen_random_uuid(); + INSERT INTO users (id, email, first_name, last_name, roles, created_at, updated_at) + VALUES (uid, 'e2e@test.local', 'E2E', 'Test', '["user"]'::jsonb, now(), now()); + END IF; + + IF NOT EXISTS (SELECT 1 FROM organizations WHERE owner_id = uid AND type = 'personal') THEN + INSERT INTO organizations (id, name, slug, parent_id, owner_id, type, + settings, metadata, is_active, provisioning_state) + VALUES (oid, 'Personal', 'personal-' || uid, NULL, uid, 'personal', + '{}'::jsonb, '{"personal": true}'::jsonb, true, 'active'); + INSERT INTO organization_memberships (id, user_id, organization_id, role, status, + joined_at, permissions, metadata) + VALUES (gen_random_uuid(), uid, oid, 'owner', 'active', + now(), '{}'::jsonb, '{}'::jsonb); + END IF; + END $$; + SQL + echo "seeded platform projection for e2e@test.local" + + # Fail loudly here rather than let the sign-in spec fail opaquely at the + # provisioning gate — a missing org looks identical to broken auth. + - name: Verify the seed + run: | + docker compose -f docker-compose.e2e.yml exec -T postgres \ + psql -tA -U e2e -d fuzefront_platform \ + -c "SELECT o.type, o.provisioning_state, m.role, m.status + FROM users u + JOIN organizations o ON o.owner_id = u.id AND o.type='personal' + JOIN organization_memberships m ON m.organization_id = o.id AND m.user_id = u.id + WHERE u.email='e2e@test.local';" | tee /tmp/seed.out + grep -q 'personal|active|owner|active' /tmp/seed.out \ + || { echo '::error::personal org/membership not seeded — the sign-in spec would hang on WorkspaceProvisioningGate'; exit 1; } + # ── Run OIDC plumbing E2E tests ──────────────────────────────────────── - name: Run OIDC plumbing E2E tests working-directory: frontend @@ -523,6 +583,22 @@ jobs: PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH: '' run: npx playwright test tests/oidc-plumbing.e2e.spec.ts --project=chromium --reporter=list,html --timeout 180000 + # ── Real sign-in through the UI, against the REAL stack ──────────────── + # This is the coverage that did not exist. The other sign-in job + # (.github/workflows/e2e.yml) runs the monolith with NO Authentik and seeds + # a local bcrypt row, so it cannot exercise the Security API path the SPA + # actually uses — it has been failing since the cutover for exactly that + # reason. Here the account exists in Authentik, the security service is in + # the stack (docker-compose.e2e.yml), and nginx routes /api/v1/security to + # it — so a pass means password sign-in genuinely works end to end. + - name: Run sign-in E2E (real Authentik user, via the Security API) + working-directory: frontend + env: + BASE_URL: http://localhost:4173 + E2E_USER_EMAIL: ${{ env.E2E_USER_EMAIL }} + E2E_USER_PASSWORD: ${{ env.E2E_USER_PASSWORD }} + run: npx playwright test tests/auth-simple.spec.ts --project=chromium --reporter=list --timeout 120000 + - name: Upload Playwright report if: always() uses: actions/upload-artifact@v4 diff --git a/deploy/e2e/nginx.e2e.conf b/deploy/e2e/nginx.e2e.conf index 8f4f3513..5f0551e9 100644 --- a/deploy/e2e/nginx.e2e.conf +++ b/deploy/e2e/nginx.e2e.conf @@ -1,150 +1,182 @@ -worker_processes auto; -pid /tmp/nginx.pid; - -events { - worker_connections 1024; - use epoll; - multi_accept on; -} - -http { - include /etc/nginx/mime.types; - default_type application/octet-stream; - - log_format main '$remote_addr - $remote_user [$time_local] "$request" ' - '$status $body_bytes_sent "$http_referer" ' - '"$http_user_agent" "$http_x_forwarded_for"'; - - access_log /dev/stdout main; - error_log /dev/stderr warn; - - sendfile on; - tcp_nopush on; - tcp_nodelay on; - keepalive_timeout 65; - types_hash_max_size 2048; - client_max_body_size 16M; - - types { - application/manifest+json webmanifest; - } - - gzip on; - gzip_vary on; - gzip_min_length 1024; - gzip_proxied any; - gzip_comp_level 6; - gzip_types - text/plain - text/css - text/xml - text/javascript - application/json - application/javascript - application/manifest+json - application/xml+rss - application/atom+xml - image/svg+xml; - - server { - listen 8080; - server_name localhost; - root /usr/share/nginx/html; - index index.html; - - location ~* (sw\.js|registerSW\.js|workbox-.+\.js)$ { - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Cache-Control "no-cache, no-store, must-revalidate"; - add_header Pragma "no-cache"; - add_header Expires "0"; - try_files $uri =404; - } - - location ~* \.(webmanifest)$ { - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Cache-Control "no-cache"; - add_header Content-Type "application/manifest+json"; - try_files $uri =404; - } - - location = /.well-known/assetlinks.json { - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Cache-Control "no-cache"; - add_header Content-Type "application/json"; - try_files $uri =404; - } - - location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { - expires 1y; - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Cache-Control "public, immutable"; - try_files $uri =404; - } - - # The SPA signs in through the provider-agnostic Security API, served - # ONLY by the security container (the monolith does not mount it). This - # location MUST stay above the /api/ catch-all — nginx prefers the - # longest matching prefix, but the intent is easy to break by reordering. - # Mirrors the prod Ingress: /api/v1/security → fuzefront-security:3002. - location /api/v1/security/ { - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - proxy_pass http://security:3002; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 86400; - } - - # E2E: every OTHER /api/* route goes to the single monolith container. - # Production nginx.conf splits across fuzefront-security/applications/backend - # (K8s service names that don't exist in docker-compose); this conf - # consolidates the rest to backend:3001 for the self-contained E2E stack. - location /api/ { - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - proxy_pass http://backend:3001; - proxy_http_version 1.1; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_read_timeout 86400; - } - - location / { - add_header X-Frame-Options "SAMEORIGIN" always; - add_header X-Content-Type-Options "nosniff" always; - add_header X-XSS-Protection "1; mode=block" always; - add_header Referrer-Policy "strict-origin-when-cross-origin" always; - add_header Cache-Control "no-cache, no-store, must-revalidate"; - add_header Pragma "no-cache"; - add_header Expires "0"; - try_files $uri $uri/ /index.html; - } - - location /health { - add_header Content-Type text/plain; - access_log off; - return 200 "healthy\n"; - } - } -} +worker_processes auto; +pid /tmp/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /dev/stdout main; + error_log /dev/stderr warn; + + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 16M; + + types { + application/manifest+json webmanifest; + } + + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/manifest+json + application/xml+rss + application/atom+xml + image/svg+xml; + + server { + listen 8080; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # ── Authentik's OWN native root paths ───────────────────────────────── + # The boundary model keeps the browser on the app origin: the security + # service returns SAME-ORIGIN redirects (e.g. /source/oauth/login/google/, + # /application/o/authorize/), never an absolute IdP host. Those paths must + # therefore be routable HERE, or the redirect 404s and browser sign-in + # cannot complete — which is exactly how the UI specs failed + # ("waiting for navigation to http://authentik-server:9000/**" never + # happening, because by design the browser no longer goes there). + # + # Mirrors the prod `fuzefront-authentik-idp` Ingress path list. Authentik + # ignores X-Forwarded-Prefix and builds absolute URLs from the forwarded + # Host, so pass Host through unchanged and pin the proto — it derives + # callback_url from Host + X-Forwarded-Proto. + # + # /api/v3 is Authentik's flow-executor + REST API, called by BOTH the + # browser and the security-service. It must sit above the /api/ catch-all + # or it hits FuzeFront's backend and fails with PROVIDER_UNAVAILABLE. + location ~ ^/(application|if|source|flows|ws|-|outpost\.goauthentik\.io|api/v3|static/dist|static/authentik) { + proxy_pass http://authentik-server:9000; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + # Deliberately NOT forwarding Upgrade/Connection: prod's nginx.conf + # keeps WebSocket upgrades to /socket.io/ only, to prevent H2C + # smuggling. Authentik's /ws is admin live-updates, which no e2e + # flow needs — so match the prod posture rather than widen it. + proxy_read_timeout 86400; + } + + location ~* (sw\.js|registerSW\.js|workbox-.+\.js)$ { + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + try_files $uri =404; + } + + location ~* \.(webmanifest)$ { + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Cache-Control "no-cache"; + add_header Content-Type "application/manifest+json"; + try_files $uri =404; + } + + location = /.well-known/assetlinks.json { + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Cache-Control "no-cache"; + add_header Content-Type "application/json"; + try_files $uri =404; + } + + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 1y; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Cache-Control "public, immutable"; + try_files $uri =404; + } + + + # The SPA signs in through the provider-agnostic Security API, served + # ONLY by the security container (the monolith does not mount it). This + # location MUST stay above the /api/ catch-all — nginx prefers the + # longest matching prefix, but the intent is easy to break by reordering. + # Mirrors the prod Ingress: /api/v1/security → fuzefront-security:3002. + location /api/v1/security/ { + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + proxy_pass http://security:3002; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } + + # E2E: every OTHER /api/* route goes to the single monolith container. + # Production nginx.conf splits across fuzefront-security/applications/backend + # (K8s service names that don't exist in docker-compose); this conf + # consolidates the rest to backend:3001 for the self-contained E2E stack. + location /api/ { + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + proxy_pass http://backend:3001; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 86400; + } + + location / { + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + try_files $uri $uri/ /index.html; + } + + location /health { + add_header Content-Type text/plain; + access_log off; + return 200 "healthy\n"; + } + } +} diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 2c7eb8e5..29356dc3 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -307,11 +307,22 @@ services: context: . dockerfile: frontend/Dockerfile args: - # Same-origin API base: empty string → relative URLs. Backend and frontend - # are on different origins in the E2E stack (3001 vs 4173); Playwright's - # baseURL is http://localhost:4173 and API calls go to http://localhost:3001. - # Set this to http://localhost:3001 so the browser knows where the API is. - VITE_API_URL: http://localhost:3001 + # SAME-ORIGIN, deliberately empty → the SPA uses relative URLs and every + # API call goes through this container's nginx, which path-routes + # /api/v1/security/ → security:3002 and everything else → backend:3001. + # + # This previously hard-coded http://localhost:3001, which pointed the + # browser straight at the MONOLITH and bypassed nginx entirely. The + # monolith does not serve /api/v1/security/*, so GET /methods 404'd, the + # SPA saw no `social`, the Google button never rendered and sign-in was + # impossible — the e2e could not exercise auth at all. It also made the + # nginx routing dead code. + # + # Same-origin is also the documented contract (CLAUDE.md: "the frontend + # talks to the API on a same-origin API base (no cross-origin base URL)… + # never hard-code an absolute API host") and is what prod does, so this + # keeps the e2e faithful to prod instead of testing a shape we never ship. + VITE_API_URL: '' ports: - '4173:8080' # Override the baked-in nginx.conf with the E2E variant that routes all diff --git a/frontend/tests/auth-simple.spec.ts b/frontend/tests/auth-simple.spec.ts index 671d2ce7..e2abb6b9 100644 --- a/frontend/tests/auth-simple.spec.ts +++ b/frontend/tests/auth-simple.spec.ts @@ -1,5 +1,13 @@ import { test, expect } from '@playwright/test' +// Credentials come from the environment so this spec can run against the FULL +// stack, where the account must exist in the identity provider. Sign-in is +// brokered through that provider — a bcrypt row seeded straight into the +// platform DB is NOT a credential it will accept, which is why the hardcoded +// admin@fuzefront.dev default only works against a local-auth stack. +const EMAIL = process.env.E2E_USER_EMAIL ?? 'admin@fuzefront.dev' +const PASSWORD = process.env.E2E_USER_PASSWORD ?? 'admin123' + test.describe('Authentication - Simple', () => { test('should successfully authenticate', async ({ page }) => { // Navigate to the app @@ -11,8 +19,8 @@ test.describe('Authentication - Simple', () => { await expect(page.locator('input[type="password"]')).toBeVisible({ timeout: 10000 }) // Fill credentials - await page.fill('input[type="email"]', 'admin@fuzefront.dev') - await page.fill('input[type="password"]', 'admin123') + await page.fill('input[type="email"]', EMAIL) + await page.fill('input[type="password"]', PASSWORD) // Wait for login response and submit. // The SPA logs in via the provider-agnostic Security API (POST diff --git a/frontend/tests/oidc-plumbing.e2e.spec.ts b/frontend/tests/oidc-plumbing.e2e.spec.ts index 0695f15e..f5c3475f 100644 --- a/frontend/tests/oidc-plumbing.e2e.spec.ts +++ b/frontend/tests/oidc-plumbing.e2e.spec.ts @@ -35,7 +35,13 @@ import { test, expect, type Page } from '@playwright/test' const AUTHENTIK_URL = process.env.AUTHENTIK_URL ?? 'http://authentik-server:9000' const FRONTEND_URL = process.env.BASE_URL ?? 'http://localhost:4173' +// The MONOLITH. Serves the deprecated /api/auth/* shim — NOT /api/v1/security/*. const BACKEND_URL = process.env.BACKEND_URL ?? 'http://localhost:3001' +// The Security API is reached the way the browser reaches it: through the app +// origin, whose nginx path-routes /api/v1/security/ to the security service. +// Do NOT point this at BACKEND_URL — the monolith 404s these routes, which is +// exactly how these tests failed in ~11ms the first time round. +const SECURITY_URL = process.env.SECURITY_URL ?? FRONTEND_URL const E2E_USER_EMAIL = process.env.E2E_USER_EMAIL ?? 'e2e@test.local' const E2E_USER_PASSWORD = process.env.E2E_USER_PASSWORD ?? 'E2eP@ssw0rd123' @@ -71,7 +77,7 @@ test.describe('OIDC plumbing — full stack (local Authentik user)', () => { // ── 2c. Security API capability descriptor (the surface the SPA reads) ── // Provider-neutral by contract: a vendor name must never appear here. test('Security API advertises neutral capabilities incl. Google social', async ({ request }) => { - const resp = await request.get(`${BACKEND_URL}/api/v1/security/methods`) + const resp = await request.get(`${SECURITY_URL}/api/v1/security/methods`) expect(resp.ok(), `GET /api/v1/security/methods -> ${resp.status()}`).toBeTruthy() const body = await resp.json() expect(body.password).toBe(true) @@ -83,7 +89,7 @@ test.describe('OIDC plumbing — full stack (local Authentik user)', () => { // ── 2d. Password sign-in through the Security API (what the SPA calls) ── test('Security API password sign-in returns a platform JWT session', async ({ request }) => { - const resp = await request.post(`${BACKEND_URL}/api/v1/security/session`, { + const resp = await request.post(`${SECURITY_URL}/api/v1/security/session`, { data: { email: E2E_USER_EMAIL, password: E2E_USER_PASSWORD }, }) expect( @@ -105,7 +111,7 @@ test.describe('OIDC plumbing — full stack (local Authentik user)', () => { }) test('Security API rejects a wrong password with 401', async ({ request }) => { - const resp = await request.post(`${BACKEND_URL}/api/v1/security/session`, { + const resp = await request.post(`${SECURITY_URL}/api/v1/security/session`, { data: { email: E2E_USER_EMAIL, password: 'definitely-not-the-password' }, }) expect(resp.status()).toBe(401)