From 66062e199d5b161f072afc28d53a27dee60fe2b2 Mon Sep 17 00:00:00 2001 From: "fuzeone-bot[bot]" Date: Thu, 16 Jul 2026 12:41:46 +0300 Subject: [PATCH 1/9] fix(security): Google sign-in goes directly to Google (no Authentik UI) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit startSocialLogin sent the browser to the generic OIDC authorize endpoint, which requires an authenticated session. With none, Authentik fell back to the brand's authentication flow and rendered its own identification page (/if/flow/...) with a "Google" button — stranding the user on the IdP's UI instead of Google. Launch the Google source directly instead: /source/oauth/login/google/?next=. The source-redirect view 302s straight to accounts.google.com; after the callback the source flow runs (silent enrollment first time, login when returning), then `next` resumes authorize, which issues the code silently. Same cookie/state/PKCE round-trip — one hop inserted ahead of authorize, so no /if/flow/ ever renders. This also lets devops drop /if from the IdP ingress. Also corrects the stale source-google.yaml comment (the removed auth.fuzefront.com -> app.fuzefront.com); the callback is host-derived, not a settable field. Co-Authored-By: Claude Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7 --- .../authentik/AuthentikIdentityProvider.ts | 18 ++++++++++++++++- .../security/tests/authentik-provider.test.ts | 20 +++++++++++++++---- .../authentik/blueprints/source-google.yaml | 8 +++++++- 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts b/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts index 991dfbf9..ff3085f3 100644 --- a/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts +++ b/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts @@ -247,7 +247,23 @@ export class AuthentikIdentityProvider implements IdentityProvider { // Rewrite the absolute internal authorize URL to a SAME-HOST path under the // IdP reverse-proxy prefix, so the browser never sees the internal host. const authorize = new URL(url) - const redirectUrl = `${idpProxyPrefix()}${authorize.pathname}${authorize.search}` + const authorizePath = `${idpProxyPrefix()}${authorize.pathname}${authorize.search}` + + // Launch the Google SOURCE directly rather than sending the browser to the + // generic authorize endpoint. Authorize requires an authenticated session, + // so with no session it falls back to the brand's authentication flow and + // renders Authentik's identification page (`/if/flow/...`) with a "Google" + // button — the user gets stranded on the IdP's own UI. + // + // The source-redirect view instead 302s straight to accounts.google.com. + // Google returns to /source/oauth/callback/google/, the source flow runs + // (auto stages: silent enrollment first time, login when returning) and + // establishes the session, then `next` sends the browser to authorize, + // which now issues the code silently. Same cookie/state/PKCE round-trip — + // this only inserts one hop ahead of authorize, so no `/if/flow/` renders. + const redirectUrl = + `${idpProxyPrefix()}/source/oauth/login/google/` + + `?next=${encodeURIComponent(authorizePath)}` this.socialStates.set(state, { codeVerifier, diff --git a/backend/security/tests/authentik-provider.test.ts b/backend/security/tests/authentik-provider.test.ts index 37ded0b1..8770b513 100644 --- a/backend/security/tests/authentik-provider.test.ts +++ b/backend/security/tests/authentik-provider.test.ts @@ -140,30 +140,42 @@ describe('passwordLogin', () => { }) describe('social login boundary', () => { - it('rewrites the authorize URL to the same-host IdP proxy prefix when one is set', async () => { + it('launches the Google source (not authorize) under the same-host IdP proxy prefix when one is set', async () => { const p = newProvider() const { redirectUrl, state, codeVerifier } = await p.startSocialLogin('google', '/home') - expect(redirectUrl.startsWith('/api/auth/idp/application/o/authorize/')).toBe(true) + // Must hit the source-redirect view — that 302s straight to Google. Going + // to authorize with no session renders Authentik's `/if/flow/` UI instead. + expect(redirectUrl.startsWith('/api/auth/idp/source/oauth/login/google/')).toBe(true) expect(redirectUrl).not.toMatch(/auth\.internal\.example/) + // authorize is carried as the post-login `next`, not as the destination. + const next = new URLSearchParams(redirectUrl.split('?')[1]).get('next') + expect(next?.startsWith('/api/auth/idp/application/o/authorize/')).toBe(true) expect(state).toBeTruthy() // codeVerifier is surfaced so the route can set the oidc_cv cookie. expect(codeVerifier).toBe('cv') }) - it('defaults to a NATIVE same-host authorize path (empty prefix, matching #256 ingress)', async () => { + it('defaults to a NATIVE same-host source path (empty prefix, matching #256 ingress)', async () => { const saved = process.env.SECURITY_IDP_PROXY_PREFIX delete process.env.SECURITY_IDP_PROXY_PREFIX try { const p = newProvider() const { redirectUrl } = await p.startSocialLogin('google', '/home') // Native path, no /api/auth/idp prefix, no double slash, no internal host. - expect(redirectUrl.startsWith('/application/o/authorize/')).toBe(true) + expect(redirectUrl.startsWith('/source/oauth/login/google/')).toBe(true) expect(redirectUrl.startsWith('//')).toBe(false) expect(redirectUrl).not.toMatch(/auth\.internal\.example/) + const next = new URLSearchParams(redirectUrl.split('?')[1]).get('next') + expect(next?.startsWith('/application/o/authorize/')).toBe(true) } finally { if (saved !== undefined) process.env.SECURITY_IDP_PROXY_PREFIX = saved } }) + + it('never sends the browser to the Authentik flow UI (/if/)', async () => { + const { redirectUrl } = await newProvider().startSocialLogin('google', '/home') + expect(redirectUrl).not.toMatch(/\/if\//) + }) it('rejects an absolute redirectTo (open-redirect guard)', async () => { await expect(newProvider().startSocialLogin('google', 'https://evil.com')).rejects.toBeInstanceOf(InvalidInputError) }) diff --git a/deploy/helm/fuzefront/authentik/blueprints/source-google.yaml b/deploy/helm/fuzefront/authentik/blueprints/source-google.yaml index 71b05710..fe9d7f01 100644 --- a/deploy/helm/fuzefront/authentik/blueprints/source-google.yaml +++ b/deploy/helm/fuzefront/authentik/blueprints/source-google.yaml @@ -8,9 +8,15 @@ # (APIs & Services → Credentials → OAuth 2.0 Client ID, type: Web application) # and add the following Authorized redirect URIs to the matching Google OAuth client: # -# Production client: https://auth.fuzefront.com/source/oauth/callback/google/ +# Production client: https://app.fuzefront.com/source/oauth/callback/google/ # Dev tunnel client: https://auth-dev.fuzefront.com/source/oauth/callback/google/ # +# The production callback is on the APP host, not a dedicated IdP host: the +# public auth.fuzefront.com ingress was removed so the browser only ever sees +# app.fuzefront.com + accounts.google.com. There is no settable callback field +# on an oauthsource — Authentik derives callback_url at runtime from the request +# Host + X-Forwarded-Proto, which the IdP ingress pins to https/app.fuzefront.com. +# # Use TWO SEPARATE Google OAuth clients — one for production, one for the dev # tunnel. Never share credentials between them. The dev client is used by # docker-compose.e2e.yml (CI) and docker-compose.tunnel.yml (local tunnel dev). From 77c093e52e0b9b3e55fb8f05a23126fa09aef947 Mon Sep 17 00:00:00 2001 From: "fuzeone-bot[bot]" Date: Thu, 16 Jul 2026 13:06:00 +0300 Subject: [PATCH 2/9] fix(e2e): point auth tests at the Security API surface the SPA actually uses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The security cutover moved the SPA onto /api/v1/security/* (POST /session to log in, GET /methods for capabilities, /social/google/start, /session/exchange), but the e2e suite still waited on /api/auth/login and mocked /api/auth/method. The frontend never calls those anymore, so page.waitForResponse() hung until timeout — this is why "E2E (sign-in)" has been red on master since the cutover. The tests were asserting an endpoint we deleted from the client, not a real bug. - auth-simple / clock-load / mobile-layout / pages/login-page: wait on POST /api/v1/security/session. Match the METHOD too — GET /session ("me") shares the URL and would otherwise satisfy the wait before login completes. - google-signin: mock /api/v1/security/methods with the neutral descriptor (`social: ['google']` replaces the vendor-flavoured `oidcConfigured`), and route the real endpoints — /social/google/start and /session/exchange. - federated-apps-register-activate: "me" is GET /api/v1/security/session. - live-smoke (post-prod): smoke /methods and sign in via /session — the same surface prod serves browsers, so it fails if real sign-in breaks. Also ADDS the missing coverage of the new surface to oidc-plumbing (it only tested the deprecated /api/auth shim): capability descriptor, password sign-in, wrong-password 401, plus a boundary assertion that no response leaks the vendor. The /api/auth shim assertions stay — it is still mounted for one release. Co-Authored-By: Claude Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7 --- frontend/e2e/post-prod/live-smoke.spec.ts | 48 +++++++----- frontend/tests/auth-simple.spec.ts | 11 ++- frontend/tests/clock-load.spec.ts | 7 +- ...ed-apps-register-activate.frontend.spec.ts | 8 +- frontend/tests/google-signin.spec.ts | 78 +++++++++++-------- frontend/tests/mobile-layout.spec.ts | 7 +- frontend/tests/oidc-plumbing.e2e.spec.ts | 48 +++++++++++- frontend/tests/pages/login-page.ts | 7 +- 8 files changed, 154 insertions(+), 60 deletions(-) diff --git a/frontend/e2e/post-prod/live-smoke.spec.ts b/frontend/e2e/post-prod/live-smoke.spec.ts index 325892f4..762dcc61 100644 --- a/frontend/e2e/post-prod/live-smoke.spec.ts +++ b/frontend/e2e/post-prod/live-smoke.spec.ts @@ -7,12 +7,12 @@ import { test, expect, type Page, type ConsoleMessage } from '@playwright/test' * Verifies the acceptance-criteria critical journey on the real deployment: * 1. The Module-Federation SHELL serves (root 200, correct title). * 2. Core API health is up. - * 3. The backend advertises OIDC as configured (/api/auth/method) — this is - * the flag that hides the local form, so it is asserted explicitly. + * 3. The Security API advertises the expected capabilities + * (/api/v1/security/methods) — a non-empty `social` array is what renders + * the Google button, so it is asserted explicitly. * 4. /login shows the NATIVE credentials form (email/password verified - * against Authentik server-side — no redirect) plus a "Sign in with - * Google" button (federated through Authentik). The old "Sign in with - * Authentik" redirect button is gone. + * server-side by the identity provider — no redirect) plus a "Sign in with + * Google" button. The old "Sign in with Authentik" redirect button is gone. * 5. Clicking "Sign in with Google" hands off into the OIDC flow. * 6. The auth backend is routable. * 7. The dashboard renders for an authenticated user and Module-Federation @@ -25,9 +25,10 @@ import { test, expect, type Page, type ConsoleMessage } from '@playwright/test' * IdP was never configured". * * The authenticated journey (test 7) obtains its session via the API - * (POST /api/auth/login) rather than the UI: the local form is intentionally - * absent from the UI, and driving real Google/Authentik credentials through a - * synthetic check is brittle + a security liability. The API path remains as + * (POST /api/v1/security/session) rather than the UI: driving real Google + * credentials through a synthetic check is brittle + a security liability. + * This is the same endpoint the SPA uses, so it still fails if sign-in + * genuinely breaks. The API path remains as * the platform's machine/break-glass authentication. * * Credentials come from env so we never hard-code secrets in the repo: @@ -73,15 +74,19 @@ test.describe('FuzeFront live post-prod smoke', () => { expect(body?.database?.status, 'platform DB connection').toBe('connected') }) - test('3. backend advertises OIDC as configured (Authentik is the identity authority)', async ({ request }) => { - const resp = await request.get('/api/auth/method') - expect(resp.status(), '/api/auth/method status').toBe(200) + test('3. Security API advertises the expected auth capabilities in prod', async ({ request }) => { + // The SPA reads the provider-agnostic Security API, not the deprecated + // /api/auth shim — smoke the surface prod actually serves to browsers. + const resp = await request.get('/api/v1/security/methods') + expect(resp.status(), '/api/v1/security/methods status').toBe(200) const body = await resp.json() expect( - body.oidcConfigured, - 'oidcConfigured must be true in prod — false would re-expose the local fallback form' - ).toBe(true) - expect(body.defaultMethod).toBe('oidc') + body.social, + 'social login must be advertised in prod — an empty array hides the Google button' + ).toContain('google') + expect(body.password, 'password sign-in must be advertised in prod').toBe(true) + // Boundary: prod must never leak the identity/authz vendor to a browser. + expect(JSON.stringify(body)).not.toMatch(/authentik|permit/i) }) test('4. /login offers the native credentials form + Google (no Authentik redirect button)', async ({ page }) => { @@ -138,17 +143,20 @@ test.describe('FuzeFront live post-prod smoke', () => { test('7. authenticated dashboard + Module-Federation apps load', async ({ page, request }) => { const { consoleErrors, pageErrors, failedRequests } = attachErrorCollectors(page) - // Authenticate via the API (machine/break-glass path). The UI no longer - // renders a local form — human sign-in is Authentik/Google only — so the - // synthetic check mints its session directly and injects the token. - const loginResp = await request.post('/api/auth/login', { + // Authenticate via the Security API (machine/break-glass path) — the same + // surface the SPA uses, so this smoke fails if real sign-in is broken. + const loginResp = await request.post('/api/v1/security/session', { data: { email: EMAIL, password: PASSWORD }, }) expect( loginResp.status(), - `POST /api/auth/login -> ${loginResp.status()} (401/403 = creds rejected: POST_PROD_EMAIL/POST_PROD_PASSWORD not provisioned in prod; 5xx = backend error)` + `POST /api/v1/security/session -> ${loginResp.status()} (401/403 = creds rejected: POST_PROD_EMAIL/POST_PROD_PASSWORD not provisioned in prod; 5xx = backend error)` ).toBe(200) const loginBody = await loginResp.json() + expect( + loginBody.status, + 'break-glass account must not require MFA step-up, or this synthetic cannot sign in' + ).not.toBe('mfa_required') const token: string = loginBody.token expect(token, 'API login returned a token').toBeTruthy() diff --git a/frontend/tests/auth-simple.spec.ts b/frontend/tests/auth-simple.spec.ts index 6ea08b45..671d2ce7 100644 --- a/frontend/tests/auth-simple.spec.ts +++ b/frontend/tests/auth-simple.spec.ts @@ -14,9 +14,16 @@ test.describe('Authentication - Simple', () => { await page.fill('input[type="email"]', 'admin@fuzefront.dev') await page.fill('input[type="password"]', 'admin123') - // Wait for login response and submit + // Wait for login response and submit. + // The SPA logs in via the provider-agnostic Security API (POST + // /api/v1/security/session), not the deprecated /api/auth/login. Match the + // METHOD too — GET /session ("me") hits the same URL and would otherwise + // satisfy this wait before the login round-trip completes. const responsePromise = page.waitForResponse( - response => response.url().includes('/api/auth/login') && response.status() === 200, + response => + response.url().includes('/api/v1/security/session') && + response.request().method() === 'POST' && + response.status() === 200, { timeout: 15000 } ) diff --git a/frontend/tests/clock-load.spec.ts b/frontend/tests/clock-load.spec.ts index bb7257a6..9ebd4289 100644 --- a/frontend/tests/clock-load.spec.ts +++ b/frontend/tests/clock-load.spec.ts @@ -28,8 +28,13 @@ test('Clock mounts from the launcher at runtime', async ({ page }) => { await page.fill('input[type="email"]', 'admin@fuzefront.dev') await page.fill('input[type="password"]', 'admin123') await Promise.all([ + // Login goes through the Security API (POST /api/v1/security/session); + // match the method so GET /session ("me") cannot satisfy the wait early. page.waitForResponse( - r => r.url().includes('/api/auth/login') && r.status() === 200 + r => + r.url().includes('/api/v1/security/session') && + r.request().method() === 'POST' && + r.status() === 200 ), page.click('button[type="submit"]'), ]) diff --git a/frontend/tests/federated-apps-register-activate.frontend.spec.ts b/frontend/tests/federated-apps-register-activate.frontend.spec.ts index c6cc8e07..ce271666 100644 --- a/frontend/tests/federated-apps-register-activate.frontend.spec.ts +++ b/frontend/tests/federated-apps-register-activate.frontend.spec.ts @@ -19,7 +19,7 @@ import { test, expect, type Page } from '@playwright/test' * DETERMINISM: the full live stack (Postgres + backend + Authentik + Permit + * a real Module-Federation remote) is not runnable in this sandbox, so this * spec MOCKS the contract surface via Playwright request interception: - * - auth (/api/auth/user) → an authenticated admin + * - auth (/api/v1/security/session) → an authenticated admin * - orgs (/api/organizations) → a personal org (opens the provisioning gate) * - registry (/api/v1/app-registry/*)→ stateful list/register/activate * @@ -79,8 +79,10 @@ function activatedApp(): Record { * AND activated, exactly like the real lifecycle. */ async function installMocks(page: Page) { - // Authenticated as a provisioned admin (AuthWrapper -> GET /api/auth/user). - await page.route('**/api/auth/user', route => + // Authenticated as a provisioned admin. The shell resolves "me" from the + // provider-agnostic Security API (GET /api/v1/security/session), not the + // deprecated /api/auth/user shim. + await page.route('**/api/v1/security/session', route => route.fulfill({ status: 200, contentType: 'application/json', diff --git a/frontend/tests/google-signin.spec.ts b/frontend/tests/google-signin.spec.ts index 3523668f..2b2e26ef 100644 --- a/frontend/tests/google-signin.spec.ts +++ b/frontend/tests/google-signin.spec.ts @@ -3,21 +3,26 @@ * * FRONTEND COMPONENT TESTS (mock-based) — NOT an end-to-end integration test. * - * All backend/Authentik calls are intercepted with page.route() so these tests - * do NOT require a live Authentik instance or Google OAuth credentials. They test - * ONLY the frontend's rendering and routing logic in isolation. + * All backend calls are intercepted with page.route() so these tests do NOT + * require a live identity provider or Google OAuth credentials. They test ONLY + * the frontend's rendering and routing logic in isolation. + * + * The SPA talks exclusively to FuzeFront's own provider-agnostic Security API + * (`/api/v1/security/*`) — it never names or calls a vendor — so these mocks + * intercept that surface, not the deprecated `/api/auth/*` compatibility layer. * * For the real end-to-end integration test that exercises every layer — - * frontend → backend OIDC login → Authentik → token exchange → JWT — see: - * tests/oidc-plumbing.e2e.spec.ts (local Authentik user, runs on every PR) + * frontend → Security API → identity provider → token exchange → JWT — see: + * tests/oidc-plumbing.e2e.spec.ts (local provider user, runs on every PR) * tests/google-oauth-e2e.spec.ts (real Google OAuth, requires CI secrets) * * What these mock tests cover: - * 1. Login page shows / hides the OIDC button based on /api/auth/method response - * 2. Click → loginWithOIDC() → browser navigates to GET /api/auth/oidc/login - * (intercepted — does NOT hit the real backend or Authentik) - * 3. After Authentik auth, backend redirects frontend to /?code= - * 4. LoginPage.handleOIDCCallback() reads ?code=, POSTs /api/auth/token-exchange + * 1. Login page shows / hides the Google button based on the `social` array + * returned by GET /api/v1/security/methods + * 2. Click → startSocialLogin() → browser navigates to + * GET /api/v1/security/social/google/start (intercepted — never hits Google) + * 3. After provider auth, the backend redirects the frontend to /?code= + * 4. handleAuthCallback() reads ?code=, POSTs /api/v1/security/session/exchange * 5. App navigates to /dashboard on success * 6. Error path: ?error= shows an error on the login page */ @@ -33,15 +38,19 @@ async function setupCommonMocks(page: Parameters[1]>[0], route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok' }) }) ) - // Auth method discovery endpoint - await page.route('**/api/auth/method', route => + // Auth capability discovery. The SPA reads the provider-agnostic Security API + // (`GET /api/v1/security/methods`), which returns a neutral descriptor — a + // non-empty `social` array is what enables the Google button; the legacy + // vendor-flavoured `oidcConfigured` boolean is gone. + await page.route('**/api/v1/security/methods', route => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ - methods: oidcConfigured ? ['local', 'oidc'] : ['local'], - oidcConfigured, - defaultMethod: oidcConfigured ? 'oidc' : 'local', + password: true, + social: oidcConfigured ? ['google'] : [], + mfa: { enabled: true, types: ['totp', 'sms', 'email'] }, + verification: { email: true, sms: true }, }), }) ) @@ -80,28 +89,28 @@ test.describe('OIDC / Google Sign-In — pre-production E2E', () => { }) /** - * 3. Clicking "Sign in with Authentik" navigates the browser to /api/auth/oidc/login. - * We intercept that route to prevent a real redirect to Authentik. + * 3. Clicking "Sign in with Google" navigates the browser to the Security API's + * social-start endpoint. We intercept it to prevent a real redirect out. */ - test('clicking "Sign in with Authentik" issues a request to /api/auth/oidc/login', async ({ page }) => { + test('clicking "Sign in with Google" issues a request to /api/v1/security/social/google/start', async ({ page }) => { await setupCommonMocks(page, true) - // Intercept the OIDC login initiation endpoint. The real backend would - // redirect here to Authentik; we short-circuit to keep the test self-contained. - let oidcLoginRequested = false - await page.route('**/api/auth/oidc/login', route => { - oidcLoginRequested = true + // Intercept the server-brokered social-login initiation endpoint. The real + // backend 302s from here toward Google; we short-circuit to stay self-contained. + let socialStartRequested = false + await page.route('**/api/v1/security/social/google/start', route => { + socialStartRequested = true // Fulfill with a 200 so the browser doesn't chase a real 302. - route.fulfill({ status: 200, body: 'Intercepted — redirecting to Authentik…' }) + route.fulfill({ status: 200, body: 'Intercepted — redirecting to Google…' }) }) await page.goto('/') - await page.getByRole('button', { name: /sign in with authentik/i }).click() + await page.getByRole('button', { name: /sign in with google/i }).click() // Give the navigation a moment to reach our route handler. await page.waitForTimeout(1500) - expect(oidcLoginRequested).toBe(true) + expect(socialStartRequested).toBe(true) }) /** @@ -136,17 +145,24 @@ test.describe('OIDC / Google Sign-In — pre-production E2E', () => { // (Playwright evaluates routes in reverse-registration order). await setupCommonMocks(page, true) - // Token exchange: frontend POSTs the code → backend returns JWT. - await page.route('**/api/auth/token-exchange', route => + // Token exchange: frontend POSTs the opaque code to the Security API + // (`POST /api/v1/security/session/exchange`) → returns a SessionResult. + await page.route('**/api/v1/security/session/exchange', route => route.fulfill({ status: 200, contentType: 'application/json', - body: JSON.stringify({ token: 'e2e-test-jwt', sessionId: 'e2e-session-1' }), + body: JSON.stringify({ + status: 'authenticated', + token: 'e2e-test-jwt', + sessionId: 'e2e-session-1', + user: { id: 'u-1', email: 'e2e@fuzefront.dev', firstName: 'E2E', lastName: 'User', roles: ['user'] }, + }), }) ) - // User lookup: frontend GETs /api/auth/user after storing the token. - await page.route('**/api/auth/user', route => + // User lookup ("me"): frontend GETs the Security API session after storing + // the token. Exact path — `/session/exchange` above keeps its own route. + await page.route('**/api/v1/security/session', route => route.fulfill({ status: 200, contentType: 'application/json', diff --git a/frontend/tests/mobile-layout.spec.ts b/frontend/tests/mobile-layout.spec.ts index acf012f8..c5926ed0 100644 --- a/frontend/tests/mobile-layout.spec.ts +++ b/frontend/tests/mobile-layout.spec.ts @@ -18,8 +18,13 @@ test.describe('Mobile layout', () => { await page.fill('input[type="email"]', 'admin@fuzefront.dev') await page.fill('input[type="password"]', 'admin123') await Promise.all([ + // Login goes through the Security API (POST /api/v1/security/session); + // match the method so GET /session ("me") cannot satisfy the wait early. page.waitForResponse( - r => r.url().includes('/api/auth/login') && r.status() === 200, + r => + r.url().includes('/api/v1/security/session') && + r.request().method() === 'POST' && + r.status() === 200, { timeout: 15000 } ), page.click('button[type="submit"]'), diff --git a/frontend/tests/oidc-plumbing.e2e.spec.ts b/frontend/tests/oidc-plumbing.e2e.spec.ts index 98ae76fc..0695f15e 100644 --- a/frontend/tests/oidc-plumbing.e2e.spec.ts +++ b/frontend/tests/oidc-plumbing.e2e.spec.ts @@ -57,7 +57,10 @@ test.describe('OIDC plumbing — full stack (local Authentik user)', () => { }) // ── 2. Backend health + OIDC configured ──────────────────────────────── - test('backend reports oidcConfigured:true', async ({ request }) => { + // NOTE: /api/auth/* is the DEPRECATED compatibility layer, kept mounted for + // one release. It is still asserted here so we notice if the shim breaks — + // but the SPA and all consumers use /api/v1/security/* (covered in 2c/2d). + test('backend reports oidcConfigured:true (deprecated /api/auth shim)', async ({ request }) => { const resp = await request.get(`${BACKEND_URL}/api/auth/method`) expect(resp.ok()).toBeTruthy() const body = await resp.json() @@ -65,6 +68,49 @@ test.describe('OIDC plumbing — full stack (local Authentik user)', () => { expect(body.methods).toContain('oidc') }) + // ── 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`) + expect(resp.ok(), `GET /api/v1/security/methods -> ${resp.status()}`).toBeTruthy() + const body = await resp.json() + expect(body.password).toBe(true) + expect(body.social).toContain('google') + expect(body.mfa?.enabled).toBe(true) + // Boundary: the descriptor must not leak the identity provider. + expect(JSON.stringify(body)).not.toMatch(/authentik|permit/i) + }) + + // ── 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`, { + data: { email: E2E_USER_EMAIL, password: E2E_USER_PASSWORD }, + }) + expect( + resp.status(), + `POST /api/v1/security/session -> ${resp.status()}: ${await resp.text().catch(() => '')}` + ).toBe(200) + const body = await resp.json() + // Either an authenticated session, or an mfa_required challenge if the + // account has step-up enabled — both are contract-valid; only the + // authenticated branch carries a token. + if (body.status === 'mfa_required') { + expect(body.challengeId ?? body.mfaToken ?? body.token).toBeTruthy() + return + } + expect(body.token, 'platform JWT returned').toBeTruthy() + expect(body.token.split('.')).toHaveLength(3) + expect(body.sessionId).toBeTruthy() + expect(body.user?.email?.toLowerCase()).toBe(E2E_USER_EMAIL.toLowerCase()) + }) + + test('Security API rejects a wrong password with 401', async ({ request }) => { + const resp = await request.post(`${BACKEND_URL}/api/v1/security/session`, { + data: { email: E2E_USER_EMAIL, password: 'definitely-not-the-password' }, + }) + expect(resp.status()).toBe(401) + }) + // ── 2b. Server-side password sign-in (flow-executor, no redirect) ─────── // The native login form posts credentials to /api/auth/oidc/password; the // backend drives Authentik's flow-executor with them and completes the OIDC diff --git a/frontend/tests/pages/login-page.ts b/frontend/tests/pages/login-page.ts index dd0020ca..0b932428 100644 --- a/frontend/tests/pages/login-page.ts +++ b/frontend/tests/pages/login-page.ts @@ -34,8 +34,13 @@ export class LoginPage { await this.page.waitForLoadState('networkidle') // Click submit and wait for the request + // Login goes through the Security API (POST /api/v1/security/session); + // match the method so GET /session ("me") cannot satisfy the wait early. const responsePromise = this.page.waitForResponse( - response => response.url().includes('/api/auth/login') && response.status() !== 0, + response => + response.url().includes('/api/v1/security/session') && + response.request().method() === 'POST' && + response.status() !== 0, { timeout: 10000 } ) From 2e2461eb825c69833086c012cd34021c1976288d Mon Sep 17 00:00:00 2001 From: "fuzeone-bot[bot]" Date: Thu, 16 Jul 2026 13:22:58 +0300 Subject: [PATCH 3/9] refactor(security): derive the social source slug from `provider` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The source path was hardcoded to google. The `provider !== 'google'` guard above means nothing else can reach it today, so this is not a live bug — but it is a trap: widening that guard would silently route every new provider through Google's source. Track `provider` so the slug cannot drift from the guard. Co-Authored-By: Claude Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7 --- .../authentik/AuthentikIdentityProvider.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts b/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts index ff3085f3..27584826 100644 --- a/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts +++ b/backend/security/src/providers/authentik/AuthentikIdentityProvider.ts @@ -249,20 +249,23 @@ export class AuthentikIdentityProvider implements IdentityProvider { const authorize = new URL(url) const authorizePath = `${idpProxyPrefix()}${authorize.pathname}${authorize.search}` - // Launch the Google SOURCE directly rather than sending the browser to the - // generic authorize endpoint. Authorize requires an authenticated session, - // so with no session it falls back to the brand's authentication flow and - // renders Authentik's identification page (`/if/flow/...`) with a "Google" - // button — the user gets stranded on the IdP's own UI. + // Launch the provider's SOURCE directly rather than sending the browser to + // the generic authorize endpoint. Authorize requires an authenticated + // session, so with none it falls back to the brand's authentication flow + // and renders the IdP's identification page (`/if/flow/...`) with a social + // button — stranding the user on the provider's own UI. // - // The source-redirect view instead 302s straight to accounts.google.com. - // Google returns to /source/oauth/callback/google/, the source flow runs + // The source-redirect view instead 302s straight to the social provider. + // It returns to /source/oauth/callback//, the source flow runs // (auto stages: silent enrollment first time, login when returning) and // establishes the session, then `next` sends the browser to authorize, // which now issues the code silently. Same cookie/state/PKCE round-trip — // this only inserts one hop ahead of authorize, so no `/if/flow/` renders. + // + // The source slug tracks `provider` (guarded to `google` above) so widening + // that guard cannot silently route a new provider through Google's source. const redirectUrl = - `${idpProxyPrefix()}/source/oauth/login/google/` + + `${idpProxyPrefix()}/source/oauth/login/${provider}/` + `?next=${encodeURIComponent(authorizePath)}` this.socialStates.set(state, { From d5d98d3bc11a29d6ab346a7651b499436db08297 Mon Sep 17 00:00:00 2001 From: "fuzeone-bot[bot]" Date: Thu, 16 Jul 2026 15:27:36 +0300 Subject: [PATCH 4/9] fix(e2e): run the security-service in the compose stack + route /api/v1/security MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The SPA was migrated onto the provider-agnostic Security API (/api/v1/security/*), served ONLY by backend/security. But no e2e stack ran that service: this compose builds backend/Dockerfile (the MONOLITH), which mounts /api/auth, /api/apps, /api/organizations and /api/v1/app-registry — not /api/v1/security. So in CI the SPA's login POST 404s and the sign-in e2e times out waiting for a response that can never arrive. That is why "E2E (sign-in)" has been red on master since the cutover, and red on EVERY pr since — including PRs that touch only a shell script and markdown. The tests were not flaky and the failure was not pre-existing noise: the security service, which now serves all SPA auth, has had ZERO e2e coverage. Adds the `security` service (Authentik + DB env — password sign-in is brokered server-side through the flow-executor, so it needs Authentik even for the plain email/password path) and routes /api/v1/security/ → security:3002 in the e2e nginx, mirroring the prod Ingress. The frontend now depends on it: nginx resolves upstreams at startup and refuses to boot on an unresolvable one. Prod was never affected — the k8s Ingress routes /api/v1/security → fuzefront-security ahead of nginx. Verified: docker compose -f docker-compose.e2e.yml config -> PARSE OK, `security` resolved nginx -t (upstreams host-mapped) -> syntax is ok, test successful Co-Authored-By: Claude Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7 --- deploy/e2e/nginx.e2e.conf | 25 ++++++++++++++-- docker-compose.e2e.yml | 61 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 3 deletions(-) diff --git a/deploy/e2e/nginx.e2e.conf b/deploy/e2e/nginx.e2e.conf index 8b20626f..8f4f3513 100644 --- a/deploy/e2e/nginx.e2e.conf +++ b/deploy/e2e/nginx.e2e.conf @@ -93,10 +93,29 @@ http { try_files $uri =404; } - # E2E: all /api/* routes to the single backend container. + # 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 them all to backend:3001 for the self-contained E2E stack. + # (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; diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 50807460..8c81c842 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -228,6 +228,63 @@ services: retries: 6 start_period: 30s + # ── Security service ────────────────────────────────────────────────────── + # The SPA signs in through the provider-agnostic Security API + # (/api/v1/security/*), which ONLY this service serves — the monolith `backend` + # above mounts /api/auth, /api/apps, /api/organizations, /api/v1/app-registry + # and nothing else. Without this container the SPA's login POST 404s, so the + # sign-in e2e times out waiting for a response that can never arrive. nginx + # (see deploy/e2e/nginx.e2e.conf) path-routes /api/v1/security/ here, mirroring + # the prod Ingress which sends /api/v1/security → fuzefront-security. + security: + build: + context: . + dockerfile: backend/security/Dockerfile + ports: + - '3002:3002' + environment: + NODE_ENV: production + USE_POSTGRES: 'true' + DB_HOST: postgres + DB_PORT: '5432' + DB_NAME: fuzefront_platform + DB_USER: e2e + DB_PASSWORD: e2e_pass + # Must match the monolith: both mint/validate the same platform JWT. + JWT_SECRET: e2e-jwt-secret-not-for-prod + PORT: '3002' + FRONTEND_URL: http://localhost:4173 + PERMIT_API_KEY: ci-noop + PERMIT_PDP_URL: http://localhost:7766 + # Password sign-in is brokered SERVER-side through Authentik's + # flow-executor (no browser redirect), so these are required even for the + # plain email/password e2e — not just the social flow. + AUTHENTIK_CLIENT_ID: fuzefront-oidc-client + AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_OIDC_CLIENT_SECRET:-e2e-oidc-client-secret} + AUTHENTIK_ISSUER_URL: ${AUTHENTIK_ISSUER_URL:-http://authentik-server:9000/application/o/fuzefront/} + # Server-side calls (flow-executor, token, userinfo, jwks) go over the + # compose network rather than back out through a published port. + AUTHENTIK_BASE_URL: http://authentik-server:9000 + # Native IdP paths — no reverse-proxy prefix (matches the prod ingress). + SECURITY_IDP_PROXY_PREFIX: '' + # Browser-facing social callback, on the APP origin: nginx routes + # /api/v1/security/ → this service. Path from + # providers/authentik/config.ts socialCallbackPath(). + AUTHENTIK_REDIRECT_URI: http://localhost:4173/api/v1/security/social/callback + networks: + - e2e + depends_on: + postgres: + condition: service_healthy + authentik-server: + condition: service_started + healthcheck: + test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3002/health'] + interval: 10s + timeout: 5s + retries: 6 + start_period: 30s + # ── Frontend ────────────────────────────────────────────────────────────── # Built from repo root (Dockerfile requires workspace context for @fuzefront/* aliases). frontend: @@ -254,3 +311,7 @@ services: depends_on: backend: condition: service_healthy + # nginx resolves upstreams at startup, so it fails to boot if `security` + # is not up yet — and without it the SPA cannot sign in at all. + security: + condition: service_healthy From b6a763aa686b5d1e0d0d0d4b024a0e9e526d2fad Mon Sep 17 00:00:00 2001 From: "fuzeone-bot[bot]" Date: Thu, 16 Jul 2026 15:33:43 +0300 Subject: [PATCH 5/9] fix(e2e): use a REGISTERED redirect_uri for the security service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-review catch: I had pointed the security container at http://localhost:4173/api/v1/security/social/callback, which is not in the OIDC provider's redirect_uris. Authentik matches those strict, so it would have rejected the authorize call and password login would still have failed — the container would be present but sign-in still broken. Password sign-in is a full authorize→code→exchange (services/authentikPassword.ts drives the flow-executor, then GETs the authorize URL with the session cookies and lifts `code` out of the 302 Location). The redirect_uri therefore has to be REGISTERED, but never has to be browser-reachable — the server follows the redirect itself. So reuse the shared, already-registered /api/auth/oidc/callback, which is exactly what prod's security service does (authentik.oidc.redirectUri → https://app.fuzefront.com/api/auth/oidc/callback). Registered set: authentik/blueprints/provider-oidc.yaml. Co-Authored-By: Claude Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7 --- docker-compose.e2e.yml | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 8c81c842..97442f73 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -267,10 +267,16 @@ services: AUTHENTIK_BASE_URL: http://authentik-server:9000 # Native IdP paths — no reverse-proxy prefix (matches the prod ingress). SECURITY_IDP_PROXY_PREFIX: '' - # Browser-facing social callback, on the APP origin: nginx routes - # /api/v1/security/ → this service. Path from - # providers/authentik/config.ts socialCallbackPath(). - AUTHENTIK_REDIRECT_URI: http://localhost:4173/api/v1/security/social/callback + # MUST be a redirect_uri registered on the OIDC provider (matching_mode: + # strict), or Authentik rejects the authorize call and password login + # fails — password sign-in is a full authorize→code→exchange, not just a + # flow-executor call. It does NOT need to be browser-reachable: the server + # follows that 302 itself and lifts `code` out of the Location header. + # So we reuse the shared, registered /api/auth/oidc/callback — exactly what + # prod's security service does (values.authentik.oidc.redirectUri points at + # https://app.fuzefront.com/api/auth/oidc/callback). The registered set + # lives in authentik/blueprints/provider-oidc.yaml. + AUTHENTIK_REDIRECT_URI: http://localhost:3001/api/auth/oidc/callback networks: - e2e depends_on: From d164f91e63e104940ed0beb65f90fb9f52f6df11 Mon Sep 17 00:00:00 2001 From: "fuzeone-bot[bot]" Date: Thu, 16 Jul 2026 15:45:24 +0300 Subject: [PATCH 6/9] fix(e2e): serialise migrations so the security container cannot kill the backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Regression I introduced in this PR: adding the security container made the OIDC-plumbing stack fail with dependency failed to start: container fuzefront-e2e-backend-1 exited (1) which the control run (same branch, before this container existed) did NOT do — there the stack came up and the test failed on its own merits (access_denied). Cause: backend/security/src/index.ts runs the SAME 001-009 migration chain against the SAME `knex_migrations` table as the monolith. Compose started both at once, they raced for the migration lock, and the loser exited 1 — taking the whole stack down, since frontend depends on it. Serialise: security now waits for backend to be healthy, so the chain is already applied and security's own run is a harmless no-op. Chain is postgres → backend → security → frontend (no cycle; compose config parses). Co-Authored-By: Claude Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7 --- docker-compose.e2e.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 97442f73..2c7eb8e5 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -284,6 +284,15 @@ services: condition: service_healthy authentik-server: condition: service_started + # Serialise migrations. This service runs the SAME 001-009 chain against + # the SAME `knex_migrations` table as the monolith (see + # backend/security/src/index.ts). Started concurrently, the two race for + # the migration lock and one dies — which is exactly what happened when + # this container was first added: `backend exited (1)` took the whole + # stack down with it. Waiting for backend to be healthy means the chain is + # already applied and this service's run is a no-op. + backend: + condition: service_healthy healthcheck: test: ['CMD', 'wget', '-q', '--spider', 'http://localhost:3002/health'] interval: 10s From 60f234ee93e9abee5e7cea349a97280a946af676 Mon Sep 17 00:00:00 2001 From: "fuzeone-bot[bot]" Date: Thu, 16 Jul 2026 15:59:16 +0300 Subject: [PATCH 7/9] test(e2e): cover real password sign-in through the Security API + Authentik MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sign-in has had NO honest CI coverage since the Security API cutover. .github/workflows/e2e.yml ("Playwright sign-in flow") cannot provide it, by construction: it starts the monolith with NO Authentik and seeds the account as a local bcrypt row. But the SPA signs in via /api/v1/security/session, brokered through Authentik — a bcrypt row in the platform DB is not a credential Authentik will accept, and the monolith does not even serve that route. So that job has been failing since the cutover for reasons no test change can fix. The plumbing workflow already has what is needed — full compose stack, Authentik provisioned, a real Authentik user — and (with the security service now in that stack) is the only place real sign-in CAN be exercised. So run it here instead of duplicating ~300 lines of Authentik provisioning into the other workflow. - auth-simple: take credentials from E2E_USER_EMAIL/E2E_USER_PASSWORD (defaults unchanged) so it can target the account that exists in the provider. - Seed the platform projection (users + personal org + owner membership) for that account. WorkspaceProvisioningGate only renders the shell once a personal org exists; a real login provisions it asynchronously, but Permit is a no-op and Kafka is absent in CI, so the gate would spin and the spec would fail for reasons unrelated to auth. Seeding by EMAIL is what makes this work: syncUserToDatabase matches on email and mints its own uuid, so the row is adopted by the login rather than duplicated. No password_hash — the credential lives in Authentik. - Verify the seed and fail loudly: a missing org looks identical to broken auth. A pass here means password sign-in genuinely works end to end: browser → Security API → Authentik flow-executor → code exchange → session → dashboard. Stacked on the security-service compose change and the endpoint repointing; both are prerequisites, so this branch carries them. Co-Authored-By: Claude Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7 --- .github/workflows/oidc-plumbing-e2e.yml | 76 +++++++++++++++++++++++++ frontend/tests/auth-simple.spec.ts | 12 +++- 2 files changed, 86 insertions(+), 2 deletions(-) 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/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 From 3912f662855b9da6e7cd04e3690f727294bdc2e0 Mon Sep 17 00:00:00 2001 From: "fuzeone-bot[bot]" Date: Thu, 16 Jul 2026 16:15:18 +0300 Subject: [PATCH 8/9] fix(e2e): make the SPA same-origin so it can actually reach the Security API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of the UI sign-in failures, found in the plumbing run: the frontend image was built with VITE_API_URL=http://localhost:3001, pointing the browser STRAIGHT AT THE MONOLITH and bypassing 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 the test died on waiting for getByRole('button', { name: /sign in with google/i }) Sign-in could not work in that stack at all — and it made the nginx route added alongside the security container dead code. Same-origin ('' → relative URLs) sends every call through this container's nginx, which path-routes /api/v1/security/ → security:3002 and the rest → backend:3001. That is also the documented contract (CLAUDE.md: "same-origin API base … never hard-code an absolute API host") and what prod does, so the e2e now exercises the shape we actually ship instead of one we never deploy. Also fixes my own bug from the previous commit: the new Security-API assertions pointed at BACKEND_URL (the monolith) and so failed in ~11ms with a 404. They now use SECURITY_URL, defaulting to the app origin — the same path the browser takes, which additionally proves the nginx routing. Co-Authored-By: Claude Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7 --- docker-compose.e2e.yml | 21 ++++++++++++++++----- frontend/tests/oidc-plumbing.e2e.spec.ts | 12 +++++++++--- 2 files changed, 25 insertions(+), 8 deletions(-) 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/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) From 01b16d54804c061f994f98a955335e2dddec3cd7 Mon Sep 17 00:00:00 2001 From: "fuzeone-bot[bot]" Date: Thu, 16 Jul 2026 16:32:06 +0300 Subject: [PATCH 9/9] fix(e2e): route Authentik's native paths so browser sign-in can complete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Next gap, exposed once the Security API itself started passing: the boundary model keeps the browser on the app origin — the security service returns SAME-ORIGIN redirects (/source/oauth/login/google/, /application/o/authorize/), never an absolute IdP host. Prod routes those via the fuzefront-authentik-idp Ingress; the e2e nginx routed NONE of them, so every such redirect 404'd and no browser sign-in could finish. The UI specs showed it as waiting for navigation to "http://authentik-server:9000/**" which by design never happens any more. Mirrors the prod Ingress path list (/application /if /source /flows /ws /- /outpost.goauthentik.io /api/v3 /static/dist /static/authentik) → authentik-server:9000. Two placement details that matter: - The block sits ABOVE the static-asset regexes. nginx evaluates regex locations in order, so Authentik's /static/dist/*.js would otherwise be swallowed by `~* \.(js|css|...)$` and try_files'd into a 404. - /api/v3 must outrank the /api/ catch-all or flow-executor calls hit FuzeFront's backend and fail with PROVIDER_UNAVAILABLE. Host is passed through unchanged and the proto pinned: Authentik ignores X-Forwarded-Prefix and derives callback_url from Host + X-Forwarded-Proto. Upgrade/Connection are deliberately NOT forwarded — prod's nginx.conf confines WebSocket upgrades to /socket.io/ to prevent H2C smuggling, and Authentik's /ws is admin live-updates that no e2e flow needs. Matching that posture rather than widening it. Verified: nginx -t (upstreams host-mapped) → syntax is ok, test is successful. Co-Authored-By: Claude Claude-Session-Id: cf830721-b1ef-4fe0-a024-035ad280dcf7 --- deploy/e2e/nginx.e2e.conf | 332 +++++++++++++++++++++----------------- 1 file changed, 182 insertions(+), 150 deletions(-) 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"; + } + } +}