From 924e354788dee58352dae5e70f2f5e6181edb4b8 Mon Sep 17 00:00:00 2001 From: Zakaria Date: Fri, 16 Oct 2026 19:35:27 -0400 Subject: [PATCH] feat: added a login page with Authentik authentication --- .env.example | 18 ++ README.md | 8 + docker-compose.yml | 9 + docker/Dockerfile | 2 + packages/api/package.json | 3 +- packages/api/scripts/hash-password.ts | 7 + packages/api/src/auth.ts | 223 ++++++++++++++++++ packages/api/src/index.cors.test.ts | 10 +- packages/api/src/index.ts | 4 + packages/api/src/loginPage.ts | 61 +++++ packages/frontend/src/api/api.ts | 20 +- .../src/components/EcrImageBrowser.tsx | 2 +- packages/frontend/src/components/Layout.tsx | 11 +- .../components/StorageObjectBrowser.test.tsx | 20 ++ .../src/components/StorageObjectBrowser.tsx | 4 +- packages/frontend/src/index.css | 84 ++++--- pnpm-lock.yaml | 8 + svgAsset/authentik.svg | 1 + 18 files changed, 441 insertions(+), 54 deletions(-) create mode 100644 packages/api/scripts/hash-password.ts create mode 100644 packages/api/src/auth.ts create mode 100644 packages/api/src/loginPage.ts create mode 100644 svgAsset/authentik.svg diff --git a/.env.example b/.env.example index 91b5317..0c106f6 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,21 @@ AWS_REGION=us-east-1 AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test PORT=4501 + +# Console authentication. Generate a hash with: +# cd packages/api && bun run scripts/hash-password.ts 'choose-a-long-password' +FLOCI_UI_ADMIN_USER=admin +FLOCI_UI_ADMIN_PASSWORD_HASH= +# At least 32 random characters, for example: openssl rand -base64 48 +FLOCI_UI_SESSION_SECRET= +FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS=1800 +FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS=28800 +# Set false only for local HTTP development. Keep true behind HTTPS reverse proxies. +FLOCI_UI_SECURE_COOKIES=true + +# Optional Authentik / OIDC mode. These are server-only values. +# FLOCI_UI_AUTH_MODE=oidc +# FLOCI_UI_OIDC_ISSUER=https://authentik.example.com/application/o/the-cloud +# FLOCI_UI_OIDC_CLIENT_ID= +# FLOCI_UI_OIDC_CLIENT_SECRET= +# FLOCI_UI_PUBLIC_BASE_URL=https://thecloud.example.com diff --git a/README.md b/README.md index 93cd239..561804f 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,14 @@ docker compose --profile multicloud up Open [http://localhost:4500](http://localhost:4500). +## Exposing To The Internet + +Floci UI requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts ''`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`. + +Put only Floci UI behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind Floci core (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. + +Set `FLOCI_UI_AUTH_MODE=oidc` to use an OIDC provider such as Authentik. Configure `FLOCI_UI_OIDC_ISSUER`, `FLOCI_UI_OIDC_CLIENT_ID`, `FLOCI_UI_OIDC_CLIENT_SECRET`, and `FLOCI_UI_PUBLIC_BASE_URL`; the callback is `${FLOCI_UI_PUBLIC_BASE_URL}/api/auth/oidc/callback`. + ## What The UI Actually Exposes Today The sidebar and Console Home are rendered from `GET /api/clouds/:cloud/services`, diff --git a/docker-compose.yml b/docker-compose.yml index bd32526..4518626 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,6 +37,15 @@ services: FLOCI_GCP_ENDPOINT: http://floci-gcp:4588 FLOCI_GCP_PROJECT: floci-local PORT: "4501" + FLOCI_UI_ADMIN_USER: ${FLOCI_UI_ADMIN_USER:-} + FLOCI_UI_ADMIN_PASSWORD_HASH: ${FLOCI_UI_ADMIN_PASSWORD_HASH:-} + FLOCI_UI_SESSION_SECRET: ${FLOCI_UI_SESSION_SECRET:-} + FLOCI_UI_SECURE_COOKIES: ${FLOCI_UI_SECURE_COOKIES:-false} + FLOCI_UI_AUTH_MODE: ${FLOCI_UI_AUTH_MODE:-local} + FLOCI_UI_OIDC_ISSUER: ${FLOCI_UI_OIDC_ISSUER:-} + FLOCI_UI_OIDC_CLIENT_ID: ${FLOCI_UI_OIDC_CLIENT_ID:-} + FLOCI_UI_OIDC_CLIENT_SECRET: ${FLOCI_UI_OIDC_CLIENT_SECRET:-} + FLOCI_UI_PUBLIC_BASE_URL: ${FLOCI_UI_PUBLIC_BASE_URL:-} extra_hosts: - "host.docker.internal:host-gateway" depends_on: diff --git a/docker/Dockerfile b/docker/Dockerfile index 1baed23..ead9af1 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -27,6 +27,8 @@ RUN apk add --no-cache ca-certificates libstdc++ WORKDIR /app COPY --from=api-build /app/server ./server COPY --from=frontend-build /app/packages/frontend/dist ./public +COPY TheCloud.png ./public/TheCloud.png +COPY svgAsset/authentik.svg ./public/authentik.svg ENV PORT=4500 EXPOSE 4500 CMD ["./server"] diff --git a/packages/api/package.json b/packages/api/package.json index b85b6b9..cd244c0 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -7,7 +7,7 @@ "dev": "bun run --watch src/index.ts", "start": "bun run src/index.ts", "type-check": "tsc --noEmit", - "test": "bun test" + "test": "FLOCI_UI_ADMIN_USER=admin FLOCI_UI_ADMIN_PASSWORD_HASH='$argon2id$v=19$m=65536,t=2,p=1$I5n/tqYyZO0S+4z++KcAlpAJVu442Ivi7KxPl2CXoO4$mRSyN9R5ACFosgnfnd6S5ePoKrz2XBfVf4DTfG7T6LQ' FLOCI_UI_SESSION_SECRET=01234567890123456789012345678901 FLOCI_UI_SECURE_COOKIES=false bun test" }, "dependencies": { "@aws-sdk/client-api-gateway": "^3.1076.0", @@ -45,6 +45,7 @@ "@aws-sdk/util-dynamodb": "^3.996.7", "dotenv": "^17.4.2", "hono": "^4.12.27", + "jose": "^6.2.12", "kafkajs": "^2.2.4", "mssql": "^12.7.0", "pg": "^8.22.0", diff --git a/packages/api/scripts/hash-password.ts b/packages/api/scripts/hash-password.ts new file mode 100644 index 0000000..61763d7 --- /dev/null +++ b/packages/api/scripts/hash-password.ts @@ -0,0 +1,7 @@ +const password = process.argv[2] + +if (!password) { + throw new Error('Usage: bun run scripts/hash-password.ts ') +} + +console.log(await Bun.password.hash(password, {algorithm: 'argon2id'})) diff --git a/packages/api/src/auth.ts b/packages/api/src/auth.ts new file mode 100644 index 0000000..fc8fa0f --- /dev/null +++ b/packages/api/src/auth.ts @@ -0,0 +1,223 @@ +import {timingSafeEqual} from 'node:crypto' +import {createRemoteJWKSet, jwtVerify} from 'jose' +import type {Context, MiddlewareHandler} from 'hono' +import {deleteCookie, getCookie, setCookie} from 'hono/cookie' +import {renderLoginPage} from './loginPage.ts' + +const SESSION_COOKIE = 'floci_ui_session' +const CSRF_COOKIE = 'floci_ui_csrf' +const LOGIN_WINDOW_MS = 15 * 60_000 +const LOGIN_ATTEMPTS = 5 + +type AuthConfig = { + mode: 'local' | 'oidc' + adminUser?: string + passwordHash?: string + secret: string + idleTimeoutMs: number + absoluteTimeoutMs: number + secureCookies: boolean + oidc?: {issuer: string; clientId: string; clientSecret: string; publicBaseUrl: string} +} + +type Session = {username: string; createdAt: number; lastSeenAt: number; csrfToken: string} +type Attempt = {count: number; firstAt: number; lockedUntil: number} + +const sessions = new Map() +const attempts = new Map() +const oidcRequests = new Map() + +export function authConfig(env = process.env): AuthConfig { + const mode = (env.FLOCI_UI_AUTH_MODE ?? 'local') as 'local' | 'oidc' + if (mode !== 'local' && mode !== 'oidc') throw new Error('FLOCI_UI_AUTH_MODE must be local or oidc') + const secret = required(env.FLOCI_UI_SESSION_SECRET, 'FLOCI_UI_SESSION_SECRET') + if (secret.length < 32) throw new Error('FLOCI_UI_SESSION_SECRET must be at least 32 characters') + const idleTimeoutMs = seconds(env.FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS, 1800) + const absoluteTimeoutMs = seconds(env.FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS, 28_800) + if (idleTimeoutMs > absoluteTimeoutMs) throw new Error('FLOCI_UI_SESSION_IDLE_TIMEOUT_SECONDS cannot exceed FLOCI_UI_SESSION_ABSOLUTE_TIMEOUT_SECONDS') + if (mode === 'oidc') { + const issuer = `${required(env.FLOCI_UI_OIDC_ISSUER, 'FLOCI_UI_OIDC_ISSUER').replace(/\/$/, '')}/` + return {mode, secret, idleTimeoutMs, absoluteTimeoutMs, secureCookies: env.FLOCI_UI_SECURE_COOKIES !== 'false', oidc: {issuer, clientId: required(env.FLOCI_UI_OIDC_CLIENT_ID, 'FLOCI_UI_OIDC_CLIENT_ID'), clientSecret: required(env.FLOCI_UI_OIDC_CLIENT_SECRET, 'FLOCI_UI_OIDC_CLIENT_SECRET'), publicBaseUrl: required(env.FLOCI_UI_PUBLIC_BASE_URL, 'FLOCI_UI_PUBLIC_BASE_URL').replace(/\/$/, '')}} + } + const adminUser = required(env.FLOCI_UI_ADMIN_USER, 'FLOCI_UI_ADMIN_USER') + const passwordHash = required(env.FLOCI_UI_ADMIN_PASSWORD_HASH, 'FLOCI_UI_ADMIN_PASSWORD_HASH') + if (!passwordHash.startsWith('$argon2id$') && !passwordHash.startsWith('$2')) throw new Error('FLOCI_UI_ADMIN_PASSWORD_HASH must be an argon2id or bcrypt hash') + return {mode, adminUser, passwordHash, secret, idleTimeoutMs, absoluteTimeoutMs, secureCookies: env.FLOCI_UI_SECURE_COOKIES !== 'false'} +} + +export function authMiddleware(config: AuthConfig): MiddlewareHandler { + return async (c, next) => { + const styleNonce = Buffer.from(crypto.getRandomValues(new Uint8Array(16))).toString('base64url') + c.set('styleNonce', styleNonce) + applySecurityHeaders(c, config, styleNonce) + if (c.req.method === 'OPTIONS') return next() + if (isPublic(c.req.path)) return next() + const session = await sessionFor(c, config) + if (!session) return unauthenticated(c) + if (stateChanging(c.req.method) && !csrfValid(c, session)) return c.json({error: 'CSRF validation failed'}, 403) + await next() + } +} + +export function authRoutes(config: AuthConfig): MiddlewareHandler { + return async (c, next) => { + if (c.req.path === '/login' && c.req.method === 'GET') return renderLogin(c, config) + if (c.req.path === '/api/auth/login' && c.req.method === 'POST') return login(c, config) + if (c.req.path === '/api/auth/oidc/login' && c.req.method === 'GET') return oidcLogin(c, config) + if (c.req.path === '/api/auth/oidc/callback' && c.req.method === 'GET') return oidcCallback(c, config) + if (c.req.path === '/api/auth/logout' && c.req.method === 'POST') return logout(c, config) + if (c.req.path === '/api/auth/session' && c.req.method === 'GET') { + const session = await sessionFor(c, config) + return session ? c.json({authenticated: true, username: session.username, csrfToken: session.csrfToken}) : c.json({authenticated: false}, 401) + } + return next() + } +} + +async function login(c: Context, config: AuthConfig): Promise { + if (config.mode !== 'local') return c.json({error: 'Local login is disabled'}, 404) + const form = await c.req.parseBody() + const username = typeof form.username === 'string' ? form.username : '' + const password = typeof form.password === 'string' ? form.password : '' + const csrf = typeof form.csrf === 'string' ? form.csrf : c.req.header('x-floci-csrf') ?? '' + if (!sameToken(getCookie(c, CSRF_COOKIE), csrf)) return loginFailure(c, 403) + const key = `${clientIp(c)}:${username.toLowerCase()}` + if (locked(key)) return loginFailure(c, 429) + const usernameMatches = sameToken(username, config.adminUser) + const passwordMatches = await Bun.password.verify(password, config.passwordHash!) + if (!usernameMatches || !passwordMatches) { + recordFailure(key) + return loginFailure(c, 401) + } + attempts.delete(key) + await createSession(c, config, config.adminUser!) + const redirectTo = safeReturnTo(typeof form.returnTo === 'string' ? form.returnTo : undefined) + return acceptsHtml(c) ? c.redirect(redirectTo, 302) : c.json({ok: true, redirectTo}) +} + +async function oidcLogin(c: Context, config: AuthConfig): Promise { + if (!config.oidc) return c.notFound() + const discovery = await oidcDiscovery(config.oidc.issuer) + const state = crypto.randomUUID() + const nonce = crypto.randomUUID() + const verifier = Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('base64url') + const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)) + const challenge = Buffer.from(digest).toString('base64url') + oidcRequests.set(state, {nonce, verifier, returnTo: safeReturnTo(c.req.query('returnTo')), expiresAt: Date.now() + 10 * 60_000}) + const url = new URL(discovery.authorization_endpoint) + url.search = new URLSearchParams({response_type: 'code', client_id: config.oidc.clientId, redirect_uri: `${config.oidc.publicBaseUrl}/api/auth/oidc/callback`, scope: 'openid profile email', state, nonce, code_challenge: challenge, code_challenge_method: 'S256'}).toString() + return c.redirect(url.toString(), 302) +} + +async function oidcCallback(c: Context, config: AuthConfig): Promise { + if (!config.oidc) return c.notFound() + const state = c.req.query('state') + const code = c.req.query('code') + const request = state ? oidcRequests.get(state) : undefined + if (!state || !code || !request || request.expiresAt < Date.now()) return c.json({error: 'Invalid OIDC callback'}, 400) + oidcRequests.delete(state) + const discovery = await oidcDiscovery(config.oidc.issuer) + const tokenResponse = await fetch(discovery.token_endpoint, {method: 'POST', headers: {'content-type': 'application/x-www-form-urlencoded'}, body: new URLSearchParams({grant_type: 'authorization_code', code, redirect_uri: `${config.oidc.publicBaseUrl}/api/auth/oidc/callback`, client_id: config.oidc.clientId, client_secret: config.oidc.clientSecret, code_verifier: request.verifier})}) + const tokens = await tokenResponse.json() as {id_token?: string} + if (!tokenResponse.ok || !tokens.id_token) return c.json({error: 'OIDC token exchange failed'}, 401) + const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri)) + const verified = await jwtVerify(tokens.id_token, jwks, {issuer: config.oidc.issuer, audience: config.oidc.clientId}) + if (verified.payload.nonce !== request.nonce) return c.json({error: 'OIDC nonce validation failed'}, 401) + const username = String(verified.payload.preferred_username ?? verified.payload.email ?? verified.payload.sub ?? '') + if (!username) return c.json({error: 'OIDC identity has no usable subject'}, 401) + await createSession(c, config, username) + return c.redirect(request.returnTo, 302) +} + +async function createSession(c: Context, config: AuthConfig, username: string): Promise { + const id = crypto.randomUUID() + const csrfToken = crypto.randomUUID() + sessions.set(id, {username, createdAt: Date.now(), lastSeenAt: Date.now(), csrfToken}) + setCookie(c, SESSION_COOKIE, `${id}.${await signature(id, config.secret)}`, cookieOptions(config)) + setCookie(c, CSRF_COOKIE, csrfToken, {...cookieOptions(config), httpOnly: false}) +} + +async function logout(c: Context, config: AuthConfig): Promise { + const raw = getCookie(c, SESSION_COOKIE) + const id = raw?.split('.', 1)[0] + if (id) sessions.delete(id) + deleteCookie(c, SESSION_COOKIE, {path: '/', secure: config.secureCookies, sameSite: 'Lax'}) + deleteCookie(c, CSRF_COOKIE, {path: '/', secure: config.secureCookies, sameSite: 'Lax'}) + return c.json({ok: true}) +} + +async function sessionFor(c: Context, config: AuthConfig): Promise { + const raw = getCookie(c, SESSION_COOKIE) + if (!raw) return null + const [id, signed] = raw.split('.') + if (!id || !signed || !sameToken(signed, await signature(id, config.secret))) return null + const session = sessions.get(id) + if (!session) return null + const now = Date.now() + if (now - session.lastSeenAt > config.idleTimeoutMs || now - session.createdAt > config.absoluteTimeoutMs) { + sessions.delete(id) + return null + } + session.lastSeenAt = now + return session +} + +function csrfValid(c: Context, session: Session): boolean { + return sameToken(getCookie(c, CSRF_COOKIE), session.csrfToken) && sameToken(c.req.header('x-floci-csrf'), session.csrfToken) +} + +function renderLogin(c: Context, config: AuthConfig): Response { + const csrf = getCookie(c, CSRF_COOKIE) ?? crypto.randomUUID() + if (!getCookie(c, CSRF_COOKIE)) setCookie(c, CSRF_COOKIE, csrf, {...cookieOptions(config), httpOnly: false}) + const returnTo = safeReturnTo(c.req.query('returnTo')) + return c.html(renderLoginPage({ + mode: config.mode, + csrf, + returnTo, + error: loginErrorMessage(c.req.query('error')), + styleNonce: String(c.get('styleNonce')), + })) +} + +function loginPage(c: Context, config: AuthConfig): Response { + const csrf = getCookie(c, CSRF_COOKIE) ?? crypto.randomUUID() + if (!getCookie(c, CSRF_COOKIE)) setCookie(c, CSRF_COOKIE, csrf, {...cookieOptions(config), httpOnly: false}) + const returnTo = safeReturnTo(c.req.query('returnTo')) + const content = config.mode === 'oidc' + ? `
` + : `
` + const error = loginErrorMessage(c.req.query('error')) + return c.html(`Welcome back | TheCloud
Floci DevToolsLocal AWS, on your own infrastructure.
Secure console

