OAuth client secret verification for confidential clients
This commit is contained in:
@@ -212,16 +212,16 @@ pub fn validate_redirect_uri(uri: &str) -> Result<(), ClientRegistrationError> {
|
||||
return Err(ClientRegistrationError::invalid_redirect_uri(
|
||||
"Redirect URI must not contain a fragment.",
|
||||
));
|
||||
}
|
||||
if uri.contains("..") {
|
||||
} else if uri.contains("..") {
|
||||
return Err(ClientRegistrationError::invalid_redirect_uri(
|
||||
"Redirect URI must not contain consecutive dots.",
|
||||
));
|
||||
}
|
||||
if uri.starts_with("http://127.0.0.1/") || uri.starts_with("http://[::1]/") {
|
||||
} else if uri.starts_with("http://127.0.0.1/")
|
||||
|| uri.starts_with("http://[::1]/")
|
||||
|| uri.starts_with("https://")
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
if let Some((scheme, _)) = uri.split_once(':')
|
||||
} else if let Some((scheme, _)) = uri.split_once(':')
|
||||
&& scheme.contains('.')
|
||||
&& scheme
|
||||
.as_bytes()
|
||||
@@ -235,7 +235,7 @@ pub fn validate_redirect_uri(uri: &str) -> Result<(), ClientRegistrationError> {
|
||||
}
|
||||
|
||||
Err(ClientRegistrationError::invalid_redirect_uri(
|
||||
"Redirect URI must be a loopback (http://127.0.0.1/, http://[::1]/) or private-use scheme URI.",
|
||||
"Redirect URI must be an https URL, a loopback (http://127.0.0.1/, http://[::1]/) or a private-use scheme URI.",
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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, ¶ms);
|
||||
|
||||
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())
|
||||
|
||||
@@ -31,6 +31,7 @@ use common::{
|
||||
Server, auth::AccessToken, cache::invalidate::CacheInvalidationBuilder,
|
||||
expr::if_block::BootstrapExprExt, ipc::CacheInvalidation,
|
||||
};
|
||||
use directory::core::secret::{hash_secret, is_password_hash};
|
||||
use http_proto::HttpSessionData;
|
||||
use jmap_proto::{
|
||||
error::set::{SetError, SetErrorType},
|
||||
@@ -463,8 +464,25 @@ impl RegistrySet for Server {
|
||||
ObjectInner::MailingList(_) if is_create => {
|
||||
validate_tenant_quota(&set, TenantStorageQuota::MaxMailingLists).await?
|
||||
}
|
||||
ObjectInner::OAuthClient(_) if is_create => {
|
||||
validate_tenant_quota(&set, TenantStorageQuota::MaxOauthClients).await?
|
||||
ObjectInner::OAuthClient(client) => {
|
||||
if let Some(secret) = client.secret.as_mut()
|
||||
&& !secret.is_empty()
|
||||
&& !(matches!(secret.as_bytes().first(), Some(&b'$' | &b'{'))
|
||||
&& is_password_hash(secret))
|
||||
{
|
||||
*secret = hash_secret(
|
||||
set.server.core.network.security.password_hash_algorithm,
|
||||
std::mem::take(secret).into_bytes(),
|
||||
)
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
}
|
||||
if is_create {
|
||||
validate_tenant_quota(&set, TenantStorageQuota::MaxOauthClients)
|
||||
.await?
|
||||
} else {
|
||||
Ok(ObjectResponse::default())
|
||||
}
|
||||
}
|
||||
ObjectInner::Directory(_) if is_create => {
|
||||
validate_tenant_quota(&set, TenantStorageQuota::MaxDirectories).await?
|
||||
|
||||
Reference in New Issue
Block a user