use std::collections::{BTreeSet, HashMap};
use std::fmt::{Debug, Formatter};
use http::{HeaderMap, HeaderName, HeaderValue};
use reqwest::IntoUrl;
use url::Url;
use uuid::Uuid;
use graph_core::crypto::{secure_random_32, ProofKeyCodeExchange};
use graph_error::{IdentityResult, AF};
use crate::identity::{
AppConfig, AsQuery, AuthorizationCodeAssertionCredentialBuilder,
AuthorizationCodeCredentialBuilder, AuthorizationUrl, AzureCloudInstance, Prompt, ResponseMode,
ResponseType,
};
use crate::oauth_serializer::{AuthParameter, AuthSerializer};
#[cfg(feature = "openssl")]
use crate::identity::X509Certificate;
#[cfg(feature = "interactive-auth")]
use {
crate::identity::{
tracing_targets::INTERACTIVE_AUTH, AuthorizationCodeCertificateCredentialBuilder,
AuthorizationResponse, Token,
},
crate::interactive::{
HostOptions, InteractiveAuthEvent, UserEvents, WebViewAuth, WebViewAuthorizationEvent,
WebViewHostValidator, WebViewOptions, WithInteractiveAuth,
},
crate::{Assertion, Secret},
graph_error::{AuthExecutionError, WebViewError, WebViewResult},
tao::{event_loop::EventLoopProxy, window::Window},
wry::{WebView, WebViewBuilder},
};
credential_builder_base!(AuthCodeAuthorizationUrlParameterBuilder);
#[derive(Clone)]
pub struct AuthCodeAuthorizationUrlParameters {
pub(crate) app_config: AppConfig,
pub(crate) response_type: BTreeSet<ResponseType>,
pub(crate) response_mode: Option<ResponseMode>,
pub(crate) nonce: Option<String>,
pub(crate) state: Option<String>,
pub(crate) prompt: BTreeSet<Prompt>,
pub(crate) domain_hint: Option<String>,
pub(crate) login_hint: Option<String>,
pub(crate) code_challenge: Option<String>,
pub(crate) code_challenge_method: Option<String>,
}
impl Debug for AuthCodeAuthorizationUrlParameters {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AuthCodeAuthorizationUrlParameters")
.field("app_config", &self.app_config)
.field("response_type", &self.response_type)
.field("response_mode", &self.response_mode)
.field("prompt", &self.prompt)
.finish()
}
}
impl AuthCodeAuthorizationUrlParameters {
pub fn new(
client_id: impl AsRef<str>,
redirect_uri: impl IntoUrl,
) -> IdentityResult<AuthCodeAuthorizationUrlParameters> {
let mut response_type = BTreeSet::new();
response_type.insert(ResponseType::Code);
let redirect_uri_result = Url::parse(redirect_uri.as_str());
Ok(AuthCodeAuthorizationUrlParameters {
app_config: AppConfig::builder(client_id.as_ref())
.redirect_uri(redirect_uri.into_url().or(redirect_uri_result)?)
.build(),
response_type,
response_mode: None,
nonce: None,
state: None,
prompt: Default::default(),
domain_hint: None,
login_hint: None,
code_challenge: None,
code_challenge_method: None,
})
}
pub fn builder(client_id: impl TryInto<Uuid>) -> AuthCodeAuthorizationUrlParameterBuilder {
AuthCodeAuthorizationUrlParameterBuilder::new(client_id)
}
pub fn url(&self) -> IdentityResult<Url> {
self.url_with_host(&AzureCloudInstance::default())
}
pub fn url_with_host(&self, azure_cloud_instance: &AzureCloudInstance) -> IdentityResult<Url> {
self.authorization_url_with_host(azure_cloud_instance)
}
pub fn into_credential(
self,
authorization_code: impl AsRef<str>,
) -> AuthorizationCodeCredentialBuilder {
AuthorizationCodeCredentialBuilder::new_with_auth_code(authorization_code, self.app_config)
}
pub fn into_assertion_credential(
self,
authorization_code: impl AsRef<str>,
) -> AuthorizationCodeAssertionCredentialBuilder {
AuthorizationCodeAssertionCredentialBuilder::new_with_auth_code(
self.app_config,
authorization_code,
)
}
#[cfg(feature = "openssl")]
pub fn into_certificate_credential(
self,
authorization_code: impl AsRef<str>,
x509: &X509Certificate,
) -> IdentityResult<AuthorizationCodeCertificateCredentialBuilder> {
AuthorizationCodeCertificateCredentialBuilder::new_with_auth_code_and_x509(
authorization_code,
x509,
self.app_config,
)
}
pub fn nonce(&mut self) -> Option<&String> {
self.nonce.as_ref()
}
#[cfg(feature = "interactive-auth")]
pub(crate) fn interactive_webview_authentication(
&self,
options: WebViewOptions,
) -> WebViewResult<AuthorizationResponse> {
let uri = self
.url()
.map_err(|err| Box::new(AuthExecutionError::from(err)))?;
let redirect_uri = self.redirect_uri().cloned().unwrap();
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
AuthCodeAuthorizationUrlParameters::run(uri, vec![redirect_uri], options, sender)
.unwrap();
});
let mut iter = receiver.try_iter();
let mut next = iter.next();
while next.is_none() {
next = iter.next();
}
match next {
None => unreachable!(),
Some(auth_event) => match auth_event {
InteractiveAuthEvent::InvalidRedirectUri(reason) => {
Err(WebViewError::InvalidUri(reason))
}
InteractiveAuthEvent::ReachedRedirectUri(uri) => {
let query = uri
.query()
.or(uri.fragment())
.ok_or(WebViewError::InvalidUri(format!(
"uri missing query or fragment: {}",
uri
)))?;
let response_query: AuthorizationResponse =
serde_urlencoded::from_str(query)
.map_err(|err| WebViewError::InvalidUri(err.to_string()))?;
if response_query.is_err() {
tracing::debug!(target: INTERACTIVE_AUTH, "error in authorization query or fragment from redirect uri");
return Err(WebViewError::Authorization {
error: response_query
.error
.map(|query_error| query_error.to_string())
.unwrap_or_default(),
error_description: response_query.error_description.unwrap_or_default(),
error_uri: response_query.error_uri.map(|uri| uri.to_string()),
});
}
tracing::debug!(target: INTERACTIVE_AUTH, "parsed authorization query or fragment from redirect uri");
Ok(response_query)
}
InteractiveAuthEvent::WindowClosed(window_close_reason) => {
Err(WebViewError::WindowClosed(window_close_reason.to_string()))
}
},
}
}
#[allow(dead_code)]
#[cfg(feature = "interactive-auth")]
pub(crate) fn interactive_authentication_builder(
&self,
options: WebViewOptions,
) -> WebViewResult<AuthorizationResponse> {
let uri = self
.url()
.map_err(|err| Box::new(AuthExecutionError::from(err)))?;
let redirect_uri = self.redirect_uri().cloned().unwrap();
let (sender, receiver) = std::sync::mpsc::channel();
std::thread::spawn(move || {
AuthCodeAuthorizationUrlParameters::run(uri, vec![redirect_uri], options, sender)
.unwrap();
});
let mut iter = receiver.try_iter();
let mut next = iter.next();
while next.is_none() {
next = iter.next();
}
match next {
None => unreachable!(),
Some(auth_event) => match auth_event {
InteractiveAuthEvent::InvalidRedirectUri(reason) => {
Err(WebViewError::InvalidUri(reason))
}
InteractiveAuthEvent::ReachedRedirectUri(uri) => {
let query = uri
.query()
.or(uri.fragment())
.ok_or(WebViewError::InvalidUri(format!(
"uri missing query or fragment: {}",
uri
)))?;
let response_query: AuthorizationResponse =
serde_urlencoded::from_str(query)
.map_err(|err| WebViewError::InvalidUri(err.to_string()))?;
Ok(response_query)
}
InteractiveAuthEvent::WindowClosed(window_close_reason) => {
Err(WebViewError::WindowClosed(window_close_reason.to_string()))
}
},
}
}
}
#[cfg(feature = "interactive-auth")]
mod internal {
use super::*;
impl WebViewAuth for AuthCodeAuthorizationUrlParameters {
fn webview(
host_options: HostOptions,
window: &Window,
proxy: EventLoopProxy<UserEvents>,
) -> anyhow::Result<WebView> {
let start_uri = host_options.start_uri.clone();
let validator = WebViewHostValidator::try_from(host_options)?;
Ok(WebViewBuilder::new(window)
.with_url(start_uri.as_ref())
.with_file_drop_handler(|_| true)
.with_navigation_handler(move |uri| {
if let Ok(url) = Url::parse(uri.as_str()) {
let is_valid_host = validator.is_valid_uri(&url);
let is_redirect = validator.is_redirect_host(&url);
if is_redirect {
proxy.send_event(UserEvents::ReachedRedirectUri(url))
.unwrap();
proxy.send_event(UserEvents::InternalCloseWindow)
.unwrap();
return true;
}
is_valid_host
} else {
tracing::debug!(target: INTERACTIVE_AUTH, "unable to navigate webview - url is none");
proxy.send_event(UserEvents::CloseWindow).unwrap();
false
}
})
.build()?)
}
}
}
impl AuthorizationUrl for AuthCodeAuthorizationUrlParameters {
fn redirect_uri(&self) -> Option<&Url> {
self.app_config.redirect_uri.as_ref()
}
fn authorization_url(&self) -> IdentityResult<Url> {
self.authorization_url_with_host(&AzureCloudInstance::default())
}
fn authorization_url_with_host(
&self,
azure_cloud_instance: &AzureCloudInstance,
) -> IdentityResult<Url> {
let mut serializer = AuthSerializer::new();
if let Some(redirect_uri) = self.app_config.redirect_uri.as_ref() {
if redirect_uri.as_str().trim().is_empty() {
return AF::result("redirect_uri");
} else {
serializer.redirect_uri(redirect_uri.as_str());
}
}
let client_id = self.app_config.client_id.to_string();
if client_id.is_empty() || self.app_config.client_id.is_nil() {
return AF::result("client_id");
}
if self.app_config.scope.is_empty() {
return AF::result("scope");
}
serializer
.client_id(client_id.as_str())
.set_scope(self.app_config.scope.clone());
let response_types: Vec<String> =
self.response_type.iter().map(|s| s.to_string()).collect();
if response_types.is_empty() {
serializer.response_type("code");
if let Some(response_mode) = self.response_mode.as_ref() {
serializer.response_mode(response_mode.as_ref());
}
} else {
let response_type = response_types.join(" ").trim().to_owned();
if response_type.is_empty() {
serializer.response_type("code");
} else {
serializer.response_type(response_type);
}
if self.response_type.contains(&ResponseType::IdToken) {
if self.response_mode.eq(&Some(ResponseMode::Query)) {
return Err(AF::msg_err(
"response_mode",
"ResponseType::IdToken requires ResponseMode::Fragment or ResponseMode::FormPost")
);
} else if let Some(response_mode) = self.response_mode.as_ref() {
serializer.response_mode(response_mode.as_ref());
}
} else if let Some(response_mode) = self.response_mode.as_ref() {
serializer.response_mode(response_mode.as_ref());
}
}
if let Some(state) = self.state.as_ref() {
serializer.state(state.as_str());
}
if !self.prompt.is_empty() {
serializer.prompt(&self.prompt.as_query());
}
if let Some(domain_hint) = self.domain_hint.as_ref() {
serializer.domain_hint(domain_hint.as_str());
}
if let Some(login_hint) = self.login_hint.as_ref() {
serializer.login_hint(login_hint.as_str());
}
if let Some(nonce) = self.nonce.as_ref() {
serializer.nonce(nonce);
}
if let Some(code_challenge) = self.code_challenge.as_ref() {
serializer.code_challenge(code_challenge.as_str());
}
if let Some(code_challenge_method) = self.code_challenge_method.as_ref() {
serializer.code_challenge_method(code_challenge_method.as_str());
}
let query = serializer.encode_query(
vec![
AuthParameter::ResponseMode,
AuthParameter::State,
AuthParameter::Prompt,
AuthParameter::LoginHint,
AuthParameter::DomainHint,
AuthParameter::Nonce,
AuthParameter::CodeChallenge,
AuthParameter::CodeChallengeMethod,
],
vec![
AuthParameter::ClientId,
AuthParameter::ResponseType,
AuthParameter::RedirectUri,
AuthParameter::Scope,
],
)?;
let mut uri = azure_cloud_instance.auth_uri(&self.app_config.authority)?;
uri.set_query(Some(query.as_str()));
Ok(uri)
}
}
#[derive(Clone)]
pub struct AuthCodeAuthorizationUrlParameterBuilder {
credential: AuthCodeAuthorizationUrlParameters,
}
impl AuthCodeAuthorizationUrlParameterBuilder {
pub fn new(client_id: impl TryInto<Uuid>) -> AuthCodeAuthorizationUrlParameterBuilder {
let mut response_type = BTreeSet::new();
response_type.insert(ResponseType::Code);
AuthCodeAuthorizationUrlParameterBuilder {
credential: AuthCodeAuthorizationUrlParameters {
app_config: AppConfig::new(client_id),
response_mode: None,
response_type,
nonce: None,
state: None,
prompt: Default::default(),
domain_hint: None,
login_hint: None,
code_challenge: None,
code_challenge_method: None,
},
}
}
pub(crate) fn new_with_app_config(
app_config: AppConfig,
) -> AuthCodeAuthorizationUrlParameterBuilder {
let mut response_type = BTreeSet::new();
response_type.insert(ResponseType::Code);
AuthCodeAuthorizationUrlParameterBuilder {
credential: AuthCodeAuthorizationUrlParameters {
app_config,
response_mode: None,
response_type,
nonce: None,
state: None,
prompt: Default::default(),
domain_hint: None,
login_hint: None,
code_challenge: None,
code_challenge_method: None,
},
}
}
pub fn with_redirect_uri(&mut self, redirect_uri: Url) -> &mut Self {
self.credential.app_config.redirect_uri = Some(redirect_uri);
self
}
pub fn with_response_type<I: IntoIterator<Item = ResponseType>>(
&mut self,
response_type: I,
) -> &mut Self {
self.credential.response_type = response_type.into_iter().collect();
self
}
pub fn with_response_mode(&mut self, response_mode: ResponseMode) -> &mut Self {
self.credential.response_mode = Some(response_mode);
self
}
pub fn with_nonce<T: AsRef<str>>(&mut self, nonce: T) -> &mut Self {
self.credential.nonce = Some(nonce.as_ref().to_owned());
self
}
pub fn with_generated_nonce(&mut self) -> &mut Self {
self.credential.nonce = Some(secure_random_32());
self
}
pub fn with_state<T: AsRef<str>>(&mut self, state: T) -> &mut Self {
self.credential.state = Some(state.as_ref().to_owned());
self
}
pub fn with_prompt<I: IntoIterator<Item = Prompt>>(&mut self, prompt: I) -> &mut Self {
self.credential.prompt.extend(prompt.into_iter());
self
}
pub fn with_domain_hint<T: AsRef<str>>(&mut self, domain_hint: T) -> &mut Self {
self.credential.domain_hint = Some(domain_hint.as_ref().to_owned());
self
}
pub fn with_login_hint<T: AsRef<str>>(&mut self, login_hint: T) -> &mut Self {
self.credential.login_hint = Some(login_hint.as_ref().to_owned());
self
}
pub fn with_code_challenge<T: AsRef<str>>(&mut self, code_challenge: T) -> &mut Self {
self.credential.code_challenge = Some(code_challenge.as_ref().to_owned());
self
}
pub fn with_code_challenge_method<T: AsRef<str>>(
&mut self,
code_challenge_method: T,
) -> &mut Self {
self.credential.code_challenge_method = Some(code_challenge_method.as_ref().to_owned());
self
}
pub fn with_pkce(&mut self, proof_key_for_code_exchange: &ProofKeyCodeExchange) -> &mut Self {
self.with_code_challenge(proof_key_for_code_exchange.code_challenge.as_str());
self.with_code_challenge_method(proof_key_for_code_exchange.code_challenge_method.as_str());
self
}
pub fn build(&self) -> AuthCodeAuthorizationUrlParameters {
self.credential.clone()
}
pub fn url_with_host(&self, azure_cloud_instance: &AzureCloudInstance) -> IdentityResult<Url> {
self.credential.url_with_host(azure_cloud_instance)
}
pub fn url(&self) -> IdentityResult<Url> {
self.credential.url()
}
pub fn with_auth_code(
self,
authorization_code: impl AsRef<str>,
) -> AuthorizationCodeCredentialBuilder {
AuthorizationCodeCredentialBuilder::new_with_auth_code(
authorization_code,
self.credential.app_config,
)
}
pub fn with_auth_code_assertion(
self,
authorization_code: impl AsRef<str>,
) -> AuthorizationCodeAssertionCredentialBuilder {
AuthorizationCodeAssertionCredentialBuilder::new_with_auth_code(
self.credential.app_config,
authorization_code,
)
}
#[cfg(feature = "openssl")]
pub fn with_auth_code_x509_certificate(
self,
authorization_code: impl AsRef<str>,
x509: &X509Certificate,
) -> IdentityResult<AuthorizationCodeCertificateCredentialBuilder> {
AuthorizationCodeCertificateCredentialBuilder::new_with_auth_code_and_x509(
authorization_code,
x509,
self.credential.app_config,
)
}
}
#[cfg(feature = "interactive-auth")]
impl WithInteractiveAuth<Secret> for AuthCodeAuthorizationUrlParameterBuilder {
type CredentialBuilder = AuthorizationCodeCredentialBuilder;
fn with_interactive_auth(
&self,
auth_type: Secret,
options: WebViewOptions,
) -> WebViewResult<WebViewAuthorizationEvent<Self::CredentialBuilder>> {
let authorization_response = self
.credential
.interactive_webview_authentication(options)?;
if authorization_response.is_err() {
tracing::debug!(target: INTERACTIVE_AUTH, "error in authorization query or fragment from redirect uri");
return Ok(WebViewAuthorizationEvent::Unauthorized(
authorization_response,
));
}
tracing::debug!(target: INTERACTIVE_AUTH, "parsed authorization query or fragment from redirect uri");
let mut credential_builder = {
if let Some(authorization_code) = authorization_response.code.as_ref() {
AuthorizationCodeCredentialBuilder::new_with_auth_code(
authorization_code,
self.credential.app_config.clone(),
)
} else {
AuthorizationCodeCredentialBuilder::new_with_token(
self.credential.app_config.clone(),
Token::try_from(authorization_response.clone())?,
)
}
};
credential_builder.with_client_secret(auth_type.0);
Ok(WebViewAuthorizationEvent::Authorized {
authorization_response,
credential_builder,
})
}
}
#[cfg(feature = "interactive-auth")]
impl WithInteractiveAuth<Assertion> for AuthCodeAuthorizationUrlParameterBuilder {
type CredentialBuilder = AuthorizationCodeAssertionCredentialBuilder;
fn with_interactive_auth(
&self,
auth_type: Assertion,
options: WebViewOptions,
) -> WebViewResult<WebViewAuthorizationEvent<Self::CredentialBuilder>> {
let authorization_response = self
.credential
.interactive_webview_authentication(options)?;
if authorization_response.is_err() {
tracing::debug!(target: INTERACTIVE_AUTH, "error in authorization query or fragment from redirect uri");
return Ok(WebViewAuthorizationEvent::Unauthorized(
authorization_response,
));
}
tracing::debug!(target: INTERACTIVE_AUTH, "parsed authorization query or fragment from redirect uri");
let mut credential_builder = {
if let Some(authorization_code) = authorization_response.code.as_ref() {
AuthorizationCodeAssertionCredentialBuilder::new_with_auth_code(
self.credential.app_config.clone(),
authorization_code,
)
} else {
AuthorizationCodeAssertionCredentialBuilder::new_with_token(
self.credential.app_config.clone(),
Token::try_from(authorization_response.clone())?,
)
}
};
credential_builder.with_client_assertion(auth_type.0);
Ok(WebViewAuthorizationEvent::Authorized {
authorization_response,
credential_builder,
})
}
}
#[cfg(feature = "openssl")]
#[cfg(feature = "interactive-auth")]
impl WithInteractiveAuth<&X509Certificate> for AuthCodeAuthorizationUrlParameterBuilder {
type CredentialBuilder = AuthorizationCodeCertificateCredentialBuilder;
fn with_interactive_auth(
&self,
auth_type: &X509Certificate,
options: WebViewOptions,
) -> WebViewResult<WebViewAuthorizationEvent<Self::CredentialBuilder>> {
let authorization_response = self
.credential
.interactive_webview_authentication(options)?;
if authorization_response.is_err() {
tracing::debug!(target: INTERACTIVE_AUTH, "error in authorization query or fragment from redirect uri");
return Ok(WebViewAuthorizationEvent::Unauthorized(
authorization_response,
));
}
tracing::debug!(target: INTERACTIVE_AUTH, "parsed authorization query or fragment from redirect uri");
let mut credential_builder = {
if let Some(authorization_code) = authorization_response.code.as_ref() {
AuthorizationCodeCertificateCredentialBuilder::new_with_auth_code_and_x509(
authorization_code,
auth_type,
self.credential.app_config.clone(),
)?
} else {
AuthorizationCodeCertificateCredentialBuilder::new_with_token(
Token::try_from(authorization_response.clone())?,
auth_type,
self.credential.app_config.clone(),
)?
}
};
credential_builder.with_x509(auth_type)?;
Ok(WebViewAuthorizationEvent::Authorized {
authorization_response,
credential_builder,
})
}
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn serialize_uri() {
let authorizer = AuthCodeAuthorizationUrlParameters::builder(Uuid::new_v4())
.with_redirect_uri(Url::parse("https://localhost:8080").unwrap())
.with_scope(["read", "write"])
.build();
let url_result = authorizer.url();
assert!(url_result.is_ok());
}
#[test]
fn url_with_host() {
let url_result = AuthCodeAuthorizationUrlParameters::builder(Uuid::new_v4())
.with_redirect_uri(Url::parse("https://localhost:8080").unwrap())
.with_scope(["read", "write"])
.url_with_host(&AzureCloudInstance::AzureGermany);
assert!(url_result.is_ok());
}
#[test]
#[should_panic]
fn response_type_id_token_panics_when_response_mode_query() {
let url = AuthCodeAuthorizationUrlParameters::builder(Uuid::new_v4())
.with_redirect_uri(Url::parse("https://localhost:8080").unwrap())
.with_scope(["read", "write"])
.with_response_mode(ResponseMode::Query)
.with_response_type(vec![ResponseType::IdToken])
.url()
.unwrap();
let _query = url.query().unwrap();
}
#[test]
fn response_mode_not_set() {
let url = AuthCodeAuthorizationUrlParameters::builder(Uuid::new_v4())
.with_redirect_uri(Url::parse("https://localhost:8080").unwrap())
.with_scope(["read", "write"])
.url()
.unwrap();
let query = url.query().unwrap();
assert!(!query.contains("response_mode"));
assert!(query.contains("response_type=code"));
}
#[test]
fn multi_response_type_set() {
let url = AuthCodeAuthorizationUrlParameters::builder(Uuid::new_v4())
.with_redirect_uri(Url::parse("https://localhost:8080").unwrap())
.with_scope(["read", "write"])
.with_response_mode(ResponseMode::FormPost)
.with_response_type(vec![ResponseType::IdToken, ResponseType::Code])
.url()
.unwrap();
let query = url.query().unwrap();
assert!(query.contains("response_mode=form_post"));
assert!(query.contains("response_type=code+id_token"));
}
#[test]
fn generate_nonce() {
let url = AuthCodeAuthorizationUrlParameters::builder(Uuid::new_v4())
.with_redirect_uri(Url::parse("https://localhost:8080").unwrap())
.with_scope(["read", "write"])
.with_generated_nonce()
.url()
.unwrap();
let query = url.query().unwrap();
assert!(query.contains("nonce"));
}
}