Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ axum-extra = { version = "0.10", features = [
] }
axum-jwt = "=0.1.0" # ^0.1.1 uses Rust 1.88
axum-test = "18"
http = "1.1"
base64 = "0.21"
bigdecimal = { version = "0.4.5", features = ["serde"] }
bincode = "1.3.3"
Expand Down
1 change: 1 addition & 0 deletions backend/lib/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ async-trait = { workspace = true }
axum = { workspace = true }
axum-extra = { workspace = true }
axum-jwt = { workspace = true }
http = { workspace = true }
base64 = { workspace = true }
bigdecimal = { workspace = true }
chrono = { workspace = true, features = ["serde"] }
Expand Down
231 changes: 218 additions & 13 deletions backend/lib/src/api/handlers/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,14 +12,31 @@ pub async fn nonce(
Json(payload): Json<NonceRequest>,
) -> Result<impl IntoResponse, Error> {
debug!(address = %payload.address, chain_id = payload.chain_id, "POST auth nonce");
// Extract domain from URI
let domain = services.auth.extract_domain_from_uri(&payload.uri)?;
let response = services
.auth
.challenge(
&payload.address,
payload.chain_id,
&payload.domain,
&payload.uri,
)
.challenge(&payload.address, payload.chain_id, &domain, &payload.uri)
.await?;
Ok(Json(response))
}

