Skip to main content

solid_pod_rs_idp/
credentials.rs

1//! `/idp/credentials` — email+password login flow (row 79).
2//!
3//! JSS parity: `src/idp/credentials.js:28-150`. The JSS endpoint
4//! accepts `email`+`password` (or `username`+`password`), optionally
5//! binds to a DPoP proof, and returns an access-token JSON body.
6//!
7//! Rate-limiting is bolted on via the core crate's `RateLimiter`
8//! trait, keyed by `(route="idp_credentials", subject=IP)`. The
9//! default policy shipped by core is 60/min which is too generous
10//! for brute-force protection; this module accepts a caller-supplied
11//! limiter so consumers can plug in `LruRateLimiter::with_policy`
12//! with `("idp_credentials", 10, Duration::from_secs(60))` to match
13//! JSS (`src/idp/index.js:255-265`).
14
15use std::net::IpAddr;
16
17use serde::{Deserialize, Serialize};
18use thiserror::Error;
19
20use solid_pod_rs::security::rate_limit::{
21    RateLimitDecision, RateLimitKey, RateLimitSubject, RateLimiter,
22};
23
24use crate::jwks::Jwks;
25use crate::tokens::{issue_access_token, AccessToken};
26use crate::user_store::{User, UserStore};
27
28/// Minimum password length enforced at registration time.
29/// Mirrors JSS commit `1feead2` which rejects passwords shorter than
30/// 8 characters.
31pub const MIN_PASSWORD_LENGTH: usize = 8;
32
33/// Rate-limit route name we share with the rest of the crate. JSS
34/// mirrors this as `/idp/credentials` — we drop the path prefix and
35/// use the canonical name so operator metrics don't need to strip.
36pub const RATE_LIMIT_ROUTE: &str = "idp_credentials";
37
38/// Errors surfaced by [`login`].
39#[derive(Debug, Error)]
40pub enum LoginError {
41    /// Request is rate-limited. The JSS handler returns 429.
42    #[error("rate limited, retry after {retry_after_secs}s")]
43    RateLimited {
44        /// Seconds the client should wait.
45        retry_after_secs: u64,
46    },
47
48    /// Unknown user, wrong password, etc. JSS returns
49    /// `{"error":"invalid_grant"}` with HTTP 401.
50    #[error("invalid credentials")]
51    InvalidGrant,
52
53    /// Password does not meet the minimum length requirement.
54    /// JSS commit `1feead2` enforces >= 8 characters.
55    #[error("password must be at least {min_length} characters")]
56    PasswordTooShort {
57        /// The minimum length that was not met.
58        min_length: usize,
59    },
60
61    /// Caller passed a malformed body.
62    #[error("invalid request: {0}")]
63    InvalidRequest(String),
64
65    /// Store backend exploded.
66    #[error("user store: {0}")]
67    UserStore(String),
68
69    /// Token signing failed.
70    #[error("token issuance: {0}")]
71    Token(String),
72}
73
74/// Response body matching JSS `src/idp/credentials.js:138-145`.
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct CredentialsResponse {
77    /// Signed access token (JWT).
78    pub access_token: String,
79    /// `"DPoP"` when a valid proof was supplied, else `"Bearer"`.
80    pub token_type: String,
81    /// Lifetime in seconds.
82    pub expires_in: u64,
83    /// Authenticated WebID.
84    pub webid: String,
85    /// Internal account id.
86    pub id: String,
87}
88
89/// Email+password login.
90///
91/// - `email` / `password` — user-supplied credentials. Email is
92///   case-normalised before lookup (matches [`UserStore`] contract).
93/// - `user_store` — source of truth for the account record.
94/// - `jwks` — signing keys; the active key is used to sign the
95///   returned access token.
96/// - `issuer` — issuer URL (must match discovery's `issuer`).
97/// - `dpop_jkt` — when the caller has already verified a DPoP proof
98///   from the Authorization-adjacent `DPoP` header, pass the proof's
99///   `jkt` here. The token is then marked DPoP-bound.
100/// - `limiter` + `ip` — rate-limit subject. Callers who do not want
101///   rate limits can pass a `NoopRateLimiter` (tests only).
102/// - `ttl_secs` — access-token TTL. JSS defaults to 3600.
103#[allow(clippy::too_many_arguments)]
104pub async fn login(
105    email: &str,
106    password: &str,
107    user_store: &dyn UserStore,
108    jwks: &Jwks,
109    issuer: &str,
110    dpop_jkt: Option<&str>,
111    limiter: &dyn RateLimiter,
112    ip: IpAddr,
113    now: u64,
114    ttl_secs: u64,
115) -> Result<CredentialsResponse, LoginError> {
116    // --- rate-limit gate ----------------------------------------------------
117    let key = RateLimitKey {
118        route: RATE_LIMIT_ROUTE,
119        subject: RateLimitSubject::Ip(ip),
120    };
121    match limiter.check(&key).await {
122        RateLimitDecision::Allow => {}
123        RateLimitDecision::Deny {
124            retry_after_secs, ..
125        } => return Err(LoginError::RateLimited { retry_after_secs }),
126    }
127
128    // --- input validation ---------------------------------------------------
129    if email.is_empty() || password.is_empty() {
130        return Err(LoginError::InvalidRequest(
131            "email and password are required".into(),
132        ));
133    }
134
135    // --- authentication -----------------------------------------------------
136    let user: Option<User> = user_store
137        .find_by_email(email)
138        .await
139        .map_err(|e| LoginError::UserStore(e.to_string()))?;
140
141    let Some(user) = user else {
142        return Err(LoginError::InvalidGrant);
143    };
144
145    let ok = user_store
146        .verify_password(&user, password)
147        .await
148        .map_err(|e| LoginError::UserStore(e.to_string()))?;
149    if !ok {
150        return Err(LoginError::InvalidGrant);
151    }
152
153    // --- token issuance -----------------------------------------------------
154    let key = jwks.active_key();
155    let token: AccessToken = issue_access_token(
156        &key,
157        issuer,
158        &user.webid,
159        &user.id,
160        "credentials_client", // Matches JSS line 121.
161        "openid webid",
162        dpop_jkt,
163        now,
164        ttl_secs,
165    )
166    .map_err(|e| LoginError::Token(e.to_string()))?;
167
168    Ok(CredentialsResponse {
169        access_token: token.jwt,
170        token_type: if dpop_jkt.is_some() {
171            "DPoP".into()
172        } else {
173            "Bearer".into()
174        },
175        expires_in: ttl_secs,
176        webid: user.webid,
177        id: user.id,
178    })
179}
180
181/// Validate that a password meets the minimum length requirement.
182///
183/// Returns `Ok(())` when the password is at least [`MIN_PASSWORD_LENGTH`]
184/// characters, or `Err(LoginError::PasswordTooShort)` otherwise. Empty
185/// passwords are also rejected (they are shorter than 8 chars).
186///
187/// This is a standalone helper so both the credentials endpoint and
188/// any registration flow can share the same policy.
189pub fn validate_password_length(password: &str) -> Result<(), LoginError> {
190    if password.len() < MIN_PASSWORD_LENGTH {
191        return Err(LoginError::PasswordTooShort {
192            min_length: MIN_PASSWORD_LENGTH,
193        });
194    }
195    Ok(())
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201    use std::net::Ipv4Addr;
202    use std::time::Duration;
203
204    use solid_pod_rs::security::rate_limit::LruRateLimiter;
205
206    use crate::jwks::Jwks;
207    use crate::user_store::InMemoryUserStore;
208
209    fn ip() -> IpAddr {
210        IpAddr::V4(Ipv4Addr::new(203, 0, 113, 1))
211    }
212
213    fn seed() -> (InMemoryUserStore, Jwks, LruRateLimiter) {
214        let store = InMemoryUserStore::new();
215        store
216            .insert_user(
217                "acct-1",
218                "alice@example.com",
219                "https://alice.example/profile#me",
220                Some("Alice".into()),
221                "hunter2!",
222            )
223            .unwrap();
224        let jwks = Jwks::generate_es256().unwrap();
225        // JSS policy: 10 per minute.
226        let limiter = LruRateLimiter::with_policy(vec![(
227            RATE_LIMIT_ROUTE.to_string(),
228            10,
229            Duration::from_secs(60),
230        )]);
231        (store, jwks, limiter)
232    }
233
234    #[tokio::test]
235    async fn login_succeeds_with_correct_password() {
236        let (store, jwks, limiter) = seed();
237        let resp = login(
238            "alice@example.com",
239            "hunter2!",
240            &store,
241            &jwks,
242            "https://pod.example/",
243            Some("JKT-OK"),
244            &limiter,
245            ip(),
246            1_700_000_000,
247            3600,
248        )
249        .await
250        .unwrap();
251        assert_eq!(resp.token_type, "DPoP");
252        assert_eq!(resp.webid, "https://alice.example/profile#me");
253        assert_eq!(resp.expires_in, 3600);
254        assert!(resp.access_token.contains('.'));
255    }
256
257    #[tokio::test]
258    async fn login_returns_bearer_when_no_dpop() {
259        let (store, jwks, limiter) = seed();
260        let resp = login(
261            "alice@example.com",
262            "hunter2!",
263            &store,
264            &jwks,
265            "https://pod.example/",
266            None,
267            &limiter,
268            ip(),
269            1_700_000_000,
270            3600,
271        )
272        .await
273        .unwrap();
274        assert_eq!(resp.token_type, "Bearer");
275    }
276
277    #[tokio::test]
278    async fn login_rejects_wrong_password() {
279        let (store, jwks, limiter) = seed();
280        let err = login(
281            "alice@example.com",
282            "nope",
283            &store,
284            &jwks,
285            "https://pod.example/",
286            None,
287            &limiter,
288            ip(),
289            1_700_000_000,
290            3600,
291        )
292        .await
293        .unwrap_err();
294        assert!(matches!(err, LoginError::InvalidGrant));
295    }
296
297    #[tokio::test]
298    async fn login_rejects_unknown_user() {
299        let (store, jwks, limiter) = seed();
300        let err = login(
301            "nobody@example.com",
302            "hunter2!",
303            &store,
304            &jwks,
305            "https://pod.example/",
306            None,
307            &limiter,
308            ip(),
309            1_700_000_000,
310            3600,
311        )
312        .await
313        .unwrap_err();
314        assert!(matches!(err, LoginError::InvalidGrant));
315    }
316
317    #[tokio::test]
318    async fn login_rate_limited_after_ten_attempts() {
319        let (store, jwks, limiter) = seed();
320        for _ in 0..10 {
321            // Deliberate wrong password — we're stress-testing the
322            // limiter, not the happy path.
323            let _ = login(
324                "alice@example.com",
325                "wrong",
326                &store,
327                &jwks,
328                "https://pod.example/",
329                None,
330                &limiter,
331                ip(),
332                1_700_000_000,
333                3600,
334            )
335            .await;
336        }
337        let err = login(
338            "alice@example.com",
339            "hunter2!",
340            &store,
341            &jwks,
342            "https://pod.example/",
343            None,
344            &limiter,
345            ip(),
346            1_700_000_000,
347            3600,
348        )
349        .await
350        .unwrap_err();
351        match err {
352            LoginError::RateLimited { retry_after_secs } => {
353                assert!(retry_after_secs >= 1);
354            }
355            other => panic!("expected RateLimited, got {other:?}"),
356        }
357    }
358
359    #[tokio::test]
360    async fn login_rejects_blank_input() {
361        let (store, jwks, limiter) = seed();
362        let err = login(
363            "",
364            "",
365            &store,
366            &jwks,
367            "https://pod.example/",
368            None,
369            &limiter,
370            ip(),
371            0,
372            3600,
373        )
374        .await
375        .unwrap_err();
376        assert!(matches!(err, LoginError::InvalidRequest(_)));
377    }
378
379    // ---- password-length validation (JSS commit 1feead2) ----
380
381    #[test]
382    fn password_too_short_7_chars_rejected() {
383        let err = validate_password_length("1234567").unwrap_err();
384        match err {
385            LoginError::PasswordTooShort { min_length } => {
386                assert_eq!(min_length, 8);
387            }
388            other => panic!("expected PasswordTooShort, got {other:?}"),
389        }
390    }
391
392    #[test]
393    fn password_exactly_8_chars_accepted() {
394        validate_password_length("12345678").unwrap();
395    }
396
397    #[test]
398    fn password_longer_than_8_chars_accepted() {
399        validate_password_length("a]9Kz!#mN@xP").unwrap();
400    }
401
402    #[test]
403    fn empty_password_rejected() {
404        let err = validate_password_length("").unwrap_err();
405        match err {
406            LoginError::PasswordTooShort { min_length } => {
407                assert_eq!(min_length, 8);
408            }
409            other => panic!("expected PasswordTooShort, got {other:?}"),
410        }
411    }
412
413    #[test]
414    fn min_password_length_constant_is_8() {
415        assert_eq!(MIN_PASSWORD_LENGTH, 8);
416    }
417}