Welcome back

Sign in with your account to access the console.

${error ? `` : ''}${content}
`) +} + +function loginFailure(c: Context, status: 401 | 403 | 429): Response { + if (acceptsHtml(c)) return c.redirect('/login?error=1', 302) + return c.json({error: status === 429 ? 'Too many sign-in attempts. Try again later.' : 'Invalid username or password.'}, status) +} + +function loginErrorMessage(code: string | undefined): string | null { + if (!code) return null + if (code === 'denied') return 'Your sign-in was cancelled. Please try again when you are ready.' + if (code === 'expired') return 'Your sign-in session expired. Please start again.' + return 'We could not complete your sign-in. Please try again.' +} + +function isPublic(path: string): boolean { return path === '/login' || path === '/TheCloud.png' || path === '/authentik.svg' || path === '/api/auth/login' || path === '/api/auth/oidc/login' || path === '/api/auth/oidc/callback' || path === '/api/auth/session' || path === '/api/health' } +function unauthenticated(c: Context): Response { return c.req.path.startsWith('/api/') ? c.json({error: 'Authentication required'}, 401) : c.redirect(`/login?returnTo=${encodeURIComponent(safeReturnTo(c.req.path + new URL(c.req.url).search))}`, 302) } +function safeReturnTo(value?: string): string { return value && value.startsWith('/') && !value.startsWith('//') ? value : '/console/aws' } +function escapeHtml(value: string): string { return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) } +function acceptsHtml(c: Context): boolean { return c.req.header('accept')?.includes('text/html') ?? false } +function stateChanging(method: string): boolean { return !['GET', 'HEAD', 'OPTIONS'].includes(method) } +function cookieOptions(config: AuthConfig) { return {httpOnly: true, secure: config.secureCookies, sameSite: 'Lax' as const, path: '/', maxAge: Math.floor(config.absoluteTimeoutMs / 1000)} } +function seconds(value: string | undefined, fallback: number): number { const parsed = Number(value ?? fallback); if (!Number.isInteger(parsed) || parsed < 60) throw new Error('Session timeout values must be whole seconds of at least 60'); return parsed * 1000 } +function required(value: string | undefined, name: string): string { if (!value?.trim()) throw new Error(`${name} is required`); return value } +async function oidcDiscovery(issuer: string): Promise<{authorization_endpoint: string; token_endpoint: string; jwks_uri: string}> { const response = await fetch(`${issuer}.well-known/openid-configuration`); if (!response.ok) throw new Error('Unable to load OIDC discovery document'); return response.json() } +async function signature(value: string, secret: string): Promise { const bytes = await crypto.subtle.sign('HMAC', await crypto.subtle.importKey('raw', new TextEncoder().encode(secret), {name: 'HMAC', hash: 'SHA-256'}, false, ['sign']), new TextEncoder().encode(value)); return Buffer.from(bytes).toString('base64url') } +function sameToken(left: string | undefined, right: string | undefined): boolean { if (!left || !right) return false; const a = Buffer.from(left); const b = Buffer.from(right); return a.length === b.length && timingSafeEqual(a, b) } +function locked(key: string): boolean { const attempt = attempts.get(key); return Boolean(attempt && attempt.lockedUntil > Date.now()) } +function recordFailure(key: string): void { const now = Date.now(); const attempt = attempts.get(key); const current = !attempt || now - attempt.firstAt > LOGIN_WINDOW_MS ? {count: 0, firstAt: now, lockedUntil: 0} : attempt; current.count++; if (current.count >= LOGIN_ATTEMPTS) current.lockedUntil = now + Math.min(15 * 60_000, 30_000 * 2 ** (current.count - LOGIN_ATTEMPTS)); attempts.set(key, current) } +// Hono's portable Request API does not expose the peer address. Do not trust a +// client-supplied X-Forwarded-For value until the Bun server is wired to verify +// it against an explicit proxy address allow-list. +function clientIp(_c: Context): string { return 'unknown' } +function applySecurityHeaders(c: Context, config: AuthConfig, styleNonce: string): void { c.header('Content-Security-Policy', `default-src 'self'; script-src 'self' 'nonce-${styleNonce}' 'sha256-mgwziK4YXYUt2nNB711bt/nNzs3je0rpErGfX/6eqmc='; style-src 'self' 'nonce-${styleNonce}' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com; img-src 'self' data:; base-uri 'self'; frame-ancestors 'none'; object-src 'none'`); c.header('X-Frame-Options', 'DENY'); c.header('X-Content-Type-Options', 'nosniff'); c.header('Referrer-Policy', 'same-origin'); if (config.secureCookies) c.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains') } diff --git a/packages/api/src/index.cors.test.ts b/packages/api/src/index.cors.test.ts index 006b169..fab173a 100644 --- a/packages/api/src/index.cors.test.ts +++ b/packages/api/src/index.cors.test.ts @@ -47,9 +47,9 @@ describe('credentialed route CORS', () => { body, })) - expect(trusted.status).toBe(400) + expect(trusted.status).toBe(401) expect(trusted.headers.get('access-control-allow-origin')).toBe(TRUSTED_ORIGIN) - expect(untrusted.status).toBe(400) + expect(untrusted.status).toBe(401) expect(untrusted.headers.get('access-control-allow-origin')).toBeNull() }) @@ -63,9 +63,9 @@ describe('credentialed route CORS', () => { headers: {origin: UNTRUSTED_ORIGIN}, })) - expect(sameOrigin.status).toBe(400) + expect(sameOrigin.status).toBe(401) expect(sameOrigin.headers.get('access-control-allow-origin')).toBeNull() - expect(unrelated.status).toBe(200) + expect(unrelated.status).toBe(401) expect(unrelated.headers.get('access-control-allow-origin')).toBe('*') }) @@ -74,7 +74,7 @@ describe('credentialed route CORS', () => { headers: {origin: UNTRUSTED_ORIGIN}, })) - expect(schema.status).toBe(200) + expect(schema.status).toBe(401) expect(schema.headers.get('access-control-allow-origin')).toBe('*') }) }) diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 03442a8..0abb055 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -3,6 +3,7 @@ import { Hono } from "hono"; import { serveStatic } from "hono/bun"; import { cors } from "hono/cors"; import { logger } from "hono/logger"; +import {authConfig, authMiddleware, authRoutes} from './auth'; import eks from "./routes/eks"; import ec2 from "./routes/ec2"; import secretsmanager from "./routes/secretsmanager"; @@ -12,6 +13,7 @@ import mskConnect from "./routes/mskConnect"; import ses from "./routes/ses"; import elasticache from "./routes/elasticache"; const app = new Hono(); +const authentication = authConfig(); // Secrets Manager and KMS crypto routes handle sensitive values with server-side // AWS credentials, so an unrestricted `cors()` would let any web page in the @@ -42,6 +44,8 @@ app.use( }), ); app.use("*", logger()); +app.use("*", authMiddleware(authentication)); +app.use("*", authRoutes(authentication)); app.route("/api/eks", eks); app.route("/api/ec2", ec2); diff --git a/packages/api/src/loginPage.ts b/packages/api/src/loginPage.ts new file mode 100644 index 0000000..6840275 --- /dev/null +++ b/packages/api/src/loginPage.ts @@ -0,0 +1,61 @@ +type LoginPageOptions = { + mode: 'local' | 'oidc' + csrf: string + returnTo: string + error: string | null + styleNonce: string +} + +export function renderLoginPage(options: LoginPageOptions): string { + const destination = options.returnTo === '/' + ? '' + : `
Landing on ${escapeHtml(options.returnTo)}
` + const alert = options.error ? `` : '' + const signIn = options.mode === 'oidc' + ? `
` + : `
` + + return ` + + + + + +Sign in · TheCloud + + + +
+ +
powered by Flocilocal AWS, your hardware

