From 1dddc146ff646d6b5b7b3fac2f58277aa1d734a2 Mon Sep 17 00:00:00 2001 From: thebadking Date: Thu, 1 Oct 2026 20:45:54 +0000 Subject: [PATCH] Add authenticated dashboard and route deployment through Kong --- defense_sim_v2/.gitignore | 3 + defense_sim_v2/DEMO_USER.txt | 3 + defense_sim_v2/README.md | 21 ++- .../deploy/defense-sim-backend.service | 3 +- .../deploy/defense-sim-frontend.service | 14 -- defense_sim_v2/deploy/supabase/compose.yml | 94 +++++++++++++ defense_sim_v2/deploy/supabase/frontend.conf | 20 +++ defense_sim_v2/deploy/supabase/migrate.sql | 13 ++ defense_sim_v2/deploy/supabase/roles.sql | 4 + .../deploy/traefik-defense-sim-v2.yml | 19 +-- .../frontend/cypress/e2e/auth.spec.ts | 32 +++++ .../src/components/SimulationScene.vue | 12 +- .../src/composables/useSimulationSocket.ts | 27 +++- defense_sim_v2/frontend/src/config.ts | 5 +- defense_sim_v2/frontend/src/layouts/login.vue | 4 +- .../frontend/src/lib/auth/inviteRedirect.ts | 6 +- defense_sim_v2/frontend/src/modules/auth.ts | 47 +++---- .../frontend/src/modules/router-auth.ts | 14 +- defense_sim_v2/frontend/src/pages/index.vue | 130 +++++++++++++----- defense_sim_v2/frontend/src/pages/login.vue | 91 +++++++----- defense_sim_v2/frontend/src/stores/auth.ts | 2 + defense_sim_v2/frontend/test/auth.test.ts | 57 ++++++++ defense_sim_v2/rebuild_restart.sh | 69 ++++++++-- defense_sim_v2/scripts/bootstrap_auth.py | 82 +++++++++++ defense_sim_v2/scripts/create_demo_user.py | 45 ++++++ defense_sim_v2/src/defense_sim/server/auth.py | 33 +++++ .../src/defense_sim/server/socketio_server.py | 26 +++- defense_sim_v2/tests/test_auth.py | 88 ++++++++++++ 28 files changed, 798 insertions(+), 166 deletions(-) create mode 100644 defense_sim_v2/DEMO_USER.txt delete mode 100644 defense_sim_v2/deploy/defense-sim-frontend.service create mode 100644 defense_sim_v2/deploy/supabase/compose.yml create mode 100644 defense_sim_v2/deploy/supabase/frontend.conf create mode 100644 defense_sim_v2/deploy/supabase/migrate.sql create mode 100644 defense_sim_v2/deploy/supabase/roles.sql create mode 100644 defense_sim_v2/frontend/cypress/e2e/auth.spec.ts create mode 100644 defense_sim_v2/frontend/test/auth.test.ts create mode 100644 defense_sim_v2/scripts/bootstrap_auth.py create mode 100644 defense_sim_v2/scripts/create_demo_user.py create mode 100644 defense_sim_v2/src/defense_sim/server/auth.py create mode 100644 defense_sim_v2/tests/test_auth.py diff --git a/defense_sim_v2/.gitignore b/defense_sim_v2/.gitignore index 7d80a57..68abfd5 100644 --- a/defense_sim_v2/.gitignore +++ b/defense_sim_v2/.gitignore @@ -3,3 +3,6 @@ __pycache__/ *.py[cod] *.egg-info/ .pytest_cache/ +deploy/supabase/.env +deploy/supabase/kong.json.local +deploy/auth.env.local diff --git a/defense_sim_v2/DEMO_USER.txt b/defense_sim_v2/DEMO_USER.txt new file mode 100644 index 0000000..025f753 --- /dev/null +++ b/defense_sim_v2/DEMO_USER.txt @@ -0,0 +1,3 @@ +URL: https://defense.naori.cloud +Username: demo@defense.naori.cloud +Password: Udm3W4tHvEcj1qKGi0oCdCNN diff --git a/defense_sim_v2/README.md b/defense_sim_v2/README.md index 0b1c1d0..5bbd3d6 100644 --- a/defense_sim_v2/README.md +++ b/defense_sim_v2/README.md @@ -30,11 +30,16 @@ Drone navigation defaults to 10 Hz, flight control to 50 Hz, and actuator accele `src/defense_sim` contains transport-independent simulation modules. The `server` package only adapts simulation state and events to Socket.IO. `tests` focuses on physical invariants and reproducibility. See `PROJECT_SPEC.md` for model assumptions and limitations. -The Vue 3 + Vite client in `frontend/` connects with `socket.io-client`, renders backend entity positions and trajectory history in Three.js, provides scenario and device configuration, and displays bounded terminal-style telemetry and event scrollback. Snapshot persistence uses a Supabase migration for per-user row-level security. Configure `VITE_SUPABASE_URL` and `VITE_SUPABASE_ANON_KEY` before applying `frontend/supabase/migrations/20260930000000_simulation_runs.sql`. +The Vue 3 + Vite client in `frontend/` connects with `socket.io-client`, renders backend entity positions and trajectory history in Three.js, provides scenario and device configuration, and displays bounded terminal-style telemetry and event scrollback. Sign-in is required before opening the simulator. Socket.IO validates Supabase access tokens on connection and before start/stop commands. Snapshot persistence uses per-user row-level security. ## Run the server +The backend requires `SUPABASE_URL` and `SUPABASE_ANON_KEY`. The rebuild script configures these automatically. For manual development, load `deploy/auth.env.local` into the environment before starting the backend. + ```bash +set -a +source deploy/auth.env.local +set +a python -m defense_sim.server.socketio_server ``` @@ -42,12 +47,18 @@ Socket.IO emits `simulation_state`, `simulation_event`, `simulation_statistics`, ## Deployment -The site is served at `https://defense.naori.cloud`. The Vue app listens on `192.168.1.194:8088`; the Python Socket.IO backend listens on `192.168.1.194:8000`. Traefik at `192.168.1.10` routes the site and `/socket.io` over HTTPS. User services are defined under `deploy/` and enabled with systemd lingering. +The site is served at `https://defense.naori.cloud`. Traefik in LXC 100 routes all site traffic to Kong at `192.168.1.194:54321`. Kong routes `/auth/v1/` to Supabase Auth, `/rest/v1/` to PostgREST, `/socket.io/` to the Python backend, and all other paths to the internal Nginx frontend container. The frontend has no separately exposed port. -Rebuild the client with: +Start or rebuild the application and its self-hosted auth stack with: ```bash -VITE_SIM_SOCKET_URL=https://defense.naori.cloud pnpm --dir frontend build +./rebuild_restart.sh ``` -Restart backend changes with `systemctl --user restart defense-sim-backend.service`. Traefik's watched dynamic route is `/etc/traefik/conf.d/defense-sim-v2.yml` in LXC 100. +Docker Compose starts the Postgres, Supabase Auth, PostgREST, Kong, and Nginx frontend services in `deploy/supabase/compose.yml`. This uses the components described in [Supabase's Docker self-hosting guide](https://supabase.com/docs/guides/self-hosting/docker). The script generates unique secrets on first run, waits for health checks, applies archive migrations, creates a confirmed demo account, verifies password login, builds the frontend, restarts Kong and Nginx, and updates/restarts the backend user service. The legacy frontend service on port 8088 is disabled. Repeating the command preserves the database, secrets, and demo password. Public signup and anonymous accounts are disabled. + +The demo email is `demo@defense.naori.cloud`. Demo login credentials are recorded in `DEMO_USER.txt` as requested; the runtime copy is stored in `deploy/supabase/.env`. This file and the generated backend environment and gateway configuration are gitignored. Do not commit them. Kong exposes port 54321 for Traefik. Nginx and the database have no published ports. Nginx serves `/login` directly so refreshes work. Database volumes persist independently of frontend rebuilds. No database port is exposed. + +For frontend development, configure `VITE_SUPABASE_URL=http://localhost:54321` and copy the generated `SUPABASE_ANON_KEY` into `VITE_SUPABASE_ANON_KEY` in `frontend/.env.local`. Keep the service-role key server-side. In production the rebuild script supplies the public anon key and uses the site origin for API requests. + +The Traefik route in `deploy/traefik-defense-sim-v2.yml` serves `https://defense.naori.cloud` entirely through Kong at `192.168.1.194:54321`. Its watched dynamic route is `/etc/traefik/conf.d/defense-sim-v2.yml` in LXC 100. The Supabase stack restarts automatically with Docker; the backend user service is enabled at boot with systemd lingering. diff --git a/defense_sim_v2/deploy/defense-sim-backend.service b/defense_sim_v2/deploy/defense-sim-backend.service index d222b33..a5a39f2 100644 --- a/defense_sim_v2/deploy/defense-sim-backend.service +++ b/defense_sim_v2/deploy/defense-sim-backend.service @@ -6,9 +6,10 @@ Wants=network-online.target [Service] Type=simple WorkingDirectory=/home/dre/git/plfm/defense_sim_v2 +EnvironmentFile=/home/dre/git/plfm/defense_sim_v2/deploy/auth.env.local Environment=SIM_BIND_HOST=0.0.0.0 Environment=SIM_PORT=8000 -Environment=SIM_ALLOWED_ORIGINS=https://defense.naori.cloud,http://localhost:3333 +Environment=SIM_ALLOWED_ORIGINS=https://defense.naori.cloud,http://localhost:3333,http://localhost:8088,http://127.0.0.1:8088 ExecStart=/home/dre/git/plfm/defense_sim_v2/.venv/bin/python -m defense_sim.server.socketio_server Restart=on-failure RestartSec=3 diff --git a/defense_sim_v2/deploy/defense-sim-frontend.service b/defense_sim_v2/deploy/defense-sim-frontend.service deleted file mode 100644 index e247adc..0000000 --- a/defense_sim_v2/deploy/defense-sim-frontend.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Defense Sim V2 static Vue frontend -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -WorkingDirectory=/home/dre/git/plfm/defense_sim_v2/frontend -ExecStart=/usr/bin/python3 -m http.server 8088 --bind 0.0.0.0 --directory /home/dre/git/plfm/defense_sim_v2/frontend/dist -Restart=on-failure -RestartSec=3 - -[Install] -WantedBy=default.target diff --git a/defense_sim_v2/deploy/supabase/compose.yml b/defense_sim_v2/deploy/supabase/compose.yml new file mode 100644 index 0000000..166395e --- /dev/null +++ b/defense_sim_v2/deploy/supabase/compose.yml @@ -0,0 +1,94 @@ +name: defense-sim-auth +services: + db: + image: supabase/postgres:17.6.1.136 + restart: unless-stopped + environment: + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Run scripts/bootstrap_auth.py first} + POSTGRES_DB: postgres + JWT_EXP: 3600 + volumes: + - database:/var/lib/postgresql/data + - db-config:/etc/postgresql-custom + - ./roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:ro + - ../../frontend/supabase/migrations:/app-migrations:ro + - ./migrate.sql:/app-migrate.sql:ro + healthcheck: + test: [CMD, pg_isready, -U, postgres, -h, localhost] + interval: 3s + timeout: 5s + retries: 30 + command: [postgres, -c, config_file=/etc/postgresql/postgresql.conf, -c, log_min_messages=warning] + auth: + image: supabase/gotrue:v2.196.0 + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + GOTRUE_API_HOST: 0.0.0.0 + GOTRUE_API_PORT: 9999 + API_EXTERNAL_URL: ${SITE_URL}/auth/v1 + GOTRUE_DB_DRIVER: postgres + GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgres + GOTRUE_SITE_URL: ${SITE_URL} + GOTRUE_DISABLE_SIGNUP: "true" + GOTRUE_JWT_ADMIN_ROLES: service_role + GOTRUE_JWT_AUD: authenticated + GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated + GOTRUE_JWT_EXP: 3600 + GOTRUE_JWT_SECRET: ${JWT_SECRET} + GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" + GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED: "false" + GOTRUE_MAILER_AUTOCONFIRM: "true" + GOTRUE_EXTERNAL_PHONE_ENABLED: "false" + healthcheck: + test: [CMD, wget, --no-verbose, --tries=1, --spider, http://localhost:9999/health] + interval: 3s + timeout: 5s + retries: 30 + rest: + image: postgrest/postgrest:v14.17 + restart: unless-stopped + depends_on: + auth: + condition: service_healthy + environment: + PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/postgres + PGRST_DB_SCHEMAS: public + PGRST_DB_ANON_ROLE: anon + PGRST_JWT_SECRET: ${JWT_SECRET} + PGRST_DB_USE_LEGACY_GUCS: "false" + gateway: + image: kong:2.8.1 + restart: unless-stopped + depends_on: + auth: + condition: service_healthy + rest: + condition: service_started + frontend: + condition: service_started + ports: + - "${KONG_BIND_ADDRESS:-0.0.0.0}:${SUPABASE_PORT:-54321}:8000" + extra_hosts: + - "host.docker.internal:host-gateway" + environment: + KONG_DATABASE: "off" + KONG_DECLARATIVE_CONFIG: /config/kong.json + KONG_DNS_ORDER: LAST,A,CNAME + KONG_PLUGINS: cors,key-auth,acl + KONG_PROXY_ACCESS_LOG: /dev/stdout + KONG_PROXY_ERROR_LOG: /dev/stderr + KONG_ADMIN_LISTEN: "off" + volumes: + - ./kong.json.local:/config/kong.json:ro + frontend: + image: nginx:1.28-alpine + restart: unless-stopped + volumes: + - ../../frontend/dist:/usr/share/nginx/html:ro + - ./frontend.conf:/etc/nginx/conf.d/default.conf:ro +volumes: + database: + db-config: diff --git a/defense_sim_v2/deploy/supabase/frontend.conf b/defense_sim_v2/deploy/supabase/frontend.conf new file mode 100644 index 0000000..edf6fad --- /dev/null +++ b/defense_sim_v2/deploy/supabase/frontend.conf @@ -0,0 +1,20 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + location / { + try_files $uri $uri.html $uri/ /index.html; + } + + location /assets/ { + try_files $uri =404; + expires 1y; + add_header Cache-Control "public, immutable"; + } + + location = /sw.js { + add_header Cache-Control "no-cache"; + } +} diff --git a/defense_sim_v2/deploy/supabase/migrate.sql b/defense_sim_v2/deploy/supabase/migrate.sql new file mode 100644 index 0000000..3cbeacc --- /dev/null +++ b/defense_sim_v2/deploy/supabase/migrate.sql @@ -0,0 +1,13 @@ +\set ON_ERROR_STOP on +BEGIN; +CREATE SCHEMA IF NOT EXISTS app_migrations; +CREATE TABLE IF NOT EXISTS app_migrations.applied (name text PRIMARY KEY, applied_at timestamptz NOT NULL DEFAULT now()); +SELECT NOT EXISTS (SELECT 1 FROM app_migrations.applied WHERE name = '20260930000000_simulation_runs') AS needs_migration \gset +\if :needs_migration +\ir /app-migrations/20260930000000_simulation_runs.sql +GRANT USAGE ON SCHEMA public TO authenticated; +GRANT SELECT, INSERT, DELETE ON public.simulation_runs TO authenticated; +INSERT INTO app_migrations.applied(name) VALUES ('20260930000000_simulation_runs'); +\endif +NOTIFY pgrst, 'reload schema'; +COMMIT; diff --git a/defense_sim_v2/deploy/supabase/roles.sql b/defense_sim_v2/deploy/supabase/roles.sql new file mode 100644 index 0000000..b02d77d --- /dev/null +++ b/defense_sim_v2/deploy/supabase/roles.sql @@ -0,0 +1,4 @@ +-- Match the official Supabase Docker role-password initialization. +\set pgpass `echo "$POSTGRES_PASSWORD"` +ALTER USER authenticator WITH PASSWORD :'pgpass'; +ALTER USER supabase_auth_admin WITH PASSWORD :'pgpass'; diff --git a/defense_sim_v2/deploy/traefik-defense-sim-v2.yml b/defense_sim_v2/deploy/traefik-defense-sim-v2.yml index 8c5b1fd..5ee62ef 100644 --- a/defense_sim_v2/deploy/traefik-defense-sim-v2.yml +++ b/defense_sim_v2/deploy/traefik-defense-sim-v2.yml @@ -4,26 +4,13 @@ http: rule: "Host(`defense.naori.cloud`)" entryPoints: - websecure - service: defense-sim-v2-web + service: defense-sim-v2-kong priority: 10 tls: certResolver: letsencrypt - defense-sim-v2-socketio: - rule: "Host(`defense.naori.cloud`) && PathPrefix(`/socket.io`)" - entryPoints: - - websecure - service: defense-sim-v2-socketio - priority: 100 - tls: - certResolver: letsencrypt - services: - defense-sim-v2-web: + defense-sim-v2-kong: loadBalancer: servers: - - url: "http://192.168.1.194:8088" - defense-sim-v2-socketio: - loadBalancer: - servers: - - url: "http://192.168.1.194:8000" + - url: "http://192.168.1.194:54321" diff --git a/defense_sim_v2/frontend/cypress/e2e/auth.spec.ts b/defense_sim_v2/frontend/cypress/e2e/auth.spec.ts new file mode 100644 index 0000000..c50e31f --- /dev/null +++ b/defense_sim_v2/frontend/cypress/e2e/auth.spec.ts @@ -0,0 +1,32 @@ +context('Simulator authentication', () => { + it('requires sign-in, opens the simulator with the demo account, and signs out', () => { + cy.visit('/') + cy.location('pathname').should('eq', '/login') + cy.get('.signin-panel').should('be.visible') + cy.get('#email').type(Cypress.env('DEMO_EMAIL')) + cy.get('#password').type('incorrect-password', { log: false }) + cy.get('button[type=submit]').click() + cy.get('[role=alert]').should('be.visible') + cy.location('pathname').should('eq', '/login') + cy.get('#password').clear().type(Cypress.env('DEMO_PASSWORD'), { log: false }) + cy.get('button[type=submit]').click() + cy.location('pathname').should('eq', '/') + cy.contains('LINK ONLINE').should('be.visible') + cy.get('.scene-canvas').should('be.visible') + cy.get('.sidebar-toggle').click() + cy.get('#scenario-sidebar').should('not.be.visible') + cy.get('.sidebar-toggle').click() + cy.get('#scenario-sidebar').should('be.visible') + cy.get('#tab-telemetry').click() + cy.get('#view-telemetry').should('be.visible') + cy.get('#view-scene').should('not.be.visible') + cy.get('#tab-scene').click() + cy.get('#view-scene').should('be.visible') + cy.contains('button', 'SIGN OUT').click() + cy.location('pathname').should('eq', '/login') + cy.visit('/') + cy.location('pathname').should('eq', '/login') + cy.reload() + cy.get('.signin-panel').should('be.visible') + }) +}) diff --git a/defense_sim_v2/frontend/src/components/SimulationScene.vue b/defense_sim_v2/frontend/src/components/SimulationScene.vue index 1017df1..f41415a 100644 --- a/defense_sim_v2/frontend/src/components/SimulationScene.vue +++ b/defense_sim_v2/frontend/src/components/SimulationScene.vue @@ -4,7 +4,7 @@ import { AmbientLight, BufferGeometry, ConeGeometry, GridHelper, Group, Line, Li import { OrbitControls } from 'three/addons/controls/OrbitControls.js' import type { SimulationEntity, SimulationState } from '~/types/simulation' -const props = defineProps<{ state: SimulationState | null }>() +const props = withDefaults(defineProps<{ state: SimulationState | null, altitudeScale?: number }>(), { altitudeScale: 1 }) const host = ref() let renderer: WebGLRenderer let camera: PerspectiveCamera @@ -17,7 +17,7 @@ const up = new Vector3(0, 1, 0) const color = { projectile: 0xff6258, drone: 0x5ca8ff } function groundPoint(ned: number[]) { - return new Vector3(ned[1] || 0, -(ned[2] || 0), -(ned[0] || 0)) + return new Vector3(ned[1] || 0, -(ned[2] || 0) * props.altitudeScale, -(ned[0] || 0)) } function makeBody(entity: SimulationEntity) { @@ -87,6 +87,11 @@ function updateScene(state: SimulationState | null) { controls.update() } + // Refresh radar heights without resetting the orbit camera when the visual scale changes. + const guides = scene.getObjectByName('scenario-guides') + for (const site of radarSites) + guides?.getObjectByName(site.id)?.position.copy(groundPoint(site.position_ned_m)) + const currentIds = new Set((state?.entities ?? []).map(entity => entity.id)) for (const [id, item] of entities) { if (!currentIds.has(id)) { @@ -163,6 +168,7 @@ onMounted(() => { }) watch(() => props.state, updateScene) +watch(() => props.altitudeScale, () => updateScene(props.state)) onUnmounted(() => { cancelAnimationFrame(frame) @@ -184,6 +190,6 @@ onUnmounted(() => { diff --git a/defense_sim_v2/frontend/src/composables/useSimulationSocket.ts b/defense_sim_v2/frontend/src/composables/useSimulationSocket.ts index e1f4f49..175579f 100644 --- a/defense_sim_v2/frontend/src/composables/useSimulationSocket.ts +++ b/defense_sim_v2/frontend/src/composables/useSimulationSocket.ts @@ -2,6 +2,7 @@ import type { Socket } from 'socket.io-client' import type { SimulationEvent, SimulationState } from '~/types/simulation' import { io } from 'socket.io-client' import { onMounted, onUnmounted, ref, shallowRef } from 'vue' +import { supabase } from '~/lib/supabaseClient' interface SimulationOptions { projectile_preset: string @@ -42,7 +43,16 @@ export function useSimulationSocket() { || (import.meta.env.PROD ? window.location.origin : 'http://localhost:8000') // Start with polling so networks that block WebSocket still come online. // Socket.IO upgrades the established session to WebSocket when available. - socket = io(url, { autoConnect: true, transports: ['polling', 'websocket'], tryAllTransports: true }) + socket = io(url, { + autoConnect: true, + transports: ['polling', 'websocket'], + tryAllTransports: true, + auth: (callback) => { + void supabase.auth.getSession().then(({ data: { session } }) => { + callback({ token: session?.access_token ?? '' }) + }).catch(() => callback({ token: '' })) + }, + }) socket.on('connect', () => { connected.value = true error.value = '' @@ -80,13 +90,22 @@ export function useSimulationSocket() { onUnmounted(() => socket?.disconnect()) - function start(options: SimulationOptions) { + async function sendCommand(name: string, options = {}) { error.value = '' - socket?.emit('simulation_start', options) + const { data: { session }, error: sessionError } = await supabase.auth.getSession() + if (sessionError || !session) { + error.value = 'Session expired; sign in again' + return + } + socket?.emit(name, { ...options, token: session.access_token }) + } + + function start(options: SimulationOptions) { + void sendCommand('simulation_start', options) } function stop() { - socket?.emit('simulation_stop') + void sendCommand('simulation_stop') } return { state, events, connected, running, error, start, stop } diff --git a/defense_sim_v2/frontend/src/config.ts b/defense_sim_v2/frontend/src/config.ts index e59dd2d..d47a6d5 100644 --- a/defense_sim_v2/frontend/src/config.ts +++ b/defense_sim_v2/frontend/src/config.ts @@ -15,10 +15,13 @@ declare global { } function getSupabaseUrl(): string { + const configured = (import.meta.env.VITE_SUPABASE_URL as string | undefined)?.trim() + if (configured) + return configured.replace(TRAILING_SLASH_RE, '') if (typeof window !== 'undefined' && import.meta.env.PROD) return window.location.origin.replace(TRAILING_SLASH_RE, '') - const raw = (import.meta.env.VITE_SUPABASE_URL as string | undefined)?.trim() || 'http://localhost:54321' + const raw = 'http://localhost:54321' return raw.replace(TRAILING_SLASH_RE, '') } diff --git a/defense_sim_v2/frontend/src/layouts/login.vue b/defense_sim_v2/frontend/src/layouts/login.vue index ec01042..7c2aa3f 100644 --- a/defense_sim_v2/frontend/src/layouts/login.vue +++ b/defense_sim_v2/frontend/src/layouts/login.vue @@ -1,5 +1,3 @@ diff --git a/defense_sim_v2/frontend/src/lib/auth/inviteRedirect.ts b/defense_sim_v2/frontend/src/lib/auth/inviteRedirect.ts index f3bb357..ebb3876 100644 --- a/defense_sim_v2/frontend/src/lib/auth/inviteRedirect.ts +++ b/defense_sim_v2/frontend/src/lib/auth/inviteRedirect.ts @@ -1,7 +1,7 @@ import type { RouteLocationRaw } from 'vue-router' /** Staff auth flows default here when no explicit `redirect` query is provided. */ -export const DEFAULT_POST_LOGIN_PATH = '/dashboard' +export const DEFAULT_POST_LOGIN_PATH = '/' export function stringQuery(q: unknown): string { if (typeof q === 'string') @@ -28,14 +28,14 @@ export function sanitizePostLoginRedirect(raw: string): string { const search = qIdx === -1 ? '' : noHash.slice(qIdx) const segments = pathPart.split('/').filter(Boolean) const pl = pathPart.toLowerCase() + if (pl === '/login' || pl === '/login/') + return DEFAULT_POST_LOGIN_PATH if (segments[0] === 'auth') return DEFAULT_POST_LOGIN_PATH if (pl.includes('/auth/v1')) return DEFAULT_POST_LOGIN_PATH if (segments[0] === 'oauth' && segments[1] === 'callback') return DEFAULT_POST_LOGIN_PATH - if (pathPart === '/' || segments.length === 0) - return DEFAULT_POST_LOGIN_PATH return pathPart + search } diff --git a/defense_sim_v2/frontend/src/modules/auth.ts b/defense_sim_v2/frontend/src/modules/auth.ts index dad9877..3590eba 100644 --- a/defense_sim_v2/frontend/src/modules/auth.ts +++ b/defense_sim_v2/frontend/src/modules/auth.ts @@ -1,40 +1,25 @@ import type { UserModule } from '~/types' -import { onMounted } from 'vue' import { supabase } from '~/lib/supabaseClient' import { useAuthStore } from '~/stores/auth' -export const install: UserModule = ({ isClient, app }) => { +export const install: UserModule = ({ isClient, router }) => { if (!isClient) return - app.component('AuthStateListener', { - setup() { - onMounted(() => { - const auth = useAuthStore() - auth.fetchUser() - - const { data: { subscription } } = supabase.auth.onAuthStateChange(async (event, session) => { - const authStore = useAuthStore() - if (event === 'SIGNED_IN' || event === 'TOKEN_REFRESHED') { - authStore.setUser(session?.user ?? null) - authStore.sessionError = false - } - else if (event === 'SIGNED_OUT') { - authStore.setUser(null) - } - else if (event === 'USER_UPDATED') { - authStore.setUser(session?.user ?? null) - } - if (event === 'TOKEN_REFRESHED' && !session) { - authStore.sessionError = true - } - }) - - if (import.meta.hot) { - import.meta.hot.dispose(() => subscription.unsubscribe()) - } - }) - return () => null - }, + const { data: { subscription } } = supabase.auth.onAuthStateChange((event, session) => { + const authStore = useAuthStore() + if (event === 'INITIAL_SESSION') + return // The route guard verifies the initial user with the auth server. + authStore.handleAuthStateChange(event, session?.user ?? null) + if (event === 'SIGNED_OUT') { + // Keep router work outside the Supabase auth callback's session lock. + setTimeout(() => { + if (router.currentRoute.value.meta.requiresAuth) + void router.replace({ path: '/login', query: { redirect: router.currentRoute.value.fullPath } }) + }, 0) + } }) + + if (import.meta.hot) + import.meta.hot.dispose(() => subscription.unsubscribe()) } diff --git a/defense_sim_v2/frontend/src/modules/router-auth.ts b/defense_sim_v2/frontend/src/modules/router-auth.ts index aa5111c..a0a4bc4 100644 --- a/defense_sim_v2/frontend/src/modules/router-auth.ts +++ b/defense_sim_v2/frontend/src/modules/router-auth.ts @@ -1,5 +1,6 @@ import type { UserModule } from '~/types' import { isGoTrueBrowserPath, isSpaAuthNamespaceGarbage } from '~/lib/auth/authReservedPaths' +import { sanitizePostLoginRedirect, stringQuery } from '~/lib/auth/inviteRedirect' import { supabase } from '~/lib/supabaseClient' import { useAuthStore } from '~/stores/auth' @@ -14,16 +15,21 @@ export const install: UserModule = ({ router, isClient }) => { if (isSpaAuthNamespaceGarbage(to.path)) return { path: '/', replace: true } - supabase.auth.stopAutoRefresh() - - if (to.meta.requiresAuth) { + if (to.meta.requiresAuth || to.path === '/login') { const authStore = useAuthStore() if (!authStore.isReady) await authStore.fetchUser() - if (!authStore.isLoggedIn) + if (to.meta.requiresAuth && !authStore.isLoggedIn) return { path: '/login', query: { redirect: to.fullPath }, replace: true } + if (to.path === '/login' && authStore.isLoggedIn) + return { path: sanitizePostLoginRedirect(stringQuery(to.query.redirect)), replace: true } } + if (to.meta.requiresAuth) + supabase.auth.startAutoRefresh() + else + supabase.auth.stopAutoRefresh() + return true }) } diff --git a/defense_sim_v2/frontend/src/pages/index.vue b/defense_sim_v2/frontend/src/pages/index.vue index 2bfd39f..50d47d7 100644 --- a/defense_sim_v2/frontend/src/pages/index.vue +++ b/defense_sim_v2/frontend/src/pages/index.vue @@ -1,14 +1,47 @@ + +meta: + requiresAuth: true + + + + diff --git a/defense_sim_v2/frontend/src/stores/auth.ts b/defense_sim_v2/frontend/src/stores/auth.ts index 7bc3c90..5d8f9b2 100644 --- a/defense_sim_v2/frontend/src/stores/auth.ts +++ b/defense_sim_v2/frontend/src/stores/auth.ts @@ -39,6 +39,7 @@ export const useAuthStore = defineStore('auth', { try { const { data, error } = await supabase.auth.getUser() if (error) { + this.user = null if (error.message?.includes('Auth session missing') || error.message?.includes('Invalid Refresh Token') || error.message?.includes('Refresh Token Not Found')) { this.sessionError = true this.user = null @@ -52,6 +53,7 @@ export const useAuthStore = defineStore('auth', { return true } catch (err) { + this.user = null console.error('Error fetching user:', err) if (err instanceof Error && (err.message?.includes('Auth session missing') || err.message?.includes('Invalid Refresh Token') || err.message?.includes('Refresh Token Not Found'))) { this.sessionError = true diff --git a/defense_sim_v2/frontend/test/auth.test.ts b/defense_sim_v2/frontend/test/auth.test.ts new file mode 100644 index 0000000..7972023 --- /dev/null +++ b/defense_sim_v2/frontend/test/auth.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it, vi } from 'vitest' +import { sanitizePostLoginRedirect } from '../src/lib/auth/inviteRedirect' + +const store = { isReady: false, isLoggedIn: false, fetchUser: vi.fn() } +const refresh = { startAutoRefresh: vi.fn(), stopAutoRefresh: vi.fn() } +vi.mock('../src/stores/auth', () => ({ useAuthStore: () => store })) +vi.mock('../src/lib/supabaseClient', () => ({ supabase: { auth: refresh } })) + +async function guard() { + store.isReady = false + store.isLoggedIn = false + store.fetchUser.mockReset().mockImplementation(async () => { store.isReady = true }) + refresh.startAutoRefresh.mockClear() + refresh.stopAutoRefresh.mockClear() + const beforeEach = vi.fn() + const { install } = await import('../src/modules/router-auth') + install({ router: { beforeEach }, isClient: true } as any) + return beforeEach.mock.calls[0][0] +} + +describe('required sign-in', () => { + it('redirects signed-out visitors before allowing the simulation route', async () => { + const check = await guard() + expect(await check({ path: '/', fullPath: '/?run=demo', meta: { requiresAuth: true } })) + .toEqual({ path: '/login', query: { redirect: '/?run=demo' }, replace: true }) + expect(store.fetchUser).toHaveBeenCalledOnce() + }) + + it('allows an authenticated user and enables session refresh', async () => { + const check = await guard() + store.fetchUser.mockImplementation(async () => { store.isReady = true; store.isLoggedIn = true }) + expect(await check({ path: '/', fullPath: '/', meta: { requiresAuth: true } })).toBe(true) + expect(refresh.startAutoRefresh).toHaveBeenCalledOnce() + }) + + it('keeps the login route available when signed out', async () => { + const check = await guard() + expect(await check({ path: '/login', meta: {}, query: {} })).toBe(true) + }) + + it('takes signed-in visitors from login to the simulator', async () => { + const check = await guard() + store.isReady = true + store.isLoggedIn = true + expect(await check({ path: '/login', meta: {}, query: {} })).toEqual({ path: '/', replace: true }) + }) +}) + +describe('login redirects', () => { + it.each(['', '/', '/login', '/login/', '//other.test', 'https://other.test', '/auth/v1/token', '/oauth/callback'])('returns to the simulator for %s', (path) => { + expect(sanitizePostLoginRedirect(path)).toBe('/') + }) + + it('preserves an internal destination and query', () => { + expect(sanitizePostLoginRedirect('/?run=demo')).toBe('/?run=demo') + }) +}) diff --git a/defense_sim_v2/rebuild_restart.sh b/defense_sim_v2/rebuild_restart.sh index d2cf6c0..53bbb23 100755 --- a/defense_sim_v2/rebuild_restart.sh +++ b/defense_sim_v2/rebuild_restart.sh @@ -4,10 +4,9 @@ set -Eeuo pipefail PROJECT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" FRONTEND_DIR="${PROJECT_DIR}/frontend" BACKEND_SERVICE="defense-sim-backend.service" -FRONTEND_PORT="${FRONTEND_PORT:-8088}" BACKEND_PORT="${BACKEND_PORT:-8000}" -for command in pnpm systemctl curl; do +for command in pnpm systemctl curl python3 docker; do if ! command -v "${command}" >/dev/null 2>&1; then echo "Required command not found: ${command}" >&2 exit 1 @@ -19,26 +18,75 @@ if [[ ! -d "${FRONTEND_DIR}/node_modules" ]]; then exit 1 fi +echo "Preparing the self-hosted authentication stack..." +python3 "${PROJECT_DIR}/scripts/bootstrap_auth.py" +# Generated values are stable across restarts; never enable shell tracing here. +source "${PROJECT_DIR}/deploy/supabase/.env" +COMPOSE=(docker compose --env-file "${PROJECT_DIR}/deploy/supabase/.env" -f "${PROJECT_DIR}/deploy/supabase/compose.yml") +"${COMPOSE[@]}" up -d --wait --wait-timeout 180 + +echo "Waiting for the auth API..." +auth_ready=false +for attempt in {1..30}; do + if curl --fail --silent --max-time 2 -H "apikey: ${SUPABASE_ANON_KEY}" \ + "http://127.0.0.1:${SUPABASE_PORT}/auth/v1/health" -o /dev/null; then + auth_ready=true + break + fi + sleep 1 +done +if [[ "${auth_ready}" != true ]]; then + echo "Auth API did not become ready." >&2 + exit 1 +fi +"${COMPOSE[@]}" exec -T db psql -U postgres -d postgres -f /app-migrate.sql +python3 "${PROJECT_DIR}/scripts/create_demo_user.py" + echo "Building frontend..." -(cd "${FRONTEND_DIR}" && pnpm build) +(cd "${FRONTEND_DIR}" && VITE_SUPABASE_URL= VITE_SUPABASE_ANON_KEY="${SUPABASE_ANON_KEY}" pnpm build) if [[ ! -s "${FRONTEND_DIR}/dist/index.html" ]]; then echo "Frontend build did not produce dist/index.html." >&2 exit 1 fi +echo "Updating user service definitions..." +mkdir -p "${HOME}/.config/systemd/user" +for service in defense-sim-backend.service; do + install -m 644 "${PROJECT_DIR}/deploy/${service}" "${HOME}/.config/systemd/user/${service}" +done +systemctl --user daemon-reload +systemctl --user enable defense-sim-backend.service +# Kong serves the frontend container; retire the old separately exposed server. +if systemctl --user cat defense-sim-frontend.service >/dev/null 2>&1; then + systemctl --user disable --now defense-sim-frontend.service +fi + echo "Restarting ${BACKEND_SERVICE}..." systemctl --user restart "${BACKEND_SERVICE}" systemctl --user is-active --quiet "${BACKEND_SERVICE}" +"${COMPOSE[@]}" restart frontend gateway -echo "Checking local frontend on port ${FRONTEND_PORT}..." -curl --fail --silent --show-error --max-time 10 "http://127.0.0.1:${FRONTEND_PORT}/" -o /dev/null +echo "Checking frontend through Kong on port ${SUPABASE_PORT}..." +frontend_ready=false +for attempt in {1..20}; do + if curl --fail --silent --max-time 2 "http://127.0.0.1:${SUPABASE_PORT}/" -o /dev/null; then + frontend_ready=true + break + fi + sleep 1 +done +if [[ "${frontend_ready}" != true ]]; then + echo "Frontend did not start on port ${SUPABASE_PORT}." >&2 + "${COMPOSE[@]}" ps >&2 + exit 1 +fi -echo "Checking local Socket.IO handshake on port ${BACKEND_PORT}..." +echo "Checking Socket.IO handshake through Kong..." handshake="" for attempt in {1..20}; do if handshake="$(curl --fail --silent --max-time 2 \ - "http://127.0.0.1:${BACKEND_PORT}/socket.io/?EIO=4&transport=polling" 2>/dev/null)" \ + "http://127.0.0.1:${SUPABASE_PORT}/socket.io/?EIO=4&transport=polling" 2>/dev/null)" \ && [[ "${handshake}" == \0\{\"sid\"* ]]; then break fi @@ -50,4 +98,9 @@ if [[ "${handshake}" != \0\{\"sid\"* ]]; then exit 1 fi -echo "Frontend rebuilt and both services are responding." +echo "Checking same-origin login and auth endpoints..." +curl --fail --silent --show-error --max-time 10 "http://127.0.0.1:${SUPABASE_PORT}/login" -o /dev/null +curl --fail --silent --show-error --max-time 10 -H "apikey: ${SUPABASE_ANON_KEY}" \ + "http://127.0.0.1:${SUPABASE_PORT}/auth/v1/health" -o /dev/null + +echo "Frontend rebuilt; Kong, authentication, demo login, and the simulation backend are responding." diff --git a/defense_sim_v2/scripts/bootstrap_auth.py b/defense_sim_v2/scripts/bootstrap_auth.py new file mode 100644 index 0000000..21c2fc7 --- /dev/null +++ b/defense_sim_v2/scripts/bootstrap_auth.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Generate stable local auth secrets. Nothing privileged is emitted to stdout.""" +import base64 +import hashlib +import hmac +import json +import os +from pathlib import Path +import secrets +import time + +ROOT = Path(__file__).resolve().parents[1] +AUTH_DIR = ROOT / 'deploy/supabase' + + +def read_env(path): + return dict(line.split('=', 1) for line in path.read_text().splitlines() if line and not line.startswith('#')) + + +def jwt(secret, role): + def encode(value): + return base64.urlsafe_b64encode(json.dumps(value, separators=(',', ':')).encode()).rstrip(b'=').decode() + now = int(time.time()) + message = encode({'alg': 'HS256', 'typ': 'JWT'}) + '.' + encode({'role': role, 'iss': 'supabase', 'iat': now, 'exp': now + 10 * 365 * 86400}) + signature = base64.urlsafe_b64encode(hmac.new(secret.encode(), message.encode(), hashlib.sha256).digest()).rstrip(b'=').decode() + return message + '.' + signature + + +def write_private(path, content): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, 'w') as output: + output.write(content) + path.chmod(0o600) + + +def main(): + env_path = AUTH_DIR / '.env' + if not env_path.exists(): + secret = secrets.token_hex(32) + demo_path = ROOT / 'DEMO_USER.txt' + demo = dict(line.split(': ', 1) for line in demo_path.read_text().splitlines() if ': ' in line) if demo_path.exists() else {} + values = { + 'POSTGRES_PASSWORD': secrets.token_hex(24), + 'JWT_SECRET': secret, + 'SUPABASE_ANON_KEY': jwt(secret, 'anon'), + 'SUPABASE_SERVICE_KEY': jwt(secret, 'service_role'), + 'SUPABASE_PORT': os.getenv('SUPABASE_PORT', '54321'), + 'KONG_BIND_ADDRESS': os.getenv('KONG_BIND_ADDRESS', '0.0.0.0'), + 'SITE_URL': os.getenv('SITE_URL', 'https://defense.naori.cloud'), + 'DEMO_EMAIL': demo.get('Username', 'demo@defense.naori.cloud'), + 'DEMO_PASSWORD': demo.get('Password', secrets.token_urlsafe(18)), + } + write_private(env_path, ''.join(f'{key}={value}\n' for key, value in values.items())) + print('Generated auth secrets and demo credentials in deploy/supabase/.env') + values = read_env(env_path) + config = { + '_format_version': '2.1', '_transform': True, + 'consumers': [ + {'username': 'anon', 'keyauth_credentials': [{'key': values['SUPABASE_ANON_KEY']}]}, + {'username': 'service_role', 'keyauth_credentials': [{'key': values['SUPABASE_SERVICE_KEY']}]}, + ], + 'services': [ + {'name': name, 'url': url, 'routes': [{'name': name, 'paths': [path], 'strip_path': True}], + 'plugins': [{'name': 'cors'}, {'name': 'key-auth', 'config': {'hide_credentials': False}}]} + for name, url, path in [('auth', 'http://auth:9999/', '/auth/v1/'), ('rest', 'http://rest:3000/', '/rest/v1/')] + ], + } + config['services'].extend([ + {'name': 'simulation', 'url': 'http://host.docker.internal:8000', + 'routes': [{'name': 'simulation', 'paths': ['/socket.io/'], 'strip_path': False}], + 'read_timeout': 60000, 'write_timeout': 60000}, + {'name': 'frontend', 'url': 'http://frontend:80', + 'routes': [{'name': 'frontend', 'paths': ['/'], 'strip_path': False}]}, + ]) + # Kong runs as UID 1337; its bind-mounted config must be readable inside the container. + write_private(AUTH_DIR / 'kong.json.local', json.dumps(config, indent=2)) + (AUTH_DIR / 'kong.json.local').chmod(0o644) + write_private(ROOT / 'deploy/auth.env.local', f"SUPABASE_URL=http://127.0.0.1:{values['SUPABASE_PORT']}\nSUPABASE_ANON_KEY={values['SUPABASE_ANON_KEY']}\n") + + +if __name__ == '__main__': + main() diff --git a/defense_sim_v2/scripts/create_demo_user.py b/defense_sim_v2/scripts/create_demo_user.py new file mode 100644 index 0000000..94487b7 --- /dev/null +++ b/defense_sim_v2/scripts/create_demo_user.py @@ -0,0 +1,45 @@ +#!/usr/bin/env python3 +"""Provision and verify the demo account without replacing an existing password.""" +import json +import sys +import urllib.error +import urllib.request +from bootstrap_auth import AUTH_DIR, read_env + + +def request(url, key, method='GET', payload=None): + headers = {'apikey': key, 'Authorization': f'Bearer {key}', 'Content-Type': 'application/json'} + body = json.dumps(payload).encode() if payload is not None else None + with urllib.request.urlopen(urllib.request.Request(url, data=body, headers=headers, method=method), timeout=15) as response: + return json.load(response) + + +def main(): + values = read_env(AUTH_DIR / '.env') + url = f"http://127.0.0.1:{values['SUPABASE_PORT']}/auth/v1" + credentials = {'email': values['DEMO_EMAIL'], 'password': values['DEMO_PASSWORD']} + existing = None + page = 1 + while True: + users = request(f'{url}/admin/users?page={page}&per_page=100', values['SUPABASE_SERVICE_KEY']).get('users', []) + existing = next((user for user in users if user.get('email') == credentials['email']), None) + if existing or len(users) < 100: + break + page += 1 + if not existing: + request(f'{url}/admin/users', values['SUPABASE_SERVICE_KEY'], 'POST', { + **credentials, 'email_confirm': True, 'user_metadata': {'name': 'Demo user'}, + }) + print('Created demo account.') + session = request(f'{url}/token?grant_type=password', values['SUPABASE_ANON_KEY'], 'POST', credentials) + if not session.get('access_token') or not session.get('user', {}).get('email') == credentials['email']: + raise RuntimeError('Demo login verification failed') + print('Demo password login verified. Credentials are stored in deploy/supabase/.env.') + + +if __name__ == '__main__': + try: + main() + except urllib.error.HTTPError as error: + print(f'Demo account setup failed (HTTP {error.code}).', file=sys.stderr) + sys.exit(1) diff --git a/defense_sim_v2/src/defense_sim/server/auth.py b/defense_sim_v2/src/defense_sim/server/auth.py new file mode 100644 index 0000000..63bc1ea --- /dev/null +++ b/defense_sim_v2/src/defense_sim/server/auth.py @@ -0,0 +1,33 @@ +"""Verify simulation clients with the configured Supabase Auth server.""" +from __future__ import annotations + +import json +import os +import urllib.error +import urllib.request + + +class AuthenticationError(Exception): + pass + + +def verify_token(token: str) -> str: + """Return the verified user ID; missing configuration and anonymous users fail closed.""" + url = os.getenv("SUPABASE_URL", "").rstrip("/") + key = os.getenv("SUPABASE_ANON_KEY", "") + if not url or not key: + raise AuthenticationError("Simulation authentication is not configured") + if not isinstance(token, str) or not token: + raise AuthenticationError("Sign in to access the simulation") + try: + request = urllib.request.Request( + f"{url}/auth/v1/user", + headers={"apikey": key, "Authorization": f"Bearer {token}"}, + ) + with urllib.request.urlopen(request, timeout=10) as response: + user = json.load(response) + except (OSError, ValueError, urllib.error.URLError) as exc: + raise AuthenticationError("Unable to verify your session; sign in again") from exc + if not isinstance(user, dict) or not isinstance(user.get("id"), str) or not user["id"] or user.get("is_anonymous"): + raise AuthenticationError("Sign in to access the simulation") + return user["id"] diff --git a/defense_sim_v2/src/defense_sim/server/socketio_server.py b/defense_sim_v2/src/defense_sim/server/socketio_server.py index 9e3e058..4473b72 100644 --- a/defense_sim_v2/src/defense_sim/server/socketio_server.py +++ b/defense_sim_v2/src/defense_sim/server/socketio_server.py @@ -6,6 +6,7 @@ import os import secrets import socketio from .events import STATE, EVENT, ERROR, COMPLETED, STOPPED +from .auth import AuthenticationError, verify_token from ..simulation.scenarios import basic_intercept @@ -19,6 +20,12 @@ stream_every = 5 @sio.event async def connect(sid, environ, auth=None): + token = auth.get("token", "") if isinstance(auth, dict) else "" + try: + user_id = await asyncio.to_thread(verify_token, token) + except AuthenticationError as exc: + raise socketio.exceptions.ConnectionRefusedError(str(exc)) from exc + await sio.save_session(sid, {"user_id": user_id, "token": token}) await sio.emit(STATE, engine.snapshot() if engine else { "schema_version": 1, "kind": "simulation_state", @@ -34,9 +41,24 @@ async def connect(sid, environ, auth=None): }, to=sid) +async def authenticated(sid, token=None): + try: + session = await sio.get_session(sid) + token = token if token is not None else session.get("token", "") + user_id = await asyncio.to_thread(verify_token, token) + await sio.save_session(sid, {"user_id": user_id, "token": token}) + return True + except (AuthenticationError, KeyError): + await sio.emit(ERROR, {"message": "Session expired; sign in again"}, to=sid) + await sio.disconnect(sid) + return False + + @sio.on("simulation_start") async def start_simulation(sid, data=None): global engine, task, stream_every + if not await authenticated(sid, data.get("token") if isinstance(data, dict) else None): + return data = data or {} try: engine = basic_intercept( @@ -76,8 +98,10 @@ async def start_simulation(sid, data=None): @sio.on("simulation_stop") -async def stop_simulation(sid): +async def stop_simulation(sid, data=None): global task + if not await authenticated(sid, data.get("token") if isinstance(data, dict) else None): + return if task and not task.done(): task.cancel() await sio.emit(STOPPED, {}, to=sid) diff --git a/defense_sim_v2/tests/test_auth.py b/defense_sim_v2/tests/test_auth.py new file mode 100644 index 0000000..63dcc25 --- /dev/null +++ b/defense_sim_v2/tests/test_auth.py @@ -0,0 +1,88 @@ +"""Authentication fails closed and validates users against Supabase.""" +import asyncio +import io +import json +from unittest.mock import AsyncMock +import urllib.error + +import pytest +import socketio + +from defense_sim.server.auth import AuthenticationError, verify_token +from defense_sim.server import socketio_server as server + + +@pytest.fixture +def auth_environment(monkeypatch): + monkeypatch.setenv('SUPABASE_URL', 'https://auth.example.test') + monkeypatch.setenv('SUPABASE_ANON_KEY', 'public-key') + + +def test_missing_auth_configuration_rejects_connections(monkeypatch): + monkeypatch.delenv('SUPABASE_URL', raising=False) + monkeypatch.delenv('SUPABASE_ANON_KEY', raising=False) + with pytest.raises(AuthenticationError, match='not configured'): + verify_token('token') + + +def test_missing_token_does_not_contact_auth_server(auth_environment, monkeypatch): + def unexpected_request(*args, **kwargs): + pytest.fail('Missing tokens must be rejected locally') + monkeypatch.setattr('urllib.request.urlopen', unexpected_request) + with pytest.raises(AuthenticationError, match='Sign in'): + verify_token('') + + +def test_token_is_verified_with_auth_server(auth_environment, monkeypatch): + def response(request, timeout): + assert request.full_url == 'https://auth.example.test/auth/v1/user' + assert request.get_header('Authorization') == 'Bearer signed-token' + assert request.get_header('Apikey') == 'public-key' + return io.BytesIO(json.dumps({'id': 'verified-user', 'is_anonymous': False}).encode()) + monkeypatch.setattr('urllib.request.urlopen', response) + assert verify_token('signed-token') == 'verified-user' + + +@pytest.mark.parametrize('payload', [{'id': 'guest', 'is_anonymous': True}, {}, [], {'id': None}]) +def test_anonymous_or_invalid_users_rejected(auth_environment, monkeypatch, payload): + monkeypatch.setattr('urllib.request.urlopen', lambda *args, **kwargs: io.BytesIO(json.dumps(payload).encode())) + with pytest.raises(AuthenticationError): + verify_token('token') + + +def test_expired_token_rejected(auth_environment, monkeypatch): + def response(*args, **kwargs): + raise urllib.error.HTTPError('https://auth.example.test', 401, 'Expired token', {}, None) + monkeypatch.setattr('urllib.request.urlopen', response) + with pytest.raises(AuthenticationError): + verify_token('expired-token') + + +def test_socket_rejects_unauthenticated_client_before_sending_state(monkeypatch): + def rejected(token): + raise AuthenticationError('Sign in') + monkeypatch.setattr(server, 'verify_token', rejected) + emit = AsyncMock() + monkeypatch.setattr(server.sio, 'emit', emit) + with pytest.raises(socketio.exceptions.ConnectionRefusedError): + asyncio.run(server.connect('client', {}, None)) + emit.assert_not_called() + + +def test_expired_session_cannot_stop_running_simulation(monkeypatch): + async def rejected(sid, token=None): + return False + running_task = AsyncMock() + monkeypatch.setattr(server, 'authenticated', rejected) + monkeypatch.setattr(server, 'task', running_task) + asyncio.run(server.stop_simulation('client')) + running_task.cancel.assert_not_called() + + +def test_expired_session_cannot_start_simulation(monkeypatch): + async def rejected(sid, token=None): + return False + monkeypatch.setattr(server, 'authenticated', rejected) + original_engine = server.engine + asyncio.run(server.start_simulation('client', {})) + assert server.engine is original_engine