From 6d9aee4d9fa7c4e56c6b1ff2f3ad2ffc703dd2ae Mon Sep 17 00:00:00 2001 From: lafleur Date: Sat, 9 Aug 2025 01:18:50 +0200 Subject: [PATCH] basic OIDC client registrar with auth tracing --- Cargo.lock | 2 + Cargo.toml | 3 + src/service/Cargo.toml | 1 + src/service/oidc/mod.rs | 5 +- src/service/oidc/registrar.rs | 163 ++++++++++++++++++++++++++++++++++ 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 src/service/oidc/registrar.rs diff --git a/Cargo.lock b/Cargo.lock index 82e7a20d..599181ef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1003,6 +1003,8 @@ dependencies = [ "log", "loole", "lru-cache", + "once_cell", + "oxide-auth", "rand 0.8.5", "regex", "reqwest", diff --git a/Cargo.toml b/Cargo.toml index 0e2694ef..fa61b631 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -543,6 +543,9 @@ version = "1.0.2" [workspace.dependencies.oxide-auth] version = "0.6.1" +[workspace.dependencies.once_cell] +version = "1.21.3" + [workspace.dependencies.percent-encoding] version = "2.3.1" diff --git a/src/service/Cargo.toml b/src/service/Cargo.toml index b62bf1e8..38459d25 100644 --- a/src/service/Cargo.toml +++ b/src/service/Cargo.toml @@ -112,6 +112,7 @@ webpage.optional = true blurhash.workspace = true blurhash.optional = true oxide-auth.workspace = true +once_cell.workspace = true [lints] workspace = true diff --git a/src/service/oidc/mod.rs b/src/service/oidc/mod.rs index 93224eda..7cee2026 100644 --- a/src/service/oidc/mod.rs +++ b/src/service/oidc/mod.rs @@ -7,6 +7,8 @@ //! //! [oxide-auth]: https://docs.rs/oxide-auth +pub mod registrar; + use std::sync::{Arc, Mutex}; use async_trait::async_trait; @@ -15,11 +17,12 @@ use oxide_auth::{ frontends::simple::endpoint::{Generic, Vacant}, primitives::{ prelude::{ - AuthMap, Authorizer, Client, ClientMap, Issuer, RandomGenerator, Registrar, TokenMap, + AuthMap, Authorizer, Client, Issuer, RandomGenerator, Registrar, TokenMap, }, registrar::RegisteredUrl, }, }; +use registrar::ClientMap; pub struct Service { registrar: Mutex, diff --git a/src/service/oidc/registrar.rs b/src/service/oidc/registrar.rs new file mode 100644 index 00000000..893238de --- /dev/null +++ b/src/service/oidc/registrar.rs @@ -0,0 +1,163 @@ +use std::borrow::Cow; +use url::Url; +use std::collections::HashMap; +use std::iter::{Extend, FromIterator}; + +use oxide_auth::endpoint::{PreGrant, Scope}; +use oxide_auth::primitives::prelude::{Client, ClientUrl}; +use oxide_auth::primitives::registrar::{Argon2, BoundClient, EncodedClient, PasswordPolicy, RegisteredClient, RegisteredUrl, Registrar, RegistrarError}; +use once_cell::sync::Lazy; + +/// oxide-auth can only ignore ports on localhost if it's spelled "localhost", +/// not "127.0.0.1" or "[::1]". This function does that replacement. +pub fn normalize_redirect_hostname(url: Url) -> Url { + let mut new_url = url.clone(); + let new_host = url.host_str().map(|h| + h.replace("127.0.0.1", "localhost").replace("[::1]", "localhost") + ); + new_url.set_host(new_host.as_deref()).expect("replaceable redirect hostname"); + + new_url +} + +/// The redirect_uri has to be wrapped in an IgnorePortOnLocalhost for oxide-auth +/// to ignore the port when comparing it with the registered ones. +pub fn normalize_redirect(url: Url) -> RegisteredUrl { + let new_url = normalize_redirect_hostname(url); + + match new_url.host_str() { + Some("localhost") => RegisteredUrl::IgnorePortOnLocalhost(new_url.into()), + _ => RegisteredUrl::Semantic(new_url) + } +} + + +static DEFAULT_PASSWORD_POLICY: Lazy = Lazy::new(Argon2::default); + +/// A very simple, in-memory hash map of client ids to Client entries. +#[derive(Default)] +pub struct ClientMap { + clients: HashMap, + password_policy: Option>, +} + +impl ClientMap { + /// Create an empty map without any clients in it. + pub fn new() -> ClientMap { + ClientMap::default() + } + + /// Insert or update the client record. + pub fn register_client(&mut self, client: Client) { + let password_policy = Self::current_policy(&self.password_policy); + let client = client.encode(password_policy); + self.clients.insert(client.client_id.clone(), client); + } + + /// Change how passwords are encoded while stored. + pub fn set_password_policy(&mut self, new_policy: P) { + self.password_policy = Some(Box::new(new_policy)) + } + + pub fn get_redirect(&self, client: Client) -> RegisteredUrl { + let password_policy = Self::current_policy(&self.password_policy); + let client = client.encode(password_policy); + + client.redirect_uri + } + + // This is not an instance method because it needs to borrow the box but register needs &mut + fn current_policy<'a>(policy: &'a Option>) -> &'a dyn PasswordPolicy { + policy + .as_ref() + .map(|boxed| &**boxed) + .unwrap_or(&*DEFAULT_PASSWORD_POLICY) + } +} + +impl Extend for ClientMap { + fn extend(&mut self, iter: I) + where + I: IntoIterator, + { + iter.into_iter().for_each(|client| self.register_client(client)) + } +} + +impl FromIterator for ClientMap { + fn from_iter(iter: I) -> Self + where + I: IntoIterator, + { + let mut into = ClientMap::new(); + into.extend(iter); + into + } +} + +impl Registrar for ClientMap { + fn bound_redirect<'a>(&self, bound: ClientUrl<'a>) -> Result, RegistrarError> { + let client = match self.clients.get(bound.client_id.as_ref()) { + None => { + tracing::debug!("this client was not registered: {}", bound.client_id); + return Err(RegistrarError::Unspecified); + }, + Some(stored) => stored, + }; + + // Perform exact matching as motivated in the rfc, just substitute "127.0.0.1" and + // "[::1]" for "localhost". + let redirect_uri = bound.redirect_uri + .map(|u| normalize_redirect(u.to_url())); + let registered_url = match redirect_uri { + None => client.redirect_uri.clone(), + Some(url) => { + let original = std::iter::once(&client.redirect_uri); + let alternatives = client.additional_redirect_uris.iter(); + if original + .chain(alternatives) + .any(|registered| *registered == url) + { + url.clone().into() + } else { + tracing::debug!("the request's redirect url didn't match any registered. bound: {:?}, in client {:#?}", url, client); + return Err(RegistrarError::Unspecified); + } + } + }; + + Ok(BoundClient { + client_id: bound.client_id, + redirect_uri: Cow::Owned(registered_url), + }) + } + + /// Always overrides the scope with a default scope. + fn negotiate(&self, bound: BoundClient<'_>, _scope: Option) -> Result { + let client = self + .clients + .get(bound.client_id.as_ref()) + .expect("Bound client appears to not have been constructed with this registrar"); + Ok(PreGrant { + client_id: bound.client_id.into_owned(), + redirect_uri: bound.redirect_uri.into_owned(), + scope: client.default_scope.clone(), + }) + } + + fn check(&self, client_id: &str, passphrase: Option<&[u8]>) -> Result<(), RegistrarError> { + let password_policy = Self::current_policy(&self.password_policy); + + self.clients + .get(client_id) + .ok_or_else(|| { + tracing::debug!("this client is not registered yet: {client_id:?}."); + RegistrarError::Unspecified + }).and_then(|client| { + RegisteredClient::new(client, password_policy).check_authentication(passphrase) + })?; + + Ok(()) + } +} +