Skip to main content

rustlavel_oauth_provider/
client.rs

1//! Registered clients: who is allowed to ask this server for a token.
2//!
3//! ```ignore
4//! let clients = MemoryClientStore::new();
5//! clients.register(
6//!     Client::confidential("checkout", "the-generated-secret")
7//!         .named("Checkout")
8//!         .redirect_uri("https://checkout.test/oauth/callback")
9//!         .scopes(Scopes::of(["orders.read", "orders.write"])),
10//! )?;
11//! ```
12//!
13//! A production application implements [`ClientStore`] against its own table
14//! instead; see the module documentation on [`crate::store`] for why this crate
15//! has no database of its own.
16
17use 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/// A client that may ask this server to authorise a user.
25///
26/// Note that "confidential" is not a field: a client is confidential exactly
27/// when it has a secret. Storing the two separately would allow a client marked
28/// confidential with no secret to authenticate as anyone who guesses its id,
29/// which is the sort of state a schema should make unrepresentable rather than
30/// validate.
31#[derive(Clone)]
32pub struct Client {
33    pub id: String,
34    /// Shown to the user on the consent screen. Defaults to the id.
35    pub name: String,
36    /// SHA-256 of the secret; `None` for a public client. See
37    /// [`crate::store::digest`] for why this hash and not argon2.
38    secret_hash: Option<String>,
39    /// Matched byte-for-byte. See [`crate::redirect`].
40    pub redirect_uris: Vec<String>,
41    /// The most this client may ever be granted, whatever it asks for.
42    pub scopes: Scopes,
43    /// Whether this client is us. A first-party client with a recorded grant
44    /// skips the consent screen — asking a user to authorise our own front end
45    /// to talk to our own API teaches them to click through consent screens.
46    pub first_party: bool,
47}
48
49impl Client {
50    /// A client that can keep a secret: a server-side application.
51    ///
52    /// The secret is hashed immediately and the plaintext is dropped, so the
53    /// caller is the last thing that ever holds it. Generate it with
54    /// [`generate_secret`] and show it to the operator once.
55    pub fn confidential(id: impl Into<String>, secret: &str) -> Client {
56        Client { secret_hash: Some(digest(secret)), ..Client::public(id) }
57    }
58
59    /// A client that cannot keep a secret: a browser or mobile application.
60    ///
61    /// Its authenticity is established by PKCE and the registered redirect URI,
62    /// not by a credential — anything shipped inside a downloadable binary is
63    /// public whatever it is called.
64    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    /// Rebuild a client from a stored hash, for a [`ClientStore`] backed by a
77    /// table. The plaintext secret is not needed and should not be available.
78    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    /// Mark this client as one of ours. Read [`Client::first_party`] first.
98    pub fn first_party(mut self) -> Client {
99        self.first_party = true;
100        self
101    }
102
103    /// Whether this client holds a secret, and so can authenticate itself.
104    pub fn is_confidential(&self) -> bool {
105        self.secret_hash.is_some()
106    }
107
108    /// The stored hash, so an application can persist it. Never the secret.
109    pub fn secret_hash(&self) -> Option<&str> {
110        self.secret_hash.as_deref()
111    }
112
113    /// Whether the presented secret is this client's.
114    ///
115    /// A public client fails this unconditionally: it has no secret, so any
116    /// secret presented for it is either a mistake or an attempt.
117    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    /// Whether this exact redirect URI was registered. See [`crate::redirect`].
125    pub fn allows_redirect(&self, uri: &str) -> bool {
126        redirect::is_registered(&self.redirect_uris, uri)
127    }
128
129    /// Whether this client is fit to be registered.
130    ///
131    /// Checked when the client is added rather than when it is used, so a bad
132    /// redirect URI is a boot-time error with an explanation instead of a
133    /// redirect that quietly works for the wrong people.
134    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
146/// `Debug` prints no hash. It is not a usable credential, but it is the value
147/// an attacker with a stolen log needs in order to know a comparison succeeded.
148impl 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
161/// A 256-bit client secret, base64url so it survives a form body untouched.
162///
163/// Show it to the operator once, at registration. Nothing stores it: what this
164/// crate keeps is [`crate::store::digest`] of it, so a secret that is lost is
165/// rotated rather than recovered.
166pub fn generate_secret() -> String {
167    rustlavel_auth::base64::encode_url(&rustlavel_auth::random::bytes(32))
168}
169
170/// Where registered clients are read from.
171pub trait ClientStore: Send + Sync + 'static {
172    /// The client with this id, or `None` if it is unknown or disabled.
173    fn find<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Client>>;
174}
175
176/// Clients held in this process's memory: right for tests and a development
177/// server, wrong for anything that restarts.
178#[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    /// Add a client, refusing one that could not be safely authorised.
189    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    /// Add a client while building the store: `MemoryClientStore::new().with(client)`.
199    ///
200    /// Panics on an invalid client, because this form is used where there is no
201    /// `Result` to return — a `main.rs` line or a test — and a client with an
202    /// unusable redirect URI is a mistake to fix, not a condition to handle.
203    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        // Including the empty string, which is what a client that forgot to
246        // send `client_secret` would present.
247        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        // And presenting the stored hash is not presenting the secret.
260        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        // Lookup is exact: no case folding, no trimming.
321        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}