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

@@ -58,10 +58,10 @@ pub async fn system_tests() {
.await;
test.insert_account(admin);
/*directory::test(&test).await;
authentication::test(&test).await;*/
directory::test(&test).await;
authentication::test(&test).await;
oidc::test(&mut test).await;
/*authorization::test(&mut test).await;
authorization::test(&mut test).await;
tenant::test(&mut test).await;
security::test(&mut test).await;
quota::test(&mut test).await;
@@ -70,7 +70,7 @@ pub async fn system_tests() {
crypto::test(&mut test).await;
antispam::test(&mut test).await;
archiving::test(&mut test).await;
task::test(&mut test).await;*/
task::test(&mut test).await;
if test.is_reset() {
test.temp_dir.delete();

View File

@@ -17,7 +17,9 @@ use bytes::Bytes;
use common::auth::oauth::{
introspect::OAuthIntrospect,
oidc::StandardClaims,
registration::{ClientRegistrationRequest, ClientRegistrationResponse},
registration::{
ClientRegistrationRequest, ClientRegistrationResponse, TokenEndpointAuthMethod,
},
};
use http::auth::oauth::{
DeviceAuthResponse, ErrorType, TokenResponse,
@@ -31,7 +33,7 @@ use jmap_client::{
use registry::schema::{
enums::JwtSignatureAlgorithm,
prelude::{ObjectType, Property},
structs::{OidcProvider, SecretText, SecretTextValue},
structs::{OAuthClient, OidcProvider, SecretText, SecretTextValue},
};
use serde::{Serialize, de::DeserializeOwned};
use std::time::{Duration, Instant};
@@ -222,7 +224,7 @@ pub async fn test(test: &mut TestServer) {
// Dynamic Client Registration: invalid redirect URIs are rejected (RFC 7591 §3.2.2)
for bad_uri in [
"https://example.com/cb",
"http://example.com/cb",
"http://127.0.0.1/cb#frag",
"http://127.0.0.1/../cb",
] {
@@ -509,6 +511,176 @@ pub async fn test(test: &mut TestServer) {
.await;
pop3.assert_read(crate::utils::pop3::ResponseType::Ok).await;
// ------------------------
// Confidential client with client_secret
// ------------------------
// Registering a confidential client requires authentication and returns a
// generated client_secret exactly once. Web (https) redirect URIs are allowed.
let confidential_redirect = "https://confidential.example.org/callback";
let confidential: ClientRegistrationResponse = post_json_basic(
&metadata.registration_endpoint,
"admin",
"popolna_zapora",
&ClientRegistrationRequest {
redirect_uris: vec![confidential_redirect.to_string()],
scope: Some(PROFILE_SCOPE.to_string()),
token_endpoint_auth_method: Some(TokenEndpointAuthMethod::ClientSecretPost),
..Default::default()
},
)
.await;
let confidential_id = confidential.client_id;
let confidential_secret = confidential
.client_secret
.expect("confidential client must receive a client_secret");
assert!(
!confidential_id.starts_with("swc1."),
"confidential client id must be registry-backed, got {confidential_id}"
);
assert!(
confidential_secret.len() >= 40,
"client secret is too short: {confidential_secret}"
);
// Registering a confidential client anonymously must be rejected
let (status, _) = post_json_raw(
&metadata.registration_endpoint,
&ClientRegistrationRequest {
redirect_uris: vec![confidential_redirect.to_string()],
token_endpoint_auth_method: Some(TokenEndpointAuthMethod::ClientSecretBasic),
..Default::default()
},
)
.await;
assert_ne!(
status, 201,
"anonymous confidential client registration must be rejected"
);
let base_params = || {
AHashMap::from_iter([
("client_id".to_string(), confidential_id.to_string()),
(
"redirect_uri".to_string(),
confidential_redirect.to_string(),
),
("grant_type".to_string(), "authorization_code".to_string()),
])
};
// A confidential client that omits its secret must be rejected
let mut params = base_params();
params.insert(
"code".to_string(),
obtain_auth_code(&http, &confidential_id, confidential_redirect).await,
);
assert_eq!(
post::<TokenResponse>(&metadata.token_endpoint, &params).await,
TokenResponse::Error {
error: ErrorType::InvalidClient
},
"token request without client_secret must be rejected"
);
// A confidential client that presents a wrong secret must be rejected
let mut params = base_params();
params.insert(
"code".to_string(),
obtain_auth_code(&http, &confidential_id, confidential_redirect).await,
);
params.insert("client_secret".to_string(), "not-the-secret".to_string());
assert_eq!(
post::<TokenResponse>(&metadata.token_endpoint, &params).await,
TokenResponse::Error {
error: ErrorType::InvalidClient
},
"token request with a wrong client_secret must be rejected"
);
// The correct secret in the request body (client_secret_post) grants a usable token
let mut params = base_params();
params.insert(
"code".to_string(),
obtain_auth_code(&http, &confidential_id, confidential_redirect).await,
);
params.insert("client_secret".to_string(), confidential_secret.to_string());
let (token, _, _) = unwrap_token_response(post(&metadata.token_endpoint, &params).await);
let confidential_client = Client::new()
.credentials(Credentials::bearer(&token))
.accept_invalid_certs(true)
.follow_redirects(["127.0.0.1"])
.connect("https://127.0.0.1:8899")
.await
.unwrap();
assert_eq!(
confidential_client.default_account_id(),
user_id.to_string()
);
// The correct secret in the Authorization header (client_secret_basic) also works
let mut params = base_params();
params.remove("client_id");
params.insert(
"code".to_string(),
obtain_auth_code(&http, &confidential_id, confidential_redirect).await,
);
let granted: TokenResponse = post_form_basic(
&metadata.token_endpoint,
&confidential_id,
&confidential_secret,
&params,
)
.await;
unwrap_token_response(granted);
// A confidential client created through the management API must have its
// secret hashed before storage; authenticating with the plaintext secret
// only succeeds if the stored value is a verifiable hash.
let managed_secret = "managed-client-secret-abcdefghijklmnopqrstuvwxyz";
let managed_id = "managed-confidential-client";
admin
.registry_create_object(OAuthClient {
client_id: managed_id.to_string(),
redirect_uris: vec![confidential_redirect.to_string()].into(),
secret: Some(managed_secret.to_string()),
..Default::default()
})
.await;
let managed_params = || {
AHashMap::from_iter([
("client_id".to_string(), managed_id.to_string()),
(
"redirect_uri".to_string(),
confidential_redirect.to_string(),
),
("grant_type".to_string(), "authorization_code".to_string()),
])
};
let mut params = managed_params();
params.insert(
"code".to_string(),
obtain_auth_code(&http, managed_id, confidential_redirect).await,
);
params.insert("client_secret".to_string(), "wrong-secret".to_string());
assert_eq!(
post::<TokenResponse>(&metadata.token_endpoint, &params).await,
TokenResponse::Error {
error: ErrorType::InvalidClient
},
"management-api client must reject a wrong secret"
);
let mut params = managed_params();
params.insert(
"code".to_string(),
obtain_auth_code(&http, managed_id, confidential_redirect).await,
);
params.insert("client_secret".to_string(), managed_secret.to_string());
unwrap_token_response(post(&metadata.token_endpoint, &params).await);
// ------------------------
// Device code flow
// ------------------------
@@ -734,6 +906,74 @@ async fn post_json<D: DeserializeOwned>(
.unwrap()
}
async fn post_json_basic<D: DeserializeOwned>(
url: &str,
username: &str,
password: &str,
body: &impl Serialize,
) -> D {
let response = reqwest::Client::builder()
.timeout(Duration::from_millis(500))
.danger_accept_invalid_certs(true)
.build()
.unwrap_or_default()
.post(url)
.basic_auth(username, Some(password))
.body(serde_json::to_string(body).unwrap().into_bytes())
.send()
.await
.unwrap()
.bytes()
.await
.unwrap();
serde_json::from_slice(&response).unwrap()
}
async fn post_form_basic<T: DeserializeOwned>(
url: &str,
username: &str,
password: &str,
params: &AHashMap<String, String>,
) -> T {
let response = reqwest::Client::builder()
.timeout(Duration::from_millis(500))
.danger_accept_invalid_certs(true)
.build()
.unwrap_or_default()
.post(url)
.basic_auth(username, Some(password))
.form(params)
.send()
.await
.unwrap()
.bytes()
.await
.unwrap();
serde_json::from_slice(&response).unwrap()
}
async fn obtain_auth_code(http: &HttpRequest, client_id: &str, redirect_uri: &str) -> String {
http.post::<LoginResponse>(
"/api/auth",
&LoginRequest::AuthCode {
account_name: "user@example.org".to_string(),
account_secret: "this is a very strong password".to_string(),
mfa_token: None,
client_id: client_id.to_string(),
redirect_uri: redirect_uri.to_string().into(),
nonce: None,
scope: Some(PROFILE_SCOPE.to_string()),
code_challenge: None,
code_challenge_method: None,
state: None,
resource: vec![],
},
)
.await
.unwrap()
.unwrap_code()
}
async fn post_json_raw(url: &str, body: &impl Serialize) -> (u16, serde_json::Value) {
let response = reqwest::Client::builder()
.timeout(Duration::from_millis(500))