diff --git a/crates/agentkeys-core/src/mock_client.rs b/crates/agentkeys-core/src/mock_client.rs index 72c4268..cbf0021 100644 --- a/crates/agentkeys-core/src/mock_client.rs +++ b/crates/agentkeys-core/src/mock_client.rs @@ -424,14 +424,27 @@ impl CredentialBackend for MockHttpClient { AuthRequestType::KeyRotate { .. } => "KeyRotate", }; + let mut request_body = json!({ + "child_pubkey": pubkey_b64, + "request_type": request_type_str, + "request_details": details_b64, + }); + + if let AuthRequestType::Recover { agent_identity, .. } = &request_type { + let (identity_type, identity_value) = match agent_identity { + agentkeys_types::AgentIdentity::Alias(s) => ("alias", s.clone()), + agentkeys_types::AgentIdentity::Email(s) => ("email", s.clone()), + agentkeys_types::AgentIdentity::Ens(s) => ("ens", s.clone()), + agentkeys_types::AgentIdentity::WalletAddress(w) => ("wallet", w.0.clone()), + }; + request_body["identity_type"] = json!(identity_type); + request_body["identity_value"] = json!(identity_value); + } + let resp = self .client .post(self.url("/auth-request/open")) - .json(&json!({ - "child_pubkey": pubkey_b64, - "request_type": request_type_str, - "request_details": details_b64, - })) + .json(&request_body) .send() .await .map_err(|e| BackendError::Transport(e.to_string()))?; diff --git a/crates/agentkeys-mock-server/src/db.rs b/crates/agentkeys-mock-server/src/db.rs index 18ac249..2e4bd64 100644 --- a/crates/agentkeys-mock-server/src/db.rs +++ b/crates/agentkeys-mock-server/src/db.rs @@ -68,7 +68,9 @@ pub fn init_schema(conn: &Connection) -> Result<()> { session_json TEXT, wallet_address TEXT, created_at INTEGER NOT NULL, - ttl_seconds INTEGER NOT NULL + ttl_seconds INTEGER NOT NULL, + identity_type TEXT, + identity_value TEXT ); CREATE TABLE IF NOT EXISTS identity_links ( diff --git a/crates/agentkeys-mock-server/src/handlers/auth_request.rs b/crates/agentkeys-mock-server/src/handlers/auth_request.rs index a3bd68e..924d123 100644 --- a/crates/agentkeys-mock-server/src/handlers/auth_request.rs +++ b/crates/agentkeys-mock-server/src/handlers/auth_request.rs @@ -11,53 +11,15 @@ use std::time::Duration; use tokio::time::sleep; use crate::{ - auth::{ - derive_pair_code_from_nonce, extract_bearer_token, generate_nonce, generate_token, - now_secs, validate_session, - }, + auth::{extract_bearer_token, generate_nonce, generate_token, now_secs, validate_session}, error::{AppError, AppResult}, state::SharedState, }; use agentkeys_core::otp::derive_otp; -use agentkeys_types::{AuthRequestType, Scope, Session, WalletAddress}; -fn parse_cbor_agent_identity(request_details: &[u8]) -> (Option, Option) { - let cbor_val: ciborium::Value = match ciborium::from_reader(request_details) { - Ok(v) => v, - Err(_) => return (None, None), - }; - let map = match cbor_val { - ciborium::Value::Map(m) => m, - _ => return (None, None), - }; - for (k, v) in &map { - let key_str = match k { - ciborium::Value::Text(s) => s.as_str(), - _ => continue, - }; - if key_str != "agent_identity" { - continue; - } - let inner_map = match v { - ciborium::Value::Map(m) => m, - _ => return (None, None), - }; - let mut id_type = None; - let mut id_value = None; - for (ik, iv) in inner_map { - if let ciborium::Value::Text(ik_str) = ik { - if let ciborium::Value::Text(iv_str) = iv { - match ik_str.as_str() { - "type" => id_type = Some(iv_str.clone()), - "value" => id_value = Some(iv_str.clone()), - _ => {} - } - } - } - } - return (id_type, id_value); - } - (None, None) +struct MintOutput { + session_json: Option, + wallet: Option, } fn ttl_for_request_type(request_type_str: &str) -> u64 { @@ -67,6 +29,103 @@ fn ttl_for_request_type(request_type_str: &str) -> u64 { } } +fn mint_pair_session( + db: &rusqlite::Connection, + parent_wallet: &str, + parent_token: &str, + now: u64, +) -> Result { + let child_wallet = crate::auth::generate_wallet_address(); + let child_token = generate_token(); + let ttl: u64 = 2_592_000; // 30 days per wiki/session-token.md policy + + let (pub_key, priv_key): (Vec, Vec) = db + .query_row( + "SELECT public_key, private_key FROM accounts WHERE wallet_address = ?1", + params![parent_wallet], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .map_err(|e| AppError::internal(e.to_string()))?; + + db.execute( + "INSERT OR IGNORE INTO accounts (wallet_address, auth_token, public_key, private_key, created_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![child_wallet, format!("child-pair:{child_token}"), pub_key, priv_key, now], + ) + .map_err(|e| AppError::internal(e.to_string()))?; + + db.execute( + "INSERT INTO sessions (token, wallet_address, parent_token, scope_json, created_at, ttl_seconds, revoked) + VALUES (?1, ?2, ?3, NULL, ?4, ?5, 0)", + params![child_token, child_wallet, parent_token, now, ttl], + ) + .map_err(|e| AppError::internal(e.to_string()))?; + + let session_obj = serde_json::json!({ + "token": child_token, + "wallet": child_wallet, + "scope": null, + "created_at": now, + "ttl_seconds": ttl, + }); + + Ok(MintOutput { + session_json: Some(session_obj.to_string()), + wallet: Some(child_wallet), + }) +} + +fn mint_recover_session( + db: &rusqlite::Connection, + identity_type: &str, + identity_value: &str, + parent_token: &str, + now: u64, +) -> Result { + let wallet = super::identity::resolve_identity_typed(db, identity_type, identity_value)?; + + let child_token = generate_token(); + let ttl: u64 = 2_592_000; // 30 days per wiki/session-token.md policy + + let scope_json: Option = db + .query_row( + "SELECT scope_json FROM sessions WHERE wallet_address = ?1 AND revoked = 0 ORDER BY created_at DESC LIMIT 1", + params![wallet], + |row| row.get(0), + ) + .ok() + .flatten(); + + db.execute( + "INSERT INTO sessions (token, wallet_address, parent_token, scope_json, created_at, ttl_seconds, revoked) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)", + params![child_token, wallet, parent_token, scope_json, now, ttl], + ) + .map_err(|e| AppError::internal(e.to_string()))?; + + let session_obj = serde_json::json!({ + "token": child_token, + "wallet": wallet, + "scope": null, + "created_at": now, + "ttl_seconds": ttl, + }); + + Ok(MintOutput { + session_json: Some(session_obj.to_string()), + wallet: Some(wallet), + }) +} + +fn mint_scope_change_session( + _db: &rusqlite::Connection, + _target_wallet: &str, + _new_scope: Option<&str>, + _now: u64, +) -> Result { + Ok(MintOutput { session_json: None, wallet: None }) +} + pub async fn open_auth_request( State(state): State, Json(body): Json, @@ -85,6 +144,27 @@ pub async fn open_auth_request( .ok_or_else(|| AppError::bad_request("request_details required"))?; let parent_wallet = body.get("parent_wallet").and_then(|v| v.as_str()).map(String::from); + let identity_type = body.get("identity_type").and_then(|v| v.as_str()).map(String::from); + let identity_value = body.get("identity_value").and_then(|v| v.as_str()).map(String::from); + + // Typed field validation: Recover requires both; non-Recover rejects both + match request_type_str { + "Recover" => { + if identity_type.is_none() || identity_value.is_none() { + return Err(AppError::bad_request( + "Recover requests require identity_type and identity_value", + )); + } + } + _ => { + if identity_type.is_some() || identity_value.is_some() { + return Err(AppError::bad_request( + "identity_type and identity_value are only valid for Recover requests", + )); + } + } + } + let child_pubkey = base64::Engine::decode( &base64::engine::general_purpose::STANDARD, child_pubkey_b64, @@ -119,8 +199,8 @@ pub async fn open_auth_request( let db = state.db.lock().unwrap(); db.execute( - "INSERT INTO auth_requests (id, pair_code, request_type, request_details, child_pubkey, parent_wallet, otp, nonce, status, created_at, ttl_seconds) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10)", + "INSERT INTO auth_requests (id, pair_code, request_type, request_details, child_pubkey, parent_wallet, otp, nonce, status, created_at, ttl_seconds, identity_type, identity_value) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, 'pending', ?9, ?10, ?11, ?12)", params![ request_id, pair_code, @@ -131,7 +211,9 @@ pub async fn open_auth_request( otp, nonce.to_vec(), now, - ttl_seconds + ttl_seconds, + identity_type, + identity_value ], ) .map_err(|e| AppError::internal(e.to_string()))?; @@ -198,7 +280,6 @@ pub async fn fetch_auth_request( // If already set, only that wallet may fetch. match &parent_wallet { None => { - // Claim ownership for this fetching session db.execute( "UPDATE auth_requests SET parent_wallet = ?1 WHERE pair_code = ?2", params![session.wallet_address, query.pair_code], @@ -250,10 +331,12 @@ pub async fn approve_auth_request( created_at, ttl_seconds, status, + identity_type, + identity_value, ) = { let db = state.db.lock().unwrap(); db.query_row( - "SELECT request_type, request_details, child_pubkey, parent_wallet, nonce, created_at, ttl_seconds, status + "SELECT request_type, request_details, child_pubkey, parent_wallet, nonce, created_at, ttl_seconds, status, identity_type, identity_value FROM auth_requests WHERE id = ?1", params![request_id], |row| { @@ -266,6 +349,8 @@ pub async fn approve_auth_request( row.get::<_, u64>(5)?, row.get::<_, u64>(6)?, row.get::<_, String>(7)?, + row.get::<_, Option>(8)?, + row.get::<_, Option>(9)?, )) }, ) @@ -280,14 +365,12 @@ pub async fn approve_auth_request( return Err(AppError::gone("auth request expired")); } - // Ownership check: if parent_wallet is set, the approving session must own it if let Some(ref pw) = parent_wallet { if *pw != session.wallet_address { return Err(AppError::unauthorized("session does not own this auth request")); } } - // Get master private key for signing let private_key_bytes: Vec = { let db = state.db.lock().unwrap(); db.query_row( @@ -302,7 +385,6 @@ pub async fn approve_auth_request( &private_key_bytes.as_slice().try_into().map_err(|_| AppError::internal("invalid key length"))?, ); - // Sign: SHA256("AgentKeys-v1-AuthRequest" || id || request_type || request_details || child_pubkey || parent_session || created_at || nonce) let mut hasher = Sha256::new(); hasher.update(b"AgentKeys-v1-AuthRequest"); hasher.update(request_id.as_bytes()); @@ -317,105 +399,22 @@ pub async fn approve_auth_request( use ed25519_dalek::Signer; let signature = signing_key.sign(&hash_bytes).to_bytes().to_vec(); - // If Pair request type, mint a child session - let (child_session_json, child_wallet) = if request_type == "Pair" { - let child_wallet = crate::auth::generate_wallet_address(); - let child_token = generate_token(); - let ttl: u64 = 2_592_000; // 30 days per wiki/session-token.md policy - - // Parse scope from request_details (canonical CBOR contains it) - // For mock: create a session with no scope restriction (full access to child wallet) - let db = state.db.lock().unwrap(); - - // Create child account - let (pub_key, priv_key): (Vec, Vec) = db - .query_row( - "SELECT public_key, private_key FROM accounts WHERE wallet_address = ?1", - params![session.wallet_address], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .map_err(|e| AppError::internal(e.to_string()))?; - - db.execute( - "INSERT OR IGNORE INTO accounts (wallet_address, auth_token, public_key, private_key, created_at) - VALUES (?1, ?2, ?3, ?4, ?5)", - params![child_wallet, format!("child-pair:{child_token}"), pub_key, priv_key, now], - ) - .map_err(|e| AppError::internal(e.to_string()))?; - - db.execute( - "INSERT INTO sessions (token, wallet_address, parent_token, scope_json, created_at, ttl_seconds, revoked) - VALUES (?1, ?2, ?3, NULL, ?4, ?5, 0)", - params![child_token, child_wallet, token, now, ttl], - ) - .map_err(|e| AppError::internal(e.to_string()))?; - - let session_obj = serde_json::json!({ - "token": child_token, - "wallet": child_wallet, - "scope": null, - "created_at": now, - "ttl_seconds": ttl, - }); - - (Some(session_obj.to_string()), Some(child_wallet)) - } else if request_type == "Recover" { - // request_details is CBOR-encoded (canonical_bytes per spec), not JSON - let (identity_type, identity_value) = parse_cbor_agent_identity(&request_details); - + let mint_output = { let db = state.db.lock().unwrap(); - - let recovered_wallet: Option = match (identity_type, identity_value) { - (Some(id_type), Some(id_val)) => { - let db_type = match id_type.as_str() { - "Alias" => "alias", - "Email" => "email", - "Ens" => "ens", - "WalletAddress" => "WalletAddress", - other => other, - }; - super::identity::resolve_identity_to_wallet(&db, db_type, &id_val) + match request_type.as_str() { + "Pair" => mint_pair_session(&db, &session.wallet_address, token, now)?, + "Recover" => { + let id_type = identity_type.as_deref().ok_or_else(|| { + AppError::bad_request("Recover request missing identity_type") + })?; + let id_value = identity_value.as_deref().ok_or_else(|| { + AppError::bad_request("Recover request missing identity_value") + })?; + mint_recover_session(&db, id_type, id_value, token, now)? } - _ => None, - }; - - if let Some(wallet) = recovered_wallet { - let child_token = generate_token(); - let ttl: u64 = 2_592_000; // 30 days per wiki/session-token.md policy - - // Preserve scope from the most recent session for this wallet - let scope_json: Option = db - .query_row( - "SELECT scope_json FROM sessions WHERE wallet_address = ?1 AND revoked = 0 ORDER BY created_at DESC LIMIT 1", - params![wallet], - |row| row.get(0), - ) - .ok() - .flatten(); - - db.execute( - "INSERT INTO sessions (token, wallet_address, parent_token, scope_json, created_at, ttl_seconds, revoked) - VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)", - params![child_token, wallet, token, scope_json, now, ttl], - ) - .map_err(|e| AppError::internal(e.to_string()))?; - - let session_obj = serde_json::json!({ - "token": child_token, - "wallet": wallet, - "scope": null, - "created_at": now, - "ttl_seconds": ttl, - }); - - (Some(session_obj.to_string()), Some(wallet)) - } else { - (None, None) + "ScopeChange" => mint_scope_change_session(&db, "", None, now)?, + _ => MintOutput { session_json: None, wallet: None }, } - } else if request_type == "ScopeChange" { - (None, None) - } else { - (None, None) }; let db = state.db.lock().unwrap(); @@ -428,7 +427,7 @@ pub async fn approve_auth_request( db.execute( "UPDATE auth_requests SET status = 'consumed', signature = ?1, session_json = ?2, wallet_address = ?3 WHERE id = ?4", - params![signature, child_session_json, child_wallet, request_id], + params![signature, mint_output.session_json, mint_output.wallet, request_id], ) .map_err(|e| AppError::internal(e.to_string()))?; @@ -493,7 +492,6 @@ pub async fn await_auth_decision( .as_deref() .and_then(|s| serde_json::from_str(s).ok()); - // Mark as awaited so subsequent polls get CONSUMED { let db = state.db.lock().unwrap(); db.execute( diff --git a/crates/agentkeys-mock-server/src/handlers/identity.rs b/crates/agentkeys-mock-server/src/handlers/identity.rs index 619c793..cc16edb 100644 --- a/crates/agentkeys-mock-server/src/handlers/identity.rs +++ b/crates/agentkeys-mock-server/src/handlers/identity.rs @@ -75,17 +75,72 @@ pub fn resolve_identity_to_wallet( } } +/// Shared typed identity → wallet resolver (Issue #13, CLAUDE.md Backend Design Principles). +/// Called from `approve_auth_request` Recover branch and `recover_session` handler. +/// +/// `identity_type` must be one of `"alias"`, `"email"`, `"ens"`, `"wallet"`. +/// - `"alias"`, `"email"`, `"ens"` query `identity_links` for the matching row. +/// - `"wallet"` validates hex format AND confirms the wallet exists in `accounts` +/// before returning it (prevents 500 on later FK constraint in `sessions`). +pub fn resolve_identity_typed( + db: &rusqlite::Connection, + identity_type: &str, + identity_value: &str, +) -> Result { + match identity_type { + "alias" | "email" | "ens" => db + .query_row( + "SELECT wallet_address FROM identity_links WHERE identity_type = ?1 AND identity_value = ?2", + params![identity_type, identity_value], + |row| row.get::<_, String>(0), + ) + .map_err(|_| { + crate::error::AppError::not_found(format!( + "no identity found for type={} value={}", + identity_type, identity_value + )) + }), + "wallet" => { + if !identity_value.starts_with("0x") + || !identity_value[2..].chars().all(|c| c.is_ascii_hexdigit()) + { + return Err(crate::error::AppError::bad_request(format!( + "invalid wallet address format: {}", + identity_value + ))); + } + // Wallet existence check: unknown wallets must return 404 here instead + // of triggering a later FK constraint on INSERT INTO sessions (which + // would surface as 500). Codex P2 on PR #21. + let exists: bool = db + .query_row( + "SELECT 1 FROM accounts WHERE wallet_address = ?1", + params![identity_value], + |_| Ok(true), + ) + .unwrap_or(false); + if !exists { + return Err(crate::error::AppError::not_found(format!( + "no account found for wallet {}", + identity_value + ))); + } + Ok(identity_value.to_string()) + } + other => Err(crate::error::AppError::bad_request(format!( + "unknown identity_type '{}'. Use 'alias', 'email', 'ens', or 'wallet'.", + other + ))), + } +} + pub async fn resolve_identity( State(state): State, Query(query): Query, ) -> AppResult> { let db = state.db.lock().unwrap(); - let wallet = resolve_identity_to_wallet(&db, &query.identity_type, &query.identity_value) - .ok_or_else(|| AppError::not_found(format!( - "no identity found for type={} value={}", - query.identity_type, query.identity_value - )))?; + let wallet = resolve_identity_typed(&db, &query.identity_type, &query.identity_value)?; Ok(Json(json!({ "wallet_address": wallet }))) } diff --git a/crates/agentkeys-mock-server/src/handlers/session.rs b/crates/agentkeys-mock-server/src/handlers/session.rs index d84ebdd..8c8440c 100644 --- a/crates/agentkeys-mock-server/src/handlers/session.rs +++ b/crates/agentkeys-mock-server/src/handlers/session.rs @@ -200,35 +200,8 @@ pub async fn recover_session( let db = state.db.lock().unwrap(); - let wallet_address: String = if identity_type == "wallet" { - let exists: bool = db - .query_row( - "SELECT COUNT(*) FROM accounts WHERE wallet_address = ?1", - params![identity_value], - |row| row.get::<_, i64>(0), - ) - .map(|c| c > 0) - .unwrap_or(false); - if !exists { - return Err(AppError::not_found(format!( - "no account found for wallet {}", - identity_value - ))); - } - identity_value.to_string() - } else { - db.query_row( - "SELECT wallet_address FROM identity_links WHERE identity_type = ?1 AND identity_value = ?2", - params![identity_type, identity_value], - |row| row.get(0), - ) - .map_err(|_| { - AppError::not_found(format!( - "no identity found for {}={}", - identity_type, identity_value - )) - })? - }; + let wallet_address: String = + super::identity::resolve_identity_typed(&db, identity_type, identity_value)?; // Preserve scope from the most recent active session for this wallet let scope_json: Option = db diff --git a/crates/agentkeys-mock-server/src/test_client.rs b/crates/agentkeys-mock-server/src/test_client.rs index b9e36e3..bcb828d 100644 --- a/crates/agentkeys-mock-server/src/test_client.rs +++ b/crates/agentkeys-mock-server/src/test_client.rs @@ -447,14 +447,27 @@ impl CredentialBackend for InProcessBackend { AuthRequestType::KeyRotate { .. } => "KeyRotate", }; + let mut request_body = json!({ + "child_pubkey": pubkey_b64, + "request_type": request_type_str, + "request_details": details_b64, + }); + + if let AuthRequestType::Recover { agent_identity, .. } = &request_type { + let (identity_type, identity_value) = match agent_identity { + agentkeys_types::AgentIdentity::Alias(s) => ("alias", s.clone()), + agentkeys_types::AgentIdentity::Email(s) => ("email", s.clone()), + agentkeys_types::AgentIdentity::Ens(s) => ("ens", s.clone()), + agentkeys_types::AgentIdentity::WalletAddress(w) => ("wallet", w.0.clone()), + }; + request_body["identity_type"] = json!(identity_type); + request_body["identity_value"] = json!(identity_value); + } + let body = self .post( "/auth-request/open", - json!({ - "child_pubkey": pubkey_b64, - "request_type": request_type_str, - "request_details": details_b64, - }), + request_body, ) .await?; diff --git a/crates/agentkeys-mock-server/tests/integration.rs b/crates/agentkeys-mock-server/tests/integration.rs index 8c2a6cf..dcea722 100644 --- a/crates/agentkeys-mock-server/tests/integration.rs +++ b/crates/agentkeys-mock-server/tests/integration.rs @@ -1158,7 +1158,16 @@ async fn recover_flow_e2e() { ) .await; - // Open a Recover request (same as Pair for mock purposes) + // Link alias so the Recover request can resolve identity → wallet + post_json_auth( + app.clone(), + "/identity/link", + &orig_session, + json!({ "identity_type": "alias", "identity_value": "recover-user-alias", "wallet_address": orig_wallet }), + ) + .await; + + // Open a Recover request with required typed identity fields let (_, open_json) = post_json( app.clone(), "/auth-request/open", @@ -1166,6 +1175,8 @@ async fn recover_flow_e2e() { "child_pubkey": make_fake_pubkey_b64(), "request_type": "Recover", "request_details": make_fake_details_b64(), + "identity_type": "alias", + "identity_value": "recover-user-alias", "parent_wallet": orig_wallet, }), ) @@ -1202,13 +1213,23 @@ async fn recover_wrong_session() { // User A let (_, ja) = post_json(app.clone(), "/session/create", json!({ "auth_token": "recover-a" })).await; + let session_a = ja["session"].as_str().unwrap().to_string(); let wallet_a = ja["wallet"].as_str().unwrap().to_string(); // User B let (_, jb) = post_json(app.clone(), "/session/create", json!({ "auth_token": "recover-b" })).await; let session_b = jb["session"].as_str().unwrap().to_string(); - // Open Recover for wallet_a + // Link alias for wallet_a so the Recover request has valid typed fields + post_json_auth( + app.clone(), + "/identity/link", + &session_a, + json!({ "identity_type": "alias", "identity_value": "recover-a-alias", "wallet_address": wallet_a }), + ) + .await; + + // Open Recover for wallet_a with typed identity fields let (_, open_json) = post_json( app.clone(), "/auth-request/open", @@ -1216,6 +1237,8 @@ async fn recover_wrong_session() { "child_pubkey": make_fake_pubkey_b64(), "request_type": "Recover", "request_details": make_fake_details_b64(), + "identity_type": "alias", + "identity_value": "recover-a-alias", "parent_wallet": wallet_a, }), ) @@ -1524,3 +1547,246 @@ async fn list_credentials_ownership_enforced() { "expected a list/DENIED audit row after the cross-agent list attempt, got: {audit_body}" ); } + +// --------------------------------------------------------------------------- +// Issue #13: resolve_identity_typed + typed auth-request fields +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn resolve_identity_alias_returns_wallet() { + let app = setup(); + let (session, wallet, app) = create_test_session(app).await; + + let (link_status, _) = post_json_auth( + app.clone(), + "/identity/link", + &session, + json!({ "identity_type": "alias", "identity_value": "my-bot", "wallet_address": wallet }), + ) + .await; + assert_eq!(link_status, StatusCode::OK); + + let req = axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri("/identity/resolve?identity_type=alias&identity_value=my-bot") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let json = body_json(resp.into_body()).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["wallet_address"].as_str().unwrap(), wallet); +} + +#[tokio::test] +async fn resolve_identity_email_returns_wallet() { + let app = setup(); + let (session, wallet, app) = create_test_session(app).await; + + let (link_status, _) = post_json_auth( + app.clone(), + "/identity/link", + &session, + json!({ "identity_type": "email", "identity_value": "bot@example.com", "wallet_address": wallet }), + ) + .await; + assert_eq!(link_status, StatusCode::OK); + + let req = axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri("/identity/resolve?identity_type=email&identity_value=bot%40example.com") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let json = body_json(resp.into_body()).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["wallet_address"].as_str().unwrap(), wallet); +} + +#[tokio::test] +async fn resolve_identity_wallet_passthrough() { + // Wallet passthrough requires the wallet to exist in `accounts` (codex P2 + // on PR #21: prevents 500 on later FK constraint). Use a wallet created + // via /session/create so the accounts row is present. + let app = setup(); + let (_session, wallet, app) = create_test_session(app).await; + + let req = axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri(format!("/identity/resolve?identity_type=wallet&identity_value={wallet}")) + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let json = body_json(resp.into_body()).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["wallet_address"].as_str().unwrap(), wallet); +} + +#[tokio::test] +async fn resolve_identity_not_found_errors() { + let app = setup(); + + let req = axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri("/identity/resolve?identity_type=alias&identity_value=nonexistent-bot") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn resolve_identity_invalid_type_errors() { + let app = setup(); + + let req = axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri("/identity/resolve?identity_type=unknown_type&identity_value=something") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +// Codex P2 on PR #21: ENS identities must resolve through the identity_links +// table, not silently map to "alias" / get rejected as unknown type. +#[tokio::test] +async fn resolve_identity_ens_returns_wallet() { + let app = setup(); + let (session, wallet, app) = create_test_session(app).await; + + let (link_status, _) = post_json_auth( + app.clone(), + "/identity/link", + &session, + json!({ "identity_type": "ens", "identity_value": "mybot.eth", "wallet_address": wallet }), + ) + .await; + assert_eq!(link_status, StatusCode::OK); + + let req = axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri("/identity/resolve?identity_type=ens&identity_value=mybot.eth") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + let status = resp.status(); + let json = body_json(resp.into_body()).await; + assert_eq!(status, StatusCode::OK, "{json}"); + assert_eq!(json["wallet_address"].as_str().unwrap(), wallet); +} + +// Codex P2 on PR #21: an unknown wallet address must return 404 from +// /identity/resolve, not flow through and 500 later on the sessions FK. +#[tokio::test] +async fn resolve_identity_wallet_unknown_returns_not_found() { + let app = setup(); + + let req = axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri("/identity/resolve?identity_type=wallet&identity_value=0xDEADBEEFDEADBEEFDEADBEEFDEADBEEFDEADBEEF") + .body(Body::empty()) + .unwrap(); + let resp = app.oneshot(req).await.unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn open_auth_request_recover_requires_typed_fields() { + let app = setup(); + + let (status, json) = post_json( + app, + "/auth-request/open", + json!({ + "child_pubkey": make_fake_pubkey_b64(), + "request_type": "Recover", + "request_details": make_fake_details_b64(), + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "Recover without typed fields should fail: {json}"); +} + +#[tokio::test] +async fn open_auth_request_pair_rejects_typed_fields() { + let app = setup(); + + let (status, json) = post_json( + app, + "/auth-request/open", + json!({ + "child_pubkey": make_fake_pubkey_b64(), + "request_type": "Pair", + "request_details": make_fake_details_b64(), + "identity_type": "alias", + "identity_value": "my-bot", + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST, "Pair with identity fields should fail: {json}"); +} + +#[tokio::test] +async fn approve_recover_uses_typed_fields() { + let app = setup(); + + let (session, wallet, app) = create_test_session(app).await; + + // Link alias identity to the session wallet + let (link_status, _) = post_json_auth( + app.clone(), + "/identity/link", + &session, + json!({ "identity_type": "alias", "identity_value": "recovery-bot", "wallet_address": wallet }), + ) + .await; + assert_eq!(link_status, StatusCode::OK); + + // Open Recover request with typed fields + let (open_status, open_json) = post_json( + app.clone(), + "/auth-request/open", + json!({ + "child_pubkey": make_fake_pubkey_b64(), + "request_type": "Recover", + "request_details": make_fake_details_b64(), + "identity_type": "alias", + "identity_value": "recovery-bot", + "parent_wallet": wallet, + }), + ) + .await; + assert_eq!(open_status, StatusCode::OK, "open failed: {open_json}"); + let request_id = open_json["id"].as_str().unwrap().to_string(); + + // Approve — reads typed columns, resolves alias → wallet, mints session + let (approve_status, approve_json) = post_json_auth( + app.clone(), + "/auth-request/approve", + &session, + json!({ "request_id": request_id }), + ) + .await; + assert_eq!(approve_status, StatusCode::OK, "approve failed: {approve_json}"); + assert!(approve_json["signature"].is_string()); + + // Await the decision — minted session targets the resolved wallet + let await_req = axum::http::Request::builder() + .method(axum::http::Method::GET) + .uri(format!("/auth-request/await?request_id={request_id}")) + .body(Body::empty()) + .unwrap(); + let await_resp = app.oneshot(await_req).await.unwrap(); + let await_status = await_resp.status(); + let await_json = body_json(await_resp.into_body()).await; + assert_eq!(await_status, StatusCode::OK, "await failed: {await_json}"); + assert_eq!(await_json["status"].as_str().unwrap(), "approved"); + assert_eq!( + await_json["wallet"].as_str().unwrap(), + wallet, + "recovered session should target the resolved wallet" + ); +} diff --git a/docs/manual-test-issue-13.md b/docs/manual-test-issue-13.md new file mode 100644 index 0000000..953c8be --- /dev/null +++ b/docs/manual-test-issue-13.md @@ -0,0 +1,84 @@ +# Manual Test: Issue #13 — Backend refactor (typed params, shared resolve_identity, modular handlers) + +**Issue:** [litentry/agentKeys#13](https://github.com/litentry/agentKeys/issues/13) + +**Branch:** `fix/issue-13` + +## Nature of the change + +Pure refactor. **No user-visible behavior change.** The mock server's API surface is preserved; the internal organization is brought into line with CLAUDE.md's "Mock Server Design Principles": + +1. `resolve_identity(db, identity_type, identity_value)` — single shared utility in `handlers/identity.rs`. Replaces the two inline copies in `handlers/session.rs` and `handlers/auth_request.rs`. +2. `auth_requests` table carries explicit `identity_type` + `identity_value` columns for Recover requests — no more JSON-blob parsing at approve time. +3. `approve_auth_request` dispatches to `mint_pair_session`, `mint_recover_session`, `mint_scope_change_session` instead of inlining per-request-type logic. + +## How to verify + +Because this PR has no user-facing behavior change, the verification is: **every existing flow still works.** + +### Run the full test suite + +```bash +cd ~/Projects/agentkeys +cargo test -p agentkeys-core -p agentkeys-cli -p agentkeys-mock-server +``` +All pre-existing tests must pass. The PR body lists the before/after pass counts. + +### Run Stage-4 manual test end-to-end + +```bash +# Follow docs/manual-test-stage4.md top-to-bottom. +# Test 1–10 should pass exactly as on main. +# The refactor should not affect any user-observable response. +``` + +### Targeted Recover flow check (the refactor's hot path) + +```bash +export AGENTKEYS_SESSION_STORE=file +export HOME_SANDBOX=$(mktemp -d) +export HOME=$HOME_SANDBOX +BACKEND=http://127.0.0.1:8090 +cargo build --release -p agentkeys-cli -p agentkeys-mock-server +BIN=$(pwd)/target/release/agentkeys + +cargo run --release -p agentkeys-mock-server & +MOCK_PID=$! +sleep 1 + +# Establish a wallet + link an alias +$BIN --backend $BACKEND init --mock-token recover-refactor +WALLET=$(jq -r .wallet "$HOME/.agentkeys/session.json") +$BIN --backend $BACKEND link $WALLET --alias my-bot + +# Revoke the session (simulate lost device) +$BIN --backend $BACKEND revoke +# Expected: local session wiped. + +# Recover by alias — exercises the refactored mint_recover_session + typed fields +$BIN --backend $BACKEND recover my-bot --method passkey +# Expected: session restored for the same wallet (0x...). + +test -f $HOME/.agentkeys/session.json && echo "recovered" +# Expected: "recovered" + +RECOVERED_WALLET=$(jq -r .wallet "$HOME/.agentkeys/session.json") +[ "$WALLET" = "$RECOVERED_WALLET" ] && echo "same wallet" +# Expected: "same wallet" + +kill $MOCK_PID +``` + +## Cleanup + +```bash +rm -rf "$HOME_SANDBOX" +``` + +## Cross-references + +- `crates/agentkeys-mock-server/src/handlers/identity.rs` — new `resolve_identity` public function +- `crates/agentkeys-mock-server/src/handlers/auth_request.rs` — three new `mint_*_session` functions; handler now dispatches +- `crates/agentkeys-mock-server/src/handlers/session.rs` — `recover_session` now calls shared `resolve_identity` +- `crates/agentkeys-mock-server/src/db.rs` — `auth_requests` table migration adds `identity_type` + `identity_value` columns +- CLAUDE.md > "Mock Server Design Principles" — the rules this PR brings the code into line with