Import upstream v0.16.22, stripped
Upstream commit: 474dd0229cb20cf513036619781ed97bd8073c3f Enterprise-only files removed or emptied: 63 Enterprise-only snippets removed: 117 in 50 files Dangling module declarations removed: 5 Cargo edits turning enterprise off: 14 Verification: clean Enterprise feature gates left for rebuilt features: 19 in 18 files Produced by tools/fork/strip.py. The full report is in docs/fork/strip-reports/ on main.
This commit is contained in:
@@ -0,0 +1,649 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{DeviceAuthResponse, FormData, MAX_POST_LEN, OAuthCode, PkceCodeChallenge};
|
||||
use crate::auth::oauth::{
|
||||
OAuthStatus, openid::OpenIdHandler, registration::ClientRegistrationHandler,
|
||||
};
|
||||
use common::{
|
||||
KV_OAUTH, Server,
|
||||
auth::{
|
||||
AuthRequest,
|
||||
authentication::UsernameParts,
|
||||
oauth::{
|
||||
CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, SUPPORTED_SCOPES, USER_CODE_ALPHABET,
|
||||
USER_CODE_LEN,
|
||||
client_id::{decode_client_id, scopes_to_mask},
|
||||
registration::redirect_uri_matches,
|
||||
},
|
||||
},
|
||||
};
|
||||
use directory::Credentials;
|
||||
use http_proto::*;
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
Serialize,
|
||||
dispatch::lookup::KeyValue,
|
||||
write::{Archive, Archiver},
|
||||
};
|
||||
use store::{
|
||||
rand::{
|
||||
RngExt,
|
||||
distr::{Alphanumeric, StandardUniform},
|
||||
rng,
|
||||
},
|
||||
write::AlignedBytes,
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct ProtectedResourceMetadata {
|
||||
pub resource: String,
|
||||
pub authorization_servers: [String; 1],
|
||||
pub scopes_supported: &'static [&'static str],
|
||||
pub bearer_methods_supported: &'static [&'static str],
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize)]
|
||||
pub struct OAuthMetadata {
|
||||
pub issuer: String,
|
||||
pub token_endpoint: String,
|
||||
pub authorization_endpoint: String,
|
||||
pub device_authorization_endpoint: String,
|
||||
pub registration_endpoint: String,
|
||||
pub introspection_endpoint: String,
|
||||
pub grant_types_supported: &'static [&'static str],
|
||||
pub response_types_supported: &'static [&'static str],
|
||||
pub scopes_supported: &'static [&'static str],
|
||||
pub token_endpoint_auth_methods_supported: &'static [&'static str],
|
||||
pub code_challenge_methods_supported: &'static [&'static str],
|
||||
pub authorization_response_iss_parameter_supported: bool,
|
||||
}
|
||||
|
||||
pub trait OAuthApiHandler: Sync + Send {
|
||||
fn handle_discover_request(
|
||||
&self,
|
||||
session: &HttpSessionData,
|
||||
account_name: &str,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_login_request(
|
||||
&self,
|
||||
session: &HttpSessionData,
|
||||
body: Vec<u8>,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_device_auth(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: &HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_oauth_metadata(&self) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_oauth_protected_resource(
|
||||
&self,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LoginRequest {
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AuthCode {
|
||||
account_name: String,
|
||||
account_secret: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
mfa_token: Option<String>,
|
||||
client_id: String,
|
||||
#[serde(default)]
|
||||
redirect_uri: Option<String>,
|
||||
#[serde(default)]
|
||||
nonce: Option<String>,
|
||||
#[serde(default)]
|
||||
scope: Option<String>,
|
||||
#[serde(default)]
|
||||
code_challenge: Option<String>,
|
||||
#[serde(default)]
|
||||
code_challenge_method: Option<String>,
|
||||
#[serde(default)]
|
||||
state: Option<String>,
|
||||
#[serde(default)]
|
||||
resource: Vec<String>,
|
||||
},
|
||||
#[serde(rename_all = "camelCase")]
|
||||
AuthDevice {
|
||||
account_name: String,
|
||||
account_secret: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
#[serde(default)]
|
||||
mfa_token: Option<String>,
|
||||
code: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Serialize, serde::Deserialize, PartialEq, Eq)]
|
||||
#[serde(tag = "type")]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub enum LoginResponse {
|
||||
Authenticated { client_code: String, iss: String },
|
||||
Verified,
|
||||
MfaRequired,
|
||||
Failure,
|
||||
}
|
||||
|
||||
impl OAuthApiHandler for Server {
|
||||
async fn handle_discover_request(
|
||||
&self,
|
||||
session: &HttpSessionData,
|
||||
account_name: &str,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
let username = UsernameParts::new(account_name.trim());
|
||||
let auth_as = username.auth_as();
|
||||
let is_recovery_admin = self
|
||||
.registry()
|
||||
.recovery_admin()
|
||||
.is_some_and(|(user, _)| user.trim().eq_ignore_ascii_case(auth_as.address()));
|
||||
|
||||
if !is_recovery_admin
|
||||
&& let Some(domain_name) = auth_as.domain().filter(|domain| !domain.is_empty())
|
||||
&& let Some(endpoint) = self
|
||||
.get_directory_for_domain(domain_name)
|
||||
.await?
|
||||
.and_then(|directory| directory.oidc_discovery_document())
|
||||
{
|
||||
Ok(JsonResponse::new(&endpoint.document)
|
||||
.no_cache()
|
||||
.into_http_response())
|
||||
} else {
|
||||
self.handle_oidc_metadata(!session.is_tls).await
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_login_request(
|
||||
&self,
|
||||
session: &HttpSessionData,
|
||||
body: Vec<u8>,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
let request = serde_json::from_slice::<LoginRequest>(&body).map_err(|err| {
|
||||
trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err)
|
||||
})?;
|
||||
|
||||
let response = match request {
|
||||
LoginRequest::AuthCode {
|
||||
account_name,
|
||||
account_secret,
|
||||
mfa_token,
|
||||
client_id,
|
||||
redirect_uri,
|
||||
nonce,
|
||||
scope,
|
||||
code_challenge,
|
||||
code_challenge_method,
|
||||
resource,
|
||||
..
|
||||
} => {
|
||||
// Validate clientId
|
||||
if client_id.len() > CLIENT_ID_MAX_LEN {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Client ID is too long."));
|
||||
} else if redirect_uri
|
||||
.as_ref()
|
||||
.is_some_and(|uri| uri.starts_with("http://"))
|
||||
{
|
||||
#[cfg(not(feature = "dev_mode"))]
|
||||
if !self.registry().is_recovery_mode() && code_challenge.is_none() {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Redirect URI must be HTTPS."));
|
||||
}
|
||||
}
|
||||
|
||||
// Resolve the client and validate the redirect URI against the registration.
|
||||
// Stateless client ids are self-describing; otherwise fall back to the registry.
|
||||
let redirect_uri = redirect_uri.ok_or_else(|| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("A redirect URI is required.")
|
||||
})?;
|
||||
let stateless_client =
|
||||
decode_client_id(self.core.oauth.oauth_key.as_bytes(), &client_id);
|
||||
let granted_scope = match &stateless_client {
|
||||
Some(meta) => {
|
||||
if !meta
|
||||
.redirect_uris
|
||||
.iter()
|
||||
.any(|uri| redirect_uri_matches(uri, &redirect_uri))
|
||||
{
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Redirect URI does not match the client registration."));
|
||||
}
|
||||
grant_scope(scope.as_deref(), meta.scope_mask)
|
||||
}
|
||||
None => scope,
|
||||
};
|
||||
|
||||
// Validate Resource Indicators (RFC 8707)
|
||||
for resource in &resource {
|
||||
if !is_known_resource(
|
||||
[self.core.network.server_name.as_str()]
|
||||
.into_iter()
|
||||
.chain(
|
||||
self.core
|
||||
.network
|
||||
.info
|
||||
.services
|
||||
.values()
|
||||
.filter_map(|v| v.hostname.as_deref()),
|
||||
)
|
||||
.chain(
|
||||
self.core
|
||||
.network
|
||||
.info
|
||||
.mxs
|
||||
.iter()
|
||||
.filter_map(|mx| mx.hostname.as_deref()),
|
||||
),
|
||||
resource,
|
||||
) {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(format!("Unknown resource indicator: {}", resource)));
|
||||
}
|
||||
}
|
||||
|
||||
// Parse and validate PKCE challenge (RFC 7636).
|
||||
let pkce_challenge = match code_challenge {
|
||||
Some(challenge) => match code_challenge_method.as_deref().unwrap_or("plain") {
|
||||
"S256" => PkceCodeChallenge::S256(challenge),
|
||||
"plain" if stateless_client.is_none() => {
|
||||
PkceCodeChallenge::Plain(challenge)
|
||||
}
|
||||
_ => {
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Unsupported PKCE code_challenge_method."));
|
||||
}
|
||||
},
|
||||
None => {
|
||||
if stateless_client.is_some() {
|
||||
return Err(trc::AuthEvent::Error.into_err().details(
|
||||
"A PKCE code_challenge with the S256 method is required.",
|
||||
));
|
||||
}
|
||||
PkceCodeChallenge::None
|
||||
}
|
||||
};
|
||||
|
||||
// Authenticate
|
||||
match self
|
||||
.authenticate(&AuthRequest {
|
||||
credentials: Credentials::Basic {
|
||||
username: account_name,
|
||||
secret: account_secret,
|
||||
mfa_token,
|
||||
},
|
||||
session_id: session.session_id,
|
||||
remote_ip: session.remote_ip,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(access_token) => {
|
||||
// Registry-backed clients are validated once the account is known
|
||||
if stateless_client.is_none()
|
||||
&& self
|
||||
.validate_client_registration(
|
||||
&client_id,
|
||||
Some(redirect_uri.as_str()),
|
||||
access_token.account_id(),
|
||||
)
|
||||
.await?
|
||||
.is_some()
|
||||
{
|
||||
return Err(trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Invalid client registration."));
|
||||
}
|
||||
|
||||
// Generate client code
|
||||
let client_code = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(DEVICE_CODE_LEN)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
|
||||
// Serialize OAuth code
|
||||
let value = Archiver::new(OAuthCode {
|
||||
status: OAuthStatus::Authorized,
|
||||
account_id: access_token.account_id(),
|
||||
client_id,
|
||||
nonce,
|
||||
params: redirect_uri,
|
||||
code_challenge: pkce_challenge,
|
||||
scope: granted_scope,
|
||||
resources: resource,
|
||||
})
|
||||
.untrusted()
|
||||
.serialize()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Insert client code
|
||||
self.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(KV_OAUTH, client_code.as_bytes(), value)
|
||||
.expires(self.core.oauth.oauth_expiry_auth_code),
|
||||
)
|
||||
.await?;
|
||||
|
||||
LoginResponse::Authenticated {
|
||||
client_code,
|
||||
iss: self.core.network.http.url_https.clone(),
|
||||
}
|
||||
}
|
||||
Err(err) => match *err.as_ref() {
|
||||
trc::EventType::Auth(trc::AuthEvent::MfaRequired) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
LoginResponse::MfaRequired
|
||||
}
|
||||
trc::EventType::Auth(_) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
LoginResponse::Failure
|
||||
}
|
||||
trc::EventType::Security(_) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
LoginResponse::Failure
|
||||
}
|
||||
_ => {
|
||||
return Err(err);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
LoginRequest::AuthDevice {
|
||||
account_name,
|
||||
account_secret,
|
||||
mfa_token,
|
||||
code,
|
||||
} => {
|
||||
// Obtain code
|
||||
let mut result = LoginResponse::Failure;
|
||||
if let Some(auth_code_) = self
|
||||
.in_memory_store()
|
||||
.key_get::<Archive<AlignedBytes>>(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
code.as_bytes(),
|
||||
))
|
||||
.await?
|
||||
{
|
||||
let oauth = auth_code_
|
||||
.unarchive::<OAuthCode>()
|
||||
.caused_by(trc::location!())?;
|
||||
if oauth.status == OAuthStatus::Pending {
|
||||
// Authenticate
|
||||
match self
|
||||
.authenticate(&AuthRequest {
|
||||
credentials: Credentials::Basic {
|
||||
username: account_name,
|
||||
secret: account_secret,
|
||||
mfa_token,
|
||||
},
|
||||
session_id: session.session_id,
|
||||
remote_ip: session.remote_ip,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(access_token) => {
|
||||
let new_oauth_code = OAuthCode {
|
||||
status: OAuthStatus::Authorized,
|
||||
account_id: access_token.account_id(),
|
||||
client_id: oauth.client_id.to_string(),
|
||||
nonce: oauth.nonce.as_ref().map(|s| s.to_string()),
|
||||
params: Default::default(),
|
||||
code_challenge: PkceCodeChallenge::None,
|
||||
scope: oauth.scope.as_ref().map(|s| s.to_string()),
|
||||
resources: oauth
|
||||
.resources
|
||||
.iter()
|
||||
.map(|s| s.to_string())
|
||||
.collect(),
|
||||
};
|
||||
|
||||
// Delete issued user code
|
||||
self.in_memory_store()
|
||||
.key_delete(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
code.as_bytes(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Update device code status
|
||||
self.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(
|
||||
KV_OAUTH,
|
||||
oauth.params.as_bytes(),
|
||||
Archiver::new(new_oauth_code)
|
||||
.untrusted()
|
||||
.serialize()
|
||||
.caused_by(trc::location!())?,
|
||||
)
|
||||
.expires(self.core.oauth.oauth_expiry_auth_code),
|
||||
)
|
||||
.await?;
|
||||
|
||||
result = LoginResponse::Verified;
|
||||
}
|
||||
Err(err) => match *err.as_ref() {
|
||||
trc::EventType::Auth(trc::AuthEvent::MfaRequired) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
result = LoginResponse::MfaRequired;
|
||||
}
|
||||
trc::EventType::Auth(_) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
result = LoginResponse::Failure;
|
||||
}
|
||||
trc::EventType::Security(_) => {
|
||||
trc::error!(err.span_id(session.session_id));
|
||||
result = LoginResponse::Failure;
|
||||
}
|
||||
_ => {
|
||||
return Err(err);
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
};
|
||||
|
||||
Ok(JsonResponse::new(response).no_cache().into_http_response())
|
||||
}
|
||||
|
||||
async fn handle_device_auth(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: &HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// Parse form
|
||||
let mut form_data = FormData::from_request(req, MAX_POST_LEN, session.session_id).await?;
|
||||
let client_id = form_data
|
||||
.remove("client_id")
|
||||
.filter(|client_id| client_id.len() <= CLIENT_ID_MAX_LEN)
|
||||
.ok_or_else(|| {
|
||||
trc::ResourceEvent::BadParameters
|
||||
.into_err()
|
||||
.details("Client ID is missing.")
|
||||
})?;
|
||||
let nonce = form_data.remove("nonce");
|
||||
let scope = form_data
|
||||
.remove("scope")
|
||||
.and_then(|scope| grant_scope(Some(&scope), u64::MAX));
|
||||
|
||||
// Generate device code
|
||||
let device_code = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(DEVICE_CODE_LEN)
|
||||
.map(char::from)
|
||||
.collect::<String>();
|
||||
|
||||
// Generate user code
|
||||
let mut user_code = String::with_capacity(USER_CODE_LEN + 1);
|
||||
for (pos, ch) in rng()
|
||||
.sample_iter(StandardUniform)
|
||||
.take(USER_CODE_LEN)
|
||||
.map(|v: u64| char::from(USER_CODE_ALPHABET[v as usize % USER_CODE_ALPHABET.len()]))
|
||||
.enumerate()
|
||||
{
|
||||
if pos == USER_CODE_LEN / 2 {
|
||||
user_code.push('-');
|
||||
}
|
||||
user_code.push(ch);
|
||||
}
|
||||
|
||||
// Add OAuth status
|
||||
let oauth_code = Archiver::new(OAuthCode {
|
||||
status: OAuthStatus::Pending,
|
||||
account_id: u32::MAX,
|
||||
client_id,
|
||||
nonce,
|
||||
params: device_code.clone(),
|
||||
code_challenge: PkceCodeChallenge::None,
|
||||
scope,
|
||||
resources: Vec::new(),
|
||||
})
|
||||
.untrusted()
|
||||
.serialize()
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
// Insert device code
|
||||
self.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(KV_OAUTH, device_code.as_bytes(), oauth_code.clone())
|
||||
.expires(self.core.oauth.oauth_expiry_user_code),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Insert user code
|
||||
self.in_memory_store()
|
||||
.key_set(
|
||||
KeyValue::with_prefix(KV_OAUTH, user_code.as_bytes(), oauth_code)
|
||||
.expires(self.core.oauth.oauth_expiry_user_code),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Build response
|
||||
let base_url = &self.core.network.http.url_https;
|
||||
Ok(JsonResponse::new(DeviceAuthResponse {
|
||||
verification_uri: format!("{base_url}/device"),
|
||||
verification_uri_complete: format!("{base_url}/device/?code={user_code}"),
|
||||
device_code,
|
||||
user_code,
|
||||
expires_in: self.core.oauth.oauth_expiry_user_code,
|
||||
interval: 5,
|
||||
})
|
||||
.no_cache()
|
||||
.into_http_response())
|
||||
}
|
||||
|
||||
async fn handle_oauth_metadata(&self) -> trc::Result<HttpResponse> {
|
||||
let base_url = &self.core.network.http.url_https;
|
||||
|
||||
Ok(JsonResponse::new(OAuthMetadata {
|
||||
authorization_endpoint: format!("{base_url}/login",),
|
||||
token_endpoint: format!("{base_url}/auth/token"),
|
||||
device_authorization_endpoint: format!("{base_url}/auth/device"),
|
||||
introspection_endpoint: format!("{base_url}/auth/introspect"),
|
||||
registration_endpoint: format!("{base_url}/auth/register"),
|
||||
grant_types_supported: &[
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
"urn:ietf:params:oauth:grant-type:device_code",
|
||||
],
|
||||
response_types_supported: &["code"],
|
||||
scopes_supported: SUPPORTED_SCOPES,
|
||||
token_endpoint_auth_methods_supported: &[
|
||||
"none",
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
],
|
||||
code_challenge_methods_supported: &["S256"],
|
||||
authorization_response_iss_parameter_supported: true,
|
||||
issuer: base_url.to_string(),
|
||||
})
|
||||
.into_http_response()
|
||||
.with_cors_unrestricted())
|
||||
}
|
||||
|
||||
async fn handle_oauth_protected_resource(&self) -> trc::Result<HttpResponse> {
|
||||
let base_url = &self.core.network.http.url_https;
|
||||
|
||||
Ok(JsonResponse::new(ProtectedResourceMetadata {
|
||||
resource: base_url.to_string(),
|
||||
authorization_servers: [base_url.to_string()],
|
||||
scopes_supported: SUPPORTED_SCOPES,
|
||||
bearer_methods_supported: &["header"],
|
||||
})
|
||||
.into_http_response()
|
||||
.with_cors_unrestricted())
|
||||
}
|
||||
}
|
||||
|
||||
fn grant_scope(requested: Option<&str>, registered_mask: u64) -> Option<String> {
|
||||
let mut granted = String::new();
|
||||
for scope in requested.unwrap_or_default().split_ascii_whitespace() {
|
||||
let bit = scopes_to_mask(scope);
|
||||
if bit != 0 && registered_mask & bit == bit {
|
||||
if !granted.is_empty() {
|
||||
granted.push(' ');
|
||||
}
|
||||
granted.push_str(scope);
|
||||
}
|
||||
}
|
||||
|
||||
(!granted.is_empty()).then_some(granted)
|
||||
}
|
||||
|
||||
fn is_known_resource<'x>(hostnames: impl IntoIterator<Item = &'x str>, uri: &str) -> bool {
|
||||
let Some((scheme, rest)) = uri.split_once("://") else {
|
||||
return false;
|
||||
};
|
||||
let supported = hashify::tiny_map!(scheme.as_bytes(),
|
||||
b"http" => true,
|
||||
b"https" => true,
|
||||
b"smtp" => true,
|
||||
b"smtps" => true,
|
||||
b"imap" => true,
|
||||
b"imaps" => true,
|
||||
b"pop3" => true,
|
||||
b"pop3s" => true,
|
||||
b"caldav" => true,
|
||||
b"caldavs" => true,
|
||||
b"webdav" => true,
|
||||
b"webdavs" => true,
|
||||
b"carddav" => true,
|
||||
b"carddavs" => true,
|
||||
b"sieve" => true,
|
||||
b"sieves" => true
|
||||
)
|
||||
.unwrap_or(false);
|
||||
|
||||
let authority = rest.split_once('/').map_or(rest, |(auth, _)| auth);
|
||||
let host = authority
|
||||
.rsplit_once(':')
|
||||
.filter(|(_, port)| !port.is_empty() && port.as_bytes().iter().all(|c| c.is_ascii_digit()))
|
||||
.map_or(authority, |(host, _)| host);
|
||||
|
||||
supported
|
||||
&& hostnames
|
||||
.into_iter()
|
||||
.any(|hostname| host.eq_ignore_ascii_case(hostname))
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use http_proto::{HttpRequest, request::fetch_body};
|
||||
use hyper::header::CONTENT_TYPE;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utils::map::vec_map::VecMap;
|
||||
|
||||
pub mod auth;
|
||||
pub mod openid;
|
||||
pub mod registration;
|
||||
pub mod token;
|
||||
|
||||
#[derive(
|
||||
rkyv::Serialize,
|
||||
rkyv::Deserialize,
|
||||
rkyv::Archive,
|
||||
Copy,
|
||||
Clone,
|
||||
Debug,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
)]
|
||||
#[rkyv(compare(PartialEq))]
|
||||
pub enum OAuthStatus {
|
||||
Authorized,
|
||||
TokenIssued,
|
||||
Pending,
|
||||
}
|
||||
|
||||
const MAX_POST_LEN: usize = 2048;
|
||||
|
||||
pub struct OAuth {
|
||||
pub key: String,
|
||||
pub expiry_user_code: u64,
|
||||
pub expiry_auth_code: u64,
|
||||
pub expiry_token: u64,
|
||||
pub expiry_refresh_token: u64,
|
||||
pub expiry_refresh_token_renew: u64,
|
||||
pub max_auth_attempts: u32,
|
||||
pub metadata: String,
|
||||
}
|
||||
|
||||
#[derive(rkyv::Serialize, rkyv::Deserialize, rkyv::Archive, Debug)]
|
||||
pub struct OAuthCode {
|
||||
pub status: OAuthStatus,
|
||||
pub account_id: u32,
|
||||
pub client_id: String,
|
||||
pub nonce: Option<String>,
|
||||
pub params: String,
|
||||
pub code_challenge: PkceCodeChallenge,
|
||||
pub scope: Option<String>,
|
||||
pub resources: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(
|
||||
rkyv::Serialize,
|
||||
rkyv::Deserialize,
|
||||
rkyv::Archive,
|
||||
Clone,
|
||||
Debug,
|
||||
Serialize,
|
||||
Deserialize,
|
||||
PartialEq,
|
||||
Eq,
|
||||
)]
|
||||
#[rkyv(compare(PartialEq))]
|
||||
pub enum PkceCodeChallenge {
|
||||
None,
|
||||
S256(String),
|
||||
Plain(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeviceAuthGet {
|
||||
code: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeviceAuthPost {
|
||||
code: Option<String>,
|
||||
email: Option<String>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeviceAuthRequest {
|
||||
client_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct DeviceAuthResponse {
|
||||
pub device_code: String,
|
||||
pub user_code: String,
|
||||
pub verification_uri: String,
|
||||
pub verification_uri_complete: String,
|
||||
pub expires_in: u64,
|
||||
pub interval: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CodeAuthRequest {
|
||||
response_type: String,
|
||||
client_id: String,
|
||||
redirect_uri: String,
|
||||
scope: Option<String>,
|
||||
state: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct CodeAuthForm {
|
||||
code: String,
|
||||
email: Option<String>,
|
||||
password: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct TokenRequest {
|
||||
pub grant_type: String,
|
||||
pub code: Option<String>,
|
||||
pub device_code: Option<String>,
|
||||
pub client_id: Option<String>,
|
||||
pub refresh_token: Option<String>,
|
||||
pub redirect_uri: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(untagged)]
|
||||
pub enum TokenResponse {
|
||||
Granted(OAuthResponse),
|
||||
Error { error: ErrorType },
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct OAuthResponse {
|
||||
pub access_token: String,
|
||||
pub token_type: String,
|
||||
pub expires_in: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub refresh_token: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub scope: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub id_token: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ErrorType {
|
||||
#[serde(rename = "invalid_grant")]
|
||||
InvalidGrant,
|
||||
#[serde(rename = "invalid_client")]
|
||||
InvalidClient,
|
||||
#[serde(rename = "invalid_scope")]
|
||||
InvalidScope,
|
||||
#[serde(rename = "invalid_request")]
|
||||
InvalidRequest,
|
||||
#[serde(rename = "unauthorized_client")]
|
||||
UnauthorizedClient,
|
||||
#[serde(rename = "unsupported_grant_type")]
|
||||
UnsupportedGrantType,
|
||||
#[serde(rename = "authorization_pending")]
|
||||
AuthorizationPending,
|
||||
#[serde(rename = "slow_down")]
|
||||
SlowDown,
|
||||
#[serde(rename = "access_denied")]
|
||||
AccessDenied,
|
||||
#[serde(rename = "expired_token")]
|
||||
ExpiredToken,
|
||||
}
|
||||
|
||||
impl TokenResponse {
|
||||
pub fn error(error: ErrorType) -> Self {
|
||||
TokenResponse::Error { error }
|
||||
}
|
||||
|
||||
pub fn is_error(&self) -> bool {
|
||||
matches!(self, TokenResponse::Error { .. })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct FormData {
|
||||
fields: VecMap<String, String>,
|
||||
}
|
||||
|
||||
impl FormData {
|
||||
pub async fn from_request(
|
||||
req: &mut HttpRequest,
|
||||
max_len: usize,
|
||||
session_id: u64,
|
||||
) -> trc::Result<Self> {
|
||||
match (
|
||||
req.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|h| h.to_str().ok())
|
||||
.and_then(|val| val.parse::<mime::Mime>().ok()),
|
||||
fetch_body(req, max_len, session_id).await,
|
||||
) {
|
||||
(Some(content_type), Some(body)) => {
|
||||
let mut fields = VecMap::new();
|
||||
if let Some(boundary) = content_type.get_param(mime::BOUNDARY) {
|
||||
for mut field in
|
||||
form_data::FormData::new(&body[..], boundary.as_str()).flatten()
|
||||
{
|
||||
let value = String::from_utf8_lossy(&field.bytes().unwrap_or_default())
|
||||
.into_owned();
|
||||
fields.append(field.name, value);
|
||||
}
|
||||
} else {
|
||||
for (key, value) in http_proto::form_urlencoded::parse(&body) {
|
||||
fields.append(key.into_owned(), value.into_owned());
|
||||
}
|
||||
}
|
||||
Ok(FormData { fields })
|
||||
}
|
||||
_ => Err(trc::ResourceEvent::BadParameters
|
||||
.into_err()
|
||||
.details("Invalid post request")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get(&self, key: &str) -> Option<&str> {
|
||||
self.fields.get(key).map(|v| v.as_str())
|
||||
}
|
||||
|
||||
pub fn remove(&mut self, key: &str) -> Option<String> {
|
||||
self.fields.remove(key)
|
||||
}
|
||||
|
||||
pub fn has_field(&self, key: &str) -> bool {
|
||||
self.fields.get(key).is_some_and(|v| !v.is_empty())
|
||||
}
|
||||
|
||||
pub fn fields(&self) -> impl Iterator<Item = (&String, &String)> {
|
||||
self.fields.iter()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use common::{Server, auth::oauth::SUPPORTED_SCOPES, auth::oauth::oidc::Userinfo};
|
||||
use http_proto::*;
|
||||
use serde::Serialize;
|
||||
use std::future::Future;
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct OpenIdMetadata {
|
||||
pub issuer: String,
|
||||
pub authorization_endpoint: String,
|
||||
pub token_endpoint: String,
|
||||
pub userinfo_endpoint: String,
|
||||
pub jwks_uri: String,
|
||||
pub registration_endpoint: String,
|
||||
pub device_authorization_endpoint: String,
|
||||
pub scopes_supported: &'static [&'static str],
|
||||
pub response_types_supported: &'static [&'static str],
|
||||
pub subject_types_supported: &'static [&'static str],
|
||||
pub grant_types_supported: &'static [&'static str],
|
||||
pub token_endpoint_auth_methods_supported: &'static [&'static str],
|
||||
pub id_token_signing_alg_values_supported: &'static [&'static str],
|
||||
pub claims_supported: &'static [&'static str],
|
||||
pub code_challenge_methods_supported: &'static [&'static str],
|
||||
pub authorization_response_iss_parameter_supported: bool,
|
||||
}
|
||||
|
||||
pub trait OpenIdHandler: Sync + Send {
|
||||
fn handle_userinfo_request(
|
||||
&self,
|
||||
account_id: u32,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_oidc_metadata(
|
||||
&self,
|
||||
strip_base_url: bool,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
}
|
||||
|
||||
impl OpenIdHandler for Server {
|
||||
async fn handle_userinfo_request(&self, account_id: u32) -> trc::Result<HttpResponse> {
|
||||
let account = self.account(account_id).await?;
|
||||
|
||||
Ok(JsonResponse::new(Userinfo {
|
||||
sub: Some(account_id.to_string()),
|
||||
name: account.description().map(|d| d.to_string()),
|
||||
preferred_username: Some(account.name().to_string()),
|
||||
email: account.name().to_string().into(),
|
||||
email_verified: true,
|
||||
..Default::default()
|
||||
})
|
||||
.no_cache()
|
||||
.into_http_response())
|
||||
}
|
||||
|
||||
async fn handle_oidc_metadata(&self, strip_base_url: bool) -> trc::Result<HttpResponse> {
|
||||
let base_url = if strip_base_url {
|
||||
#[cfg(feature = "dev_mode")]
|
||||
{
|
||||
"http://127.0.0.1:8080"
|
||||
}
|
||||
|
||||
#[cfg(not(feature = "dev_mode"))]
|
||||
{
|
||||
""
|
||||
}
|
||||
} else {
|
||||
&self.core.network.http.url_https
|
||||
};
|
||||
|
||||
Ok(JsonResponse::new(OpenIdMetadata {
|
||||
authorization_endpoint: format!("{base_url}/login",),
|
||||
token_endpoint: format!("{base_url}/auth/token"),
|
||||
userinfo_endpoint: format!("{base_url}/auth/userinfo"),
|
||||
jwks_uri: format!("{base_url}/auth/jwks.json"),
|
||||
registration_endpoint: format!("{base_url}/auth/register"),
|
||||
device_authorization_endpoint: format!("{base_url}/auth/device"),
|
||||
response_types_supported: &["code"],
|
||||
grant_types_supported: &[
|
||||
"authorization_code",
|
||||
"refresh_token",
|
||||
"urn:ietf:params:oauth:grant-type:device_code",
|
||||
],
|
||||
scopes_supported: SUPPORTED_SCOPES,
|
||||
subject_types_supported: &["public"],
|
||||
token_endpoint_auth_methods_supported: &[
|
||||
"none",
|
||||
"client_secret_post",
|
||||
"client_secret_basic",
|
||||
],
|
||||
id_token_signing_alg_values_supported: &[
|
||||
"RS256", "RS384", "RS512", "ES256", "ES384", "PS256", "PS384", "PS512", "HS256",
|
||||
"HS384", "HS512",
|
||||
],
|
||||
claims_supported: &[
|
||||
"sub",
|
||||
"name",
|
||||
"preferred_username",
|
||||
"email",
|
||||
"email_verified",
|
||||
],
|
||||
code_challenge_methods_supported: &["S256"],
|
||||
authorization_response_iss_parameter_supported: true,
|
||||
issuer: base_url.to_string(),
|
||||
})
|
||||
.into_http_response()
|
||||
.with_cors_unrestricted())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::ErrorType;
|
||||
use crate::auth::authenticate::Authenticator;
|
||||
use common::{
|
||||
Server,
|
||||
auth::{
|
||||
BuildAccessToken,
|
||||
oauth::{
|
||||
client_id::{ClientMeta, decode_client_id, encode_client_id, scopes_to_mask},
|
||||
registration::{
|
||||
ClientRegistrationError, ClientRegistrationRequest, ClientRegistrationResponse,
|
||||
TokenEndpointAuthMethod, redirect_uri_matches, validate_grant_metadata,
|
||||
validate_redirect_uri,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
use directory::core::secret::{hash_secret, verify_secret_hash};
|
||||
use http_proto::{request::fetch_body, *};
|
||||
use hyper::StatusCode;
|
||||
use registry::schema::{
|
||||
enums::{PasswordHashAlgorithm, Permission},
|
||||
prelude::{ObjectType, Property, UTCDateTime},
|
||||
structs::OAuthClient,
|
||||
};
|
||||
use std::future::Future;
|
||||
use store::{
|
||||
rand::{RngExt, distr::Alphanumeric, rng},
|
||||
registry::write::{RegistryWrite, RegistryWriteResult},
|
||||
write::now,
|
||||
};
|
||||
use trc::{AddContext, AuthEvent};
|
||||
use types::id::Id;
|
||||
|
||||
pub trait ClientRegistrationHandler: Sync + Send {
|
||||
fn handle_oauth_registration_request(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn validate_client_registration(
|
||||
&self,
|
||||
client_id: &str,
|
||||
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(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// Parse request
|
||||
let body = fetch_body(req, 20 * 1024, session.session_id).await;
|
||||
let request = serde_json::from_slice::<ClientRegistrationRequest>(
|
||||
body.as_deref().unwrap_or_default(),
|
||||
)
|
||||
.map_err(|err| {
|
||||
trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err)
|
||||
})?;
|
||||
|
||||
// Validate redirect URIs and grant metadata (RFC 7591 + OAuth Public Clients profile)
|
||||
if request.redirect_uris.is_empty() {
|
||||
return Ok(registration_error(
|
||||
ClientRegistrationError::invalid_redirect_uri(
|
||||
"At least one redirect URI is required.",
|
||||
),
|
||||
));
|
||||
}
|
||||
for uri in &request.redirect_uris {
|
||||
if let Err(err) = validate_redirect_uri(uri) {
|
||||
return Ok(registration_error(err));
|
||||
}
|
||||
}
|
||||
if let Err(err) = validate_grant_metadata(&request) {
|
||||
return Ok(registration_error(err));
|
||||
}
|
||||
|
||||
let is_public = matches!(
|
||||
request.token_endpoint_auth_method,
|
||||
None | Some(TokenEndpointAuthMethod::None)
|
||||
);
|
||||
|
||||
if is_public {
|
||||
// Public client: issue a stateless, self-describing client id with no database write
|
||||
if self.core.oauth.allow_anonymous_client_registration {
|
||||
self.is_http_anonymous_request_allowed(session.remote_ip)
|
||||
.await?;
|
||||
} else {
|
||||
let (_, access_token) = self.authenticate_headers(req, &session).await?;
|
||||
access_token.enforce_permission(Permission::OAuthClientRegistration)?;
|
||||
}
|
||||
|
||||
let client_id = encode_client_id(
|
||||
self.core.oauth.oauth_key.as_bytes(),
|
||||
&ClientMeta {
|
||||
redirect_uris: request.redirect_uris.clone(),
|
||||
scope_mask: scopes_to_mask(request.scope.as_deref().unwrap_or_default()),
|
||||
client_name: request.client_name.clone(),
|
||||
},
|
||||
)
|
||||
.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details("Failed to encode client id.")
|
||||
.reason(err)
|
||||
.caused_by(trc::location!())
|
||||
})?;
|
||||
|
||||
trc::event!(
|
||||
Auth(AuthEvent::ClientRegistration),
|
||||
Id = client_id.clone(),
|
||||
RemoteIp = session.remote_ip
|
||||
);
|
||||
|
||||
return Ok(JsonResponse::with_status(
|
||||
StatusCode::CREATED,
|
||||
ClientRegistrationResponse {
|
||||
client_id_issued_at: Some(now()),
|
||||
client_id,
|
||||
request,
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.no_cache()
|
||||
.into_http_response());
|
||||
}
|
||||
|
||||
// Confidential client: authenticate and persist the registration
|
||||
let (_, access_token) = self.authenticate_headers(req, &session).await?;
|
||||
access_token.enforce_permission(Permission::OAuthClientRegistration)?;
|
||||
let tenant_id = access_token.tenant_id();
|
||||
|
||||
// Generate client ID
|
||||
let client_id = rng()
|
||||
.sample_iter(Alphanumeric)
|
||||
.take(20)
|
||||
.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(
|
||||
&OAuthClient {
|
||||
client_id: client_id.clone(),
|
||||
description: request.client_name.clone(),
|
||||
contacts: request.contacts.clone().into(),
|
||||
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(),
|
||||
))
|
||||
.await
|
||||
.caused_by(trc::location!())?;
|
||||
|
||||
if !matches!(result, RegistryWriteResult::Success(_)) {
|
||||
return Err(trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("Failed to register OAuth client.")
|
||||
.reason(result.to_string())
|
||||
.caused_by(trc::location!()));
|
||||
}
|
||||
|
||||
trc::event!(
|
||||
Auth(AuthEvent::ClientRegistration),
|
||||
Id = client_id.to_string(),
|
||||
RemoteIp = session.remote_ip
|
||||
);
|
||||
|
||||
Ok(JsonResponse::with_status(
|
||||
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()
|
||||
},
|
||||
)
|
||||
.no_cache()
|
||||
.into_http_response())
|
||||
}
|
||||
|
||||
async fn validate_client_registration(
|
||||
&self,
|
||||
client_id: &str,
|
||||
redirect_uri: Option<&str>,
|
||||
account_id: u32,
|
||||
) -> trc::Result<Option<ErrorType>> {
|
||||
// Stateless client ids are self-describing and validated at the authorization endpoint
|
||||
if decode_client_id(self.core.oauth.oauth_key.as_bytes(), client_id).is_some() {
|
||||
return Ok(None);
|
||||
}
|
||||
if !self.core.oauth.require_client_authentication {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
// Fetch client registration
|
||||
let found_registration = if let Some(client_id) = self
|
||||
.registry()
|
||||
.primary_key(
|
||||
ObjectType::OAuthClient.into(),
|
||||
Property::ClientId,
|
||||
client_id.as_bytes().to_vec(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
if let Some(redirect_uri) = redirect_uri {
|
||||
let client = self
|
||||
.registry()
|
||||
.object::<OAuthClient>(client_id.id())
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
trc::StoreEvent::UnexpectedError
|
||||
.into_err()
|
||||
.details("OAuth client not found.")
|
||||
.caused_by(trc::location!())
|
||||
.ctx(trc::Key::Id, client_id.id().id())
|
||||
})?;
|
||||
if client
|
||||
.redirect_uris
|
||||
.iter()
|
||||
.any(|uri| redirect_uri_matches(uri, redirect_uri))
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
} else {
|
||||
// Device flow does not require a redirect URI
|
||||
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
true
|
||||
} else {
|
||||
false
|
||||
};
|
||||
|
||||
// Check if the account is allowed to override client registration
|
||||
if self
|
||||
.access_token(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.build()
|
||||
.has_permission(Permission::OAuthClientOverride)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(if found_registration {
|
||||
ErrorType::InvalidClient
|
||||
} else {
|
||||
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 {
|
||||
JsonResponse::with_status(StatusCode::BAD_REQUEST, error)
|
||||
.no_cache()
|
||||
.into_http_response()
|
||||
}
|
||||
@@ -0,0 +1,439 @@
|
||||
/*
|
||||
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
|
||||
*/
|
||||
|
||||
use super::{
|
||||
ArchivedOAuthStatus, ArchivedPkceCodeChallenge, ErrorType, FormData, MAX_POST_LEN, OAuthCode,
|
||||
OAuthResponse, OAuthStatus, TokenResponse, registration::ClientRegistrationHandler,
|
||||
};
|
||||
use crate::auth::authenticate::HttpHeaders;
|
||||
use base64::{
|
||||
Engine,
|
||||
engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD},
|
||||
};
|
||||
use common::{
|
||||
KV_OAUTH, Server,
|
||||
auth::{
|
||||
AccessToken,
|
||||
oauth::{GrantType, oidc::StandardClaims},
|
||||
},
|
||||
};
|
||||
use http_proto::*;
|
||||
use hyper::StatusCode;
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::{borrow::Cow, future::Future};
|
||||
use store::{
|
||||
dispatch::lookup::KeyValue,
|
||||
write::{AlignedBytes, Archive},
|
||||
};
|
||||
use trc::AddContext;
|
||||
|
||||
pub trait TokenHandler: Sync + Send {
|
||||
fn handle_token_request(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
fn handle_token_introspect(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
access_token: &AccessToken,
|
||||
session_id: u64,
|
||||
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn issue_token(
|
||||
&self,
|
||||
account_id: u32,
|
||||
client_id: &str,
|
||||
issuer: String,
|
||||
nonce: Option<String>,
|
||||
scope: Option<String>,
|
||||
with_refresh_token: bool,
|
||||
with_id_token: bool,
|
||||
) -> impl Future<Output = trc::Result<OAuthResponse>> + Send;
|
||||
}
|
||||
|
||||
impl TokenHandler for Server {
|
||||
// Token endpoint
|
||||
async fn handle_token_request(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
session: HttpSessionData,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// 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);
|
||||
|
||||
let issuer = self.core.network.http.url_https.to_string();
|
||||
|
||||
if grant_type.eq_ignore_ascii_case("authorization_code") {
|
||||
response = if let (Some(code), Some(client_id), Some(redirect_uri)) = (
|
||||
params.get("code"),
|
||||
client_id_cred.as_deref(),
|
||||
params.get("redirect_uri"),
|
||||
) {
|
||||
// Obtain code
|
||||
match self
|
||||
.in_memory_store()
|
||||
.key_get::<Archive<AlignedBytes>>(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
code.as_bytes(),
|
||||
))
|
||||
.await?
|
||||
{
|
||||
Some(auth_code_) => {
|
||||
let oauth = auth_code_
|
||||
.unarchive::<OAuthCode>()
|
||||
.caused_by(trc::location!())?;
|
||||
if client_id != oauth.client_id || redirect_uri != oauth.params {
|
||||
TokenResponse::error(ErrorType::InvalidClient)
|
||||
} else if !verify_pkce(&oauth.code_challenge, params.get("code_verifier")) {
|
||||
TokenResponse::error(ErrorType::InvalidGrant)
|
||||
} else if oauth.status == OAuthStatus::Authorized {
|
||||
// Validate client id
|
||||
if let Some(error) = self
|
||||
.validate_client_registration(
|
||||
client_id,
|
||||
redirect_uri.into(),
|
||||
oauth.account_id.into(),
|
||||
)
|
||||
.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()
|
||||
.key_delete(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
code.as_bytes(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Issue token
|
||||
self.issue_token(
|
||||
oauth.account_id.into(),
|
||||
&oauth.client_id,
|
||||
issuer,
|
||||
oauth.nonce.as_ref().map(|s| s.as_str().into()),
|
||||
oauth.scope.as_ref().map(|s| s.as_str().into()),
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map(TokenResponse::Granted)
|
||||
.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(err)
|
||||
.caused_by(trc::location!())
|
||||
})?
|
||||
}
|
||||
} else {
|
||||
TokenResponse::error(ErrorType::InvalidGrant)
|
||||
}
|
||||
}
|
||||
None => TokenResponse::error(ErrorType::AccessDenied),
|
||||
}
|
||||
} else {
|
||||
TokenResponse::error(ErrorType::InvalidClient)
|
||||
};
|
||||
} else if grant_type.eq_ignore_ascii_case("urn:ietf:params:oauth:grant-type:device_code") {
|
||||
response = TokenResponse::error(ErrorType::ExpiredToken);
|
||||
|
||||
if let (Some(device_code), Some(client_id)) =
|
||||
(params.get("device_code"), params.get("client_id"))
|
||||
{
|
||||
// Obtain code
|
||||
if let Some(auth_code_) = self
|
||||
.in_memory_store()
|
||||
.key_get::<Archive<AlignedBytes>>(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
device_code.as_bytes(),
|
||||
))
|
||||
.await?
|
||||
{
|
||||
let oauth = auth_code_
|
||||
.unarchive::<OAuthCode>()
|
||||
.caused_by(trc::location!())?;
|
||||
response = if oauth.client_id != client_id {
|
||||
TokenResponse::error(ErrorType::InvalidClient)
|
||||
} else {
|
||||
match oauth.status {
|
||||
ArchivedOAuthStatus::Authorized => {
|
||||
if let Some(error) = self
|
||||
.validate_client_registration(
|
||||
client_id,
|
||||
None,
|
||||
oauth.account_id.into(),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
TokenResponse::error(error)
|
||||
} else {
|
||||
// Mark this token as issued
|
||||
self.in_memory_store()
|
||||
.key_delete(KeyValue::<()>::build_key(
|
||||
KV_OAUTH,
|
||||
device_code.as_bytes(),
|
||||
))
|
||||
.await?;
|
||||
|
||||
// Issue token
|
||||
self.issue_token(
|
||||
oauth.account_id.into(),
|
||||
&oauth.client_id,
|
||||
issuer,
|
||||
oauth.nonce.as_ref().map(|s| s.as_str().into()),
|
||||
oauth.scope.as_ref().map(|s| s.as_str().into()),
|
||||
true,
|
||||
true,
|
||||
)
|
||||
.await
|
||||
.map(TokenResponse::Granted)
|
||||
.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(err)
|
||||
.caused_by(trc::location!())
|
||||
})?
|
||||
}
|
||||
}
|
||||
ArchivedOAuthStatus::Pending => {
|
||||
TokenResponse::error(ErrorType::AuthorizationPending)
|
||||
}
|
||||
ArchivedOAuthStatus::TokenIssued => {
|
||||
TokenResponse::error(ErrorType::ExpiredToken)
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
} 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
|
||||
{
|
||||
Ok(token_info) => self
|
||||
.issue_token(
|
||||
token_info.account_id,
|
||||
"",
|
||||
issuer,
|
||||
None,
|
||||
None,
|
||||
token_info.expires_in
|
||||
<= self.core.oauth.oauth_expiry_refresh_token_renew,
|
||||
false,
|
||||
)
|
||||
.await
|
||||
.map(TokenResponse::Granted)
|
||||
.map_err(|err| {
|
||||
trc::AuthEvent::Error
|
||||
.into_err()
|
||||
.details(err)
|
||||
.caused_by(trc::location!())
|
||||
})?,
|
||||
Err(err) => {
|
||||
trc::error!(
|
||||
err.caused_by(trc::location!())
|
||||
.details("Failed to validate refresh token")
|
||||
.span_id(session.session_id)
|
||||
);
|
||||
TokenResponse::error(ErrorType::InvalidGrant)
|
||||
}
|
||||
};
|
||||
} else {
|
||||
response = TokenResponse::error(ErrorType::InvalidRequest);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(JsonResponse::with_status(
|
||||
if response.is_error() {
|
||||
StatusCode::BAD_REQUEST
|
||||
} else {
|
||||
StatusCode::OK
|
||||
},
|
||||
response,
|
||||
)
|
||||
.into_http_response())
|
||||
}
|
||||
|
||||
async fn handle_token_introspect(
|
||||
&self,
|
||||
req: &mut HttpRequest,
|
||||
access_token: &AccessToken,
|
||||
session_id: u64,
|
||||
) -> trc::Result<HttpResponse> {
|
||||
// Parse token
|
||||
let token = FormData::from_request(req, 1024, session_id)
|
||||
.await?
|
||||
.remove("token")
|
||||
.ok_or_else(|| {
|
||||
trc::ResourceEvent::BadParameters
|
||||
.into_err()
|
||||
.details("Client ID is missing.")
|
||||
})?;
|
||||
|
||||
self.introspect_access_token(&token, access_token)
|
||||
.await
|
||||
.map(|response| JsonResponse::new(response).no_cache().into_http_response())
|
||||
}
|
||||
|
||||
async fn issue_token(
|
||||
&self,
|
||||
account_id: u32,
|
||||
client_id: &str,
|
||||
issuer: String,
|
||||
nonce: Option<String>,
|
||||
scope: Option<String>,
|
||||
with_refresh_token: bool,
|
||||
with_id_token: bool,
|
||||
) -> trc::Result<OAuthResponse> {
|
||||
let credential_version = self
|
||||
.access_token(account_id)
|
||||
.await
|
||||
.caused_by(trc::location!())?
|
||||
.credential_version();
|
||||
let account = self.account(account_id).await.caused_by(trc::location!())?;
|
||||
let account_name = account.name();
|
||||
|
||||
Ok(OAuthResponse {
|
||||
access_token: self
|
||||
.encode_access_token(
|
||||
GrantType::AccessToken,
|
||||
account_id,
|
||||
account_name,
|
||||
self.core.oauth.oauth_expiry_token,
|
||||
None,
|
||||
credential_version.into(),
|
||||
)
|
||||
.await?,
|
||||
token_type: "bearer".to_string(),
|
||||
expires_in: self.core.oauth.oauth_expiry_token,
|
||||
refresh_token: if with_refresh_token {
|
||||
self.encode_access_token(
|
||||
GrantType::RefreshToken,
|
||||
account_id,
|
||||
account_name,
|
||||
self.core.oauth.oauth_expiry_refresh_token,
|
||||
None,
|
||||
credential_version.into(),
|
||||
)
|
||||
.await?
|
||||
.into()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
id_token: if with_id_token {
|
||||
match self.issue_id_token(
|
||||
account_id.to_string(),
|
||||
issuer,
|
||||
client_id,
|
||||
StandardClaims {
|
||||
nonce,
|
||||
preferred_username: account.name().to_string().into(),
|
||||
email: account.name().to_string().into(),
|
||||
description: account.description().map(|d| d.to_string()),
|
||||
},
|
||||
) {
|
||||
Ok(id_token) => Some(id_token),
|
||||
Err(err) => {
|
||||
trc::error!(err);
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
},
|
||||
scope,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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())
|
||||
&& challenge
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'.' | b'_' | b'~'))
|
||||
};
|
||||
let constant_time_eq = |a: &[u8], b: &[u8]| {
|
||||
if a.len() != b.len() {
|
||||
return false;
|
||||
}
|
||||
let mut diff: u8 = 0;
|
||||
for (x, y) in a.iter().zip(b.iter()) {
|
||||
diff |= x ^ y;
|
||||
}
|
||||
diff == 0
|
||||
};
|
||||
|
||||
match (stored, verifier) {
|
||||
(ArchivedPkceCodeChallenge::None, None) => true,
|
||||
(ArchivedPkceCodeChallenge::Plain(expected), Some(verifier))
|
||||
if is_valid_pkce_challenge(verifier) =>
|
||||
{
|
||||
constant_time_eq(expected.as_bytes(), verifier.as_bytes())
|
||||
}
|
||||
(ArchivedPkceCodeChallenge::S256(expected), Some(verifier))
|
||||
if is_valid_pkce_challenge(verifier) =>
|
||||
{
|
||||
let digest = Sha256::digest(verifier.as_bytes());
|
||||
let computed = URL_SAFE_NO_PAD.encode(digest);
|
||||
constant_time_eq(expected.as_bytes(), computed.as_bytes())
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user