Skip to main content

scp_node/
bridge_auth.rs

1//! DID-signed bearer token authentication for bridge HTTP endpoints.
2//!
3//! Implements the authentication layer specified in spec section 12.10.2.
4//! Bridge operators authenticate using DID-signed JWTs in the
5//! `Authorization: Bearer` header. The node verifies the JWT signature
6//! against the operator's DID document (section 3.2).
7//!
8//! For webhook callbacks (platform to bridge node), Ed25519 signatures in
9//! the `X-SCP-Signature` header are verified against the platform's
10//! pre-registered public key.
11//!
12//! # Error Codes
13//!
14//! | Code | HTTP Status | Description |
15//! |------|-------------|-------------|
16//! | `BRIDGE_NOT_AUTHORIZED` | 401 | Bearer token invalid or expired |
17//! | `BRIDGE_SUSPENDED` | 403 | Bridge is suspended by context governance |
18//!
19//! See ADR-023 in `.docs/adrs/phase-5.md` and spec section 12.10.3.
20
21use std::sync::Arc;
22use std::time::{SystemTime, UNIX_EPOCH};
23
24use axum::Json;
25use axum::body::Body;
26use axum::extract::State;
27use axum::http::{Request, StatusCode, header};
28use axum::middleware::Next;
29use axum::response::IntoResponse;
30use base64::Engine;
31use base64::engine::general_purpose::URL_SAFE_NO_PAD;
32use ed25519_dalek::{Signature, VerifyingKey};
33use scp_core::bridge::{BridgeConnector, BridgeStatus};
34use scp_identity::dht::decode_multibase_key;
35use scp_identity::document::DidDocument;
36use serde::{Deserialize, Serialize};
37
38use crate::error::ApiError;
39
40// ---------------------------------------------------------------------------
41// Constants
42// ---------------------------------------------------------------------------
43
44/// Maximum allowed JWT token lifetime (1 hour, in seconds).
45///
46/// Spec section 12.10.2: "Token lifetime MUST NOT exceed 1 hour."
47const MAX_TOKEN_LIFETIME_SECS: u64 = 3600;
48
49/// Clock skew tolerance for JWT validation (30 seconds).
50///
51/// Allows for minor clock differences between bridge operator and node.
52const CLOCK_SKEW_TOLERANCE_SECS: u64 = 30;
53
54/// The JWT `alg` header value for Ed25519 signatures (RFC 8037).
55const JWT_ALG_EDDSA: &str = "EdDSA";
56
57/// The JWT `typ` header value.
58const JWT_TYP: &str = "JWT";
59
60// ---------------------------------------------------------------------------
61// Bridge error responses (spec section 12.10.3)
62// ---------------------------------------------------------------------------
63
64/// Returns a 401 error response with the `BRIDGE_NOT_AUTHORIZED` error code.
65///
66/// Used when the bearer token is invalid, expired, or has a bad signature.
67/// See spec section 12.10.3.
68fn bridge_not_authorized(msg: impl Into<String>) -> (StatusCode, Json<ApiError>) {
69    (
70        StatusCode::UNAUTHORIZED,
71        Json(ApiError {
72            error: msg.into(),
73            code: "BRIDGE_NOT_AUTHORIZED".to_owned(),
74        }),
75    )
76}
77
78/// Returns a 403 error response with the `BRIDGE_SUSPENDED` error code.
79///
80/// Used when the bridge has been suspended by context governance.
81/// See spec section 12.10.3.
82fn bridge_suspended(msg: impl Into<String>) -> (StatusCode, Json<ApiError>) {
83    (
84        StatusCode::FORBIDDEN,
85        Json(ApiError {
86            error: msg.into(),
87            code: "BRIDGE_SUSPENDED".to_owned(),
88        }),
89    )
90}
91
92// ---------------------------------------------------------------------------
93// JWT structures
94// ---------------------------------------------------------------------------
95
96/// JWT header for DID-signed bridge tokens.
97///
98/// Only the `EdDSA` algorithm (Ed25519) is supported, per spec section 12.10.2.
99#[derive(Debug, Deserialize)]
100struct JwtHeader {
101    /// Algorithm — must be `"EdDSA"` (RFC 8037).
102    alg: String,
103
104    /// Type — must be `"JWT"`.
105    #[serde(default)]
106    typ: Option<String>,
107
108    /// Key ID — optional. If present, specifies the DID document verification
109    /// method fragment to use (e.g., `"#active"`).
110    #[serde(default)]
111    kid: Option<String>,
112}
113
114/// JWT claims for bridge operator authentication.
115///
116/// The payload contains the operator's DID, the target audience (node URL),
117/// timestamps, and SCP-specific bridge/context identifiers.
118///
119/// See spec section 12.10.2.
120#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct BridgeJwtClaims {
122    /// Issuer — the bridge operator's DID (e.g., `"did:dht:z6MkOperator..."`).
123    pub iss: String,
124
125    /// Audience — the target node URL (e.g., `"https://platform.example.com"`).
126    pub aud: String,
127
128    /// Issued At — Unix timestamp (seconds) when the JWT was created.
129    pub iat: u64,
130
131    /// Expiration — Unix timestamp (seconds) when the JWT expires.
132    pub exp: u64,
133
134    /// The bridge instance identifier this token authenticates for.
135    pub scp_bridge_id: String,
136
137    /// The context this bridge is registered in.
138    pub scp_context_id: String,
139}
140
141/// Validated bridge authentication context extracted by the middleware.
142///
143/// Stored as a request extension so downstream handlers can access the
144/// authenticated bridge identity without re-parsing the JWT.
145#[derive(Debug, Clone)]
146pub struct BridgeAuthContext {
147    /// The verified JWT claims.
148    pub claims: BridgeJwtClaims,
149
150    /// The resolved bridge connector from the registry.
151    pub bridge: BridgeConnector,
152}
153
154// ---------------------------------------------------------------------------
155// Bridge lookup trait
156// ---------------------------------------------------------------------------
157
158/// Trait for looking up registered bridges and resolving DID documents.
159///
160/// Implementors provide the bridge registry and DID resolution needed
161/// by [`bridge_auth_middleware`]. This decouples the auth layer from
162/// specific storage and identity implementations.
163pub trait BridgeLookup: Send + Sync + 'static {
164    /// Look up a bridge by its ID.
165    ///
166    /// Returns `None` if no bridge with the given ID is registered.
167    fn find_bridge(&self, bridge_id: &str) -> Option<BridgeConnector>;
168
169    /// Resolve a DID document for the given DID string.
170    ///
171    /// Returns `None` if the DID cannot be resolved. Implementations
172    /// MAY cache resolved documents with TTL (spec section 12.10.2).
173    fn resolve_did_document(&self, did: &str) -> Option<DidDocument>;
174
175    /// Look up a pre-registered webhook signing public key by key ID.
176    ///
177    /// Returns the Ed25519 public key bytes for the given platform key ID.
178    /// Returns `None` if no key with that ID is registered.
179    fn find_webhook_key(&self, key_id: &str) -> Option<[u8; 32]>;
180
181    /// Returns the expected audience (node URL) for JWT validation.
182    fn expected_audience(&self) -> &str;
183}
184
185// ---------------------------------------------------------------------------
186// JWT parsing and verification
187// ---------------------------------------------------------------------------
188
189/// Decodes a base64url-encoded JWT segment.
190fn decode_jwt_segment(segment: &str) -> Result<Vec<u8>, String> {
191    URL_SAFE_NO_PAD
192        .decode(segment)
193        .map_err(|e| format!("invalid base64url encoding: {e}"))
194}
195
196/// Parses and verifies a DID-signed JWT bearer token.
197///
198/// Performs the following checks (spec section 12.10.2):
199/// 1. Splits the JWT into header, payload, and signature segments.
200/// 2. Validates the header (`alg` must be `EdDSA`).
201/// 3. Deserializes the claims payload.
202/// 4. Resolves the issuer's DID document.
203/// 5. Extracts the signing public key from the DID document.
204/// 6. Verifies the Ed25519 signature over `header.payload`.
205/// 7. Validates temporal claims (`iat`, `exp`, max lifetime).
206///
207/// # Errors
208///
209/// Returns a human-readable error string if any check fails.
210fn verify_bridge_jwt(token: &str, lookup: &dyn BridgeLookup) -> Result<BridgeJwtClaims, String> {
211    // Step 1: Split into three segments.
212    let parts: Vec<&str> = token.split('.').collect();
213    if parts.len() != 3 {
214        return Err("JWT must have exactly three segments".to_owned());
215    }
216
217    let header_b64 = parts[0];
218    let payload_b64 = parts[1];
219    let signature_b64 = parts[2];
220
221    // Step 2: Decode and validate the header.
222    let header_bytes = decode_jwt_segment(header_b64)?;
223    let header: JwtHeader = serde_json::from_slice(&header_bytes)
224        .map_err(|e| format!("invalid JWT header JSON: {e}"))?;
225
226    if header.alg != JWT_ALG_EDDSA {
227        return Err(format!(
228            "unsupported JWT algorithm: expected {JWT_ALG_EDDSA}, got {}",
229            header.alg
230        ));
231    }
232
233    if let Some(ref typ) = header.typ
234        && !typ.eq_ignore_ascii_case(JWT_TYP)
235    {
236        return Err(format!(
237            "unsupported JWT type: expected {JWT_TYP}, got {typ}"
238        ));
239    }
240
241    // Step 3: Decode and parse the claims payload.
242    let payload_bytes = decode_jwt_segment(payload_b64)?;
243    let claims: BridgeJwtClaims = serde_json::from_slice(&payload_bytes)
244        .map_err(|e| format!("invalid JWT payload JSON: {e}"))?;
245
246    // Step 4: Resolve the issuer's DID document.
247    let did_doc = lookup
248        .resolve_did_document(&claims.iss)
249        .ok_or_else(|| format!("could not resolve DID document for issuer: {}", claims.iss))?;
250
251    // Step 5: Extract the signing public key.
252    //
253    // Use the `kid` header if present (strip the DID prefix if included),
254    // otherwise default to `#active` (the Human Signing Key per ADR-039).
255    // Extract fragment from kid header. kid may be a full DID URL like
256    // "did:dht:z6Mk...#active", just the fragment like "#active", or bare "active".
257    // Default to "active" (the Human Signing Key per ADR-039) when kid is absent.
258    let fragment = header.kid.as_ref().map_or_else(
259        || "active".to_owned(),
260        |kid| {
261            kid.strip_prefix('#').map_or_else(
262                || {
263                    kid.rsplit_once('#')
264                        .map_or_else(|| kid.clone(), |(_, f)| (*f).to_owned())
265                },
266                str::to_owned,
267            )
268        },
269    );
270
271    let vm = did_doc
272        .verification_method_by_fragment(&fragment)
273        .ok_or_else(|| {
274            format!(
275                "DID document for {} has no verification method with fragment #{fragment}",
276                claims.iss
277            )
278        })?;
279
280    let pub_key_bytes = decode_multibase_key(&vm.public_key_multibase)
281        .map_err(|e| format!("failed to decode public key from DID document: {e}"))?;
282
283    let verifying_key = VerifyingKey::from_bytes(&pub_key_bytes)
284        .map_err(|e| format!("invalid Ed25519 public key in DID document: {e}"))?;
285
286    // Step 6: Decode and verify the signature.
287    let signature_bytes = decode_jwt_segment(signature_b64)?;
288    let signature_array: [u8; 64] = signature_bytes.try_into().map_err(|v: Vec<u8>| {
289        format!(
290            "invalid Ed25519 signature length: expected 64, got {}",
291            v.len()
292        )
293    })?;
294    let signature = Signature::from_bytes(&signature_array);
295
296    // The signed message is "header.payload" (the raw base64url segments).
297    let signing_input = format!("{header_b64}.{payload_b64}");
298    verifying_key
299        .verify_strict(signing_input.as_bytes(), &signature)
300        .map_err(|e| format!("JWT signature verification failed: {e}"))?;
301
302    // Step 7: Validate temporal claims.
303    let now = SystemTime::now()
304        .duration_since(UNIX_EPOCH)
305        .map(|d| d.as_secs())
306        .map_err(|_| "system clock is before Unix epoch".to_owned())?;
307
308    // Check expiration (with clock skew tolerance).
309    if claims.exp + CLOCK_SKEW_TOLERANCE_SECS < now {
310        return Err(format!("JWT has expired: exp={}, now={now}", claims.exp));
311    }
312
313    // Check that iat is not in the future (with clock skew tolerance).
314    if claims.iat > now + CLOCK_SKEW_TOLERANCE_SECS {
315        return Err(format!(
316            "JWT issued in the future: iat={}, now={now}",
317            claims.iat
318        ));
319    }
320
321    // Check maximum token lifetime (spec: MUST NOT exceed 1 hour).
322    let lifetime = claims.exp.saturating_sub(claims.iat);
323    if lifetime > MAX_TOKEN_LIFETIME_SECS {
324        return Err(format!(
325            "JWT lifetime exceeds maximum: {lifetime}s > {MAX_TOKEN_LIFETIME_SECS}s"
326        ));
327    }
328
329    // Validate audience matches the expected node URL.
330    if claims.aud != lookup.expected_audience() {
331        return Err(format!(
332            "JWT audience mismatch: expected {}, got {}",
333            lookup.expected_audience(),
334            claims.aud
335        ));
336    }
337
338    Ok(claims)
339}
340
341// ---------------------------------------------------------------------------
342// Bridge auth middleware
343// ---------------------------------------------------------------------------
344
345/// Axum middleware that validates DID-signed bearer tokens for bridge
346/// endpoints.
347///
348/// Extracts the `Authorization: Bearer <JWT>` header, verifies the JWT
349/// signature against the operator's DID document, validates temporal
350/// claims, and checks that the bridge is registered and active.
351///
352/// On success, inserts a [`BridgeAuthContext`] into the request extensions
353/// so downstream handlers can access the authenticated bridge identity.
354///
355/// # Error Responses
356///
357/// - **401 `BRIDGE_NOT_AUTHORIZED`** — Missing, invalid, or expired token;
358///   signature verification failure; bridge not found.
359/// - **403 `BRIDGE_SUSPENDED`** — The bridge exists but is suspended by
360///   context governance.
361///
362/// See spec sections 12.10.2 and 12.10.3.
363pub async fn bridge_auth_middleware<L: BridgeLookup>(
364    State(lookup): State<Arc<L>>,
365    mut req: Request<Body>,
366    next: Next,
367) -> impl IntoResponse {
368    // Extract the Authorization header.
369    let auth_header = req
370        .headers()
371        .get(header::AUTHORIZATION)
372        .and_then(|v| v.to_str().ok());
373
374    let token = match auth_header {
375        Some(value) if value.len() > 7 && value[..7].eq_ignore_ascii_case("bearer ") => &value[7..],
376        _ => {
377            return bridge_not_authorized("missing or invalid Authorization header")
378                .into_response();
379        }
380    };
381
382    // Verify the JWT.
383    let claims = match verify_bridge_jwt(token, lookup.as_ref()) {
384        Ok(claims) => claims,
385        Err(msg) => {
386            return bridge_not_authorized(msg).into_response();
387        }
388    };
389
390    // Look up the bridge in the registry.
391    let Some(bridge) = lookup.find_bridge(&claims.scp_bridge_id) else {
392        return bridge_not_authorized(format!("bridge not found: {}", claims.scp_bridge_id))
393            .into_response();
394    };
395
396    // Validate that the JWT issuer matches the bridge operator.
397    if bridge.operator_did != claims.iss {
398        return bridge_not_authorized("JWT issuer does not match bridge operator DID")
399            .into_response();
400    }
401
402    // Validate that the context ID matches.
403    if claims.scp_context_id != bridge.registration_context {
404        return bridge_not_authorized("JWT context ID does not match bridge registration context")
405            .into_response();
406    }
407
408    // Check bridge status.
409    match bridge.status {
410        BridgeStatus::Active => {}
411        BridgeStatus::Suspended => {
412            return bridge_suspended(format!(
413                "bridge {} is suspended by context governance",
414                bridge.bridge_id
415            ))
416            .into_response();
417        }
418        BridgeStatus::Revoked => {
419            return bridge_not_authorized(format!("bridge {} has been revoked", bridge.bridge_id))
420                .into_response();
421        }
422    }
423
424    // Insert the auth context for downstream handlers.
425    let auth_ctx = BridgeAuthContext { claims, bridge };
426    req.extensions_mut().insert(auth_ctx);
427
428    next.run(req).await.into_response()
429}
430
431// ---------------------------------------------------------------------------
432// Webhook signature verification
433// ---------------------------------------------------------------------------
434
435/// Verifies an Ed25519 webhook signature from an external platform.
436///
437/// Extracts the `X-SCP-Signature` and `X-SCP-Platform-Key-Id` headers,
438/// looks up the platform's pre-registered public key, and verifies the
439/// Ed25519 signature over the raw request body.
440///
441/// See spec section 12.10.2.
442///
443/// # Errors
444///
445/// Returns a human-readable error string if verification fails.
446pub fn verify_webhook_signature(
447    signature_header: &str,
448    key_id: &str,
449    body: &[u8],
450    lookup: &dyn BridgeLookup,
451) -> Result<(), String> {
452    // Look up the platform's signing key.
453    let pub_key_bytes = lookup
454        .find_webhook_key(key_id)
455        .ok_or_else(|| format!("unknown platform key ID: {key_id}"))?;
456
457    let verifying_key = VerifyingKey::from_bytes(&pub_key_bytes)
458        .map_err(|e| format!("invalid platform public key: {e}"))?;
459
460    // Decode the signature from base64url.
461    let sig_bytes = URL_SAFE_NO_PAD
462        .decode(signature_header)
463        .map_err(|e| format!("invalid signature encoding: {e}"))?;
464
465    let sig_array: [u8; 64] = sig_bytes.try_into().map_err(|v: Vec<u8>| {
466        format!(
467            "invalid Ed25519 signature length: expected 64, got {}",
468            v.len()
469        )
470    })?;
471    let signature = Signature::from_bytes(&sig_array);
472
473    verifying_key
474        .verify_strict(body, &signature)
475        .map_err(|e| format!("webhook signature verification failed: {e}"))
476}
477
478/// Axum middleware that validates webhook signatures from external platforms.
479///
480/// Extracts the `X-SCP-Signature` and `X-SCP-Platform-Key-Id` headers and
481/// verifies the Ed25519 signature over the raw request body.
482///
483/// On success, the request proceeds to the next handler. On failure,
484/// returns 401 with error code `BRIDGE_NOT_AUTHORIZED`.
485///
486/// See spec section 12.10.2.
487pub async fn webhook_auth_middleware<L: BridgeLookup>(
488    State(lookup): State<Arc<L>>,
489    req: Request<Body>,
490    next: Next,
491) -> impl IntoResponse {
492    // Extract required headers.
493    let signature_header = match req
494        .headers()
495        .get("x-scp-signature")
496        .and_then(|v| v.to_str().ok())
497    {
498        Some(s) => s.to_owned(),
499        None => {
500            return bridge_not_authorized("missing X-SCP-Signature header").into_response();
501        }
502    };
503
504    let key_id = match req
505        .headers()
506        .get("x-scp-platform-key-id")
507        .and_then(|v| v.to_str().ok())
508    {
509        Some(k) => k.to_owned(),
510        None => {
511            return bridge_not_authorized("missing X-SCP-Platform-Key-Id header").into_response();
512        }
513    };
514
515    // We need to read the body for signature verification, then reconstruct
516    // the request for downstream handlers.
517    let (parts, body) = req.into_parts();
518    let body_bytes = match axum::body::to_bytes(body, 10 * 1024 * 1024).await {
519        Ok(b) => b,
520        Err(e) => {
521            return bridge_not_authorized(format!("failed to read request body: {e}"))
522                .into_response();
523        }
524    };
525
526    // Verify the signature.
527    if let Err(msg) =
528        verify_webhook_signature(&signature_header, &key_id, &body_bytes, lookup.as_ref())
529    {
530        return bridge_not_authorized(msg).into_response();
531    }
532
533    // Reconstruct the request with the body bytes.
534    let req = Request::from_parts(parts, Body::from(body_bytes));
535    next.run(req).await.into_response()
536}
537
538// ---------------------------------------------------------------------------
539// JWT creation helper (for testing and bridge operators)
540// ---------------------------------------------------------------------------
541
542/// Creates a DID-signed JWT for bridge authentication.
543///
544/// This is a convenience function for bridge operators to create
545/// authentication tokens. The JWT is signed with the operator's
546/// Ed25519 signing key.
547///
548/// # Arguments
549///
550/// * `claims` — The JWT claims payload.
551/// * `signing_key` — The operator's Ed25519 signing key (32 bytes).
552///
553/// # Errors
554///
555/// Returns an error string if signing fails.
556pub fn create_bridge_jwt(
557    claims: &BridgeJwtClaims,
558    signing_key: &ed25519_dalek::SigningKey,
559) -> Result<String, String> {
560    use ed25519_dalek::Signer;
561
562    let header = serde_json::json!({
563        "alg": JWT_ALG_EDDSA,
564        "typ": JWT_TYP
565    });
566
567    let header_b64 = URL_SAFE_NO_PAD.encode(
568        serde_json::to_vec(&header).map_err(|e| format!("header serialization failed: {e}"))?,
569    );
570    let payload_b64 = URL_SAFE_NO_PAD.encode(
571        serde_json::to_vec(claims).map_err(|e| format!("payload serialization failed: {e}"))?,
572    );
573
574    let signing_input = format!("{header_b64}.{payload_b64}");
575    let signature = signing_key.sign(signing_input.as_bytes());
576    let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
577
578    Ok(format!("{header_b64}.{payload_b64}.{sig_b64}"))
579}
580
581// ---------------------------------------------------------------------------
582// Tests
583// ---------------------------------------------------------------------------
584
585#[cfg(test)]
586#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
587mod tests {
588    use super::*;
589
590    use axum::Router;
591    use axum::body::Body;
592    use axum::http::{Request, StatusCode};
593    use axum::middleware;
594    use axum::routing::get;
595    use ed25519_dalek::SigningKey;
596    use http_body_util::BodyExt;
597    use rand::rngs::OsRng;
598    use scp_core::bridge::{BridgeConnector, BridgeMode, BridgeStatus};
599    use scp_identity::document::{DidDocument, VerificationMethod};
600    use tower::ServiceExt;
601
602    // -----------------------------------------------------------------------
603    // Test BridgeLookup implementation
604    // -----------------------------------------------------------------------
605
606    /// Test-only bridge lookup that stores bridges and DID documents in memory.
607    struct TestBridgeLookup {
608        bridges: Vec<BridgeConnector>,
609        did_docs: Vec<(String, DidDocument)>,
610        webhook_keys: Vec<(String, [u8; 32])>,
611        audience: String,
612    }
613
614    impl TestBridgeLookup {
615        fn new(audience: &str) -> Self {
616            Self {
617                bridges: Vec::new(),
618                did_docs: Vec::new(),
619                webhook_keys: Vec::new(),
620                audience: audience.to_owned(),
621            }
622        }
623    }
624
625    impl BridgeLookup for TestBridgeLookup {
626        fn find_bridge(&self, bridge_id: &str) -> Option<BridgeConnector> {
627            self.bridges
628                .iter()
629                .find(|b| b.bridge_id == bridge_id)
630                .cloned()
631        }
632
633        fn resolve_did_document(&self, did: &str) -> Option<DidDocument> {
634            self.did_docs
635                .iter()
636                .find(|(d, _)| d == did)
637                .map(|(_, doc)| doc.clone())
638        }
639
640        fn find_webhook_key(&self, key_id: &str) -> Option<[u8; 32]> {
641            self.webhook_keys
642                .iter()
643                .find(|(id, _)| id == key_id)
644                .map(|(_, key)| *key)
645        }
646
647        fn expected_audience(&self) -> &str {
648            &self.audience
649        }
650    }
651
652    // -----------------------------------------------------------------------
653    // Helpers
654    // -----------------------------------------------------------------------
655
656    fn test_did(signing_key: &SigningKey) -> String {
657        // Use a deterministic test DID based on key fingerprint.
658        let pubkey_hex = hex::encode(signing_key.verifying_key().as_bytes());
659        format!("did:dht:z6Mk{}", &pubkey_hex[..16])
660    }
661
662    fn test_did_document(did: &str, signing_key: &SigningKey) -> DidDocument {
663        let verifying = signing_key.verifying_key();
664        let pub_bytes = verifying.as_bytes();
665        let multibase = format!("z{}", bs58::encode(pub_bytes).into_string());
666
667        DidDocument {
668            context: vec!["https://www.w3.org/ns/did/v1".to_owned()],
669            id: did.to_owned(),
670            verification_method: vec![VerificationMethod {
671                id: format!("{did}#active"),
672                method_type: "Ed25519VerificationKey2020".to_owned(),
673                controller: did.to_owned(),
674                public_key_multibase: multibase,
675            }],
676            authentication: vec![format!("{did}#active")],
677            assertion_method: vec![format!("{did}#active")],
678            service: vec![],
679            also_known_as: Vec::new(),
680        }
681    }
682
683    fn test_bridge(
684        bridge_id: &str,
685        operator_did: &str,
686        context_id: &str,
687        status: BridgeStatus,
688    ) -> BridgeConnector {
689        BridgeConnector {
690            bridge_id: bridge_id.to_owned(),
691            operator_did: operator_did.into(),
692            platform: "discord".to_owned(),
693            mode: BridgeMode::Cooperative,
694            status,
695            registration_context: context_id.to_owned(),
696            registered_at: 1_700_000_000,
697        }
698    }
699
700    fn current_time() -> u64 {
701        SystemTime::now()
702            .duration_since(UNIX_EPOCH)
703            .map(|d| d.as_secs())
704            .unwrap_or(0)
705    }
706
707    fn test_claims(did: &str) -> BridgeJwtClaims {
708        let now = current_time();
709        BridgeJwtClaims {
710            iss: did.to_owned(),
711            aud: "https://node.example.com".to_owned(),
712            iat: now,
713            exp: now + 1800, // 30 minutes
714            scp_bridge_id: "bridge-test-001".to_owned(),
715            scp_context_id: "ctx-test-001".to_owned(),
716        }
717    }
718
719    fn test_app(lookup: Arc<TestBridgeLookup>) -> Router {
720        Router::new()
721            .route("/test", get(|| async { "ok" }))
722            .layer(middleware::from_fn_with_state(
723                lookup,
724                bridge_auth_middleware::<TestBridgeLookup>,
725            ))
726    }
727
728    async fn response_body(resp: axum::response::Response) -> String {
729        let bytes = resp.into_body().collect().await.unwrap().to_bytes();
730        String::from_utf8(bytes.to_vec()).unwrap()
731    }
732
733    // -----------------------------------------------------------------------
734    // JWT unit tests
735    // -----------------------------------------------------------------------
736
737    #[test]
738    fn create_and_verify_jwt_roundtrip() {
739        let signing_key = SigningKey::generate(&mut OsRng);
740        let did = test_did(&signing_key);
741        let claims = test_claims(&did);
742
743        let token = create_bridge_jwt(&claims, &signing_key).unwrap();
744
745        let mut lookup = TestBridgeLookup::new("https://node.example.com");
746        lookup
747            .did_docs
748            .push((did.clone(), test_did_document(&did, &signing_key)));
749
750        let verified = verify_bridge_jwt(&token, &lookup).unwrap();
751        assert_eq!(verified.iss, did);
752        assert_eq!(verified.scp_bridge_id, "bridge-test-001");
753        assert_eq!(verified.scp_context_id, "ctx-test-001");
754    }
755
756    #[test]
757    fn reject_expired_jwt() {
758        let signing_key = SigningKey::generate(&mut OsRng);
759        let did = test_did(&signing_key);
760        let mut claims = test_claims(&did);
761        // Set expiration to 2 minutes ago (past the clock skew tolerance).
762        claims.iat = current_time() - 7200;
763        claims.exp = current_time() - 120;
764
765        let token = create_bridge_jwt(&claims, &signing_key).unwrap();
766
767        let mut lookup = TestBridgeLookup::new("https://node.example.com");
768        lookup
769            .did_docs
770            .push((did.clone(), test_did_document(&did, &signing_key)));
771
772        let result = verify_bridge_jwt(&token, &lookup);
773        assert!(result.is_err());
774        assert!(
775            result.unwrap_err().contains("expired"),
776            "error should mention expiration"
777        );
778    }
779
780    #[test]
781    fn reject_jwt_with_excessive_lifetime() {
782        let signing_key = SigningKey::generate(&mut OsRng);
783        let did = test_did(&signing_key);
784        let mut claims = test_claims(&did);
785        // Set lifetime to 2 hours (exceeds 1-hour maximum).
786        claims.exp = claims.iat + 7200;
787
788        let token = create_bridge_jwt(&claims, &signing_key).unwrap();
789
790        let mut lookup = TestBridgeLookup::new("https://node.example.com");
791        lookup
792            .did_docs
793            .push((did.clone(), test_did_document(&did, &signing_key)));
794
795        let result = verify_bridge_jwt(&token, &lookup);
796        assert!(result.is_err());
797        assert!(
798            result.unwrap_err().contains("lifetime exceeds maximum"),
799            "error should mention lifetime"
800        );
801    }
802
803    #[test]
804    fn reject_jwt_with_wrong_key() {
805        let signing_key = SigningKey::generate(&mut OsRng);
806        let wrong_key = SigningKey::generate(&mut OsRng);
807        let did = test_did(&signing_key);
808        let claims = test_claims(&did);
809
810        // Sign with the wrong key.
811        let token = create_bridge_jwt(&claims, &wrong_key).unwrap();
812
813        let mut lookup = TestBridgeLookup::new("https://node.example.com");
814        lookup
815            .did_docs
816            .push((did.clone(), test_did_document(&did, &signing_key)));
817
818        let result = verify_bridge_jwt(&token, &lookup);
819        assert!(result.is_err());
820        assert!(
821            result
822                .unwrap_err()
823                .contains("signature verification failed"),
824            "error should mention signature failure"
825        );
826    }
827
828    #[test]
829    fn reject_jwt_with_wrong_audience() {
830        let signing_key = SigningKey::generate(&mut OsRng);
831        let did = test_did(&signing_key);
832        let mut claims = test_claims(&did);
833        claims.aud = "https://wrong-node.example.com".to_owned();
834
835        let token = create_bridge_jwt(&claims, &signing_key).unwrap();
836
837        let mut lookup = TestBridgeLookup::new("https://node.example.com");
838        lookup
839            .did_docs
840            .push((did.clone(), test_did_document(&did, &signing_key)));
841
842        let result = verify_bridge_jwt(&token, &lookup);
843        assert!(result.is_err());
844        assert!(
845            result.unwrap_err().contains("audience mismatch"),
846            "error should mention audience mismatch"
847        );
848    }
849
850    #[test]
851    fn reject_jwt_with_future_iat() {
852        let signing_key = SigningKey::generate(&mut OsRng);
853        let did = test_did(&signing_key);
854        let mut claims = test_claims(&did);
855        // Set iat far in the future.
856        claims.iat = current_time() + 3600;
857        claims.exp = claims.iat + 1800;
858
859        let token = create_bridge_jwt(&claims, &signing_key).unwrap();
860
861        let mut lookup = TestBridgeLookup::new("https://node.example.com");
862        lookup
863            .did_docs
864            .push((did.clone(), test_did_document(&did, &signing_key)));
865
866        let result = verify_bridge_jwt(&token, &lookup);
867        assert!(result.is_err());
868        assert!(
869            result.unwrap_err().contains("issued in the future"),
870            "error should mention future iat"
871        );
872    }
873
874    #[test]
875    fn reject_malformed_jwt() {
876        let lookup = TestBridgeLookup::new("https://node.example.com");
877
878        // No segments.
879        assert!(verify_bridge_jwt("not-a-jwt", &lookup).is_err());
880
881        // Only two segments.
882        assert!(verify_bridge_jwt("header.payload", &lookup).is_err());
883
884        // Four segments.
885        assert!(verify_bridge_jwt("a.b.c.d", &lookup).is_err());
886    }
887
888    #[test]
889    fn reject_unsupported_algorithm() {
890        let header = serde_json::json!({"alg": "RS256", "typ": "JWT"});
891        let claims = serde_json::json!({"iss": "did:test", "aud": "test", "iat": 0, "exp": 0, "scp_bridge_id": "b", "scp_context_id": "c"});
892
893        let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap());
894        let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&claims).unwrap());
895        let fake_sig = URL_SAFE_NO_PAD.encode([0u8; 64]);
896        let token = format!("{header_b64}.{payload_b64}.{fake_sig}");
897
898        let lookup = TestBridgeLookup::new("test");
899        let result = verify_bridge_jwt(&token, &lookup);
900        assert!(result.is_err());
901        assert!(result.unwrap_err().contains("unsupported JWT algorithm"));
902    }
903
904    // -----------------------------------------------------------------------
905    // Middleware integration tests
906    // -----------------------------------------------------------------------
907
908    #[tokio::test]
909    async fn middleware_accepts_valid_jwt() {
910        let signing_key = SigningKey::generate(&mut OsRng);
911        let did = test_did(&signing_key);
912        let claims = test_claims(&did);
913        let token = create_bridge_jwt(&claims, &signing_key).unwrap();
914
915        let mut lookup = TestBridgeLookup::new("https://node.example.com");
916        lookup
917            .did_docs
918            .push((did.clone(), test_did_document(&did, &signing_key)));
919        lookup.bridges.push(test_bridge(
920            "bridge-test-001",
921            &did,
922            "ctx-test-001",
923            BridgeStatus::Active,
924        ));
925        let lookup = Arc::new(lookup);
926
927        let app = test_app(lookup);
928        let req = Request::builder()
929            .uri("/test")
930            .header("Authorization", format!("Bearer {token}"))
931            .body(Body::empty())
932            .unwrap();
933
934        let resp = app.oneshot(req).await.unwrap();
935        assert_eq!(resp.status(), StatusCode::OK);
936        assert_eq!(response_body(resp).await, "ok");
937    }
938
939    #[tokio::test]
940    async fn middleware_rejects_missing_auth_header() {
941        let lookup = Arc::new(TestBridgeLookup::new("https://node.example.com"));
942        let app = test_app(lookup);
943
944        let req = Request::builder().uri("/test").body(Body::empty()).unwrap();
945
946        let resp = app.oneshot(req).await.unwrap();
947        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
948        let body = response_body(resp).await;
949        assert!(body.contains("BRIDGE_NOT_AUTHORIZED"));
950    }
951
952    #[tokio::test]
953    async fn middleware_rejects_expired_jwt() {
954        let signing_key = SigningKey::generate(&mut OsRng);
955        let did = test_did(&signing_key);
956        let mut claims = test_claims(&did);
957        claims.iat = current_time() - 7200;
958        claims.exp = current_time() - 120;
959        let token = create_bridge_jwt(&claims, &signing_key).unwrap();
960
961        let mut lookup = TestBridgeLookup::new("https://node.example.com");
962        lookup
963            .did_docs
964            .push((did.clone(), test_did_document(&did, &signing_key)));
965        lookup.bridges.push(test_bridge(
966            "bridge-test-001",
967            &did,
968            "ctx-test-001",
969            BridgeStatus::Active,
970        ));
971        let lookup = Arc::new(lookup);
972
973        let app = test_app(lookup);
974        let req = Request::builder()
975            .uri("/test")
976            .header("Authorization", format!("Bearer {token}"))
977            .body(Body::empty())
978            .unwrap();
979
980        let resp = app.oneshot(req).await.unwrap();
981        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
982        let body = response_body(resp).await;
983        assert!(body.contains("BRIDGE_NOT_AUTHORIZED"));
984    }
985
986    #[tokio::test]
987    async fn middleware_rejects_wrong_key_jwt() {
988        let signing_key = SigningKey::generate(&mut OsRng);
989        let wrong_key = SigningKey::generate(&mut OsRng);
990        let did = test_did(&signing_key);
991        let claims = test_claims(&did);
992        let token = create_bridge_jwt(&claims, &wrong_key).unwrap();
993
994        let mut lookup = TestBridgeLookup::new("https://node.example.com");
995        lookup
996            .did_docs
997            .push((did.clone(), test_did_document(&did, &signing_key)));
998        lookup.bridges.push(test_bridge(
999            "bridge-test-001",
1000            &did,
1001            "ctx-test-001",
1002            BridgeStatus::Active,
1003        ));
1004        let lookup = Arc::new(lookup);
1005
1006        let app = test_app(lookup);
1007        let req = Request::builder()
1008            .uri("/test")
1009            .header("Authorization", format!("Bearer {token}"))
1010            .body(Body::empty())
1011            .unwrap();
1012
1013        let resp = app.oneshot(req).await.unwrap();
1014        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1015        let body = response_body(resp).await;
1016        assert!(body.contains("BRIDGE_NOT_AUTHORIZED"));
1017    }
1018
1019    #[tokio::test]
1020    async fn middleware_returns_403_for_suspended_bridge() {
1021        let signing_key = SigningKey::generate(&mut OsRng);
1022        let did = test_did(&signing_key);
1023        let claims = test_claims(&did);
1024        let token = create_bridge_jwt(&claims, &signing_key).unwrap();
1025
1026        let mut lookup = TestBridgeLookup::new("https://node.example.com");
1027        lookup
1028            .did_docs
1029            .push((did.clone(), test_did_document(&did, &signing_key)));
1030        lookup.bridges.push(test_bridge(
1031            "bridge-test-001",
1032            &did,
1033            "ctx-test-001",
1034            BridgeStatus::Suspended,
1035        ));
1036        let lookup = Arc::new(lookup);
1037
1038        let app = test_app(lookup);
1039        let req = Request::builder()
1040            .uri("/test")
1041            .header("Authorization", format!("Bearer {token}"))
1042            .body(Body::empty())
1043            .unwrap();
1044
1045        let resp = app.oneshot(req).await.unwrap();
1046        assert_eq!(resp.status(), StatusCode::FORBIDDEN);
1047        let body = response_body(resp).await;
1048        assert!(body.contains("BRIDGE_SUSPENDED"));
1049    }
1050
1051    #[tokio::test]
1052    async fn middleware_rejects_revoked_bridge() {
1053        let signing_key = SigningKey::generate(&mut OsRng);
1054        let did = test_did(&signing_key);
1055        let claims = test_claims(&did);
1056        let token = create_bridge_jwt(&claims, &signing_key).unwrap();
1057
1058        let mut lookup = TestBridgeLookup::new("https://node.example.com");
1059        lookup
1060            .did_docs
1061            .push((did.clone(), test_did_document(&did, &signing_key)));
1062        lookup.bridges.push(test_bridge(
1063            "bridge-test-001",
1064            &did,
1065            "ctx-test-001",
1066            BridgeStatus::Revoked,
1067        ));
1068        let lookup = Arc::new(lookup);
1069
1070        let app = test_app(lookup);
1071        let req = Request::builder()
1072            .uri("/test")
1073            .header("Authorization", format!("Bearer {token}"))
1074            .body(Body::empty())
1075            .unwrap();
1076
1077        let resp = app.oneshot(req).await.unwrap();
1078        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1079        let body = response_body(resp).await;
1080        assert!(body.contains("BRIDGE_NOT_AUTHORIZED"));
1081    }
1082
1083    #[tokio::test]
1084    async fn middleware_rejects_operator_did_mismatch() {
1085        let signing_key = SigningKey::generate(&mut OsRng);
1086        let did = test_did(&signing_key);
1087        let claims = test_claims(&did);
1088        let token = create_bridge_jwt(&claims, &signing_key).unwrap();
1089
1090        let mut lookup = TestBridgeLookup::new("https://node.example.com");
1091        lookup
1092            .did_docs
1093            .push((did.clone(), test_did_document(&did, &signing_key)));
1094        // Bridge has a different operator DID.
1095        lookup.bridges.push(test_bridge(
1096            "bridge-test-001",
1097            "did:dht:z6MkDifferentOperator",
1098            "ctx-test-001",
1099            BridgeStatus::Active,
1100        ));
1101        let lookup = Arc::new(lookup);
1102
1103        let app = test_app(lookup);
1104        let req = Request::builder()
1105            .uri("/test")
1106            .header("Authorization", format!("Bearer {token}"))
1107            .body(Body::empty())
1108            .unwrap();
1109
1110        let resp = app.oneshot(req).await.unwrap();
1111        assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
1112    }
1113
1114    // -----------------------------------------------------------------------
1115    // Webhook signature tests
1116    // -----------------------------------------------------------------------
1117
1118    #[test]
1119    fn verify_valid_webhook_signature() {
1120        use ed25519_dalek::Signer;
1121
1122        let signing_key = SigningKey::generate(&mut OsRng);
1123        let pub_key = *signing_key.verifying_key().as_bytes();
1124        let body = b"webhook payload content";
1125
1126        let signature = signing_key.sign(body);
1127        let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1128
1129        let mut lookup = TestBridgeLookup::new("https://node.example.com");
1130        lookup
1131            .webhook_keys
1132            .push(("platform-key-1".to_owned(), pub_key));
1133
1134        let result = verify_webhook_signature(&sig_b64, "platform-key-1", body, &lookup);
1135        assert!(result.is_ok());
1136    }
1137
1138    #[test]
1139    fn reject_invalid_webhook_signature() {
1140        use ed25519_dalek::Signer;
1141
1142        let signing_key = SigningKey::generate(&mut OsRng);
1143        let wrong_key = SigningKey::generate(&mut OsRng);
1144        let pub_key = *signing_key.verifying_key().as_bytes();
1145        let body = b"webhook payload content";
1146
1147        // Sign with wrong key.
1148        let signature = wrong_key.sign(body);
1149        let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1150
1151        let mut lookup = TestBridgeLookup::new("https://node.example.com");
1152        lookup
1153            .webhook_keys
1154            .push(("platform-key-1".to_owned(), pub_key));
1155
1156        let result = verify_webhook_signature(&sig_b64, "platform-key-1", body, &lookup);
1157        assert!(result.is_err());
1158        assert!(
1159            result
1160                .unwrap_err()
1161                .contains("signature verification failed")
1162        );
1163    }
1164
1165    #[test]
1166    fn reject_unknown_webhook_key_id() {
1167        let body = b"webhook payload";
1168        let sig_b64 = URL_SAFE_NO_PAD.encode([0u8; 64]);
1169        let lookup = TestBridgeLookup::new("https://node.example.com");
1170
1171        let result = verify_webhook_signature(&sig_b64, "unknown-key", body, &lookup);
1172        assert!(result.is_err());
1173        assert!(result.unwrap_err().contains("unknown platform key ID"));
1174    }
1175
1176    #[test]
1177    fn reject_tampered_webhook_body() {
1178        use ed25519_dalek::Signer;
1179
1180        let signing_key = SigningKey::generate(&mut OsRng);
1181        let pub_key = *signing_key.verifying_key().as_bytes();
1182        let body = b"original payload";
1183        let tampered_body = b"tampered payload";
1184
1185        let signature = signing_key.sign(body);
1186        let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes());
1187
1188        let mut lookup = TestBridgeLookup::new("https://node.example.com");
1189        lookup
1190            .webhook_keys
1191            .push(("platform-key-1".to_owned(), pub_key));
1192
1193        let result = verify_webhook_signature(&sig_b64, "platform-key-1", tampered_body, &lookup);
1194        assert!(result.is_err());
1195    }
1196}