OAuth Profile for Open Public Clients

This commit is contained in:
Maurus Decimus
2026-06-17 18:04:46 +02:00
parent c9e79025bc
commit a6e1f97915
18 changed files with 900 additions and 71 deletions

View File

@@ -0,0 +1,225 @@
/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
use super::{
SCOPE_CALENDARS, SCOPE_CONTACTS, SCOPE_MAIL, SCOPE_OFFLINE_ACCESS, SCOPE_OPENID,
crypto::SymmetricEncrypt,
};
use base64::{Engine, engine::general_purpose};
use store::blake3;
use utils::codec::leb128::{Leb128Iterator, Leb128Vec};
const CLIENT_ID_HEADER: &str = "swc1.";
const CLIENT_ID_KEY_CONTEXT: &str = "stalwart-oauth-client-id-sw1";
const CLIENT_ID_VERSION: u8 = 1;
const SCOPE_BITS: &[&str] = &[
SCOPE_OPENID,
SCOPE_OFFLINE_ACCESS,
SCOPE_MAIL,
SCOPE_CONTACTS,
SCOPE_CALENDARS,
];
pub fn scopes_to_mask(scope: &str) -> u64 {
let mut mask = 0u64;
for scope in scope.split_ascii_whitespace() {
if let Some(bit) = SCOPE_BITS.iter().position(|known| *known == scope) {
mask |= 1 << bit;
}
}
mask
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct ClientMeta {
pub redirect_uris: Vec<String>,
pub scope_mask: u64,
pub client_name: Option<String>,
}
pub fn encode_client_id(key: &[u8], meta: &ClientMeta) -> Result<String, String> {
let client_name = meta.client_name.as_deref().unwrap_or_default();
let mut payload = Vec::with_capacity(
24 + meta
.redirect_uris
.iter()
.map(|u| u.len() + 2)
.sum::<usize>()
+ client_name.len(),
);
payload.push(CLIENT_ID_VERSION);
payload.push_leb128(meta.redirect_uris.len());
for uri in &meta.redirect_uris {
payload.push_leb128(uri.len());
payload.extend_from_slice(uri.as_bytes());
}
payload.push_leb128(meta.scope_mask);
payload.push_leb128(client_name.len());
payload.extend_from_slice(client_name.as_bytes());
let digest = blake3::hash(&payload);
let nonce = &digest.as_bytes()[..SymmetricEncrypt::NONCE_LEN];
let ciphertext =
SymmetricEncrypt::new(key, CLIENT_ID_KEY_CONTEXT).encrypt_with_aad(&payload, nonce, &[])?;
let mut body = Vec::with_capacity(nonce.len() + ciphertext.len());
body.extend_from_slice(nonce);
body.extend_from_slice(&ciphertext);
let mut out = String::with_capacity(CLIENT_ID_HEADER.len() + body.len().div_ceil(3) * 4);
out.push_str(CLIENT_ID_HEADER);
general_purpose::URL_SAFE_NO_PAD.encode_string(&body, &mut out);
Ok(out)
}
pub fn decode_client_id(key: &[u8], client_id: &str) -> Option<ClientMeta> {
let body = general_purpose::URL_SAFE_NO_PAD
.decode(client_id.strip_prefix(CLIENT_ID_HEADER)?.as_bytes())
.ok()?;
if body.len() < SymmetricEncrypt::NONCE_LEN + SymmetricEncrypt::ENCRYPT_TAG_LEN {
return None;
}
let (nonce, ciphertext) = body.split_at(SymmetricEncrypt::NONCE_LEN);
let payload = SymmetricEncrypt::new(key, CLIENT_ID_KEY_CONTEXT)
.decrypt_with_aad(ciphertext, nonce, &[])
.ok()?;
let mut bytes = payload.iter();
if bytes.next().copied()? != CLIENT_ID_VERSION {
return None;
}
let uri_count: usize = bytes.next_leb128()?;
if uri_count > u8::MAX as usize {
return None;
}
let mut redirect_uris = Vec::with_capacity(uri_count);
for _ in 0..uri_count {
redirect_uris.push(take_string(&mut bytes)?);
}
let scope_mask: u64 = bytes.next_leb128()?;
let client_name = take_string(&mut bytes)?;
Some(ClientMeta {
redirect_uris,
scope_mask,
client_name: (!client_name.is_empty()).then_some(client_name),
})
}
fn take_string(bytes: &mut std::slice::Iter<'_, u8>) -> Option<String> {
let len: usize = bytes.next_leb128()?;
let slice = bytes.as_slice();
if slice.len() < len {
return None;
}
let value = String::from_utf8(slice[..len].to_vec()).ok()?;
if len > 0 {
bytes.nth(len - 1)?;
}
Some(value)
}
#[cfg(test)]
mod tests {
use super::*;
const KEY: &[u8] = b"a-test-encryption-key-of-some-length";
fn sample() -> ClientMeta {
ClientMeta {
redirect_uris: vec![
"http://127.0.0.1/cb".to_string(),
"com.example.app:/oauth".to_string(),
],
scope_mask: scopes_to_mask(&format!("{SCOPE_OFFLINE_ACCESS} {SCOPE_MAIL}")),
client_name: Some("Example Client".to_string()),
}
}
#[test]
fn round_trip_preserves_all_fields() {
for meta in [
sample(),
ClientMeta {
redirect_uris: vec!["http://[::1]/".to_string()],
scope_mask: 0,
client_name: None,
},
ClientMeta::default(),
] {
let client_id = encode_client_id(KEY, &meta).unwrap();
assert!(client_id.starts_with(CLIENT_ID_HEADER));
assert_eq!(decode_client_id(KEY, &client_id), Some(meta));
}
}
#[test]
fn scope_mask_is_order_independent_and_drops_unknown() {
assert_eq!(
scopes_to_mask(&format!("{SCOPE_MAIL} {SCOPE_OFFLINE_ACCESS}")),
scopes_to_mask(&format!("{SCOPE_OFFLINE_ACCESS} {SCOPE_MAIL}"))
);
assert_eq!(
scopes_to_mask(&format!("{SCOPE_MAIL} custom:unknown")),
scopes_to_mask(SCOPE_MAIL)
);
assert_eq!(scopes_to_mask("totally unknown"), 0);
}
#[test]
fn identical_input_is_deterministic() {
let meta = sample();
assert_eq!(
encode_client_id(KEY, &meta).unwrap(),
encode_client_id(KEY, &meta).unwrap()
);
}
#[test]
fn wrong_key_is_rejected() {
let client_id = encode_client_id(KEY, &sample()).unwrap();
assert_eq!(
decode_client_id(b"a-completely-different-key-value!", &client_id),
None
);
}
#[test]
fn tampering_is_rejected() {
let client_id = encode_client_id(KEY, &sample()).unwrap();
let (header, body_b64) = client_id.split_at(CLIENT_ID_HEADER.len());
let mut body = general_purpose::URL_SAFE_NO_PAD.decode(body_b64).unwrap();
for idx in 0..body.len() {
let mut tampered = body.clone();
tampered[idx] ^= 0x01;
let forged = format!(
"{header}{}",
general_purpose::URL_SAFE_NO_PAD.encode(&tampered)
);
assert_eq!(decode_client_id(KEY, &forged), None, "byte {idx}");
}
body[0] ^= 0x00;
assert!(decode_client_id(KEY, &client_id).is_some());
}
#[test]
fn malformed_input_never_panics() {
for case in [
"",
"swc1.",
"swc1.!!!",
"swc1.AAAA",
"wrong.AAAA",
"swc1.AAAAAAAAAAAAAAAAAAAAAAAAAAAA",
] {
assert_eq!(decode_client_id(KEY, case), None, "{case:?}");
}
}
}

View File

@@ -4,6 +4,7 @@
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*/
pub mod client_id;
pub mod config;
pub mod crypto;
pub mod introspect;
@@ -14,10 +15,24 @@ pub mod token;
pub const DEVICE_CODE_LEN: usize = 40;
pub const USER_CODE_LEN: usize = 8;
pub const RANDOM_CODE_LEN: usize = 32;
pub const CLIENT_ID_MAX_LEN: usize = 100;
pub const CLIENT_ID_MAX_LEN: usize = 2048;
pub const USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // No 0, O, I, 1
pub const SCOPE_OPENID: &str = "openid";
pub const SCOPE_OFFLINE_ACCESS: &str = "offline_access";
pub const SCOPE_MAIL: &str = "urn:ietf:params:oauth:scope:mail";
pub const SCOPE_CONTACTS: &str = "urn:ietf:params:oauth:scope:contacts";
pub const SCOPE_CALENDARS: &str = "urn:ietf:params:oauth:scope:calendars";
pub const SUPPORTED_SCOPES: &[&str] = &[
SCOPE_OPENID,
SCOPE_OFFLINE_ACCESS,
SCOPE_MAIL,
SCOPE_CONTACTS,
SCOPE_CALENDARS,
];
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
pub enum GrantType {
AccessToken,

View File

@@ -12,6 +12,10 @@ use std::collections::HashMap;
pub struct ClientRegistrationRequest {
pub redirect_uris: Vec<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Option::is_none")]
pub scope: Option<String>,
#[serde(default)]
#[serde(skip_serializing_if = "Vec::is_empty")]
pub response_types: Vec<String>,
@@ -170,7 +174,7 @@ pub enum SubjectType {
Public,
}
#[derive(Serialize, Deserialize, Debug)]
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TokenEndpointAuthMethod {
ClientSecretPost,
@@ -179,3 +183,86 @@ pub enum TokenEndpointAuthMethod {
PrivateKeyJwt,
None,
}
#[derive(Serialize, Debug)]
pub struct ClientRegistrationError {
pub error: &'static str,
#[serde(skip_serializing_if = "Option::is_none")]
pub error_description: Option<&'static str>,
}
impl ClientRegistrationError {
pub fn invalid_redirect_uri(description: &'static str) -> Self {
ClientRegistrationError {
error: "invalid_redirect_uri",
error_description: Some(description),
}
}
pub fn invalid_client_metadata(description: &'static str) -> Self {
ClientRegistrationError {
error: "invalid_client_metadata",
error_description: Some(description),
}
}
}
pub fn validate_redirect_uri(uri: &str) -> Result<(), ClientRegistrationError> {
if uri.contains('#') {
return Err(ClientRegistrationError::invalid_redirect_uri(
"Redirect URI must not contain a fragment.",
));
}
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]/") {
return Ok(());
}
if let Some((scheme, _)) = uri.split_once(':')
&& scheme.contains('.')
&& scheme
.as_bytes()
.first()
.is_some_and(u8::is_ascii_alphabetic)
&& scheme
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'+'))
{
return Ok(());
}
Err(ClientRegistrationError::invalid_redirect_uri(
"Redirect URI must be a loopback (http://127.0.0.1/, http://[::1]/) or private-use scheme URI.",
))
}
pub fn validate_grant_metadata(
request: &ClientRegistrationRequest,
) -> Result<(), ClientRegistrationError> {
if !request.response_types.is_empty() && !request.response_types.iter().any(|t| t == "code") {
return Err(ClientRegistrationError::invalid_client_metadata(
"response_types must include \"code\".",
));
}
if !request.grant_types.is_empty() {
if !request
.grant_types
.iter()
.any(|t| t == "authorization_code")
{
return Err(ClientRegistrationError::invalid_client_metadata(
"grant_types must include \"authorization_code\".",
));
}
if !request.grant_types.iter().any(|t| t == "refresh_token") {
return Err(ClientRegistrationError::invalid_client_metadata(
"grant_types must include \"refresh_token\".",
));
}
}
Ok(())
}