1use 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
28pub const MIN_PASSWORD_LENGTH: usize = 8;
32
33pub const RATE_LIMIT_ROUTE: &str = "idp_credentials";
37
38#[derive(Debug, Error)]
40pub enum LoginError {
41 #[error("rate limited, retry after {retry_after_secs}s")]
43 RateLimited {
44 retry_after_secs: u64,
46 },
47
48 #[error("invalid credentials")]
51 InvalidGrant,
52
53 #[error("password must be at least {min_length} characters")]
56 PasswordTooShort {
57 min_length: usize,
59 },
60
61 #[error("invalid request: {0}")]
63 InvalidRequest(String),
64
65 #[error("user store: {0}")]
67 UserStore(String),
68
69 #[error("token issuance: {0}")]
71 Token(String),
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct CredentialsResponse {
77 pub access_token: String,
79 pub token_type: String,
81 pub expires_in: u64,
83 pub webid: String,
85 pub id: String,
87}
88
89#[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 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 if email.is_empty() || password.is_empty() {
130 return Err(LoginError::InvalidRequest(
131 "email and password are required".into(),
132 ));
133 }
134
135 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 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", "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
181pub 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 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 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 #[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}