huskies: merge 609_story_extract_oauth_service
This commit is contained in:
+77
-283
@@ -1,102 +1,23 @@
|
||||
//! OAuth endpoints — Anthropic OAuth callback and token exchange flow.
|
||||
use crate::llm::oauth;
|
||||
//! OAuth endpoints — thin HTTP adapters over `service::oauth`.
|
||||
//!
|
||||
//! Business logic lives in `service::oauth`. These handlers only:
|
||||
//! 1. Extract parameters from the HTTP request.
|
||||
//! 2. Call the service layer.
|
||||
//! 3. Map service errors to HTTP responses.
|
||||
use crate::service::oauth as svc;
|
||||
use crate::slog;
|
||||
use poem::handler;
|
||||
use poem::http::StatusCode;
|
||||
use poem::web::{Data, Query, Redirect};
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Anthropic OAuth configuration.
|
||||
const CLIENT_ID: &str = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
|
||||
/// Claude.ai authorize URL (for Max/Pro subscriptions).
|
||||
const AUTHORIZE_URL: &str = "https://claude.com/cai/oauth/authorize";
|
||||
const TOKEN_ENDPOINT: &str = "https://platform.claude.com/v1/oauth/token";
|
||||
const SCOPES: &str =
|
||||
"user:inference user:profile user:mcp_servers user:sessions:claude_code user:file_upload";
|
||||
|
||||
/// In-memory store for pending PKCE flows, keyed by state parameter.
|
||||
#[derive(Clone)]
|
||||
pub struct OAuthState {
|
||||
/// Maps state → (code_verifier, redirect_uri)
|
||||
pending: Arc<Mutex<HashMap<String, PendingFlow>>>,
|
||||
/// The port the server is listening on (for building redirect_uri).
|
||||
port: u16,
|
||||
}
|
||||
|
||||
struct PendingFlow {
|
||||
code_verifier: String,
|
||||
redirect_uri: String,
|
||||
}
|
||||
|
||||
impl OAuthState {
|
||||
pub fn new(port: u16) -> Self {
|
||||
Self {
|
||||
pending: Arc::new(Mutex::new(HashMap::new())),
|
||||
port,
|
||||
}
|
||||
}
|
||||
|
||||
fn callback_url(&self) -> String {
|
||||
format!("http://localhost:{}/callback", self.port)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate a random alphanumeric string of the given length.
|
||||
fn random_string(len: usize) -> String {
|
||||
use std::collections::hash_map::RandomState;
|
||||
use std::hash::{BuildHasher, Hasher};
|
||||
let mut s = String::with_capacity(len);
|
||||
let chars = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
|
||||
for _ in 0..len {
|
||||
let hasher = RandomState::new().build_hasher();
|
||||
let idx = hasher.finish() as usize % chars.len();
|
||||
s.push(chars[idx] as char);
|
||||
}
|
||||
s
|
||||
}
|
||||
|
||||
/// Compute the S256 PKCE code challenge from a code verifier.
|
||||
fn compute_code_challenge(verifier: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
let hash = Sha256::digest(verifier.as_bytes());
|
||||
base64url_encode(&hash)
|
||||
}
|
||||
|
||||
/// Base64url-encode without padding (RFC 7636).
|
||||
fn base64url_encode(data: &[u8]) -> String {
|
||||
// Standard base64 then convert to base64url
|
||||
const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
let mut result = String::new();
|
||||
let mut i = 0;
|
||||
while i < data.len() {
|
||||
let b0 = data[i] as u32;
|
||||
let b1 = if i + 1 < data.len() {
|
||||
data[i + 1] as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let b2 = if i + 2 < data.len() {
|
||||
data[i + 2] as u32
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let triple = (b0 << 16) | (b1 << 8) | b2;
|
||||
|
||||
result.push(CHARS[((triple >> 18) & 0x3F) as usize] as char);
|
||||
result.push(CHARS[((triple >> 12) & 0x3F) as usize] as char);
|
||||
if i + 1 < data.len() {
|
||||
result.push(CHARS[((triple >> 6) & 0x3F) as usize] as char);
|
||||
}
|
||||
if i + 2 < data.len() {
|
||||
result.push(CHARS[(triple & 0x3F) as usize] as char);
|
||||
}
|
||||
i += 3;
|
||||
}
|
||||
// Convert to base64url: replace + with -, / with _
|
||||
result.replace('+', "-").replace('/', "_")
|
||||
}
|
||||
// Re-export service types so that existing tests in this file continue to
|
||||
// compile unchanged (they use `use super::*` and call these by name).
|
||||
pub(crate) use svc::OAuthState;
|
||||
// Re-exported for tests only (tests use `use super::*` to call these by name).
|
||||
#[cfg(test)]
|
||||
pub(crate) use svc::pkce::{base64url_encode, compute_code_challenge, random_string};
|
||||
|
||||
/// `GET /oauth/authorize` — Initiates the OAuth flow.
|
||||
///
|
||||
@@ -104,35 +25,11 @@ fn base64url_encode(data: &[u8]) -> String {
|
||||
/// Anthropic's authorization page.
|
||||
#[handler]
|
||||
pub async fn oauth_authorize(state: Data<&Arc<OAuthState>>) -> Redirect {
|
||||
let code_verifier = random_string(128);
|
||||
let code_challenge = compute_code_challenge(&code_verifier);
|
||||
let csrf_state = random_string(32);
|
||||
let redirect_uri = state.callback_url();
|
||||
|
||||
slog!("[oauth] Starting OAuth flow, state={}", csrf_state);
|
||||
|
||||
// Store the pending flow
|
||||
state.pending.lock().unwrap().insert(
|
||||
csrf_state.clone(),
|
||||
PendingFlow {
|
||||
code_verifier,
|
||||
redirect_uri: redirect_uri.clone(),
|
||||
},
|
||||
);
|
||||
|
||||
let authorize_url = format!(
|
||||
"{}?code=true&client_id={}&response_type=code&redirect_uri={}&scope={}&code_challenge={}&code_challenge_method=S256&state={}",
|
||||
AUTHORIZE_URL,
|
||||
CLIENT_ID,
|
||||
percent_encode(&redirect_uri),
|
||||
percent_encode(SCOPES),
|
||||
percent_encode(&code_challenge),
|
||||
percent_encode(&csrf_state),
|
||||
);
|
||||
|
||||
Redirect::temporary(authorize_url)
|
||||
let (_, url) = svc::initiate_flow(&state);
|
||||
Redirect::temporary(url)
|
||||
}
|
||||
|
||||
/// Query parameters received on the OAuth callback URL.
|
||||
#[derive(Deserialize)]
|
||||
pub struct CallbackParams {
|
||||
code: Option<String>,
|
||||
@@ -141,18 +38,6 @@ pub struct CallbackParams {
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
/// Response from the Anthropic OAuth token endpoint.
|
||||
#[derive(Deserialize)]
|
||||
struct TokenResponse {
|
||||
access_token: String,
|
||||
refresh_token: Option<String>,
|
||||
expires_in: u64,
|
||||
#[allow(dead_code)]
|
||||
token_type: Option<String>,
|
||||
#[allow(dead_code)]
|
||||
scope: Option<String>,
|
||||
}
|
||||
|
||||
/// `GET /oauth/callback` — Handles the OAuth redirect from Anthropic.
|
||||
///
|
||||
/// Exchanges the authorization code for tokens and writes them to
|
||||
@@ -162,7 +47,7 @@ pub async fn oauth_callback(
|
||||
state: Data<&Arc<OAuthState>>,
|
||||
Query(params): Query<CallbackParams>,
|
||||
) -> poem::Response {
|
||||
// Handle errors from Anthropic
|
||||
// Handle provider-side errors (e.g. user denied access).
|
||||
if let Some(err) = ¶ms.error {
|
||||
let desc = params
|
||||
.error_description
|
||||
@@ -177,7 +62,7 @@ pub async fn oauth_callback(
|
||||
}
|
||||
|
||||
let code = match ¶ms.code {
|
||||
Some(c) => c,
|
||||
Some(c) => c.clone(),
|
||||
None => {
|
||||
return html_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -188,7 +73,7 @@ pub async fn oauth_callback(
|
||||
};
|
||||
|
||||
let csrf_state = match ¶ms.state {
|
||||
Some(s) => s,
|
||||
Some(s) => s.clone(),
|
||||
None => {
|
||||
return html_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
@@ -198,163 +83,72 @@ pub async fn oauth_callback(
|
||||
}
|
||||
};
|
||||
|
||||
// Look up and remove the pending flow
|
||||
let pending = state.pending.lock().unwrap().remove(csrf_state);
|
||||
let flow = match pending {
|
||||
Some(f) => f,
|
||||
None => {
|
||||
slog!("[oauth] Unknown state parameter: {}", csrf_state);
|
||||
return html_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Invalid State",
|
||||
"Unknown or expired state parameter. Please try logging in again.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
slog!("[oauth] Received callback, exchanging code for tokens");
|
||||
|
||||
// Exchange the 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": &flow.redirect_uri,
|
||||
"code_verifier": &flow.code_verifier,
|
||||
"state": csrf_state,
|
||||
}))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let resp = match resp {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
slog!("[oauth] Token exchange request failed: {}", e);
|
||||
return html_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Token Exchange Failed",
|
||||
&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 html_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Token Exchange Failed",
|
||||
&format!("Anthropic returned HTTP {status}. Please try again."),
|
||||
);
|
||||
match svc::exchange_code(&state, &code, &csrf_state).await {
|
||||
Ok(()) => html_response(
|
||||
StatusCode::OK,
|
||||
"Authenticated!",
|
||||
"Claude OAuth login successful. You can close this tab and return to Huskies.",
|
||||
),
|
||||
Err(e) => map_service_error(e),
|
||||
}
|
||||
|
||||
let token_resp: TokenResponse = match serde_json::from_str(&body) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
slog!("[oauth] Failed to parse token response: {}", e);
|
||||
return html_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Token Parse Failed",
|
||||
"Received an unexpected response from Anthropic.",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
let creds = oauth::CredentialsFile {
|
||||
claude_ai_oauth: oauth::OAuthCredentials {
|
||||
access_token: token_resp.access_token,
|
||||
refresh_token: token_resp.refresh_token.unwrap_or_default(),
|
||||
expires_at: now_ms + (token_resp.expires_in * 1000),
|
||||
scopes: SCOPES.split(' ').map(|s| s.to_string()).collect(),
|
||||
subscription_type: None,
|
||||
rate_limit_tier: None,
|
||||
},
|
||||
};
|
||||
|
||||
if let Err(e) = oauth::write_credentials(&creds) {
|
||||
slog!("[oauth] Failed to write credentials: {}", e);
|
||||
return html_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Credential Write Failed",
|
||||
&format!("Tokens received but failed to save: {e}"),
|
||||
);
|
||||
}
|
||||
|
||||
slog!("[oauth] Successfully authenticated and saved credentials");
|
||||
|
||||
html_response(
|
||||
StatusCode::OK,
|
||||
"Authenticated!",
|
||||
"Claude OAuth login successful. You can close this tab and return to Huskies.",
|
||||
)
|
||||
}
|
||||
|
||||
/// Check whether valid (non-expired) OAuth credentials exist.
|
||||
/// `GET /oauth/status` — Check whether valid (non-expired) OAuth credentials exist.
|
||||
#[handler]
|
||||
pub async fn oauth_status() -> poem::Response {
|
||||
match oauth::read_credentials() {
|
||||
Ok(creds) => {
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
let expired = now_ms > creds.claude_ai_oauth.expires_at;
|
||||
let body = serde_json::json!({
|
||||
"authenticated": true,
|
||||
"expired": expired,
|
||||
"expires_at": creds.claude_ai_oauth.expires_at,
|
||||
"has_refresh_token": !creds.claude_ai_oauth.refresh_token.is_empty(),
|
||||
});
|
||||
poem::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_string())
|
||||
}
|
||||
Err(_) => {
|
||||
let body = serde_json::json!({
|
||||
"authenticated": false,
|
||||
"expired": false,
|
||||
"expires_at": 0,
|
||||
"has_refresh_token": false,
|
||||
});
|
||||
poem::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_string())
|
||||
}
|
||||
}
|
||||
let status = svc::check_status();
|
||||
let body = serde_json::json!({
|
||||
"authenticated": status.authenticated,
|
||||
"expired": status.expired,
|
||||
"expires_at": status.expires_at,
|
||||
"has_refresh_token": status.has_refresh_token,
|
||||
});
|
||||
poem::Response::builder()
|
||||
.status(StatusCode::OK)
|
||||
.header("Content-Type", "application/json")
|
||||
.body(body.to_string())
|
||||
}
|
||||
|
||||
/// Percent-encode a string for use in URL query parameters.
|
||||
fn percent_encode(input: &str) -> String {
|
||||
let mut encoded = String::with_capacity(input.len() * 3);
|
||||
for byte in input.bytes() {
|
||||
match byte {
|
||||
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
|
||||
encoded.push(byte as char);
|
||||
}
|
||||
_ => {
|
||||
encoded.push_str(&format!("%{byte:02X}"));
|
||||
}
|
||||
// ── Private helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
/// Map a service-layer `Error` to an HTML HTTP response.
|
||||
fn map_service_error(e: svc::Error) -> poem::Response {
|
||||
use svc::Error;
|
||||
match e {
|
||||
Error::MissingCode => html_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing Code",
|
||||
"No authorization code received.",
|
||||
),
|
||||
Error::MissingState => html_response(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"Missing State",
|
||||
"No state parameter received.",
|
||||
),
|
||||
Error::InvalidState(msg) => html_response(StatusCode::BAD_REQUEST, "Invalid State", &msg),
|
||||
Error::AuthorizationDenied(msg) => {
|
||||
html_response(StatusCode::BAD_REQUEST, "Authentication Failed", &msg)
|
||||
}
|
||||
Error::InvalidGrant(msg) => {
|
||||
html_response(StatusCode::BAD_REQUEST, "Token Exchange Failed", &msg)
|
||||
}
|
||||
Error::Network(msg) => html_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Token Exchange Failed",
|
||||
&msg,
|
||||
),
|
||||
Error::TokenExpired(msg) => html_response(StatusCode::UNAUTHORIZED, "Token Expired", &msg),
|
||||
Error::TokenStorage(msg) => html_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Credential Write Failed",
|
||||
&msg,
|
||||
),
|
||||
Error::Parse(msg) => html_response(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Token Parse Failed",
|
||||
&msg,
|
||||
),
|
||||
}
|
||||
encoded
|
||||
}
|
||||
|
||||
fn html_response(status: StatusCode, title: &str, message: &str) -> poem::Response {
|
||||
|
||||
Reference in New Issue
Block a user