use crate::redirect;
use crate::store::{StoreFuture, digest, digest_matches};
use rustlavel_core::{Error, Result};
use rustlavel_oauth::Scopes;
use std::collections::HashMap;
use std::sync::Mutex;
#[derive(Clone)]
pub struct Client {
pub id: String,
pub name: String,
secret_hash: Option<String>,
pub redirect_uris: Vec<String>,
pub scopes: Scopes,
pub first_party: bool,
}
impl Client {
pub fn confidential(id: impl Into<String>, secret: &str) -> Client {
Client { secret_hash: Some(digest(secret)), ..Client::public(id) }
}
pub fn public(id: impl Into<String>) -> Client {
let id = id.into();
Client {
name: id.clone(),
id,
secret_hash: None,
redirect_uris: Vec::new(),
scopes: Scopes::new(),
first_party: false,
}
}
pub fn from_secret_hash(id: impl Into<String>, secret_hash: impl Into<String>) -> Client {
Client { secret_hash: Some(secret_hash.into()), ..Client::public(id) }
}
pub fn named(mut self, name: impl Into<String>) -> Client {
self.name = name.into();
self
}
pub fn redirect_uri(mut self, uri: impl Into<String>) -> Client {
self.redirect_uris.push(uri.into());
self
}
pub fn scopes(mut self, scopes: Scopes) -> Client {
self.scopes = scopes;
self
}
pub fn first_party(mut self) -> Client {
self.first_party = true;
self
}
pub fn is_confidential(&self) -> bool {
self.secret_hash.is_some()
}
pub fn secret_hash(&self) -> Option<&str> {
self.secret_hash.as_deref()
}
pub fn verify_secret(&self, presented: &str) -> bool {
match &self.secret_hash {
Some(stored) => digest_matches(presented, stored),
None => false,
}
}
pub fn allows_redirect(&self, uri: &str) -> bool {
redirect::is_registered(&self.redirect_uris, uri)
}
pub fn validate(&self) -> std::result::Result<(), String> {
if self.id.trim().is_empty() {
return Err("a client needs an id".to_string());
}
for uri in &self.redirect_uris {
redirect::validate_registration(uri)
.map_err(|why| format!("client {:?}: {why}", self.id))?;
}
Ok(())
}
}
impl std::fmt::Debug for Client {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Client")
.field("id", &self.id)
.field("name", &self.name)
.field("secret_hash", &self.secret_hash.as_ref().map(|_| "<redacted>"))
.field("redirect_uris", &self.redirect_uris)
.field("scopes", &self.scopes)
.field("first_party", &self.first_party)
.finish()
}
}
pub fn generate_secret() -> String {
rustlavel_auth::base64::encode_url(&rustlavel_auth::random::bytes(32))
}
pub trait ClientStore: Send + Sync + 'static {
fn find<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Client>>;
}
#[derive(Default)]
pub struct MemoryClientStore {
clients: Mutex<HashMap<String, Client>>,
}
impl MemoryClientStore {
pub fn new() -> MemoryClientStore {
MemoryClientStore::default()
}
pub fn register(&self, client: Client) -> Result<()> {
client.validate().map_err(Error::msg)?;
self.clients
.lock()
.expect("client store lock poisoned")
.insert(client.id.clone(), client);
Ok(())
}
pub fn with(self, client: Client) -> MemoryClientStore {
self.register(client).expect("client is registerable");
self
}
pub fn forget(&self, id: &str) {
self.clients.lock().expect("client store lock poisoned").remove(id);
}
pub fn len(&self) -> usize {
self.clients.lock().expect("client store lock poisoned").len()
}
pub fn is_empty(&self) -> bool {
self.len() == 0
}
}
impl ClientStore for MemoryClientStore {
fn find<'a>(&'a self, id: &'a str) -> StoreFuture<'a, Option<Client>> {
Box::pin(async move {
Ok(self.clients.lock().expect("client store lock poisoned").get(id).cloned())
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_confidential_client_verifies_its_own_secret_and_no_other() {
let client = Client::confidential("web", "s3cret");
assert!(client.is_confidential());
assert!(client.verify_secret("s3cret"));
assert!(!client.verify_secret("s3creT"));
assert!(!client.verify_secret(""));
}
#[test]
fn a_public_client_accepts_no_secret_at_all() {
let client = Client::public("spa");
assert!(!client.is_confidential());
assert!(!client.verify_secret(""));
assert!(!client.verify_secret("anything"));
}
#[test]
fn the_secret_is_not_kept_anywhere_on_the_client() {
let client = Client::confidential("web", "s3cret");
assert_ne!(client.secret_hash(), Some("s3cret"));
assert!(!client.verify_secret(client.secret_hash().expect("hashed")));
}
#[test]
fn debug_prints_no_hash() {
let printed = format!("{:?}", Client::confidential("web", "s3cret"));
assert!(!printed.contains("s3cret"));
assert!(!printed.contains(&digest("s3cret")));
assert!(printed.contains("redacted"));
}
#[test]
fn a_client_from_a_stored_hash_verifies_the_same_way() {
let stored = Client::confidential("web", "s3cret");
let loaded =
Client::from_secret_hash("web", stored.secret_hash().expect("hashed").to_string());
assert!(loaded.verify_secret("s3cret"));
}
#[test]
fn a_generated_secret_is_long_random_and_url_safe() {
let secret = generate_secret();
assert_eq!(secret.len(), 43, "32 bytes, base64url, unpadded");
assert!(secret.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
assert_ne!(secret, generate_secret());
}
#[test]
fn redirect_matching_goes_through_the_registered_set_exactly() {
let client = Client::public("spa").redirect_uri("https://a.test/cb");
assert!(client.allows_redirect("https://a.test/cb"));
assert!(!client.allows_redirect("https://a.test/cb/"));
assert!(!client.allows_redirect("https://a.test.evil.com/cb"));
}
#[test]
fn registration_refuses_a_client_with_an_unsafe_redirect_uri() {
let store = MemoryClientStore::new();
let error = store
.register(Client::public("spa").redirect_uri("http://example.com/cb"))
.unwrap_err()
.to_string();
assert!(error.contains("spa"), "the message names the client: {error}");
assert!(error.contains("clear text"), "got {error}");
assert!(store.is_empty(), "a refused client must not be half-registered");
}
#[tokio::test]
async fn the_store_finds_what_was_registered_and_nothing_else() {
let store = MemoryClientStore::new()
.with(Client::public("spa").redirect_uri("https://a.test/cb"));
assert!(store.find("spa").await.expect("lookup").is_some());
assert!(store.find("nope").await.expect("lookup").is_none());
assert!(store.find("SPA").await.expect("lookup").is_none());
assert!(store.find(" spa").await.expect("lookup").is_none());
}
#[tokio::test]
async fn a_forgotten_client_stops_resolving() {
let store = MemoryClientStore::new().with(Client::public("spa"));
store.forget("spa");
assert!(store.find("spa").await.expect("lookup").is_none());
}
}