diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c8d53331..64f17df7 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -190,6 +190,7 @@ jobs: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} FILE: deploy/helm/fuzefront/values-prod.yaml run: | + set -euo pipefail SHA="${{ steps.tag.outputs.sha }}" # Read-modify-write against CURRENT master, not this job's (stale) # checkout: the builds above take minutes, and pairing fresh blob-SHA @@ -199,14 +200,46 @@ jobs: RESP=$(gh api "repos/${GITHUB_REPOSITORY}/contents/${FILE}?ref=master") CUR_SHA=$(jq -r .sha <<<"$RESP") jq -r .content <<<"$RESP" | base64 -d > /tmp/vp-current.yaml - sed -E "s/^(\s*tag:).*/\1 ${SHA}/" /tmp/vp-current.yaml > /tmp/vp-new.yaml + # Bump ONLY the core-app image tags built by this workflow. A blanket + # `tag:` sed would also stomp deliberately-pinned services and the + # EXTERNAL images (authentik, unleash) whose tags are managed by hand. + # The tag: line must be the line IMMEDIATELY after the repository: + # line (true for every block in values-prod.yaml). Scoping `hot` to a + # single following line means a block that drops its tag: can never + # leak the bump onto a later, unrelated tag: (review finding). + awk -v sha="$SHA" ' + hot { + hot=0 + if ($0 ~ /^[[:space:]]*tag:/) { + match($0, /^[[:space:]]*/) + print substr($0, 1, RLENGTH) "tag: " sha + n++ + next + } + } + /repository: ghcr\.io\/izzywdev\/fuzefront-(backend|frontend|security-service|applications-service|clock-app)$/ { hot=1 } + { print } + END { print n+0 > "/tmp/bump-count" } + ' /tmp/vp-current.yaml > /tmp/vp-new.yaml + # Guard against silent no-ops: if the file layout drifts (quoted + # values, reordered keys, renamed registry path) the awk matches + # nothing and we would otherwise "succeed" while deploying stale + # tags (review finding). Exactly 5 core-app tags must be rewritten. + COUNT=$(cat /tmp/bump-count) + if [ "$COUNT" -ne 5 ]; then + echo "::error::expected 5 core-app tag rewrites in ${FILE}, got ${COUNT} — file layout changed; update the bump step" + exit 1 + fi if cmp -s /tmp/vp-current.yaml /tmp/vp-new.yaml; then echo "values-prod.yaml already at ${SHA} — nothing to bump" exit 0 fi - gh api -X PUT "repos/${GITHUB_REPOSITORY}/contents/${FILE}" \ + # No pipe here: a rejected PUT (e.g. ruleset denies the actor) must + # FAIL this step, not vanish into a downstream consumer's exit code. + NEW_COMMIT=$(gh api -X PUT "repos/${GITHUB_REPOSITORY}/contents/${FILE}" \ -f message="release: fuzefront images ${SHA} [skip ci]" \ -f branch=master \ -f sha="${CUR_SHA}" \ -f content="$(base64 -w0 /tmp/vp-new.yaml)" \ - --jq '.commit.sha' | xargs -I{} echo "Bumped via API commit {} (server-signed)" + --jq '.commit.sha') + echo "Bumped via API commit ${NEW_COMMIT}" diff --git a/backend/security/src/index.ts b/backend/security/src/index.ts index b340330a..ae9319b7 100644 --- a/backend/security/src/index.ts +++ b/backend/security/src/index.ts @@ -25,6 +25,10 @@ dotenv.config() const PORT = process.env.PORT || 3002 const app = createExpressApp({ serviceName: 'security-service' }) +// Behind the k8s ingress every request otherwise carries the ingress IP — +// trust the first proxy hop so req.ip (rate limiting, auth logs) reflects +// the real client from X-Forwarded-For. +app.set('trust proxy', 1) const httpServer = createServer(app) const startTime = Date.now() diff --git a/backend/security/src/routes/auth.ts b/backend/security/src/routes/auth.ts index fe7cd78f..4b2f64e7 100644 --- a/backend/security/src/routes/auth.ts +++ b/backend/security/src/routes/auth.ts @@ -1,5 +1,6 @@ import crypto from 'crypto' import express from 'express' +import rateLimit from 'express-rate-limit' import bcrypt from 'bcryptjs' import jwt from 'jsonwebtoken' import { v4 as uuidv4 } from 'uuid' @@ -7,6 +8,12 @@ import { db } from '../config/database' import { authenticateToken } from '../middleware/auth' import { User } from '../types/shared' import { oidcService } from '../services/oidc' +import { + authentikPasswordLogin, + InvalidCredentialsError, + AuthentikUnavailableError, + UnsupportedFlowStageError, +} from '../services/authentikPassword' import { runInternalProvision } from '../services/organizationProvisioning' @@ -360,7 +367,18 @@ router.post('/logout', authenticateToken, async (req: any, res) => { */ router.get('/oidc/login', async (req, res) => { const requestId = uuidv4().substring(0, 8) - console.log(`šŸ” [${requestId}] OIDC login request received`) + // Structured trace: enough to diagnose a broken handoff from pod logs alone + // (misconfigured issuer/redirect/frontend-base, or an uninitialized client + // whose discovery against Authentik failed at boot). + console.log('šŸ” OIDC login request received', { + requestId, + referer: req.get('Referer'), + configured: oidcService.isConfigured?.(), + initialized: oidcService.isInitialized?.(), + issuerUrl: process.env.AUTHENTIK_ISSUER_URL, + redirectUri: process.env.AUTHENTIK_REDIRECT_URI, + frontendBase: FRONTEND_BASE, + }) try { if (!oidcService.isConfigured()) { @@ -388,6 +406,139 @@ router.get('/oidc/login', async (req, res) => { } }) + +// Rate limit for the password endpoint: the flow-executor login is a +// credential-stuffing surface, so cap FAILED attempts per client before we +// ever contact Authentik (same express-rate-limit convention as +// tokenAuthRateLimiter). Successful sign-ins are never throttled. +const passwordLoginRateLimiter = rateLimit({ + windowMs: 5 * 60_000, + limit: 10, + // Count ONLY rejected credentials (401) against the budget: 503s from an + // Authentik outage or an MFA-required account must not lock users out. + skipSuccessfulRequests: true, + requestWasSuccessful: (_req, res) => res.statusCode !== 401, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many sign-in attempts. Try again later.' }, +}) + +/** + * @swagger + * /api/auth/oidc/password: + * post: + * summary: Password sign-in against Authentik (no redirect) + * description: > + * Authenticates email+password by driving Authentik's flow-executor API + * server-side, then completes the OIDC code exchange with the resulting + * Authentik session. Authentik remains the sole identity authority; the + * response shape matches /api/auth/login so the frontend treats both + * identically. + * tags: [Authentication] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, password] + * properties: + * email: { type: string } + * password: { type: string } + * responses: + * 200: + * description: Authenticated — platform JWT + user + * 400: + * description: Missing email or password + * 401: + * description: Invalid credentials + * 503: + * description: OIDC not configured, Authentik unreachable, or the + * account requires a browser flow (MFA/consent) + */ +router.post('/oidc/password', passwordLoginRateLimiter, async (req, res) => { + const requestId = uuidv4().substring(0, 8) + const { email, password } = req.body || {} + + console.log('šŸ” Authentik password login request', { + requestId, + hasEmail: !!email, + configured: oidcService.isConfigured?.(), + initialized: oidcService.isInitialized?.(), + }) + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password required' }) + } + if (!oidcService.isConfigured()) { + return res.status(503).json({ + error: + 'OIDC authentication not configured. Please set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET.', + }) + } + + try { + // Lazy re-init mirrors the monolith and /oidc/login: Authentik may not + // have been ready when this replica booted — self-heal here instead of + // 503ing until an SSO request happens to re-initialize the client. + if (!oidcService.isInitialized()) { + try { + await oidcService.initialize() + } catch (initErr) { + console.error('āŒ OIDC lazy init failed', JSON.stringify({ requestId, message: (initErr as Error).message?.replace(/[\r\n]+/g, ' ') })) + return res + .status(503) + .json({ error: 'Authentication service unavailable. Try again shortly.' }) + } + } + + const user = await authentikPasswordLogin(email, password) + + // Session + JWT minting — identical to the local login / OIDC callback. + const sessionId = uuidv4() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours + // This IS FuzeFront's identity service — the issuer of platform tokens + // (same mint as /login and the OIDC callback), not a product self-minting. + // nosemgrep: fuze-auth-self-minted-user-token, semgrep.fuze-auth-self-minted-user-token + const token = jwt.sign( + { userId: user.id, sessionId }, + process.env.JWT_SECRET!, + { expiresIn: '24h' } + ) + await db('sessions').insert({ + id: sessionId, + user_id: user.id, + expires_at: expiresAt, + }) + + selfHealProvisioningOnLogin(user.id) + + console.log('šŸŽ‰ Authentik password login successful', { requestId, userId: user.id }) + return res.json({ token, user, sessionId }) + } catch (error) { + if (error instanceof InvalidCredentialsError) { + console.log('āŒ Authentik rejected credentials', { requestId }) + return res.status(401).json({ error: 'Invalid credentials' }) + } + if (error instanceof UnsupportedFlowStageError) { + console.warn('āš ļø Unsupported Authentik flow stage', JSON.stringify({ requestId, message: error.message.replace(/[\r\n]+/g, ' ') })) + return res.status(503).json({ + error: + 'This account requires a browser sign-in flow (e.g. MFA). Use the SSO button instead.', + }) + } + if (error instanceof AuthentikUnavailableError) { + console.error('āŒ Authentik unavailable', JSON.stringify({ requestId, message: error.message.replace(/[\r\n]+/g, ' ') })) + return res + .status(503) + .json({ error: 'Authentication service unavailable. Try again shortly.' }) + } + console.error('āŒ Authentik password login error', { requestId }, error) + return res.status(500).json({ error: 'Authentication failed' }) + } +}) + /** * @swagger * /api/auth/oidc/callback: diff --git a/backend/security/src/services/authentikPassword.ts b/backend/security/src/services/authentikPassword.ts new file mode 100644 index 00000000..8b916594 --- /dev/null +++ b/backend/security/src/services/authentikPassword.ts @@ -0,0 +1,329 @@ +/** + * Server-side Authentik password authentication — no browser redirect. + * + * The login page shows native email/password fields; this service drives + * Authentik's flow-executor JSON API with those credentials, then completes a + * standard OIDC authorization-code + PKCE exchange using the authenticated + * Authentik session. Authentik therefore remains the SOLE identity authority + * (same as the redirect flow), and everything downstream — user sync into the + * platform DB, session/JWT minting — reuses the existing OIDC machinery. + * + * Flow: + * 1. GET /api/v3/flows/executor//?query= → identification stage + * 2. POST { component, uid_field: email [, password] } + * 3. POST { component, password } (separate password stage) + * 4. challenge "xak-flow-redirect" → Authentik session established + * 5. GET the OIDC authorize URL with the session cookies (implicit consent) + * → 302 …/api/auth/oidc/callback?code=…&state=… + * 6. oidcService.handleCallback(code, state, codeVerifier) → synced User + * + * Only single-factor identification/password flows are supported. Users with + * MFA or other stages configured must use the browser (Google/SSO) path — we + * fail closed with a clear error rather than trying to drive arbitrary stages. + */ +import { generators } from 'openid-client' +import { oidcService } from './oidc' +import { User } from '../types/shared' + +export class InvalidCredentialsError extends Error { + constructor(message = 'Invalid credentials') { + super(message) + this.name = 'InvalidCredentialsError' + } +} + +export class AuthentikUnavailableError extends Error { + constructor(message = 'Authentication service unavailable') { + super(message) + this.name = 'AuthentikUnavailableError' + } +} + +export class UnsupportedFlowStageError extends Error { + constructor(stage: string) { + super(`Unsupported Authentik flow stage: ${stage} (only identification+password is supported server-side)`) + this.name = 'UnsupportedFlowStageError' + } +} + +/** Minimal cookie jar for the short-lived per-login Authentik session. */ +class CookieJar { + private cookies = new Map() + + absorb(res: { headers: Headers }): void { + // Node >=18.14 exposes getSetCookie(); fall back to the single-value get() + // (sufficient in practice — Authentik sets one cookie per response hop). + const anyHeaders = res.headers as Headers & { getSetCookie?: () => string[] } + const setCookies: string[] = + typeof anyHeaders.getSetCookie === 'function' + ? anyHeaders.getSetCookie() + : ([res.headers.get('set-cookie')].filter(Boolean) as string[]) + for (const sc of setCookies) { + const pair = sc.split(';')[0] + const eq = pair.indexOf('=') + if (eq <= 0) continue + const name = pair.slice(0, eq).trim() + const value = pair.slice(eq + 1).trim() + if (value === '' || /max-age=0|expires=thu, 01 jan 1970/i.test(sc)) { + this.cookies.delete(name) + } else { + this.cookies.set(name, value) + } + } + } + + header(): string { + return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') + } + + get(name: string): string | undefined { + return this.cookies.get(name) + } +} + +function authentikBaseUrl(): string { + if (process.env.AUTHENTIK_BASE_URL) { + return process.env.AUTHENTIK_BASE_URL.replace(/\/$/, '') + } + const issuer = + process.env.AUTHENTIK_ISSUER_URL || + 'http://localhost:9000/application/o/fuzefront/' + return new URL(issuer).origin +} + +function authFlowSlug(): string { + return process.env.AUTHENTIK_AUTH_FLOW_SLUG || 'default-authentication-flow' +} + +function redirectUri(): string { + return ( + process.env.AUTHENTIK_REDIRECT_URI || + 'http://fuzefront.dev.local/api/auth/oidc/callback' + ) +} + +interface FlowChallenge { + component?: string + type?: string + to?: string + password_fields?: boolean + response_errors?: Record> + [key: string]: unknown +} + +async function flowRequest( + base: string, + slug: string, + jar: CookieJar, + body?: Record +): Promise { + // Authentik commonly answers the first executor request with a 302 that + // establishes the session cookie (Location points back into the flow), so + // follow same-origin redirects manually, carrying the jar. Per Django 302 + // semantics a redirected POST is retried as GET. + let url = `${base}/api/v3/flows/executor/${slug}/?query=` + let method: 'GET' | 'POST' = body ? 'POST' : 'GET' + let payload: string | undefined = body ? JSON.stringify(body) : undefined + + for (let hop = 0; hop < 10; hop++) { + const headers: Record = { + Accept: 'application/json', + // Django CSRF validates Referer on secure requests. + Referer: `${base}/`, + } + const cookie = jar.header() + if (cookie) headers['Cookie'] = cookie + if (method === 'POST') { + headers['Content-Type'] = 'application/json' + const csrf = jar.get('authentik_csrf') + if (csrf) headers['X-CSRFToken'] = csrf + } + + let res: Response + try { + res = await fetch(url, { + method, + headers, + body: payload, + redirect: 'manual', + }) + } catch (err) { + throw new AuthentikUnavailableError( + `Authentik unreachable at ${base}: ${(err as Error).message}` + ) + } + jar.absorb(res) + + const loc = res.headers.get('location') + if ([301, 302, 303, 307, 308].includes(res.status) && loc) { + const nextUrl = new URL(loc, url) + // The jar carries authentik_session/authentik_csrf — never present those + // cookies to any host other than Authentik itself. + if (nextUrl.origin !== new URL(base).origin) { + throw new AuthentikUnavailableError( + `Flow executor redirected off-origin to ${nextUrl.origin} — refusing to follow with session cookies` + ) + } + url = nextUrl.toString() + // 301/302/303 rewrite the retry as GET (Django semantics); 307/308 + // preserve the original method and body per HTTP spec. + if (res.status !== 307 && res.status !== 308) { + method = 'GET' + payload = undefined + } + continue + } + const contentTypeEarly = res.headers.get('content-type') || '' + if (!res.ok) { + // A 4xx with a JSON body is a FLOW response (e.g. 400 carrying + // response_errors for rejected credentials) — return it so the caller + // maps it to 401, instead of mislabeling it a 503 outage. + if (res.status < 500 && contentTypeEarly.includes('json')) { + return (await res.json()) as FlowChallenge + } + // Surface Authentik's own error payload — a bare status is undebuggable + // from CI logs (e.g. 403 CSRF vs 404 unknown flow slug). + const bodySnippet = (await res.text().catch(() => '')).slice(0, 300) + throw new AuthentikUnavailableError( + `Authentik flow executor HTTP ${res.status} at ${url}: ${bodySnippet}` + ) + } + if (!contentTypeEarly.includes('json')) { + const bodySnippet = (await res.text().catch(() => '')).slice(0, 300) + throw new AuthentikUnavailableError( + `Authentik flow executor returned non-JSON (${contentTypeEarly}) at ${url}: ${bodySnippet}` + ) + } + return (await res.json()) as FlowChallenge + } + throw new AuthentikUnavailableError('Authentik flow executor redirect loop') +} + +function challengeHasCredentialErrors(challenge: FlowChallenge): boolean { + const errs = challenge.response_errors + if (!errs) return false + return Object.keys(errs).length > 0 +} + +/** + * Authenticate email+password against Authentik and return the synced platform + * User. Throws InvalidCredentialsError / AuthentikUnavailableError / + * UnsupportedFlowStageError. + */ +export async function authentikPasswordLogin( + email: string, + password: string +): Promise { + if (!oidcService.isConfigured() || !oidcService.isInitialized()) { + throw new AuthentikUnavailableError('OIDC is not configured/initialized') + } + + const base = authentikBaseUrl() + const slug = authFlowSlug() + const jar = new CookieJar() + + // ── Drive the authentication flow ───────────────────────────────────────── + let challenge = await flowRequest(base, slug, jar) + const MAX_STEPS = 6 + let authenticated = false + + for (let step = 0; step < MAX_STEPS; step++) { + const component = challenge.component || challenge.type || '' + + if (component === 'xak-flow-redirect') { + authenticated = true + break + } + + if (component === 'ak-stage-identification') { + const body: Record = { + component, + uid_field: email, + } + // Combined identification+password stage + if (challenge.password_fields) body.password = password + challenge = await flowRequest(base, slug, jar, body) + } else if (component === 'ak-stage-password') { + challenge = await flowRequest(base, slug, jar, { component, password }) + } else if (component === 'ak-stage-access-denied') { + throw new InvalidCredentialsError() + } else { + // MFA, consent, prompts, … — not driveable server-side. + throw new UnsupportedFlowStageError(component || 'unknown') + } + + if (challengeHasCredentialErrors(challenge)) { + throw new InvalidCredentialsError() + } + } + + if (!authenticated) { + const last = challenge.component || challenge.type || 'unknown' + if (last !== 'xak-flow-redirect') { + throw new UnsupportedFlowStageError(last) + } + } + + // ── Complete OIDC code+PKCE with the authenticated session ──────────────── + const state = generators.state() + const { url: authorizeUrl, codeVerifier } = oidcService.generateAuthUrl(state) + const target = redirectUri() + + let location = authorizeUrl + let code: string | null = null + let returnedState: string | null = null + + for (let hop = 0; hop < 10; hop++) { + let res: Response + try { + res = await fetch(location, { + method: 'GET', + headers: { Cookie: jar.header(), Accept: 'application/json' }, + redirect: 'manual', + }) + } catch (err) { + throw new AuthentikUnavailableError( + `Authorize request failed: ${(err as Error).message}` + ) + } + jar.absorb(res) + + const next = res.headers.get('location') + if (!next) { + // 200 here means Authentik rendered a flow UI (consent / re-auth) — + // implicit consent is expected on the FuzeFront provider. + throw new UnsupportedFlowStageError( + `authorize returned HTTP ${res.status} without redirect (consent flow?)` + ) + } + const resolvedUrl = new URL(next, location) + const resolved = resolvedUrl.toString() + if (resolved.startsWith(target)) { + const u = new URL(resolved) + code = u.searchParams.get('code') + returnedState = u.searchParams.get('state') + const err = u.searchParams.get('error') + if (err) { + throw new AuthentikUnavailableError(`Authorize error: ${err}`) + } + break + } + // Continue only within Authentik's own origin — the jar must not follow + // an arbitrary redirect elsewhere. + if (resolvedUrl.origin !== new URL(base).origin) { + throw new AuthentikUnavailableError( + `Authorize flow redirected off-origin to ${resolvedUrl.origin} — refusing to follow with session cookies` + ) + } + location = resolved + } + + if (!code) { + throw new AuthentikUnavailableError( + 'Authorize flow did not produce an authorization code' + ) + } + + // Token exchange + user sync — identical to the redirect callback path. + return oidcService.handleCallback(code, returnedState || state, codeVerifier) +} diff --git a/backend/security/tests/authentik-password-login.test.ts b/backend/security/tests/authentik-password-login.test.ts new file mode 100644 index 00000000..8c14f167 --- /dev/null +++ b/backend/security/tests/authentik-password-login.test.ts @@ -0,0 +1,310 @@ +/** + * Unit tests for the server-side Authentik password login + * (services/authentikPassword.ts). + * + * The flow-executor conversation and the authorize redirect are simulated by + * mocking global.fetch — no network. The OIDC pieces (authorize URL, token + * exchange/user sync) are mocked at the oidcService boundary, mirroring how + * the redirect flow's tests isolate openid-client. + */ + +jest.mock('../src/services/oidc', () => ({ + oidcService: { + isConfigured: jest.fn().mockReturnValue(true), + isInitialized: jest.fn().mockReturnValue(true), + generateAuthUrl: jest.fn().mockReturnValue({ + url: 'http://auth.example.test/application/o/authorize/?client_id=x&state=st', + codeVerifier: 'test-code-verifier', + }), + handleCallback: jest.fn().mockResolvedValue({ + id: 'user-1', + email: 'e2e@test.local', + firstName: 'E2E', + lastName: 'User', + roles: ['user'], + }), + }, +})) + +import { + authentikPasswordLogin, + InvalidCredentialsError, + AuthentikUnavailableError, + UnsupportedFlowStageError, +} from '../src/services/authentikPassword' +import { oidcService } from '../src/services/oidc' + +const REDIRECT_URI = 'http://fuzefront.test.local/api/auth/oidc/callback' + +/** Build a minimal fetch Response stand-in. */ +function mkRes(opts: { + status?: number + json?: unknown + setCookies?: string[] + location?: string +}) { + const headerMap = new Map() + if (opts.location) headerMap.set('location', opts.location) + if (opts.json !== undefined) headerMap.set('content-type', 'application/json') + return { + ok: (opts.status ?? 200) >= 200 && (opts.status ?? 200) < 300, + status: opts.status ?? 200, + headers: { + get: (name: string) => headerMap.get(name.toLowerCase()) ?? null, + getSetCookie: () => opts.setCookies ?? [], + }, + json: async () => opts.json ?? {}, + text: async () => JSON.stringify(opts.json ?? ''), + } as unknown as Response +} + +describe('authentikPasswordLogin()', () => { + const savedEnv = { ...process.env } + let fetchMock: jest.Mock + + beforeEach(() => { + jest.clearAllMocks() + process.env.AUTHENTIK_ISSUER_URL = + 'http://auth.example.test/application/o/fuzefront/' + process.env.AUTHENTIK_REDIRECT_URI = REDIRECT_URI + delete process.env.AUTHENTIK_BASE_URL + delete process.env.AUTHENTIK_AUTH_FLOW_SLUG + ;(oidcService.isConfigured as jest.Mock).mockReturnValue(true) + ;(oidcService.isInitialized as jest.Mock).mockReturnValue(true) + fetchMock = jest.fn() + ;(global as any).fetch = fetchMock + }) + + afterAll(() => { + process.env = savedEnv + }) + + it('drives identification → password → redirect, then exchanges the authorize code', async () => { + fetchMock + // 1. GET flow → identification stage (+ CSRF cookie) + .mockResolvedValueOnce( + mkRes({ + json: { component: 'ak-stage-identification', password_fields: false }, + setCookies: ['authentik_csrf=csrf-tok; Path=/'], + }) + ) + // 2. POST identification → password stage + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + // 3. POST password → flow complete (+ session cookie) + .mockResolvedValueOnce( + mkRes({ + json: { component: 'xak-flow-redirect', to: '/' }, + setCookies: ['authentik_session=sess-1; Path=/; HttpOnly'], + }) + ) + // 4. GET authorize → 302 straight to our callback with the code + .mockResolvedValueOnce( + mkRes({ + status: 302, + location: `${REDIRECT_URI}?code=the-code&state=st`, + }) + ) + + const user = await authentikPasswordLogin('e2e@test.local', 'pw123') + + expect(user.email).toBe('e2e@test.local') + expect(oidcService.handleCallback).toHaveBeenCalledWith( + 'the-code', + 'st', + 'test-code-verifier' + ) + + // Identification POST carried the uid_field + CSRF header + cookie jar. + const [, identInit] = fetchMock.mock.calls[1] + expect(JSON.parse(identInit.body)).toMatchObject({ + component: 'ak-stage-identification', + uid_field: 'e2e@test.local', + }) + expect(identInit.headers['X-CSRFToken']).toBe('csrf-tok') + + // Authorize GET presented the authenticated session cookie. + const [authorizeUrl, authorizeInit] = fetchMock.mock.calls[3] + expect(authorizeUrl).toContain('/application/o/authorize/') + expect(authorizeInit.headers.Cookie).toContain('authentik_session=sess-1') + }) + + it('follows the session-establishing 302 before the first challenge', async () => { + fetchMock + // initial GET -> 302 back into the flow, setting session + csrf cookies + .mockResolvedValueOnce( + mkRes({ + status: 302, + location: + 'http://auth.example.test/api/v3/flows/executor/default-authentication-flow/?query=', + setCookies: [ + 'authentik_session=pre-sess; Path=/', + 'authentik_csrf=csrf-tok; Path=/', + ], + }) + ) + // redirected GET -> identification challenge + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + .mockResolvedValueOnce( + mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) + ) + .mockResolvedValueOnce( + mkRes({ status: 302, location: `${REDIRECT_URI}?code=c3&state=st` }) + ) + + const user = await authentikPasswordLogin('e2e@test.local', 'pw123') + expect(user.email).toBe('e2e@test.local') + + // The identification POST happened AFTER the redirect hop, with cookies. + const [identUrl, identInit] = fetchMock.mock.calls[2] + expect(identUrl).toContain('/flows/executor/') + expect(identInit.headers.Cookie).toContain('authentik_session=pre-sess') + expect(identInit.headers['X-CSRFToken']).toBe('csrf-tok') + }) + + it('supports a combined identification+password stage (password_fields: true)', async () => { + fetchMock + .mockResolvedValueOnce( + mkRes({ + json: { component: 'ak-stage-identification', password_fields: true }, + }) + ) + .mockResolvedValueOnce( + mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) + ) + .mockResolvedValueOnce( + mkRes({ status: 302, location: `${REDIRECT_URI}?code=c2&state=st` }) + ) + + await authentikPasswordLogin('e2e@test.local', 'pw123') + + const [, identInit] = fetchMock.mock.calls[1] + expect(JSON.parse(identInit.body)).toMatchObject({ + uid_field: 'e2e@test.local', + password: 'pw123', + }) + }) + + it('throws InvalidCredentialsError when the password stage reports response_errors', async () => { + fetchMock + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + .mockResolvedValueOnce( + mkRes({ + json: { + component: 'ak-stage-password', + response_errors: { + password: [{ string: 'Invalid password', code: 'invalid' }], + }, + }, + }) + ) + + await expect( + authentikPasswordLogin('e2e@test.local', 'wrong') + ).rejects.toBeInstanceOf(InvalidCredentialsError) + expect(oidcService.handleCallback).not.toHaveBeenCalled() + }) + + it('maps a 4xx JSON flow response carrying response_errors to InvalidCredentialsError', async () => { + fetchMock + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + // Authentik rejects the credentials with an HTTP 400 + JSON errors body + .mockResolvedValueOnce( + mkRes({ + status: 400, + json: { + component: 'ak-stage-password', + response_errors: { + password: [{ string: 'Invalid password', code: 'invalid' }], + }, + }, + }) + ) + + await expect( + authentikPasswordLogin('e2e@test.local', 'wrong') + ).rejects.toBeInstanceOf(InvalidCredentialsError) + }) + + it('throws InvalidCredentialsError on an access-denied stage', async () => { + fetchMock.mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-access-denied' } }) + ) + + await expect( + authentikPasswordLogin('nobody@test.local', 'pw') + ).rejects.toBeInstanceOf(InvalidCredentialsError) + }) + + it('fails closed on stages it cannot drive (e.g. MFA)', async () => { + fetchMock + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-authenticator-validate' } }) + ) + + await expect( + authentikPasswordLogin('mfa@test.local', 'pw') + ).rejects.toBeInstanceOf(UnsupportedFlowStageError) + }) + + it('refuses to follow an off-origin redirect with session cookies', async () => { + fetchMock.mockResolvedValueOnce( + mkRes({ + status: 302, + location: 'http://evil.example.net/steal', + setCookies: ['authentik_session=sess; Path=/'], + }) + ) + + await expect( + authentikPasswordLogin('e2e@test.local', 'pw') + ).rejects.toBeInstanceOf(AuthentikUnavailableError) + // No request was made to the off-origin host. + expect(fetchMock).toHaveBeenCalledTimes(1) + }) + + it('throws AuthentikUnavailableError when Authentik is unreachable', async () => { + fetchMock.mockRejectedValueOnce(new Error('ECONNREFUSED')) + + await expect( + authentikPasswordLogin('e2e@test.local', 'pw') + ).rejects.toBeInstanceOf(AuthentikUnavailableError) + }) + + it('throws AuthentikUnavailableError when OIDC is not initialized', async () => { + ;(oidcService.isInitialized as jest.Mock).mockReturnValue(false) + + await expect( + authentikPasswordLogin('e2e@test.local', 'pw') + ).rejects.toBeInstanceOf(AuthentikUnavailableError) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it('fails when authorize renders a flow UI instead of redirecting (consent required)', async () => { + fetchMock + .mockResolvedValueOnce( + mkRes({ json: { component: 'ak-stage-identification' } }) + ) + .mockResolvedValueOnce(mkRes({ json: { component: 'ak-stage-password' } })) + .mockResolvedValueOnce( + mkRes({ json: { component: 'xak-flow-redirect', to: '/' } }) + ) + // authorize returns 200 HTML (no Location) — consent flow not implicit + .mockResolvedValueOnce(mkRes({ status: 200 })) + + await expect( + authentikPasswordLogin('e2e@test.local', 'pw') + ).rejects.toBeInstanceOf(UnsupportedFlowStageError) + }) +}) diff --git a/backend/src/index.ts b/backend/src/index.ts index dbcbb2ca..ebc12788 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -1,580 +1,584 @@ -// FuzeFront Backend - Updated 2025-06-19 13:15 - Auth & Health Fix -import express from 'express' -import cors from 'cors' -import helmet from 'helmet' -import { createServer } from 'http' -import dotenv from 'dotenv' - -// Import routes -import authRoutes from './routes/auth' -import appsRoutes from './routes/apps' -import organizationsRoutes from './routes/organizations' -import internalRoutes from './routes/internal' -import billingRoutes, { billingWebhookRouter } from './routes/billing' -import appRegistryRoutes from './routes/appRegistry' -import appRegistryProxyRoutes from './routes/app-registry' -import { initializeSocketIO } from './sockets/socketHandler' -import { - initializeDatabase, - closeDatabase, - checkDatabaseHealth, -} from './config/database' -import { oidcService } from './services/oidc' -import { setupMetrics } from './metrics' -import { provisionM2MClients } from './authentik/provision-m2m-clients' - -// Load environment variables -dotenv.config() - -// Prometheus metrics (Phase E). Scraped at /metrics; gracefully degrades to a -// 503 if prom-client is not installed. -const metrics = setupMetrics() - -// Extend Express Request interface to include requestId -declare global { - namespace Express { - interface Request { - requestId?: string - } - } -} - -const app = express() -const httpServer = createServer(app) -const PORT = process.env.PORT || 3001 - -// Initialize Socket.IO -const io = initializeSocketIO(httpServer) - -// Make io available to routes -app.set('io', io) - -// Middleware -app.use( - helmet({ - contentSecurityPolicy: { - directives: { - defaultSrc: ["'self'"], - frameSrc: ["'self'", '*'], // Allow iframes for microfrontends - scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // Allow scripts for dynamic loading - }, - }, - }) -) - -app.use( - cors({ - origin: [ - process.env.FRONTEND_URL || 'http://localhost:5173', - 'http://localhost:8085', // Production frontend external URL - 'http://localhost:3004', // Allow calls from external backend port - 'http://fuzefront-frontend-prod:8080', // Internal container URL - ], - credentials: true, - }) -) - -// Stripe webhook passthrough MUST be mounted before the global JSON body parser -// so the raw signed bytes survive for downstream signature verification. It uses -// its own express.raw() parser. (See routes/billing.ts.) -app.use('/api/v1/billing/webhooks/stripe', billingWebhookRouter) - -app.use(express.json()) -app.use(express.urlencoded({ extended: true })) - -// Enhanced request logging middleware -app.use((req, res, next) => { - const requestId = require('uuid').v4().substring(0, 8) - const startTime = Date.now() - - // Add request ID to request object for tracking - req.requestId = requestId - - console.log(`šŸ“„ [${requestId}] ${req.method} ${req.path}`, { - timestamp: new Date().toISOString(), - ip: req.ip || req.connection.remoteAddress, - userAgent: req.get('User-Agent'), - origin: req.get('Origin'), - referer: req.get('Referer'), - contentType: req.get('Content-Type'), - contentLength: req.get('Content-Length'), - authorization: req.get('Authorization') ? 'Bearer ***' : 'none', - query: Object.keys(req.query).length > 0 ? req.query : 'none', - bodySize: req.body ? JSON.stringify(req.body).length : 0, - }) - - // Log response when it finishes - const originalSend = res.send - res.send = function (data) { - const responseTime = Date.now() - startTime - console.log( - `šŸ“¤ [${requestId}] ${req.method} ${req.path} - ${res.statusCode}`, - { - responseTime: `${responseTime}ms`, - statusCode: res.statusCode, - contentType: res.get('Content-Type'), - responseSize: data ? data.length : 0, - } - ) - return originalSend.call(this, data) - } - - next() -}) - -// Prometheus request metrics (records method/route/status + duration). -app.use(metrics.middleware) -// Expose /metrics for the Prometheus scrape. -metrics.registerEndpoint(app) - -// Setup Swagger documentation -try { - // Only import and setup Swagger if packages are available - const { specs, swaggerUi } = require('./config/swagger.js') - - /** - * @swagger - * tags: - * - name: Authentication - * description: User authentication and session management - * - name: Applications - * description: Microfrontend application management - * - name: Health - * description: System health and status endpoints - */ - - app.use( - '/api-docs', - swaggerUi.serve, - swaggerUi.setup(specs, { - explorer: true, - customCss: '.swagger-ui .topbar { display: none }', - customSiteTitle: 'FrontFuse API Documentation', - swaggerOptions: { - persistAuthorization: true, - displayRequestDuration: true, - filter: true, - showExtensions: true, - showCommonExtensions: true, - }, - }) - ) - - console.log( - 'šŸ“š Swagger documentation available at http://localhost:' + - PORT + - '/api-docs' - ) -} catch (error) { - console.warn( - 'āš ļø Swagger documentation not available (packages not installed)' - ) - - // Provide a simple fallback API documentation - app.get('/api-docs', (req, res) => { - res.send(` - - - - FrontFuse API Documentation - - - -

