From ecd6b00a1eb5e0b953a81ae3ad05184ca4702a36 Mon Sep 17 00:00:00 2001 From: Till Wegmueller Date: Tue, 6 Jan 2026 12:31:22 +0100 Subject: [PATCH] Implement Passkey classification features Signed-off-by: Till Wegmueller --- .claude/settings.local.json | 6 +- src/storage.rs | 16 ++++ src/web.rs | 152 +++++++++++++++++++++++++++++++++--- 3 files changed, 160 insertions(+), 14 deletions(-) diff --git a/.claude/settings.local.json b/.claude/settings.local.json index e1acb91..b62dcca 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -32,7 +32,11 @@ "Bash(find:*)", "Bash(wc:*)", "Bash(cargo fix:*)", - "Bash(tee:*)" + "Bash(tee:*)", + "mcp__context7__query-docs", + "Bash(cargo expand:*)", + "Bash(cargo tree:*)", + "Bash(cargo metadata:*)" ], "deny": [], "ask": [] diff --git a/src/storage.rs b/src/storage.rs index 3509534..8d70c29 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -1042,6 +1042,22 @@ pub async fn append_session_amr( Ok(()) } +/// Revoke an access token by marking it as revoked (RFC 7009) +pub async fn revoke_access_token(db: &DatabaseConnection, token: &str) -> Result<(), CrabError> { + use entities::access_token::{Column, Entity}; + + if let Some(at) = Entity::find() + .filter(Column::Token.eq(token)) + .one(db) + .await? + { + let mut active: entities::access_token::ActiveModel = at.into(); + active.revoked = Set(1); + active.update(db).await?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/src/web.rs b/src/web.rs index 5792d0b..2859be7 100644 --- a/src/web.rs +++ b/src/web.rs @@ -119,6 +119,7 @@ pub async fn serve( .route("/logout", get(logout)) .route("/authorize", get(authorize)) .route("/token", post(token)) + .route("/revoke", post(token_revoke)) .route("/userinfo", get(userinfo)) // WebAuthn / Passkey endpoints .route("/webauthn/register/start", post(passkey_register_start)) @@ -1013,6 +1014,94 @@ async fn handle_authorization_code_grant( ) } +/// POST /revoke - Token revocation endpoint (RFC 7009) +#[derive(Debug, Deserialize)] +struct TokenRevokeRequest { + token: String, + token_type_hint: Option, + client_id: Option, + client_secret: Option, +} + +async fn token_revoke( + State(state): State, + headers: HeaderMap, + Form(req): Form, +) -> Response { + // Client authentication: try Basic auth first, then form body + let mut basic_client: Option<(String, String)> = None; + if let Some(auth_val) = headers + .get(axum::http::header::AUTHORIZATION) + .and_then(|h| h.to_str().ok()) + { + if let Some(b64) = auth_val.strip_prefix("Basic ") { + if let Ok(mut decoded) = Base64::decode_vec(b64) { + if let Ok(s) = String::from_utf8(std::mem::take(&mut decoded)) { + if let Some((id, sec)) = s.split_once(':') { + basic_client = Some((id.to_string(), sec.to_string())); + } + } + } + } + } + + let (client_id, client_secret) = if let Some(pair) = basic_client { + pair + } else { + // Try client_secret_post (form body) + match (req.client_id.clone(), req.client_secret.clone()) { + (Some(id), Some(sec)) => (id, sec), + _ => { + return json_with_headers( + StatusCode::UNAUTHORIZED, + json!({"error":"invalid_client"}), + &[( + "www-authenticate", + "Basic realm=\"token\", error=\"invalid_client\"".to_string(), + )], + ) + } + } + }; + + // Verify client exists and credentials match + let client = match storage::get_client(&state.db, &client_id).await { + Ok(Some(c)) => c, + _ => { + return json_with_headers( + StatusCode::UNAUTHORIZED, + json!({"error":"invalid_client"}), + &[( + "www-authenticate", + "Basic realm=\"token\", error=\"invalid_client\"".to_string(), + )], + ) + } + }; + + if client.client_secret != client_secret { + return json_with_headers( + StatusCode::UNAUTHORIZED, + json!({"error":"invalid_client"}), + &[( + "www-authenticate", + "Basic realm=\"token\", error=\"invalid_client\"".to_string(), + )], + ); + } + + // Per RFC 7009 section 2.2: The authorization server responds with HTTP 200 + // whether the token was revoked or not (to prevent token scanning) + let _ = storage::revoke_access_token(&state.db, &req.token).await; + let _ = storage::revoke_refresh_token(&state.db, &req.token).await; + + // Return 200 OK with empty response + Response::builder() + .status(StatusCode::OK) + .body(Body::empty()) + .unwrap() +} + async fn handle_refresh_token_grant( state: AppState, headers: HeaderMap, @@ -2001,6 +2090,34 @@ async fn passkey_register_finish( ) })?; + // Parse serialized passkey to extract fields (cred is private, use JSON introspection) + let passkey_data: serde_json::Value = serde_json::from_str(&passkey_json).map_err(|e| { + ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("Failed to parse passkey JSON: {}", e), + ) + })?; + + // Extract counter from cred.counter + let counter = passkey_data + .get("cred") + .and_then(|c| c.get("counter")) + .and_then(|c| c.as_u64()) + .unwrap_or(0) as i64; + + // Extract backup flags from cred + let backup_eligible = passkey_data + .get("cred") + .and_then(|c| c.get("backup_eligible")) + .and_then(|b| b.as_bool()) + .unwrap_or(false); + + let backup_state = passkey_data + .get("cred") + .and_then(|c| c.get("backup_state")) + .and_then(|b| b.as_bool()) + .unwrap_or(false); + // Use credential ID from the request let cred_id_b64 = Base64UrlUnpadded::encode_string(req.credential.id.as_bytes()); @@ -2009,12 +2126,12 @@ async fn passkey_register_finish( &cred_id_b64, &session.subject, &passkey_json, - 0, // counter - TODO: extract from passkey when we understand the API - None, // aaguid - false, // backup_eligible - TODO: extract from passkey - false, // backup_state - TODO: extract from passkey - None, // transports - req.name.as_deref(), // Name from request + counter, // Extracted from passkey + None, // aaguid + backup_eligible, // Extracted from passkey + backup_state, // Extracted from passkey + None, // transports + req.name.as_deref(), // Name from request ) .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -2138,7 +2255,7 @@ async fn passkey_auth_finish( })?; // Finish authentication - let _auth_result = state + let auth_result = state .webauthn .finish_passkey_authentication(&req.credential, &auth_state) .map_err(|e| { @@ -2148,9 +2265,13 @@ async fn passkey_auth_finish( ) })?; - // Update counter (TODO: extract counter from auth_result when we understand the API) - // For now, just update last_used_at - // storage::update_passkey_counter(&state.db, &cred_id_b64, new_counter).await?; + // Extract counter using public API (AuthenticationResult has accessor methods) + let new_counter = auth_result.counter() as i64; + + // Update counter for clone detection + storage::update_passkey_counter(&state.db, &cred_id_b64, new_counter) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Determine AMR based on backup state let amr = if passkey.backup_eligible == 1 && passkey.backup_state == 1 { @@ -2306,7 +2427,7 @@ async fn passkey_2fa_finish( })?; // Finish authentication - let _auth_result = state + let auth_result = state .webauthn .finish_passkey_authentication(&req.credential, &auth_state) .map_err(|e| { @@ -2323,8 +2444,13 @@ async fn passkey_2fa_finish( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? .ok_or((StatusCode::NOT_FOUND, "Passkey not found".to_string()))?; - // Update counter (TODO: extract counter from auth_result when we understand the API) - // storage::update_passkey_counter(&state.db, &cred_id_b64, new_counter).await?; + // Extract counter using public API (AuthenticationResult has accessor methods) + let new_counter = auth_result.counter() as i64; + + // Update counter for clone detection + storage::update_passkey_counter(&state.db, &cred_id_b64, new_counter) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; // Append passkey method to AMR let amr_method = if passkey.backup_eligible == 1 && passkey.backup_state == 1 {