Skip to main content

auth_framework/protocols/
uma.rs

1//! UMA 2.0 (User-Managed Access) implementation.
2//!
3//! Provides federated authorization where Resource Owners (RO) delegate access
4//! to their protected resources using OAuth 2.0 workflows.
5
6use crate::errors::{AuthError, Result};
7use serde::{Deserialize, Serialize};
8use std::collections::HashMap;
9use std::sync::Arc;
10use tokio::sync::RwLock;
11
12// ── UMA 2.0 Discovery Metadata ─────────────────────────────────────
13
14/// UMA 2.0 Authorization Server discovery metadata (`.well-known/uma2-configuration`).
15///
16/// Defined by UMA 2.0 Grant for OAuth 2.0 Authorization, Section 2.
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct UmaDiscoveryMetadata {
19    /// The base URI of the UMA authorization server.
20    pub issuer: String,
21    /// Protection API Token (PAT) endpoint (OAuth 2.0 token endpoint).
22    pub token_endpoint: String,
23    /// Resource registration endpoint.
24    pub resource_registration_endpoint: String,
25    /// Permission endpoint (where resource servers register permission requests).
26    pub permission_endpoint: String,
27    /// RPT (Requesting Party Token) endpoint (same as token_endpoint).
28    pub rpt_endpoint: String,
29    /// Introspection endpoint for RPTs (RFC 7662).
30    pub introspection_endpoint: String,
31    /// Claims interaction endpoint for interactive claims gathering.
32    pub claims_interaction_endpoint: String,
33    /// Supported UMA grant types.
34    pub grant_types_supported: Vec<String>,
35    /// Supported token endpoint authentication methods.
36    pub token_endpoint_auth_methods_supported: Vec<String>,
37    /// Supported UMA profiles.
38    pub uma_profiles_supported: Vec<String>,
39}
40
41impl UmaDiscoveryMetadata {
42    /// Create discovery metadata for a given issuer base URL.
43    pub fn new(issuer: impl Into<String>) -> Self {
44        let base = issuer.into();
45        Self {
46            token_endpoint: format!("{base}/oauth/token"),
47            resource_registration_endpoint: format!("{base}/uma/resource_set"),
48            permission_endpoint: format!("{base}/uma/permission"),
49            rpt_endpoint: format!("{base}/oauth/token"),
50            introspection_endpoint: format!("{base}/oauth/introspect"),
51            claims_interaction_endpoint: format!("{base}/uma/claims"),
52            grant_types_supported: vec!["urn:ietf:params:oauth:grant-type:uma-ticket".to_string()],
53            token_endpoint_auth_methods_supported: vec![
54                "client_secret_basic".to_string(),
55                "client_secret_post".to_string(),
56            ],
57            uma_profiles_supported: vec![],
58            issuer: base,
59        }
60    }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize)]
64pub struct UmaConfig {
65    pub enabled: bool,
66    pub pat_lifetime: u64, // Protection API Token lifetime in seconds
67    pub rpt_lifetime: u64, // Requesting Party Token lifetime in seconds
68    pub claims_interaction_endpoint: String,
69    /// Permission ticket lifetime in seconds (default: 300 = 5 minutes)
70    pub ticket_lifetime: u64,
71}
72
73impl Default for UmaConfig {
74    fn default() -> Self {
75        Self {
76            enabled: false,
77            pat_lifetime: 3600,
78            rpt_lifetime: 3600,
79            claims_interaction_endpoint: "/api/uma/claims".to_string(),
80            ticket_lifetime: 300,
81        }
82    }
83}
84
85/// Thread-safe UMA service
86pub struct UmaService {
87    config: UmaConfig,
88    resource_sets: Arc<RwLock<HashMap<String, UmaResourceSet>>>,
89    permission_tickets: Arc<RwLock<HashMap<String, PermissionTicket>>>,
90}
91
92#[derive(Debug, Clone, Serialize, Deserialize)]
93pub struct UmaResourceSet {
94    pub id: String,
95    pub name: String,
96    pub scopes: Vec<String>,
97    pub owner_id: String,
98    /// Optional policy: required claims for access (claim_name → expected_value)
99    pub required_claims: HashMap<String, String>,
100}
101
102/// A permission ticket issued when a resource server encounters an unauthorized request
103#[derive(Debug, Clone, Serialize, Deserialize)]
104struct PermissionTicket {
105    pub ticket: String,
106    pub resource_id: String,
107    pub requested_scopes: Vec<String>,
108    pub created_at: u64,
109}
110
111impl UmaService {
112    pub fn new(config: UmaConfig) -> Self {
113        Self {
114            config,
115            resource_sets: Arc::new(RwLock::new(HashMap::new())),
116            permission_tickets: Arc::new(RwLock::new(HashMap::new())),
117        }
118    }
119
120    /// Registers a resource set as part of the Resource Registration API (UMA §3.1).
121    pub async fn register_resource_set(&self, mut resource_set: UmaResourceSet) -> Result<String> {
122        if !self.config.enabled {
123            return Err(AuthError::config("UMA 2.0 protocol is currently disabled"));
124        }
125
126        if resource_set.id.is_empty() {
127            resource_set.id = uuid::Uuid::new_v4().to_string();
128        }
129
130        let id = resource_set.id.clone();
131        self.resource_sets
132            .write()
133            .await
134            .insert(id.clone(), resource_set);
135        Ok(id)
136    }
137
138    /// Create a permission ticket when a resource server encounters an unauthorized request (UMA §3.2).
139    pub async fn create_permission_ticket(
140        &self,
141        resource_id: &str,
142        requested_scopes: Vec<String>,
143    ) -> Result<String> {
144        if !self.config.enabled {
145            return Err(AuthError::config("UMA 2.0 protocol is currently disabled"));
146        }
147
148        // Verify the resource exists
149        let resources = self.resource_sets.read().await;
150        let resource = resources
151            .get(resource_id)
152            .ok_or_else(|| AuthError::validation("Resource set not found"))?;
153
154        // Verify requested scopes are valid for this resource
155        for scope in &requested_scopes {
156            if !resource.scopes.contains(scope) {
157                return Err(AuthError::validation(&format!(
158                    "Scope '{}' is not valid for resource '{}'",
159                    scope, resource_id
160                )));
161            }
162        }
163
164        let ticket = format!("urn:uma:ticket:{}", uuid::Uuid::new_v4());
165        let now = std::time::SystemTime::now()
166            .duration_since(std::time::UNIX_EPOCH)
167            .unwrap_or_default()
168            .as_secs();
169
170        let permission = PermissionTicket {
171            ticket: ticket.clone(),
172            resource_id: resource_id.to_string(),
173            requested_scopes,
174            created_at: now,
175        };
176
177        self.permission_tickets
178            .write()
179            .await
180            .insert(ticket.clone(), permission);
181
182        Ok(ticket)
183    }
184
185    /// Request an RPT (Requesting Party Token) by presenting a permission ticket and claims (UMA §3.3).
186    pub async fn request_rpt(
187        &self,
188        ticket: &str,
189        claims: Option<HashMap<String, String>>,
190    ) -> Result<String> {
191        if !self.config.enabled {
192            return Err(AuthError::config("UMA 2.0 protocol is currently disabled"));
193        }
194
195        if ticket.is_empty() {
196            return Err(AuthError::validation("Missing permission ticket"));
197        }
198
199        // Look up the permission ticket
200        let tickets = self.permission_tickets.read().await;
201        let permission = tickets
202            .get(ticket)
203            .ok_or_else(|| AuthError::validation("Permission ticket not found or expired"))?;
204
205        // Enforce ticket expiration
206        let now = std::time::SystemTime::now()
207            .duration_since(std::time::UNIX_EPOCH)
208            .unwrap_or_default()
209            .as_secs();
210        if now.saturating_sub(permission.created_at) > self.config.ticket_lifetime {
211            drop(tickets);
212            self.permission_tickets.write().await.remove(ticket);
213            return Err(AuthError::validation("Permission ticket has expired"));
214        };
215
216        // Look up the resource to evaluate policies
217        let resources = self.resource_sets.read().await;
218        let resource = resources.get(&permission.resource_id).ok_or_else(|| {
219            AuthError::internal("Resource for permission ticket no longer exists")
220        })?;
221
222        // Evaluate claims against resource policies
223        if !resource.required_claims.is_empty() {
224            let provided_claims = claims.as_ref().ok_or_else(|| {
225                AuthError::validation(&format!(
226                    "UMA need_info: Redirect to {} with ticket {}",
227                    self.config.claims_interaction_endpoint, ticket
228                ))
229            })?;
230
231            for (required_claim, expected_value) in &resource.required_claims {
232                match provided_claims.get(required_claim) {
233                    Some(actual_value) if actual_value == expected_value => {}
234                    Some(_) => {
235                        return Err(AuthError::validation(&format!(
236                            "Claim '{}' does not match required policy",
237                            required_claim
238                        )));
239                    }
240                    None => {
241                        return Err(AuthError::validation(&format!(
242                            "UMA need_info: Missing required claim '{}'",
243                            required_claim
244                        )));
245                    }
246                }
247            }
248        }
249
250        // Claims satisfied — issue RPT
251        let rpt = format!("urn:uma:rpt:{}", uuid::Uuid::new_v4());
252
253        // Remove the consumed permission ticket
254        drop(tickets);
255        drop(resources);
256        self.permission_tickets.write().await.remove(ticket);
257
258        Ok(rpt)
259    }
260
261    /// List registered resource sets for an owner
262    pub async fn list_resource_sets(&self, owner_id: &str) -> Result<Vec<UmaResourceSet>> {
263        if !self.config.enabled {
264            return Err(AuthError::config("UMA 2.0 protocol is currently disabled"));
265        }
266
267        let resources = self.resource_sets.read().await;
268        Ok(resources
269            .values()
270            .filter(|r| r.owner_id == owner_id)
271            .cloned()
272            .collect())
273    }
274
275    /// Delete a resource set
276    pub async fn delete_resource_set(&self, resource_id: &str, owner_id: &str) -> Result<()> {
277        if !self.config.enabled {
278            return Err(AuthError::config("UMA 2.0 protocol is currently disabled"));
279        }
280
281        let mut resources = self.resource_sets.write().await;
282        if let Some(resource) = resources.get(resource_id) {
283            if resource.owner_id != owner_id {
284                return Err(AuthError::validation(
285                    "Only the resource owner can delete a resource set",
286                ));
287            }
288            resources.remove(resource_id);
289            Ok(())
290        } else {
291            Err(AuthError::validation("Resource set not found"))
292        }
293    }
294
295    /// Remove expired permission tickets from the in-memory store.
296    pub async fn cleanup_expired_tickets(&self) {
297        let now = std::time::SystemTime::now()
298            .duration_since(std::time::UNIX_EPOCH)
299            .unwrap_or_default()
300            .as_secs();
301        let lifetime = self.config.ticket_lifetime;
302        self.permission_tickets
303            .write()
304            .await
305            .retain(|_, t| now.saturating_sub(t.created_at) <= lifetime);
306    }
307
308    /// Get the UMA discovery metadata for this service.
309    pub fn discovery_metadata(&self, issuer: &str) -> UmaDiscoveryMetadata {
310        let mut meta = UmaDiscoveryMetadata::new(issuer);
311        meta.claims_interaction_endpoint = self.config.claims_interaction_endpoint.clone();
312        meta
313    }
314
315    /// Count of active permission tickets.
316    pub async fn ticket_count(&self) -> usize {
317        self.permission_tickets.read().await.len()
318    }
319
320    /// Count of registered resource sets.
321    pub async fn resource_count(&self) -> usize {
322        self.resource_sets.read().await.len()
323    }
324
325    /// Get a resource set by ID.
326    pub async fn get_resource_set(&self, resource_id: &str) -> Option<UmaResourceSet> {
327        self.resource_sets.read().await.get(resource_id).cloned()
328    }
329
330    /// Update a resource set (owner must match).
331    pub async fn update_resource_set(
332        &self,
333        resource_id: &str,
334        owner_id: &str,
335        name: Option<String>,
336        scopes: Option<Vec<String>>,
337        required_claims: Option<HashMap<String, String>>,
338    ) -> Result<()> {
339        if !self.config.enabled {
340            return Err(AuthError::config("UMA 2.0 protocol is currently disabled"));
341        }
342
343        let mut resources = self.resource_sets.write().await;
344        let resource = resources
345            .get_mut(resource_id)
346            .ok_or_else(|| AuthError::validation("Resource set not found"))?;
347
348        if resource.owner_id != owner_id {
349            return Err(AuthError::validation(
350                "Only the resource owner can update a resource set",
351            ));
352        }
353
354        if let Some(n) = name {
355            resource.name = n;
356        }
357        if let Some(s) = scopes {
358            resource.scopes = s;
359        }
360        if let Some(rc) = required_claims {
361            resource.required_claims = rc;
362        }
363        Ok(())
364    }
365}
366
367// ── Protection API Token (PAT) ──────────────────────────────────────
368
369/// A PAT (Protection API Token) record.
370///
371/// PATs are OAuth 2.0 access tokens with the `uma_protection` scope that
372/// resource servers use to access the UMA protection API.
373#[derive(Debug, Clone, Serialize, Deserialize)]
374pub struct Pat {
375    /// The PAT access token value.
376    pub access_token: String,
377    /// Client ID of the resource server that owns this PAT.
378    pub client_id: String,
379    /// When the PAT was issued (UNIX timestamp).
380    pub issued_at: u64,
381    /// When the PAT expires (UNIX timestamp).
382    pub expires_at: u64,
383}
384
385impl Pat {
386    /// Check if PAT has expired.
387    pub fn is_expired(&self) -> bool {
388        let now = std::time::SystemTime::now()
389            .duration_since(std::time::UNIX_EPOCH)
390            .unwrap_or_default()
391            .as_secs();
392        now >= self.expires_at
393    }
394}
395
396/// In-memory PAT store for managing Protection API Tokens.
397pub struct PatStore {
398    tokens: Arc<RwLock<HashMap<String, Pat>>>,
399}
400
401impl PatStore {
402    /// Create a new empty PAT store.
403    pub fn new() -> Self {
404        Self {
405            tokens: Arc::new(RwLock::new(HashMap::new())),
406        }
407    }
408
409    /// Issue a new PAT for a resource server.
410    pub async fn issue(&self, client_id: &str, lifetime_secs: u64) -> Pat {
411        let now = std::time::SystemTime::now()
412            .duration_since(std::time::UNIX_EPOCH)
413            .unwrap_or_default()
414            .as_secs();
415        let pat = Pat {
416            access_token: format!("pat_{}", uuid::Uuid::new_v4()),
417            client_id: client_id.to_string(),
418            issued_at: now,
419            expires_at: now + lifetime_secs,
420        };
421        self.tokens
422            .write()
423            .await
424            .insert(pat.access_token.clone(), pat.clone());
425        pat
426    }
427
428    /// Validate a PAT and return its record if valid.
429    pub async fn validate(&self, token: &str) -> Result<Pat> {
430        let tokens = self.tokens.read().await;
431        let pat = tokens
432            .get(token)
433            .ok_or_else(|| AuthError::invalid_credential("PAT", "Invalid or unknown PAT"))?;
434        if pat.is_expired() {
435            return Err(AuthError::invalid_credential("PAT", "PAT has expired"));
436        }
437        Ok(pat.clone())
438    }
439
440    /// Revoke a PAT.
441    pub async fn revoke(&self, token: &str) -> bool {
442        self.tokens.write().await.remove(token).is_some()
443    }
444
445    /// Remove expired PATs.
446    pub async fn cleanup_expired(&self) {
447        self.tokens.write().await.retain(|_, p| !p.is_expired());
448    }
449
450    /// Count of stored PATs.
451    pub async fn count(&self) -> usize {
452        self.tokens.read().await.len()
453    }
454}
455
456impl Default for PatStore {
457    fn default() -> Self {
458        Self::new()
459    }
460}
461
462// ── RPT Introspection ───────────────────────────────────────────────
463
464/// Result of RPT (Requesting Party Token) introspection (RFC 7662 + UMA 2.0).
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct RptIntrospectionResponse {
467    /// Whether the RPT is currently active.
468    pub active: bool,
469    /// The permissions granted by this RPT.
470    #[serde(default)]
471    pub permissions: Vec<RptPermission>,
472    /// When the RPT expires (UNIX timestamp).
473    #[serde(default)]
474    pub exp: Option<u64>,
475    /// When the RPT was issued (UNIX timestamp).
476    #[serde(default)]
477    pub iat: Option<u64>,
478}
479
480/// A single permission within an RPT.
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct RptPermission {
483    /// The resource set ID this permission applies to.
484    pub resource_id: String,
485    /// The scopes granted.
486    pub scopes: Vec<String>,
487}
488
489/// In-memory RPT store with introspection support.
490pub struct RptStore {
491    tokens: Arc<RwLock<HashMap<String, RptIntrospectionResponse>>>,
492}
493
494impl RptStore {
495    /// Create a new RPT store.
496    pub fn new() -> Self {
497        Self {
498            tokens: Arc::new(RwLock::new(HashMap::new())),
499        }
500    }
501
502    /// Register an issued RPT with its permissions.
503    pub async fn register(
504        &self,
505        rpt: &str,
506        resource_id: &str,
507        scopes: Vec<String>,
508        lifetime_secs: u64,
509    ) {
510        let now = std::time::SystemTime::now()
511            .duration_since(std::time::UNIX_EPOCH)
512            .unwrap_or_default()
513            .as_secs();
514        let resp = RptIntrospectionResponse {
515            active: true,
516            permissions: vec![RptPermission {
517                resource_id: resource_id.to_string(),
518                scopes,
519            }],
520            exp: Some(now + lifetime_secs),
521            iat: Some(now),
522        };
523        self.tokens.write().await.insert(rpt.to_string(), resp);
524    }
525
526    /// Introspect an RPT (RFC 7662).
527    pub async fn introspect(&self, rpt: &str) -> RptIntrospectionResponse {
528        let tokens = self.tokens.read().await;
529        match tokens.get(rpt) {
530            Some(resp) => {
531                let now = std::time::SystemTime::now()
532                    .duration_since(std::time::UNIX_EPOCH)
533                    .unwrap_or_default()
534                    .as_secs();
535                let expired = resp.exp.is_some_and(|exp| now >= exp);
536                if expired {
537                    RptIntrospectionResponse {
538                        active: false,
539                        permissions: vec![],
540                        exp: resp.exp,
541                        iat: resp.iat,
542                    }
543                } else {
544                    resp.clone()
545                }
546            }
547            None => RptIntrospectionResponse {
548                active: false,
549                permissions: vec![],
550                exp: None,
551                iat: None,
552            },
553        }
554    }
555
556    /// Revoke an RPT.
557    pub async fn revoke(&self, rpt: &str) -> bool {
558        self.tokens.write().await.remove(rpt).is_some()
559    }
560
561    /// Remove expired RPTs.
562    pub async fn cleanup_expired(&self) {
563        let now = std::time::SystemTime::now()
564            .duration_since(std::time::UNIX_EPOCH)
565            .unwrap_or_default()
566            .as_secs();
567        self.tokens
568            .write()
569            .await
570            .retain(|_, r| r.exp.map_or(true, |exp| now < exp));
571    }
572
573    /// Number of stored RPTs.
574    pub async fn count(&self) -> usize {
575        self.tokens.read().await.len()
576    }
577}
578
579impl Default for RptStore {
580    fn default() -> Self {
581        Self::new()
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    fn enabled_config() -> UmaConfig {
590        UmaConfig {
591            enabled: true,
592            ..UmaConfig::default()
593        }
594    }
595
596    fn sample_resource(owner: &str) -> UmaResourceSet {
597        UmaResourceSet {
598            id: String::new(),
599            name: "Photos".to_string(),
600            scopes: vec!["read".to_string(), "write".to_string()],
601            owner_id: owner.to_string(),
602            required_claims: HashMap::new(),
603        }
604    }
605
606    #[tokio::test]
607    async fn test_register_resource_set() {
608        let svc = UmaService::new(enabled_config());
609        let id = svc
610            .register_resource_set(sample_resource("alice"))
611            .await
612            .unwrap();
613        assert!(!id.is_empty());
614    }
615
616    #[tokio::test]
617    async fn test_list_resource_sets_filters_by_owner() {
618        let svc = UmaService::new(enabled_config());
619        svc.register_resource_set(sample_resource("alice"))
620            .await
621            .unwrap();
622        svc.register_resource_set(sample_resource("bob"))
623            .await
624            .unwrap();
625        let alice_rs = svc.list_resource_sets("alice").await.unwrap();
626        assert_eq!(alice_rs.len(), 1);
627        assert_eq!(alice_rs[0].owner_id, "alice");
628    }
629
630    #[tokio::test]
631    async fn test_delete_resource_set() {
632        let svc = UmaService::new(enabled_config());
633        let id = svc
634            .register_resource_set(sample_resource("alice"))
635            .await
636            .unwrap();
637        svc.delete_resource_set(&id, "alice").await.unwrap();
638        let resources = svc.list_resource_sets("alice").await.unwrap();
639        assert!(resources.is_empty());
640    }
641
642    #[tokio::test]
643    async fn test_delete_resource_wrong_owner_rejected() {
644        let svc = UmaService::new(enabled_config());
645        let id = svc
646            .register_resource_set(sample_resource("alice"))
647            .await
648            .unwrap();
649        let result = svc.delete_resource_set(&id, "eve").await;
650        assert!(result.is_err());
651    }
652
653    #[tokio::test]
654    async fn test_create_permission_ticket() {
655        let svc = UmaService::new(enabled_config());
656        let id = svc
657            .register_resource_set(sample_resource("alice"))
658            .await
659            .unwrap();
660        let ticket = svc
661            .create_permission_ticket(&id, vec!["read".to_string()])
662            .await
663            .unwrap();
664        assert!(!ticket.is_empty());
665    }
666
667    #[tokio::test]
668    async fn test_permission_ticket_invalid_scope_rejected() {
669        let svc = UmaService::new(enabled_config());
670        let id = svc
671            .register_resource_set(sample_resource("alice"))
672            .await
673            .unwrap();
674        let result = svc
675            .create_permission_ticket(&id, vec!["delete".to_string()])
676            .await;
677        assert!(result.is_err());
678    }
679
680    #[tokio::test]
681    async fn test_request_rpt_with_valid_ticket() {
682        let svc = UmaService::new(enabled_config());
683        let id = svc
684            .register_resource_set(sample_resource("alice"))
685            .await
686            .unwrap();
687        let ticket = svc
688            .create_permission_ticket(&id, vec!["read".to_string()])
689            .await
690            .unwrap();
691        let rpt = svc.request_rpt(&ticket, None).await.unwrap();
692        assert!(!rpt.is_empty());
693    }
694
695    #[tokio::test]
696    async fn test_request_rpt_invalid_ticket_rejected() {
697        let svc = UmaService::new(enabled_config());
698        let result = svc.request_rpt("bogus-ticket", None).await;
699        assert!(result.is_err());
700    }
701
702    #[tokio::test]
703    async fn test_disabled_service_rejects() {
704        let svc = UmaService::new(UmaConfig::default()); // enabled: false
705        let result = svc.register_resource_set(sample_resource("alice")).await;
706        assert!(result.is_err());
707    }
708
709    #[tokio::test]
710    async fn test_required_claims_enforced() {
711        let svc = UmaService::new(enabled_config());
712        let mut rs = sample_resource("alice");
713        rs.required_claims
714            .insert("country".to_string(), "US".to_string());
715        let id = svc.register_resource_set(rs).await.unwrap();
716        let ticket = svc
717            .create_permission_ticket(&id, vec!["read".to_string()])
718            .await
719            .unwrap();
720
721        // Without claims → should fail
722        let result = svc.request_rpt(&ticket, None).await;
723        assert!(result.is_err());
724
725        // Re-create ticket (old one consumed)
726        let ticket2 = svc
727            .create_permission_ticket(&id, vec!["read".to_string()])
728            .await
729            .unwrap();
730        // With correct claims → should succeed
731        let mut claims = HashMap::new();
732        claims.insert("country".to_string(), "US".to_string());
733        let rpt = svc.request_rpt(&ticket2, Some(claims)).await.unwrap();
734        assert!(!rpt.is_empty());
735    }
736
737    // ── UMA Discovery Metadata ──────────────────────────────────
738
739    #[test]
740    fn test_uma_discovery_metadata() {
741        let meta = UmaDiscoveryMetadata::new("https://auth.example.com");
742        assert_eq!(meta.issuer, "https://auth.example.com");
743        assert_eq!(meta.token_endpoint, "https://auth.example.com/oauth/token");
744        assert_eq!(
745            meta.resource_registration_endpoint,
746            "https://auth.example.com/uma/resource_set"
747        );
748        assert_eq!(
749            meta.permission_endpoint,
750            "https://auth.example.com/uma/permission"
751        );
752        assert_eq!(
753            meta.introspection_endpoint,
754            "https://auth.example.com/oauth/introspect"
755        );
756        assert!(
757            meta.grant_types_supported
758                .contains(&"urn:ietf:params:oauth:grant-type:uma-ticket".to_string())
759        );
760    }
761
762    #[test]
763    fn test_uma_discovery_serialization() {
764        let meta = UmaDiscoveryMetadata::new("https://auth.example.com");
765        let json = serde_json::to_value(&meta).unwrap();
766        assert_eq!(json["issuer"], "https://auth.example.com");
767        assert!(json["grant_types_supported"].as_array().unwrap().len() > 0);
768    }
769
770    #[tokio::test]
771    async fn test_uma_service_discovery() {
772        let svc = UmaService::new(enabled_config());
773        let meta = svc.discovery_metadata("https://auth.example.com");
774        assert_eq!(meta.claims_interaction_endpoint, "/api/uma/claims");
775    }
776
777    // ── Resource Set Updates ────────────────────────────────────
778
779    #[tokio::test]
780    async fn test_update_resource_set() {
781        let svc = UmaService::new(enabled_config());
782        let id = svc
783            .register_resource_set(sample_resource("alice"))
784            .await
785            .unwrap();
786        svc.update_resource_set(
787            &id,
788            "alice",
789            Some("Updated Photos".to_string()),
790            Some(vec![
791                "read".to_string(),
792                "write".to_string(),
793                "delete".to_string(),
794            ]),
795            None,
796        )
797        .await
798        .unwrap();
799        let rs = svc.get_resource_set(&id).await.unwrap();
800        assert_eq!(rs.name, "Updated Photos");
801        assert_eq!(rs.scopes.len(), 3);
802    }
803
804    #[tokio::test]
805    async fn test_update_resource_set_wrong_owner() {
806        let svc = UmaService::new(enabled_config());
807        let id = svc
808            .register_resource_set(sample_resource("alice"))
809            .await
810            .unwrap();
811        assert!(
812            svc.update_resource_set(&id, "eve", Some("Hacked".to_string()), None, None)
813                .await
814                .is_err()
815        );
816    }
817
818    #[tokio::test]
819    async fn test_resource_count() {
820        let svc = UmaService::new(enabled_config());
821        assert_eq!(svc.resource_count().await, 0);
822        svc.register_resource_set(sample_resource("alice"))
823            .await
824            .unwrap();
825        assert_eq!(svc.resource_count().await, 1);
826    }
827
828    // ── PAT Store ───────────────────────────────────────────────
829
830    #[tokio::test]
831    async fn test_pat_issue_and_validate() {
832        let store = PatStore::new();
833        let pat = store.issue("client1", 3600).await;
834        assert!(pat.access_token.starts_with("pat_"));
835        assert_eq!(pat.client_id, "client1");
836        assert!(!pat.is_expired());
837
838        let validated = store.validate(&pat.access_token).await.unwrap();
839        assert_eq!(validated.client_id, "client1");
840    }
841
842    #[tokio::test]
843    async fn test_pat_validate_unknown() {
844        let store = PatStore::new();
845        assert!(store.validate("bogus").await.is_err());
846    }
847
848    #[tokio::test]
849    async fn test_pat_revoke() {
850        let store = PatStore::new();
851        let pat = store.issue("client1", 3600).await;
852        assert!(store.revoke(&pat.access_token).await);
853        assert!(!store.revoke(&pat.access_token).await);
854        assert!(store.validate(&pat.access_token).await.is_err());
855    }
856
857    #[tokio::test]
858    async fn test_pat_count() {
859        let store = PatStore::new();
860        store.issue("c1", 3600).await;
861        store.issue("c2", 3600).await;
862        assert_eq!(store.count().await, 2);
863    }
864
865    // ── RPT Store & Introspection ───────────────────────────────
866
867    #[tokio::test]
868    async fn test_rpt_register_and_introspect() {
869        let store = RptStore::new();
870        store
871            .register("rpt-123", "resource-1", vec!["read".to_string()], 3600)
872            .await;
873        let resp = store.introspect("rpt-123").await;
874        assert!(resp.active);
875        assert_eq!(resp.permissions.len(), 1);
876        assert_eq!(resp.permissions[0].resource_id, "resource-1");
877        assert_eq!(resp.permissions[0].scopes, vec!["read"]);
878    }
879
880    #[tokio::test]
881    async fn test_rpt_introspect_unknown() {
882        let store = RptStore::new();
883        let resp = store.introspect("unknown").await;
884        assert!(!resp.active);
885        assert!(resp.permissions.is_empty());
886    }
887
888    #[tokio::test]
889    async fn test_rpt_revoke() {
890        let store = RptStore::new();
891        store
892            .register("rpt-456", "res-1", vec!["write".to_string()], 3600)
893            .await;
894        assert!(store.revoke("rpt-456").await);
895        let resp = store.introspect("rpt-456").await;
896        assert!(!resp.active);
897    }
898
899    #[tokio::test]
900    async fn test_rpt_count() {
901        let store = RptStore::new();
902        store
903            .register("rpt-1", "r1", vec!["read".to_string()], 3600)
904            .await;
905        store
906            .register("rpt-2", "r2", vec!["write".to_string()], 3600)
907            .await;
908        assert_eq!(store.count().await, 2);
909    }
910
911    #[tokio::test]
912    async fn test_rpt_introspection_serialization() {
913        let resp = RptIntrospectionResponse {
914            active: true,
915            permissions: vec![RptPermission {
916                resource_id: "res-1".to_string(),
917                scopes: vec!["read".to_string()],
918            }],
919            exp: Some(9999999999),
920            iat: Some(1000000000),
921        };
922        let json = serde_json::to_value(&resp).unwrap();
923        assert_eq!(json["active"], true);
924        assert_eq!(json["permissions"][0]["resource_id"], "res-1");
925    }
926}