šŸš€ FrontFuse API Documentation

-

Welcome to the FrontFuse Platform API. This is a simplified documentation view.

- -

šŸ” Authentication Endpoints

-
-
POST /api/auth/login
-

Authenticate user with email and password

-
-{
-  "email": "admin@frontfuse.dev",
-  "password": "admin123"
-}
-          
-
- -
-
GET /api/auth/user
-

Get current authenticated user information

-

Requires: Authorization: Bearer <token>

-
- -
-
POST /api/auth/logout
-

Logout current user and invalidate session

-

Requires: Authorization: Bearer <token>

-
- -

šŸ“± Application Management

-
-
GET /api/apps
-

Get list of all registered applications

-

Requires: Authorization: Bearer <token>

-
- -
-
POST /api/apps
-

Register a new microfrontend application (Admin only)

-

Requires: Authorization: Bearer <token>

-
-{
-  "name": "My App",
-  "url": "https://my-app.netlify.app",
-  "integrationType": "module-federation",
-  "remoteUrl": "https://my-app.netlify.app/assets/remoteEntry.js",
-  "scope": "myApp",
-  "module": "./App"
-}
-          
-
- -

šŸ’“ Health & Monitoring

-
-
GET /health
-

Platform health check endpoint

-

No authentication required

-
- -
-
POST /api/apps/:id/heartbeat
-

