rustlavel_oauth_provider/
client.rs1use crate::redirect;
18use crate::store::{StoreFuture, digest, digest_matches};
19use rustlavel_core::{Error, Result};
20use rustlavel_oauth::Scopes;
21use std::collections::HashMap;
22use std::sync::Mutex;
23
24#[derive(Clone)]
32pub struct Client {
33 pub id: String,
34 pub name: String,
36 secret_hash: Option<String>,
39 pub redirect_uris: Vec<String>,
41 pub scopes: Scopes,
43 pub first_party: bool,
47}
48
49impl Client {
50 pub fn confidential(id: impl Into<String>, secret: &str) -> Client {
56 Client { secret_hash: Some(digest(secret)), ..Client::public(id) }
57 }
58
59 pub fn public(id: impl Into<String>) -> Client {
65 let id = id.into();
66 Client {
67 name: id.clone(),
68 id,
69 secret_hash: None,
70 redirect_uris: Vec::new(),
71 scopes: Scopes::new(),
72 first_party: false,
73 }
74 }
75
76 pub fn from_secret_hash(id: impl Into<String>, secret_hash: impl Into<String>) -> Client {
79 Client { secret_hash: Some(secret_hash.into()), ..Client::public(id) }
80 }
81
82 pub fn named(mut self, name: impl Into<String>) -> Client {
83 self.name = name.into();
84 self
85 }
86
87 pub fn redirect_uri(mut self, uri: impl Into<String>) -> Client {
88 self.redirect_uris.push(uri.into());
89 self
90 }
91
92 pub fn scopes(mut self, scopes: Scopes) -> Client {
93 self.scopes = scopes;
94 self
95 }
96
97 pub fn first_party(mut self) -> Client {
99 self.first_party = true;
100 self
101 }
102
103 pub fn is_confidential(&self) -> bool {
105 self.secret_hash.is_some()
106 }
107
108 pub fn secret_hash(&self) -> Option<&str> {
110 self.secret_hash.as_deref()
111 }
112
113 pub fn verify_secret(&self, presented: &str) -> bool {
118 match &self.secret_hash {
119 Some(stored) => digest_matches(presented, stored),
120 None => false,
121 }
122 }
123
124 pub fn allows_redirect(&self, uri: &str) -> bool {
126 redirect::is_registered(&self.redirect_uris, uri)
127 }
128
129 pub fn validate(&self) -> std::result::Result<(), String> {
135 if self.id.trim().is_empty() {
136 return Err("a client needs an id".to_string());
137 }
138 for uri in &self.redirect_uris {
139 redirect::validate_registration(uri)
140 .map_err(|why| format!("client {:?}: {why}", self.id))?;
141 }
142 Ok(())
143 }
144}
145
146impl std::fmt::Debug for Client {
149 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
150 f.debug_struct("Client")
151 .field("id", &self.id)
152 .field("name", &self.name)
153 .field("secret_hash", &self.secret_hash.as_ref().map(|_| "<redacted>"))
154 .field("redirect_uris", &self.redirect_uris)
155 .field("scopes", &self.scopes)
156 .field("first_party", &self.first_party)
157 .finish()
158 }
159}
160
161pub fn generate_secret() -> String {
167 rustlavel_auth::base64::encode_url(&rustlavel_auth::random::bytes(32))
168}
169
170pub trait ClientStore: Send + Sync + 'static {
172 fn find<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Client>>;
174}
175
176#[derive(Default)]
179pub struct MemoryClientStore {
180 clients: Mutex<HashMap<String, Client>>,
181}
182
183impl MemoryClientStore {
184 pub fn new() -> MemoryClientStore {
185 MemoryClientStore::default()
186 }
187
188 pub fn register(&self, client: Client) -> Result<()> {
190 client.validate().map_err(Error::msg)?;
191 self.clients
192 .lock()
193 .expect("client store lock poisoned")
194 .insert(client.id.clone(), client);
195 Ok(())
196 }
197
198 pub fn with(self, client: Client) -> MemoryClientStore {
204 self.register(client).expect("client is registerable");
205 self
206 }
207
208 pub fn forget(&self, id: &str) {
209 self.clients.lock().expect("client store lock poisoned").remove(id);
210 }
211
212 pub fn len(&self) -> usize {
213 self.clients.lock().expect("client store lock poisoned").len()
214 }
215
216 pub fn is_empty(&self) -> bool {
217 self.len() == 0
218 }
219}
220
221impl ClientStore for MemoryClientStore {
222 fn find<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Client>> {
223 Box::pin(async move {
224 Ok(self.clients.lock().expect("client store lock poisoned").get(id).cloned())
225 })
226 }
227}
228
229#[cfg(test)]
230mod tests {
231 use super::*;
232
233 #[test]
234 fn a_confidential_client_verifies_its_own_secret_and_no_other() {
235 let client = Client::confidential("web", "s3cret");
236
237 assert!(client.is_confidential());
238 assert!(client.verify_secret("s3cret"));
239 assert!(!client.verify_secret("s3creT"));
240 assert!(!client.verify_secret(""));
241 }
242
243 #[test]
244 fn a_public_client_accepts_no_secret_at_all() {
245 let client = Client::public("spa");
248
249 assert!(!client.is_confidential());
250 assert!(!client.verify_secret(""));
251 assert!(!client.verify_secret("anything"));
252 }
253
254 #[test]
255 fn the_secret_is_not_kept_anywhere_on_the_client() {
256 let client = Client::confidential("web", "s3cret");
257
258 assert_ne!(client.secret_hash(), Some("s3cret"));
259 assert!(!client.verify_secret(client.secret_hash().expect("hashed")));
261 }
262
263 #[test]
264 fn debug_prints_no_hash() {
265 let printed = format!("{:?}", Client::confidential("web", "s3cret"));
266
267 assert!(!printed.contains("s3cret"));
268 assert!(!printed.contains(&digest("s3cret")));
269 assert!(printed.contains("redacted"));
270 }
271
272 #[test]
273 fn a_client_from_a_stored_hash_verifies_the_same_way() {
274 let stored = Client::confidential("web", "s3cret");
275 let loaded =
276 Client::from_secret_hash("web", stored.secret_hash().expect("hashed").to_string());
277
278 assert!(loaded.verify_secret("s3cret"));
279 }
280
281 #[test]
282 fn a_generated_secret_is_long_random_and_url_safe() {
283 let secret = generate_secret();
284
285 assert_eq!(secret.len(), 43, "32 bytes, base64url, unpadded");
286 assert!(secret.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
287 assert_ne!(secret, generate_secret());
288 }
289
290 #[test]
291 fn redirect_matching_goes_through_the_registered_set_exactly() {
292 let client = Client::public("spa").redirect_uri("https://a.test/cb");
293
294 assert!(client.allows_redirect("https://a.test/cb"));
295 assert!(!client.allows_redirect("https://a.test/cb/"));
296 assert!(!client.allows_redirect("https://a.test.evil.com/cb"));
297 }
298
299 #[test]
300 fn registration_refuses_a_client_with_an_unsafe_redirect_uri() {
301 let store = MemoryClientStore::new();
302
303 let error = store
304 .register(Client::public("spa").redirect_uri("http://example.com/cb"))
305 .unwrap_err()
306 .to_string();
307
308 assert!(error.contains("spa"), "the message names the client: {error}");
309 assert!(error.contains("clear text"), "got {error}");
310 assert!(store.is_empty(), "a refused client must not be half-registered");
311 }
312
313 #[tokio::test]
314 async fn the_store_finds_what_was_registered_and_nothing_else() {
315 let store = MemoryClientStore::new()
316 .with(Client::public("spa").redirect_uri("https://a.test/cb"));
317
318 assert!(store.find("spa").await.expect("lookup").is_some());
319 assert!(store.find("nope").await.expect("lookup").is_none());
320 assert!(store.find("SPA").await.expect("lookup").is_none());
322 assert!(store.find(" spa").await.expect("lookup").is_none());
323 }
324
325 #[tokio::test]
326 async fn a_forgotten_client_stops_resolving() {
327 let store = MemoryClientStore::new().with(Client::public("spa"));
328 store.forget("spa");
329
330 assert!(store.find("spa").await.expect("lookup").is_none());
331 }
332}