use std::fmt;
use crate::crypto::RandomError;
use crate::encoding::url_encode_component;
use crate::util::log::debug;
use super::config::OAuthConfig;
use super::pkce::PkceChallenge;
use super::state::OAuthState;
#[doc(alias = "auth_request")]
pub struct AuthorizationRequest {
url: String,
state: OAuthState,
pkce: PkceChallenge,
}
impl AuthorizationRequest {
#[must_use = "building may fail; handle the Result"]
pub fn build(config: &OAuthConfig) -> Result<Self, RandomError> {
let state = OAuthState::generate()?;
let pkce = PkceChallenge::generate()?;
let scope_str = config.scopes().join(" ");
let mut url = String::with_capacity(512);
url.push_str(config.authorization_endpoint());
if config.authorization_endpoint().contains('?') {
url.push_str("&response_type=code");
} else {
url.push_str("?response_type=code");
}
url.push_str("&client_id=");
url.push_str(&url_encode_component(config.client_id()));
url.push_str("&redirect_uri=");
url.push_str(&url_encode_component(config.redirect_uri()));
if !scope_str.is_empty() {
url.push_str("&scope=");
url.push_str(&url_encode_component(&scope_str));
}
url.push_str("&state=");
url.push_str(&url_encode_component(state.value()));
url.push_str("&code_challenge=");
url.push_str(&url_encode_component(pkce.challenge()));
url.push_str("&code_challenge_method=S256");
debug!(
endpoint = %config.authorization_endpoint(),
"oauth: authorization URL built"
);
Ok(Self { url, state, pkce })
}
#[must_use]
#[inline]
pub fn url(&self) -> &str {
&self.url
}
#[must_use]
#[inline]
pub fn state(&self) -> &OAuthState {
&self.state
}
#[must_use]
#[inline]
pub fn pkce(&self) -> &PkceChallenge {
&self.pkce
}
}
impl fmt::Debug for AuthorizationRequest {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("AuthorizationRequest")
.field("url", &self.url)
.field("state", &self.state)
.field("pkce", &self.pkce)
.finish()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::oauth::config::OAuthConfig;
fn test_config() -> OAuthConfig {
OAuthConfig::builder("my-client-id", "my-secret")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback")
.scope("openid")
.scope("profile")
.build()
.unwrap()
}
#[test]
fn url_contains_response_type() {
let config = test_config();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
req.url().contains("response_type=code"),
"URL should contain response_type=code: {}",
req.url(),
);
}
#[test]
fn url_contains_client_id() {
let config = test_config();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
req.url().contains("client_id=my-client-id"),
"URL should contain client_id: {}",
req.url(),
);
}
#[test]
fn url_contains_redirect_uri() {
let config = test_config();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
req.url().contains("redirect_uri="),
"URL should contain redirect_uri: {}",
req.url(),
);
assert!(
req.url().contains("myapp.example.com"),
"URL should contain the redirect host: {}",
req.url(),
);
}
#[test]
fn url_contains_scope() {
let config = test_config();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
req.url().contains("scope=openid%20profile"),
"URL should contain encoded scope: {}",
req.url(),
);
}
#[test]
fn url_contains_state() {
let config = test_config();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
req.url().contains("state="),
"URL should contain state parameter: {}",
req.url(),
);
assert!(
req.url().contains(req.state().value()),
"URL should contain the actual state value: {}",
req.url(),
);
}
#[test]
fn url_contains_code_challenge() {
let config = test_config();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
req.url().contains("code_challenge="),
"URL should contain code_challenge: {}",
req.url(),
);
assert!(
req.url().contains(req.pkce().challenge()),
"URL should contain the actual challenge value: {}",
req.url(),
);
}
#[test]
fn url_contains_code_challenge_method() {
let config = test_config();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
req.url().contains("code_challenge_method=S256"),
"URL should contain code_challenge_method=S256: {}",
req.url(),
);
}
#[test]
fn url_starts_with_authorization_endpoint() {
let config = test_config();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
req.url().starts_with("https://auth.example.com/authorize?"),
"URL should start with the authorization endpoint: {}",
req.url(),
);
}
#[test]
fn url_does_not_contain_client_secret() {
let config = test_config();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
!req.url().contains("my-secret"),
"URL must not contain client_secret: {}",
req.url(),
);
assert!(
!req.url().contains("client_secret"),
"URL must not contain client_secret parameter: {}",
req.url(),
);
}
#[test]
fn redirect_uri_is_url_encoded() {
let config = OAuthConfig::builder("client", "secret")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback?foo=bar")
.scope("openid")
.build()
.unwrap();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
req.url()
.contains("redirect_uri=https%3A%2F%2Fmyapp.example.com%2Fcallback%3Ffoo%3Dbar"),
"redirect_uri should be fully URL-encoded: {}",
req.url(),
);
}
#[test]
fn url_omits_scope_when_empty() {
let config = OAuthConfig::builder("client", "secret")
.authorization_endpoint("https://auth.example.com/authorize")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback")
.build()
.unwrap();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
!req.url().contains("scope="),
"URL should not contain scope when no scopes are configured: {}",
req.url(),
);
}
#[test]
fn state_accessor_returns_valid_state() {
let config = test_config();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(!req.state().value().is_empty());
}
#[test]
fn pkce_accessor_returns_valid_pkce() {
let config = test_config();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(!req.pkce().verifier().is_empty());
assert!(!req.pkce().challenge().is_empty());
assert_eq!(req.pkce().method(), "S256");
}
#[test]
fn two_builds_produce_different_state_and_pkce() {
let config = test_config();
let a = AuthorizationRequest::build(&config).unwrap();
let b = AuthorizationRequest::build(&config).unwrap();
assert_ne!(
a.state().value(),
b.state().value(),
"state values should differ",
);
assert_ne!(
a.pkce().verifier(),
b.pkce().verifier(),
"PKCE verifiers should differ",
);
}
#[test]
fn url_appends_with_ampersand_when_endpoint_has_query() {
let config = OAuthConfig::builder("client", "secret")
.authorization_endpoint("https://auth.example.com/authorize?tenant=abc")
.token_endpoint("https://auth.example.com/token")
.redirect_uri("https://myapp.example.com/callback")
.scope("openid")
.build()
.unwrap();
let req = AuthorizationRequest::build(&config).unwrap();
assert!(
req.url().contains("?tenant=abc&response_type=code"),
"URL should append with '&' when endpoint has existing query params: {}",
req.url(),
);
}
}