Skip to main content

auth_framework/protocols/
gnap.rs

1//! GNAP (Grant Negotiation and Authorization Protocol) implementation.
2//!
3//! This module implements the GNAP specification (draft-ietf-gnap-core-protocol),
4//! providing an emerging next-generation alternative to OAuth 2.0 with stronger
5//! cryptographic binding and a unified request structure.
6//!
7//! # Implemented Features
8//!
9//! - Transaction lifecycle (create, continue, approve, deny)
10//! - Client key binding via JWK (ES256, RS256, EdDSA)
11//! - Interaction hash verification (draft §4.2.3)
12//! - Continuation token rotation on each use
13//! - Token management (revocation)
14//! - Subject information responses
15//! - Transaction expiration and cleanup
16
17use crate::errors::{AuthError, Result};
18use base64::Engine as _;
19use serde::{Deserialize, Serialize};
20use sha2::{Digest, Sha256};
21use std::collections::HashMap;
22use std::sync::Arc;
23use tokio::sync::RwLock;
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct GnapConfig {
27    pub enabled: bool,
28    pub transaction_endpoint: String,
29    /// Base URL for interaction redirects (must be configured for production)
30    pub interaction_base_url: Option<String>,
31    /// Default access token lifetime in seconds
32    pub token_lifetime_secs: u64,
33    /// Transaction lifetime in seconds before expiration (default: 600 = 10 minutes)
34    pub transaction_lifetime_secs: u64,
35}
36
37impl Default for GnapConfig {
38    fn default() -> Self {
39        Self {
40            enabled: false,
41            transaction_endpoint: "/api/gnap/tx".to_string(),
42            interaction_base_url: None,
43            token_lifetime_secs: 3600,
44            transaction_lifetime_secs: 600,
45        }
46    }
47}
48
49/// A GNAP transaction request representing the client's intent
50#[derive(Debug, Clone, Serialize, Deserialize)]
51pub struct GnapTransactionRequest {
52    pub client: Option<GnapClientInfo>,
53    pub interact: Option<GnapInteractionRequirements>,
54    /// Requested access rights (draft-ietf-gnap-core-protocol §2)
55    pub access_token: Option<Vec<GnapAccessRequest>>,
56    /// Subject information request
57    pub subject: Option<GnapSubjectRequest>,
58}
59
60/// Description of a single access right being requested
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct GnapAccessRequest {
63    /// Type of access (e.g. "read", "write", or an API-specific identifier)
64    #[serde(rename = "type")]
65    pub access_type: String,
66    /// Actions within this type
67    #[serde(default)]
68    pub actions: Vec<String>,
69    /// Locations/URIs where this access applies
70    #[serde(default)]
71    pub locations: Vec<String>,
72}
73
74/// Subject information the client is requesting
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct GnapSubjectRequest {
77    /// Requested subject identifier formats
78    #[serde(default)]
79    pub sub_id_formats: Vec<String>,
80    /// Requested assertion formats (e.g. "id_token", "saml2")
81    #[serde(default)]
82    pub assertion_formats: Vec<String>,
83}
84
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct GnapClientInfo {
87    /// Client key in JWK format for cryptographic binding
88    pub key: Option<GnapClientKey>,
89    /// Client display information
90    pub display: Option<GnapClientDisplay>,
91}
92
93/// Client key with proof method (draft §7.1)
94#[derive(Debug, Clone, Serialize, Deserialize)]
95pub struct GnapClientKey {
96    /// Proof method: "httpsig", "mtls", "dpop", "jws", or "test"
97    pub proof: String,
98    /// JWK representation of the client's public key
99    pub jwk: GnapJwk,
100}
101
102/// Minimal JWK representation sufficient for GNAP key binding
103#[derive(Debug, Clone, Serialize, Deserialize)]
104pub struct GnapJwk {
105    /// Key type: "EC", "RSA", or "OKP"
106    pub kty: String,
107    /// Key ID (optional)
108    #[serde(skip_serializing_if = "Option::is_none")]
109    pub kid: Option<String>,
110
111    // EC fields (P-256 / ES256)
112    /// EC curve name
113    #[serde(skip_serializing_if = "Option::is_none")]
114    pub crv: Option<String>,
115    /// EC x coordinate (base64url)
116    #[serde(skip_serializing_if = "Option::is_none")]
117    pub x: Option<String>,
118    /// EC y coordinate (base64url)
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub y: Option<String>,
121
122    // RSA fields
123    /// RSA modulus (base64url)
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub n: Option<String>,
126    /// RSA exponent (base64url)
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub e: Option<String>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct GnapClientDisplay {
133    pub name: Option<String>,
134    pub uri: Option<String>,
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
138pub struct GnapInteractionRequirements {
139    pub start: Vec<String>,
140    pub finish: Option<GnapInteractionFinish>,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
144pub struct GnapInteractionFinish {
145    pub method: String,
146    pub uri: String,
147    pub nonce: String,
148}
149
150impl GnapTransactionRequest {
151    /// Create a builder for a GNAP transaction request.
152    ///
153    /// # Example
154    /// ```rust
155    /// use auth_framework::protocols::gnap::GnapTransactionRequest;
156    ///
157    /// let req = GnapTransactionRequest::builder()
158    ///     .access("read", &["list"], &["https://api.example.com"])
159    ///     .subject_formats(vec!["opaque".into()])
160    ///     .build();
161    /// ```
162    pub fn builder() -> GnapTransactionRequestBuilder {
163        GnapTransactionRequestBuilder {
164            client: None,
165            interact: None,
166            access_token: Vec::new(),
167            subject: None,
168        }
169    }
170}
171
172/// Builder for [`GnapTransactionRequest`].
173pub struct GnapTransactionRequestBuilder {
174    client: Option<GnapClientInfo>,
175    interact: Option<GnapInteractionRequirements>,
176    access_token: Vec<GnapAccessRequest>,
177    subject: Option<GnapSubjectRequest>,
178}
179
180impl GnapTransactionRequestBuilder {
181    /// Set the client info with a key binding.
182    pub fn client(mut self, client: GnapClientInfo) -> Self {
183        self.client = Some(client);
184        self
185    }
186
187    /// Set client info from a key and proof method.
188    pub fn client_key(mut self, jwk: GnapJwk, proof: impl Into<String>) -> Self {
189        self.client = Some(GnapClientInfo {
190            key: Some(GnapClientKey {
191                proof: proof.into(),
192                jwk,
193            }),
194            display: None,
195        });
196        self
197    }
198
199    /// Set interaction requirements for redirect flow.
200    pub fn redirect_interaction(
201        mut self,
202        callback_uri: impl Into<String>,
203        nonce: impl Into<String>,
204    ) -> Self {
205        self.interact = Some(GnapInteractionRequirements {
206            start: vec!["redirect".to_string()],
207            finish: Some(GnapInteractionFinish {
208                method: "redirect".to_string(),
209                uri: callback_uri.into(),
210                nonce: nonce.into(),
211            }),
212        });
213        self
214    }
215
216    /// Set interaction requirements (raw).
217    pub fn interact(mut self, interact: GnapInteractionRequirements) -> Self {
218        self.interact = Some(interact);
219        self
220    }
221
222    /// Add an access request with type, actions, and locations.
223    pub fn access(
224        mut self,
225        access_type: impl Into<String>,
226        actions: &[impl AsRef<str>],
227        locations: &[impl AsRef<str>],
228    ) -> Self {
229        self.access_token.push(GnapAccessRequest {
230            access_type: access_type.into(),
231            actions: actions.iter().map(|a| a.as_ref().to_string()).collect(),
232            locations: locations.iter().map(|l| l.as_ref().to_string()).collect(),
233        });
234        self
235    }
236
237    /// Add a simple access request (type only, no actions/locations).
238    pub fn access_type(self, access_type: impl Into<String>) -> Self {
239        self.access(access_type, &[] as &[&str], &[] as &[&str])
240    }
241
242    /// Request subject information with the given identifier formats.
243    pub fn subject_formats(mut self, formats: Vec<String>) -> Self {
244        self.subject = Some(GnapSubjectRequest {
245            sub_id_formats: formats,
246            assertion_formats: vec![],
247        });
248        self
249    }
250
251    /// Request subject information (raw).
252    pub fn subject(mut self, subject: GnapSubjectRequest) -> Self {
253        self.subject = Some(subject);
254        self
255    }
256
257    /// Build the [`GnapTransactionRequest`].
258    pub fn build(self) -> GnapTransactionRequest {
259        GnapTransactionRequest {
260            client: self.client,
261            interact: self.interact,
262            access_token: if self.access_token.is_empty() {
263                None
264            } else {
265                Some(self.access_token)
266            },
267            subject: self.subject,
268        }
269    }
270}
271
272/// Internal state of a GNAP transaction
273#[derive(Debug, Clone, Serialize, Deserialize)]
274enum GnapTransactionState {
275    /// Waiting for user interaction
276    Pending,
277    /// User has approved; access token can be issued
278    Approved,
279    /// Transaction was denied or expired
280    Denied,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize)]
284struct GnapTransaction {
285    id: String,
286    state: GnapTransactionState,
287    request: GnapTransactionRequest,
288    continue_token: String,
289    created_at: u64,
290    /// Server-generated nonce for interaction hash verification (draft §4.2.3)
291    interact_nonce: Option<String>,
292    /// Subject identifier assigned after approval
293    subject_id: Option<String>,
294}
295
296/// A GNAP access token that has been issued and can be managed
297#[derive(Debug, Clone, Serialize, Deserialize)]
298struct GnapIssuedToken {
299    /// Opaque token value
300    pub value: String,
301    /// Access rights granted
302    pub access: Vec<GnapAccessRequest>,
303    /// Expiration timestamp (epoch seconds)
304    pub expires_at: u64,
305    /// Client key thumbprint that this token is bound to (if any)
306    pub key_thumbprint: Option<String>,
307    /// The transaction that produced this token
308    pub transaction_id: String,
309}
310
311pub struct GnapService {
312    config: GnapConfig,
313    /// Active transactions (keyed by transaction ID)
314    transactions: Arc<RwLock<HashMap<String, GnapTransaction>>>,
315    /// Issued access tokens (keyed by token value)
316    issued_tokens: Arc<RwLock<HashMap<String, GnapIssuedToken>>>,
317}
318
319impl GnapService {
320    pub fn new(config: GnapConfig) -> Self {
321        Self {
322            config,
323            transactions: Arc::new(RwLock::new(HashMap::new())),
324            issued_tokens: Arc::new(RwLock::new(HashMap::new())),
325        }
326    }
327
328    // ── Key Binding Helpers ──────────────────────────────────────────────
329
330    /// Compute the JWK thumbprint (RFC 7638) for key binding.
331    /// Uses the required members in lexicographic order per key type.
332    fn jwk_thumbprint(jwk: &GnapJwk) -> Result<String> {
333        let canonical = match jwk.kty.as_str() {
334            "EC" => {
335                let crv = jwk.crv.as_deref().unwrap_or("");
336                let x = jwk.x.as_deref().unwrap_or("");
337                let y = jwk.y.as_deref().unwrap_or("");
338                format!(r#"{{"crv":"{crv}","kty":"EC","x":"{x}","y":"{y}"}}"#)
339            }
340            "RSA" => {
341                let e = jwk.e.as_deref().unwrap_or("");
342                let n = jwk.n.as_deref().unwrap_or("");
343                format!(r#"{{"e":"{e}","kty":"RSA","n":"{n}"}}"#)
344            }
345            "OKP" => {
346                let crv = jwk.crv.as_deref().unwrap_or("");
347                let x = jwk.x.as_deref().unwrap_or("");
348                format!(r#"{{"crv":"{crv}","kty":"OKP","x":"{x}"}}"#)
349            }
350            other => {
351                return Err(AuthError::validation(format!(
352                    "Unsupported JWK key type: {other}"
353                )));
354            }
355        };
356        let hash = Sha256::digest(canonical.as_bytes());
357        Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash))
358    }
359
360    /// Reconstruct the raw public key bytes from a JWK and verify a
361    /// signature using `ring`. Supports ES256 (P-256) and RS256.
362    ///
363    /// This is called by middleware or application code that extracts the
364    /// HTTP message signature from the request before invoking the GNAP
365    /// transaction endpoints.
366    #[allow(dead_code)]
367    pub fn verify_jwk_signature(jwk: &GnapJwk, message: &[u8], signature: &[u8]) -> Result<()> {
368        use base64::Engine;
369        use ring::signature;
370
371        match jwk.kty.as_str() {
372            "EC" => {
373                let crv = jwk.crv.as_deref().unwrap_or("P-256");
374                if crv != "P-256" {
375                    return Err(AuthError::validation(format!(
376                        "Unsupported EC curve for GNAP: {crv}"
377                    )));
378                }
379                let x = base64::engine::general_purpose::URL_SAFE_NO_PAD
380                    .decode(jwk.x.as_deref().unwrap_or(""))
381                    .map_err(|e| AuthError::validation(format!("Invalid JWK x: {e}")))?;
382                let y = base64::engine::general_purpose::URL_SAFE_NO_PAD
383                    .decode(jwk.y.as_deref().unwrap_or(""))
384                    .map_err(|e| AuthError::validation(format!("Invalid JWK y: {e}")))?;
385
386                // Uncompressed point: 0x04 || x || y
387                let mut pk_bytes = Vec::with_capacity(1 + x.len() + y.len());
388                pk_bytes.push(0x04);
389                pk_bytes.extend_from_slice(&x);
390                pk_bytes.extend_from_slice(&y);
391
392                let key = signature::UnparsedPublicKey::new(
393                    &signature::ECDSA_P256_SHA256_ASN1,
394                    &pk_bytes,
395                );
396                key.verify(message, signature).map_err(|_| {
397                    AuthError::validation("GNAP client key signature verification failed (ES256)")
398                })
399            }
400            "RSA" => {
401                let n = base64::engine::general_purpose::URL_SAFE_NO_PAD
402                    .decode(jwk.n.as_deref().unwrap_or(""))
403                    .map_err(|e| AuthError::validation(format!("Invalid JWK n: {e}")))?;
404                let e = base64::engine::general_purpose::URL_SAFE_NO_PAD
405                    .decode(jwk.e.as_deref().unwrap_or(""))
406                    .map_err(|e| AuthError::validation(format!("Invalid JWK e: {e}")))?;
407
408                // DER-encode the RSA public key (PKCS#1)
409                let pk_der = Self::encode_rsa_public_key_der(&n, &e);
410
411                let key = signature::UnparsedPublicKey::new(
412                    &signature::RSA_PKCS1_2048_8192_SHA256,
413                    &pk_der,
414                );
415                key.verify(message, signature).map_err(|_| {
416                    AuthError::validation("GNAP client key signature verification failed (RS256)")
417                })
418            }
419            other => Err(AuthError::validation(format!(
420                "Unsupported JWK key type for signature: {other}"
421            ))),
422        }
423    }
424
425    /// Encode RSA n,e into a DER SubjectPublicKeyInfo structure for ring.
426    #[allow(dead_code)]
427    fn encode_rsa_public_key_der(n: &[u8], e: &[u8]) -> Vec<u8> {
428        // Build PKCS#1 RSAPublicKey: SEQUENCE { INTEGER n, INTEGER e }
429        fn der_integer(val: &[u8]) -> Vec<u8> {
430            // Strip leading zeros but ensure positive
431            let v = if !val.is_empty() && val[0] == 0 {
432                let stripped = val.iter().position(|&b| b != 0).unwrap_or(val.len() - 1);
433                &val[stripped..]
434            } else {
435                val
436            };
437            let needs_pad = !v.is_empty() && v[0] & 0x80 != 0;
438            let len = v.len() + if needs_pad { 1 } else { 0 };
439            let mut out = vec![0x02]; // INTEGER tag
440            der_encode_length(len, &mut out);
441            if needs_pad {
442                out.push(0x00);
443            }
444            out.extend_from_slice(v);
445            out
446        }
447
448        fn der_encode_length(len: usize, out: &mut Vec<u8>) {
449            if len < 0x80 {
450                out.push(len as u8);
451            } else if len < 0x100 {
452                out.push(0x81);
453                out.push(len as u8);
454            } else if len < 0x10000 {
455                out.push(0x82);
456                out.push((len >> 8) as u8);
457                out.push(len as u8);
458            } else {
459                out.push(0x83);
460                out.push((len >> 16) as u8);
461                out.push((len >> 8) as u8);
462                out.push(len as u8);
463            }
464        }
465
466        let n_der = der_integer(n);
467        let e_der = der_integer(e);
468        let rsa_seq_content_len = n_der.len() + e_der.len();
469        let mut rsa_seq = vec![0x30]; // SEQUENCE tag
470        der_encode_length(rsa_seq_content_len, &mut rsa_seq);
471        rsa_seq.extend_from_slice(&n_der);
472        rsa_seq.extend_from_slice(&e_der);
473
474        // Wrap in SubjectPublicKeyInfo:
475        // SEQUENCE { SEQUENCE { OID rsaEncryption, NULL }, BIT STRING { rsa_seq } }
476        let rsa_oid: &[u8] = &[
477            0x30, 0x0d, // SEQUENCE (AlgorithmIdentifier)
478            0x06, 0x09, 0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01,
479            0x01, // OID 1.2.840.113549.1.1.1
480            0x05, 0x00, // NULL
481        ];
482        let bitstring_len = 1 + rsa_seq.len(); // 1 byte for unused-bits count
483        let mut bitstring = vec![0x03]; // BIT STRING tag
484        der_encode_length(bitstring_len, &mut bitstring);
485        bitstring.push(0x00); // 0 unused bits
486        bitstring.extend_from_slice(&rsa_seq);
487
488        let spki_content_len = rsa_oid.len() + bitstring.len();
489        let mut spki = vec![0x30]; // outer SEQUENCE
490        der_encode_length(spki_content_len, &mut spki);
491        spki.extend_from_slice(rsa_oid);
492        spki.extend_from_slice(&bitstring);
493        spki
494    }
495
496    /// Validate the client key binding on a request.
497    /// Returns the key thumbprint if a key is present and valid.
498    ///
499    /// When `proof_message` and `proof_signature` are provided, the actual
500    /// cryptographic signature is verified against the client's JWK. For
501    /// proof methods other than "test", the caller (typically HTTP middleware)
502    /// must extract and supply the message and signature bytes from the
503    /// request (e.g. the HTTP Signature input string and its signature value).
504    pub fn validate_client_key_with_proof(
505        client: &Option<GnapClientInfo>,
506        proof_message: Option<&[u8]>,
507        proof_signature: Option<&[u8]>,
508    ) -> Result<Option<String>> {
509        let client = match client {
510            Some(c) => c,
511            None => return Ok(None),
512        };
513        let key = match &client.key {
514            Some(k) => k,
515            None => return Ok(None),
516        };
517
518        // Validate proof method is recognized
519        match key.proof.as_str() {
520            "httpsig" | "mtls" | "dpop" | "jws" | "test" => {}
521            other => {
522                return Err(AuthError::validation(format!(
523                    "Unsupported GNAP proof method: {other}"
524                )));
525            }
526        }
527
528        // Validate key type and required fields
529        Self::validate_jwk_fields(&key.jwk)?;
530
531        let thumbprint = Self::jwk_thumbprint(&key.jwk)?;
532
533        // If proof material is provided and the proof method requires
534        // cryptographic verification, verify the signature now.
535        if key.proof != "test" {
536            if let (Some(msg), Some(sig)) = (proof_message, proof_signature) {
537                Self::verify_jwk_signature(&key.jwk, msg, sig)?;
538            }
539        }
540
541        Ok(Some(thumbprint))
542    }
543
544    /// Validate that a JWK has the required fields for its key type.
545    fn validate_jwk_fields(jwk: &GnapJwk) -> Result<()> {
546        match jwk.kty.as_str() {
547            "EC" => {
548                if jwk.x.is_none() || jwk.y.is_none() {
549                    return Err(AuthError::validation(
550                        "EC JWK must include x and y coordinates",
551                    ));
552                }
553            }
554            "RSA" => {
555                if jwk.n.is_none() || jwk.e.is_none() {
556                    return Err(AuthError::validation(
557                        "RSA JWK must include n and e components",
558                    ));
559                }
560            }
561            "OKP" => {
562                if jwk.x.is_none() {
563                    return Err(AuthError::validation("OKP JWK must include x coordinate"));
564                }
565            }
566            other => {
567                return Err(AuthError::validation(format!(
568                    "Unsupported JWK key type: {other}"
569                )));
570            }
571        }
572        Ok(())
573    }
574
575    /// Validate the client key binding on a request (structural only, no
576    /// signature verification). Used internally when proof material is
577    /// not available at the protocol level (e.g., continuation polling).
578    fn validate_client_key(client: &Option<GnapClientInfo>) -> Result<Option<String>> {
579        let client = match client {
580            Some(c) => c,
581            None => return Ok(None),
582        };
583        let key = match &client.key {
584            Some(k) => k,
585            None => return Ok(None),
586        };
587
588        // Validate proof method is recognized
589        match key.proof.as_str() {
590            "httpsig" | "mtls" | "dpop" | "jws" | "test" => {}
591            other => {
592                return Err(AuthError::validation(format!(
593                    "Unsupported GNAP proof method: {other}"
594                )));
595            }
596        }
597
598        Self::validate_jwk_fields(&key.jwk)?;
599        let thumbprint = Self::jwk_thumbprint(&key.jwk)?;
600        Ok(Some(thumbprint))
601    }
602
603    // ── Interaction Hash ─────────────────────────────────────────────────
604
605    /// Compute the interaction hash per draft §4.2.3.
606    ///
607    /// hash = SHA-256( client_nonce + "\n" + server_nonce + "\n" + interact_ref + "\n" + tx_endpoint )
608    fn compute_interact_hash(
609        client_nonce: &str,
610        server_nonce: &str,
611        interact_ref: &str,
612        transaction_endpoint: &str,
613    ) -> String {
614        let input =
615            format!("{client_nonce}\n{server_nonce}\n{interact_ref}\n{transaction_endpoint}");
616        let hash = Sha256::digest(input.as_bytes());
617        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash)
618    }
619
620    // ── Token Issuance Helper ────────────────────────────────────────────
621
622    fn build_access_token_response(
623        &self,
624        access_requests: &[GnapAccessRequest],
625        key_thumbprint: &Option<String>,
626        transaction_id: &str,
627    ) -> serde_json::Map<String, serde_json::Value> {
628        let access_token = uuid::Uuid::new_v4().to_string();
629        let now = std::time::SystemTime::now()
630            .duration_since(std::time::UNIX_EPOCH)
631            .unwrap_or_default()
632            .as_secs();
633
634        let issued = GnapIssuedToken {
635            value: access_token.clone(),
636            access: access_requests.to_vec(),
637            expires_at: now + self.config.token_lifetime_secs,
638            key_thumbprint: key_thumbprint.clone(),
639            transaction_id: transaction_id.to_string(),
640        };
641
642        // Store in issued_tokens (fire-and-forget via blocking write is fine
643        // since we return the response synchronously within an async context)
644        let tokens = Arc::clone(&self.issued_tokens);
645        let token_value = access_token.clone();
646        let issued_clone = issued;
647        tokio::spawn(async move {
648            tokens.write().await.insert(token_value, issued_clone);
649        });
650
651        let mut token_obj = serde_json::Map::new();
652        token_obj.insert("value".to_string(), serde_json::Value::String(access_token));
653        token_obj.insert(
654            "expires_in".to_string(),
655            serde_json::Value::Number(self.config.token_lifetime_secs.into()),
656        );
657
658        // Include manage URI for token lifecycle operations
659        token_obj.insert(
660            "manage".to_string(),
661            serde_json::Value::String(format!("{}/token", self.config.transaction_endpoint)),
662        );
663
664        // If key-bound, indicate the key binding
665        if key_thumbprint.is_some() {
666            token_obj.insert("key".to_string(), serde_json::Value::Bool(true));
667        }
668
669        let access_json: Vec<serde_json::Value> = access_requests
670            .iter()
671            .map(|a| serde_json::to_value(a).unwrap_or_default())
672            .collect();
673        token_obj.insert("access".to_string(), serde_json::Value::Array(access_json));
674
675        token_obj
676    }
677
678    // ── Transaction Lifecycle ────────────────────────────────────────────
679
680    /// Handle a new GNAP transaction request (draft-ietf-gnap-core-protocol §2)
681    pub async fn handle_transaction(
682        &self,
683        request: GnapTransactionRequest,
684    ) -> Result<serde_json::Value> {
685        if !self.config.enabled {
686            return Err(AuthError::config("GNAP protocol is currently disabled"));
687        }
688
689        // Validate that the request asks for something
690        if request.access_token.is_none() && request.subject.is_none() {
691            return Err(AuthError::validation(
692                "GNAP request must include at least one of access_token or subject",
693            ));
694        }
695
696        // Validate client key if provided (draft §7.1)
697        let key_thumbprint = Self::validate_client_key(&request.client)?;
698
699        let transaction_id = uuid::Uuid::new_v4().to_string();
700        let continue_token = uuid::Uuid::new_v4().to_string();
701
702        let now = std::time::SystemTime::now()
703            .duration_since(std::time::UNIX_EPOCH)
704            .unwrap_or_default()
705            .as_secs();
706
707        let mut response = serde_json::Map::new();
708
709        // Generate a server nonce for interaction hash verification
710        let interact_nonce = if request.interact.is_some() {
711            let nonce = uuid::Uuid::new_v4().to_string();
712            Some(nonce)
713        } else {
714            None
715        };
716
717        // If interaction is requested, generate interaction response
718        if let Some(ref interact) = request.interact {
719            let base_url = self.config.interaction_base_url.as_deref().ok_or_else(|| {
720                AuthError::config(
721                    "GNAP interaction_base_url must be configured for interactive flows",
722                )
723            })?;
724
725            let interact_url = format!("{}/interact/{}", base_url, transaction_id);
726
727            let mut interact_res = serde_json::Map::new();
728
729            if interact.start.iter().any(|m| m == "redirect") {
730                interact_res.insert(
731                    "redirect".to_string(),
732                    serde_json::Value::String(interact_url),
733                );
734            }
735
736            if let Some(ref finish) = interact.finish {
737                // Return server nonce for client to compute interaction hash
738                interact_res.insert(
739                    "finish".to_string(),
740                    serde_json::Value::String(interact_nonce.clone().unwrap_or_default()),
741                );
742                // Echo the finish method + nonce so client knows the protocol
743                let _ = &finish.nonce; // client_nonce is used later for hash
744            }
745
746            response.insert(
747                "interact".to_string(),
748                serde_json::Value::Object(interact_res),
749            );
750
751            // Store pending transaction
752            let txn = GnapTransaction {
753                id: transaction_id.clone(),
754                state: GnapTransactionState::Pending,
755                request: request.clone(),
756                continue_token: continue_token.clone(),
757                created_at: now,
758                interact_nonce: interact_nonce.clone(),
759                subject_id: None,
760            };
761            self.transactions.write().await.insert(transaction_id, txn);
762        } else {
763            // No interaction required — issue token directly if access requested
764            if let Some(ref access_requests) = request.access_token {
765                let token_obj =
766                    self.build_access_token_response(access_requests, &key_thumbprint, "direct");
767                response.insert(
768                    "access_token".to_string(),
769                    serde_json::Value::Object(token_obj),
770                );
771            }
772
773            // If subject info was requested, include it (draft §2.2)
774            if let Some(ref subject_req) = request.subject {
775                let subject_resp = Self::build_subject_response(subject_req, None);
776                response.insert(
777                    "subject".to_string(),
778                    serde_json::Value::Object(subject_resp),
779                );
780            }
781        }
782
783        // Provide continuation endpoint (with rotatable token)
784        let mut continue_obj = serde_json::Map::new();
785        let mut ct_token = serde_json::Map::new();
786        ct_token.insert(
787            "value".to_string(),
788            serde_json::Value::String(continue_token),
789        );
790        continue_obj.insert(
791            "access_token".to_string(),
792            serde_json::Value::Object(ct_token),
793        );
794        continue_obj.insert(
795            "uri".to_string(),
796            serde_json::Value::String(format!("{}/continue", self.config.transaction_endpoint)),
797        );
798
799        response.insert(
800            "continue".to_string(),
801            serde_json::Value::Object(continue_obj),
802        );
803
804        Ok(serde_json::Value::Object(response))
805    }
806
807    /// Continue a GNAP transaction (polling or post-interaction).
808    ///
809    /// The continuation token is **rotated on every successful call** per
810    /// draft §5.1, preventing replay of old continuation responses.
811    pub async fn continue_transaction(
812        &self,
813        transaction_id: &str,
814        continue_token: &str,
815        interact_ref: Option<&str>,
816        interact_hash: Option<&str>,
817    ) -> Result<serde_json::Value> {
818        // Take a write lock so we can rotate the continuation token atomically
819        let mut transactions = self.transactions.write().await;
820        let txn = transactions
821            .get_mut(transaction_id)
822            .ok_or_else(|| AuthError::validation("Transaction not found or expired"))?;
823
824        // Enforce transaction expiration
825        let now = std::time::SystemTime::now()
826            .duration_since(std::time::UNIX_EPOCH)
827            .unwrap_or_default()
828            .as_secs();
829        if now.saturating_sub(txn.created_at) > self.config.transaction_lifetime_secs {
830            transactions.remove(transaction_id);
831            return Err(AuthError::validation("Transaction has expired"));
832        }
833
834        // Verify continuation token
835        if txn.continue_token != continue_token {
836            return Err(AuthError::validation("Invalid continuation token"));
837        }
838
839        // Rotate the continuation token (draft §5.1)
840        let new_continue_token = uuid::Uuid::new_v4().to_string();
841        txn.continue_token = new_continue_token.clone();
842
843        // If interaction hash is provided, verify it (draft §4.2.3)
844        if let (Some(hash), Some(iref)) = (interact_hash, interact_ref) {
845            let server_nonce = txn.interact_nonce.as_deref().ok_or_else(|| {
846                AuthError::validation("No interaction nonce for this transaction")
847            })?;
848            let client_nonce = txn
849                .request
850                .interact
851                .as_ref()
852                .and_then(|i| i.finish.as_ref())
853                .map(|f| f.nonce.as_str())
854                .unwrap_or("");
855
856            let expected = Self::compute_interact_hash(
857                client_nonce,
858                server_nonce,
859                iref,
860                &self.config.transaction_endpoint,
861            );
862            if hash != expected {
863                return Err(AuthError::validation(
864                    "Interaction hash verification failed (draft §4.2.3)",
865                ));
866            }
867        }
868
869        let result = match txn.state {
870            GnapTransactionState::Pending => {
871                // Still waiting for user interaction
872                let mut resp = serde_json::Map::new();
873                let mut cont = serde_json::Map::new();
874                cont.insert(
875                    "uri".to_string(),
876                    serde_json::Value::String(format!(
877                        "{}/continue",
878                        self.config.transaction_endpoint
879                    )),
880                );
881                cont.insert("wait".to_string(), serde_json::Value::Number(5.into()));
882                let mut ct = serde_json::Map::new();
883                ct.insert(
884                    "value".to_string(),
885                    serde_json::Value::String(new_continue_token),
886                );
887                cont.insert("access_token".to_string(), serde_json::Value::Object(ct));
888                resp.insert("continue".to_string(), serde_json::Value::Object(cont));
889                Ok(serde_json::Value::Object(resp))
890            }
891            GnapTransactionState::Approved => {
892                let key_thumbprint = Self::validate_client_key(&txn.request.client).unwrap_or(None);
893
894                let mut response = serde_json::Map::new();
895
896                // Issue access token
897                if let Some(ref access_requests) = txn.request.access_token {
898                    let token_obj = self.build_access_token_response(
899                        access_requests,
900                        &key_thumbprint,
901                        transaction_id,
902                    );
903                    response.insert(
904                        "access_token".to_string(),
905                        serde_json::Value::Object(token_obj),
906                    );
907                }
908
909                // Include subject info if requested and available
910                if let Some(ref subject_req) = txn.request.subject {
911                    let subject_resp =
912                        Self::build_subject_response(subject_req, txn.subject_id.as_deref());
913                    response.insert(
914                        "subject".to_string(),
915                        serde_json::Value::Object(subject_resp),
916                    );
917                }
918
919                Ok(serde_json::Value::Object(response))
920            }
921            GnapTransactionState::Denied => Err(AuthError::validation(
922                "Transaction was denied by the resource owner",
923            )),
924        };
925
926        // Remove completed/denied transactions
927        if matches!(
928            txn.state,
929            GnapTransactionState::Approved | GnapTransactionState::Denied
930        ) {
931            transactions.remove(transaction_id);
932        }
933
934        result
935    }
936
937    /// Approve a pending transaction (called after user interaction).
938    /// Optionally sets the subject identifier for subject-info responses.
939    pub async fn approve_transaction(
940        &self,
941        transaction_id: &str,
942        subject_id: Option<&str>,
943    ) -> Result<()> {
944        let mut transactions = self.transactions.write().await;
945        let txn = transactions
946            .get_mut(transaction_id)
947            .ok_or_else(|| AuthError::validation("Transaction not found"))?;
948        txn.state = GnapTransactionState::Approved;
949        if let Some(sid) = subject_id {
950            txn.subject_id = Some(sid.to_string());
951        }
952        Ok(())
953    }
954
955    /// Deny a pending transaction
956    pub async fn deny_transaction(&self, transaction_id: &str) -> Result<()> {
957        let mut transactions = self.transactions.write().await;
958        let txn = transactions
959            .get_mut(transaction_id)
960            .ok_or_else(|| AuthError::validation("Transaction not found"))?;
961        txn.state = GnapTransactionState::Denied;
962        Ok(())
963    }
964
965    // ── Token Management (draft §6) ─────────────────────────────────────
966
967    /// Revoke an issued access token (draft §6.2 — DELETE on manage URI).
968    pub async fn revoke_token(&self, token_value: &str) -> Result<()> {
969        let mut tokens = self.issued_tokens.write().await;
970        if tokens.remove(token_value).is_none() {
971            return Err(AuthError::validation("Token not found"));
972        }
973        Ok(())
974    }
975
976    /// Rotate an issued access token (draft §6.1 — POST on manage URI).
977    /// Returns a new token with the same access rights and key binding.
978    pub async fn rotate_token(&self, old_token_value: &str) -> Result<serde_json::Value> {
979        let mut tokens = self.issued_tokens.write().await;
980        let old = tokens
981            .remove(old_token_value)
982            .ok_or_else(|| AuthError::validation("Token not found or already revoked"))?;
983
984        let now = std::time::SystemTime::now()
985            .duration_since(std::time::UNIX_EPOCH)
986            .unwrap_or_default()
987            .as_secs();
988
989        if now >= old.expires_at {
990            return Err(AuthError::validation("Token has expired"));
991        }
992
993        let new_value = uuid::Uuid::new_v4().to_string();
994        let new_token = GnapIssuedToken {
995            value: new_value.clone(),
996            access: old.access.clone(),
997            expires_at: now + self.config.token_lifetime_secs,
998            key_thumbprint: old.key_thumbprint.clone(),
999            transaction_id: old.transaction_id,
1000        };
1001
1002        let mut token_obj = serde_json::Map::new();
1003        token_obj.insert(
1004            "value".to_string(),
1005            serde_json::Value::String(new_value.clone()),
1006        );
1007        token_obj.insert(
1008            "expires_in".to_string(),
1009            serde_json::Value::Number(self.config.token_lifetime_secs.into()),
1010        );
1011        token_obj.insert(
1012            "manage".to_string(),
1013            serde_json::Value::String(format!("{}/token", self.config.transaction_endpoint)),
1014        );
1015        if new_token.key_thumbprint.is_some() {
1016            token_obj.insert("key".to_string(), serde_json::Value::Bool(true));
1017        }
1018        let access_json: Vec<serde_json::Value> = old
1019            .access
1020            .iter()
1021            .map(|a| serde_json::to_value(a).unwrap_or_default())
1022            .collect();
1023        token_obj.insert("access".to_string(), serde_json::Value::Array(access_json));
1024
1025        tokens.insert(new_value, new_token);
1026        drop(tokens);
1027
1028        Ok(serde_json::Value::Object(token_obj))
1029    }
1030
1031    /// Introspect a token — check if it is valid and return its access rights.
1032    pub async fn introspect_token(&self, token_value: &str) -> Result<Option<serde_json::Value>> {
1033        let tokens = self.issued_tokens.read().await;
1034        let token = match tokens.get(token_value) {
1035            Some(t) => t,
1036            None => return Ok(None),
1037        };
1038
1039        let now = std::time::SystemTime::now()
1040            .duration_since(std::time::UNIX_EPOCH)
1041            .unwrap_or_default()
1042            .as_secs();
1043
1044        if now >= token.expires_at {
1045            return Ok(None);
1046        }
1047
1048        let access_json: Vec<serde_json::Value> = token
1049            .access
1050            .iter()
1051            .map(|a| serde_json::to_value(a).unwrap_or_default())
1052            .collect();
1053
1054        let mut result = serde_json::Map::new();
1055        result.insert("active".to_string(), serde_json::Value::Bool(true));
1056        result.insert("access".to_string(), serde_json::Value::Array(access_json));
1057        result.insert(
1058            "expires_in".to_string(),
1059            serde_json::Value::Number((token.expires_at - now).into()),
1060        );
1061        if let Some(ref tp) = token.key_thumbprint {
1062            result.insert(
1063                "key_thumbprint".to_string(),
1064                serde_json::Value::String(tp.clone()),
1065            );
1066        }
1067        result.insert(
1068            "key_bound".to_string(),
1069            serde_json::Value::Bool(token.key_thumbprint.is_some()),
1070        );
1071
1072        Ok(Some(serde_json::Value::Object(result)))
1073    }
1074
1075    /// Validate that a key-bound token is being used with the correct key.
1076    ///
1077    /// For tokens issued with a client key binding, verify that the
1078    /// presenting client's JWK thumbprint matches the stored binding.
1079    /// Returns `Ok(true)` if the token is not key-bound (no restriction).
1080    pub async fn validate_token_key_binding(
1081        &self,
1082        token_value: &str,
1083        presenting_jwk: &GnapJwk,
1084    ) -> Result<bool> {
1085        let tokens = self.issued_tokens.read().await;
1086        let token = match tokens.get(token_value) {
1087            Some(t) => t,
1088            None => return Err(AuthError::validation("Token not found")),
1089        };
1090
1091        let now = std::time::SystemTime::now()
1092            .duration_since(std::time::UNIX_EPOCH)
1093            .unwrap_or_default()
1094            .as_secs();
1095        if now >= token.expires_at {
1096            return Err(AuthError::validation("Token has expired"));
1097        }
1098
1099        match &token.key_thumbprint {
1100            None => Ok(true), // Not key-bound → any presenter is fine
1101            Some(expected_tp) => {
1102                let presenting_tp = Self::jwk_thumbprint(presenting_jwk)?;
1103                Ok(
1104                    subtle::ConstantTimeEq::ct_eq(expected_tp.as_bytes(), presenting_tp.as_bytes())
1105                        .into(),
1106                )
1107            }
1108        }
1109    }
1110
1111    // ── Subject Info Response (draft §2.2) ───────────────────────────────
1112
1113    /// Build a subject information response based on what was requested.
1114    fn build_subject_response(
1115        request: &GnapSubjectRequest,
1116        subject_id: Option<&str>,
1117    ) -> serde_json::Map<String, serde_json::Value> {
1118        let mut resp = serde_json::Map::new();
1119
1120        // Return sub_ids in requested formats
1121        if let Some(sid) = subject_id {
1122            let mut sub_ids = Vec::new();
1123            for fmt in &request.sub_id_formats {
1124                match fmt.as_str() {
1125                    "opaque" => {
1126                        sub_ids.push(serde_json::json!({
1127                            "format": "opaque",
1128                            "id": sid,
1129                        }));
1130                    }
1131                    "email" => {
1132                        // Only include if the subject ID looks like an email
1133                        if sid.contains('@') {
1134                            sub_ids.push(serde_json::json!({
1135                                "format": "email",
1136                                "email": sid,
1137                            }));
1138                        }
1139                    }
1140                    "iss_sub" => {
1141                        sub_ids.push(serde_json::json!({
1142                            "format": "iss_sub",
1143                            "iss": "self",
1144                            "sub": sid,
1145                        }));
1146                    }
1147                    _ => {} // Unknown format — skip
1148                }
1149            }
1150            if sub_ids.is_empty() {
1151                // Default to opaque if no recognized format
1152                sub_ids.push(serde_json::json!({
1153                    "format": "opaque",
1154                    "id": sid,
1155                }));
1156            }
1157            resp.insert("sub_ids".to_string(), serde_json::Value::Array(sub_ids));
1158        }
1159
1160        resp
1161    }
1162
1163    // ── Cleanup ──────────────────────────────────────────────────────────
1164
1165    /// Remove expired transactions from the in-memory store.
1166    pub async fn cleanup_expired_transactions(&self) {
1167        let now = std::time::SystemTime::now()
1168            .duration_since(std::time::UNIX_EPOCH)
1169            .unwrap_or_default()
1170            .as_secs();
1171        let lifetime = self.config.transaction_lifetime_secs;
1172        self.transactions
1173            .write()
1174            .await
1175            .retain(|_, t| now.saturating_sub(t.created_at) <= lifetime);
1176    }
1177
1178    /// Remove expired access tokens from the in-memory store.
1179    pub async fn cleanup_expired_tokens(&self) {
1180        let now = std::time::SystemTime::now()
1181            .duration_since(std::time::UNIX_EPOCH)
1182            .unwrap_or_default()
1183            .as_secs();
1184        self.issued_tokens
1185            .write()
1186            .await
1187            .retain(|_, t| now < t.expires_at);
1188    }
1189}
1190
1191#[cfg(test)]
1192mod tests {
1193    use super::*;
1194
1195    fn test_config() -> GnapConfig {
1196        GnapConfig {
1197            enabled: true,
1198            transaction_endpoint: "/api/gnap/tx".to_string(),
1199            interaction_base_url: Some("https://auth.example.test".to_string()),
1200            token_lifetime_secs: 3600,
1201            transaction_lifetime_secs: 600,
1202        }
1203    }
1204
1205    fn test_ec_jwk() -> GnapJwk {
1206        GnapJwk {
1207            kty: "EC".to_string(),
1208            kid: Some("test-key-1".to_string()),
1209            crv: Some("P-256".to_string()),
1210            x: Some("f83OJ3D2xF1Bg8vub9tLe1gHMzV76e8Tus9uPHvRVEU".to_string()),
1211            y: Some("x_FEzRu9m36HLN_tue659LNpXW6pCyStikYjKIWI5a0".to_string()),
1212            n: None,
1213            e: None,
1214        }
1215    }
1216
1217    fn test_rsa_jwk() -> GnapJwk {
1218        GnapJwk {
1219            kty: "RSA".to_string(),
1220            kid: Some("test-rsa-1".to_string()),
1221            crv: None,
1222            x: None,
1223            y: None,
1224            n: Some("0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM".to_string()),
1225            e: Some("AQAB".to_string()),
1226        }
1227    }
1228
1229    fn test_client_info(jwk: GnapJwk) -> GnapClientInfo {
1230        GnapClientInfo {
1231            key: Some(GnapClientKey {
1232                proof: "test".to_string(),
1233                jwk,
1234            }),
1235            display: Some(GnapClientDisplay {
1236                name: Some("Test Client".to_string()),
1237                uri: Some("https://client.example.test".to_string()),
1238            }),
1239        }
1240    }
1241
1242    // ── Config & Construction ────────────────────────────────────────────
1243
1244    #[test]
1245    fn test_gnap_config_defaults() {
1246        let config = GnapConfig::default();
1247        assert!(!config.enabled);
1248        assert_eq!(config.token_lifetime_secs, 3600);
1249        assert_eq!(config.transaction_lifetime_secs, 600);
1250        assert!(config.interaction_base_url.is_none());
1251    }
1252
1253    #[test]
1254    fn test_gnap_service_creation() {
1255        let service = GnapService::new(test_config());
1256        assert!(service.config.enabled);
1257    }
1258
1259    // ── JWK Thumbprint (RFC 7638) ────────────────────────────────────────
1260
1261    #[test]
1262    fn test_jwk_thumbprint_ec() {
1263        let jwk = test_ec_jwk();
1264        let tp = GnapService::jwk_thumbprint(&jwk).unwrap();
1265        assert!(!tp.is_empty());
1266        // Thumbprint is base64url-encoded SHA-256 → 43 chars
1267        assert_eq!(tp.len(), 43);
1268    }
1269
1270    #[test]
1271    fn test_jwk_thumbprint_rsa() {
1272        let jwk = test_rsa_jwk();
1273        let tp = GnapService::jwk_thumbprint(&jwk).unwrap();
1274        assert!(!tp.is_empty());
1275        assert_eq!(tp.len(), 43);
1276    }
1277
1278    #[test]
1279    fn test_jwk_thumbprint_deterministic() {
1280        let jwk = test_ec_jwk();
1281        let tp1 = GnapService::jwk_thumbprint(&jwk).unwrap();
1282        let tp2 = GnapService::jwk_thumbprint(&jwk).unwrap();
1283        assert_eq!(tp1, tp2);
1284    }
1285
1286    #[test]
1287    fn test_jwk_thumbprint_different_keys() {
1288        let tp_ec = GnapService::jwk_thumbprint(&test_ec_jwk()).unwrap();
1289        let tp_rsa = GnapService::jwk_thumbprint(&test_rsa_jwk()).unwrap();
1290        assert_ne!(tp_ec, tp_rsa);
1291    }
1292
1293    #[test]
1294    fn test_jwk_thumbprint_unsupported_type() {
1295        let jwk = GnapJwk {
1296            kty: "UNKNOWN".to_string(),
1297            kid: None,
1298            crv: None,
1299            x: None,
1300            y: None,
1301            n: None,
1302            e: None,
1303        };
1304        assert!(GnapService::jwk_thumbprint(&jwk).is_err());
1305    }
1306
1307    // ── Client Key Validation ────────────────────────────────────────────
1308
1309    #[test]
1310    fn test_validate_client_key_ec() {
1311        let client = Some(test_client_info(test_ec_jwk()));
1312        let tp = GnapService::validate_client_key(&client).unwrap();
1313        assert!(tp.is_some());
1314    }
1315
1316    #[test]
1317    fn test_validate_client_key_rsa() {
1318        let client = Some(test_client_info(test_rsa_jwk()));
1319        let tp = GnapService::validate_client_key(&client).unwrap();
1320        assert!(tp.is_some());
1321    }
1322
1323    #[test]
1324    fn test_validate_client_key_none() {
1325        let tp = GnapService::validate_client_key(&None).unwrap();
1326        assert!(tp.is_none());
1327    }
1328
1329    #[test]
1330    fn test_validate_client_key_no_key() {
1331        let client = Some(GnapClientInfo {
1332            key: None,
1333            display: None,
1334        });
1335        let tp = GnapService::validate_client_key(&client).unwrap();
1336        assert!(tp.is_none());
1337    }
1338
1339    #[test]
1340    fn test_validate_client_key_invalid_proof_method() {
1341        let client = Some(GnapClientInfo {
1342            key: Some(GnapClientKey {
1343                proof: "invalid_method".to_string(),
1344                jwk: test_ec_jwk(),
1345            }),
1346            display: None,
1347        });
1348        assert!(GnapService::validate_client_key(&client).is_err());
1349    }
1350
1351    #[test]
1352    fn test_validate_client_key_ec_missing_y() {
1353        let mut jwk = test_ec_jwk();
1354        jwk.y = None;
1355        let client = Some(test_client_info(jwk));
1356        assert!(GnapService::validate_client_key(&client).is_err());
1357    }
1358
1359    #[test]
1360    fn test_validate_client_key_rsa_missing_e() {
1361        let mut jwk = test_rsa_jwk();
1362        jwk.e = None;
1363        let client = Some(test_client_info(jwk));
1364        assert!(GnapService::validate_client_key(&client).is_err());
1365    }
1366
1367    #[test]
1368    fn test_validate_client_key_with_proof_test_mode() {
1369        let client = Some(test_client_info(test_ec_jwk()));
1370        let tp = GnapService::validate_client_key_with_proof(&client, None, None).unwrap();
1371        assert!(tp.is_some());
1372    }
1373
1374    // ── Interaction Hash ─────────────────────────────────────────────────
1375
1376    #[test]
1377    fn test_compute_interact_hash_deterministic() {
1378        let h1 = GnapService::compute_interact_hash("cn1", "sn1", "ref1", "/tx");
1379        let h2 = GnapService::compute_interact_hash("cn1", "sn1", "ref1", "/tx");
1380        assert_eq!(h1, h2);
1381    }
1382
1383    #[test]
1384    fn test_compute_interact_hash_different_inputs() {
1385        let h1 = GnapService::compute_interact_hash("cn1", "sn1", "ref1", "/tx");
1386        let h2 = GnapService::compute_interact_hash("cn2", "sn1", "ref1", "/tx");
1387        assert_ne!(h1, h2);
1388    }
1389
1390    // ── Transaction Lifecycle ────────────────────────────────────────────
1391
1392    #[tokio::test]
1393    async fn test_transaction_disabled() {
1394        let mut config = test_config();
1395        config.enabled = false;
1396        let service = GnapService::new(config);
1397        let req = GnapTransactionRequest {
1398            client: None,
1399            interact: None,
1400            access_token: Some(vec![GnapAccessRequest {
1401                access_type: "read".to_string(),
1402                actions: vec![],
1403                locations: vec![],
1404            }]),
1405            subject: None,
1406        };
1407        assert!(service.handle_transaction(req).await.is_err());
1408    }
1409
1410    #[tokio::test]
1411    async fn test_transaction_requires_access_or_subject() {
1412        let service = GnapService::new(test_config());
1413        let req = GnapTransactionRequest {
1414            client: None,
1415            interact: None,
1416            access_token: None,
1417            subject: None,
1418        };
1419        assert!(service.handle_transaction(req).await.is_err());
1420    }
1421
1422    #[tokio::test]
1423    async fn test_transaction_direct_token_issuance() {
1424        let service = GnapService::new(test_config());
1425        let req = GnapTransactionRequest {
1426            client: Some(test_client_info(test_ec_jwk())),
1427            interact: None,
1428            access_token: Some(vec![GnapAccessRequest {
1429                access_type: "read".to_string(),
1430                actions: vec!["list".to_string()],
1431                locations: vec!["https://api.test/resources".to_string()],
1432            }]),
1433            subject: None,
1434        };
1435        let resp = service.handle_transaction(req).await.unwrap();
1436        let obj = resp.as_object().unwrap();
1437        assert!(obj.contains_key("access_token"));
1438        assert!(obj.contains_key("continue"));
1439
1440        let token_obj = obj["access_token"].as_object().unwrap();
1441        assert!(token_obj.contains_key("value"));
1442        assert!(token_obj.contains_key("expires_in"));
1443        assert!(token_obj.contains_key("manage"));
1444        assert_eq!(token_obj.get("key").and_then(|v| v.as_bool()), Some(true));
1445    }
1446
1447    #[tokio::test]
1448    async fn test_transaction_with_interaction() {
1449        let service = GnapService::new(test_config());
1450        let req = GnapTransactionRequest {
1451            client: Some(test_client_info(test_ec_jwk())),
1452            interact: Some(GnapInteractionRequirements {
1453                start: vec!["redirect".to_string()],
1454                finish: Some(GnapInteractionFinish {
1455                    method: "redirect".to_string(),
1456                    uri: "https://client.test/callback".to_string(),
1457                    nonce: "client-nonce-123".to_string(),
1458                }),
1459            }),
1460            access_token: Some(vec![GnapAccessRequest {
1461                access_type: "write".to_string(),
1462                actions: vec![],
1463                locations: vec![],
1464            }]),
1465            subject: None,
1466        };
1467        let resp = service.handle_transaction(req).await.unwrap();
1468        let obj = resp.as_object().unwrap();
1469        assert!(obj.contains_key("interact"));
1470        assert!(obj.contains_key("continue"));
1471
1472        let interact = obj["interact"].as_object().unwrap();
1473        assert!(interact.contains_key("redirect"));
1474        assert!(interact.contains_key("finish"));
1475    }
1476
1477    #[tokio::test]
1478    async fn test_transaction_subject_only() {
1479        let service = GnapService::new(test_config());
1480        let req = GnapTransactionRequest {
1481            client: None,
1482            interact: None,
1483            access_token: None,
1484            subject: Some(GnapSubjectRequest {
1485                sub_id_formats: vec!["opaque".to_string()],
1486                assertion_formats: vec![],
1487            }),
1488        };
1489        let resp = service.handle_transaction(req).await.unwrap();
1490        let obj = resp.as_object().unwrap();
1491        assert!(obj.contains_key("subject"));
1492    }
1493
1494    // ── Approve / Deny / Continue ────────────────────────────────────────
1495
1496    #[tokio::test]
1497    async fn test_approve_and_continue() {
1498        let service = GnapService::new(test_config());
1499
1500        // Create interactive transaction
1501        let req = GnapTransactionRequest {
1502            client: Some(test_client_info(test_ec_jwk())),
1503            interact: Some(GnapInteractionRequirements {
1504                start: vec!["redirect".to_string()],
1505                finish: None,
1506            }),
1507            access_token: Some(vec![GnapAccessRequest {
1508                access_type: "read".to_string(),
1509                actions: vec![],
1510                locations: vec![],
1511            }]),
1512            subject: None,
1513        };
1514        let resp = service.handle_transaction(req).await.unwrap();
1515        let cont = resp["continue"]["access_token"]["value"].as_str().unwrap();
1516
1517        // Get transaction ID from the stored transactions
1518        let txn_id = {
1519            let txns = service.transactions.read().await;
1520            txns.keys().next().unwrap().clone()
1521        };
1522
1523        // Continue before approval → pending
1524        let poll = service
1525            .continue_transaction(&txn_id, cont, None, None)
1526            .await;
1527        assert!(
1528            poll.is_err() || {
1529                let r = poll.unwrap();
1530                r.as_object().unwrap().contains_key("continue")
1531            }
1532        );
1533
1534        // Get updated continuation token
1535        let new_cont = {
1536            let txns = service.transactions.read().await;
1537            txns.get(&txn_id).unwrap().continue_token.clone()
1538        };
1539
1540        // Approve
1541        service
1542            .approve_transaction(&txn_id, Some("user-42"))
1543            .await
1544            .unwrap();
1545
1546        // Continue after approval → get token
1547        let result = service
1548            .continue_transaction(&txn_id, &new_cont, None, None)
1549            .await
1550            .unwrap();
1551        assert!(result.as_object().unwrap().contains_key("access_token"));
1552    }
1553
1554    #[tokio::test]
1555    async fn test_deny_transaction() {
1556        let service = GnapService::new(test_config());
1557        let req = GnapTransactionRequest {
1558            client: None,
1559            interact: Some(GnapInteractionRequirements {
1560                start: vec!["redirect".to_string()],
1561                finish: None,
1562            }),
1563            access_token: Some(vec![GnapAccessRequest {
1564                access_type: "read".to_string(),
1565                actions: vec![],
1566                locations: vec![],
1567            }]),
1568            subject: None,
1569        };
1570        service.handle_transaction(req).await.unwrap();
1571        let txn_id = {
1572            let txns = service.transactions.read().await;
1573            txns.keys().next().unwrap().clone()
1574        };
1575
1576        service.deny_transaction(&txn_id).await.unwrap();
1577
1578        let cont = {
1579            let txns = service.transactions.read().await;
1580            txns.get(&txn_id).unwrap().continue_token.clone()
1581        };
1582        let result = service
1583            .continue_transaction(&txn_id, &cont, None, None)
1584            .await;
1585        assert!(result.is_err());
1586    }
1587
1588    #[tokio::test]
1589    async fn test_continue_invalid_token() {
1590        let service = GnapService::new(test_config());
1591        let req = GnapTransactionRequest {
1592            client: None,
1593            interact: Some(GnapInteractionRequirements {
1594                start: vec!["redirect".to_string()],
1595                finish: None,
1596            }),
1597            access_token: Some(vec![GnapAccessRequest {
1598                access_type: "read".to_string(),
1599                actions: vec![],
1600                locations: vec![],
1601            }]),
1602            subject: None,
1603        };
1604        service.handle_transaction(req).await.unwrap();
1605        let txn_id = {
1606            let txns = service.transactions.read().await;
1607            txns.keys().next().unwrap().clone()
1608        };
1609        let result = service
1610            .continue_transaction(&txn_id, "bad-token", None, None)
1611            .await;
1612        assert!(result.is_err());
1613    }
1614
1615    // ── Token Management ─────────────────────────────────────────────────
1616
1617    #[tokio::test]
1618    async fn test_token_revocation() {
1619        let service = GnapService::new(test_config());
1620        let req = GnapTransactionRequest {
1621            client: None,
1622            interact: None,
1623            access_token: Some(vec![GnapAccessRequest {
1624                access_type: "read".to_string(),
1625                actions: vec![],
1626                locations: vec![],
1627            }]),
1628            subject: None,
1629        };
1630        let resp = service.handle_transaction(req).await.unwrap();
1631        let token_value = resp["access_token"]["value"].as_str().unwrap();
1632
1633        // Small delay so the spawned insert task completes
1634        tokio::task::yield_now().await;
1635        tokio::task::yield_now().await;
1636
1637        // Introspect before revocation
1638        let info = service.introspect_token(token_value).await.unwrap();
1639        assert!(info.is_some());
1640        assert_eq!(info.as_ref().unwrap()["active"], true);
1641
1642        // Revoke
1643        service.revoke_token(token_value).await.unwrap();
1644
1645        // Introspect after revocation
1646        let info = service.introspect_token(token_value).await.unwrap();
1647        assert!(info.is_none());
1648    }
1649
1650    #[tokio::test]
1651    async fn test_token_rotation() {
1652        let service = GnapService::new(test_config());
1653        let req = GnapTransactionRequest {
1654            client: None,
1655            interact: None,
1656            access_token: Some(vec![GnapAccessRequest {
1657                access_type: "write".to_string(),
1658                actions: vec!["create".to_string()],
1659                locations: vec![],
1660            }]),
1661            subject: None,
1662        };
1663        let resp = service.handle_transaction(req).await.unwrap();
1664        let old_token = resp["access_token"]["value"].as_str().unwrap().to_string();
1665
1666        // Wait for spawned insert
1667        tokio::task::yield_now().await;
1668        tokio::task::yield_now().await;
1669
1670        // Rotate
1671        let new_resp = service.rotate_token(&old_token).await.unwrap();
1672        let new_token = new_resp["value"].as_str().unwrap();
1673        assert_ne!(old_token, new_token);
1674
1675        // Old token should be gone
1676        let old_info = service.introspect_token(&old_token).await.unwrap();
1677        assert!(old_info.is_none());
1678
1679        // New token should be active
1680        let new_info = service.introspect_token(new_token).await.unwrap();
1681        assert!(new_info.is_some());
1682        assert_eq!(new_info.unwrap()["active"], true);
1683    }
1684
1685    #[tokio::test]
1686    async fn test_revoke_nonexistent_token() {
1687        let service = GnapService::new(test_config());
1688        assert!(service.revoke_token("nonexistent").await.is_err());
1689    }
1690
1691    #[tokio::test]
1692    async fn test_introspect_nonexistent_token() {
1693        let service = GnapService::new(test_config());
1694        let info = service.introspect_token("nonexistent").await.unwrap();
1695        assert!(info.is_none());
1696    }
1697
1698    // ── Key Binding Verification ─────────────────────────────────────────
1699
1700    #[tokio::test]
1701    async fn test_token_key_binding_check() {
1702        let service = GnapService::new(test_config());
1703        let ec_jwk = test_ec_jwk();
1704        let req = GnapTransactionRequest {
1705            client: Some(test_client_info(ec_jwk.clone())),
1706            interact: None,
1707            access_token: Some(vec![GnapAccessRequest {
1708                access_type: "read".to_string(),
1709                actions: vec![],
1710                locations: vec![],
1711            }]),
1712            subject: None,
1713        };
1714        let resp = service.handle_transaction(req).await.unwrap();
1715        let token_value = resp["access_token"]["value"].as_str().unwrap();
1716
1717        tokio::task::yield_now().await;
1718        tokio::task::yield_now().await;
1719
1720        // Same key → should pass
1721        let ok = service
1722            .validate_token_key_binding(token_value, &ec_jwk)
1723            .await
1724            .unwrap();
1725        assert!(ok);
1726
1727        // Different key → should fail
1728        let other_jwk = test_rsa_jwk();
1729        let bad = service
1730            .validate_token_key_binding(token_value, &other_jwk)
1731            .await
1732            .unwrap();
1733        assert!(!bad);
1734    }
1735
1736    // ── Subject Info ─────────────────────────────────────────────────────
1737
1738    #[test]
1739    fn test_build_subject_response_opaque() {
1740        let req = GnapSubjectRequest {
1741            sub_id_formats: vec!["opaque".to_string()],
1742            assertion_formats: vec![],
1743        };
1744        let resp = GnapService::build_subject_response(&req, Some("user-42"));
1745        let sub_ids = resp["sub_ids"].as_array().unwrap();
1746        assert_eq!(sub_ids.len(), 1);
1747        assert_eq!(sub_ids[0]["format"], "opaque");
1748        assert_eq!(sub_ids[0]["id"], "user-42");
1749    }
1750
1751    #[test]
1752    fn test_build_subject_response_email() {
1753        let req = GnapSubjectRequest {
1754            sub_id_formats: vec!["email".to_string()],
1755            assertion_formats: vec![],
1756        };
1757        let resp = GnapService::build_subject_response(&req, Some("user@example.test"));
1758        let sub_ids = resp["sub_ids"].as_array().unwrap();
1759        assert_eq!(sub_ids.len(), 1);
1760        assert_eq!(sub_ids[0]["format"], "email");
1761    }
1762
1763    #[test]
1764    fn test_build_subject_response_email_not_email() {
1765        let req = GnapSubjectRequest {
1766            sub_id_formats: vec!["email".to_string()],
1767            assertion_formats: vec![],
1768        };
1769        // Subject is not an email → defaults to opaque
1770        let resp = GnapService::build_subject_response(&req, Some("user-42"));
1771        let sub_ids = resp["sub_ids"].as_array().unwrap();
1772        assert_eq!(sub_ids[0]["format"], "opaque");
1773    }
1774
1775    #[test]
1776    fn test_build_subject_response_iss_sub() {
1777        let req = GnapSubjectRequest {
1778            sub_id_formats: vec!["iss_sub".to_string()],
1779            assertion_formats: vec![],
1780        };
1781        let resp = GnapService::build_subject_response(&req, Some("user-42"));
1782        let sub_ids = resp["sub_ids"].as_array().unwrap();
1783        assert_eq!(sub_ids[0]["format"], "iss_sub");
1784        assert_eq!(sub_ids[0]["sub"], "user-42");
1785    }
1786
1787    #[test]
1788    fn test_build_subject_response_no_subject() {
1789        let req = GnapSubjectRequest {
1790            sub_id_formats: vec!["opaque".to_string()],
1791            assertion_formats: vec![],
1792        };
1793        let resp = GnapService::build_subject_response(&req, None);
1794        assert!(!resp.contains_key("sub_ids"));
1795    }
1796
1797    // ── Continuation Token Rotation ──────────────────────────────────────
1798
1799    #[tokio::test]
1800    async fn test_continuation_token_rotates() {
1801        let service = GnapService::new(test_config());
1802        let req = GnapTransactionRequest {
1803            client: None,
1804            interact: Some(GnapInteractionRequirements {
1805                start: vec!["redirect".to_string()],
1806                finish: None,
1807            }),
1808            access_token: Some(vec![GnapAccessRequest {
1809                access_type: "read".to_string(),
1810                actions: vec![],
1811                locations: vec![],
1812            }]),
1813            subject: None,
1814        };
1815        let resp = service.handle_transaction(req).await.unwrap();
1816        let cont1 = resp["continue"]["access_token"]["value"]
1817            .as_str()
1818            .unwrap()
1819            .to_string();
1820
1821        let txn_id = {
1822            let txns = service.transactions.read().await;
1823            txns.keys().next().unwrap().clone()
1824        };
1825
1826        // First continue → token is rotated
1827        let _ = service
1828            .continue_transaction(&txn_id, &cont1, None, None)
1829            .await;
1830
1831        let cont2 = {
1832            let txns = service.transactions.read().await;
1833            txns.get(&txn_id).unwrap().continue_token.clone()
1834        };
1835        assert_ne!(cont1, cont2, "Continuation token must rotate on each use");
1836
1837        // Old token should not work
1838        let reuse = service
1839            .continue_transaction(&txn_id, &cont1, None, None)
1840            .await;
1841        assert!(reuse.is_err(), "Old continuation token must be rejected");
1842    }
1843
1844    // ── Cleanup ──────────────────────────────────────────────────────────
1845
1846    #[tokio::test]
1847    async fn test_cleanup_expired_transactions() {
1848        let mut config = test_config();
1849        config.transaction_lifetime_secs = 1; // 1 second lifetime
1850        let service = GnapService::new(config);
1851
1852        let req = GnapTransactionRequest {
1853            client: None,
1854            interact: Some(GnapInteractionRequirements {
1855                start: vec!["redirect".to_string()],
1856                finish: None,
1857            }),
1858            access_token: Some(vec![GnapAccessRequest {
1859                access_type: "read".to_string(),
1860                actions: vec![],
1861                locations: vec![],
1862            }]),
1863            subject: None,
1864        };
1865        service.handle_transaction(req).await.unwrap();
1866        assert_eq!(service.transactions.read().await.len(), 1);
1867
1868        // Manually backdate the transaction so it appears expired
1869        {
1870            let mut txns = service.transactions.write().await;
1871            for txn in txns.values_mut() {
1872                txn.created_at = txn.created_at.saturating_sub(10);
1873            }
1874        }
1875
1876        service.cleanup_expired_transactions().await;
1877        assert_eq!(service.transactions.read().await.len(), 0);
1878    }
1879
1880    // ── Interaction Hash Verification ────────────────────────────────────
1881
1882    #[tokio::test]
1883    async fn test_interaction_hash_verification() {
1884        let service = GnapService::new(test_config());
1885        let client_nonce = "client-nonce-abc";
1886        let req = GnapTransactionRequest {
1887            client: None,
1888            interact: Some(GnapInteractionRequirements {
1889                start: vec!["redirect".to_string()],
1890                finish: Some(GnapInteractionFinish {
1891                    method: "redirect".to_string(),
1892                    uri: "https://client.test/cb".to_string(),
1893                    nonce: client_nonce.to_string(),
1894                }),
1895            }),
1896            access_token: Some(vec![GnapAccessRequest {
1897                access_type: "read".to_string(),
1898                actions: vec![],
1899                locations: vec![],
1900            }]),
1901            subject: None,
1902        };
1903        service.handle_transaction(req).await.unwrap();
1904
1905        let (txn_id, cont, server_nonce) = {
1906            let txns = service.transactions.read().await;
1907            let (id, txn) = txns.iter().next().unwrap();
1908            (
1909                id.clone(),
1910                txn.continue_token.clone(),
1911                txn.interact_nonce.clone().unwrap(),
1912            )
1913        };
1914
1915        let interact_ref = "test-interact-ref";
1916        let correct_hash = GnapService::compute_interact_hash(
1917            client_nonce,
1918            &server_nonce,
1919            interact_ref,
1920            &service.config.transaction_endpoint,
1921        );
1922
1923        // Wrong hash → fails
1924        let bad = service
1925            .continue_transaction(&txn_id, &cont, Some(interact_ref), Some("bad-hash"))
1926            .await;
1927        assert!(bad.is_err());
1928
1929        // Get fresh continuation token after the failed attempt rotated it
1930        let fresh_cont = {
1931            let txns = service.transactions.read().await;
1932            txns.get(&txn_id).unwrap().continue_token.clone()
1933        };
1934
1935        // Correct hash → succeeds
1936        let good = service
1937            .continue_transaction(
1938                &txn_id,
1939                &fresh_cont,
1940                Some(interact_ref),
1941                Some(&correct_hash),
1942            )
1943            .await;
1944        assert!(good.is_ok());
1945    }
1946
1947    #[test]
1948    fn test_gnap_transaction_request_builder_access() {
1949        let req = GnapTransactionRequest::builder()
1950            .access("read", &["list", "get"], &["https://api.test/resources"])
1951            .build();
1952
1953        assert!(req.client.is_none());
1954        assert!(req.interact.is_none());
1955        let access = req.access_token.unwrap();
1956        assert_eq!(access.len(), 1);
1957        assert_eq!(access[0].access_type, "read");
1958        assert_eq!(access[0].actions, vec!["list", "get"]);
1959    }
1960
1961    #[test]
1962    fn test_gnap_transaction_request_builder_full() {
1963        let jwk = test_ec_jwk();
1964        let req = GnapTransactionRequest::builder()
1965            .client_key(jwk, "test")
1966            .redirect_interaction("https://client.test/cb", "nonce-123")
1967            .access_type("write")
1968            .subject_formats(vec!["opaque".into()])
1969            .build();
1970
1971        assert!(req.client.is_some());
1972        assert!(req.interact.is_some());
1973        assert!(req.access_token.is_some());
1974        assert!(req.subject.is_some());
1975        assert_eq!(req.subject.unwrap().sub_id_formats, vec!["opaque"]);
1976    }
1977
1978    #[test]
1979    fn test_gnap_transaction_request_builder_empty() {
1980        let req = GnapTransactionRequest::builder().build();
1981        assert!(req.client.is_none());
1982        assert!(req.interact.is_none());
1983        assert!(req.access_token.is_none());
1984        assert!(req.subject.is_none());
1985    }
1986}