Gunnar

nordisk/frida

crates/frida-verifier/src/main.rs

//! frida — WebAuthn **Relying Party (verifier)** — standalone localhost demo.
//!
//! Proves the frida eID auth/sign loop end-to-end with a real passkey, no mobile app and no card
//! reader: browser WebAuthn ↔ this RP. `localhost` is a secure context, so plain HTTP is fine for
//! the demo (production terminates TLS at the ingress / uses a real cert — see the Helm chart).
//!
//! State is in-memory (a single demo user) — **never a relational database**; a real deployment
//! backs it with `eid-core`'s `EventStore`. Run: `cargo run -p frida-verifier`, then open
//! <http://localhost:8080>, "Register passkey", then "Sign / authenticate".
//!
//! NOTE: the server is verified to compile; the browser flow needs a real browser to exercise
//! (the JSON field mapping follows the standard WebAuthn shape).

use std::sync::{Arc, Mutex};

use axum::{
    Json, Router,
    extract::State,
    http::StatusCode,
    response::{Html, IntoResponse},
    routing::{get, post},
};
use webauthn_rs::prelude::*;

/// In-memory demo store (single user). No RDBMS.
#[derive(Default)]
struct Store {
    passkeys: Vec<Passkey>,
    reg: Option<PasskeyRegistration>,
    auth: Option<PasskeyAuthentication>,
}

struct AppState {
    webauthn: Webauthn,
    store: Mutex<Store>,
    /// Mount path the browser sees (`FRIDA_PATH_PREFIX`, e.g. `/frida`), baked
    /// into the served HTML so its fetch() targets stay inside the mount.
    path_prefix: String,
}

const DEMO_USER: &str = "rickard@vetra";

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt().with_max_level(tracing::Level::INFO).init();

    // Deployment-configurable RP identity (env), defaults to the localhost demo.
    // In-cluster: FRIDA_RP_ID = the registrable domain (e.g. g11.se),
    // FRIDA_RP_ORIGIN = the exact https origin (e.g. https://frida.g11.se). TLS is
    // terminated at the ingress, so this process itself serves plain HTTP.
    let rp_id = std::env::var("FRIDA_RP_ID").unwrap_or_else(|_| "localhost".into());
    let rp_origin_str =
        std::env::var("FRIDA_RP_ORIGIN").unwrap_or_else(|_| "http://localhost:8080".into());
    let rp_origin = Url::parse(&rp_origin_str).expect("valid FRIDA_RP_ORIGIN");
    let webauthn = WebauthnBuilder::new(&rp_id, &rp_origin)
        .expect("valid webauthn config")
        .rp_name("frida")
        .build()
        .expect("build webauthn");

    // Mount path. In-cluster the RP is served **same-origin behind the consuming
    // app's ingress** (e.g. https://lab.g11.se/frida) so the WebAuthn RP-ID and
    // origin match the app — hosting it on a separate origin would bind the
    // credential somewhere the app can never assert against. Empty (default) =
    // localhost demo at `/`.
    //
    // WARNING — same env var name as `frida-order`, OPPOSITE deployment meaning:
    // `frida-order` only interpolates FRIDA_PATH_PREFIX into the handoff/QR URLs
    // and does NOT nest its router, so it needs an ingress path *rewrite*. This
    // binary really does nest under the prefix, so its ingress must NOT rewrite.
    // Don't copy one's ingress rule to the other.
    let path_prefix =
        std::env::var("FRIDA_PATH_PREFIX").unwrap_or_default().trim_end_matches('/').to_string();

    let state = Arc::new(AppState {
        webauthn,
        store: Mutex::new(Store::default()),
        path_prefix: path_prefix.clone(),
    });

    let routes = Router::new()
        .route("/", get(index))
        .route("/register/start", post(register_start))
        .route("/register/finish", post(register_finish))
        .route("/auth/start", post(auth_start))
        .route("/auth/finish", post(auth_finish));
    let app = if path_prefix.is_empty() {
        routes.with_state(state)
    } else {
        // `nest` already serves the nested `/` at the bare prefix as well, so
        // both https://lab.g11.se/frida and /frida/ reach the index. Adding an
        // explicit route for the prefix here panics with "Overlapping method
        // route" at startup — don't.
        // `nest` serves the bare prefix but NOT the trailing-slash form, so route
        // `/frida/` explicitly. (Routing the bare prefix here instead would panic
        // with "Overlapping method route" — nest already owns it.)
        Router::new()
            .nest(&path_prefix, routes)
            .route(&format!("{path_prefix}/"), get(index))
            .with_state(state)
    };

    // Bind address: 0.0.0.0:8080 in-cluster (FRIDA_LISTEN), 127.0.0.1:8080 for the demo.
    let listen = std::env::var("FRIDA_LISTEN").unwrap_or_else(|_| "127.0.0.1:8080".into());
    let listener = tokio::net::TcpListener::bind(&listen).await.expect("bind");
    tracing::info!(
        "frida RP (rp_id={rp_id}, origin={rp_origin_str}, prefix={:?}) on http://{listen}",
        path_prefix
    );
    axum::serve(listener, app).await.expect("serve");
}