Application heartbeat endpoint

-
- -

šŸ”‘ Authentication

-

Most endpoints require a JWT token in the Authorization header:

-
Authorization: Bearer <your-jwt-token>
- -

šŸ“ž Support

-

For full interactive documentation, install swagger packages:

-
npm install swagger-ui-express swagger-jsdoc
- -

For support: support@frontfuse.dev

- - - `) - }) - - console.log( - 'šŸ“š Basic API documentation available at http://localhost:' + - PORT + - '/api-docs' - ) -} - -// Routes -app.use('/api/auth', authRoutes) -app.use('/api/apps', appsRoutes) -app.use('/api/organizations', organizationsRoutes) -// Billing proxy: browser -> backend -> fuzefront-billing-service:3006 (adds the -// internal token). Webhook subroute is mounted separately above (raw body). -app.use('/api/v1/billing', billingRoutes) -// App-registry: CI/local uses a direct DB adapter (routes/appRegistry); prod uses a -// proxy to the applications-service (routes/app-registry). Mount adapter first so CI -// env (no applications-service) is served from the local DB, then the proxy handles -// any requests the adapter passes through via next(). -app.use('/api/v1/app-registry', appRegistryRoutes) -// App-registry proxy: browser -> backend -> fuzefront-applications:3003. The -// ingress `/api` catch-all + frontend nginx both route the manifest-shaped -// `/api/v1/app-registry/*` here, so without this the registry client 404s and no -// federated app (e.g. the built-in Clock) can mount. Forwards the platform JWT -// verbatim; the applications-service does its own authn/authz. -app.use('/api/v1/app-registry', appRegistryProxyRoutes) -// Internal, secret-guarded provisioning endpoint (NOT exposed via public ingress). -app.use('/internal', internalRoutes) - -// Serve static documentation files -app.use('/docs', express.static('docs')) - -// User info route -app.get('/api/user', (req, res) => { - // This will be handled by the auth middleware in production - res.json({ message: 'User endpoint - use /auth/user instead' }) -}) - -/** - * @swagger - * /health: - * get: - * summary: Health check endpoint - * description: Check if the FrontFuse platform is running and get system information - * tags: [Health] - * security: [] - * responses: - * 200: - * description: Platform is healthy - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/HealthResponse' - * example: - * status: "ok" - * timestamp: "2024-01-01T12:00:00.000Z" - * uptime: 3600 - * version: "1.0.0" - * environment: "development" - * memory: - * used: 45 - * total: 128 - */ -// Health check -const startTime = Date.now() - -// Main health check endpoint (without /api prefix) -app.get('/health', async (req, res) => { - const uptime = Math.floor((Date.now() - startTime) / 1000) - const dbHealthy = await checkDatabaseHealth() - - res.json({ - status: dbHealthy ? 'ok' : 'degraded', - timestamp: new Date().toISOString(), - uptime: uptime, - version: process.env.npm_package_version || '1.0.0', - environment: process.env.NODE_ENV || 'development', - database: { - status: dbHealthy ? 'connected' : 'disconnected', - type: 'PostgreSQL', - host: process.env.DB_HOST || 'localhost', - database: process.env.DB_NAME || 'fuzefront_platform', - }, - memory: { - used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), - total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), - }, - }) -}) - -// Add /api/health endpoint to match frontend expectations -app.get('/api/health', async (req, res) => { - const uptime = Math.floor((Date.now() - startTime) / 1000) - const dbHealthy = await checkDatabaseHealth() - - res.json({ - status: dbHealthy ? 'ok' : 'degraded', - timestamp: new Date().toISOString(), - uptime: uptime, - version: process.env.npm_package_version || '1.0.0', - environment: process.env.NODE_ENV || 'development', - database: { - status: dbHealthy ? 'connected' : 'disconnected', - type: 'PostgreSQL', - host: process.env.DB_HOST || 'localhost', - database: process.env.DB_NAME || 'fuzefront_platform', - }, - memory: { - used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), - total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), - }, - }) -}) - -// Error handling middleware -app.use((err: any, req: any, res: any, next: any) => { - console.error(err.stack) - res.status(500).json({ error: 'Something went wrong!' }) -}) - -// 404 handler -app.use((req, res) => { - res.status(404).json({ error: 'Not found' }) -}) - -// Graceful shutdown function -function gracefulShutdown(signal: string) { - console.log(`\nšŸ›‘ Received ${signal}. Starting graceful shutdown...`) - - httpServer.close(err => { - if (err) { - console.error('āŒ Error during server shutdown:', err) - process.exit(1) - } - - console.log('āœ… HTTP server closed') - - // Close Socket.IO connections - io.close(async () => { - console.log('āœ… Socket.IO server closed') - - // Close database connections - try { - await closeDatabase() - console.log('āœ… Database connections closed') - } catch (error) { - console.error('āŒ Error closing database:', error) - } - - console.log('šŸŽÆ Graceful shutdown complete') - process.exit(0) - }) - }) - - // Force exit after 30 seconds if graceful shutdown fails - setTimeout(() => { - console.error('ā° Graceful shutdown timeout - forcing exit') - process.exit(1) - }, 30000) -} - -// Register shutdown handlers -process.on('SIGTERM', () => gracefulShutdown('SIGTERM')) -process.on('SIGINT', () => gracefulShutdown('SIGINT')) - -// Handle uncaught exceptions -process.on('uncaughtException', err => { - console.error('šŸ’„ Uncaught Exception:', err) - gracefulShutdown('uncaughtException') -}) - -// Handle unhandled promise rejections -process.on('unhandledRejection', (reason, promise) => { - console.error('🚨 Unhandled Rejection at:', promise, 'reason:', reason) - gracefulShutdown('unhandledRejection') -}) - -// Function to find available port -async function findAvailablePort( - startPort: number, - maxAttempts: number = 10 -): Promise { - return new Promise((resolve, reject) => { - const currentPort = startPort - let attempts = 0 - - function tryPort(port: number) { - const testServer = require('net').createServer() - - testServer.listen(port, (err: any) => { - if (err) { - testServer.close() - attempts++ - - if (attempts >= maxAttempts) { - reject( - new Error( - `No available port found after ${maxAttempts} attempts starting from ${startPort}` - ) - ) - return - } - - console.log(`āš ļø Port ${port} is busy, trying ${port + 1}...`) - tryPort(port + 1) - } else { - testServer.close(() => { - resolve(port) - }) - } - }) - - testServer.on('error', (err: any) => { - testServer.close() - attempts++ - - if (attempts >= maxAttempts) { - reject( - new Error( - `No available port found after ${maxAttempts} attempts starting from ${startPort}` - ) - ) - return - } - - console.log(`āš ļø Port ${port} is busy, trying ${port + 1}...`) - tryPort(port + 1) - }) - } - - tryPort(currentPort) - }) -} - -// Start server with port conflict handling -async function startServer() { - try { - // Initialize database first - console.log('šŸ”„ Starting FuzeFront Backend Server...') - await initializeDatabase() - - // Initialize OIDC service - try { - console.log('šŸ”§ Initializing OIDC service...') - if (oidcService.isConfigured()) { - await oidcService.initialize() - console.log('āœ… OIDC service initialized successfully') - } else { - console.log('āš ļø OIDC service not configured - local auth only') - console.log('šŸ’” Set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET to enable OIDC') - } - } catch (error) { - console.error('āŒ Failed to initialize OIDC service:', error) - console.log('āš ļø Continuing with local authentication only') - } - - // Provision Authentik M2M clients (idempotent; errors are non-fatal) - await provisionM2MClients() - - const portNumber = typeof PORT === 'string' ? parseInt(PORT, 10) : PORT - const availablePort = await findAvailablePort(portNumber) - - if (availablePort !== portNumber) { - console.log( - `šŸ”„ Original port ${portNumber} was busy, using port ${availablePort} instead` - ) - } - - httpServer.listen(availablePort, () => { - console.log( - `šŸš€ FuzeFront backend server running on port ${availablePort}` - ) - console.log( - `🌐 Frontend URL: ${process.env.FRONTEND_URL || 'http://localhost:5173'}` - ) - console.log(`šŸ“” WebSocket server ready`) - console.log( - `šŸ“š API Documentation: http://localhost:${availablePort}/api-docs` - ) - console.log(`šŸ’“ Health Check: http://localhost:${availablePort}/health`) - console.log(`šŸ—„ļø Database: PostgreSQL (shared-postgres)`) - - // Log authentication methods available - const authMethods = ['Local Database'] - if (oidcService.isConfigured()) { - authMethods.push('OIDC (Authentik)') - } - console.log(`šŸ” Authentication: ${authMethods.join(', ')}`) - - // Update PORT variable for other parts of the app - process.env.PORT = availablePort.toString() - }) - - httpServer.on('error', (err: any) => { - if (err.code === 'EADDRINUSE') { - console.error(`āŒ Port ${availablePort} is already in use`) - console.log( - 'šŸ’” This might happen if another instance is already running' - ) - console.log('šŸ’” Try stopping other instances or use a different port') - gracefulShutdown('EADDRINUSE') - } else { - console.error('āŒ Server error:', err) - gracefulShutdown('ServerError') - } - }) - } catch (error) { - console.error('āŒ Failed to start server:', error) - console.log('šŸ’” Please check if ports 3001-3010 are available') - process.exit(1) - } -} - -// Start the server -startServer() - -export default app +// FuzeFront Backend - Updated 2025-06-19 13:15 - Auth & Health Fix +import express from 'express' +import cors from 'cors' +import helmet from 'helmet' +import { createServer } from 'http' +import dotenv from 'dotenv' + +// Import routes +import authRoutes from './routes/auth' +import appsRoutes from './routes/apps' +import organizationsRoutes from './routes/organizations' +import internalRoutes from './routes/internal' +import billingRoutes, { billingWebhookRouter } from './routes/billing' +import appRegistryRoutes from './routes/appRegistry' +import appRegistryProxyRoutes from './routes/app-registry' +import { initializeSocketIO } from './sockets/socketHandler' +import { + initializeDatabase, + closeDatabase, + checkDatabaseHealth, +} from './config/database' +import { oidcService } from './services/oidc' +import { setupMetrics } from './metrics' +import { provisionM2MClients } from './authentik/provision-m2m-clients' + +// Load environment variables +dotenv.config() + +// Prometheus metrics (Phase E). Scraped at /metrics; gracefully degrades to a +// 503 if prom-client is not installed. +const metrics = setupMetrics() + +// Extend Express Request interface to include requestId +declare global { + namespace Express { + interface Request { + requestId?: string + } + } +} + +const app = express() +// Behind the k8s ingress every request otherwise carries the ingress IP — +// trust the first proxy hop so req.ip (rate limiting, auth logs) reflects +// the real client from X-Forwarded-For. +app.set('trust proxy', 1) +const httpServer = createServer(app) +const PORT = process.env.PORT || 3001 + +// Initialize Socket.IO +const io = initializeSocketIO(httpServer) + +// Make io available to routes +app.set('io', io) + +// Middleware +app.use( + helmet({ + contentSecurityPolicy: { + directives: { + defaultSrc: ["'self'"], + frameSrc: ["'self'", '*'], // Allow iframes for microfrontends + scriptSrc: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], // Allow scripts for dynamic loading + }, + }, + }) +) + +app.use( + cors({ + origin: [ + process.env.FRONTEND_URL || 'http://localhost:5173', + 'http://localhost:8085', // Production frontend external URL + 'http://localhost:3004', // Allow calls from external backend port + 'http://fuzefront-frontend-prod:8080', // Internal container URL + ], + credentials: true, + }) +) + +// Stripe webhook passthrough MUST be mounted before the global JSON body parser +// so the raw signed bytes survive for downstream signature verification. It uses +// its own express.raw() parser. (See routes/billing.ts.) +app.use('/api/v1/billing/webhooks/stripe', billingWebhookRouter) + +app.use(express.json()) +app.use(express.urlencoded({ extended: true })) + +// Enhanced request logging middleware +app.use((req, res, next) => { + const requestId = require('uuid').v4().substring(0, 8) + const startTime = Date.now() + + // Add request ID to request object for tracking + req.requestId = requestId + + console.log(`šŸ“„ [${requestId}] ${req.method} ${req.path}`, { + timestamp: new Date().toISOString(), + ip: req.ip || req.connection.remoteAddress, + userAgent: req.get('User-Agent'), + origin: req.get('Origin'), + referer: req.get('Referer'), + contentType: req.get('Content-Type'), + contentLength: req.get('Content-Length'), + authorization: req.get('Authorization') ? 'Bearer ***' : 'none', + query: Object.keys(req.query).length > 0 ? req.query : 'none', + bodySize: req.body ? JSON.stringify(req.body).length : 0, + }) + + // Log response when it finishes + const originalSend = res.send + res.send = function (data) { + const responseTime = Date.now() - startTime + console.log( + `šŸ“¤ [${requestId}] ${req.method} ${req.path} - ${res.statusCode}`, + { + responseTime: `${responseTime}ms`, + statusCode: res.statusCode, + contentType: res.get('Content-Type'), + responseSize: data ? data.length : 0, + } + ) + return originalSend.call(this, data) + } + + next() +}) + +// Prometheus request metrics (records method/route/status + duration). +app.use(metrics.middleware) +// Expose /metrics for the Prometheus scrape. +metrics.registerEndpoint(app) + +// Setup Swagger documentation +try { + // Only import and setup Swagger if packages are available + const { specs, swaggerUi } = require('./config/swagger.js') + + /** + * @swagger + * tags: + * - name: Authentication + * description: User authentication and session management + * - name: Applications + * description: Microfrontend application management + * - name: Health + * description: System health and status endpoints + */ + + app.use( + '/api-docs', + swaggerUi.serve, + swaggerUi.setup(specs, { + explorer: true, + customCss: '.swagger-ui .topbar { display: none }', + customSiteTitle: 'FrontFuse API Documentation', + swaggerOptions: { + persistAuthorization: true, + displayRequestDuration: true, + filter: true, + showExtensions: true, + showCommonExtensions: true, + }, + }) + ) + + console.log( + 'šŸ“š Swagger documentation available at http://localhost:' + + PORT + + '/api-docs' + ) +} catch (error) { + console.warn( + 'āš ļø Swagger documentation not available (packages not installed)' + ) + + // Provide a simple fallback API documentation + app.get('/api-docs', (req, res) => { + res.send(` + + + + FrontFuse API Documentation + + + +

