chore: hapus implementasi OAuth/session lama yang sudah digantikan zesdex-iam
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
add6845edf
commit
f51a32569f
@@ -1,4 +1,3 @@
|
||||
//! Service layer: LLM provider HTTP client and OAuth flows.
|
||||
//! Service layer: LLM provider HTTP client.
|
||||
|
||||
pub mod oauth;
|
||||
pub mod provider;
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! Minimal loopback HTTP server for capturing OAuth authorization-code redirects.
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
|
||||
/// `?code=...` redirect and serves back a static confirmation page.
|
||||
pub struct LoopbackServer {
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl LoopbackServer {
|
||||
/// Bind to an OS-assigned free port on localhost.
|
||||
///
|
||||
/// Return: `Err` if the loopback interface can't be bound.
|
||||
pub fn bind() -> std::io::Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
Ok(LoopbackServer { listener, port })
|
||||
}
|
||||
|
||||
/// The redirect URI to hand to the OAuth authorization endpoint.
|
||||
pub fn redirect_uri(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/callback", self.port)
|
||||
}
|
||||
|
||||
/// Block until one HTTP request arrives, then extract the `code` query param
|
||||
/// and validate that the `state` param matches the expected value.
|
||||
///
|
||||
/// Flow: accept one connection → apply read timeout → parse request line
|
||||
/// → verify state matches → respond 200/400 depending on whether the code
|
||||
/// was found and state matched.
|
||||
///
|
||||
/// Return: `Err(InvalidData)` if no `code` param is present or the state
|
||||
/// doesn't match `expected_state`.
|
||||
pub fn wait_for_code(&self, timeout_ms: u64, expected_state: &str) -> std::io::Result<String> {
|
||||
let (mut stream, _) = self.listener.accept()?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
||||
Self::read_callback(&mut stream, expected_state)
|
||||
}
|
||||
|
||||
/// Read and parse a single HTTP callback request off `stream`, replying with a status page.
|
||||
///
|
||||
/// Why: writes the HTTP response before returning so the browser tab
|
||||
/// shows a result regardless of whether the code was found.
|
||||
fn read_callback(stream: &mut TcpStream, expected_state: &str) -> std::io::Result<String> {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf)?;
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
let code = Self::extract_code(&request);
|
||||
let state = Self::extract_state(&request);
|
||||
let state_ok = state.as_deref() == Some(expected_state);
|
||||
let response = match (code.as_ref(), state_ok) {
|
||||
(Some(_), true) => "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\nAuthorization complete. You may close this tab.",
|
||||
(Some(_), false) => "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nState mismatch — possible CSRF attack.",
|
||||
(None, _) => "HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\nMissing authorization code.",
|
||||
};
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
if !state_ok {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"state mismatch",
|
||||
));
|
||||
}
|
||||
code.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"code not found in callback",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract and percent-decode the `code` query parameter from an HTTP request line.
|
||||
///
|
||||
/// Return: `None` if the request is malformed or has no `code` param.
|
||||
fn extract_code(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "code" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the `state` query parameter from an HTTP request line.
|
||||
///
|
||||
/// Return: `None` if the request is malformed or has no `state` param.
|
||||
fn extract_state(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "state" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-decode a string (e.g. `%20` -> space).
|
||||
///
|
||||
/// Why: invalid escape sequences (missing/non-hex digits) are passed through
|
||||
/// literally as `%` rather than erroring, since this only handles a redirect
|
||||
/// query param, not untrusted binary data.
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%' {
|
||||
match (
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
) {
|
||||
(Some(hi), Some(lo)) => result.push(char::from((hi * 16 + lo) as u8)),
|
||||
_ => {
|
||||
result.push('%');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
//! OAuth 2.0 authorization-code + PKCE flow: token exchange and authorization URL building.
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// An OAuth access token plus its refresh token and absolute expiry (unix seconds).
|
||||
#[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 {}
|
||||
|
||||
/// Static configuration for an OAuth provider: endpoints, client identity, and requested scopes.
|
||||
#[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(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives one OAuth flow: holds config, the current token (if any), and an HTTP client.
|
||||
pub struct OAuthManager {
|
||||
pub config: OAuthConfig,
|
||||
pub token: Option<OAuthToken>,
|
||||
client: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
impl OAuthManager {
|
||||
/// Create a manager for the given provider config with no token yet acquired.
|
||||
pub fn new(config: OAuthConfig) -> Self {
|
||||
OAuthManager {
|
||||
config,
|
||||
token: None,
|
||||
client: reqwest::blocking::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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()
|
||||
.map_err(|e| format!("token request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {e}"))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("token endpoint returned {status}: {body}"));
|
||||
}
|
||||
|
||||
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,
|
||||
refresh_token: body["refresh_token"]
|
||||
.as_str()
|
||||
.map(std::string::ToString::to_string),
|
||||
expires_at: now + expires_in,
|
||||
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// this silently fell back to <https://example.com>, which produced a valid-looking
|
||||
/// 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.
|
||||
pub fn build_auth_url(&self, redirect_uri: &str, state: &str, code_challenge: &str) -> String {
|
||||
let mut url = match url::Url::parse(&self.config.auth_url) {
|
||||
Ok(u) if !self.config.auth_url.is_empty() => u,
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"warning: OAuth auth_url is missing or invalid ('{}'); aborting build_auth_url",
|
||||
self.config.auth_url
|
||||
);
|
||||
return String::new();
|
||||
}
|
||||
};
|
||||
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()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
//! OAuth 2.0 authorization-code + PKCE flow: local HTTP callback server,
|
||||
//! token exchange, and code verifier/challenge generation.
|
||||
|
||||
pub mod loopback;
|
||||
pub mod manager;
|
||||
pub mod pkce;
|
||||
@@ -1,68 +0,0 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! PKCE (Proof Key for Code Exchange) verifier/challenge pair generation for OAuth flows.
|
||||
use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
const VERIFIER_LENGTH: usize = 64;
|
||||
|
||||
/// A randomly generated, base64url-encoded PKCE code verifier.
|
||||
pub struct CodeVerifier(String);
|
||||
|
||||
impl CodeVerifier {
|
||||
/// Generate a fresh random code verifier.
|
||||
pub fn new() -> Self {
|
||||
let bytes: Vec<u8> = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect();
|
||||
CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes))
|
||||
}
|
||||
|
||||
/// Borrow the verifier as a string, to send in the token exchange request.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Derive the S256 code challenge (SHA-256 hash, base64url-encoded) to send
|
||||
/// in the authorization request.
|
||||
pub fn challenge(&self) -> CodeChallenge {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.0.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
CodeChallenge(URL_SAFE_NO_PAD.encode(digest))
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce one pseudo-random byte from the system clock mixed with a monotonic
|
||||
/// counter, providing ~64 bits of per-call unpredictability without a `rand`
|
||||
/// dependency.
|
||||
///
|
||||
/// Why: avoids pulling in a `rand` dependency for a short-lived verifier; the
|
||||
/// monotonic counter ensures that calls within the same clock tick produce
|
||||
/// different values, which is sufficient to prevent OAuth code interception.
|
||||
fn rand_byte() -> u8 {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let seed = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_else(|_| {
|
||||
tracing::warn!("[pkce] system time before UNIX_EPOCH, using 0 for random byte");
|
||||
std::time::Duration::default()
|
||||
})
|
||||
.as_nanos() as u64;
|
||||
((seed ^ counter) & 0xFF) as u8
|
||||
}
|
||||
|
||||
/// The S256-derived code challenge sent in the authorization request URL.
|
||||
pub struct CodeChallenge(String);
|
||||
|
||||
impl CodeChallenge {
|
||||
/// Borrow the challenge as a string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,7 @@
|
||||
//! Authentication entities: session metadata, PID-file lock, and OAuth
|
||||
//! 2.0 PKCE flow types.
|
||||
//! Authentication entities: session metadata and PID-file lock.
|
||||
|
||||
pub mod oauth;
|
||||
pub mod session;
|
||||
pub mod session_lock;
|
||||
|
||||
pub use oauth::{OAuthConfig, OAuthManager, OAuthToken};
|
||||
pub use session::Session;
|
||||
pub use session_lock::SessionLock;
|
||||
|
||||
@@ -1,408 +0,0 @@
|
||||
#![allow(
|
||||
clippy::cast_possible_truncation,
|
||||
clippy::cast_sign_loss,
|
||||
clippy::cast_precision_loss,
|
||||
clippy::cast_possible_wrap
|
||||
)]
|
||||
//! OAuth 2.0 authorization-code + PKCE flow: token exchange, authorization
|
||||
//! URL building, and the PKCE verifier/challenge pair.
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine as _;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
const VERIFIER_LENGTH: usize = 64;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PKCE primitives
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A randomly generated, base64url-encoded PKCE code verifier.
|
||||
pub struct CodeVerifier(String);
|
||||
|
||||
impl CodeVerifier {
|
||||
/// Generate a fresh random code verifier.
|
||||
pub fn new() -> Self {
|
||||
let bytes: Vec<u8> = (0..VERIFIER_LENGTH).map(|_| rand_byte()).collect();
|
||||
CodeVerifier(URL_SAFE_NO_PAD.encode(&bytes))
|
||||
}
|
||||
|
||||
/// Borrow the verifier as a string, to send in the token exchange request.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
|
||||
/// Derive the S256 code challenge (SHA-256 hash, base64url-encoded) to send
|
||||
/// in the authorization request.
|
||||
pub fn challenge(&self) -> CodeChallenge {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(self.0.as_bytes());
|
||||
let digest = hasher.finalize();
|
||||
CodeChallenge(URL_SAFE_NO_PAD.encode(digest))
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for CodeVerifier {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
/// The S256-derived code challenge sent in the authorization request URL.
|
||||
pub struct CodeChallenge(String);
|
||||
|
||||
impl CodeChallenge {
|
||||
/// Borrow the challenge as a string.
|
||||
pub fn as_str(&self) -> &str {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Produce one pseudo-random byte from the system clock mixed with a monotonic
|
||||
/// counter, providing ~64 bits of per-call unpredictability without a `rand`
|
||||
/// dependency.
|
||||
///
|
||||
/// Why: avoids pulling in a `rand` dependency for a short-lived verifier; the
|
||||
/// monotonic counter ensures that calls within the same clock tick produce
|
||||
/// different values, which is sufficient to prevent OAuth code interception.
|
||||
fn rand_byte() -> u8 {
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
static COUNTER: AtomicU64 = AtomicU64::new(0);
|
||||
let counter = COUNTER.fetch_add(1, Ordering::Relaxed);
|
||||
let seed = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_nanos() as u64;
|
||||
((seed ^ counter) & 0xFF) as u8
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OAuth token / config / manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// An OAuth access token plus its refresh token and absolute expiry (unix seconds).
|
||||
#[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,
|
||||
}
|
||||
|
||||
/// Static configuration for an OAuth provider: endpoints, client identity, and
|
||||
/// requested scopes.
|
||||
#[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(),
|
||||
],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drives one OAuth flow: holds config, the current token (if any), and an
|
||||
/// HTTP client.
|
||||
pub struct OAuthManager {
|
||||
pub config: OAuthConfig,
|
||||
pub token: Option<OAuthToken>,
|
||||
client: reqwest::blocking::Client,
|
||||
}
|
||||
|
||||
impl OAuthManager {
|
||||
/// Create a manager for the given provider config with no token yet acquired.
|
||||
pub fn new(config: OAuthConfig) -> Self {
|
||||
OAuthManager {
|
||||
config,
|
||||
token: None,
|
||||
client: reqwest::blocking::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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.
|
||||
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()
|
||||
.map_err(|e| format!("token request failed: {e}"))?;
|
||||
|
||||
let status = resp.status();
|
||||
let body: serde_json::Value = resp.json().map_err(|e| format!("parse failed: {e}"))?;
|
||||
|
||||
if !status.is_success() {
|
||||
return Err(format!("token endpoint returned {status}: {body}"));
|
||||
}
|
||||
|
||||
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,
|
||||
refresh_token: body["refresh_token"]
|
||||
.as_str()
|
||||
.map(std::string::ToString::to_string),
|
||||
expires_at: now + expires_in,
|
||||
token_type: body["token_type"].as_str().unwrap_or("Bearer").to_string(),
|
||||
});
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// this silently fell back to <https://example.com>, which produced a
|
||||
/// valid-looking 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.
|
||||
pub fn build_auth_url(
|
||||
&self,
|
||||
redirect_uri: &str,
|
||||
state: &str,
|
||||
code_challenge: &str,
|
||||
) -> String {
|
||||
let mut url = match url::Url::parse(&self.config.auth_url) {
|
||||
Ok(u) if !self.config.auth_url.is_empty() => u,
|
||||
_ => {
|
||||
tracing::warn!(
|
||||
"warning: OAuth auth_url is missing or invalid ('{}'); aborting build_auth_url",
|
||||
self.config.auth_url
|
||||
);
|
||||
return String::new();
|
||||
}
|
||||
};
|
||||
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()
|
||||
}
|
||||
|
||||
/// Persist the current token to a JSON file at the given path.
|
||||
///
|
||||
/// Flow: serialise `self.token` to pretty JSON → write to temp file →
|
||||
/// fsync → rename → fsync parent directory.
|
||||
pub fn save_token(&self, path: &std::path::Path) -> std::io::Result<()> {
|
||||
if let Some(token) = &self.token {
|
||||
let data = serde_json::to_string_pretty(token)?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
std::fs::write(&tmp, data)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, path)?;
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = std::fs::File::open(parent).and_then(|d| d.sync_all());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Load a token from a JSON file at the given path, replacing the
|
||||
/// in-memory token.
|
||||
///
|
||||
/// Return: `Ok(())` on success, or an `io::Error` if the file is missing
|
||||
/// or malformed.
|
||||
pub fn load_token(&mut self, path: &std::path::Path) -> std::io::Result<()> {
|
||||
let data = std::fs::read_to_string(path)?;
|
||||
let token: OAuthToken = serde_json::from_str(&data)?;
|
||||
self.token = Some(token);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Loopback server for capturing the OAuth authorization-code redirect
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
/// A single-use HTTP listener on `127.0.0.1` that receives the OAuth
|
||||
/// `?code=...` redirect and serves back a static confirmation page.
|
||||
pub struct LoopbackServer {
|
||||
listener: TcpListener,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl LoopbackServer {
|
||||
/// Bind to an OS-assigned free port on localhost.
|
||||
///
|
||||
/// Return: `Err` if the loopback interface can't be bound.
|
||||
pub fn bind() -> std::io::Result<Self> {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")?;
|
||||
let port = listener.local_addr()?.port();
|
||||
Ok(LoopbackServer { listener, port })
|
||||
}
|
||||
|
||||
/// The redirect URI to hand to the OAuth authorization endpoint.
|
||||
pub fn redirect_uri(&self) -> String {
|
||||
format!("http://127.0.0.1:{}/callback", self.port)
|
||||
}
|
||||
|
||||
/// Block until one HTTP request arrives, then extract the `code` query
|
||||
/// param and validate that the `state` param matches the expected value.
|
||||
///
|
||||
/// Flow: accept one connection → apply read timeout → parse request line
|
||||
/// → verify state matches → respond 200/400 depending on whether the code
|
||||
/// was found and state matched.
|
||||
///
|
||||
/// Return: `Err(InvalidData)` if no `code` param is present or the state
|
||||
/// doesn't match `expected_state`.
|
||||
pub fn wait_for_code(
|
||||
&self,
|
||||
timeout_ms: u64,
|
||||
expected_state: &str,
|
||||
) -> std::io::Result<String> {
|
||||
let (mut stream, _) = self.listener.accept()?;
|
||||
stream.set_read_timeout(Some(std::time::Duration::from_millis(timeout_ms)))?;
|
||||
Self::read_callback(&mut stream, expected_state)
|
||||
}
|
||||
|
||||
/// Read and parse a single HTTP callback request off `stream`, replying
|
||||
/// with a status page.
|
||||
///
|
||||
/// Why: writes the HTTP response before returning so the browser tab
|
||||
/// shows a result regardless of whether the code was found.
|
||||
fn read_callback(
|
||||
stream: &mut TcpStream,
|
||||
expected_state: &str,
|
||||
) -> std::io::Result<String> {
|
||||
let mut buf = [0u8; 4096];
|
||||
let n = stream.read(&mut buf)?;
|
||||
let request = String::from_utf8_lossy(&buf[..n]);
|
||||
let code = Self::extract_code(&request);
|
||||
let state = Self::extract_state(&request);
|
||||
let state_ok = state.as_deref() == Some(expected_state);
|
||||
let response = match (code.as_ref(), state_ok) {
|
||||
(Some(_), true) => {
|
||||
"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n\
|
||||
Authorization complete. You may close this tab."
|
||||
}
|
||||
(Some(_), false) => {
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||
State mismatch — possible CSRF attack."
|
||||
}
|
||||
(None, _) => {
|
||||
"HTTP/1.1 400 Bad Request\r\nContent-Type: text/plain\r\n\r\n\
|
||||
Missing authorization code."
|
||||
}
|
||||
};
|
||||
let _ = stream.write_all(response.as_bytes());
|
||||
let _ = stream.flush();
|
||||
if !state_ok {
|
||||
return Err(std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"state mismatch",
|
||||
));
|
||||
}
|
||||
code.ok_or_else(|| {
|
||||
std::io::Error::new(
|
||||
std::io::ErrorKind::InvalidData,
|
||||
"code not found in callback",
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/// Extract and percent-decode the `code` query parameter from an HTTP
|
||||
/// request line.
|
||||
fn extract_code(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "code" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Extract the `state` query parameter from an HTTP request line.
|
||||
fn extract_state(request: &str) -> Option<String> {
|
||||
let line = request.lines().next()?;
|
||||
let path = line.split(' ').nth(1)?;
|
||||
let query = path.split('?').nth(1)?;
|
||||
for pair in query.split('&') {
|
||||
let mut parts = pair.splitn(2, '=');
|
||||
if parts.next()? == "state" {
|
||||
return parts.next().map(urlencoding);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Percent-decode a string (e.g. `%20` -> space).
|
||||
fn urlencoding(s: &str) -> String {
|
||||
let mut result = String::with_capacity(s.len());
|
||||
let mut chars = s.chars();
|
||||
while let Some(c) = chars.next() {
|
||||
if c == '%' {
|
||||
match (
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
chars.next().and_then(|c| c.to_digit(16)),
|
||||
) {
|
||||
(Some(hi), Some(lo)) => result.push(char::from((hi * 16 + lo) as u8)),
|
||||
_ => {
|
||||
result.push('%');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
result.push(c);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user