/// Begin registering a new passkey for the demo user.
async fn register_start(State(s): State<Arc<AppState>>) -> impl IntoResponse {
    let user_id = Uuid::new_v4();
    let exclude: Option<Vec<CredentialID>> = {
        let st = s.store.lock().unwrap();
        (!st.passkeys.is_empty()).then(|| st.passkeys.iter().map(|p| p.cred_id().clone()).collect())
    };
    match s
        .webauthn
        .start_passkey_registration(user_id, DEMO_USER, "Rickard (frida demo)", exclude)
    {
        Ok((ccr, reg)) => {
            s.store.lock().unwrap().reg = Some(reg);
            Json(ccr).into_response()
        }
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("register start: {e}")).into_response(),
    }
}

/// Finish registration: store the verified passkey.
async fn register_finish(
    State(s): State<Arc<AppState>>,
    Json(cred): Json<RegisterPublicKeyCredential>,
) -> impl IntoResponse {
    let reg = s.store.lock().unwrap().reg.take();
    let Some(reg) = reg else {
        return (StatusCode::BAD_REQUEST, "no registration in progress").into_response();
    };
    match s.webauthn.finish_passkey_registration(&cred, &reg) {
        Ok(pk) => {
            s.store.lock().unwrap().passkeys.push(pk);
            (StatusCode::OK, "passkey registered — now Sign / authenticate").into_response()
        }
        Err(e) => (StatusCode::BAD_REQUEST, format!("register finish: {e}")).into_response(),
    }
}

/// Begin an authentication / signing ceremony against the registered passkey(s).
async fn auth_start(State(s): State<Arc<AppState>>) -> impl IntoResponse {
    let passkeys = s.store.lock().unwrap().passkeys.clone();
    if passkeys.is_empty() {
        return (StatusCode::BAD_REQUEST, "no passkey registered yet — Register first").into_response();
    }
    match s.webauthn.start_passkey_authentication(&passkeys) {
        Ok((rcr, auth)) => {
            s.store.lock().unwrap().auth = Some(auth);
            Json(rcr).into_response()
        }
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, format!("auth start: {e}")).into_response(),
    }
}

/// Finish authentication: verify the assertion — the RP's core job.
async fn auth_finish(
    State(s): State<Arc<AppState>>,
    Json(cred): Json<PublicKeyCredential>,
) -> impl IntoResponse {
    let auth = s.store.lock().unwrap().auth.take();
    let Some(auth) = auth else {
        return (StatusCode::BAD_REQUEST, "no authentication in progress").into_response();
    };
    match s.webauthn.finish_passkey_authentication(&cred, &auth) {
        Ok(res) => (
            StatusCode::OK,
            format!(
                "✅ verified by the frida RP — user_verified={}, counter={}",
                res.user_verified(),
                res.counter()
            ),
        )
            .into_response(),
        Err(e) => (StatusCode::BAD_REQUEST, format!("auth finish: {e}")).into_response(),
    }
}

