Skip to main content

solid_pod_rs_idp/
provider.rs

1//! OIDC provider surface: `/auth` + `/token` + `/me` (rows 74, 76, 77).
2//!
3//! JSS parity: `src/idp/provider.js:92-455` — the `createProvider`
4//! factory. JSS wraps the `oidc-provider` npm package; we implement
5//! the same state machine directly because the Rust ecosystem
6//! doesn't have a drop-in equivalent and the Solid-OIDC profile is
7//! narrow enough (authorization-code + PKCE + DPoP, public clients
8//! by default) to code up in ~400 LOC.
9//!
10//! ## What IS implemented here
11//!
12//! - Authorization-code flow (public clients, PKCE S256 mandatory).
13//! - DPoP-bound access tokens at `/token`.
14//! - Client lookup via [`ClientStore`], including Client Identifier
15//!   Documents.
16//! - User info via the session's account record.
17//!
18//! ## What is NOT implemented here (deliberately)
19//!
20//! - **Login UI.** `/auth` returns an `AuthorizeResponse::NeedsLogin`
21//!   that the consumer must render however they wish (JSS drives an
22//!   interaction UID through the oidc-provider interaction tables;
23//!   we surface the raw "please authenticate" state to the caller).
24//! - **Consent prompt.** JSS auto-approves via `loadExistingGrant`
25//!   (`provider.js:266-304`); we mirror that — every successful
26//!   login implies consent.
27//! - **Refresh tokens.** Sprint 10 ships access tokens only. A
28//!   follow-up sprint can extend [`TokenResponse`] with
29//!   `refresh_token` and extend [`SessionStore`] with refresh
30//!   records.
31
32use std::sync::Arc;
33
34use serde::{Deserialize, Serialize};
35use tracing::debug;
36
37use solid_pod_rs::oidc::verify_dpop_proof;
38
39use crate::error::ProviderError;
40use crate::jwks::Jwks;
41use crate::registration::ClientStore;
42use crate::session::{AuthCodeRecord, SessionStore};
43use crate::tokens::{issue_access_token, AccessToken};
44use crate::user_store::UserStore;
45
46/// Provider configuration.
47#[derive(Debug, Clone)]
48pub struct ProviderConfig {
49    /// Issuer URL — must match the discovery document.
50    pub issuer: String,
51    /// Access-token TTL in seconds. Default 3600 (JSS matches).
52    pub access_token_ttl_secs: u64,
53    /// DPoP `iat` tolerance, seconds. Default 60.
54    pub dpop_skew_secs: u64,
55}
56
57impl ProviderConfig {
58    /// Reasonable Solid-OIDC defaults.
59    pub fn new(issuer: impl Into<String>) -> Self {
60        Self {
61            issuer: issuer.into(),
62            access_token_ttl_secs: 3600,
63            dpop_skew_secs: 60,
64        }
65    }
66}
67
68/// Opaque provider — holds all the stores and dispatches requests.
69#[derive(Clone)]
70pub struct Provider {
71    config: ProviderConfig,
72    client_store: ClientStore,
73    session_store: SessionStore,
74    user_store: Arc<dyn UserStore>,
75    jwks: Jwks,
76}
77
78impl Provider {
79    /// Construct a provider.
80    pub fn new(
81        config: ProviderConfig,
82        client_store: ClientStore,
83        session_store: SessionStore,
84        user_store: Arc<dyn UserStore>,
85        jwks: Jwks,
86    ) -> Self {
87        Self {
88            config,
89            client_store,
90            session_store,
91            user_store,
92            jwks,
93        }
94    }
95
96    /// Access the JWKS (for serving `/.well-known/jwks.json`).
97    pub fn jwks(&self) -> &Jwks {
98        &self.jwks
99    }
100
101    /// Access the configuration.
102    pub fn config(&self) -> &ProviderConfig {
103        &self.config
104    }
105
106    /// Access the client store (for dynamic client registration
107    /// endpoint).
108    pub fn client_store(&self) -> &ClientStore {
109        &self.client_store
110    }
111
112    /// Access the session store (for credentials-flow, logout, etc).
113    pub fn session_store(&self) -> &SessionStore {
114        &self.session_store
115    }
116
117    /// Access the underlying [`UserStore`] through a trait object.
118    /// Used by the optional axum binder to share one user store
119    /// across the /credentials handler and the /auth handler.
120    pub fn user_store_trait_object(&self) -> &dyn UserStore {
121        self.user_store.as_ref()
122    }
123
124    /// Render the discovery document for the configured issuer.
125    pub fn discovery_document(&self) -> crate::discovery::DiscoveryDocument {
126        crate::discovery::build_discovery(&self.config.issuer)
127    }
128
129    /// Start the authorization-code flow.
130    ///
131    /// Returns [`AuthorizeResponse::Redirect`] on success (a code
132    /// has been minted and the client should receive a 302 to
133    /// `redirect_uri?code=<code>&state=<state>`), or
134    /// [`AuthorizeResponse::NeedsLogin`] when there is no active
135    /// session for the current request.
136    pub async fn authorize(
137        &self,
138        req: AuthorizeRequest,
139    ) -> Result<AuthorizeResponse, ProviderError> {
140        // 1. Validate client.
141        let client = self
142            .client_store
143            .find(&req.client_id)
144            .await
145            .map_err(|e| ProviderError::ClientDocument(e.to_string()))?
146            .ok_or_else(|| ProviderError::InvalidClient(format!("unknown: {}", req.client_id)))?;
147
148        // 2. response_type MUST be "code".
149        if req.response_type != "code" {
150            return Err(ProviderError::InvalidRequest(format!(
151                "response_type must be 'code', got '{}'",
152                req.response_type
153            )));
154        }
155
156        // 3. Validate redirect_uri against the registered set.
157        if !client.redirect_uris.iter().any(|r| r == &req.redirect_uri) {
158            return Err(ProviderError::InvalidRequest(format!(
159                "redirect_uri not registered: {}",
160                req.redirect_uri
161            )));
162        }
163
164        // 4. PKCE is mandatory for public clients under Solid-OIDC.
165        //    We enforce it for everyone to match JSS
166        //    (`provider.js:346 — pkce.required = () => true`).
167        if req.code_challenge_method.as_deref() != Some("S256") {
168            return Err(ProviderError::InvalidRequest(
169                "PKCE S256 is required (code_challenge_method)".into(),
170            ));
171        }
172        if req.code_challenge.is_none() {
173            return Err(ProviderError::InvalidRequest(
174                "code_challenge is required".into(),
175            ));
176        }
177
178        // 5. Session gate. If we have one, we can mint a code now.
179        match req.session_account_id {
180            None => Ok(AuthorizeResponse::NeedsLogin {
181                client_id: req.client_id,
182                redirect_uri: req.redirect_uri,
183                state: req.state,
184                code_challenge: req.code_challenge,
185                scope: req.scope,
186            }),
187            Some(account_id) => {
188                let code = self.session_store.issue_code(
189                    &client.client_id,
190                    account_id,
191                    &req.redirect_uri,
192                    req.code_challenge.clone(),
193                    req.scope.clone(),
194                );
195                Ok(AuthorizeResponse::Redirect {
196                    redirect_uri: req.redirect_uri,
197                    code: code.code,
198                    state: req.state,
199                    iss: self.config.issuer.clone(),
200                })
201            }
202        }
203    }
204
205    /// Exchange an authorization code at `/token`.
206    ///
207    /// Requires a valid DPoP proof whose `htu` / `htm` match
208    /// `POST {issuer}/idp/token`. The returned access token is
209    /// bound to the proof's JWK thumbprint (`cnf.jkt`).
210    pub async fn token(&self, req: TokenRequest<'_>) -> Result<TokenResponse, ProviderError> {
211        if req.grant_type != "authorization_code" {
212            return Err(ProviderError::InvalidRequest(format!(
213                "grant_type must be 'authorization_code', got '{}'",
214                req.grant_type
215            )));
216        }
217
218        // Verify DPoP proof first — this is a REQUIRED header for
219        // Solid-OIDC /token. Without it we won't bind cnf.jkt.
220        let dpop_proof = req
221            .dpop_proof
222            .ok_or_else(|| ProviderError::InvalidDpop("missing DPoP header".into()))?;
223
224        let expected_htu = format!(
225            "{}/idp/token",
226            self.config.issuer.trim_end_matches('/')
227        );
228        let verified = verify_dpop_proof(
229            dpop_proof,
230            &expected_htu,
231            "POST",
232            req.now_unix,
233            self.config.dpop_skew_secs,
234            None, // replay cache: consumer can plumb this later
235        )
236        .await
237        .map_err(|e| ProviderError::InvalidDpop(e.to_string()))?;
238        let jkt = verified.jkt;
239
240        // Consume the code (single-use).
241        let code: AuthCodeRecord = self
242            .session_store
243            .take_code(req.code)
244            .ok_or_else(|| ProviderError::InvalidGrant("code expired or unknown".into()))?;
245
246        // Client + redirect_uri binding checks.
247        if code.client_id != req.client_id {
248            return Err(ProviderError::InvalidGrant(
249                "code issued to different client_id".into(),
250            ));
251        }
252        if code.redirect_uri != req.redirect_uri {
253            return Err(ProviderError::InvalidGrant(
254                "redirect_uri mismatch".into(),
255            ));
256        }
257
258        // PKCE check — the challenge stored at /auth must match
259        // SHA256(verifier) base64url-noPad.
260        if let Some(challenge) = &code.code_challenge {
261            let verifier = req.code_verifier.ok_or_else(|| {
262                ProviderError::InvalidRequest("code_verifier required for PKCE".into())
263            })?;
264            let computed = pkce_s256(verifier);
265            if &computed != challenge {
266                return Err(ProviderError::InvalidGrant(
267                    "PKCE verifier mismatch".into(),
268                ));
269            }
270        }
271
272        // Resolve the account → webid.
273        let user = self
274            .user_store
275            .find_by_id(&code.account_id)
276            .await
277            .map_err(|e| ProviderError::UserStore(e.to_string()))?
278            .ok_or_else(|| ProviderError::InvalidGrant("account not found".into()))?;
279
280        // Sign the access token.
281        let key = self.jwks.active_key();
282        let token: AccessToken = issue_access_token(
283            &key,
284            &self.config.issuer,
285            &user.webid,
286            &user.id,
287            &code.client_id,
288            code.requested_scope.as_deref().unwrap_or("openid webid"),
289            Some(&jkt),
290            req.now_unix,
291            self.config.access_token_ttl_secs,
292        )
293        .map_err(|e| ProviderError::Crypto(e.to_string()))?;
294
295        debug!(client_id = %code.client_id, webid = %user.webid, "issued DPoP-bound access token");
296
297        Ok(TokenResponse {
298            access_token: token.jwt,
299            token_type: "DPoP".into(),
300            expires_in: self.config.access_token_ttl_secs,
301            scope: token.payload.scope,
302            webid: Some(user.webid),
303        })
304    }
305
306    /// Resolve a bearer-authenticated request to [`UserInfo`].
307    /// Verifies the access token (ES256 signed by our JWKS) and
308    /// matches its `cnf.jkt` against the supplied DPoP thumbprint.
309    pub async fn userinfo(
310        &self,
311        access_token: &str,
312        dpop_jkt: &str,
313        now_unix: u64,
314    ) -> Result<UserInfo, ProviderError> {
315        // Build an asymmetric JwkSet from our published JWKS so we
316        // can route back through the core verifier. This is a little
317        // roundabout (we just signed the token ourselves) but using
318        // the same verification path the resource server would use
319        // catches sign/verify drift.
320        let keyset = build_jwk_set(&self.jwks);
321        let v = solid_pod_rs::oidc::verify_access_token(
322            access_token,
323            &solid_pod_rs::oidc::TokenVerifyKey::Asymmetric(keyset),
324            &self.config.issuer,
325            dpop_jkt,
326            now_unix,
327        )
328        .map_err(|e| ProviderError::InvalidDpop(e.to_string()))?;
329
330        Ok(UserInfo {
331            webid: v.webid.clone(),
332            sub: v.webid,
333            client_id: v.client_id,
334            scope: v.scope,
335        })
336    }
337}
338
339/// Convert our internal [`Jwks`] to the `jsonwebtoken::jwk::JwkSet`
340/// shape the core verifier expects.
341fn build_jwk_set(jwks: &Jwks) -> jsonwebtoken::jwk::JwkSet {
342    let doc = jwks.public_document();
343    let keys: Vec<jsonwebtoken::jwk::Jwk> = doc
344        .keys
345        .iter()
346        .filter_map(|k| serde_json::to_value(k).ok())
347        .filter_map(|v| serde_json::from_value(v).ok())
348        .collect();
349    jsonwebtoken::jwk::JwkSet { keys }
350}
351
352/// Base64url-noPad SHA256 — PKCE S256 (`RFC 7636 §4.6`).
353fn pkce_s256(verifier: &str) -> String {
354    use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
355    use base64::Engine;
356    use sha2::{Digest, Sha256};
357    let hash = Sha256::digest(verifier.as_bytes());
358    B64.encode(hash)
359}
360
361/// Input to [`Provider::authorize`].
362#[derive(Debug, Clone)]
363pub struct AuthorizeRequest {
364    /// OAuth2 client id (opaque or Client Identifier Document URL).
365    pub client_id: String,
366    /// Always `"code"` for Solid-OIDC.
367    pub response_type: String,
368    /// Callback URL. Must be one of the client's registered
369    /// `redirect_uris`.
370    pub redirect_uri: String,
371    /// OAuth2 state (echoed back in the redirect).
372    pub state: Option<String>,
373    /// PKCE code challenge (`S256` hash of the verifier).
374    pub code_challenge: Option<String>,
375    /// PKCE method — must be `"S256"`.
376    pub code_challenge_method: Option<String>,
377    /// Requested scope string (e.g. `"openid webid"`).
378    pub scope: Option<String>,
379    /// Account id from the caller's session cookie. `None` means
380    /// no active session → the consumer must render a login page
381    /// and re-call `authorize` with `session_account_id = Some(...)`.
382    pub session_account_id: Option<String>,
383}
384
385/// Output of [`Provider::authorize`].
386#[derive(Debug, Clone)]
387pub enum AuthorizeResponse {
388    /// Session already authenticated — emit a 302 to the redirect
389    /// URI with the returned `code` + optional `state`.
390    Redirect {
391        /// Target redirect URI (verbatim from the request).
392        redirect_uri: String,
393        /// Single-use authorisation code.
394        code: String,
395        /// OAuth2 `state` parameter, echoed back.
396        state: Option<String>,
397        /// RFC 9207 `iss` response parameter.
398        iss: String,
399    },
400    /// Consumer must render a login page and re-submit.
401    NeedsLogin {
402        /// Echo of the `/auth` params so the consumer can bundle
403        /// them into the login form and re-enter `authorize` after
404        /// the user authenticates.
405        client_id: String,
406        /// Callback URL.
407        redirect_uri: String,
408        /// OAuth2 state.
409        state: Option<String>,
410        /// PKCE code challenge.
411        code_challenge: Option<String>,
412        /// Requested scope.
413        scope: Option<String>,
414    },
415}
416
417/// Input to [`Provider::token`].
418#[derive(Debug, Clone)]
419pub struct TokenRequest<'a> {
420    /// Always `"authorization_code"` for Sprint-10 scope.
421    pub grant_type: String,
422    /// Single-use code returned by `/auth`.
423    pub code: &'a str,
424    /// Redirect URI that was used at `/auth` (must match).
425    pub redirect_uri: String,
426    /// Client id.
427    pub client_id: String,
428    /// PKCE code verifier (required when challenge was set).
429    pub code_verifier: Option<&'a str>,
430    /// Raw DPoP proof JWT (the `DPoP:` request header value).
431    pub dpop_proof: Option<&'a str>,
432    /// Current Unix-seconds timestamp (injected for tests).
433    pub now_unix: u64,
434}
435
436/// `/token` response body — RFC 6749 §5.1 shape plus Solid-OIDC
437/// `webid`.
438#[derive(Debug, Clone, Serialize, Deserialize)]
439pub struct TokenResponse {
440    /// Signed JWT access token.
441    pub access_token: String,
442    /// Always `"DPoP"` today.
443    pub token_type: String,
444    /// Lifetime in seconds.
445    pub expires_in: u64,
446    /// Granted scope.
447    pub scope: String,
448    /// Convenience copy of the authenticated WebID.
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub webid: Option<String>,
451}
452
453/// Response shape for `/idp/me` (JSS parity —
454/// `userinfo_endpoint`).
455#[derive(Debug, Clone, Serialize, Deserialize)]
456pub struct UserInfo {
457    /// Subject WebID.
458    pub sub: String,
459    /// Duplicate of `sub` for Solid-OIDC clients that look for a
460    /// `webid` claim by name.
461    pub webid: String,
462    /// Client id that minted the active token.
463    #[serde(skip_serializing_if = "Option::is_none")]
464    pub client_id: Option<String>,
465    /// OAuth2 scope string.
466    #[serde(skip_serializing_if = "Option::is_none")]
467    pub scope: Option<String>,
468}
469
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use base64::engine::general_purpose::URL_SAFE_NO_PAD as B64;
475    use base64::Engine;
476    use jsonwebtoken::{encode, Algorithm, EncodingKey, Header};
477    use rand::Rng;
478    use serde_json::json;
479
480    use crate::registration::{register_client, ClientDocument, RegistrationRequest};
481    use crate::user_store::InMemoryUserStore;
482
483    async fn seed_provider() -> (Provider, InMemoryUserStore, ClientDocument, String) {
484        let store = Arc::new(InMemoryUserStore::new());
485        store
486            .insert_user(
487                "acct-1",
488                "alice@example.com",
489                "https://alice.example/profile#me",
490                None,
491                "hunter2!",
492            )
493            .unwrap();
494        let jwks = Jwks::generate_es256().unwrap();
495        let clients = ClientStore::new();
496        let client = register_client(
497            &clients,
498            RegistrationRequest {
499                redirect_uris: vec!["https://app.example/cb".into()],
500                client_name: Some("TestApp".into()),
501                ..Default::default()
502            },
503        )
504        .await
505        .unwrap();
506        let sessions = SessionStore::new();
507        let cfg = ProviderConfig::new("https://pod.example/");
508        let provider = Provider::new(cfg, clients, sessions, store.clone() as Arc<dyn UserStore>, jwks);
509
510        let verifier: String = (0..43)
511            .map(|_| {
512                let c = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
513                c[rand::thread_rng().gen_range(0..c.len())] as char
514            })
515            .collect();
516
517        (provider, InMemoryUserStore::new(), client, verifier)
518    }
519
520    fn s256(s: &str) -> String {
521        use sha2::{Digest, Sha256};
522        B64.encode(Sha256::digest(s.as_bytes()))
523    }
524
525    /// Build an HS256-signed DPoP proof with `kty=oct` — the core
526    /// DPoP verifier has an explicit test path for this (see
527    /// `solid-pod-rs/src/oidc/mod.rs:553-563`).
528    fn test_dpop_proof(htu: &str, htm: &str, iat: u64) -> String {
529        // oct JWK — 32 random bytes, base64url-noPad.
530        let mut sec = [0u8; 32];
531        rand::thread_rng().fill(&mut sec);
532        let k = B64.encode(sec);
533        let jwk = json!({
534            "kty": "oct",
535            "k": k,
536        });
537        let mut header = Header::new(Algorithm::HS256);
538        header.typ = Some("dpop+jwt".into());
539        header.jwk = Some(serde_json::from_value(jwk).unwrap());
540        let claims = json!({
541            "htu": htu,
542            "htm": htm,
543            "iat": iat,
544            "jti": uuid::Uuid::new_v4().to_string(),
545        });
546        encode(&header, &claims, &EncodingKey::from_secret(&sec)).unwrap()
547    }
548
549    #[tokio::test]
550    async fn authorize_needs_login_without_session() {
551        let (p, _, client, _) = seed_provider().await;
552        let req = AuthorizeRequest {
553            client_id: client.client_id.clone(),
554            response_type: "code".into(),
555            redirect_uri: "https://app.example/cb".into(),
556            state: Some("xyz".into()),
557            code_challenge: Some(s256("verifier-1")),
558            code_challenge_method: Some("S256".into()),
559            scope: Some("openid webid".into()),
560            session_account_id: None,
561        };
562        match p.authorize(req).await.unwrap() {
563            AuthorizeResponse::NeedsLogin { client_id, .. } => {
564                assert_eq!(client_id, client.client_id);
565            }
566            other => panic!("expected NeedsLogin, got {other:?}"),
567        }
568    }
569
570    #[tokio::test]
571    async fn authorize_issues_code_when_logged_in() {
572        let (p, _, client, verifier) = seed_provider().await;
573        let challenge = s256(&verifier);
574        let req = AuthorizeRequest {
575            client_id: client.client_id.clone(),
576            response_type: "code".into(),
577            redirect_uri: "https://app.example/cb".into(),
578            state: Some("state-1".into()),
579            code_challenge: Some(challenge),
580            code_challenge_method: Some("S256".into()),
581            scope: Some("openid webid".into()),
582            session_account_id: Some("acct-1".into()),
583        };
584        match p.authorize(req).await.unwrap() {
585            AuthorizeResponse::Redirect {
586                redirect_uri,
587                code,
588                state,
589                iss,
590            } => {
591                assert_eq!(redirect_uri, "https://app.example/cb");
592                assert!(!code.is_empty());
593                assert_eq!(state.as_deref(), Some("state-1"));
594                assert!(iss.contains("pod.example"));
595            }
596            other => panic!("expected Redirect, got {other:?}"),
597        }
598    }
599
600    #[tokio::test]
601    async fn authorize_rejects_unregistered_redirect_uri() {
602        let (p, _, client, verifier) = seed_provider().await;
603        let req = AuthorizeRequest {
604            client_id: client.client_id.clone(),
605            response_type: "code".into(),
606            redirect_uri: "https://evil.example/steal".into(),
607            state: None,
608            code_challenge: Some(s256(&verifier)),
609            code_challenge_method: Some("S256".into()),
610            scope: Some("openid".into()),
611            session_account_id: Some("acct-1".into()),
612        };
613        let err = p.authorize(req).await.unwrap_err();
614        assert!(matches!(err, ProviderError::InvalidRequest(_)));
615    }
616
617    #[tokio::test]
618    async fn authorize_rejects_without_pkce() {
619        let (p, _, client, _) = seed_provider().await;
620        let req = AuthorizeRequest {
621            client_id: client.client_id.clone(),
622            response_type: "code".into(),
623            redirect_uri: "https://app.example/cb".into(),
624            state: None,
625            code_challenge: None,
626            code_challenge_method: None,
627            scope: Some("openid".into()),
628            session_account_id: Some("acct-1".into()),
629        };
630        let err = p.authorize(req).await.unwrap_err();
631        assert!(matches!(err, ProviderError::InvalidRequest(_)));
632    }
633
634    #[tokio::test]
635    async fn token_endpoint_rejects_without_dpop() {
636        let (p, _, client, verifier) = seed_provider().await;
637        // First mint a code.
638        let auth = p
639            .authorize(AuthorizeRequest {
640                client_id: client.client_id.clone(),
641                response_type: "code".into(),
642                redirect_uri: "https://app.example/cb".into(),
643                state: None,
644                code_challenge: Some(s256(&verifier)),
645                code_challenge_method: Some("S256".into()),
646                scope: Some("openid webid".into()),
647                session_account_id: Some("acct-1".into()),
648            })
649            .await
650            .unwrap();
651        let code = match auth {
652            AuthorizeResponse::Redirect { code, .. } => code,
653            _ => panic!(),
654        };
655
656        let err = p
657            .token(TokenRequest {
658                grant_type: "authorization_code".into(),
659                code: &code,
660                redirect_uri: "https://app.example/cb".into(),
661                client_id: client.client_id.clone(),
662                code_verifier: Some(&verifier),
663                dpop_proof: None,
664                now_unix: 1_700_000_000,
665            })
666            .await
667            .unwrap_err();
668        assert!(matches!(err, ProviderError::InvalidDpop(_)));
669    }
670
671    #[tokio::test]
672    async fn token_endpoint_rejects_dpop_with_wrong_htu() {
673        let (p, _, client, verifier) = seed_provider().await;
674        let auth = p
675            .authorize(AuthorizeRequest {
676                client_id: client.client_id.clone(),
677                response_type: "code".into(),
678                redirect_uri: "https://app.example/cb".into(),
679                state: None,
680                code_challenge: Some(s256(&verifier)),
681                code_challenge_method: Some("S256".into()),
682                scope: Some("openid webid".into()),
683                session_account_id: Some("acct-1".into()),
684            })
685            .await
686            .unwrap();
687        let code = match auth {
688            AuthorizeResponse::Redirect { code, .. } => code,
689            _ => panic!(),
690        };
691
692        let wrong_htu = "https://evil.example/idp/token";
693        let proof = test_dpop_proof(wrong_htu, "POST", 1_700_000_000);
694        let err = p
695            .token(TokenRequest {
696                grant_type: "authorization_code".into(),
697                code: &code,
698                redirect_uri: "https://app.example/cb".into(),
699                client_id: client.client_id.clone(),
700                code_verifier: Some(&verifier),
701                dpop_proof: Some(&proof),
702                now_unix: 1_700_000_000,
703            })
704            .await
705            .unwrap_err();
706        assert!(matches!(err, ProviderError::InvalidDpop(_)));
707    }
708
709    #[tokio::test]
710    async fn authorization_code_flow_end_to_end() {
711        let (p, _, client, verifier) = seed_provider().await;
712
713        let auth = p
714            .authorize(AuthorizeRequest {
715                client_id: client.client_id.clone(),
716                response_type: "code".into(),
717                redirect_uri: "https://app.example/cb".into(),
718                state: Some("s-1".into()),
719                code_challenge: Some(s256(&verifier)),
720                code_challenge_method: Some("S256".into()),
721                scope: Some("openid webid".into()),
722                session_account_id: Some("acct-1".into()),
723            })
724            .await
725            .unwrap();
726        let code = match auth {
727            AuthorizeResponse::Redirect { code, .. } => code,
728            _ => panic!(),
729        };
730
731        let proof = test_dpop_proof("https://pod.example/idp/token", "POST", 1_700_000_000);
732        let tok = p
733            .token(TokenRequest {
734                grant_type: "authorization_code".into(),
735                code: &code,
736                redirect_uri: "https://app.example/cb".into(),
737                client_id: client.client_id.clone(),
738                code_verifier: Some(&verifier),
739                dpop_proof: Some(&proof),
740                now_unix: 1_700_000_000,
741            })
742            .await
743            .unwrap();
744        assert_eq!(tok.token_type, "DPoP");
745        assert!(tok.access_token.contains('.'));
746        assert_eq!(tok.expires_in, 3600);
747        assert_eq!(tok.webid.as_deref(), Some("https://alice.example/profile#me"));
748
749        // Second redemption must fail — code is single-use.
750        let proof2 = test_dpop_proof("https://pod.example/idp/token", "POST", 1_700_000_000);
751        let err = p
752            .token(TokenRequest {
753                grant_type: "authorization_code".into(),
754                code: &code,
755                redirect_uri: "https://app.example/cb".into(),
756                client_id: client.client_id.clone(),
757                code_verifier: Some(&verifier),
758                dpop_proof: Some(&proof2),
759                now_unix: 1_700_000_000,
760            })
761            .await
762            .unwrap_err();
763        assert!(matches!(err, ProviderError::InvalidGrant(_)));
764    }
765
766    #[tokio::test]
767    async fn token_endpoint_rejects_pkce_verifier_mismatch() {
768        let (p, _, client, verifier) = seed_provider().await;
769        let auth = p
770            .authorize(AuthorizeRequest {
771                client_id: client.client_id.clone(),
772                response_type: "code".into(),
773                redirect_uri: "https://app.example/cb".into(),
774                state: None,
775                code_challenge: Some(s256(&verifier)),
776                code_challenge_method: Some("S256".into()),
777                scope: Some("openid webid".into()),
778                session_account_id: Some("acct-1".into()),
779            })
780            .await
781            .unwrap();
782        let code = match auth {
783            AuthorizeResponse::Redirect { code, .. } => code,
784            _ => panic!(),
785        };
786        let proof = test_dpop_proof("https://pod.example/idp/token", "POST", 1_700_000_000);
787        let err = p
788            .token(TokenRequest {
789                grant_type: "authorization_code".into(),
790                code: &code,
791                redirect_uri: "https://app.example/cb".into(),
792                client_id: client.client_id.clone(),
793                code_verifier: Some("totally-wrong-verifier"),
794                dpop_proof: Some(&proof),
795                now_unix: 1_700_000_000,
796            })
797            .await
798            .unwrap_err();
799        assert!(matches!(err, ProviderError::InvalidGrant(_)));
800    }
801}