Files
huskies/server/src/service/oauth/io.rs
T

113 lines
3.8 KiB
Rust
Raw Normal View History

//! OAuth I/O — the ONLY place in `service/oauth/` that may perform side effects.
//!
//! Side effects here include: reading the system clock, making HTTP requests to
//! the Anthropic token endpoint, and reading/writing `~/.claude/.credentials.json`.
//! All business logic and branching belong in `mod.rs`, `pkce.rs`, or `flow.rs`.
use super::Error;
use super::flow::FlowStatus;
use super::pkce::SCOPES;
use crate::llm::oauth::{self, CredentialsFile};
use crate::slog;
/// Raw token exchange result returned by the Anthropic OAuth endpoint.
#[derive(serde::Deserialize)]
pub(super) struct TokenExchangeResult {
pub access_token: String,
pub refresh_token: Option<String>,
pub expires_in: u64,
#[allow(dead_code)]
pub token_type: Option<String>,
#[allow(dead_code)]
pub scope: Option<String>,
}
/// Return the current Unix-epoch time in milliseconds.
pub(super) fn current_time_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
/// Exchange an authorization code for tokens via the Anthropic token endpoint.
///
/// Returns the raw token response on success. Network or HTTP errors are
/// mapped to typed [`Error`] variants.
pub(super) async fn exchange_code_for_tokens(
code: &str,
redirect_uri: &str,
code_verifier: &str,
csrf_state: &str,
) -> Result<TokenExchangeResult, Error> {
use super::pkce::CLIENT_ID;
const TOKEN_ENDPOINT: &str = "https://platform.claude.com/v1/oauth/token";
slog!("[oauth] Exchanging authorization code for tokens");
let client = reqwest::Client::new();
let resp = client
.post(TOKEN_ENDPOINT)
.header("Content-Type", "application/json")
.json(&serde_json::json!({
"grant_type": "authorization_code",
"code": code,
"client_id": CLIENT_ID,
"redirect_uri": redirect_uri,
"code_verifier": code_verifier,
"state": csrf_state,
}))
.send()
.await
.map_err(|e| Error::Network(format!("Failed to contact Anthropic: {e}")))?;
let status = resp.status();
let body = resp.text().await.unwrap_or_default();
slog!(
"[oauth] Token exchange response (HTTP {}): {}",
status,
body
);
if !status.is_success() {
return Err(Error::InvalidGrant(format!(
"Anthropic returned HTTP {status}. Please try again."
)));
}
serde_json::from_str(&body)
.map_err(|e| Error::Parse(format!("Unexpected response from Anthropic: {e}")))
}
/// Persist a token exchange result to `~/.claude/.credentials.json`.
///
/// Builds a [`CredentialsFile`] from the token response and `now_ms`, then
/// delegates to [`oauth::write_credentials`].
pub(super) fn save_credentials(token: &TokenExchangeResult, now_ms: u64) -> Result<(), Error> {
let creds = CredentialsFile {
claude_ai_oauth: oauth::OAuthCredentials {
access_token: token.access_token.clone(),
refresh_token: token.refresh_token.clone().unwrap_or_default(),
expires_at: now_ms + (token.expires_in * 1000),
scopes: SCOPES.split(' ').map(|s| s.to_string()).collect(),
subscription_type: None,
rate_limit_tier: None,
},
};
oauth::write_credentials(&creds).map_err(Error::TokenStorage)
}
/// Load OAuth credentials from disk and compute a [`FlowStatus`].
///
/// Returns `Ok(None)` when no credentials file exists yet (user not logged in).
pub(super) fn load_status() -> FlowStatus {
match oauth::read_credentials() {
Ok(creds) => {
let now_ms = current_time_ms();
super::flow::build_flow_status(&creds, now_ms)
}
Err(_) => super::flow::unauthenticated_status(),
}
}