diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 897a875..d0dca2d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -373,6 +373,73 @@ jobs: git commit -m "Épingle le staging sur open-eidas/open-eidas@${GITHUB_SHA}" git push origin main + frontend: + name: Frontend de ra-console (typage, build, bout en bout) + runs-on: ubuntu-latest + # La console réelle tourne sur PostgreSQL (examples/e2e_console.rs), avec un + # opérateur dont la clé est confiée à l'authentificateur WebAuthn virtuel de + # Chromium : connexion, déconnexion et verrouillage sont prouvés dans un vrai + # navigateur, en-têtes de sécurité et CSP compris (docs/UI-UX.md §6.3). + services: + postgres: + image: postgres:17-alpine + env: + POSTGRES_PASSWORD: test + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 5s + --health-timeout 5s + --health-retries 10 + defaults: + run: + working-directory: bin/ra-console/web + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 24 + cache: npm + cache-dependency-path: bin/ra-console/web/package-lock.json + + - uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + - name: Dépendances de développement (jamais dans l'image) + run: npm ci + + - name: Typage strict (navigateur et tests) + run: npm run typecheck + + - name: web/dist correspond aux sources + # Les assets sont versionnés pour que la compilation Rust n'exige pas + # Node ; un dist/ désynchronisé servirait autre chose que le code relu. + run: | + npm run build + git diff --exit-code -- dist || { + echo "::error::web/dist est désynchronisé : lancez 'npm run build' dans bin/ra-console/web" + exit 1 + } + + - name: Navigateur de test + run: npx playwright install --with-deps chromium + + - name: Parcours de bout en bout (Playwright) + env: + OE_CASTORE_TEST_DSN: postgres://postgres:test@localhost:5432/postgres + run: npx playwright test + + - name: Rapport Playwright (en cas d'échec) + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-report + path: bin/ra-console/web/playwright-report + retention-days: 7 + helm-lint: name: Lint du chart Helm runs-on: ubuntu-latest diff --git a/Cargo.lock b/Cargo.lock index 9093fb5..e750569 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2215,6 +2215,7 @@ version = "0.1.0" dependencies = [ "async-trait", "axum", + "base64 0.22.1", "ca-server", "clap", "der 0.8.2", @@ -2231,11 +2232,13 @@ dependencies = [ "oe-raflow", "oe-s3", "oe-webauthn", + "openssl", "rand 0.8.8", "reqwest", "rsa", "rustls", "serde", + "serde_cbor_2", "serde_json", "sha2 0.10.9", "sqlx", diff --git a/bin/ra-console/Cargo.toml b/bin/ra-console/Cargo.toml index 1a27a82..3a8a5a7 100644 --- a/bin/ra-console/Cargo.toml +++ b/bin/ra-console/Cargo.toml @@ -56,6 +56,12 @@ rand = "0.8" [dev-dependencies] async-trait = "0.1" +# Harnais de bout en bout du frontend (examples/e2e_console.rs) : export de la +# clé du SoftToken vers l'authentificateur virtuel du navigateur. Déjà dans +# l'arbre de dépendances (webauthn-authenticator-rs), aucune crate nouvelle. +serde_cbor_2 = "0.13" +openssl = "0.10" +base64 = "0.22" oe-raflow = { path = "../../crates/oe-raflow" } webauthn-authenticator-rs = { version = "0.5", features = ["softtoken"] } ca-server = { path = "../ca-server" } diff --git a/bin/ra-console/examples/e2e_console.rs b/bin/ra-console/examples/e2e_console.rs new file mode 100644 index 0000000..8e080c9 --- /dev/null +++ b/bin/ra-console/examples/e2e_console.rs @@ -0,0 +1,211 @@ +//! Harnais des tests de bout en bout du frontend (bin/ra-console/web/e2e, +//! Playwright) : une console réelle (`ra_console::http::app`, assets embarqués +//! et en-têtes de sécurité compris) sur un vrai PostgreSQL, un opérateur +//! enregistré, et sa clé privée exportée pour l'authentificateur virtuel du +//! navigateur (CDP `WebAuthn.addCredential`). +//! +//! La liste blanche de modèles de clés refuserait l'attestation d'un +//! authentificateur virtuel : l'opérateur est donc enregistré avec le +//! `SoftToken` des tests Rust, dont la clé est ensuite confiée au navigateur. +//! +//! Variables : `OE_CASTORE_TEST_DSN` (obligatoire), `E2E_PORT` (défaut 8431), +//! `E2E_FIXTURE` (défaut `target/e2e-fixture.json`). +//! Ne sert qu'aux tests : jamais construit dans l'image. + +#[path = "../tests/common/mod.rs"] +mod common; + +use std::sync::Arc; + +use base64::Engine; +use oe_actions::{NewCredential, Registry, Role}; +use oe_webauthn::{trusted_models, TrustedModel, Url, Verifier}; +use ra_console::ca_link::CaLink; +use ra_console::http::{app, AppState}; +use ra_console::login::LoginService; +use sqlx::postgres::PgPoolOptions; +use webauthn_authenticator_rs::softtoken::{SoftToken, SoftTokenFile, AAGUID}; +use webauthn_authenticator_rs::WebauthnAuthenticator; + +#[tokio::main] +async fn main() { + let base = std::env::var("OE_CASTORE_TEST_DSN").expect("OE_CASTORE_TEST_DSN est obligatoire"); + let port: u16 = std::env::var("E2E_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(8431); + let fixture = std::env::var("E2E_FIXTURE").unwrap_or_else(|_| "target/e2e-fixture.json".into()); + let origin = Url::parse(&format!("http://localhost:{port}")).unwrap(); + + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos(); + let name = format!("e2e_{nanos}"); + let admin = PgPoolOptions::new().connect(&base).await.unwrap(); + sqlx::query(&format!("CREATE DATABASE {name}")) + .execute(&admin) + .await + .unwrap(); + let dsn = format!("{}/{name}", base.rsplit_once('/').unwrap().0); + let _ = oe_castore::Postgres::open(&dsn).await.unwrap(); + let registry = Registry::connect(&dsn).await.unwrap(); + + let (token, root) = SoftToken::new(true).unwrap(); + let root_pem = root.to_pem().unwrap(); + let verifier = || { + Verifier::new( + "localhost", + &origin, + "Open eIDAS Console — e2e", + trusted_models(&[TrustedModel { + root_pem: &root_pem, + aaguid: AAGUID, + description: "SoftToken (e2e)", + }]) + .unwrap(), + ) + .unwrap() + }; + + // L'opérateur et sa clé, comme en production mais sans passer par + // l'enregistrement relayé (qui a ses propres tests). + let now = time::OffsetDateTime::now_utc(); + let operator = registry + .add_operator("alice", Role::RaOperateur, "e2e", now) + .await + .unwrap(); + let reg_verifier = verifier(); + // Le SoftToken s'enregistre dans ce fichier à sa fermeture : c'est ainsi que + // la clé du credential créé ci-dessous se relit (aucun accesseur public). + let token_path = std::env::temp_dir().join(format!("{name}.softtoken")); + let token_file = std::fs::File::create(&token_path).unwrap(); + let mut authn = WebauthnAuthenticator::new(SoftTokenFile::new(token, token_file)); + let (options, state) = reg_verifier + .start_registration(operator, "alice", None) + .unwrap(); + let reg = authn.do_registration(origin.clone(), options).unwrap(); + drop(authn); + let key = reg_verifier.finish_registration(®, &state).unwrap(); + registry + .add_credential( + NewCredential { + operator_id: operator, + passkey: &key, + aaguid: AAGUID, + attestation_format: "packed", + attestation_object: reg.response.attestation_object.as_ref(), + label: "e2e", + initiated_by: "e2e", + confirmed_by: Some("e2e"), + }, + now, + ) + .await + .unwrap(); + + // La clé privée du SoftToken (SEC1), convertie en PKCS#8 pour le navigateur. + let credential_id: Vec = reg.raw_id.as_ref().to_vec(); + let soft: serde_cbor_2::Value = + serde_cbor_2::from_slice(&std::fs::read(&token_path).unwrap()).unwrap(); + let _ = std::fs::remove_file(&token_path); + let (sec1, counter) = soft_key(&soft, &credential_id); + let ec = openssl::ec::EcKey::private_key_from_der(&sec1).unwrap(); + let pkcs8 = openssl::pkey::PKey::from_ec_key(ec) + .unwrap() + .private_key_to_pkcs8() + .unwrap(); + let b64 = base64::engine::general_purpose::STANDARD; + let out = serde_json::json!({ + "origin": origin.as_str().trim_end_matches('/'), + "operator": "alice", + "role": "ra_operateur", + "credential": { + "credentialId": b64.encode(&credential_id), + "isResidentCredential": false, + "rpId": "localhost", + "privateKey": b64.encode(pkcs8), + "userHandle": b64.encode(operator.as_bytes()), + "signCount": counter, + }, + }); + if let Some(parent) = std::path::Path::new(&fixture).parent() { + std::fs::create_dir_all(parent).unwrap(); + } + std::fs::write(&fixture, serde_json::to_vec_pretty(&out).unwrap()).unwrap(); + + // Un lien vers ca-server injoignable : la connexion et le poste n'en ont + // pas besoin (seul /healthz s'en soucie). + let pki = common::pki().await; + let dir = common::tempdir::Dir::new(); + let client = pki + .cert(&oe_ca_core::profile::internal_client(), "ra-console") + .await; + let link = CaLink::new(&pki.files(&dir, &client, 9)).unwrap(); + let pool = PgPoolOptions::new().connect(&dsn).await.unwrap(); + let console = app( + Arc::new(AppState { + pool: pool.clone(), + link, + login: LoginService::new( + registry.clone(), + verifier(), + b"secret-de-test-au-moins-16-octets".to_vec(), + Arc::new(ra_console::audit::NullRecorder), + ), + sessions: common::sessions(pool), + journal: Arc::new(ra_console::audit::NullRecorder), + }), + ra_console::web::Console { + environment: ra_console::web::Environment::Staging, + }, + ); + let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)) + .await + .unwrap(); + eprintln!("e2e : console prête sur {origin}, fixture {fixture}"); + axum::serve(listener, console).await.unwrap(); +} + +/// La clé privée (DER SEC1) d'un credential, et le compteur de signatures. +fn soft_key(token: &serde_cbor_2::Value, credential_id: &[u8]) -> (Vec, u64) { + use serde_cbor_2::Value; + let Value::Map(fields) = token else { + panic!("SoftToken : format inattendu") + }; + let field = |name: &str| { + fields + .iter() + .find(|(k, _)| matches!(k, Value::Text(t) if t == name)) + .map(|(_, v)| v) + .unwrap_or_else(|| panic!("SoftToken : champ {name} absent")) + }; + let Value::Map(tokens) = field("tokens") else { + panic!("SoftToken : tokens inattendu") + }; + let key = tokens + .iter() + .find_map(|(k, v)| match (k, v) { + (Value::Bytes(id), Value::Bytes(der)) if id == credential_id => Some(der.clone()), + (Value::Array(id), Value::Array(der)) if bytes_of(id) == credential_id => { + Some(bytes_of(der)) + } + _ => None, + }) + .expect("SoftToken : clé du credential introuvable"); + let counter = match field("counter") { + Value::Integer(n) => *n as u64, + _ => 0, + }; + (key, counter) +} + +fn bytes_of(values: &[serde_cbor_2::Value]) -> Vec { + values + .iter() + .map(|v| match v { + serde_cbor_2::Value::Integer(n) => *n as u8, + _ => panic!("octet attendu"), + }) + .collect() +} diff --git a/bin/ra-console/src/config.rs b/bin/ra-console/src/config.rs index e07baea..f00da97 100644 --- a/bin/ra-console/src/config.rs +++ b/bin/ra-console/src/config.rs @@ -24,6 +24,8 @@ pub struct Config { /// décision déjà prise pour ce journal (voir `ra_console::audit::Recorder`), non /// remise en cause par l'ajout de S3. pub s3: Option, + /// Environnement annoncé par le frontend (docs/UI-UX.md §1, principe 4). + pub environment: crate::web::Environment, } /// Mêmes champs que `ca_server::config::S3Config` (même stockage S3-compatible @@ -153,6 +155,9 @@ impl Config { "/var/lib/open-eidas/state/ra-console-audit.log", ), s3: s3_config()?, + environment: crate::web::Environment::parse( + &std::env::var("OPENEIDAS_RA_ENVIRONMENT").unwrap_or_default(), + )?, }) } diff --git a/bin/ra-console/src/http.rs b/bin/ra-console/src/http.rs index abb4010..24d3f10 100644 --- a/bin/ra-console/src/http.rs +++ b/bin/ra-console/src/http.rs @@ -31,6 +31,15 @@ pub struct AppState { pub journal: Arc, } +/// L'application complète servie par `ra-console` : l'API ([`router`]), le +/// frontend embarqué ([`crate::web`]) et les en-têtes de sécurité sur toutes +/// les réponses (docs/UI-UX.md §6.3). +pub fn app(state: Arc, console: crate::web::Console) -> Router { + router(state) + .merge(crate::web::router(console)) + .layer(axum::middleware::from_fn(crate::web::security_headers)) +} + pub fn router(state: Arc) -> Router { Router::new() .route("/healthz", get(handle_health)) diff --git a/bin/ra-console/src/lib.rs b/bin/ra-console/src/lib.rs index 1e82d38..db92001 100644 --- a/bin/ra-console/src/lib.rs +++ b/bin/ra-console/src/lib.rs @@ -23,4 +23,5 @@ pub mod quorum; pub mod registry_routes; pub mod requests; pub mod session; +pub mod web; pub mod webauthn_models; diff --git a/bin/ra-console/src/main.rs b/bin/ra-console/src/main.rs index ec6f462..e26709f 100644 --- a/bin/ra-console/src/main.rs +++ b/bin/ra-console/src/main.rs @@ -118,13 +118,19 @@ async fn run_serve() { // aucune opération manuelle, arrêtée par le même signal que le serveur. purge::spawn_periodic(pool.clone(), cfg.purge_interval); - let app = http::router(Arc::new(http::AppState { - pool, - link, - login, - sessions, - journal, - })); + let console = ra_console::web::Console { + environment: cfg.environment, + }; + let app = http::app( + Arc::new(http::AppState { + pool, + link, + login, + sessions, + journal, + }), + console, + ); let listener = tokio::net::TcpListener::bind(bind_addr(&cfg.listen)) .await .unwrap_or_else(|e| die(&format!("écoute sur {}", cfg.listen), e)); diff --git a/bin/ra-console/src/web.rs b/bin/ra-console/src/web.rs new file mode 100644 index 0000000..c2e70ca --- /dev/null +++ b/bin/ra-console/src/web.rs @@ -0,0 +1,136 @@ +//! Le frontend de `ra-console` (docs/WEBUI.md §15 étape 6, docs/UI-UX.md) : +//! des assets statiques **embarqués dans le binaire** (UI-UX §7 : un +//! déploiement est un binaire unique autonome, sans serveur web ni +//! répertoire d'assets à côté), et les en-têtes de sécurité posés sur +//! **toutes** les réponses, API comprise (UI-UX §6.3). +//! +//! Les assets sont construits depuis `bin/ra-console/web/` (TypeScript, +//! esbuild) et versionnés dans `web/dist/` : la compilation Rust n'exige pas +//! Node, et la CI vérifie que `dist/` correspond aux sources. + +use axum::extract::State; +use axum::http::{header, HeaderValue, Request}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use axum::{Json, Router}; + +static INDEX_HTML: &[u8] = include_bytes!("../web/dist/index.html"); +static CONSOLE_JS: &[u8] = include_bytes!("../web/dist/console.js"); +static CONSOLE_CSS: &[u8] = include_bytes!("../web/dist/console.css"); + +/// Environnement annoncé en tête de chaque écran (UI-UX §1, principe 4) : +/// **PRODUCTION** en rouge, les autres en teinte discrète. Déclaré par le +/// déploiement (`OPENEIDAS_RA_ENVIRONMENT`) ; non déclaré, la console le dit +/// plutôt que de laisser croire à un environnement sans risque. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Environment { + Production, + Staging, + Demo, + Undeclared, +} + +impl Environment { + pub fn parse(value: &str) -> Result { + match value { + "" => Ok(Environment::Undeclared), + "production" => Ok(Environment::Production), + "staging" => Ok(Environment::Staging), + "demo" => Ok(Environment::Demo), + other => Err(format!( + "OPENEIDAS_RA_ENVIRONMENT={other:?} : production, staging ou demo" + )), + } + } + + pub fn as_str(self) -> &'static str { + match self { + Environment::Production => "production", + Environment::Staging => "staging", + Environment::Demo => "demo", + Environment::Undeclared => "undeclared", + } + } +} + +/// Ce que le frontend doit savoir avant toute connexion. +#[derive(Debug, Clone)] +pub struct Console { + pub environment: Environment, +} + +/// Politique de contenu d'UI-UX §6.3, à l'identique : aucun script ni style +/// en ligne, rien hors de l'origine, pas d'intégration dans un cadre. +pub const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; object-src 'none'; base-uri 'self'; form-action 'self'"; + +pub fn router(console: Console) -> Router { + Router::new() + .route("/", get(index)) + .route("/assets/console.js", get(script)) + .route("/assets/console.css", get(style)) + .route("/api/v1/console", get(describe)) + .with_state(console) +} + +/// Les en-têtes de sécurité, sur toutes les réponses (UI-UX §6.3). +pub async fn security_headers(req: Request, next: Next) -> Response { + let mut res = next.run(req).await; + let h = res.headers_mut(); + h.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static(CONTENT_SECURITY_POLICY), + ); + h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); + h.insert( + header::X_CONTENT_TYPE_OPTIONS, + HeaderValue::from_static("nosniff"), + ); + h.insert( + header::REFERRER_POLICY, + HeaderValue::from_static("no-referrer"), + ); + h.insert( + "cross-origin-opener-policy", + HeaderValue::from_static("same-origin"), + ); + h.insert( + "permissions-policy", + HeaderValue::from_static("camera=(), microphone=(), geolocation=(), payment=()"), + ); + // Rien de ce que sert la console ne doit rester dans un cache partagé + // (réponses d'API comprises : identité, files, corps à signer). + h.entry(header::CACHE_CONTROL) + .or_insert(HeaderValue::from_static("no-store")); + res +} + +async fn index() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/html; charset=utf-8")], + INDEX_HTML, + ) +} + +async fn script() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/javascript; charset=utf-8")], + CONSOLE_JS, + ) +} + +async fn style() -> impl IntoResponse { + ( + [(header::CONTENT_TYPE, "text/css; charset=utf-8")], + CONSOLE_CSS, + ) +} + +/// `GET /api/v1/console` : l'environnement et la version, sans session — la +/// bannière doit s'afficher dès l'écran de connexion. +async fn describe(State(console): State) -> impl IntoResponse { + Json(serde_json::json!({ + "environment": console.environment.as_str(), + "version": env!("CARGO_PKG_VERSION"), + })) +} diff --git a/bin/ra-console/tests/web.rs b/bin/ra-console/tests/web.rs new file mode 100644 index 0000000..06b3ecd --- /dev/null +++ b/bin/ra-console/tests/web.rs @@ -0,0 +1,141 @@ +//! Le frontend embarqué et les en-têtes de sécurité (docs/UI-UX.md §6.3, §7), +//! vérifiés sans navigateur : chaque réponse de l'application complète — API +//! comprise — porte la CSP stricte et les protections contre l'intégration en +//! cadre, et les assets servis sont bien ceux de `web/dist`. Les parcours dans +//! un vrai navigateur sont dans `web/e2e` (Playwright). +//! +//! DSN dans `OE_CASTORE_TEST_DSN` ; test ignoré si elle n'est pas définie +//! (l'`AppState` exige un pool PostgreSQL). + +mod common; + +use std::sync::Arc; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use common::{pki, tempdir::Dir}; +use http_body_util::BodyExt; +use ra_console::ca_link::CaLink; +use ra_console::http::{app, AppState}; +use ra_console::web::{Console, Environment, CONTENT_SECURITY_POLICY}; +use sqlx::postgres::PgPoolOptions; +use tower::ServiceExt; + +async fn console(environment: Environment) -> Option { + let dsn = std::env::var("OE_CASTORE_TEST_DSN").ok()?; + let pool = PgPoolOptions::new().connect_lazy(&dsn).unwrap(); + let ca = pki().await; + let dir = Box::leak(Box::new(Dir::new())); + let client = ca + .cert(&oe_ca_core::profile::internal_client(), "ra-console") + .await; + let link = CaLink::new(&ca.files(dir, &client, 9)).unwrap(); + Some(app( + Arc::new(AppState { + pool: pool.clone(), + link, + login: common::login_service(pool.clone()), + sessions: common::sessions(pool), + journal: Arc::new(ra_console::audit::NullRecorder), + }), + Console { environment }, + )) +} + +async fn get(app: &axum::Router, path: &str) -> (StatusCode, axum::http::HeaderMap, Vec) { + let res = app + .clone() + .oneshot(Request::get(path).body(Body::empty()).unwrap()) + .await + .unwrap(); + let status = res.status(); + let headers = res.headers().clone(); + let body = res.into_body().collect().await.unwrap().to_bytes().to_vec(); + (status, headers, body) +} + +#[tokio::test] +async fn every_response_carries_the_security_headers() { + let Some(app) = console(Environment::Production).await else { + eprintln!("OE_CASTORE_TEST_DSN non définie : test ignoré"); + return; + }; + // Des assets, une route anonyme, une route refusée sans session : toutes. + for path in [ + "/", + "/assets/console.js", + "/assets/console.css", + "/api/v1/console", + "/api/v1/me", + ] { + let (_, headers, _) = get(&app, path).await; + let header = |name: &str| { + headers + .get(name) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + }; + assert_eq!( + header("content-security-policy"), + CONTENT_SECURITY_POLICY, + "{path}" + ); + assert_eq!(header("x-frame-options"), "DENY", "{path}"); + assert_eq!(header("x-content-type-options"), "nosniff", "{path}"); + assert_eq!(header("referrer-policy"), "no-referrer", "{path}"); + assert_eq!(header("cache-control"), "no-store", "{path}"); + } + // La politique elle-même : ni `unsafe-inline`, ni `unsafe-eval`, ni cadre. + assert!(!CONTENT_SECURITY_POLICY.contains("unsafe")); + assert!(CONTENT_SECURITY_POLICY.contains("frame-ancestors 'none'")); +} + +#[tokio::test] +async fn the_embedded_assets_are_served_with_their_types() { + let Some(app) = console(Environment::Production).await else { + eprintln!("OE_CASTORE_TEST_DSN non définie : test ignoré"); + return; + }; + let (status, headers, html) = get(&app, "/").await; + assert_eq!(status, StatusCode::OK); + assert!(headers["content-type"] + .to_str() + .unwrap() + .starts_with("text/html")); + let html = String::from_utf8(html).unwrap(); + assert!(html.contains(r#" + + + +
+ + + diff --git a/bin/ra-console/web/e2e/console.spec.ts b/bin/ra-console/web/e2e/console.spec.ts new file mode 100644 index 0000000..14261bf --- /dev/null +++ b/bin/ra-console/web/e2e/console.spec.ts @@ -0,0 +1,102 @@ +// Parcours de la console dans un vrai navigateur (docs/WEBUI.md §15 étape 6a, +// docs/UI-UX.md §6.3) : en-têtes de sécurité, connexion par clé FIDO2, +// déconnexion, verrouillage après inactivité. + +import { expect, test, type Page } from "@playwright/test"; +import { readFileSync } from "node:fs"; +import { fixturePath } from "../playwright.config"; + +interface Fixture { + operator: string; + credential: Record & { signCount: number }; +} + +const fixture = (): Fixture => JSON.parse(readFileSync(fixturePath, "utf8")) as Fixture; + +// Chaque test recrée un authentificateur : son compteur doit dépasser celui +// déjà vu par la console, sans quoi elle détecte (à juste titre) un clone. +let signCountBase = 1000; + +async function withOperatorKey(page: Page): Promise { + const cdp = await page.context().newCDPSession(page); + await cdp.send("WebAuthn.enable"); + const { authenticatorId } = await cdp.send("WebAuthn.addVirtualAuthenticator", { + options: { + protocol: "ctap2", + // La clé de test a été enregistrée par le SoftToken, qui s'annonce + // `internal` : le navigateur ne consulte que les authentificateurs de + // ce transport (les options relaient les transports enregistrés). + transport: "internal", + hasResidentKey: false, + hasUserVerification: true, + isUserVerified: true, + automaticPresenceSimulation: true, + }, + }); + signCountBase += 1000; + await cdp.send("WebAuthn.addCredential", { + authenticatorId, + credential: { ...fixture().credential, signCount: fixture().credential.signCount + signCountBase }, + } as never); +} + +/// Exceptions de la page et violations de CSP : aucune n'est admise. Les +/// réponses d'erreur HTTP attendues (401 avant connexion) n'en sont pas. +function collectErrors(page: Page): string[] { + const errors: string[] = []; + page.on("console", (m) => { + if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) errors.push(m.text()); + }); + page.on("pageerror", (e) => errors.push(e.message)); + return errors; +} + +async function logIn(page: Page): Promise { + await page.getByTestId("login-name").fill(fixture().operator); + await page.getByTestId("login-submit").click(); + await expect(page.getByTestId("operator")).toHaveText(fixture().operator); +} + +test("chaque réponse porte les en-têtes de sécurité, sans script en ligne", async ({ request }) => { + for (const path of ["/", "/assets/console.js", "/api/v1/console", "/api/v1/me"]) { + const res = await request.get(path); + const headers = res.headers(); + expect(headers["content-security-policy"], path).toContain("script-src 'self'"); + expect(headers["content-security-policy"], path).toContain("frame-ancestors 'none'"); + expect(headers["x-frame-options"], path).toBe("DENY"); + expect(headers["x-content-type-options"], path).toBe("nosniff"); + expect(headers["cache-control"], path).toBe("no-store"); + } + const html = await (await request.get("/")).text(); + expect(html).not.toMatch(/]*\bsrc=)[^>]*>/); + expect(html).not.toMatch(/\sstyle=/); +}); + +test("connexion par clé FIDO2, puis déconnexion qui révoque la session", async ({ page }) => { + const errors = collectErrors(page); + await withOperatorKey(page); + await page.goto("/"); + await expect(page.getByTestId("env-banner")).toHaveText("STAGING"); + await logIn(page); + await expect(page.getByTestId("role")).toHaveText("opérateur RA"); + await expect(page.getByTestId("count-requests")).toHaveText("(0)"); + await expect(page.getByTestId("count-quorum")).toHaveText("(0)"); + + await page.getByTestId("logout").click(); + await expect(page.getByTestId("login-name")).toBeVisible(); + expect((await page.request.get("/api/v1/me")).status()).toBe(401); + expect(errors).toEqual([]); +}); + +test("verrouillage après 15 minutes d'inactivité", async ({ page }) => { + await page.clock.install(); + await withOperatorKey(page); + await page.goto("/"); + await logIn(page); + + await page.clock.fastForward("14:00"); + await expect(page.getByTestId("idle-warning")).toBeVisible(); + await page.clock.fastForward("01:00"); + await expect(page.getByTestId("login-status")).toContainText("verrouillée"); + expect((await page.request.get("/api/v1/me")).status()).toBe(401); +}); diff --git a/bin/ra-console/web/e2e/tsconfig.json b/bin/ra-console/web/e2e/tsconfig.json new file mode 100644 index 0000000..9100174 --- /dev/null +++ b/bin/ra-console/web/e2e/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../tsconfig.json", + "compilerOptions": { + "lib": [ + "ES2023", + "DOM" + ], + "types": [ + "node" + ] + }, + "include": [ + "./**/*.ts", + "../playwright.config.ts" + ] +} diff --git a/bin/ra-console/web/package-lock.json b/bin/ra-console/web/package-lock.json new file mode 100644 index 0000000..14b9847 --- /dev/null +++ b/bin/ra-console/web/package-lock.json @@ -0,0 +1,938 @@ +{ + "name": "ra-console-web", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ra-console-web", + "license": "EUPL-1.2 OR AGPL-3.0-only", + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "24.19.0", + "esbuild": "0.28.2", + "typescript": "7.0.2" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz", + "integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz", + "integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz", + "integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz", + "integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz", + "integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz", + "integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz", + "integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz", + "integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz", + "integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz", + "integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz", + "integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz", + "integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz", + "integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz", + "integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz", + "integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz", + "integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz", + "integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz", + "integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz", + "integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz", + "integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz", + "integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz", + "integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz", + "integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz", + "integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz", + "integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz", + "integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@playwright/test": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.63.0.tgz", + "integrity": "sha512-oxMK4vllB9RK5NQ2l1pq1IfOf2AvnEuj/vYGDj0H2nMtmtZpKtCwt/l00GEO6xjGfpBNAvjovvYdCm50dRQkpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@types/node": { + "version": "24.19.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.19.0.tgz", + "integrity": "sha512-zY+5tKxXdhGh1PYI0ac+7juvEu4OI6vWtVVoj5i2m42jxAY1U+zHGt6QCyOFwykdP62sM3MJ9stoYYUw5aCWew==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": ">=7.24.0 <7.24.7" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/esbuild": { + "version": "0.28.2", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz", + "integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.2", + "@esbuild/android-arm": "0.28.2", + "@esbuild/android-arm64": "0.28.2", + "@esbuild/android-x64": "0.28.2", + "@esbuild/darwin-arm64": "0.28.2", + "@esbuild/darwin-x64": "0.28.2", + "@esbuild/freebsd-arm64": "0.28.2", + "@esbuild/freebsd-x64": "0.28.2", + "@esbuild/linux-arm": "0.28.2", + "@esbuild/linux-arm64": "0.28.2", + "@esbuild/linux-ia32": "0.28.2", + "@esbuild/linux-loong64": "0.28.2", + "@esbuild/linux-mips64el": "0.28.2", + "@esbuild/linux-ppc64": "0.28.2", + "@esbuild/linux-riscv64": "0.28.2", + "@esbuild/linux-s390x": "0.28.2", + "@esbuild/linux-x64": "0.28.2", + "@esbuild/netbsd-arm64": "0.28.2", + "@esbuild/netbsd-x64": "0.28.2", + "@esbuild/openbsd-arm64": "0.28.2", + "@esbuild/openbsd-x64": "0.28.2", + "@esbuild/openharmony-arm64": "0.28.2", + "@esbuild/sunos-x64": "0.28.2", + "@esbuild/win32-arm64": "0.28.2", + "@esbuild/win32-ia32": "0.28.2", + "@esbuild/win32-x64": "0.28.2" + } + }, + "node_modules/playwright": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.63.0.tgz", + "integrity": "sha512-+7ziBLidS4NaNCdt57SUDT+wYmmd5fmiQejUic/kb+YsYSCPyOOE9sebzMjNmQrsnNpDJqd4WHvV/8lfKfUDUg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.63.0" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.63.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.63.0.tgz", + "integrity": "sha512-rYCsBF/M5HjUch52bbtVONEFjv6Xu8sm8h72dNlR5bzIE1fvC/bxgspzkjSfU+MweEMmPM8KJebG6nnyxo5mCg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "7.24.6", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz", + "integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==", + "dev": true, + "license": "MIT" + } + } +} diff --git a/bin/ra-console/web/package.json b/bin/ra-console/web/package.json new file mode 100644 index 0000000..b5a63e4 --- /dev/null +++ b/bin/ra-console/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "ra-console-web", + "private": true, + "description": "Frontend de ra-console (docs/UI-UX.md) : TypeScript compilé par esbuild, embarqué dans le binaire.", + "license": "EUPL-1.2 OR AGPL-3.0-only", + "type": "module", + "scripts": { + "typecheck": "tsc --noEmit -p tsconfig.json && tsc --noEmit -p e2e/tsconfig.json", + "build": "esbuild src/main.ts --bundle --format=esm --target=es2022 --minify --legal-comments=none --outfile=dist/console.js && cp static/index.html static/console.css dist/", + "e2e": "playwright test" + }, + "devDependencies": { + "@playwright/test": "1.63.0", + "@types/node": "24.19.0", + "esbuild": "0.28.2", + "typescript": "7.0.2" + } +} diff --git a/bin/ra-console/web/playwright.config.ts b/bin/ra-console/web/playwright.config.ts new file mode 100644 index 0000000..24db39e --- /dev/null +++ b/bin/ra-console/web/playwright.config.ts @@ -0,0 +1,43 @@ +// Tests de bout en bout du frontend (docs/UI-UX.md) contre une console réelle : +// `cargo run --example e2e_console` monte ra-console (assets embarqués, +// en-têtes de sécurité) sur PostgreSQL, avec un opérateur dont la clé est +// confiée à l'authentificateur WebAuthn virtuel du navigateur. +// +// Exige OE_CASTORE_TEST_DSN. En local, PW_CHANNEL=chrome utilise le Chrome +// installé plutôt qu'un navigateur téléchargé par Playwright. + +import { defineConfig, devices } from "@playwright/test"; +import { resolve } from "node:path"; + +const port = Number(process.env.E2E_PORT ?? "8431"); +const origin = `http://localhost:${port}`; +const repo = resolve(import.meta.dirname, "../../.."); +export const fixturePath = resolve(repo, "target/e2e-fixture.json"); + +export default defineConfig({ + testDir: "e2e", + workers: 1, + fullyParallel: false, + forbidOnly: !!process.env.CI, + reporter: process.env.CI ? [["list"], ["html", { open: "never" }]] : "list", + use: { + baseURL: origin, + trace: "retain-on-failure", + ...(process.env.PW_CHANNEL ? { channel: process.env.PW_CHANNEL } : {}), + }, + projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }], + webServer: { + command: "cargo run -q -p ra-console --example e2e_console", + cwd: repo, + url: `${origin}/api/v1/console`, + timeout: 900_000, + reuseExistingServer: false, + stdout: "pipe", + stderr: "pipe", + env: { + OE_CASTORE_TEST_DSN: process.env.OE_CASTORE_TEST_DSN ?? "", + E2E_PORT: String(port), + E2E_FIXTURE: fixturePath, + }, + }, +}); diff --git a/bin/ra-console/web/src/api.ts b/bin/ra-console/web/src/api.ts new file mode 100644 index 0000000..5a2bff1 --- /dev/null +++ b/bin/ra-console/web/src/api.ts @@ -0,0 +1,46 @@ +// Appels à l'API de la console. Même origine, cookie de session posé par le +// serveur (`HttpOnly`, jamais lu ici). Toute erreur a la forme +// `{"error": "", "message": "..."}` (docs/WEBUI.md §5). + +export interface ApiError { + error: string; + message: string; +} + +export interface Reply { + status: number; + body: T | ApiError | null; +} + +export async function call(method: "GET" | "POST", path: string, body?: unknown): Promise> { + const init: RequestInit = { method, credentials: "same-origin", headers: {} }; + if (body !== undefined) { + init.headers = { "Content-Type": "application/json" }; + init.body = JSON.stringify(body); + } + const res = await fetch(path, init); + const text = await res.text(); + let parsed: T | ApiError | null = null; + if (text !== "") { + try { + parsed = JSON.parse(text) as T | ApiError; + } catch { + parsed = null; + } + } + return { status: res.status, body: parsed }; +} + +export function isError(body: unknown): body is ApiError { + return typeof body === "object" && body !== null && "error" in body; +} + +export interface ConsoleInfo { + environment: "production" | "staging" | "demo" | "undeclared"; + version: string; +} + +export interface Me { + operator: string; + role: "auditeur" | "ra_operateur" | "ca_operateur" | "admin"; +} diff --git a/bin/ra-console/web/src/b64url.ts b/bin/ra-console/web/src/b64url.ts new file mode 100644 index 0000000..9dd9b98 --- /dev/null +++ b/bin/ra-console/web/src/b64url.ts @@ -0,0 +1,17 @@ +// base64url sans remplissage : la forme des champs WebAuthn en JSON (niveau 3). + +export function toBase64Url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ""; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +export function fromBase64Url(value: string): ArrayBuffer { + const base64 = value.replace(/-/g, "+").replace(/_/g, "/"); + const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4); + const binary = atob(padded); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); + return bytes.buffer; +} diff --git a/bin/ra-console/web/src/banner.ts b/bin/ra-console/web/src/banner.ts new file mode 100644 index 0000000..c271e22 --- /dev/null +++ b/bin/ra-console/web/src/banner.ts @@ -0,0 +1,20 @@ +// Bannière d'environnement (docs/UI-UX.md §1 principe 4, §2.1) : PRODUCTION +// en rouge, toujours visible, sur tous les écrans y compris la connexion. + +import type { ConsoleInfo } from "./api"; +import { h } from "./dom"; + +const LABELS: Record = { + production: "PRODUCTION", + staging: "STAGING", + demo: "DÉMONSTRATION", + undeclared: "ENVIRONNEMENT NON DÉCLARÉ", +}; + +export function banner(info: ConsoleInfo): HTMLElement { + return h( + "div", + { class: `env-banner env-${info.environment}`, role: "status", "data-testid": "env-banner" }, + LABELS[info.environment], + ); +} diff --git a/bin/ra-console/web/src/dom.ts b/bin/ra-console/web/src/dom.ts new file mode 100644 index 0000000..b2788b9 --- /dev/null +++ b/bin/ra-console/web/src/dom.ts @@ -0,0 +1,25 @@ +// Construction du DOM sans `innerHTML` : tout contenu venu de l'API (noms, +// motifs, corps à signer) est inséré comme texte, jamais interprété. C'est ce +// qui ferme l'injection de HTML, en plus de la CSP (docs/UI-UX.md §6.3). + +type Child = Node | string | null | undefined | false; + +export function h( + tag: K, + attrs: Record = {}, + ...children: Child[] +): HTMLElementTagNameMap[K] { + const el = document.createElement(tag); + for (const [name, value] of Object.entries(attrs)) { + el.setAttribute(name, value); + } + for (const child of children) { + if (child === null || child === undefined || child === false) continue; + el.append(typeof child === "string" ? document.createTextNode(child) : child); + } + return el; +} + +export function replace(target: Element, ...children: Node[]): void { + target.replaceChildren(...children); +} diff --git a/bin/ra-console/web/src/idle.ts b/bin/ra-console/web/src/idle.ts new file mode 100644 index 0000000..bfb155c --- /dev/null +++ b/bin/ra-console/web/src/idle.ts @@ -0,0 +1,27 @@ +// Verrouillage de session inactive (docs/UI-UX.md §6.3) : avertissement à 14 +// minutes, verrouillage à 15 — la session est révoquée côté serveur, et une +// nouvelle authentification FIDO2 est exigée. Indépendant de la durée fixe de +// la session (8 h) : c'est l'inactivité du poste qui est bornée ici. + +export const WARN_AFTER_MS = 14 * 60 * 1000; +export const LOCK_AFTER_MS = 15 * 60 * 1000; + +const ACTIVITY = ["keydown", "pointerdown", "wheel", "touchstart"] as const; + +export function watchIdle(onWarn: () => void, onLock: () => void): () => void { + let warn = 0; + let lock = 0; + const arm = (): void => { + window.clearTimeout(warn); + window.clearTimeout(lock); + warn = window.setTimeout(onWarn, WARN_AFTER_MS); + lock = window.setTimeout(onLock, LOCK_AFTER_MS); + }; + for (const event of ACTIVITY) window.addEventListener(event, arm, { passive: true }); + arm(); + return () => { + window.clearTimeout(warn); + window.clearTimeout(lock); + for (const event of ACTIVITY) window.removeEventListener(event, arm); + }; +} diff --git a/bin/ra-console/web/src/login.ts b/bin/ra-console/web/src/login.ts new file mode 100644 index 0000000..744949e --- /dev/null +++ b/bin/ra-console/web/src/login.ts @@ -0,0 +1,69 @@ +// Écran de connexion (docs/WEBUI.md §15 étape 1c) : le nom de l'opérateur, +// puis sa clé FIDO2. Les refus ont tous la même forme (§16) : l'écran ne +// distingue jamais un nom inconnu d'une clé refusée. + +import { call, isError, type ConsoleInfo, type Me } from "./api"; +import { banner } from "./banner"; +import { h, replace } from "./dom"; +import { assert } from "./webauthn"; + +interface Begun { + challenge_id: string; + webauthn: Parameters[0]; +} + +export function renderLogin(root: HTMLElement, info: ConsoleInfo, onLoggedIn: (me: Me) => void, notice?: string): void { + const status = h("p", { class: "status", role: "status", "aria-live": "polite", "data-testid": "login-status" }, notice ?? ""); + const name = h("input", { + id: "operator-name", + name: "operator", + autocomplete: "username webauthn", + required: "", + maxlength: "256", + "data-testid": "login-name", + }); + const submit = h("button", { type: "submit", class: "primary", "data-testid": "login-submit" }, "Se connecter avec ma clé FIDO2"); + const form = h( + "form", + { class: "login-form", "aria-labelledby": "login-title" }, + h("h1", { id: "login-title" }, "Console d'opération Open eIDAS"), + h("label", { for: "operator-name" }, "Nom d'opérateur"), + name, + submit, + status, + ); + form.addEventListener("submit", (event) => { + event.preventDefault(); + void login(name.value.trim(), submit, status, onLoggedIn); + }); + replace(root, banner(info), h("main", { class: "login" }, form)); + name.focus(); +} + +async function login(name: string, submit: HTMLButtonElement, status: HTMLElement, onLoggedIn: (me: Me) => void): Promise { + if (name === "") return; + submit.disabled = true; + status.textContent = "Touchez votre clé de sécurité matérielle…"; + try { + const begun = await call("POST", "/api/v1/webauthn/login/begin", { name }); + if (begun.status !== 200 || begun.body === null || isError(begun.body)) { + status.textContent = "Connexion impossible pour le moment."; + return; + } + const credential = await assert(begun.body.webauthn); + const done = await call("POST", "/api/v1/webauthn/login/finish", { + challenge_id: begun.body.challenge_id, + credential, + }); + if (done.status !== 200 || done.body === null || isError(done.body)) { + status.textContent = "Identifiants invalides."; + return; + } + onLoggedIn(done.body); + } catch { + // Annulation, délai dépassé, clé inconnue du navigateur : une seule forme. + status.textContent = "La clé n'a pas répondu. Réessayez."; + } finally { + submit.disabled = false; + } +} diff --git a/bin/ra-console/web/src/main.ts b/bin/ra-console/web/src/main.ts new file mode 100644 index 0000000..d8b517b --- /dev/null +++ b/bin/ra-console/web/src/main.ts @@ -0,0 +1,54 @@ +// Point d'entrée du frontend de ra-console (docs/WEBUI.md §15 étape 6a). + +import { call, isError, type ConsoleInfo, type Me } from "./api"; +import { watchIdle } from "./idle"; +import { renderLogin } from "./login"; +import { idleWarning, renderShell } from "./shell"; + +async function boot(root: HTMLElement): Promise { + const described = await call("GET", "/api/v1/console"); + const info: ConsoleInfo = + described.body !== null && !isError(described.body) + ? described.body + : { environment: "undeclared", version: "?" }; + + let stopIdle: (() => void) | null = null; + + const showLogin = (notice?: string): void => { + stopIdle?.(); + stopIdle = null; + renderLogin(root, info, showShell, notice); + }; + + const logout = async (notice?: string): Promise => { + await call("POST", "/api/v1/logout"); + showLogin(notice); + }; + + const showShell = (me: Me): void => { + renderShell(root, info, me, () => void logout()); + let warning: HTMLElement | null = null; + stopIdle = watchIdle( + () => { + warning ??= idleWarning(root); + }, + () => void logout("Session verrouillée après 15 minutes d'inactivité : reconnectez-vous avec votre clé."), + ); + const clearWarning = (): void => { + warning?.remove(); + warning = null; + }; + window.addEventListener("keydown", clearWarning); + window.addEventListener("pointerdown", clearWarning); + }; + + const me = await call("GET", "/api/v1/me"); + if (me.status === 200 && me.body !== null && !isError(me.body)) { + showShell(me.body); + } else { + showLogin(); + } +} + +const root = document.getElementById("app"); +if (root !== null) void boot(root); diff --git a/bin/ra-console/web/src/shell.ts b/bin/ra-console/web/src/shell.ts new file mode 100644 index 0000000..102ea22 --- /dev/null +++ b/bin/ra-console/web/src/shell.ts @@ -0,0 +1,69 @@ +// Le poste de travail une fois connecté (docs/UI-UX.md §2) : barre de +// sécurité (environnement, identité et rôle relus sur le serveur), navigation +// latérale avec les compteurs des files, zone de travail. Les écrans métier +// (demandes, révocation, quorum, audit) arrivent aux étapes 6b et suivantes. + +import { call, isError, type ConsoleInfo, type Me } from "./api"; +import { banner } from "./banner"; +import { h, replace } from "./dom"; + +const ROLE_LABELS: Record = { + auditeur: "auditeur", + ra_operateur: "opérateur RA", + ca_operateur: "opérateur CA", + admin: "administrateur", +}; + +export function renderShell(root: HTMLElement, info: ConsoleInfo, me: Me, onLogout: () => void): void { + const logout = h("button", { type: "button", class: "quiet", "data-testid": "logout" }, "Se déconnecter"); + logout.addEventListener("click", onLogout); + const bar = h( + "header", + { class: "security-bar" }, + h("span", { class: "brand" }, "Open eIDAS"), + h( + "span", + { class: "identity" }, + h("span", { class: "operator", "data-testid": "operator" }, me.operator), + h("span", { class: `role role-${me.role}`, "data-testid": "role" }, ROLE_LABELS[me.role]), + ), + logout, + ); + const requests = h("span", { class: "count", "data-testid": "count-requests" }, "…"); + const quorum = h("span", { class: "count", "data-testid": "count-quorum" }, "…"); + const nav = h( + "nav", + { class: "sidebar", "aria-label": "Files de travail" }, + h("ul", {}, h("li", {}, "Demandes RA ", requests), h("li", {}, "Quorum ", quorum)), + ); + const work = h( + "main", + { class: "workspace", tabindex: "-1" }, + h("h1", {}, "Files de travail"), + h("p", { class: "muted" }, "Les écrans de décision arrivent avec les étapes suivantes."), + ); + replace(root, banner(info), bar, h("div", { class: "layout" }, nav, work)); + void refreshCounts(requests, quorum); +} + +async function refreshCounts(requests: HTMLElement, quorum: HTMLElement): Promise { + const [pending, waiting] = await Promise.all([ + call("GET", "/api/v1/requests?state=PENDING"), + call("GET", "/api/v1/quorum?state=PENDING"), + ]); + requests.textContent = Array.isArray(pending.body) ? `(${pending.body.length})` : "(—)"; + quorum.textContent = Array.isArray(waiting.body) ? `(${waiting.body.length})` : "(—)"; + if (isError(pending.body) || isError(waiting.body)) { + requests.title = quorum.title = "compteur indisponible"; + } +} + +export function idleWarning(root: HTMLElement): HTMLElement { + const warning = h( + "div", + { class: "idle-warning", role: "alert", "data-testid": "idle-warning" }, + "Session inactive : verrouillage dans une minute. Une action au clavier ou à la souris la prolonge.", + ); + root.prepend(warning); + return warning; +} diff --git a/bin/ra-console/web/src/webauthn.ts b/bin/ra-console/web/src/webauthn.ts new file mode 100644 index 0000000..b323125 --- /dev/null +++ b/bin/ra-console/web/src/webauthn.ts @@ -0,0 +1,43 @@ +// Cérémonie d'authentification WebAuthn côté navigateur : seule l'API +// standard `navigator.credentials` est utilisée (docs/UI-UX.md §7). Les +// options viennent du serveur (webauthn-rs, JSON niveau 3) ; l'assertion est +// rendue dans la même forme, que le serveur vérifie seul. + +import { fromBase64Url, toBase64Url } from "./b64url"; + +interface RequestOptionsJson { + challenge: string; + timeout?: number; + rpId?: string; + allowCredentials?: { type: "public-key"; id: string; transports?: AuthenticatorTransport[] }[]; + userVerification?: UserVerificationRequirement; +} + +export async function assert(options: RequestOptionsJson): Promise { + const publicKey: PublicKeyCredentialRequestOptions = { + challenge: fromBase64Url(options.challenge), + allowCredentials: (options.allowCredentials ?? []).map((c) => ({ + type: c.type, + id: fromBase64Url(c.id), + ...(c.transports ? { transports: c.transports } : {}), + })), + ...(options.timeout !== undefined ? { timeout: options.timeout } : {}), + ...(options.rpId !== undefined ? { rpId: options.rpId } : {}), + ...(options.userVerification !== undefined ? { userVerification: options.userVerification } : {}), + }; + const credential = (await navigator.credentials.get({ publicKey })) as PublicKeyCredential | null; + if (credential === null) throw new Error("aucune clé n'a répondu"); + const response = credential.response as AuthenticatorAssertionResponse; + return { + id: credential.id, + rawId: toBase64Url(credential.rawId), + type: credential.type, + response: { + clientDataJSON: toBase64Url(response.clientDataJSON), + authenticatorData: toBase64Url(response.authenticatorData), + signature: toBase64Url(response.signature), + userHandle: response.userHandle ? toBase64Url(response.userHandle) : null, + }, + extensions: {}, + }; +} diff --git a/bin/ra-console/web/static/console.css b/bin/ra-console/web/static/console.css new file mode 100644 index 0000000..15b2bc1 --- /dev/null +++ b/bin/ra-console/web/static/console.css @@ -0,0 +1,212 @@ +/* Console d'opération Open eIDAS — tokens de docs/UI-UX.md §4. + Aucun style en ligne ni police distante : la CSP l'interdit (§6.3). */ + +:root { + --bg-canvas: #090d16; + --bg-surface: #111827; + --bg-surface-elevated: #1f2937; + --border-subtle: #374151; + --border-focus: #38bdf8; + --text-main: #f3f4f6; + --text-muted: #9ca3af; + + --prod-bg: #881337; + --prod-text: #ffe4e6; + --staging-bg: #0c4a6e; + --staging-text: #e0f2fe; + --demo-bg: #1e293b; + --demo-text: #cbd5e1; + --undeclared-bg: #451a03; + --undeclared-text: #fbbf24; + + --primary-action: #059669; + --primary-action-hover: #10b981; + + --role-auditeur: #0284c7; + --role-ra_operateur: #059669; + --role-ca_operateur: #7c3aed; + --role-admin: #d97706; + + --font-ui: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + --font-mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + margin: 0; + background: var(--bg-canvas); + color: var(--text-main); + font-family: var(--font-ui); + font-size: 14px; + line-height: 1.4; +} + +:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 2px; +} + +.env-banner { + padding: 4px 16px; + font-weight: 700; + letter-spacing: 0.08em; + text-align: center; + font-size: 12px; +} +.env-production { + background: var(--prod-bg); + color: var(--prod-text); +} +.env-staging { + background: var(--staging-bg); + color: var(--staging-text); +} +.env-demo { + background: var(--demo-bg); + color: var(--demo-text); +} +.env-undeclared { + background: var(--undeclared-bg); + color: var(--undeclared-text); + border-bottom: 2px dashed var(--undeclared-text); +} + +.login { + display: grid; + place-items: center; + min-height: calc(100vh - 32px); +} +.login-form { + display: grid; + gap: 12px; + width: min(420px, 90vw); + padding: 32px; + background: var(--bg-surface); + border: 1px solid var(--border-subtle); + border-radius: 8px; +} +.login-form h1 { + font-size: 18px; + font-weight: 600; + margin: 0 0 8px; +} +input { + font: inherit; + color: var(--text-main); + background: var(--bg-canvas); + border: 1px solid var(--border-subtle); + border-radius: 4px; + padding: 8px 10px; +} +button { + font: inherit; + cursor: pointer; + border-radius: 4px; + padding: 8px 14px; + border: 1px solid var(--border-subtle); + background: var(--bg-surface-elevated); + color: var(--text-main); +} +button.primary { + background: var(--primary-action); + border-color: var(--primary-action); + color: #ffffff; + font-weight: 600; +} +button.primary:hover { + background: var(--primary-action-hover); +} +button:disabled { + opacity: 0.6; + cursor: progress; +} +button.quiet { + background: transparent; +} +.status { + min-height: 1.4em; + color: var(--text-muted); + margin: 0; +} + +.security-bar { + display: flex; + align-items: center; + gap: 16px; + padding: 8px 16px; + background: var(--bg-surface); + border-bottom: 1px solid var(--border-subtle); +} +.security-bar .brand { + font-weight: 700; +} +.security-bar .identity { + margin-left: auto; + display: flex; + align-items: center; + gap: 8px; +} +.role { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid currentColor; +} +.role-auditeur { + color: var(--role-auditeur); +} +.role-ra_operateur { + color: var(--role-ra_operateur); +} +.role-ca_operateur { + color: var(--role-ca_operateur); +} +.role-admin { + color: var(--role-admin); +} + +.layout { + display: grid; + grid-template-columns: 220px 1fr; + min-height: calc(100vh - 80px); +} +.sidebar { + background: var(--bg-surface); + border-right: 1px solid var(--border-subtle); + padding: 16px; +} +.sidebar ul { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 8px; +} +.sidebar .count { + color: var(--text-muted); + font-family: var(--font-mono); + font-variant-numeric: slashed-zero tabular-nums; +} +.workspace { + padding: 24px; +} +.workspace h1 { + font-size: 18px; + font-weight: 600; + margin-top: 0; +} +.muted { + color: var(--text-muted); +} + +.idle-warning { + padding: 8px 16px; + background: var(--undeclared-bg); + color: var(--undeclared-text); + border-bottom: 1px dashed var(--undeclared-text); +} diff --git a/bin/ra-console/web/static/index.html b/bin/ra-console/web/static/index.html new file mode 100644 index 0000000..f335804 --- /dev/null +++ b/bin/ra-console/web/static/index.html @@ -0,0 +1,16 @@ + + + + + + Open eIDAS — Console d'opération + + + + + + +
+ + + diff --git a/bin/ra-console/web/tsconfig.json b/bin/ra-console/web/tsconfig.json new file mode 100644 index 0000000..3428c20 --- /dev/null +++ b/bin/ra-console/web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": [ + "ES2022", + "DOM", + "DOM.Iterable" + ], + "strict": true, + "noUncheckedIndexedAccess": true, + "noImplicitOverride": true, + "exactOptionalPropertyTypes": true, + "noEmit": true, + "skipLibCheck": true, + "types": [] + }, + "include": [ + "src/**/*.ts" + ] +} diff --git a/docs/RA-CONSOLE.md b/docs/RA-CONSOLE.md index b4a724b..035ebd8 100644 --- a/docs/RA-CONSOLE.md +++ b/docs/RA-CONSOLE.md @@ -169,6 +169,26 @@ d'une action à plusieurs signatures (`status`, `signatures`, `required`, `signe - Identifiant de clé : base64url, 1 024 caractères au plus ; nom d'opérateur : 1 à 256 caractères. Toute autre forme est refusée avant relais. +## Frontend (étape 6a : socle) + +La console sert elle-même son interface (docs/UI-UX.md) : `/` et `/assets/*`, embarqués +dans le binaire (aucun serveur web ni répertoire d'assets à déployer). Sources et +construction : [`bin/ra-console/web/`](../bin/ra-console/web/README.md). + +- **Toutes** les réponses, API comprise, portent la CSP stricte d'UI-UX §6.3 (aucun + script ni style en ligne, rien hors de l'origine, `frame-ancestors 'none'`), + `X-Frame-Options: DENY`, `nosniff`, `Referrer-Policy: no-referrer` et + `Cache-Control: no-store`. +- Bannière d'environnement sur tous les écrans, connexion comprise : + `OPENEIDAS_RA_ENVIRONMENT` (`production`, `staging`, `demo`) ; non déclarée, la + console affiche « ENVIRONNEMENT NON DÉCLARÉ » plutôt qu'un environnement sans risque. + `GET /api/v1/console` (sans session) la rend au frontend. +- Connexion par nom et clé FIDO2, poste de travail (identité et rôle relus sur le + serveur, compteurs des files), déconnexion, **verrouillage après 15 minutes + d'inactivité** (avertissement à 14) : la session est révoquée côté serveur. +- Les écrans métier (décisions, révocation, quorum, audit) suivent (étapes 6b et + suivantes). + ## Variables d'environnement | Variable | Défaut | Rôle | @@ -178,6 +198,7 @@ d'une action à plusieurs signatures (`status`, `signatures`, `required`, `signe | `OPENEIDAS_INTERNAL_TLS_CERT_FILE` / `_KEY_FILE` | — (obligatoires) | Certificat `internal_client` de la console et sa clé (PEM) | | `OPENEIDAS_CA_CERT_FILE` | — (obligatoire) | Certificat de la CA émettrice, seule racine de confiance du lien | | `OPENEIDAS_RA_LISTEN` | `:8330` | Adresse d'écoute | +| `OPENEIDAS_RA_ENVIRONMENT` | — (non déclaré) | `production`, `staging` ou `demo` : bannière du frontend | | `OPENEIDAS_ENROLL_URL` | — | (`internal-cert`) API d'enrôlement publique de la CA | | `OPENEIDAS_ENROLL_HMAC_KEY` | — | (`internal-cert`) secret partagé d'enrôlement | | `OPENEIDAS_ENROLL_TIMEOUT_SECONDS` | 600 | (`internal-cert`) attente de l'approbation |