šŸš€ FrontFuse API Documentation

+

Welcome to the FrontFuse Platform API. This is a simplified documentation view.

+ +

šŸ” Authentication Endpoints

+
+
POST /api/auth/login
+

Authenticate user with email and password

+
+{
+  "email": "admin@frontfuse.dev",
+  "password": "admin123"
+}
+          
+
+ +
+
GET /api/auth/user
+

Get current authenticated user information

+

Requires: Authorization: Bearer <token>

+
+ +
+
POST /api/auth/logout
+

Logout current user and invalidate session

+

Requires: Authorization: Bearer <token>

+
+ +

šŸ“± Application Management

+
+
GET /api/apps
+

Get list of all registered applications

+

Requires: Authorization: Bearer <token>

+
+ +
+
POST /api/apps
+

Register a new microfrontend application (Admin only)

+

Requires: Authorization: Bearer <token>

+
+{
+  "name": "My App",
+  "url": "https://my-app.netlify.app",
+  "integrationType": "module-federation",
+  "remoteUrl": "https://my-app.netlify.app/assets/remoteEntry.js",
+  "scope": "myApp",
+  "module": "./App"
+}
+          
+
+ +

šŸ’“ Health & Monitoring

+
+
GET /health
+

Platform health check endpoint

+

No authentication required

+
+ +
+
POST /api/apps/:id/heartbeat
+

Application heartbeat endpoint

+
+ +

šŸ”‘ Authentication

+

Most endpoints require a JWT token in the Authorization header:

+
Authorization: Bearer <your-jwt-token>
+ +

šŸ“ž Support

+

For full interactive documentation, install swagger packages:

+
npm install swagger-ui-express swagger-jsdoc
+ +

For support: support@frontfuse.dev

