2026-07-12 11:28:39 +07:00
|
|
|
//! OAuth 2.0 authorization-code + PKCE flow: token exchange and authorization URL building.
|
|
|
|
|
|
2026-07-11 18:23:01 +07:00
|
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// An OAuth access token plus its refresh token and absolute expiry (unix seconds).
|
2026-07-11 18:23:01 +07:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct OAuthToken {
|
|
|
|
|
pub access_token: String,
|
|
|
|
|
pub refresh_token: Option<String>,
|
|
|
|
|
pub expires_at: u64,
|
|
|
|
|
pub token_type: String,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl OAuthToken {
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Static configuration for an OAuth provider: endpoints, client identity, and requested scopes.
|
2026-07-11 18:23:01 +07:00
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
|
|
|
pub struct OAuthConfig {
|
|
|
|
|
pub auth_url: String,
|
|
|
|
|
pub token_url: String,
|
|
|
|
|
pub client_id: String,
|
|
|
|
|
pub client_secret: Option<String>,
|
|
|
|
|
pub scopes: Vec<String>,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl Default for OAuthConfig {
|
|
|
|
|
fn default() -> Self {
|
|
|
|
|
OAuthConfig {
|
|
|
|
|
auth_url: String::new(),
|
|
|
|
|
token_url: String::new(),
|
|
|
|
|
client_id: String::new(),
|
|
|
|
|
client_secret: None,
|
|
|
|
|
scopes: vec!["openid".to_string(), "profile".to_string(), "email".to_string()],
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Drives one OAuth flow: holds config, the current token (if any), and an HTTP client.
|
2026-07-11 18:23:01 +07:00
|
|
|
pub struct OAuthManager {
|
|
|
|
|
pub config: OAuthConfig,
|
|
|
|
|
pub token: Option<OAuthToken>,
|
|
|
|
|
client: reqwest::blocking::Client,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
impl OAuthManager {
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Create a manager for the given provider config with no token yet acquired.
|
2026-07-11 18:23:01 +07:00
|
|
|
pub fn new(config: OAuthConfig) -> Self {
|
|
|
|
|
OAuthManager {
|
|
|
|
|
config,
|
|
|
|
|
token: None,
|
|
|
|
|
client: reqwest::blocking::Client::new(),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Exchange an authorization code for an access token via the provider's token endpoint.
|
|
|
|
|
///
|
|
|
|
|
/// Flow: POST form-encoded grant to `token_url` → parse JSON body →
|
|
|
|
|
/// compute absolute `expires_at` from `expires_in` → store on `self.token`.
|
|
|
|
|
///
|
|
|
|
|
/// Return: `Err(String)` on network failure, non-2xx status, or a missing `access_token` field.
|
2026-07-11 18:23:01 +07:00
|
|
|
pub fn exchange_code(&mut self, code: &str, redirect_uri: &str, code_verifier: &str) -> Result<(), String> {
|
|
|
|
|
let mut params = std::collections::HashMap::new();
|
|
|
|
|
params.insert("grant_type", "authorization_code");
|
|
|
|
|
params.insert("code", code);
|
|
|
|
|
params.insert("redirect_uri", redirect_uri);
|
|
|
|
|
params.insert("client_id", &self.config.client_id);
|
|
|
|
|
params.insert("code_verifier", code_verifier);
|
|
|
|
|
|
|
|
|
|
let resp = self.client
|
|
|
|
|
.post(&self.config.token_url)
|
|
|
|
|
.form(¶ms)
|
|
|
|
|
.send()
|
2026-07-13 08:12:02 +07:00
|
|
|
.map_err(|e| format!("token request failed: {e}"))?;
|
2026-07-11 18:23:01 +07:00
|
|
|
|
|
|
|
|
let status = resp.status();
|
2026-07-13 08:12:02 +07:00
|
|
|
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {e}"))?;
|
2026-07-11 18:23:01 +07:00
|
|
|
|
|
|
|
|
if !status.is_success() {
|
2026-07-13 08:12:02 +07:00
|
|
|
return Err(format!("token endpoint returned {status}: {body}"));
|
2026-07-11 18:23:01 +07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let access_token = body["access_token"].as_str().ok_or("missing access_token")?.to_string();
|
|
|
|
|
let expires_in = body["expires_in"].as_u64().unwrap_or(3600);
|
|
|
|
|
let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_secs();
|
|
|
|
|
|
|
|
|
|
self.token = Some(OAuthToken {
|
|
|
|
|
access_token,
|
2026-07-13 08:12:02 +07:00
|
|
|
refresh_token: body["refresh_token"].as_str().map(std::string::ToString::to_string),
|
2026-07-11 18:23:01 +07:00
|
|
|
expires_at: now + expires_in,
|
|
|
|
|
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
Ok(())
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-12 11:28:39 +07:00
|
|
|
/// Build the provider's authorization URL with PKCE and state params attached.
|
|
|
|
|
///
|
|
|
|
|
/// Why: refuses to build a URL if `auth_url` is missing or invalid. Previously
|
2026-07-13 08:12:02 +07:00
|
|
|
/// this silently fell back to <https://example.com>, which produced a valid-looking
|
2026-07-12 11:28:39 +07:00
|
|
|
/// auth URL pointing at the wrong server and leaked client credentials in
|
|
|
|
|
/// query params. Returning an empty string signals failure to callers, who
|
|
|
|
|
/// can prompt the user to fix the OAuth config instead of starting a flow
|
|
|
|
|
/// against a wrong host.
|
|
|
|
|
///
|
|
|
|
|
/// Return: the full authorization URL, or `""` if `auth_url` is empty/unparseable.
|
2026-07-11 18:23:01 +07:00
|
|
|
pub fn build_auth_url(&self, redirect_uri: &str, state: &str, code_challenge: &str) -> String {
|
2026-07-12 10:23:26 +07:00
|
|
|
let mut url = match url::Url::parse(&self.config.auth_url) {
|
|
|
|
|
Ok(u) if !self.config.auth_url.is_empty() => u,
|
|
|
|
|
_ => {
|
2026-07-12 10:57:32 +07:00
|
|
|
tracing::warn!(
|
2026-07-12 10:23:26 +07:00
|
|
|
"warning: OAuth auth_url is missing or invalid ('{}'); aborting build_auth_url",
|
|
|
|
|
self.config.auth_url
|
|
|
|
|
);
|
|
|
|
|
return String::new();
|
|
|
|
|
}
|
|
|
|
|
};
|
2026-07-11 18:23:01 +07:00
|
|
|
url.query_pairs_mut()
|
|
|
|
|
.append_pair("response_type", "code")
|
|
|
|
|
.append_pair("client_id", &self.config.client_id)
|
|
|
|
|
.append_pair("redirect_uri", redirect_uri)
|
|
|
|
|
.append_pair("scope", &self.config.scopes.join(" "))
|
|
|
|
|
.append_pair("state", state)
|
|
|
|
|
.append_pair("code_challenge_method", "S256")
|
|
|
|
|
.append_pair("code_challenge", code_challenge);
|
|
|
|
|
url.to_string()
|
|
|
|
|
}
|
|
|
|
|
}
|