use anyhow::{anyhow, bail, Context, Result};
use reqwest::{Client, Url};
use serde::Deserialize;
use serde_json::{json, Value};
use crate::credentials::{now_epoch_seconds, OAuthSession};
const REFRESH_SKEW_SECS: u64 = 300;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthMetadata {
pub issuer: String,
pub token_endpoint: String,
pub revocation_endpoint: String,
pub device_authorization_endpoint: String,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OAuthTokenSet {
pub access_token: String,
pub refresh_token: String,
pub expires_in: u64,
pub refresh_expires_at: Option<String>,
pub scope: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AuthorizationServerMetadata {
issuer: String,
token_endpoint: String,
revocation_endpoint: String,
device_authorization_endpoint: String,
#[serde(default)]
grant_types_supported: Vec<String>,
#[serde(default)]
token_endpoint_auth_methods_supported: Vec<String>,
}
pub fn http_client() -> Result<Client> {
Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.context("build OAuth HTTP client")
}
pub async fn discover(http: &Client, auth_base: &str) -> Result<OAuthMetadata> {
let expected_issuer = issuer_from_auth_base(auth_base)?;
let metadata_url = format!(
"{}/.well-known/oauth-authorization-server",
expected_issuer.trim_end_matches('/')
);
let response = http
.get(&metadata_url)
.send()
.await
.with_context(|| format!("OAuth discovery GET {metadata_url}"))?;
if !response.status().is_success() {
bail!(
"OAuth discovery failed HTTP {} at {}",
response.status(),
metadata_url
);
}
let document: AuthorizationServerMetadata = response
.json()
.await
.with_context(|| format!("decode OAuth metadata from {metadata_url}"))?;
let issuer = document.issuer.trim_end_matches('/').to_string();
if issuer != expected_issuer {
bail!("OAuth issuer mismatch: expected {expected_issuer}, received {issuer}");
}
if !document
.grant_types_supported
.iter()
.any(|grant| grant == "refresh_token")
{
bail!("OAuth server does not advertise refresh_token");
}
if !document
.token_endpoint_auth_methods_supported
.iter()
.any(|method| method == "none")
{
bail!("OAuth server does not admit a public-client token endpoint");
}
for (kind, endpoint) in [
("token", document.token_endpoint.as_str()),
("revocation", document.revocation_endpoint.as_str()),
(
"device authorization",
document.device_authorization_endpoint.as_str(),
),
] {
validate_bound_endpoint(&issuer, endpoint)
.with_context(|| format!("invalid {kind} endpoint"))?;
}
Ok(OAuthMetadata {
issuer,
token_endpoint: document.token_endpoint,
revocation_endpoint: document.revocation_endpoint,
device_authorization_endpoint: document.device_authorization_endpoint,
})
}
pub fn token_set_from_value(body: &Value) -> Result<OAuthTokenSet> {
let access_token = string_field(body, &["access_token", "accessToken"])
.ok_or_else(|| anyhow!("OAuth token response is missing access_token"))?;
let refresh_token = string_field(body, &["refresh_token", "refreshToken"])
.ok_or_else(|| anyhow!("OAuth token response is missing rotating refresh_token"))?;
let expires_in = body
.get("expires_in")
.or_else(|| body.get("expiresIn"))
.and_then(Value::as_u64)
.ok_or_else(|| anyhow!("OAuth token response is missing expires_in"))?;
if expires_in == 0 {
bail!("OAuth token response has zero expires_in");
}
let refresh_expires_at = string_field(body, &["refresh_expires_at", "refreshExpiresAt"]);
let scope = body
.get("scope")
.and_then(Value::as_str)
.map(str::to_string);
Ok(OAuthTokenSet {
access_token,
refresh_token,
expires_in,
refresh_expires_at,
scope,
})
}
pub fn session_from_token_set(
metadata: &OAuthMetadata,
client_id: &str,
tokens: &OAuthTokenSet,
now: u64,
) -> OAuthSession {
OAuthSession {
issuer: metadata.issuer.clone(),
token_endpoint: metadata.token_endpoint.clone(),
revocation_endpoint: metadata.revocation_endpoint.clone(),
client_id: client_id.to_string(),
refresh_token: tokens.refresh_token.clone(),
access_expires_at: Some(now.saturating_add(tokens.expires_in)),
refresh_expires_at: tokens.refresh_expires_at.clone(),
scope: tokens.scope.clone(),
}
}
pub async fn refresh(http: &Client, session: &OAuthSession) -> Result<OAuthTokenSet> {
validate_stored_session(session)?;
let response = http
.post(&session.token_endpoint)
.json(&json!({
"grant_type": "refresh_token",
"refresh_token": session.refresh_token,
"client_id": session.client_id,
}))
.send()
.await
.context("OAuth refresh request failed")?;
let status = response.status();
let body: Value = response
.json()
.await
.context("decode OAuth refresh response")?;
if !status.is_success() {
let code = body
.get("error")
.and_then(Value::as_str)
.unwrap_or("oauth_refresh_failed");
bail!("OAuth refresh rejected ({code}, HTTP {status}); run `sylphx login` again");
}
token_set_from_value(&body)
}
pub async fn revoke(http: &Client, session: &OAuthSession, token: &str, hint: &str) -> Result<()> {
validate_stored_session(session)?;
let response = http
.post(&session.revocation_endpoint)
.json(&json!({
"token": token,
"token_type_hint": hint,
"client_id": session.client_id,
}))
.send()
.await
.context("OAuth revocation request failed")?;
if !response.status().is_success() {
bail!(
"OAuth revocation rejected HTTP {}; stored credentials were retained",
response.status()
);
}
Ok(())
}
pub fn should_refresh(access_expires_at: Option<u64>, now: u64) -> bool {
access_expires_at.is_some_and(|expires_at| expires_at <= now.saturating_add(REFRESH_SKEW_SECS))
}
pub fn now() -> Result<u64> {
now_epoch_seconds()
}
fn validate_stored_session(session: &OAuthSession) -> Result<()> {
let issuer = session.issuer.trim_end_matches('/');
if issuer.is_empty() || session.client_id.trim().is_empty() || session.refresh_token.is_empty()
{
bail!("stored OAuth session is incomplete; run `sylphx login` again");
}
validate_bound_endpoint(issuer, &session.token_endpoint)
.context("stored token endpoint is no longer bound to the issuer")?;
validate_bound_endpoint(issuer, &session.revocation_endpoint)
.context("stored revocation endpoint is no longer bound to the issuer")?;
Ok(())
}
fn issuer_from_auth_base(auth_base: &str) -> Result<String> {
let parsed = Url::parse(auth_base).context("parse OAuth auth base URL")?;
validate_transport(&parsed)?;
let host = parsed
.host_str()
.ok_or_else(|| anyhow!("OAuth auth base has no host"))?;
let mut issuer = format!("{}://{}", parsed.scheme(), format_host(host));
if let Some(port) = parsed.port() {
issuer.push(':');
issuer.push_str(&port.to_string());
}
Ok(issuer)
}
fn validate_bound_endpoint(issuer: &str, endpoint: &str) -> Result<()> {
let issuer = Url::parse(issuer).context("parse OAuth issuer")?;
let endpoint = Url::parse(endpoint).context("parse OAuth endpoint")?;
validate_transport(&issuer)?;
validate_transport(&endpoint)?;
if issuer.scheme() != endpoint.scheme()
|| issuer.host_str() != endpoint.host_str()
|| issuer.port_or_known_default() != endpoint.port_or_known_default()
{
bail!("endpoint origin does not match OAuth issuer");
}
if endpoint.username() != "" || endpoint.password().is_some() || endpoint.fragment().is_some() {
bail!("OAuth endpoint contains forbidden authority or fragment components");
}
Ok(())
}
fn validate_transport(url: &Url) -> Result<()> {
if url.scheme() == "https" {
return Ok(());
}
if url.scheme() == "http"
&& url
.host_str()
.is_some_and(|host| matches!(host, "localhost" | "127.0.0.1" | "::1"))
{
return Ok(());
}
bail!("OAuth endpoints require HTTPS (HTTP is allowed only on loopback)")
}
fn format_host(host: &str) -> String {
if host.contains(':') {
format!("[{host}]")
} else {
host.to_string()
}
}
fn string_field(body: &Value, keys: &[&str]) -> Option<String> {
keys.iter()
.find_map(|key| body.get(*key).and_then(Value::as_str))
.map(str::to_string)
.filter(|value| !value.is_empty())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rejects_cross_origin_token_endpoint() {
let error = validate_bound_endpoint(
"https://tenant.api.sylphx.com",
"https://evil.example/oauth/token",
)
.unwrap_err();
assert!(error.to_string().contains("origin"));
}
#[test]
fn accepts_https_and_loopback_only() {
assert!(validate_bound_endpoint(
"https://tenant.api.sylphx.com",
"https://tenant.api.sylphx.com/v1/oauth/token"
)
.is_ok());
assert!(validate_bound_endpoint(
"http://127.0.0.1:8080",
"http://127.0.0.1:8080/v1/oauth/token"
)
.is_ok());
assert!(issuer_from_auth_base("http://tenant.example/v1").is_err());
}
#[test]
fn requires_rotating_refresh_token_and_expiry() {
let missing = json!({"access_token": "at", "expires_in": 900});
assert!(token_set_from_value(&missing).is_err());
let valid = json!({
"access_token": "at",
"refresh_token": "rt",
"expires_in": 900
});
assert_eq!(token_set_from_value(&valid).unwrap().expires_in, 900);
}
#[test]
fn refreshes_before_a_command_can_outlive_the_access_token() {
assert!(!should_refresh(Some(1_001), 700));
assert!(should_refresh(Some(1_000), 700));
assert!(!should_refresh(None, 700));
}
}