pub async fn message(
State(services): State<Services>,
Json(payload): Json<NonceRequest>,
) -> Result<impl IntoResponse, Error> {
debug!(
address = %payload.address,
chain_id = payload.chain_id,
"POST auth message (CAIP-122)"
);

// Extract domain from URI
let domain = services.auth.extract_domain_from_uri(&payload.uri)?;

let response = services
.auth
.challenge_caip122(&payload.address, payload.chain_id, &domain, &payload.uri)
.await?;
Ok(Json(response))
}
Expand Down Expand Up @@ -100,7 +117,6 @@ mod tests {
let nonce_request = NonceRequest {
address,
chain_id: 1,
domain: "localhost".to_string(),
uri: "https://localhost/".to_string(),
};

Expand Down Expand Up @@ -173,7 +189,6 @@ mod tests {
let invalid_json = serde_json::json!({
"address": "not_an_eth_address",
"chainId": 1,
"domain": "localhost",
"uri": "https://localhost/"
});

Expand Down Expand Up @@ -218,7 +233,6 @@ mod tests {
let nonce_request = NonceRequest {
address,
chain_id: 1,
domain: "localhost".to_string(),
uri: "https://localhost/".to_string(),
};

Expand Down Expand Up @@ -248,7 +262,6 @@ mod tests {
let nonce_request = NonceRequest {
address,
chain_id: 1,
domain: "localhost".to_string(),
uri: "https://localhost/".to_string(),
};

Expand Down Expand Up @@ -374,7 +387,6 @@ mod tests {
let nonce_request = NonceRequest {
address,
chain_id: 1,
domain: "localhost".to_string(),
uri: "https://localhost/".to_string(),
};

Expand Down Expand Up @@ -422,7 +434,6 @@ mod tests {
let nonce_request1 = NonceRequest {
address: address1,
chain_id: 1,
domain: "localhost".to_string(),
uri: "https://localhost/".to_string(),
};

Expand All @@ -444,7 +455,6 @@ mod tests {
let nonce_request2 = NonceRequest {
address: address2,
chain_id: 1,
domain: "localhost".to_string(),
uri: "https://localhost/".to_string(),
};

Expand Down Expand Up @@ -521,4 +531,199 @@ mod tests {
.await;
assert_eq!(response.status_code(), StatusCode::UNAUTHORIZED);
}

#[tokio::test]
async fn auth_flow_caip122_complete() {
let mut cfg = Config::default();
cfg.auth.mock_mode = false;

let services = Services::mocks_with_config(cfg).await;
let app = create_app(services.clone());
let server = TestServer::new(app).unwrap();

let (address, signing_key) = eth_wallet();

// Step 1: Get CAIP-122 message challenge (domain extracted from URI)
let message_request = NonceRequest {
address,
chain_id: 1,
uri: "https://datahaven.app".to_string(),
};

let response = server.post("/auth/message").json(&message_request).await;

assert_eq!(response.status_code(), StatusCode::OK);
let message_response: NonceResponse = response.json();

// Verify CAIP-122 format
assert!(message_response.message.contains("blockchain account"));
assert!(message_response.message.contains("eip155:1:"));
assert!(message_response.message.contains(&address.to_string()));
assert!(message_response.message.contains("datahaven.app"));
assert!(message_response.message.contains("Chain ID: 1"));

// Step 2: Sign the message and login
let signature = sign_message(&signing_key, &message_response.message);
let verify_request = VerifyRequest {
message: message_response.message,
signature,
};

let response = server.post("/auth/verify").json(&verify_request).await;

assert_eq!(response.status_code(), StatusCode::OK);
let verify_response: VerifyResponse = response.json();
assert_eq!(verify_response.user.address, address);
assert!(!verify_response.token.is_empty());

// Decode and verify the JWT
let jwt_key = services.auth.jwt_decoding_key();
let jwt_validation = services.auth.jwt_validation();

let decoded = decode::<JwtClaims>(&verify_response.token, jwt_key, jwt_validation)
.expect("Failed to decode JWT");
assert_eq!(decoded.claims.address, address);
}

#[tokio::test]
async fn caip122_message_format_verification() {
let app = mock_app().await;
let server = TestServer::new(app).unwrap();

let message_request = NonceRequest {
address: MOCK_ADDRESS,
chain_id: 55931,
uri: "https://datahaven.app".to_string(),
};

let response = server.post("/auth/message").json(&message_request).await;
assert_eq!(response.status_code(), StatusCode::OK);

let message_response: NonceResponse = response.json();
let message = message_response.message;

// Verify CAIP-122 format
assert!(
message.starts_with("datahaven.app wants you to sign in with your blockchain account:")
);
assert!(message.contains("eip155:55931:"));
assert!(message.contains(&MOCK_ADDRESS.to_string()));
assert!(message.contains("Chain ID: 55931"));
assert!(message.contains("URI: https://datahaven.app"));
}

#[tokio::test]
async fn caip122_domain_extraction_from_uri() {
let app = mock_app().await;
let server = TestServer::new(app).unwrap();

// Test with https URI
let message_request = NonceRequest {
address: MOCK_ADDRESS,
chain_id: 1,
uri: "https://example.com:8080/path".to_string(),
};

let response = server.post("/auth/message").json(&message_request).await;
assert_eq!(response.status_code(), StatusCode::OK);
let message_response: NonceResponse = response.json();
assert!(message_response.message.contains("example.com:8080"));

// Test with http URI
let message_request = NonceRequest {
address: MOCK_ADDRESS,
chain_id: 1,
uri: "http://localhost:3000".to_string(),
};

let response = server.post("/auth/message").json(&message_request).await;
assert_eq!(response.status_code(), StatusCode::OK);
let message_response: NonceResponse = response.json();
assert!(message_response.message.contains("localhost:3000"));

// Test with URI without port
let message_request = NonceRequest {
address: MOCK_ADDRESS,
chain_id: 1,
uri: "https://datahaven.app".to_string(),
};

let response = server.post("/auth/message").json(&message_request).await;
assert_eq!(response.status_code(), StatusCode::OK);
let message_response: NonceResponse = response.json();
assert!(message_response.message.contains("datahaven.app"));
}

#[tokio::test]
async fn caip122_invalid_uri_fails() {
let app = mock_app().await;
let server = TestServer::new(app).unwrap();

// Use a URI that will fail parsing even after prepending https://
let message_request = NonceRequest {
address: MOCK_ADDRESS,
chain_id: 1,
uri: "https://exa mple.com".to_string(),
};

let response = server.post("/auth/message").json(&message_request).await;
let status = response.status_code();
let body = response.text();
println!("Response status: {:?}", status);
println!("Response body: {}", body);
assert_eq!(status, StatusCode::BAD_REQUEST);
}

#[tokio::test]
async fn caip122_and_siwe_coexist() {
let app = mock_app().await;
let server = TestServer::new(app).unwrap();
let (address, signing_key) = eth_wallet();

// Test CAIP-122 flow
let caip122_request = NonceRequest {
address,
chain_id: 1,
uri: "https://datahaven.app".to_string(),
};

let response = server.post("/auth/message").json(&caip122_request).await;
assert_eq!(response.status_code(), StatusCode::OK);
let caip122_response: NonceResponse = response.json();
assert!(caip122_response.message.contains("blockchain account"));
assert!(caip122_response.message.contains("eip155:1:"));

// Test SIWE flow still works (domain extracted from URI)
let siwe_request = NonceRequest {
address,
chain_id: 1,
uri: "https://localhost/".to_string(),
};

let response = server.post(AUTH_NONCE_ENDPOINT).json(&siwe_request).await;
assert_eq!(response.status_code(), StatusCode::OK);
let siwe_response: NonceResponse = response.json();
assert!(siwe_response.message.contains("Ethereum account"));
assert!(siwe_response.message.contains(&address.to_string()));
assert!(!siwe_response.message.contains("eip155:1:"));

// Both messages should be verifiable with same endpoint
let caip122_sig = sign_message(&signing_key, &caip122_response.message);
let siwe_sig = sign_message(&signing_key, &siwe_response.message);

let verify_caip122 = VerifyRequest {
message: caip122_response.message,
signature: caip122_sig,
};
let verify_siwe = VerifyRequest {
message: siwe_response.message,
signature: siwe_sig,
};

let response1 = server.post("/auth/verify").json(&verify_caip122).await;
let response2 = server.post("/auth/verify").json(&verify_siwe).await;

assert_eq!(response1.status_code(), StatusCode::OK);
assert_eq!(response2.status_code(), StatusCode::OK);
}
}
1 change: 1 addition & 0 deletions backend/lib/src/api/routes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ pub fn routes(services: Services) -> Router {
Router::new()
// Auth routes
.route(AUTH_NONCE_ENDPOINT, post(handlers::auth::nonce))
.route("/auth/message", post(handlers::auth::message))
.route("/auth/verify", post(handlers::auth::verify))
.route("/auth/refresh", post(handlers::auth::refresh))
.route("/auth/logout", post(handlers::auth::logout))
Expand Down
5 changes: 2 additions & 3 deletions backend/lib/src/models/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,8 @@ pub struct NonceRequest {
pub address: Address,
#[serde(rename = "chainId")]
pub chain_id: u64,
/// Domain (host[:port]) to display in the SIWE header line (e.g., "datahaven.app")
pub domain: String,
/// Full URI to include in the SIWE message as-is (e.g., "https://datahaven.app/testnet")
/// Full URI to include in the message (e.g., "https://datahaven.app/testnet")
/// Domain will be extracted from this URI automatically
pub uri: String,
}

Expand Down
Loading
Loading