Skip to main content

auth_framework/protocols/
indieauth.rs

1//! IndieAuth protocol support (OAuth 2.0–based identity layer for the IndieWeb).
2//!
3//! Provides authorization endpoint discovery, PKCE-secured authorization code
4//! exchange, and profile URL verification per the IndieAuth specification.
5//!
6//! # References
7//!
8//! - [IndieAuth spec](https://indieauth.spec.indieweb.org/)
9
10use crate::errors::{AuthError, Result};
11use base64::Engine;
12use serde::{Deserialize, Serialize};
13use sha2::{Digest, Sha256};
14use std::collections::HashMap;
15use std::sync::Arc;
16use std::time::{Duration, SystemTime, UNIX_EPOCH};
17use tokio::sync::RwLock;
18
19/// IndieAuth client configuration.
20#[derive(Debug, Clone, Serialize, Deserialize)]
21pub struct IndieAuthConfig {
22    /// Client application URL (used as client_id).
23    pub client_id: String,
24    /// Redirect URI for the authorization callback.
25    pub redirect_uri: String,
26    /// Authorization endpoint URL (discovered from the user's profile).
27    pub authorization_endpoint: Option<String>,
28    /// Token endpoint URL (discovered from the user's profile).
29    pub token_endpoint: Option<String>,
30}
31
32/// An IndieAuth authorization request.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct IndieAuthRequest {
35    /// Type of response ("code").
36    pub response_type: String,
37    /// Client identifier (URL).
38    pub client_id: String,
39    /// Redirect URI.
40    pub redirect_uri: String,
41    /// State parameter for CSRF protection.
42    pub state: String,
43    /// PKCE code challenge.
44    pub code_challenge: String,
45    /// PKCE code challenge method ("S256").
46    pub code_challenge_method: String,
47    /// Profile URL the user is authenticating as.
48    pub me: Option<String>,
49    /// Requested scopes (e.g., "profile", "create", "update").
50    pub scope: Option<String>,
51}
52
53/// IndieAuth authorization response (callback parameters).
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct IndieAuthCallback {
56    /// Authorization code.
57    pub code: String,
58    /// State parameter (must match original request).
59    pub state: String,
60    /// The URL the user authenticated as (canonical form).
61    pub me: Option<String>,
62}
63
64/// IndieAuth token response.
65#[derive(Debug, Clone, Serialize, Deserialize)]
66pub struct IndieAuthTokenResponse {
67    /// The canonical profile URL.
68    pub me: String,
69    /// Access token (if scopes were requested).
70    pub access_token: Option<String>,
71    /// Token type ("Bearer").
72    pub token_type: Option<String>,
73    /// Granted scope.
74    pub scope: Option<String>,
75    /// Profile information.
76    pub profile: Option<IndieAuthProfile>,
77}
78
79/// Profile information returned by IndieAuth.
80#[derive(Debug, Clone, Serialize, Deserialize)]
81pub struct IndieAuthProfile {
82    pub name: Option<String>,
83    pub url: Option<String>,
84    pub photo: Option<String>,
85    pub email: Option<String>,
86}
87
88/// IndieAuth client for building authorization flows.
89pub struct IndieAuthClient {
90    config: IndieAuthConfig,
91}
92
93impl IndieAuthClient {
94    /// Create a new IndieAuth client.
95    pub fn new(config: IndieAuthConfig) -> Result<Self> {
96        if config.client_id.is_empty() {
97            return Err(AuthError::validation("client_id cannot be empty"));
98        }
99        if config.redirect_uri.is_empty() {
100            return Err(AuthError::validation("redirect_uri cannot be empty"));
101        }
102        Ok(Self { config })
103    }
104
105    /// Generate a PKCE code verifier (43–128 character random string).
106    pub fn generate_code_verifier() -> Result<String> {
107        use ring::rand::{SecureRandom, SystemRandom};
108        let rng = SystemRandom::new();
109        let mut buf = [0u8; 32];
110        rng.fill(&mut buf)
111            .map_err(|_| AuthError::crypto("Failed to generate code verifier".to_string()))?;
112        Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(buf))
113    }
114
115    /// Compute the S256 code challenge from a code verifier.
116    pub fn compute_code_challenge(verifier: &str) -> String {
117        let hash = Sha256::digest(verifier.as_bytes());
118        base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(hash)
119    }
120
121    /// Build an authorization request URL.
122    pub fn build_authorization_url(
123        &self,
124        code_verifier: &str,
125        scope: Option<&str>,
126        me: Option<&str>,
127    ) -> Result<(IndieAuthRequest, String)> {
128        let auth_endpoint = self
129            .config
130            .authorization_endpoint
131            .as_deref()
132            .ok_or_else(|| {
133                AuthError::config("Authorization endpoint not discovered yet".to_string())
134            })?;
135
136        let state = generate_state()?;
137        let code_challenge = Self::compute_code_challenge(code_verifier);
138
139        let request = IndieAuthRequest {
140            response_type: "code".to_string(),
141            client_id: self.config.client_id.clone(),
142            redirect_uri: self.config.redirect_uri.clone(),
143            state: state.clone(),
144            code_challenge: code_challenge.clone(),
145            code_challenge_method: "S256".to_string(),
146            me: me.map(|s| s.to_string()),
147            scope: scope.map(|s| s.to_string()),
148        };
149
150        let mut url = format!(
151            "{endpoint}?response_type=code&client_id={cid}&redirect_uri={ruri}&state={state}&code_challenge={cc}&code_challenge_method=S256",
152            endpoint = auth_endpoint,
153            cid = urlencoding::encode(&self.config.client_id),
154            ruri = urlencoding::encode(&self.config.redirect_uri),
155            state = urlencoding::encode(&state),
156            cc = urlencoding::encode(&code_challenge),
157        );
158
159        if let Some(s) = scope {
160            url.push_str(&format!("&scope={}", urlencoding::encode(s)));
161        }
162        if let Some(m) = me {
163            url.push_str(&format!("&me={}", urlencoding::encode(m)));
164        }
165
166        Ok((request, url))
167    }
168
169    /// Verify a callback matches the original request state.
170    pub fn verify_callback(
171        &self,
172        callback: &IndieAuthCallback,
173        expected_state: &str,
174    ) -> Result<()> {
175        if callback.state != expected_state {
176            return Err(AuthError::validation("State parameter mismatch"));
177        }
178        if callback.code.is_empty() {
179            return Err(AuthError::validation("Authorization code is empty"));
180        }
181        Ok(())
182    }
183
184    /// Verify a PKCE code challenge against the stored verifier.
185    pub fn verify_pkce(code_verifier: &str, code_challenge: &str) -> Result<()> {
186        let expected = Self::compute_code_challenge(code_verifier);
187        if expected != code_challenge {
188            return Err(AuthError::validation("PKCE code challenge mismatch"));
189        }
190        Ok(())
191    }
192
193    /// Validate that a profile URL is canonical per IndieAuth rules.
194    ///
195    /// - Must have scheme `https://` or `http://`
196    /// - Must not contain a fragment
197    /// - Must not contain a username/password
198    /// - Path must end with `/`
199    pub fn validate_profile_url(url: &str) -> Result<()> {
200        if !(url.starts_with("https://") || url.starts_with("http://")) {
201            return Err(AuthError::validation(
202                "Profile URL must use http or https scheme",
203            ));
204        }
205        if url.contains('#') {
206            return Err(AuthError::validation(
207                "Profile URL must not contain a fragment",
208            ));
209        }
210        if url.contains('@') {
211            return Err(AuthError::validation(
212                "Profile URL must not contain userinfo",
213            ));
214        }
215        // Extract host portion
216        let after_scheme = url.split("://").nth(1).unwrap_or("");
217        let host = after_scheme.split('/').next().unwrap_or("");
218        if host.is_empty() {
219            return Err(AuthError::validation("Profile URL has no host"));
220        }
221        // Must not be an IP address
222        if host.parse::<std::net::Ipv4Addr>().is_ok() {
223            return Err(AuthError::validation(
224                "Profile URL must not be an IP address",
225            ));
226        }
227        Ok(())
228    }
229}
230
231// ── IndieAuth Server Metadata (RFC draft) ───────────────────────────
232
233/// IndieAuth server metadata (returned at `/.well-known/oauth-authorization-server`
234/// or linked from `<link rel="indieauth-metadata">` on the user's profile).
235#[derive(Debug, Clone, Serialize, Deserialize)]
236pub struct IndieAuthMetadata {
237    pub issuer: String,
238    pub authorization_endpoint: String,
239    pub token_endpoint: String,
240    #[serde(skip_serializing_if = "Option::is_none")]
241    pub introspection_endpoint: Option<String>,
242    #[serde(skip_serializing_if = "Option::is_none")]
243    pub revocation_endpoint: Option<String>,
244    pub code_challenge_methods_supported: Vec<String>,
245    #[serde(skip_serializing_if = "Vec::is_empty", default)]
246    pub scopes_supported: Vec<String>,
247    #[serde(skip_serializing_if = "Vec::is_empty", default)]
248    pub response_types_supported: Vec<String>,
249}
250
251impl IndieAuthMetadata {
252    /// Create a metadata document for the given issuer base URL.
253    pub fn new(issuer: &str) -> Self {
254        Self {
255            issuer: issuer.to_string(),
256            authorization_endpoint: format!("{issuer}/auth"),
257            token_endpoint: format!("{issuer}/token"),
258            introspection_endpoint: Some(format!("{issuer}/introspect")),
259            revocation_endpoint: Some(format!("{issuer}/revoke")),
260            code_challenge_methods_supported: vec!["S256".to_string()],
261            scopes_supported: vec![
262                "profile".to_string(),
263                "email".to_string(),
264                "create".to_string(),
265                "update".to_string(),
266                "delete".to_string(),
267            ],
268            response_types_supported: vec!["code".to_string()],
269        }
270    }
271}
272
273// ── IndieAuth Server (authorization + token exchange) ───────────────
274
275/// Stored authorization code with associated metadata.
276#[allow(dead_code)]
277#[derive(Debug, Clone)]
278struct StoredAuthCode {
279    code: String,
280    client_id: String,
281    redirect_uri: String,
282    me: String,
283    scope: Option<String>,
284    code_challenge: String,
285    code_challenge_method: String,
286    created_at: u64,
287}
288
289/// IndieAuth server handling code issuance and token exchange.
290pub struct IndieAuthServer {
291    issuer: String,
292    /// Authorization code store: code → StoredAuthCode
293    codes: Arc<RwLock<HashMap<String, StoredAuthCode>>>,
294    /// Access token store: token → (me, scope, created_at)
295    tokens: Arc<RwLock<HashMap<String, (String, Option<String>, u64)>>>,
296    /// Authorization code lifetime (seconds).
297    code_lifetime: u64,
298    /// Access token lifetime (seconds).
299    token_lifetime: u64,
300}
301
302impl IndieAuthServer {
303    /// Create a new IndieAuth server with the given issuer URL.
304    pub fn new(issuer: &str, code_lifetime: u64, token_lifetime: u64) -> Self {
305        Self {
306            issuer: issuer.to_string(),
307            codes: Arc::new(RwLock::new(HashMap::new())),
308            tokens: Arc::new(RwLock::new(HashMap::new())),
309            code_lifetime,
310            token_lifetime,
311        }
312    }
313
314    /// Get server metadata.
315    pub fn metadata(&self) -> IndieAuthMetadata {
316        IndieAuthMetadata::new(&self.issuer)
317    }
318
319    fn now_secs() -> u64 {
320        SystemTime::now()
321            .duration_since(UNIX_EPOCH)
322            .unwrap_or(Duration::ZERO)
323            .as_secs()
324    }
325
326    fn generate_token() -> Result<String> {
327        use ring::rand::{SecureRandom, SystemRandom};
328        let rng = SystemRandom::new();
329        let mut buf = [0u8; 32];
330        rng.fill(&mut buf)
331            .map_err(|_| AuthError::crypto("Failed to generate token"))?;
332        Ok(hex::encode(buf))
333    }
334
335    /// Issue an authorization code after user consent.
336    pub async fn issue_code(
337        &self,
338        client_id: &str,
339        redirect_uri: &str,
340        me: &str,
341        scope: Option<&str>,
342        code_challenge: &str,
343        code_challenge_method: &str,
344    ) -> Result<String> {
345        if code_challenge_method != "S256" {
346            return Err(AuthError::validation(
347                "Only S256 code_challenge_method is supported",
348            ));
349        }
350        let code = Self::generate_token()?;
351        let stored = StoredAuthCode {
352            code: code.clone(),
353            client_id: client_id.to_string(),
354            redirect_uri: redirect_uri.to_string(),
355            me: me.to_string(),
356            scope: scope.map(|s| s.to_string()),
357            code_challenge: code_challenge.to_string(),
358            code_challenge_method: code_challenge_method.to_string(),
359            created_at: Self::now_secs(),
360        };
361        self.codes.write().await.insert(code.clone(), stored);
362        Ok(code)
363    }
364
365    /// Exchange an authorization code for a token response.
366    ///
367    /// This is the server-side token exchange endpoint handler.
368    pub async fn exchange_code(
369        &self,
370        code: &str,
371        client_id: &str,
372        redirect_uri: &str,
373        code_verifier: &str,
374    ) -> Result<IndieAuthTokenResponse> {
375        let stored = {
376            let mut codes = self.codes.write().await;
377            codes
378                .remove(code)
379                .ok_or_else(|| AuthError::validation("Invalid or expired authorization code"))?
380        };
381
382        // Validate client_id and redirect_uri
383        if stored.client_id != client_id {
384            return Err(AuthError::validation("client_id mismatch"));
385        }
386        if stored.redirect_uri != redirect_uri {
387            return Err(AuthError::validation("redirect_uri mismatch"));
388        }
389
390        // Check code expiry
391        let now = Self::now_secs();
392        if now - stored.created_at > self.code_lifetime {
393            return Err(AuthError::validation("Authorization code has expired"));
394        }
395
396        // Verify PKCE
397        IndieAuthClient::verify_pkce(code_verifier, &stored.code_challenge)?;
398
399        // Issue access token if scopes were requested
400        let (access_token, token_type) = if stored.scope.is_some() {
401            let token = Self::generate_token()?;
402            self.tokens.write().await.insert(
403                token.clone(),
404                (stored.me.clone(), stored.scope.clone(), now),
405            );
406            (Some(token), Some("Bearer".to_string()))
407        } else {
408            (None, None)
409        };
410
411        Ok(IndieAuthTokenResponse {
412            me: stored.me,
413            access_token,
414            token_type,
415            scope: stored.scope,
416            profile: None,
417        })
418    }
419
420    /// Introspect an access token.
421    pub async fn introspect_token(&self, token: &str) -> Option<(String, Option<String>, bool)> {
422        let tokens = self.tokens.read().await;
423        tokens.get(token).map(|(me, scope, created_at)| {
424            let now = Self::now_secs();
425            let active = now - created_at <= self.token_lifetime;
426            (me.clone(), scope.clone(), active)
427        })
428    }
429
430    /// Revoke an access token.
431    pub async fn revoke_token(&self, token: &str) -> bool {
432        self.tokens.write().await.remove(token).is_some()
433    }
434
435    /// Clean up expired codes and tokens.
436    pub async fn cleanup(&self) {
437        let now = Self::now_secs();
438        self.codes
439            .write()
440            .await
441            .retain(|_, v| now - v.created_at <= self.code_lifetime);
442        self.tokens
443            .write()
444            .await
445            .retain(|_, (_, _, created)| now - *created <= self.token_lifetime);
446    }
447}
448
449/// Generate a cryptographically random state parameter.
450fn generate_state() -> Result<String> {
451    use ring::rand::{SecureRandom, SystemRandom};
452    let rng = SystemRandom::new();
453    let mut buf = [0u8; 16];
454    rng.fill(&mut buf)
455        .map_err(|_| AuthError::crypto("Failed to generate state".to_string()))?;
456    Ok(hex::encode(buf))
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use base64::Engine;
463
464    fn test_config() -> IndieAuthConfig {
465        IndieAuthConfig {
466            client_id: "https://app.example.com/".to_string(),
467            redirect_uri: "https://app.example.com/callback".to_string(),
468            authorization_endpoint: Some("https://indieauth.example.com/auth".to_string()),
469            token_endpoint: Some("https://indieauth.example.com/token".to_string()),
470        }
471    }
472
473    #[test]
474    fn test_create_client() {
475        let client = IndieAuthClient::new(test_config()).unwrap();
476        assert_eq!(client.config.client_id, "https://app.example.com/");
477    }
478
479    #[test]
480    fn test_empty_client_id_rejected() {
481        let mut cfg = test_config();
482        cfg.client_id = String::new();
483        assert!(IndieAuthClient::new(cfg).is_err());
484    }
485
486    #[test]
487    fn test_generate_code_verifier() {
488        let v1 = IndieAuthClient::generate_code_verifier().unwrap();
489        let v2 = IndieAuthClient::generate_code_verifier().unwrap();
490        assert!(v1.len() >= 43);
491        assert_ne!(v1, v2);
492    }
493
494    #[test]
495    fn test_pkce_challenge_s256() {
496        let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
497        let challenge = IndieAuthClient::compute_code_challenge(verifier);
498        // SHA-256 of the verifier, base64url-encoded
499        assert!(!challenge.is_empty());
500        // Verify it's valid base64url
501        assert!(
502            base64::engine::general_purpose::URL_SAFE_NO_PAD
503                .decode(&challenge)
504                .is_ok()
505        );
506    }
507
508    #[test]
509    fn test_pkce_verify_success() {
510        let verifier = IndieAuthClient::generate_code_verifier().unwrap();
511        let challenge = IndieAuthClient::compute_code_challenge(&verifier);
512        IndieAuthClient::verify_pkce(&verifier, &challenge).unwrap();
513    }
514
515    #[test]
516    fn test_pkce_verify_mismatch() {
517        let verifier = IndieAuthClient::generate_code_verifier().unwrap();
518        assert!(IndieAuthClient::verify_pkce(&verifier, "wrong-challenge").is_err());
519    }
520
521    #[test]
522    fn test_build_authorization_url() {
523        let client = IndieAuthClient::new(test_config()).unwrap();
524        let verifier = IndieAuthClient::generate_code_verifier().unwrap();
525        let (req, url) = client
526            .build_authorization_url(
527                &verifier,
528                Some("profile"),
529                Some("https://user.example.com/"),
530            )
531            .unwrap();
532
533        assert_eq!(req.response_type, "code");
534        assert_eq!(req.code_challenge_method, "S256");
535        assert!(url.starts_with("https://indieauth.example.com/auth?"));
536        assert!(url.contains("response_type=code"));
537        assert!(url.contains("code_challenge="));
538        assert!(url.contains("scope=profile"));
539    }
540
541    #[test]
542    fn test_build_url_no_endpoint() {
543        let mut cfg = test_config();
544        cfg.authorization_endpoint = None;
545        let client = IndieAuthClient::new(cfg).unwrap();
546        let verifier = IndieAuthClient::generate_code_verifier().unwrap();
547        assert!(
548            client
549                .build_authorization_url(&verifier, None, None)
550                .is_err()
551        );
552    }
553
554    #[test]
555    fn test_verify_callback_valid() {
556        let client = IndieAuthClient::new(test_config()).unwrap();
557        let cb = IndieAuthCallback {
558            code: "auth-code-123".to_string(),
559            state: "expected-state".to_string(),
560            me: None,
561        };
562        client.verify_callback(&cb, "expected-state").unwrap();
563    }
564
565    #[test]
566    fn test_verify_callback_state_mismatch() {
567        let client = IndieAuthClient::new(test_config()).unwrap();
568        let cb = IndieAuthCallback {
569            code: "auth-code-123".to_string(),
570            state: "wrong-state".to_string(),
571            me: None,
572        };
573        assert!(client.verify_callback(&cb, "expected-state").is_err());
574    }
575
576    #[test]
577    fn test_verify_callback_empty_code() {
578        let client = IndieAuthClient::new(test_config()).unwrap();
579        let cb = IndieAuthCallback {
580            code: String::new(),
581            state: "ok".to_string(),
582            me: None,
583        };
584        assert!(client.verify_callback(&cb, "ok").is_err());
585    }
586
587    #[test]
588    fn test_validate_profile_url_valid() {
589        IndieAuthClient::validate_profile_url("https://user.example.com/").unwrap();
590        IndieAuthClient::validate_profile_url("http://user.example.com/path").unwrap();
591    }
592
593    #[test]
594    fn test_validate_profile_url_no_scheme() {
595        assert!(IndieAuthClient::validate_profile_url("ftp://example.com").is_err());
596    }
597
598    #[test]
599    fn test_validate_profile_url_fragment() {
600        assert!(IndieAuthClient::validate_profile_url("https://example.com/#frag").is_err());
601    }
602
603    #[test]
604    fn test_validate_profile_url_userinfo() {
605        assert!(IndieAuthClient::validate_profile_url("https://user@example.com/").is_err());
606    }
607
608    #[test]
609    fn test_validate_profile_url_ip_address() {
610        assert!(IndieAuthClient::validate_profile_url("https://127.0.0.1/").is_err());
611    }
612
613    // ── Server metadata ─────────────────────────────────────────
614
615    #[test]
616    fn test_server_metadata() {
617        let meta = IndieAuthMetadata::new("https://auth.example.com");
618        assert_eq!(meta.issuer, "https://auth.example.com");
619        assert_eq!(meta.authorization_endpoint, "https://auth.example.com/auth");
620        assert!(
621            meta.code_challenge_methods_supported
622                .contains(&"S256".to_string())
623        );
624        let json = serde_json::to_string(&meta).unwrap();
625        assert!(json.contains("issuer"));
626    }
627
628    // ── Server code exchange ────────────────────────────────────
629
630    #[tokio::test]
631    async fn test_server_issue_and_exchange_code() {
632        let server = IndieAuthServer::new("https://auth.example.com", 600, 3600);
633
634        let verifier = IndieAuthClient::generate_code_verifier().unwrap();
635        let challenge = IndieAuthClient::compute_code_challenge(&verifier);
636
637        let code = server
638            .issue_code(
639                "https://app.example.com/",
640                "https://app.example.com/callback",
641                "https://user.example.com/",
642                Some("profile create"),
643                &challenge,
644                "S256",
645            )
646            .await
647            .unwrap();
648
649        let resp = server
650            .exchange_code(
651                &code,
652                "https://app.example.com/",
653                "https://app.example.com/callback",
654                &verifier,
655            )
656            .await
657            .unwrap();
658
659        assert_eq!(resp.me, "https://user.example.com/");
660        assert!(resp.access_token.is_some());
661        assert_eq!(resp.token_type.as_deref(), Some("Bearer"));
662        assert_eq!(resp.scope.as_deref(), Some("profile create"));
663    }
664
665    #[tokio::test]
666    async fn test_server_code_single_use() {
667        let server = IndieAuthServer::new("https://auth.example.com", 600, 3600);
668        let verifier = IndieAuthClient::generate_code_verifier().unwrap();
669        let challenge = IndieAuthClient::compute_code_challenge(&verifier);
670
671        let code = server
672            .issue_code(
673                "https://app/",
674                "https://app/cb",
675                "https://me/",
676                None,
677                &challenge,
678                "S256",
679            )
680            .await
681            .unwrap();
682
683        // First exchange succeeds
684        server
685            .exchange_code(&code, "https://app/", "https://app/cb", &verifier)
686            .await
687            .unwrap();
688
689        // Second exchange fails (code consumed)
690        assert!(
691            server
692                .exchange_code(&code, "https://app/", "https://app/cb", &verifier)
693                .await
694                .is_err()
695        );
696    }
697
698    #[tokio::test]
699    async fn test_server_pkce_mismatch() {
700        let server = IndieAuthServer::new("https://auth.example.com", 600, 3600);
701        let verifier = IndieAuthClient::generate_code_verifier().unwrap();
702        let challenge = IndieAuthClient::compute_code_challenge(&verifier);
703
704        let code = server
705            .issue_code(
706                "https://app/",
707                "https://app/cb",
708                "https://me/",
709                None,
710                &challenge,
711                "S256",
712            )
713            .await
714            .unwrap();
715
716        assert!(
717            server
718                .exchange_code(&code, "https://app/", "https://app/cb", "wrong-verifier")
719                .await
720                .is_err()
721        );
722    }
723
724    #[tokio::test]
725    async fn test_server_introspect_and_revoke() {
726        let server = IndieAuthServer::new("https://auth.example.com", 600, 3600);
727        let verifier = IndieAuthClient::generate_code_verifier().unwrap();
728        let challenge = IndieAuthClient::compute_code_challenge(&verifier);
729
730        let code = server
731            .issue_code(
732                "https://app/",
733                "https://app/cb",
734                "https://me/",
735                Some("profile"),
736                &challenge,
737                "S256",
738            )
739            .await
740            .unwrap();
741
742        let resp = server
743            .exchange_code(&code, "https://app/", "https://app/cb", &verifier)
744            .await
745            .unwrap();
746
747        let token = resp.access_token.unwrap();
748        let (me, scope, active) = server.introspect_token(&token).await.unwrap();
749        assert_eq!(me, "https://me/");
750        assert_eq!(scope.as_deref(), Some("profile"));
751        assert!(active);
752
753        assert!(server.revoke_token(&token).await);
754        assert!(server.introspect_token(&token).await.is_none());
755    }
756
757    #[tokio::test]
758    async fn test_server_no_token_without_scope() {
759        let server = IndieAuthServer::new("https://auth.example.com", 600, 3600);
760        let verifier = IndieAuthClient::generate_code_verifier().unwrap();
761        let challenge = IndieAuthClient::compute_code_challenge(&verifier);
762
763        let code = server
764            .issue_code(
765                "https://app/",
766                "https://app/cb",
767                "https://me/",
768                None,
769                &challenge,
770                "S256",
771            )
772            .await
773            .unwrap();
774
775        let resp = server
776            .exchange_code(&code, "https://app/", "https://app/cb", &verifier)
777            .await
778            .unwrap();
779
780        assert!(resp.access_token.is_none());
781        assert_eq!(resp.me, "https://me/");
782    }
783}