+ + + `) + }) + + console.log( + 'šŸ“š Basic API documentation available at http://localhost:' + + PORT + + '/api-docs' + ) +} + +// Routes +app.use('/api/auth', authRoutes) +app.use('/api/apps', appsRoutes) +app.use('/api/organizations', organizationsRoutes) +// Billing proxy: browser -> backend -> fuzefront-billing-service:3006 (adds the +// internal token). Webhook subroute is mounted separately above (raw body). +app.use('/api/v1/billing', billingRoutes) +// App-registry: CI/local uses a direct DB adapter (routes/appRegistry); prod uses a +// proxy to the applications-service (routes/app-registry). Mount adapter first so CI +// env (no applications-service) is served from the local DB, then the proxy handles +// any requests the adapter passes through via next(). +app.use('/api/v1/app-registry', appRegistryRoutes) +// App-registry proxy: browser -> backend -> fuzefront-applications:3003. The +// ingress `/api` catch-all + frontend nginx both route the manifest-shaped +// `/api/v1/app-registry/*` here, so without this the registry client 404s and no +// federated app (e.g. the built-in Clock) can mount. Forwards the platform JWT +// verbatim; the applications-service does its own authn/authz. +app.use('/api/v1/app-registry', appRegistryProxyRoutes) +// Internal, secret-guarded provisioning endpoint (NOT exposed via public ingress). +app.use('/internal', internalRoutes) + +// Serve static documentation files +app.use('/docs', express.static('docs')) + +// User info route +app.get('/api/user', (req, res) => { + // This will be handled by the auth middleware in production + res.json({ message: 'User endpoint - use /auth/user instead' }) +}) + +/** + * @swagger + * /health: + * get: + * summary: Health check endpoint + * description: Check if the FrontFuse platform is running and get system information + * tags: [Health] + * security: [] + * responses: + * 200: + * description: Platform is healthy + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/HealthResponse' + * example: + * status: "ok" + * timestamp: "2024-01-01T12:00:00.000Z" + * uptime: 3600 + * version: "1.0.0" + * environment: "development" + * memory: + * used: 45 + * total: 128 + */ +// Health check +const startTime = Date.now() + +// Main health check endpoint (without /api prefix) +app.get('/health', async (req, res) => { + const uptime = Math.floor((Date.now() - startTime) / 1000) + const dbHealthy = await checkDatabaseHealth() + + res.json({ + status: dbHealthy ? 'ok' : 'degraded', + timestamp: new Date().toISOString(), + uptime: uptime, + version: process.env.npm_package_version || '1.0.0', + environment: process.env.NODE_ENV || 'development', + database: { + status: dbHealthy ? 'connected' : 'disconnected', + type: 'PostgreSQL', + host: process.env.DB_HOST || 'localhost', + database: process.env.DB_NAME || 'fuzefront_platform', + }, + memory: { + used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), + total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), + }, + }) +}) + +// Add /api/health endpoint to match frontend expectations +app.get('/api/health', async (req, res) => { + const uptime = Math.floor((Date.now() - startTime) / 1000) + const dbHealthy = await checkDatabaseHealth() + + res.json({ + status: dbHealthy ? 'ok' : 'degraded', + timestamp: new Date().toISOString(), + uptime: uptime, + version: process.env.npm_package_version || '1.0.0', + environment: process.env.NODE_ENV || 'development', + database: { + status: dbHealthy ? 'connected' : 'disconnected', + type: 'PostgreSQL', + host: process.env.DB_HOST || 'localhost', + database: process.env.DB_NAME || 'fuzefront_platform', + }, + memory: { + used: Math.round(process.memoryUsage().heapUsed / 1024 / 1024), + total: Math.round(process.memoryUsage().heapTotal / 1024 / 1024), + }, + }) +}) + +// Error handling middleware +app.use((err: any, req: any, res: any, next: any) => { + console.error(err.stack) + res.status(500).json({ error: 'Something went wrong!' }) +}) + +// 404 handler +app.use((req, res) => { + res.status(404).json({ error: 'Not found' }) +}) + +// Graceful shutdown function +function gracefulShutdown(signal: string) { + console.log(`\nšŸ›‘ Received ${signal}. Starting graceful shutdown...`) + + httpServer.close(err => { + if (err) { + console.error('āŒ Error during server shutdown:', err) + process.exit(1) + } + + console.log('āœ… HTTP server closed') + + // Close Socket.IO connections + io.close(async () => { + console.log('āœ… Socket.IO server closed') + + // Close database connections + try { + await closeDatabase() + console.log('āœ… Database connections closed') + } catch (error) { + console.error('āŒ Error closing database:', error) + } + + console.log('šŸŽÆ Graceful shutdown complete') + process.exit(0) + }) + }) + + // Force exit after 30 seconds if graceful shutdown fails + setTimeout(() => { + console.error('ā° Graceful shutdown timeout - forcing exit') + process.exit(1) + }, 30000) +} + +// Register shutdown handlers +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')) +process.on('SIGINT', () => gracefulShutdown('SIGINT')) + +// Handle uncaught exceptions +process.on('uncaughtException', err => { + console.error('šŸ’„ Uncaught Exception:', err) + gracefulShutdown('uncaughtException') +}) + +// Handle unhandled promise rejections +process.on('unhandledRejection', (reason, promise) => { + console.error('🚨 Unhandled Rejection at:', promise, 'reason:', reason) + gracefulShutdown('unhandledRejection') +}) + +// Function to find available port +async function findAvailablePort( + startPort: number, + maxAttempts: number = 10 +): Promise { + return new Promise((resolve, reject) => { + const currentPort = startPort + let attempts = 0 + + function tryPort(port: number) { + const testServer = require('net').createServer() + + testServer.listen(port, (err: any) => { + if (err) { + testServer.close() + attempts++ + + if (attempts >= maxAttempts) { + reject( + new Error( + `No available port found after ${maxAttempts} attempts starting from ${startPort}` + ) + ) + return + } + + console.log(`āš ļø Port ${port} is busy, trying ${port + 1}...`) + tryPort(port + 1) + } else { + testServer.close(() => { + resolve(port) + }) + } + }) + + testServer.on('error', (err: any) => { + testServer.close() + attempts++ + + if (attempts >= maxAttempts) { + reject( + new Error( + `No available port found after ${maxAttempts} attempts starting from ${startPort}` + ) + ) + return + } + + console.log(`āš ļø Port ${port} is busy, trying ${port + 1}...`) + tryPort(port + 1) + }) + } + + tryPort(currentPort) + }) +} + +// Start server with port conflict handling +async function startServer() { + try { + // Initialize database first + console.log('šŸ”„ Starting FuzeFront Backend Server...') + await initializeDatabase() + + // Initialize OIDC service + try { + console.log('šŸ”§ Initializing OIDC service...') + if (oidcService.isConfigured()) { + await oidcService.initialize() + console.log('āœ… OIDC service initialized successfully') + } else { + console.log('āš ļø OIDC service not configured - local auth only') + console.log('šŸ’” Set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET to enable OIDC') + } + } catch (error) { + console.error('āŒ Failed to initialize OIDC service:', error) + console.log('āš ļø Continuing with local authentication only') + } + + // Provision Authentik M2M clients (idempotent; errors are non-fatal) + await provisionM2MClients() + + const portNumber = typeof PORT === 'string' ? parseInt(PORT, 10) : PORT + const availablePort = await findAvailablePort(portNumber) + + if (availablePort !== portNumber) { + console.log( + `šŸ”„ Original port ${portNumber} was busy, using port ${availablePort} instead` + ) + } + + httpServer.listen(availablePort, () => { + console.log( + `šŸš€ FuzeFront backend server running on port ${availablePort}` + ) + console.log( + `🌐 Frontend URL: ${process.env.FRONTEND_URL || 'http://localhost:5173'}` + ) + console.log(`šŸ“” WebSocket server ready`) + console.log( + `šŸ“š API Documentation: http://localhost:${availablePort}/api-docs` + ) + console.log(`šŸ’“ Health Check: http://localhost:${availablePort}/health`) + console.log(`šŸ—„ļø Database: PostgreSQL (shared-postgres)`) + + // Log authentication methods available + const authMethods = ['Local Database'] + if (oidcService.isConfigured()) { + authMethods.push('OIDC (Authentik)') + } + console.log(`šŸ” Authentication: ${authMethods.join(', ')}`) + + // Update PORT variable for other parts of the app + process.env.PORT = availablePort.toString() + }) + + httpServer.on('error', (err: any) => { + if (err.code === 'EADDRINUSE') { + console.error(`āŒ Port ${availablePort} is already in use`) + console.log( + 'šŸ’” This might happen if another instance is already running' + ) + console.log('šŸ’” Try stopping other instances or use a different port') + gracefulShutdown('EADDRINUSE') + } else { + console.error('āŒ Server error:', err) + gracefulShutdown('ServerError') + } + }) + } catch (error) { + console.error('āŒ Failed to start server:', error) + console.log('šŸ’” Please check if ports 3001-3010 are available') + process.exit(1) + } +} + +// Start the server +startServer() + +export default app diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index 21ed1e39..c9b52bae 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -1,561 +1,681 @@ -import crypto from 'crypto' -import express from 'express' -import bcrypt from 'bcryptjs' -import jwt from 'jsonwebtoken' -import { v4 as uuidv4 } from 'uuid' -import { db } from '../config/database' -import { authenticateToken } from '../middleware/auth' -import { User } from '../types/shared' -import { oidcService } from '../services/oidc' -import { runInternalProvision } from '../services/organizationProvisioning' - -const FRONTEND_BASE = (process.env.FRONTEND_URL || 'http://fuzefront.dev.local').replace(/\/$/, '') - -const CODE_TTL_MS = 60_000 -interface PendingCode { token: string; sessionId: string; expiresAt: number } -const pendingCodes = new Map() -setInterval(() => { - const now = Date.now() - for (const [key, value] of pendingCodes) { - if (value.expiresAt < now) pendingCodes.delete(key) - } -}, CODE_TTL_MS).unref() - -const router = express.Router() - -// ─── Fire-and-forget provisioning tracker ──────────────────────────────────── -// -// selfHealProvisioningOnLogin fires runInternalProvision() without awaiting. -// In tests, multiple pending promises can keep Knex/tarn DB connections borrowed -// after the test suite finishes, causing pool.destroy() to hang indefinitely. -// -// We register every promise in this Set and expose drainProvisioningQueue() for -// test teardown so setup.ts can await all in-flight operations before calling -// closeDatabase(). Production code never calls drainProvisioningQueue(), so the -// Set stays small (just the tail of the last login's provisioning). -// -const _pendingProvisioningPromises: Set> = new Set() - -/** - * Wait for all in-flight selfHealProvisioningOnLogin promises to settle. - * Call this in test afterAll BEFORE closeDatabase() to prevent tarn.js - * pool.destroy() from hanging on borrowed connections. - */ -export function drainProvisioningQueue(timeoutMs = 10_000): Promise { - if (_pendingProvisioningPromises.size === 0) return Promise.resolve() - const pending = Array.from(_pendingProvisioningPromises) - return Promise.race([ - Promise.allSettled(pending).then(() => undefined), - new Promise(resolve => setTimeout(resolve, timeoutMs)), - ]) -} - -/** - * Self-heal provisioning on login: ensure the user has a personal org and that - * every org they own which isn't `active` gets reconciled. Fire-and-forget — - * this must never block or fail the login response. Acts as the safety net when - * the identity.user.created Kafka event was lost. - */ -function selfHealProvisioningOnLogin(userId: string): void { - const p = runInternalProvision(userId).catch(err => { - console.error(`Login self-heal provisioning failed for ${userId}:`, err) - }) - _pendingProvisioningPromises.add(p) - p.finally(() => _pendingProvisioningPromises.delete(p)) -} - -/** - * @swagger - * /api/auth/login: - * post: - * summary: User login - * description: Authenticate user with email and password, returns JWT token - * tags: [Authentication] - * security: [] - * requestBody: - * required: true - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/LoginRequest' - * example: - * email: "admin@frontfuse.dev" - * password: "admin123" - * responses: - * 200: - * description: Login successful - * content: - * application/json: - * schema: - * allOf: - * - $ref: '#/components/schemas/LoginResponse' - * - type: object - * properties: - * sessionId: - * type: string - * format: uuid - * description: Session identifier - * example: - * token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." - * user: - * id: "550e8400-e29b-41d4-a716-446655440000" - * email: "admin@frontfuse.dev" - * firstName: "Admin" - * lastName: "User" - * roles: ["admin", "user"] - * sessionId: "123e4567-e89b-12d3-a456-426614174000" - * 400: - * description: Missing email or password - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * example: - * error: "Email and password required" - * 401: - * description: Invalid credentials - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * example: - * error: "Invalid credentials" - * 500: - * description: Internal server error - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ -// POST /auth/login - Mock login -router.post('/login', async (req, res) => { - const requestId = uuidv4().substring(0, 8) - const startTime = Date.now() - - console.log(`šŸ” [${requestId}] Login request received:`, { - timestamp: new Date().toISOString(), - ip: req.ip || req.connection.remoteAddress, - userAgent: req.get('User-Agent'), - origin: req.get('Origin'), - referer: req.get('Referer'), - contentType: req.get('Content-Type'), - bodyKeys: Object.keys(req.body || {}), - hasEmail: !!req.body?.email, - hasPassword: !!req.body?.password, - emailDomain: req.body?.email ? req.body.email.split('@')[1] : 'none', - }) - - try { - const { email, password } = req.body - - if (!email || !password) { - console.log(`āŒ [${requestId}] Missing credentials:`, { - hasEmail: !!email, - hasPassword: !!password, - responseTime: Date.now() - startTime, - }) - return res.status(400).json({ error: 'Email and password required' }) - } - - console.log(`šŸ” [${requestId}] Looking up user:`, { - email, - passwordLength: password.length, - }) - - // Find user - const userRow = await db('users').where('email', email).first() - - if (!userRow) { - console.log(`āŒ [${requestId}] User not found:`, { - email, - responseTime: Date.now() - startTime, - }) - return res.status(401).json({ error: 'Invalid credentials' }) - } - - console.log(`šŸ‘¤ [${requestId}] User found:`, { - userId: userRow.id, - email: userRow.email, - hasPasswordHash: !!userRow.password_hash, - roles: userRow.roles, - }) - - // Verify password - console.log(`šŸ”’ [${requestId}] Verifying password...`) - const isValidPassword = await bcrypt.compare( - password, - userRow.password_hash - ) - - if (!isValidPassword) { - console.log(`āŒ [${requestId}] Invalid password:`, { - email, - responseTime: Date.now() - startTime, - }) - return res.status(401).json({ error: 'Invalid credentials' }) - } - - console.log(`āœ… [${requestId}] Password verified, generating token...`) - - // Create the session id first so it can be embedded in the token; this lets - // logout invalidate only THIS session rather than all of the user's sessions. - const sessionId = uuidv4() - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours - - // Generate JWT - const token = jwt.sign( - { userId: userRow.id, sessionId }, - process.env.JWT_SECRET!, - { expiresIn: '24h' } - ) - - console.log(`šŸŽ« [${requestId}] JWT token generated:`, { - tokenLength: token.length, - tokenPreview: token.substring(0, 20) + '...', - }) - - console.log(`šŸ’¾ [${requestId}] Creating session:`, { - sessionId, - expiresAt: expiresAt.toISOString(), - }) - - await db('sessions').insert({ - id: sessionId, - user_id: userRow.id, - expires_at: expiresAt, - }) - - // Debug logging for roles parsing - console.log(`šŸ” [${requestId}] Parsing roles:`, { - rawRoles: userRow.roles, - rolesType: typeof userRow.roles, - rolesLength: userRow.roles?.length, - firstChar: userRow.roles?.[0], - fallback: '["user"]', - }) - - const user: User = { - id: userRow.id, - email: userRow.email, - firstName: userRow.first_name, - lastName: userRow.last_name, - defaultAppId: userRow.default_app_id, - roles: Array.isArray(userRow.roles) - ? userRow.roles - : JSON.parse(userRow.roles || '["user"]'), - } - - console.log(`šŸŽ‰ [${requestId}] Login successful:`, { - userId: user.id, - email: user.email, - roles: user.roles, - sessionId, - responseTime: Date.now() - startTime, - }) - - // Self-heal provisioning in the background (does not block the response). - selfHealProvisioningOnLogin(user.id) - - res.json({ - token, - user, - sessionId, - }) - } catch (error) { - console.error(`šŸ’„ [${requestId}] Login error:`, { - error: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - responseTime: Date.now() - startTime, - }) - res.status(500).json({ error: 'Internal server error' }) - } -}) - -/** - * @swagger - * /api/auth/user: - * get: - * summary: Get current user - * description: Get information about the currently authenticated user - * tags: [Authentication] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Current user information - * content: - * application/json: - * schema: - * type: object - * properties: - * user: - * $ref: '#/components/schemas/User' - * example: - * user: - * id: "550e8400-e29b-41d4-a716-446655440000" - * email: "admin@frontfuse.dev" - * firstName: "Admin" - * lastName: "User" - * roles: ["admin", "user"] - * 401: - * description: Access token required - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - * 403: - * description: Invalid token - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ -// GET /auth/user - Get current user -router.get('/user', authenticateToken, async (req, res) => { - res.json({ user: req.user }) -}) - -/** - * @swagger - * /api/auth/logout: - * post: - * summary: User logout - * description: Logout the current user and invalidate their session - * tags: [Authentication] - * security: - * - bearerAuth: [] - * responses: - * 200: - * description: Logout successful - * content: - * application/json: - * schema: - * type: object - * properties: - * message: - * type: string - * example: "Logged out successfully" - * 500: - * description: Logout failed - * content: - * application/json: - * schema: - * $ref: '#/components/schemas/Error' - */ -// POST /auth/logout -router.post('/logout', authenticateToken, async (req: any, res) => { - try { - const authHeader = req.headers['authorization'] - const token = authHeader && authHeader.split(' ')[1] - - if (token) { - const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { - userId: string - sessionId?: string - } - // Invalidate only the current session, not every session the user has. - if (decoded.sessionId) { - await db('sessions').where('id', decoded.sessionId).del() - } - } - - res.json({ message: 'Logged out successfully' }) - } catch (error) { - res.status(500).json({ error: 'Logout failed' }) - } -}) - -/** - * @swagger - * /api/auth/oidc/login: - * get: - * summary: Initiate OIDC login - * description: Redirects to Authentik for OIDC authentication - * tags: [Authentication] - * security: [] - * responses: - * 302: - * description: Redirect to Authentik login page - * 500: - * description: OIDC not configured or server error - */ -router.get('/oidc/login', async (req, res) => { - const requestId = uuidv4().substring(0, 8) - console.log(`šŸ” [${requestId}] OIDC login request received`) - - try { - if (!oidcService.isConfigured()) { - console.log(`āŒ [${requestId}] OIDC not configured`) - return res.status(500).json({ - error: 'OIDC authentication not configured. Please set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET.' - }) - } - - // Lazy re-initialization: if the client failed to init at startup (e.g. - // Authentik wasn't ready yet), retry now before giving up. - if (!oidcService.isInitialized()) { - console.log(`šŸ”„ [${requestId}] OIDC client not initialized — retrying initialization`) - await oidcService.initialize() - } - - const state = uuidv4() - const authUrl = oidcService.generateAuthUrl(state) - - console.log(`šŸ”— [${requestId}] Redirecting to Authentik:`, authUrl) - res.redirect(authUrl) - } catch (error) { - console.error(`āŒ [${requestId}] OIDC login error:`, error) - res.status(500).json({ error: 'Failed to initiate OIDC login' }) - } -}) - -/** - * @swagger - * /api/auth/oidc/callback: - * get: - * summary: OIDC callback handler - * description: Handles the callback from Authentik after successful authentication - * tags: [Authentication] - * security: [] - * parameters: - * - in: query - * name: code - * required: true - * schema: - * type: string - * description: Authorization code from Authentik - * - in: query - * name: state - * required: true - * schema: - * type: string - * description: State parameter for CSRF protection - * responses: - * 302: - * description: Redirect to frontend with authentication token - * 400: - * description: Missing code or state parameter - * 500: - * description: Authentication failed - */ -router.get('/oidc/callback', async (req, res) => { - const requestId = uuidv4().substring(0, 8) - const { code, state, error } = req.query - - console.log(`šŸ”„ [${requestId}] OIDC callback received:`, { - hasCode: !!code, - hasState: !!state, - error, - }) - - try { - if (error) { - const errorDesc = (req.query.error_description as string) || '' - console.log(`āŒ [${requestId}] OIDC error:`, error, errorDesc || '(no description)') - return res.redirect( - `${FRONTEND_BASE}/?error=oidc_error&message=${encodeURIComponent(error as string)}${errorDesc ? `&desc=${encodeURIComponent(errorDesc)}` : ''}` - ) - } - - if (!code || !state) { - console.log(`āŒ [${requestId}] Missing code or state`) - return res.redirect(`${FRONTEND_BASE}/?error=missing_parameters`) - } - - // Handle the callback and get user - const user = await oidcService.handleCallback(code as string, state as string) - console.log(`āœ… [${requestId}] User authenticated via OIDC:`, user.email) - - // Create session id first so it can be embedded in the token - const sessionId = uuidv4() - const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours - - // Generate JWT token — include standard OIDC claims (sub, email) alongside - // the internal userId/sessionId so consumers can inspect identity claims. - const token = jwt.sign( - { userId: user.id, sessionId, sub: user.id, email: user.email }, - process.env.JWT_SECRET!, - { expiresIn: '24h' } - ) - - await db('sessions').insert({ - id: sessionId, - user_id: user.id, - expires_at: expiresAt, - }) - - console.log(`šŸŽ‰ [${requestId}] OIDC login successful for:`, user.email) - - // Self-heal provisioning in the background (does not block the redirect). - selfHealProvisioningOnLogin(user.id) - - // Issue a short-lived opaque exchange code instead of putting the bearer token - // in the URL (avoids token leakage via referrer headers, server logs, and history). - const exchangeCode = crypto.randomBytes(32).toString('hex') - pendingCodes.set(exchangeCode, { token, sessionId, expiresAt: Date.now() + CODE_TTL_MS }) - res.redirect(`${FRONTEND_BASE}/?code=${exchangeCode}`) - - } catch (error) { - console.error(`āŒ [${requestId}] OIDC callback error:`, error) - res.redirect(`${FRONTEND_BASE}/?error=authentication_failed`) - } -}) - -// POST /auth/token-exchange — redeem the single-use exchange code issued by /oidc/callback -router.post('/token-exchange', async (req, res) => { - const { code } = req.body - if (!code || typeof code !== 'string') { - return res.status(400).json({ error: 'code required' }) - } - const pending = pendingCodes.get(code) - if (!pending || Date.now() > pending.expiresAt) { - pendingCodes.delete(code) - return res.status(401).json({ error: 'invalid or expired code' }) - } - pendingCodes.delete(code) - return res.json({ token: pending.token, sessionId: pending.sessionId }) -}) - -/** - * @swagger - * /api/auth/method: - * get: - * summary: Get available authentication methods - * description: Returns which authentication methods are available - * tags: [Authentication] - * security: [] - * responses: - * 200: - * description: Available authentication methods - * content: - * application/json: - * schema: - * type: object - * properties: - * methods: - * type: array - * items: - * type: string - * example: ["local", "oidc"] - * oidcConfigured: - * type: boolean - * defaultMethod: - * type: string - */ -router.get('/method', (req, res) => { - const oidcConfigured = oidcService.isConfigured() - - const methods = ['local'] // Always support local auth - if (oidcConfigured) { - methods.push('oidc') - } - - res.json({ - methods, - oidcConfigured, - defaultMethod: oidcConfigured ? 'oidc' : 'local', - oidcLoginUrl: oidcConfigured ? '/api/auth/oidc/login' : null, - }) -}) - -export default router +import crypto from 'crypto' +import express from 'express' +import rateLimit from 'express-rate-limit' +import bcrypt from 'bcryptjs' +import jwt from 'jsonwebtoken' +import { v4 as uuidv4 } from 'uuid' +import { db } from '../config/database' +import { authenticateToken } from '../middleware/auth' +import { User } from '../types/shared' +import { oidcService } from '../services/oidc' +import { + authentikPasswordLogin, + InvalidCredentialsError, + AuthentikUnavailableError, + UnsupportedFlowStageError, +} from '../services/authentikPassword' +import { runInternalProvision } from '../services/organizationProvisioning' + +const FRONTEND_BASE = (process.env.FRONTEND_URL || 'http://fuzefront.dev.local').replace(/\/$/, '') + +const CODE_TTL_MS = 60_000 +interface PendingCode { token: string; sessionId: string; expiresAt: number } +const pendingCodes = new Map() +setInterval(() => { + const now = Date.now() + for (const [key, value] of pendingCodes) { + if (value.expiresAt < now) pendingCodes.delete(key) + } +}, CODE_TTL_MS).unref() + +const router = express.Router() + +// ─── Fire-and-forget provisioning tracker ──────────────────────────────────── +// +// selfHealProvisioningOnLogin fires runInternalProvision() without awaiting. +// In tests, multiple pending promises can keep Knex/tarn DB connections borrowed +// after the test suite finishes, causing pool.destroy() to hang indefinitely. +// +// We register every promise in this Set and expose drainProvisioningQueue() for +// test teardown so setup.ts can await all in-flight operations before calling +// closeDatabase(). Production code never calls drainProvisioningQueue(), so the +// Set stays small (just the tail of the last login's provisioning). +// +const _pendingProvisioningPromises: Set> = new Set() + +/** + * Wait for all in-flight selfHealProvisioningOnLogin promises to settle. + * Call this in test afterAll BEFORE closeDatabase() to prevent tarn.js + * pool.destroy() from hanging on borrowed connections. + */ +export function drainProvisioningQueue(timeoutMs = 10_000): Promise { + if (_pendingProvisioningPromises.size === 0) return Promise.resolve() + const pending = Array.from(_pendingProvisioningPromises) + return Promise.race([ + Promise.allSettled(pending).then(() => undefined), + new Promise(resolve => setTimeout(resolve, timeoutMs)), + ]) +} + +/** + * Self-heal provisioning on login: ensure the user has a personal org and that + * every org they own which isn't `active` gets reconciled. Fire-and-forget — + * this must never block or fail the login response. Acts as the safety net when + * the identity.user.created Kafka event was lost. + */ +function selfHealProvisioningOnLogin(userId: string): void { + const p = runInternalProvision(userId).catch(err => { + console.error(`Login self-heal provisioning failed for ${userId}:`, err) + }) + _pendingProvisioningPromises.add(p) + p.finally(() => _pendingProvisioningPromises.delete(p)) +} + +/** + * @swagger + * /api/auth/login: + * post: + * summary: User login + * description: Authenticate user with email and password, returns JWT token + * tags: [Authentication] + * security: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/LoginRequest' + * example: + * email: "admin@frontfuse.dev" + * password: "admin123" + * responses: + * 200: + * description: Login successful + * content: + * application/json: + * schema: + * allOf: + * - $ref: '#/components/schemas/LoginResponse' + * - type: object + * properties: + * sessionId: + * type: string + * format: uuid + * description: Session identifier + * example: + * token: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." + * user: + * id: "550e8400-e29b-41d4-a716-446655440000" + * email: "admin@frontfuse.dev" + * firstName: "Admin" + * lastName: "User" + * roles: ["admin", "user"] + * sessionId: "123e4567-e89b-12d3-a456-426614174000" + * 400: + * description: Missing email or password + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Email and password required" + * 401: + * description: Invalid credentials + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * example: + * error: "Invalid credentials" + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +// POST /auth/login - Mock login +router.post('/login', async (req, res) => { + const requestId = uuidv4().substring(0, 8) + const startTime = Date.now() + + console.log(`šŸ” [${requestId}] Login request received:`, { + timestamp: new Date().toISOString(), + ip: req.ip || req.connection.remoteAddress, + userAgent: req.get('User-Agent'), + origin: req.get('Origin'), + referer: req.get('Referer'), + contentType: req.get('Content-Type'), + bodyKeys: Object.keys(req.body || {}), + hasEmail: !!req.body?.email, + hasPassword: !!req.body?.password, + emailDomain: req.body?.email ? req.body.email.split('@')[1] : 'none', + }) + + try { + const { email, password } = req.body + + if (!email || !password) { + console.log(`āŒ [${requestId}] Missing credentials:`, { + hasEmail: !!email, + hasPassword: !!password, + responseTime: Date.now() - startTime, + }) + return res.status(400).json({ error: 'Email and password required' }) + } + + console.log(`šŸ” [${requestId}] Looking up user:`, { + email, + passwordLength: password.length, + }) + + // Find user + const userRow = await db('users').where('email', email).first() + + if (!userRow) { + console.log(`āŒ [${requestId}] User not found:`, { + email, + responseTime: Date.now() - startTime, + }) + return res.status(401).json({ error: 'Invalid credentials' }) + } + + console.log(`šŸ‘¤ [${requestId}] User found:`, { + userId: userRow.id, + email: userRow.email, + hasPasswordHash: !!userRow.password_hash, + roles: userRow.roles, + }) + + // Verify password + console.log(`šŸ”’ [${requestId}] Verifying password...`) + const isValidPassword = await bcrypt.compare( + password, + userRow.password_hash + ) + + if (!isValidPassword) { + console.log(`āŒ [${requestId}] Invalid password:`, { + email, + responseTime: Date.now() - startTime, + }) + return res.status(401).json({ error: 'Invalid credentials' }) + } + + console.log(`āœ… [${requestId}] Password verified, generating token...`) + + // Create the session id first so it can be embedded in the token; this lets + // logout invalidate only THIS session rather than all of the user's sessions. + const sessionId = uuidv4() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours + + // Generate JWT + const token = jwt.sign( + { userId: userRow.id, sessionId }, + process.env.JWT_SECRET!, + { expiresIn: '24h' } + ) + + console.log(`šŸŽ« [${requestId}] JWT token generated:`, { + tokenLength: token.length, + tokenPreview: token.substring(0, 20) + '...', + }) + + console.log(`šŸ’¾ [${requestId}] Creating session:`, { + sessionId, + expiresAt: expiresAt.toISOString(), + }) + + await db('sessions').insert({ + id: sessionId, + user_id: userRow.id, + expires_at: expiresAt, + }) + + // Debug logging for roles parsing + console.log(`šŸ” [${requestId}] Parsing roles:`, { + rawRoles: userRow.roles, + rolesType: typeof userRow.roles, + rolesLength: userRow.roles?.length, + firstChar: userRow.roles?.[0], + fallback: '["user"]', + }) + + const user: User = { + id: userRow.id, + email: userRow.email, + firstName: userRow.first_name, + lastName: userRow.last_name, + defaultAppId: userRow.default_app_id, + roles: Array.isArray(userRow.roles) + ? userRow.roles + : JSON.parse(userRow.roles || '["user"]'), + } + + console.log(`šŸŽ‰ [${requestId}] Login successful:`, { + userId: user.id, + email: user.email, + roles: user.roles, + sessionId, + responseTime: Date.now() - startTime, + }) + + // Self-heal provisioning in the background (does not block the response). + selfHealProvisioningOnLogin(user.id) + + res.json({ + token, + user, + sessionId, + }) + } catch (error) { + console.error(`šŸ’„ [${requestId}] Login error:`, { + error: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + responseTime: Date.now() - startTime, + }) + res.status(500).json({ error: 'Internal server error' }) + } +}) + +/** + * @swagger + * /api/auth/user: + * get: + * summary: Get current user + * description: Get information about the currently authenticated user + * tags: [Authentication] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Current user information + * content: + * application/json: + * schema: + * type: object + * properties: + * user: + * $ref: '#/components/schemas/User' + * example: + * user: + * id: "550e8400-e29b-41d4-a716-446655440000" + * email: "admin@frontfuse.dev" + * firstName: "Admin" + * lastName: "User" + * roles: ["admin", "user"] + * 401: + * description: Access token required + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + * 403: + * description: Invalid token + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +// GET /auth/user - Get current user +router.get('/user', authenticateToken, async (req, res) => { + res.json({ user: req.user }) +}) + +/** + * @swagger + * /api/auth/logout: + * post: + * summary: User logout + * description: Logout the current user and invalidate their session + * tags: [Authentication] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Logout successful + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: "Logged out successfully" + * 500: + * description: Logout failed + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/Error' + */ +// POST /auth/logout +router.post('/logout', authenticateToken, async (req: any, res) => { + try { + const authHeader = req.headers['authorization'] + const token = authHeader && authHeader.split(' ')[1] + + if (token) { + const decoded = jwt.verify(token, process.env.JWT_SECRET!) as { + userId: string + sessionId?: string + } + // Invalidate only the current session, not every session the user has. + if (decoded.sessionId) { + await db('sessions').where('id', decoded.sessionId).del() + } + } + + res.json({ message: 'Logged out successfully' }) + } catch (error) { + res.status(500).json({ error: 'Logout failed' }) + } +}) + +/** + * @swagger + * /api/auth/oidc/login: + * get: + * summary: Initiate OIDC login + * description: Redirects to Authentik for OIDC authentication + * tags: [Authentication] + * security: [] + * responses: + * 302: + * description: Redirect to Authentik login page + * 500: + * description: OIDC not configured or server error + */ +router.get('/oidc/login', async (req, res) => { + const requestId = uuidv4().substring(0, 8) + console.log(`šŸ” [${requestId}] OIDC login request received`) + + try { + if (!oidcService.isConfigured()) { + console.log(`āŒ [${requestId}] OIDC not configured`) + return res.status(500).json({ + error: 'OIDC authentication not configured. Please set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET.' + }) + } + + // Lazy re-initialization: if the client failed to init at startup (e.g. + // Authentik wasn't ready yet), retry now before giving up. + if (!oidcService.isInitialized()) { + console.log(`šŸ”„ [${requestId}] OIDC client not initialized — retrying initialization`) + await oidcService.initialize() + } + + const state = uuidv4() + const authUrl = oidcService.generateAuthUrl(state) + + console.log(`šŸ”— [${requestId}] Redirecting to Authentik:`, authUrl) + res.redirect(authUrl) + } catch (error) { + console.error(`āŒ [${requestId}] OIDC login error:`, error) + res.status(500).json({ error: 'Failed to initiate OIDC login' }) + } +}) + + +// Rate limit for the password endpoint: the flow-executor login is a +// credential-stuffing surface, so cap FAILED attempts per client before we +// ever contact Authentik (same express-rate-limit convention as +// tokenAuthRateLimiter). Successful sign-ins are never throttled. +const passwordLoginRateLimiter = rateLimit({ + windowMs: 5 * 60_000, + limit: 10, + // Count ONLY rejected credentials (401) against the budget: 503s from an + // Authentik outage or an MFA-required account must not lock users out. + skipSuccessfulRequests: true, + requestWasSuccessful: (_req, res) => res.statusCode !== 401, + standardHeaders: true, + legacyHeaders: false, + message: { error: 'Too many sign-in attempts. Try again later.' }, +}) + +/** + * @swagger + * /api/auth/oidc/password: + * post: + * summary: Password sign-in against Authentik (no redirect) + * description: > + * Authenticates email+password by driving Authentik's flow-executor API + * server-side, then completes the OIDC code exchange with the resulting + * Authentik session. Response shape matches /api/auth/login. + * tags: [Authentication] + * security: [] + * responses: + * 200: { description: Authenticated } + * 400: { description: Missing email or password } + * 401: { description: Invalid credentials } + * 503: { description: OIDC unavailable or browser flow required } + */ +router.post('/oidc/password', passwordLoginRateLimiter, async (req, res) => { + const requestId = uuidv4().substring(0, 8) + const { email, password } = req.body || {} + + console.log('šŸ” Authentik password login request', { + requestId, + hasEmail: !!email, + configured: oidcService.isConfigured?.(), + initialized: oidcService.isInitialized?.(), + }) + + if (!email || !password) { + return res.status(400).json({ error: 'Email and password required' }) + } + if (!oidcService.isConfigured()) { + return res.status(503).json({ + error: + 'OIDC authentication not configured. Please set AUTHENTIK_CLIENT_ID and AUTHENTIK_CLIENT_SECRET.', + }) + } + + try { + // Lazy re-init mirrors /oidc/login: Authentik may not have been ready at boot. + if (!oidcService.isInitialized()) { + try { + await oidcService.initialize() + } catch (initErr) { + console.error('āŒ OIDC lazy init failed', JSON.stringify({ requestId, message: (initErr as Error).message?.replace(/[\r\n]+/g, ' ') })) + return res + .status(503) + .json({ error: 'Authentication service unavailable. Try again shortly.' }) + } + } + + const user = await authentikPasswordLogin(email, password) + + const sessionId = uuidv4() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours + // This IS FuzeFront's identity service — the issuer of platform tokens + // (same mint as /login and the OIDC callback), not a product self-minting. + // nosemgrep: fuze-auth-self-minted-user-token, semgrep.fuze-auth-self-minted-user-token + const token = jwt.sign( + { userId: user.id, sessionId }, + process.env.JWT_SECRET!, + { expiresIn: '24h' } + ) + await db('sessions').insert({ + id: sessionId, + user_id: user.id, + expires_at: expiresAt, + }) + + selfHealProvisioningOnLogin(user.id) + + console.log('šŸŽ‰ Authentik password login successful', { requestId, userId: user.id }) + return res.json({ token, user, sessionId }) + } catch (error) { + if (error instanceof InvalidCredentialsError) { + console.log('āŒ Authentik rejected credentials', { requestId }) + return res.status(401).json({ error: 'Invalid credentials' }) + } + if (error instanceof UnsupportedFlowStageError) { + console.warn('āš ļø Unsupported Authentik flow stage', JSON.stringify({ requestId, message: error.message.replace(/[\r\n]+/g, ' ') })) + return res.status(503).json({ + error: + 'This account requires a browser sign-in flow (e.g. MFA). Use the SSO button instead.', + }) + } + if (error instanceof AuthentikUnavailableError) { + console.error('āŒ Authentik unavailable', JSON.stringify({ requestId, message: error.message.replace(/[\r\n]+/g, ' ') })) + return res + .status(503) + .json({ error: 'Authentication service unavailable. Try again shortly.' }) + } + console.error('āŒ Authentik password login error', { requestId }, error) + return res.status(500).json({ error: 'Authentication failed' }) + } +}) + +/** + * @swagger + * /api/auth/oidc/callback: + * get: + * summary: OIDC callback handler + * description: Handles the callback from Authentik after successful authentication + * tags: [Authentication] + * security: [] + * parameters: + * - in: query + * name: code + * required: true + * schema: + * type: string + * description: Authorization code from Authentik + * - in: query + * name: state + * required: true + * schema: + * type: string + * description: State parameter for CSRF protection + * responses: + * 302: + * description: Redirect to frontend with authentication token + * 400: + * description: Missing code or state parameter + * 500: + * description: Authentication failed + */ +router.get('/oidc/callback', async (req, res) => { + const requestId = uuidv4().substring(0, 8) + const { code, state, error } = req.query + + console.log(`šŸ”„ [${requestId}] OIDC callback received:`, { + hasCode: !!code, + hasState: !!state, + error, + }) + + try { + if (error) { + const errorDesc = (req.query.error_description as string) || '' + console.log(`āŒ [${requestId}] OIDC error:`, error, errorDesc || '(no description)') + return res.redirect( + `${FRONTEND_BASE}/?error=oidc_error&message=${encodeURIComponent(error as string)}${errorDesc ? `&desc=${encodeURIComponent(errorDesc)}` : ''}` + ) + } + + if (!code || !state) { + console.log(`āŒ [${requestId}] Missing code or state`) + return res.redirect(`${FRONTEND_BASE}/?error=missing_parameters`) + } + + // Handle the callback and get user + const user = await oidcService.handleCallback(code as string, state as string) + console.log(`āœ… [${requestId}] User authenticated via OIDC:`, user.email) + + // Create session id first so it can be embedded in the token + const sessionId = uuidv4() + const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000) // 24 hours + + // Generate JWT token — include standard OIDC claims (sub, email) alongside + // the internal userId/sessionId so consumers can inspect identity claims. + const token = jwt.sign( + { userId: user.id, sessionId, sub: user.id, email: user.email }, + process.env.JWT_SECRET!, + { expiresIn: '24h' } + ) + + await db('sessions').insert({ + id: sessionId, + user_id: user.id, + expires_at: expiresAt, + }) + + console.log(`šŸŽ‰ [${requestId}] OIDC login successful for:`, user.email) + + // Self-heal provisioning in the background (does not block the redirect). + selfHealProvisioningOnLogin(user.id) + + // Issue a short-lived opaque exchange code instead of putting the bearer token + // in the URL (avoids token leakage via referrer headers, server logs, and history). + const exchangeCode = crypto.randomBytes(32).toString('hex') + pendingCodes.set(exchangeCode, { token, sessionId, expiresAt: Date.now() + CODE_TTL_MS }) + res.redirect(`${FRONTEND_BASE}/?code=${exchangeCode}`) + + } catch (error) { + console.error(`āŒ [${requestId}] OIDC callback error:`, error) + res.redirect(`${FRONTEND_BASE}/?error=authentication_failed`) + } +}) + +// POST /auth/token-exchange — redeem the single-use exchange code issued by /oidc/callback +router.post('/token-exchange', async (req, res) => { + const { code } = req.body + if (!code || typeof code !== 'string') { + return res.status(400).json({ error: 'code required' }) + } + const pending = pendingCodes.get(code) + if (!pending || Date.now() > pending.expiresAt) { + pendingCodes.delete(code) + return res.status(401).json({ error: 'invalid or expired code' }) + } + pendingCodes.delete(code) + return res.json({ token: pending.token, sessionId: pending.sessionId }) +}) + +/** + * @swagger + * /api/auth/method: + * get: + * summary: Get available authentication methods + * description: Returns which authentication methods are available + * tags: [Authentication] + * security: [] + * responses: + * 200: + * description: Available authentication methods + * content: + * application/json: + * schema: + * type: object + * properties: + * methods: + * type: array + * items: + * type: string + * example: ["local", "oidc"] + * oidcConfigured: + * type: boolean + * defaultMethod: + * type: string + */ +router.get('/method', (req, res) => { + const oidcConfigured = oidcService.isConfigured() + + const methods = ['local'] // Always support local auth + if (oidcConfigured) { + methods.push('oidc') + } + + res.json({ + methods, + oidcConfigured, + defaultMethod: oidcConfigured ? 'oidc' : 'local', + oidcLoginUrl: oidcConfigured ? '/api/auth/oidc/login' : null, + }) +}) + +export default router diff --git a/backend/src/services/authentikPassword.ts b/backend/src/services/authentikPassword.ts new file mode 100644 index 00000000..1b3e13da --- /dev/null +++ b/backend/src/services/authentikPassword.ts @@ -0,0 +1,312 @@ +/** + * Server-side Authentik password authentication — no browser redirect. + * Monolith-backend port of backend/security/src/services/authentikPassword.ts + * (the split security service is authoritative in prod; the monolith serves + * the docker-compose / e2e stacks). Behavior is identical; the only + * differences are this oidc service's signatures: generateAuthUrl(state) + * returns the URL string and stashes the PKCE verifier in the in-process map, + * and handleCallback(code, state) reads it back from there. + * + * See the security-service copy for the full flow documentation. + */ +import { generators } from 'openid-client' +import { oidcService } from './oidc' +import { User } from '../types/shared' + +export class InvalidCredentialsError extends Error { + constructor(message = 'Invalid credentials') { + super(message) + this.name = 'InvalidCredentialsError' + } +} + +export class AuthentikUnavailableError extends Error { + constructor(message = 'Authentication service unavailable') { + super(message) + this.name = 'AuthentikUnavailableError' + } +} + +export class UnsupportedFlowStageError extends Error { + constructor(stage: string) { + super(`Unsupported Authentik flow stage: ${stage} (only identification+password is supported server-side)`) + this.name = 'UnsupportedFlowStageError' + } +} + +/** Minimal cookie jar for the short-lived per-login Authentik session. */ +class CookieJar { + private cookies = new Map() + + absorb(res: { headers: Headers }): void { + const anyHeaders = res.headers as Headers & { getSetCookie?: () => string[] } + const setCookies: string[] = + typeof anyHeaders.getSetCookie === 'function' + ? anyHeaders.getSetCookie() + : ([res.headers.get('set-cookie')].filter(Boolean) as string[]) + for (const sc of setCookies) { + const pair = sc.split(';')[0] + const eq = pair.indexOf('=') + if (eq <= 0) continue + const name = pair.slice(0, eq).trim() + const value = pair.slice(eq + 1).trim() + if (value === '' || /max-age=0|expires=thu, 01 jan 1970/i.test(sc)) { + this.cookies.delete(name) + } else { + this.cookies.set(name, value) + } + } + } + + header(): string { + return [...this.cookies].map(([k, v]) => `${k}=${v}`).join('; ') + } + + get(name: string): string | undefined { + return this.cookies.get(name) + } +} + +function authentikBaseUrl(): string { + if (process.env.AUTHENTIK_BASE_URL) { + return process.env.AUTHENTIK_BASE_URL.replace(/\/$/, '') + } + const issuer = + process.env.AUTHENTIK_ISSUER_URL || + 'http://localhost:9000/application/o/fuzefront/' + return new URL(issuer).origin +} + +function authFlowSlug(): string { + return process.env.AUTHENTIK_AUTH_FLOW_SLUG || 'default-authentication-flow' +} + +function redirectUri(): string { + return ( + process.env.AUTHENTIK_REDIRECT_URI || + 'http://fuzefront.dev.local/api/auth/oidc/callback' + ) +} + +interface FlowChallenge { + component?: string + type?: string + to?: string + password_fields?: boolean + response_errors?: Record> + [key: string]: unknown +} + +async function flowRequest( + base: string, + slug: string, + jar: CookieJar, + body?: Record +): Promise { + // Authentik commonly answers the first executor request with a 302 that + // establishes the session cookie (Location points back into the flow), so + // follow same-origin redirects manually, carrying the jar. Per Django 302 + // semantics a redirected POST is retried as GET. + let url = `${base}/api/v3/flows/executor/${slug}/?query=` + let method: 'GET' | 'POST' = body ? 'POST' : 'GET' + let payload: string | undefined = body ? JSON.stringify(body) : undefined + + for (let hop = 0; hop < 10; hop++) { + const headers: Record = { + Accept: 'application/json', + // Django CSRF validates Referer on secure requests. + Referer: `${base}/`, + } + const cookie = jar.header() + if (cookie) headers['Cookie'] = cookie + if (method === 'POST') { + headers['Content-Type'] = 'application/json' + const csrf = jar.get('authentik_csrf') + if (csrf) headers['X-CSRFToken'] = csrf + } + + let res: Response + try { + res = await fetch(url, { + method, + headers, + body: payload, + redirect: 'manual', + }) + } catch (err) { + throw new AuthentikUnavailableError( + `Authentik unreachable at ${base}: ${(err as Error).message}` + ) + } + jar.absorb(res) + + const loc = res.headers.get('location') + if ([301, 302, 303, 307, 308].includes(res.status) && loc) { + const nextUrl = new URL(loc, url) + // The jar carries authentik_session/authentik_csrf — never present those + // cookies to any host other than Authentik itself. + if (nextUrl.origin !== new URL(base).origin) { + throw new AuthentikUnavailableError( + `Flow executor redirected off-origin to ${nextUrl.origin} — refusing to follow with session cookies` + ) + } + url = nextUrl.toString() + // 301/302/303 rewrite the retry as GET (Django semantics); 307/308 + // preserve the original method and body per HTTP spec. + if (res.status !== 307 && res.status !== 308) { + method = 'GET' + payload = undefined + } + continue + } + const contentTypeEarly = res.headers.get('content-type') || '' + if (!res.ok) { + // A 4xx with a JSON body is a FLOW response (e.g. 400 carrying + // response_errors for rejected credentials) — return it so the caller + // maps it to 401, instead of mislabeling it a 503 outage. + if (res.status < 500 && contentTypeEarly.includes('json')) { + return (await res.json()) as FlowChallenge + } + // Surface Authentik's own error payload — a bare status is undebuggable + // from CI logs (e.g. 403 CSRF vs 404 unknown flow slug). + const bodySnippet = (await res.text().catch(() => '')).slice(0, 300) + throw new AuthentikUnavailableError( + `Authentik flow executor HTTP ${res.status} at ${url}: ${bodySnippet}` + ) + } + if (!contentTypeEarly.includes('json')) { + const bodySnippet = (await res.text().catch(() => '')).slice(0, 300) + throw new AuthentikUnavailableError( + `Authentik flow executor returned non-JSON (${contentTypeEarly}) at ${url}: ${bodySnippet}` + ) + } + return (await res.json()) as FlowChallenge + } + throw new AuthentikUnavailableError('Authentik flow executor redirect loop') +} + +function challengeHasCredentialErrors(challenge: FlowChallenge): boolean { + const errs = challenge.response_errors + if (!errs) return false + return Object.keys(errs).length > 0 +} + +/** + * Authenticate email+password against Authentik and return the synced platform + * User. Throws InvalidCredentialsError / AuthentikUnavailableError / + * UnsupportedFlowStageError. + */ +export async function authentikPasswordLogin( + email: string, + password: string +): Promise { + if (!oidcService.isConfigured() || !oidcService.isInitialized()) { + throw new AuthentikUnavailableError('OIDC is not configured/initialized') + } + + const base = authentikBaseUrl() + const slug = authFlowSlug() + const jar = new CookieJar() + + // ── Drive the authentication flow ───────────────────────────────────────── + let challenge = await flowRequest(base, slug, jar) + const MAX_STEPS = 6 + let authenticated = false + + for (let step = 0; step < MAX_STEPS; step++) { + const component = challenge.component || challenge.type || '' + + if (component === 'xak-flow-redirect') { + authenticated = true + break + } + + if (component === 'ak-stage-identification') { + const body: Record = { + component, + uid_field: email, + } + if (challenge.password_fields) body.password = password + challenge = await flowRequest(base, slug, jar, body) + } else if (component === 'ak-stage-password') { + challenge = await flowRequest(base, slug, jar, { component, password }) + } else if (component === 'ak-stage-access-denied') { + throw new InvalidCredentialsError() + } else { + throw new UnsupportedFlowStageError(component || 'unknown') + } + + if (challengeHasCredentialErrors(challenge)) { + throw new InvalidCredentialsError() + } + } + + if (!authenticated) { + const last = challenge.component || challenge.type || 'unknown' + if (last !== 'xak-flow-redirect') { + throw new UnsupportedFlowStageError(last) + } + } + + // ── Complete OIDC code+PKCE with the authenticated session ──────────────── + // The monolith's generateAuthUrl(state) stores the PKCE verifier in its + // in-process map keyed by state; handleCallback(code, state) reads it back. + const state = generators.state() + const authorizeUrl = oidcService.generateAuthUrl(state) + const target = redirectUri() + + let location = authorizeUrl + let code: string | null = null + let returnedState: string | null = null + + for (let hop = 0; hop < 10; hop++) { + let res: Response + try { + res = await fetch(location, { + method: 'GET', + headers: { Cookie: jar.header(), Accept: 'application/json' }, + redirect: 'manual', + }) + } catch (err) { + throw new AuthentikUnavailableError( + `Authorize request failed: ${(err as Error).message}` + ) + } + jar.absorb(res) + + const next = res.headers.get('location') + if (!next) { + throw new UnsupportedFlowStageError( + `authorize returned HTTP ${res.status} without redirect (consent flow?)` + ) + } + const resolvedUrl = new URL(next, location) + const resolved = resolvedUrl.toString() + if (resolved.startsWith(target)) { + const u = new URL(resolved) + code = u.searchParams.get('code') + returnedState = u.searchParams.get('state') + const err = u.searchParams.get('error') + if (err) { + throw new AuthentikUnavailableError(`Authorize error: ${err}`) + } + break + } + // Continue only within Authentik's own origin — the jar must not follow + // an arbitrary redirect elsewhere. + if (resolvedUrl.origin !== new URL(base).origin) { + throw new AuthentikUnavailableError( + `Authorize flow redirected off-origin to ${resolvedUrl.origin} — refusing to follow with session cookies` + ) + } + location = resolved + } + + if (!code) { + throw new AuthentikUnavailableError( + 'Authorize flow did not produce an authorization code' + ) + } + + return oidcService.handleCallback(code, returnedState || state) +} diff --git a/frontend/e2e/post-prod/live-smoke.spec.ts b/frontend/e2e/post-prod/live-smoke.spec.ts index 7cc22b77..350447ec 100644 --- a/frontend/e2e/post-prod/live-smoke.spec.ts +++ b/frontend/e2e/post-prod/live-smoke.spec.ts @@ -9,10 +9,10 @@ import { test, expect, type Page, type ConsoleMessage } from '@playwright/test' * 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. - * 4. /login offers ONLY SSO sign-in: "Sign in with Authentik" and - * "Sign in with Google" are visible, and the internal (local - * email/password) form is NOT rendered. Google auth is federated through - * Authentik, so both buttons start the same OIDC flow. + * 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. * 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 @@ -80,20 +80,22 @@ test.describe('FuzeFront live post-prod smoke', () => { expect(body.defaultMethod).toBe('oidc') }) - test('4. /login offers ONLY Authentik + Google sign-in (no local form)', async ({ page }) => { + test('4. /login offers the native credentials form + Google (no Authentik redirect button)', async ({ page }) => { await page.goto('/login') - // SSO buttons are the only sign-in affordances. - await expect( - page.getByRole('button', { name: /sign in with authentik/i }) - ).toBeVisible({ timeout: 20_000 }) + // Native credentials form — the default UI. With oidcConfigured=true these + // fields are verified AGAINST AUTHENTIK server-side (no redirect). + await expect(page.locator('input[type="email"]')).toBeVisible({ timeout: 20_000 }) + await expect(page.locator('input[type="password"]')).toBeVisible() + await expect(page.getByRole('button', { name: /^sign in$/i })).toBeVisible() + + // Google is federated through Authentik and offered as a button. await expect( page.getByRole('button', { name: /sign in with google/i }) ).toBeVisible() - // The internal (local email/password) sign-in must NOT be rendered. - await expect(page.locator('input[type="email"]')).toHaveCount(0) - await expect(page.locator('input[type="password"]')).toHaveCount(0) + // The old "Sign in with Authentik" redirect button is gone. + await expect(page.getByText(/sign in with authentik/i)).toHaveCount(0) // The demo-credentials disclosure is gone. await expect(page.getByText(/demo credentials/i)).toHaveCount(0) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bea34493..b38604a3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,306 +1,305 @@ -import React, { useEffect, useRef, useState } from 'react' -import { Routes, Route, Navigate, useParams } from 'react-router-dom' -import { useCurrentUser, useAppContext, MenuItem } from './lib/shared' -import { installBridge, bridge } from './platform/bridge' -import { AppRegistryProvider } from './platform/appRegistry' -import StandaloneAppSurface from './components/StandaloneAppSurface' -import ApplicationsPage from './pages/ApplicationsPage' -import AddApplicationPage from './pages/AddApplicationPage' -import Layout from './components/Layout' -import LoginPage from './pages/LoginPage' -import DashboardPage from './pages/DashboardPage' -import AdminPage from './pages/AdminPage' -import OrganizationPage from './pages/OrganizationPage' -import StatusPage from './pages/StatusPage' -import HelpPage from './pages/HelpPage' -import TestPage from './pages/TestPage' -import { FederatedAppLoader } from './components/FederatedAppLoader' -import { getCurrentUser } from './services/api' -import websocketService from './services/websocket' -import { UserProfileManagement } from './components/UserProfileManagement' -import { WorkspaceProvisioningGate } from './components/WorkspaceProvisioningGate' -import CreateOrganizationPage from './pages/CreateOrganizationPage' -import AcceptInvitePage from './pages/AcceptInvitePage' -import BillingPage from './pages/BillingPage' - -// Authentication wrapper component -function AuthWrapper({ children }: { children: React.ReactNode }) { - const { state, dispatch } = useAppContext() - const [isLoading, setIsLoading] = useState(true) - - useEffect(() => { - const initializeAuth = async () => { - try { - const token = localStorage.getItem('authToken') - console.log('Initializing auth - token found:', !!token) - - if (token) { - try { - console.log('Attempting to get current user...') - const user = await getCurrentUser() - console.log('Successfully got user:', user.email) - dispatch({ type: 'SET_USER', payload: user }) - } catch (userError) { - // Token is invalid or expired - console.error('Failed to get current user:', userError) - localStorage.removeItem('authToken') - } - } else { - console.log('No auth token found') - } - } catch (error) { - console.error('Failed to initialize auth:', error) - localStorage.removeItem('authToken') - } finally { - setIsLoading(false) - } - } - - initializeAuth() - }, [dispatch]) - - // Connect to WebSocket and listen for app status changes - useEffect(() => { - if (state.user) { - // Connect to WebSocket when user is authenticated - websocketService.connect() - - // Listen for app status changes - const handleAppStatusChange = (data: { - appId: string - appName: string - status: string - isHealthy: boolean - timestamp: string - }) => { - console.log(`šŸ“” App ${data.appName} is now ${data.status}`) - dispatch({ - type: 'UPDATE_APP_STATUS', - payload: { - appId: data.appId, - isHealthy: data.isHealthy, - }, - }) - } - - // Listen for new app registrations - const handleAppRegistered = (data: { app: any; timestamp: string }) => { - console.log(`šŸš€ New app registered: ${data.app.name}`) - dispatch({ - type: 'ADD_APP', - payload: data.app, - }) - } - - websocketService.on('app-status-changed', handleAppStatusChange) - websocketService.on('app-registered', handleAppRegistered) - - // Cleanup on unmount - return () => { - websocketService.off('app-status-changed', handleAppStatusChange) - websocketService.off('app-registered', handleAppRegistered) - websocketService.disconnect() - } - } - }, [state.user, dispatch]) - - // Install the platform bridge once, and keep its context + menu wiring in - // sync with host state so runtime-loaded apps can read live context and call - // shared services (toaster, menu) through window.__FUZEFRONT__. - const menuRef = useRef([]) - useEffect(() => { - menuRef.current = state.menuItems - }, [state.menuItems]) - - useEffect(() => { - installBridge({ - onMenuAdd: (appId, items) => { - const others = menuRef.current.filter(m => m.appId !== appId) - const added = items.map(i => ({ ...i, category: 'app' as const, appId })) - dispatch({ type: 'SET_MENU_ITEMS', payload: [...others, ...added] }) - }, - onMenuRemove: appId => { - dispatch({ - type: 'SET_MENU_ITEMS', - payload: menuRef.current.filter(m => m.appId !== appId), - }) - }, - socket: { - on: (event, handler) => websocketService.onServer(event, handler), - off: (event, handler) => websocketService.offServer(event, handler), - emit: (event, payload) => websocketService.emitServer(event, payload), - isConnected: () => websocketService.isConnected(), - }, - }) - }, [dispatch]) - - useEffect(() => { - bridge.setContext({ - user: state.user - ? { - id: state.user.id, - email: state.user.email, - roles: state.user.roles, - } - : null, - apps: state.apps.map(a => ({ id: a.id, name: a.name })), - activeApp: state.activeApp - ? { id: state.activeApp.id, name: state.activeApp.name } - : null, - isPlatformMode: true, - }) - }, [state.user, state.apps, state.activeApp]) - - if (isLoading) { - return ( -
-
Loading...
-
- ) - } - - return <>{children} -} - -function App() { - return ( - - {/* Bind the app-registry client (same-origin /api/v1/app-registry) once, - above all routes that read or mutate the registry. */} - - - - - ) -} - -function AppContent() { - const { isAuthenticated, user } = useCurrentUser() - const currentPath = typeof window !== 'undefined' ? window.location.pathname : '' - - // Public route: invitation accept page — handle before auth check - if (currentPath.startsWith('/invitations/')) { - return - } - - console.log('AppContent - Authentication state:', { - isAuthenticated, - user: user?.email, - }) - - if (!isAuthenticated) { - console.log('User not authenticated, showing login page') - return - } - - console.log('User authenticated, showing main app') - - // Standalone apps (mode = "standalone") render WITHOUT any portal chrome — - // no side menu, no topbar — on their own surface (frame 04). Short-circuit - // before the portal Layout so the standalone canvas is edge-to-edge. - if (currentPath.startsWith('/standalone/')) { - const slug = decodeURIComponent(currentPath.replace('/standalone/', '').split('/')[0]) - return ( - - - - ) - } - - return ( - - - - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - - ) -} - -// Portal-mode federated app mount (/app/:appId), keyed by the manifest slug. -function AppRoute() { - const { appId } = useParams<{ appId: string }>() - - if (!appId) { - return - } - - return -} - -// Protected admin route -function AdminRoute() { - const { user } = useCurrentUser() - - if (!user?.roles.includes('admin')) { - return ( -
-

šŸ”’ Access Denied

-

You need admin privileges to access this page.

- -
- ) - } - - return -} - -// 404 page -function NotFoundPage() { - return ( -
-

