From 03e3cd561c19e7398d177ff536224b73527fa485 Mon Sep 17 00:00:00 2001 From: Soma <0421.soma@gmail.com> Date: Sat, 25 Jul 2026 16:02:49 +0900 Subject: [PATCH] fix(test-auth-generator): emit share-token payload in canonical field order; log redacted auth failures MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit generate-share-token built its JWT payload with serde_json::json!, whose map keys serialize alphabetically (att, aud, exp, iat, iss, jti). The state node verifies JWT signatures by re-serializing the parsed AuthTokenPayload struct (field order iss, aud, exp, iat, jti, att), so every token the tool minted failed signature verification — which is also why the delegated-JWT read path had never been exercised end to end. The payload is now a serde struct matching monas-account's DelegationClaims field order, verified against a live 4-node mesh: recipient reads with a delegated JWT succeed on both member and non-member (relay) nodes. Also logs the detailed reason (tracing::warn) when an authentication failure is redacted to the generic HTTP "Authentication failed" body — without it, diagnosing JWT verification failures on a running node is guesswork. Note for a follow-up: verifying JWTs by re-serializing parsed structs is brittle (field order, whitespace, unknown fields all break genuine tokens). Verification should use the original wire segments captured in from_jwt instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PKq6ZoPVmhTZfv4oBeRF8J --- .../src/bin/test_auth_generator.rs | 53 +++++++++++++------ monas-state-node/src/presentation/http_api.rs | 5 +- 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/monas-state-node/src/bin/test_auth_generator.rs b/monas-state-node/src/bin/test_auth_generator.rs index 3aaee5c..0b75478 100644 --- a/monas-state-node/src/bin/test_auth_generator.rs +++ b/monas-state-node/src/bin/test_auth_generator.rs @@ -216,6 +216,29 @@ struct DelegatedPayload { jti: String, } +/// Delegated-JWT payload with the SAME field order as monas-account's +/// `DelegationClaims` and the state node's `AuthTokenPayload`. +/// +/// The state node verifies JWT signatures by re-serializing the parsed +/// payload struct, so the signing input is only reproducible when the +/// issuer serializes fields in this exact order. `serde_json::json!` maps +/// are alphabetical and produce tokens the state node cannot verify. +#[derive(serde::Serialize)] +struct ShareTokenPayload { + iss: String, + aud: String, + exp: u64, + iat: u64, + jti: String, + att: Vec, +} + +#[derive(serde::Serialize)] +struct ShareTokenCapability { + with: String, + can: String, +} + fn build_delegated_request_message(jwt: &str) -> String { let parts: Vec<&str> = jwt.split('.').collect(); if parts.len() != 3 { @@ -360,14 +383,11 @@ fn generate_share_token(args: &[String]) { let recipient_key_id = format!("user:{}", hex::encode(&recipient_public_key_bytes)); - let caps: Vec = capabilities_str + let caps: Vec = capabilities_str .split(',') - .map(|c| { - let action = c.trim(); - json!({ - "with": format!("monas://content/{}", content_id), - "can": action - }) + .map(|c| ShareTokenCapability { + with: format!("monas://content/{}", content_id), + can: c.trim().to_string(), }) .collect(); @@ -384,17 +404,18 @@ fn generate_share_token(args: &[String]) { let jti = Uuid::new_v4().to_string(); - let payload = json!({ - "iss": owner_key_id, - "aud": recipient_key_id, - "exp": now + expiry, - "iat": now, - "jti": jti, - "att": caps - }); + let payload = ShareTokenPayload { + iss: owner_key_id.clone(), + aud: recipient_key_id.clone(), + exp: now + expiry, + iat: now, + jti: jti.clone(), + att: caps, + }; let header_b64 = URL_SAFE_NO_PAD.encode(header.to_string()); - let payload_b64 = URL_SAFE_NO_PAD.encode(payload.to_string()); + let payload_b64 = + URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload).expect("payload serialization")); let signing_input = format!("{}.{}", header_b64, payload_b64); diff --git a/monas-state-node/src/presentation/http_api.rs b/monas-state-node/src/presentation/http_api.rs index 2dc4129..e06eb0f 100644 --- a/monas-state-node/src/presentation/http_api.rs +++ b/monas-state-node/src/presentation/http_api.rs @@ -193,7 +193,10 @@ impl IntoResponse for StateNodeError { StateNodeError::NotAMember { .. } => self.to_string(), StateNodeError::PermissionDenied(_) => "Permission denied".to_string(), StateNodeError::InvalidUcanToken(_) => "Invalid authentication token".to_string(), - StateNodeError::AuthenticationFailed(_) => "Authentication failed".to_string(), + StateNodeError::AuthenticationFailed(detail) => { + tracing::warn!("authentication failed: {detail}"); + "Authentication failed".to_string() + } StateNodeError::AuthorizationFailed(_) => "Authorization failed".to_string(), StateNodeError::InvalidCid(_) => "Invalid content identifier".to_string(), StateNodeError::InvalidConfiguration(_) => "Invalid request".to_string(),