//! 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, ®) {
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>"##;