404 - Page Not Found

-

The page you're looking for doesn't exist.

- -
- ) -} - -export default App - +import React, { useEffect, useRef, useState } from 'react' +import { Routes, Route, Navigate, useParams } from 'react-router-dom' +import { useCurrentUser, useAppContext, MenuItem } from './lib/shared' +import { installBridge, bridge } from './platform/bridge' +import { AppRegistryProvider } from './platform/appRegistry' +import StandaloneAppSurface from './components/StandaloneAppSurface' +import ApplicationsPage from './pages/ApplicationsPage' +import AddApplicationPage from './pages/AddApplicationPage' +import Layout from './components/Layout' +import LoginPage from './pages/LoginPage' +import DashboardPage from './pages/DashboardPage' +import AdminPage from './pages/AdminPage' +import OrganizationPage from './pages/OrganizationPage' +import StatusPage from './pages/StatusPage' +import HelpPage from './pages/HelpPage' +import TestPage from './pages/TestPage' +import { FederatedAppLoader } from './components/FederatedAppLoader' +import { getCurrentUser } from './services/api' +import websocketService from './services/websocket' +import { UserProfileManagement } from './components/UserProfileManagement' +import { WorkspaceProvisioningGate } from './components/WorkspaceProvisioningGate' +import CreateOrganizationPage from './pages/CreateOrganizationPage' +import AcceptInvitePage from './pages/AcceptInvitePage' +import BillingPage from './pages/BillingPage' + +// Authentication wrapper component +function AuthWrapper({ children }: { children: React.ReactNode }) { + const { state, dispatch } = useAppContext() + const [isLoading, setIsLoading] = useState(true) + + useEffect(() => { + const initializeAuth = async () => { + try { + const token = localStorage.getItem('authToken') + console.log('Initializing auth - token found:', !!token) + + if (token) { + try { + console.log('Attempting to get current user...') + const user = await getCurrentUser() + console.log('Successfully got user:', user.email) + dispatch({ type: 'SET_USER', payload: user }) + } catch (userError) { + // Token is invalid or expired + console.error('Failed to get current user:', userError) + localStorage.removeItem('authToken') + } + } else { + console.log('No auth token found') + } + } catch (error) { + console.error('Failed to initialize auth:', error) + localStorage.removeItem('authToken') + } finally { + setIsLoading(false) + } + } + + initializeAuth() + }, [dispatch]) + + // Connect to WebSocket and listen for app status changes + useEffect(() => { + if (state.user) { + // Connect to WebSocket when user is authenticated + websocketService.connect() + + // Listen for app status changes + const handleAppStatusChange = (data: { + appId: string + appName: string + status: string + isHealthy: boolean + timestamp: string + }) => { + console.log(`šŸ“” App ${data.appName} is now ${data.status}`) + dispatch({ + type: 'UPDATE_APP_STATUS', + payload: { + appId: data.appId, + isHealthy: data.isHealthy, + }, + }) + } + + // Listen for new app registrations + const handleAppRegistered = (data: { app: any; timestamp: string }) => { + console.log(`šŸš€ New app registered: ${data.app.name}`) + dispatch({ + type: 'ADD_APP', + payload: data.app, + }) + } + + websocketService.on('app-status-changed', handleAppStatusChange) + websocketService.on('app-registered', handleAppRegistered) + + // Cleanup on unmount + return () => { + websocketService.off('app-status-changed', handleAppStatusChange) + websocketService.off('app-registered', handleAppRegistered) + websocketService.disconnect() + } + } + }, [state.user, dispatch]) + + // Install the platform bridge once, and keep its context + menu wiring in + // sync with host state so runtime-loaded apps can read live context and call + // shared services (toaster, menu) through window.__FUZEFRONT__. + const menuRef = useRef([]) + useEffect(() => { + menuRef.current = state.menuItems + }, [state.menuItems]) + + useEffect(() => { + installBridge({ + onMenuAdd: (appId, items) => { + const others = menuRef.current.filter(m => m.appId !== appId) + const added = items.map(i => ({ ...i, category: 'app' as const, appId })) + dispatch({ type: 'SET_MENU_ITEMS', payload: [...others, ...added] }) + }, + onMenuRemove: appId => { + dispatch({ + type: 'SET_MENU_ITEMS', + payload: menuRef.current.filter(m => m.appId !== appId), + }) + }, + socket: { + on: (event, handler) => websocketService.onServer(event, handler), + off: (event, handler) => websocketService.offServer(event, handler), + emit: (event, payload) => websocketService.emitServer(event, payload), + isConnected: () => websocketService.isConnected(), + }, + }) + }, [dispatch]) + + useEffect(() => { + bridge.setContext({ + user: state.user + ? { + id: state.user.id, + email: state.user.email, + roles: state.user.roles, + } + : null, + apps: state.apps.map(a => ({ id: a.id, name: a.name })), + activeApp: state.activeApp + ? { id: state.activeApp.id, name: state.activeApp.name } + : null, + isPlatformMode: true, + }) + }, [state.user, state.apps, state.activeApp]) + + if (isLoading) { + return ( +
+
Loading...
+
+ ) + } + + return <>{children} +} + +function App() { + return ( + + {/* Bind the app-registry client (same-origin /api/v1/app-registry) once, + above all routes that read or mutate the registry. */} + + + + + ) +} + +function AppContent() { + const { isAuthenticated, user } = useCurrentUser() + const currentPath = typeof window !== 'undefined' ? window.location.pathname : '' + + // Public route: invitation accept page — handle before auth check + if (currentPath.startsWith('/invitations/')) { + return + } + + if (import.meta.env.DEV) { + console.log('AppContent - Authentication state:', { + isAuthenticated, + user: user?.email, + }) + } + + if (!isAuthenticated) { + return + } + + // Standalone apps (mode = "standalone") render WITHOUT any portal chrome — + // no side menu, no topbar — on their own surface (frame 04). Short-circuit + // before the portal Layout so the standalone canvas is edge-to-edge. + if (currentPath.startsWith('/standalone/')) { + const slug = decodeURIComponent(currentPath.replace('/standalone/', '').split('/')[0]) + return ( + + + + ) + } + + return ( + + + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + + ) +} + +// Portal-mode federated app mount (/app/:appId), keyed by the manifest slug. +function AppRoute() { + const { appId } = useParams<{ appId: string }>() + + if (!appId) { + return + } + + return +} + +// Protected admin route +function AdminRoute() { + const { user } = useCurrentUser() + + if (!user?.roles.includes('admin')) { + return ( +
+

šŸ”’ Access Denied

+

You need admin privileges to access this page.

+ +
+ ) + } + + return +} + +// 404 page +function NotFoundPage() { + return ( +
+