/// The clickable demo page, with the mount path baked in so its fetch() calls
/// resolve inside the prefix the ingress routes to us.
async fn index(State(s): State<Arc<AppState>>) -> Html<String> {
    Html(INDEX_HTML.replace("__FRIDA_BASE__", &s.path_prefix))
}

const INDEX_HTML: &str = r##"<!doctype html><html><head><meta charset="utf-8">
<title>frida — eID demo (localhost)</title>
<style>
 body{font-family:Inter,system-ui,sans-serif;max-width:36rem;margin:3rem auto;color:#151A23}
 button{font:inherit;padding:.6rem 1rem;border-radius:.5rem;border:0;background:#F7C600;cursor:pointer;margin-right:.5rem}
 #log{margin-top:1.5rem;padding:1rem;background:#F8FAFC;border-radius:.5rem;white-space:pre-wrap;min-height:3rem}
</style></head><body>
<h1>frida eID — localhost demo</h1>
<p>A passkey stands in for the frida holder. Register once, then sign/authenticate — the frida RP verifies it.</p>
<button onclick="register()">Register passkey</button>
<button onclick="sign()">Sign / authenticate</button>
<div id="log">Ready.</div>
<script>
const BASE = '__FRIDA_BASE__';
const log = m => document.getElementById('log').textContent = m;
const b64urlToBuf = s => { s=s.replace(/-/g,'+').replace(/_/g,'/'); s+='='.repeat((4-s.length%4)%4);
  const b=atob(s), u=new Uint8Array(b.length); for(let i=0;i<b.length;i++)u[i]=b.charCodeAt(i); return u.buffer; };
const bufToB64url = b => btoa(String.fromCharCode(...new Uint8Array(b))).replace(/\+/g,'-').replace(/\//g,'_').replace(/=+$/,'');
async function register(){ try{
  const opts = await (await fetch(BASE+'/register/start',{method:'POST'})).json();
  const pk = opts.publicKey;
  pk.challenge = b64urlToBuf(pk.challenge);
  pk.user.id = b64urlToBuf(pk.user.id);
  (pk.excludeCredentials||[]).forEach(c=>c.id=b64urlToBuf(c.id));
  const c = await navigator.credentials.create({publicKey: pk});
  const body = { id:c.id, rawId:bufToB64url(c.rawId), type:c.type,
    response:{ attestationObject:bufToB64url(c.response.attestationObject), clientDataJSON:bufToB64url(c.response.clientDataJSON) },
    extensions: c.getClientExtensionResults ? c.getClientExtensionResults() : {} };
  log(await (await fetch(BASE+'/register/finish',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)})).text());
}catch(e){ log('error: '+e); } }
async function sign(){ try{
  const opts = await (await fetch(BASE+'/auth/start',{method:'POST'})).json();
  const pk = opts.publicKey;
  pk.challenge = b64urlToBuf(pk.challenge);
  (pk.allowCredentials||[]).forEach(c=>c.id=b64urlToBuf(c.id));
  const c = await navigator.credentials.get({publicKey: pk});
  const body = { id:c.id, rawId:bufToB64url(c.rawId), type:c.type,
    response:{ authenticatorData:bufToB64url(c.response.authenticatorData), clientDataJSON:bufToB64url(c.response.clientDataJSON),
      signature:bufToB64url(c.response.signature), userHandle: c.response.userHandle ? bufToB64url(c.response.userHandle) : null },
    extensions: c.getClientExtensionResults ? c.getClientExtensionResults() : {} };
  log(await (await fetch(BASE+'/auth/finish',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)})).text());
}catch(e){ log('error: '+e); } }
</script></body></html>"##;