$

Lambda, SQS, SNS, DynamoDB and Step Functions, running on your own network.

7workflow integrations
$0aws bill
1sign-in, sso + mfa

Sign in

Use your Authentik account to open the TheCloud console.

${destination}${alert}${signIn}
  • Browse and manage local AWS resources
  • Design state machines in Workflow Studio
  • Run executions and inspect every step

TheCloud never sees your password. Sessions end after a period of inactivity.

+ +
quickstart

Same CLI. Different cloud.

Point any AWS SDK or the AWS CLI at your TheCloud endpoint and your code runs unchanged, with nothing leaving your network.

quickstart.sh
# point the AWS CLI at TheCloud (sample values)
+export AWS_ENDPOINT_URL=http://localhost:4566
+export AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test
+export AWS_DEFAULT_REGION=us-east-1
+
+aws s3 mb s3://demo-bucket
+aws sqs create-queue --queue-name fulfilment-queue
+aws stepfunctions list-state-machines
+
$ TheCloud, powered by Flocipress T to switch theme
+ + +` +} + +function escapeHtml(value: string): string { + return value.replace(/[&<>'"]/g, (character) => ({'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character] ?? character)) +} diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 0b676c9..9593132 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -1217,6 +1217,13 @@ export function createApiClient( return { url, init }; }); + client.addRequestInterceptor((url, init) => { + if (!['POST', 'PUT', 'PATCH', 'DELETE'].includes(init.method ?? 'GET')) return {url, init}; + const csrf = document.cookie.split('; ').find((item) => item.startsWith('floci_ui_csrf='))?.split('=')[1]; + if (csrf) (init.headers as Record)['X-Floci-CSRF'] = decodeURIComponent(csrf); + return {url, init}; + }); + // Auth interceptor — attaches Bearer token if available if (getToken) { client.addRequestInterceptor(async (url, init) => { @@ -1237,12 +1244,13 @@ export function createApiClient( }); } - // client.addResponseInterceptor((res) => { - // if (res.status === 401) { - // handleUnauthorizedApiResponse(); - // } - // return res; - // }); + client.addResponseInterceptor((res) => { + if (res.status === 401 && !res.url.includes('/api/auth/')) { + const returnTo = `${window.location.pathname}${window.location.search}`; + window.location.assign(`/login?returnTo=${encodeURIComponent(returnTo)}`); + } + return res; + }); return client; } diff --git a/packages/frontend/src/components/EcrImageBrowser.tsx b/packages/frontend/src/components/EcrImageBrowser.tsx index 79541e9..d713778 100644 --- a/packages/frontend/src/components/EcrImageBrowser.tsx +++ b/packages/frontend/src/components/EcrImageBrowser.tsx @@ -40,7 +40,7 @@ export function EcrImageBrowser({resource, runtimeReachable}: EcrImageBrowserPro {imagesQuery.isLoading &&
Loading images
} {imagesQuery.isSuccess && images.length === 0 &&

No images

Push an image to this repository to see it here.

} {images.length > 0 &&
- +
{images.map((image) => { const tags = Array.isArray(image.body.imageTags) ? image.body.imageTags.filter((tag): tag is string => typeof tag === 'string') : [] diff --git a/packages/frontend/src/components/Layout.tsx b/packages/frontend/src/components/Layout.tsx index 8f40768..70919e6 100644 --- a/packages/frontend/src/components/Layout.tsx +++ b/packages/frontend/src/components/Layout.tsx @@ -1,5 +1,5 @@ import {NavLink, Outlet, useLocation, useSearchParams} from 'react-router-dom' -import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, Moon, Search, Sun} from 'lucide-react' +import {AlertTriangle, ChevronsLeft, ChevronsRight, LayoutDashboard, LogOut, Moon, Search, Sun} from 'lucide-react' import {useCallback, useEffect, useRef, useState} from 'react' import theCloud from '../../../../TheCloud.png' import {useTheme} from '@/lib/useTheme' @@ -220,6 +220,9 @@ export function Layout() {
+
TagsDigestPushedSize