404 - Page Not Found

+

The page you're looking for doesn't exist.

+ +
+ ) +} + +export default App + diff --git a/frontend/src/__tests__/LoginPage.google-signin.test.tsx b/frontend/src/__tests__/LoginPage.google-signin.test.tsx index 82ae9731..c79d596e 100644 --- a/frontend/src/__tests__/LoginPage.google-signin.test.tsx +++ b/frontend/src/__tests__/LoginPage.google-signin.test.tsx @@ -122,6 +122,8 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { vi.spyOn(authAPI, 'handleOIDCCallback').mockResolvedValue({}) vi.spyOn(authAPI, 'getAuthMethods') vi.spyOn(authAPI, 'loginWithOIDC').mockResolvedValue(undefined) + vi.spyOn(authAPI, 'loginWithAuthentikPassword') + vi.spyOn(authAPI, 'login') vi.spyOn(authAPI, 'getCurrentUser') // Suppress api.ts / component console noise so test output stays clean. @@ -136,14 +138,14 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { vi.restoreAllMocks() }) - // ── 1: OIDC buttons hidden / local form shown when oidcConfigured is false + // ── 1: No Google button, local-auth form fallback when oidcConfigured=false - it('does NOT render OIDC buttons but DOES render the local fallback form when oidcConfigured is false', async () => { + it('renders the credentials form but no Google button when oidcConfigured is false', async () => { vi.mocked(authAPI.getAuthMethods).mockResolvedValue(LOCAL_ONLY_METHODS) render() - // Wait for auth methods to have loaded (the local fallback form is the + // Wait for auth methods to have loaded (the credentials form is the // sentinel — it only renders once authMethods state is set). await waitFor(() => { expect(screen.getByLabelText(/email/i)).toBeInTheDocument() @@ -154,76 +156,114 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { expect(screen.queryByText(/sign in with google/i)).not.toBeInTheDocument() }) - // ── 2: OIDC buttons shown / local form hidden when oidcConfigured is true + // ── 2: Native credentials form + Google button when oidcConfigured is true - it('renders "Sign in with Authentik" and "Sign in with Google" buttons when oidcConfigured is true', async () => { + it('renders the credentials form AND "Sign in with Google" (no Authentik redirect button) when oidcConfigured is true', async () => { vi.mocked(authAPI.getAuthMethods).mockResolvedValue(OIDC_METHODS) render() await waitFor(() => { expect( - screen.getByRole('button', { name: /sign in with authentik/i }) + screen.getByRole('button', { name: /sign in with google/i }) ).toBeInTheDocument() }) - expect( - screen.getByRole('button', { name: /sign in with google/i }) - ).toBeInTheDocument() + // Default UI components for credentials — always present. + expect(screen.getByLabelText(/email/i)).toBeInTheDocument() + expect(screen.getByLabelText(/password/i)).toBeInTheDocument() + // The redirect button is gone — Authentik is driven server-side instead. + expect(screen.queryByText(/sign in with authentik/i)).not.toBeInTheDocument() }) - it('does NOT render the local email/password form when oidcConfigured is true (Authentik-only sign-in)', async () => { + // ── 2a: Form submit verifies credentials AGAINST AUTHENTIK when configured + + it('submitting the form calls loginWithAuthentikPassword (not local login) when oidcConfigured is true', async () => { + const mockUser = { + id: 'user-1', + email: 'someone@example.com', + firstName: 'Some', + lastName: 'One', + roles: ['user'], + } + const setUser = vi.fn() + ;(sharedMock.useCurrentUser as ReturnType).mockReturnValue( + makeUserCtx({ setUser }) + ) vi.mocked(authAPI.getAuthMethods).mockResolvedValue(OIDC_METHODS) + vi.mocked(authAPI.loginWithAuthentikPassword).mockResolvedValue({ + token: 'jwt-authentik', + sessionId: 'sess-ak', + user: mockUser, + } as any) render() await waitFor(() => { - expect( - screen.getByRole('button', { name: /sign in with authentik/i }) - ).toBeInTheDocument() + expect(screen.getByLabelText(/email/i)).toBeInTheDocument() }) - // The internal (local) sign-in capability must be hidden: no email or - // password inputs, no local submit button. - expect(screen.queryByLabelText(/email/i)).not.toBeInTheDocument() - expect(screen.queryByLabelText(/password/i)).not.toBeInTheDocument() - expect(screen.queryByRole('button', { name: /^sign in$/i })).not.toBeInTheDocument() - }) + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'someone@example.com' }, + }) + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: 'hunter22' }, + }) + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: /^sign in$/i })) + }) - // ── 2b: Clicking the Google button starts the same Authentik OIDC flow ─── + expect(authAPI.loginWithAuthentikPassword).toHaveBeenCalledWith({ + email: 'someone@example.com', + password: 'hunter22', + }) + expect(authAPI.login).not.toHaveBeenCalled() + expect(setUser).toHaveBeenCalledWith(mockUser) + expect(locationStub.href).toBe('/dashboard') + }) - it('clicking "Sign in with Google" calls authAPI.loginWithOIDC (Google is federated via Authentik)', async () => { - vi.mocked(authAPI.getAuthMethods).mockResolvedValue(OIDC_METHODS) + it('submitting the form calls the LOCAL login when oidcConfigured is false', async () => { + vi.mocked(authAPI.getAuthMethods).mockResolvedValue(LOCAL_ONLY_METHODS) + vi.mocked(authAPI.login).mockResolvedValue({ + token: 'jwt-local', + sessionId: 'sess-local', + user: { id: 'u2', email: 'dev@local', firstName: 'D', lastName: 'V', roles: ['user'] }, + } as any) render() await waitFor(() => { - expect( - screen.getByRole('button', { name: /sign in with google/i }) - ).toBeInTheDocument() + expect(screen.getByLabelText(/email/i)).toBeInTheDocument() }) + fireEvent.change(screen.getByLabelText(/email/i), { + target: { value: 'dev@local' }, + }) + fireEvent.change(screen.getByLabelText(/password/i), { + target: { value: 'pw' }, + }) await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /sign in with google/i })) + fireEvent.click(screen.getByRole('button', { name: /^sign in$/i })) }) - expect(authAPI.loginWithOIDC).toHaveBeenCalledTimes(1) + expect(authAPI.login).toHaveBeenCalledTimes(1) + expect(authAPI.loginWithAuthentikPassword).not.toHaveBeenCalled() }) - // ── 3: Clicking the OIDC button calls loginWithOIDC ───────────────────── + // ── 2b: Clicking the Google button starts the Authentik OIDC redirect ──── - it('clicking "Sign in with Authentik" calls authAPI.loginWithOIDC', async () => { + it('clicking "Sign in with Google" calls authAPI.loginWithOIDC (Google is federated via Authentik)', async () => { vi.mocked(authAPI.getAuthMethods).mockResolvedValue(OIDC_METHODS) render() await waitFor(() => { expect( - screen.getByRole('button', { name: /sign in with authentik/i }) + screen.getByRole('button', { name: /sign in with google/i }) ).toBeInTheDocument() }) await act(async () => { - fireEvent.click(screen.getByRole('button', { name: /sign in with authentik/i })) + fireEvent.click(screen.getByRole('button', { name: /sign in with google/i })) }) expect(authAPI.loginWithOIDC).toHaveBeenCalledTimes(1) @@ -302,7 +342,7 @@ describe('LoginPage — OIDC / Google Sign-In UI', () => { // Wait for auth methods so the page is fully settled. await waitFor(() => { expect( - screen.getByRole('button', { name: /sign in with authentik/i }) + screen.getByRole('button', { name: /sign in with google/i }) ).toBeInTheDocument() }) diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index cad7790a..ef5573f0 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -76,8 +76,11 @@ function LoginPage() { } } - // Log environment information on component mount + // Log environment information on component mount (dev-only: this block also + // fired a /health probe and dumped env/localStorage details into the prod + // console on every login-page visit). useEffect(() => { + if (!import.meta.env.DEV) return console.log('šŸ  LoginPage mounted - Environment Info:', { timestamp: new Date().toISOString(), currentURL: window.location.href, @@ -124,38 +127,28 @@ function LoginPage() { }) }, []) - const handleLocalLogin = async (e: React.FormEvent) => { + // Credentials form submit. When Authentik/OIDC is configured the credentials + // are verified AGAINST AUTHENTIK (server-side flow-executor — no redirect); + // the local users-table login is only the fallback for stacks without + // Authentik (local dev, CI ephemeral environments). + const handleCredentialsLogin = async (e: React.FormEvent) => { e.preventDefault() - console.log('šŸŽÆ Local login form submitted:', { - email, - passwordLength: password.length, - timestamp: new Date().toISOString(), - }) - setLoading(true) setError('') try { - console.log('šŸ”„ Starting local login process...') - const { token, user } = await authAPI.login({ email, password }) - - console.log('šŸŽ‰ Local login successful:', { - hasToken: !!token, - hasUser: !!user, - userEmail: user?.email, - userRoles: user?.roles, - }) + const { token, user } = authMethods?.oidcConfigured + ? await authAPI.loginWithAuthentikPassword({ email, password }) + : await authAPI.login({ email, password }) if (token && user) { - console.log('šŸ‘¤ Setting user in context...') setUser(user) - console.log('šŸ”„ Redirecting to dashboard...') window.location.href = '/dashboard' } else { throw new Error('Invalid response from server') } } catch (err: any) { - console.error('āŒ Local login error:', err) + console.error('āŒ Login error:', err) let errorMessage = err.response?.data?.error || err.message || 'Login failed' if (err.code === 'NETWORK_ERROR' || !err.response) { @@ -307,31 +300,58 @@ function LoginPage() { )} - {/* OIDC Authentication Options — the ONLY sign-in paths when Authentik is - configured. Google auth is federated through Authentik (the platform - never contacts Google directly), so both buttons start the same OIDC - flow; Authentik presents Google as an identity provider. */} + {/* Credentials form — the DEFAULT sign-in UI. When Authentik/OIDC is + configured, submitting verifies the credentials against AUTHENTIK + server-side (no redirect); Authentik stays the sole identity + authority. Without Authentik (local dev / CI stacks) the same form + falls back to the local users-table login. */} + {authMethods && ( +
+
+ + setEmail(e.target.value)} + required + /> +
+ +
+ + setPassword(e.target.value)} + required + /> +
+ + +
+ )} + + {/* Google sign-in — federated through Authentik (the platform never + contacts Google directly), so the button starts the Authentik OIDC + redirect flow where Google is offered as the identity provider. */} {authMethods?.oidcConfigured && ( -
- +
+ or +
+
-

- Single Sign-On via Authentik -

)} - {/* Local (email/password) authentication is ONLY offered as a fallback - when Authentik/OIDC is not configured (local dev, CI ephemeral - stacks). In production Authentik is the sole identity authority, so - this form is intentionally hidden there. */} - {authMethods && !authMethods.oidcConfigured && ( -
-
- - setEmail(e.target.value)} - required - /> -
- -
- - setPassword(e.target.value)} - required - /> -
- - -
- )} -