use std::fmt;
use std::time::Duration;
use oauth2::basic::{BasicClient, BasicErrorResponse, BasicTokenResponse};
use oauth2::url::{Host, Position, Url};
use oauth2::{
AuthUrl, AuthorizationCode, ClientId, ClientSecret, CsrfToken, EndpointNotSet, EndpointSet,
HttpClientError, PkceCodeChallenge, RedirectUrl, RequestTokenError, Scope, TokenResponse,
TokenUrl,
};
use crate::oauth::error::OauthError;
use crate::oauth::pkce::{OauthState, PkceVerifier};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Endpoints {
pub authorization: &'static str,
pub token: &'static str,
}
pub const GITHUB: Endpoints = Endpoints {
authorization: "https://github.com/login/oauth/authorize",
token: "https://github.com/login/oauth/access_token",
};
pub const GOOGLE: Endpoints = Endpoints {
authorization: "https://accounts.google.com/o/oauth2/v2/auth",
token: "https://oauth2.googleapis.com/token",
};
pub const DISCORD: Endpoints = Endpoints {
authorization: "https://discord.com/oauth2/authorize",
token: "https://discord.com/api/oauth2/token",
};
fn require_transport_security(url: &Url, role: &'static str) -> Result<(), OauthError> {
if url.scheme() == "https" {
return Ok(());
}
let loopback = match url.host() {
Some(Host::Domain(name)) => name.eq_ignore_ascii_case("localhost"),
Some(Host::Ipv4(address)) => address.is_loopback(),
Some(Host::Ipv6(address)) => address.is_loopback(),
None => false,
};
if url.scheme() == "http" && loopback {
return Ok(());
}
Err(OauthError::InsecureTransport { role })
}
fn checked_url(raw: &str, role: &'static str) -> Result<Url, OauthError> {
let url = Url::parse(raw).map_err(|_| OauthError::InvalidUrl { role })?;
require_transport_security(&url, role)?;
Ok(url)
}
type ConfiguredClient =
BasicClient<EndpointSet, EndpointNotSet, EndpointNotSet, EndpointNotSet, EndpointSet>;
pub struct OauthClient {
inner: ConfiguredClient,
http: oauth2::reqwest::Client,
}
impl fmt::Debug for OauthClient {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("OauthClient").finish_non_exhaustive()
}
}
impl OauthClient {
pub fn new(
endpoints: Endpoints,
client_id: impl Into<String>,
client_secret: Option<String>,
redirect_uri: &str,
) -> Result<Self, OauthError> {
Self::for_urls(
endpoints.authorization,
endpoints.token,
client_id,
client_secret,
redirect_uri,
)
}
pub fn for_urls(
authorization_endpoint: &str,
token_endpoint: &str,
client_id: impl Into<String>,
client_secret: Option<String>,
redirect_uri: &str,
) -> Result<Self, OauthError> {
let auth = checked_url(authorization_endpoint, "authorization endpoint")?;
let token = checked_url(token_endpoint, "token endpoint")?;
let redirect = checked_url(redirect_uri, "redirect URI")?;
let mut inner = BasicClient::new(ClientId::new(client_id.into()));
if let Some(secret) = client_secret {
inner = inner.set_client_secret(ClientSecret::new(secret));
}
let inner = inner
.set_auth_uri(AuthUrl::from_url(auth))
.set_token_uri(TokenUrl::from_url(token))
.set_redirect_uri(RedirectUrl::from_url(redirect));
let http = oauth2::reqwest::ClientBuilder::new()
.redirect(oauth2::reqwest::redirect::Policy::none())
.build()
.map_err(|_| OauthError::Transport)?;
Ok(Self { inner, http })
}
}
pub struct Authorization {
url: Url,
state: OauthState,
verifier: PkceVerifier,
}
impl fmt::Debug for Authorization {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Authorization")
.field(
"url",
&format_args!("{}?[redacted]", &self.url[..Position::AfterPath]),
)
.field("state", &self.state)
.field("verifier", &self.verifier)
.finish()
}
}
impl Authorization {
#[must_use]
pub fn url(&self) -> &Url {
&self.url
}
#[must_use]
pub fn state(&self) -> &OauthState {
&self.state
}
#[must_use]
pub fn verifier(&self) -> &PkceVerifier {
&self.verifier
}
#[must_use]
pub fn into_parts(self) -> (Url, OauthState, PkceVerifier) {
(self.url, self.state, self.verifier)
}
}
impl OauthClient {
pub fn authorize(&self, scopes: &[&str]) -> Result<Authorization, OauthError> {
let state = OauthState::generate()?;
let (challenge, verifier) = PkceCodeChallenge::new_random_sha256();
let carried = state.as_str().to_string();
let (url, _) = self
.inner
.authorize_url(move || CsrfToken::new(carried))
.add_scopes(scopes.iter().map(|s| Scope::new((*s).to_string())))
.set_pkce_challenge(challenge)
.url();
Ok(Authorization {
url,
state,
verifier: PkceVerifier::from_secret(verifier.into_secret()),
})
}
pub async fn exchange(
&self,
stored: &OauthState,
returned: &str,
code: &str,
verifier: PkceVerifier,
) -> Result<TokenSet, OauthError> {
if !stored.verify(returned) {
return Err(OauthError::StateMismatch);
}
let response = self
.inner
.exchange_code(AuthorizationCode::new(code.to_string()))
.set_pkce_verifier(verifier.into_inner())
.request_async(&self.http)
.await
.map_err(map_token_error)?;
Ok(TokenSet::from_response(&response))
}
}
fn map_token_error(
error: RequestTokenError<HttpClientError<oauth2::reqwest::Error>, BasicErrorResponse>,
) -> OauthError {
match error {
RequestTokenError::ServerResponse(response) => OauthError::Provider {
code: response.error().to_string(),
},
RequestTokenError::Request(_) => OauthError::Transport,
RequestTokenError::Parse(_, _) => OauthError::MalformedResponse,
RequestTokenError::Other(_) => OauthError::MalformedResponse,
}
}
pub struct TokenSet {
access_token: String,
refresh_token: Option<String>,
token_type: String,
expires_in: Option<Duration>,
scopes: Vec<String>,
}
impl fmt::Debug for TokenSet {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str("TokenSet([redacted])")
}
}
impl TokenSet {
fn from_response(response: &BasicTokenResponse) -> Self {
Self {
access_token: response.access_token().secret().clone(),
refresh_token: response.refresh_token().map(|t| t.secret().clone()),
token_type: response.token_type().as_ref().to_string(),
expires_in: response.expires_in(),
scopes: response
.scopes()
.map(|scopes| scopes.iter().map(|s| s.to_string()).collect())
.unwrap_or_default(),
}
}
#[must_use]
pub fn new(access_token: impl Into<String>, token_type: impl Into<String>) -> Self {
Self {
access_token: access_token.into(),
refresh_token: None,
token_type: token_type.into(),
expires_in: None,
scopes: Vec::new(),
}
}
#[must_use]
pub fn access_token(&self) -> &str {
&self.access_token
}
#[must_use]
pub fn refresh_token(&self) -> Option<&str> {
self.refresh_token.as_deref()
}
#[must_use]
pub fn token_type(&self) -> &str {
&self.token_type
}
#[must_use]
pub fn expires_in(&self) -> Option<Duration> {
self.expires_in
}
#[must_use]
pub fn scopes(&self) -> &[String] {
&self.scopes
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(raw: &str) -> Url {
Url::parse(raw).expect("test URL parses")
}
#[test]
fn https_is_always_accepted() {
assert!(require_transport_security(&parse("https://example.test/authorize"), "x").is_ok());
}
#[test]
fn plaintext_http_is_refused_off_loopback() {
let result = require_transport_security(&parse("http://example.test/authorize"), "x");
assert!(matches!(result, Err(OauthError::InsecureTransport { .. })));
}
#[test]
fn plaintext_http_is_allowed_on_loopback_only() {
for raw in [
"http://localhost:3000/callback",
"http://LOCALHOST:3000/callback",
"http://127.0.0.1:3000/callback",
"http://[::1]:3000/callback",
] {
assert!(
require_transport_security(&parse(raw), "x").is_ok(),
"{raw} should be allowed"
);
}
assert!(require_transport_security(&parse("http://localhost.evil.test/"), "x").is_err());
assert!(require_transport_security(&parse("http://127.0.0.1.evil.test/"), "x").is_err());
}
#[test]
fn a_client_refuses_to_build_over_plaintext() {
let result = OauthClient::for_urls(
"http://sso.example.test/authorize",
"https://sso.example.test/token",
"id",
Some("secret".into()),
"https://app.example.test/callback",
);
assert!(matches!(
result,
Err(OauthError::InsecureTransport {
role: "authorization endpoint"
})
));
}
#[test]
fn the_bundled_presets_all_pass_the_transport_check() {
for endpoints in [GITHUB, GOOGLE, DISCORD] {
assert!(checked_url(endpoints.authorization, "authorization endpoint").is_ok());
assert!(checked_url(endpoints.token, "token endpoint").is_ok());
}
}
}