OAuth client secret verification for confidential clients

This commit is contained in:
Maurus Decimus
2026-06-17 19:04:01 +02:00
parent a6e1f97915
commit 8778e1bc45
8 changed files with 400 additions and 22 deletions

View File

@@ -219,7 +219,7 @@ impl OAuthApiHandler for Server {
}
grant_scope(scope.as_deref(), meta.scope_mask)
}
None => grant_scope(scope.as_deref(), u64::MAX),
None => scope,
};
// Validate Resource Indicators (RFC 8707)

View File

@@ -19,11 +19,12 @@ use common::{
},
},
};
use directory::core::secret::{hash_secret, verify_secret_hash};
use http_proto::{request::fetch_body, *};
use hyper::StatusCode;
use registry::schema::{
enums::Permission,
prelude::{ObjectType, Property},
enums::{PasswordHashAlgorithm, Permission},
prelude::{ObjectType, Property, UTCDateTime},
structs::OAuthClient,
};
use std::future::Future;
@@ -48,6 +49,12 @@ pub trait ClientRegistrationHandler: Sync + Send {
redirect_uri: Option<&str>,
account_id: u32,
) -> impl Future<Output = trc::Result<Option<ErrorType>>> + Send;
fn verify_client_secret(
&self,
client_id: &str,
client_secret: Option<&str>,
) -> impl Future<Output = trc::Result<Option<ErrorType>>> + Send;
}
impl ClientRegistrationHandler for Server {
async fn handle_oauth_registration_request(
@@ -143,6 +150,19 @@ impl ClientRegistrationHandler for Server {
.map(|ch| char::from(ch.to_ascii_lowercase()))
.collect::<String>();
// Generate client secret
let client_secret = rng()
.sample_iter(Alphanumeric)
.take(48)
.map(char::from)
.collect::<String>();
let secret_hash = hash_secret(
PasswordHashAlgorithm::Argon2id,
client_secret.clone().into_bytes(),
)
.await
.caused_by(trc::location!())?;
let result = self
.registry()
.write(RegistryWrite::insert(
@@ -153,6 +173,8 @@ impl ClientRegistrationHandler for Server {
member_tenant_id: tenant_id.map(|id| Id::new(id as u64)),
redirect_uris: request.redirect_uris.clone().into(),
logo: request.logo_uri.clone(),
secret: Some(secret_hash),
created_at: UTCDateTime::now(),
..Default::default()
}
.into(),
@@ -178,6 +200,9 @@ impl ClientRegistrationHandler for Server {
StatusCode::CREATED,
ClientRegistrationResponse {
client_id,
client_secret: Some(client_secret),
client_id_issued_at: Some(now()),
client_secret_expires_at: Some(0),
request,
..Default::default()
},
@@ -253,6 +278,50 @@ impl ClientRegistrationHandler for Server {
ErrorType::InvalidRequest
}))
}
async fn verify_client_secret(
&self,
client_id: &str,
client_secret: Option<&str>,
) -> trc::Result<Option<ErrorType>> {
// Stateless and unregistered clients have no secret to verify
if decode_client_id(self.core.oauth.oauth_key.as_bytes(), client_id).is_some() {
return Ok(None);
}
let Some(client_id) = self
.registry()
.primary_key(
ObjectType::OAuthClient.into(),
Property::ClientId,
client_id.as_bytes().to_vec(),
)
.await?
else {
return Ok(None);
};
let Some(client) = self
.registry()
.object::<OAuthClient>(client_id.id())
.await
.caused_by(trc::location!())?
else {
return Ok(None);
};
match client.secret.as_deref() {
Some(hash) if !hash.is_empty() => match client_secret {
Some(secret)
if verify_secret_hash(hash, secret.as_bytes())
.await
.caused_by(trc::location!())? =>
{
Ok(None)
}
_ => Ok(Some(ErrorType::InvalidClient)),
},
_ => Ok(None),
}
}
}
fn registration_error(error: ClientRegistrationError) -> HttpResponse {

View File

@@ -8,7 +8,11 @@ use super::{
ArchivedOAuthStatus, ArchivedPkceCodeChallenge, ErrorType, FormData, MAX_POST_LEN, OAuthCode,
OAuthResponse, OAuthStatus, TokenResponse, registration::ClientRegistrationHandler,
};
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
use crate::auth::authenticate::HttpHeaders;
use base64::{
Engine,
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
};
use common::{
KV_OAUTH, Server,
auth::{
@@ -19,7 +23,7 @@ use common::{
use http_proto::*;
use hyper::StatusCode;
use sha2::{Digest, Sha256};
use std::future::Future;
use std::{borrow::Cow, future::Future};
use store::{
dispatch::lookup::KeyValue,
write::{AlignedBytes, Archive},
@@ -63,6 +67,7 @@ impl TokenHandler for Server {
// Parse form
let params = FormData::from_request(req, MAX_POST_LEN, session.session_id).await?;
let grant_type = params.get("grant_type").unwrap_or_default();
let (client_id_cred, client_secret_cred) = client_credentials(req, &params);
let mut response = TokenResponse::error(ErrorType::InvalidGrant);
@@ -71,7 +76,7 @@ impl TokenHandler for Server {
if grant_type.eq_ignore_ascii_case("authorization_code") {
response = if let (Some(code), Some(client_id), Some(redirect_uri)) = (
params.get("code"),
params.get("client_id"),
client_id_cred.as_deref(),
params.get("redirect_uri"),
) {
// Obtain code
@@ -102,6 +107,11 @@ impl TokenHandler for Server {
.await?
{
TokenResponse::error(error)
} else if let Some(error) = self
.verify_client_secret(client_id, client_secret_cred.as_deref())
.await?
{
TokenResponse::error(error)
} else {
// Mark this token as issued
self.in_memory_store()
@@ -212,6 +222,17 @@ impl TokenHandler for Server {
}
} else if grant_type.eq_ignore_ascii_case("refresh_token") {
if let Some(refresh_token) = params.get("refresh_token") {
if let Some(client_id) = client_id_cred.as_deref()
&& let Some(error) = self
.verify_client_secret(client_id, client_secret_cred.as_deref())
.await?
{
return Ok(JsonResponse::with_status(
StatusCode::BAD_REQUEST,
TokenResponse::error(error),
)
.into_http_response());
}
response = match self
.validate_access_token(GrantType::RefreshToken.into(), refresh_token)
.await
@@ -352,6 +373,35 @@ impl TokenHandler for Server {
}
}
fn client_credentials<'x>(
req: &'x HttpRequest,
params: &'x FormData,
) -> (Option<Cow<'x, str>>, Option<Cow<'x, str>>) {
let mut client_id = params.get("client_id").map(Cow::Borrowed);
let mut client_secret = params.get("client_secret").map(Cow::Borrowed);
if (client_id.is_none() || client_secret.is_none())
&& let Some((id, secret)) = req
.authorization_basic()
.and_then(|token| STANDARD.decode(token).ok())
.and_then(|bytes| String::from_utf8(bytes).ok())
.and_then(|creds| {
creds
.split_once(':')
.map(|(id, secret)| (id.to_string(), secret.to_string()))
})
{
if client_id.is_none() {
client_id = Some(Cow::Owned(id));
}
if client_secret.is_none() {
client_secret = Some(Cow::Owned(secret));
}
}
(client_id, client_secret)
}
fn verify_pkce(stored: &ArchivedPkceCodeChallenge, verifier: Option<&str>) -> bool {
let is_valid_pkce_challenge = |challenge: &str| {
(43..=128).contains(&challenge.len())