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,
pub authorization_endpoint: Option<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)]
authorization_endpoint: Option<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 candidates = metadata_candidates(&expected_issuer);
let primary_url = candidates
.first()
.cloned()
.unwrap_or_else(|| expected_issuer.clone());
let mut found: Option<(String, AuthorizationServerMetadata)> = None;
for metadata_url in &candidates {
let response = http
.get(metadata_url)
.send()
.await
.with_context(|| format!("OAuth discovery GET {metadata_url}"))?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
continue;
}
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}"))?;
found = Some((metadata_url.clone(), document));
break;
}
let document = match found {
Some((_, document)) => document,
None => bail!(
"OAuth discovery failed HTTP {} at {}",
reqwest::StatusCode::NOT_FOUND,
primary_url
),
};
let issuer = document.issuer.trim_end_matches('/').to_string();
if !issuer_matches(&expected_issuer, &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"))?;
}
if let Some(authorize) = document.authorization_endpoint.as_deref() {
validate_bound_endpoint(&issuer, authorize)
.with_context(|| "invalid authorization endpoint")?;
}
Ok(OAuthMetadata {
issuer,
token_endpoint: document.token_endpoint,
revocation_endpoint: document.revocation_endpoint,
device_authorization_endpoint: document.device_authorization_endpoint,
authorization_endpoint: document.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(),
management_base_url: None,
}
}
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 raw = response
.text()
.await
.context("read OAuth refresh response")?;
let parsed: Option<Value> = serde_json::from_str(&raw).ok();
if !status.is_success() {
let code = refresh_rejection_code(parsed.as_ref());
if parsed.is_some() {
bail!("OAuth refresh rejected ({code}, HTTP {status}); run `sylphx login` again");
}
bail!(
"OAuth refresh rejected (oauth_refresh_failed, HTTP {status}): {}; run `sylphx login` again",
refresh_snippet(&raw)
);
}
let Some(body) = parsed else {
bail!(
"OAuth refresh returned a non-JSON success payload (oauth_refresh_failed, HTTP {status}); run `sylphx login` again"
);
};
token_set_from_value(&body).map_err(|err| anyhow!("{err:#}; run `sylphx login` again"))
}
pub(crate) fn refresh_rejection_code(body: Option<&Value>) -> &str {
let top = body.and_then(|value| value.get("error"));
if let Some(code) = top
.and_then(Value::as_str)
.filter(|code| !code.trim().is_empty())
{
return code;
}
if let Some(code) = top
.and_then(|err| err.get("code"))
.and_then(Value::as_str)
.filter(|code| !code.trim().is_empty())
{
return code;
}
"oauth_refresh_failed"
}
pub(crate) fn refresh_snippet(raw: &str) -> String {
const LIMIT: usize = 120;
let single: String = raw.split_whitespace().collect::<Vec<_>>().join(" ");
let trimmed = single.trim();
if trimmed.is_empty() {
return "(empty response body)".to_string();
}
let mut snippet: String = trimmed.chars().take(LIMIT).collect();
if trimmed.chars().count() > LIMIT {
snippet.push('…');
}
snippet
}
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());
}
let path = parsed.path().trim_end_matches('/');
if !path.is_empty() {
issuer.push_str(path);
}
Ok(issuer)
}
fn metadata_candidates(expected_issuer: &str) -> Vec<String> {
let trimmed = expected_issuer.trim_end_matches('/');
let (origin, path) = match Url::parse(trimmed) {
Ok(url) => {
let mut origin = format!(
"{}://{}",
url.scheme(),
format_host(url.host_str().unwrap_or_default())
);
if let Some(port) = url.port() {
origin.push(':');
origin.push_str(&port.to_string());
}
(origin, url.path().trim_end_matches('/').to_string())
}
Err(_) => (trimmed.to_string(), String::new()),
};
let mut candidates = Vec::new();
candidates.push(format!(
"{origin}/.well-known/oauth-authorization-server{path}"
));
if !path.is_empty() {
candidates.push(format!("{origin}/.well-known/oauth-authorization-server"));
candidates.push(format!("{trimmed}/.well-known/oauth-authorization-server"));
}
candidates.push(format!("{origin}/.well-known/openid-configuration"));
if !path.is_empty() {
candidates.push(format!("{trimmed}/.well-known/openid-configuration"));
}
candidates
}
fn issuer_matches(expected: &str, received: &str) -> bool {
let expected = expected.trim_end_matches('/');
let received = received.trim_end_matches('/');
if expected == received {
return true;
}
let (Ok(exp), Ok(got)) = (Url::parse(expected), Url::parse(received)) else {
return false;
};
if exp.scheme() != got.scheme()
|| exp.host_str() != got.host_str()
|| exp.port_or_known_default() != got.port_or_known_default()
{
return false;
}
let epath = exp.path().trim_end_matches('/');
let rpath = got.path().trim_end_matches('/');
if epath.is_empty() || rpath.is_empty() {
return true;
}
epath == rpath
|| (!epath.is_empty() && rpath.starts_with(epath) && rpath[epath.len()..].starts_with('/'))
|| (!rpath.is_empty() && epath.starts_with(rpath) && epath[rpath.len()..].starts_with('/'))
}
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 rejection_code_prefers_structured_error() {
let structured = json!({"error": "invalid_grant"});
assert_eq!(refresh_rejection_code(Some(&structured)), "invalid_grant");
let blank = json!({"error": " "});
assert_eq!(refresh_rejection_code(Some(&blank)), "oauth_refresh_failed");
let missing = json!({"ok": true});
assert_eq!(
refresh_rejection_code(Some(&missing)),
"oauth_refresh_failed"
);
let typed = json!({"ok": false, "error": {"code": "identity_org_ambiguous"}});
assert_eq!(
refresh_rejection_code(Some(&typed)),
"identity_org_ambiguous"
);
assert_eq!(refresh_rejection_code(None), "oauth_refresh_failed");
}
#[test]
fn snippet_is_truncated_single_line_and_never_empty() {
assert_eq!(refresh_snippet(""), "(empty response body)");
assert_eq!(refresh_snippet(" \n "), "(empty response body)");
assert_eq!(
refresh_snippet("<html>\n Bad Gateway"),
"<html> Bad Gateway"
);
let long = "x".repeat(500);
let snippet = refresh_snippet(&long);
assert!(snippet.ends_with('…'));
assert!(snippet.chars().count() <= 121);
}
#[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));
}
fn spawn_refresh_stub(
status: u16,
body: String,
content_type: &'static str,
) -> (String, std::thread::JoinHandle<()>) {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::time::Duration;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind refresh stub");
let addr = listener.local_addr().expect("refresh stub addr");
let join = std::thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
let mut seen = Vec::new();
let mut tmp = [0u8; 512];
loop {
match stream.read(&mut tmp) {
Ok(0) => break,
Ok(n) => {
seen.extend_from_slice(&tmp[..n]);
if seen.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
Err(_) => break,
}
}
let reason = match status {
200 => "OK",
400 => "Bad Request",
404 => "Not Found",
502 => "Bad Gateway",
_ => "Error",
};
let resp = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
});
(format!("http://127.0.0.1:{}", addr.port()), join)
}
fn spawn_identity_wire_stub(
status: u16,
body: String,
) -> (
String,
std::sync::mpsc::Receiver<(String, String)>,
std::thread::JoinHandle<()>,
) {
use std::io::{Read, Write};
use std::net::TcpListener;
use std::time::Duration;
let listener = TcpListener::bind("127.0.0.1:0").expect("bind identity stub");
let addr = listener.local_addr().expect("identity stub addr");
let (tx, rx) = std::sync::mpsc::channel();
let join = std::thread::spawn(move || {
let Ok((mut stream, _)) = listener.accept() else {
return;
};
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
let mut seen = Vec::new();
let mut tmp = [0u8; 512];
loop {
match stream.read(&mut tmp) {
Ok(0) => break,
Ok(n) => {
seen.extend_from_slice(&tmp[..n]);
if seen.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
Err(_) => break,
}
}
let text = String::from_utf8_lossy(&seen);
let request_line = text.lines().next().unwrap_or("");
let mut parts = request_line.split_whitespace();
let method = parts.next().unwrap_or("").to_string();
let path = parts.next().unwrap_or("").to_string();
let _ = tx.send((method, path));
let reason = if status == 200 { "OK" } else { "Bad Request" };
let resp = format!(
"HTTP/1.1 {status} {reason}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes());
});
(format!("http://127.0.0.1:{}", addr.port()), rx, join)
}
fn stub_refresh_session(base: &str) -> OAuthSession {
OAuthSession {
issuer: base.to_string(),
token_endpoint: format!("{base}/v1/oauth/token"),
revocation_endpoint: format!("{base}/v1/oauth/revoke"),
client_id: "test-client".to_string(),
refresh_token: "test-refresh-token".to_string(),
access_expires_at: None,
refresh_expires_at: None,
scope: None,
management_base_url: None,
}
}
fn stub_management_session(base: &str) -> OAuthSession {
OAuthSession {
issuer: "https://api.identity.sylphx.com".to_string(),
token_endpoint: "https://api.identity.sylphx.com/v1/oauth/token".to_string(),
revocation_endpoint: "https://api.identity.sylphx.com/v1/oauth/revoke".to_string(),
client_id: "sylphx-cli".to_string(),
refresh_token: "mgmt-refresh-token".to_string(),
access_expires_at: Some(1),
refresh_expires_at: None,
scope: None,
management_base_url: Some(base.to_string()),
}
}
#[test]
fn management_v1_root_accepts_both_base_forms() {
assert_eq!(
management_v1_root("https://api.sylphx.com/v1"),
"https://api.sylphx.com/v1"
);
assert_eq!(
management_v1_root("https://api.sylphx.com/"),
"https://api.sylphx.com/v1"
);
assert_eq!(
management_v1_root("https://api.sylphx.com"),
"https://api.sylphx.com/v1"
);
}
#[test]
fn identity_base_prefers_explicit_then_env_then_default() {
assert_eq!(
identity_base_with_config(Some("https://id.example/"), |_| None, None),
"https://id.example"
);
assert_eq!(
identity_base_with_config(
None,
|k| if k == "SYLPHX_IDENTITY_URL" {
Some("https://env.example/".into())
} else {
None
},
None
),
"https://env.example"
);
assert_eq!(
identity_base_with_config(None, |_| None, None),
IDENTITY_DEFAULT_BASE
);
}
#[test]
fn identity_base_prefers_registered_issuer_over_the_compiled_default() {
let registered = "https://api.identity.example/v1";
assert_eq!(
identity_base_with_config(None, |_| None, Some(registered)),
"https://api.identity.example"
);
assert_eq!(
identity_base_with_config(Some("https://flag.example/"), |_| None, Some(registered)),
"https://flag.example"
);
assert_eq!(
identity_base_with_config(
None,
|k| (k == "SYLPHX_IDENTITY_URL").then(|| "https://env.example".to_string()),
Some(registered)
),
"https://env.example"
);
assert_eq!(
identity_base_with_config(None, |_| None, Some("not-a-url")),
IDENTITY_DEFAULT_BASE
);
assert_eq!(
identity_base_with_config(None, |_| None, Some(" ")),
IDENTITY_DEFAULT_BASE
);
assert_eq!(
identity_base_with_config(None, |_| None, None),
IDENTITY_DEFAULT_BASE
);
}
#[test]
fn cli_client_id_prefers_explicit_then_registered_then_default() {
let registered = CliLoginConfig {
issuer: String::new(),
client_id: "identity_oauth_client_registered".to_string(),
};
assert_eq!(
resolve_cli_client_id(Some("identity_oauth_client_flag"), Some(®istered)),
"identity_oauth_client_flag"
);
assert_eq!(
resolve_cli_client_id(Some(" "), Some(®istered)),
"identity_oauth_client_registered"
);
assert_eq!(
resolve_cli_client_id(None, Some(®istered)),
"identity_oauth_client_registered"
);
assert_eq!(resolve_cli_client_id(None, None), DEFAULT_CLI_CLIENT_ID);
let blank = CliLoginConfig::default();
assert_eq!(
resolve_cli_client_id(None, Some(&blank)),
DEFAULT_CLI_CLIENT_ID
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cli_login_config_door_serves_the_registered_client() {
let (base, join) = spawn_refresh_stub(
200,
r#"{"issuer":"https://api.identity.example","client_id":"identity_oauth_client_registered"}"#
.to_string(),
"application/json",
);
let http = http_client().expect("config stub client");
let config = fetch_cli_login_config(&http, &base)
.await
.expect("config door must parse");
assert_eq!(config.issuer, "https://api.identity.example");
assert_eq!(config.client_id, "identity_oauth_client_registered");
join.join().expect("config stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn failing_cli_login_config_door_falls_back_to_the_compiled_default() {
let (base, join) = spawn_refresh_stub(
503,
r#"{"ok":false,"error":{"code":"login_not_configured"}}"#.to_string(),
"application/json",
);
let http = http_client().expect("config stub client");
let config = fetch_cli_login_config(&http, &base).await;
assert!(config.is_none(), "503 door must not serve enrollment data");
assert_eq!(
resolve_cli_client_id(None, config.as_ref()),
DEFAULT_CLI_CLIENT_ID
);
assert_eq!(
identity_base_with_config(None, |_| None, config.as_ref().map(|c| c.issuer.as_str())),
IDENTITY_DEFAULT_BASE
);
join.join().expect("config stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn identity_device_start_parses_grant_shape() {
let (base, rx, join) = spawn_identity_wire_stub(
200,
r#"{"device_code":"dc","user_code":"UC-1","verification_uri":"https://id.example/d","verification_uri_complete":"https://id.example/d?c=UC-1","interval":5,"expires_in":300}"#.to_string(),
);
let http = http_client().expect("identity stub client");
let started = identity_device_start(&http, &base, "sylphx-cli")
.await
.expect("device start must parse");
assert_eq!(started.device_code, "dc");
assert_eq!(started.user_code, "UC-1");
assert!(started.verification_uri_complete.contains("UC-1"));
let (method, path) = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("wire");
let spec = sylphx_identity_sdk::operation("identity.oauth.device.authorize").expect("spec");
assert_eq!(method, spec.method);
assert_eq!(path.split('?').next().unwrap_or(&path), spec.path);
join.join().expect("identity stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn identity_device_poll_maps_pending_slow_down_denied_done() {
let http = http_client().expect("identity stub client");
for (status, body, expect) in [
(400u16, r#"{"error":"authorization_pending"}"#, "pending"),
(400u16, r#"{"error":"slow_down"}"#, "slowdown"),
(200u16, r#"{"error":"access_denied"}"#, "denied"),
(
200u16,
r#"{"token_type":"Bearer","access_token":"id-at","expires_in":3600,"scope":"openid"}"#,
"done",
),
] {
let (base, rx, join) = spawn_identity_wire_stub(status, body.to_string());
let outcome = identity_device_poll(&http, &base, "sylphx-cli", "dc").await;
let (method, path) = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("wire");
let spec = sylphx_identity_sdk::operation("identity.oauth.token").expect("spec");
assert_eq!(method, spec.method, "token method for {body}");
assert_eq!(
path.split('?').next().unwrap_or(&path),
spec.path,
"token path for {body}"
);
match expect {
"pending" => assert!(
matches!(outcome.unwrap(), IdentityDevicePoll::Pending),
"pending for {body}"
),
"slowdown" => assert!(
matches!(outcome.unwrap(), IdentityDevicePoll::SlowDown),
"slowdown for {body}"
),
"denied" => assert!(outcome.is_err(), "denied for {body}"),
"done" => {
let done = outcome.unwrap();
assert!(
matches!(done, IdentityDevicePoll::Done { ref access_token } if access_token == "id-at"),
"done for {body}"
);
}
_ => unreachable!(),
}
join.join().expect("identity stub thread");
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn management_exchange_accepts_native_snake_shape() {
let (base, join) = spawn_refresh_stub(
200,
r#"{"access_token":"mgmt-at","refresh_token":"mgmt-rt","expires_in":3600,"refresh_expires_at":"2030-01-01T00:00:00Z"}"#.to_string(),
"application/json",
);
let http = http_client().expect("exchange stub client");
let set = management_exchange(&http, &base, "id-at", None)
.await
.expect("exchange must parse");
assert_eq!(set.access_token, "mgmt-at");
assert_eq!(set.refresh_token, "mgmt-rt");
assert_eq!(set.expires_in, 3600);
join.join().expect("exchange stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn management_exchange_surfaces_ambiguous_org() {
let (base, join) = spawn_refresh_stub(
409,
r#"{"ok":false,"error":{"code":"identity_org_ambiguous"},"orgs":[{"slug":"a"}]}"#
.to_string(),
"application/json",
);
let http = http_client().expect("exchange stub client");
let err = management_exchange(&http, &base, "id-at", None)
.await
.expect_err("ambiguous org must reject");
let msg = format!("{err:#}");
assert!(msg.contains("organization"), "org hint lost: {msg}");
join.join().expect("exchange stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn management_refresh_rotates_and_rejects() {
let http = http_client().expect("mgmt refresh stub client");
let (base, join) = spawn_refresh_stub(
200,
r#"{"access_token":"mgmt-at-2","refresh_token":"mgmt-rt-2","expires_in":3600}"#
.to_string(),
"application/json",
);
let set = refresh_management(&http, &stub_management_session(&base))
.await
.expect("mgmt refresh must parse");
assert_eq!(set.access_token, "mgmt-at-2");
assert_eq!(set.refresh_token, "mgmt-rt-2");
join.join().expect("mgmt refresh stub thread");
let (base2, join2) = spawn_refresh_stub(
401,
r#"{"error":"invalid_grant"}"#.to_string(),
"application/json",
);
let err = refresh_management(&http, &stub_management_session(&base2))
.await
.expect_err("invalid_grant must reject");
let msg = format!("{err:#}");
assert!(msg.contains("invalid_grant"), "server code lost: {msg}");
assert!(
msg.contains("sylphx login --identity"),
"re-login path lost: {msg}"
);
join2.join().expect("mgmt refresh stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn refresh_rejects_empty_404_without_decode_mask() {
let (base, join) = spawn_refresh_stub(404, String::new(), "text/plain");
let http = http_client().expect("refresh stub client");
let err = refresh(&http, &stub_refresh_session(&base))
.await
.expect_err("empty 404 must reject");
let msg = format!("{err:#}");
assert!(
msg.contains("OAuth refresh rejected (oauth_refresh_failed, HTTP 404"),
"unexpected message: {msg}"
);
assert!(
!msg.contains("decode"),
"non-JSON rejection must not surface as a decode error: {msg}"
);
assert!(
msg.contains("sylphx login"),
"must carry re-login path: {msg}"
);
join.join().expect("refresh stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn refresh_rejects_html_error_with_truncated_snippet() {
let html = format!(
"<html><head><title>502 Bad Gateway</title></head><body>{}</body></html>",
"upstream unavailable ".repeat(20)
);
assert!(
html.chars().count() > 120,
"fixture must exceed snippet limit"
);
let (base, join) = spawn_refresh_stub(502, html, "text/html");
let http = http_client().expect("refresh stub client");
let err = refresh(&http, &stub_refresh_session(&base))
.await
.expect_err("HTML 502 must reject");
let msg = format!("{err:#}");
assert!(
msg.contains("OAuth refresh rejected (oauth_refresh_failed, HTTP 502"),
"unexpected message: {msg}"
);
assert!(msg.contains("<html>"), "snippet must be present: {msg}");
assert!(msg.contains('…'), "long snippet must be truncated: {msg}");
assert!(
!msg.contains("decode"),
"HTML rejection must not surface as a decode error: {msg}"
);
join.join().expect("refresh stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn refresh_preserves_structured_invalid_grant_code() {
let (base, join) = spawn_refresh_stub(
400,
r#"{"error":"invalid_grant","error_description":"refresh token expired"}"#.to_string(),
"application/json",
);
let http = http_client().expect("refresh stub client");
let err = refresh(&http, &stub_refresh_session(&base))
.await
.expect_err("invalid_grant must reject");
let msg = format!("{err:#}");
assert!(msg.contains("invalid_grant"), "server code lost: {msg}");
assert!(msg.contains("HTTP 400"), "status lost: {msg}");
assert!(
msg.contains("sylphx login"),
"must carry re-login path: {msg}"
);
join.join().expect("refresh stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn refresh_rejects_non_json_success_payload() {
let (base, join) = spawn_refresh_stub(200, "OK - not json".to_string(), "text/plain");
let http = http_client().expect("refresh stub client");
let err = refresh(&http, &stub_refresh_session(&base))
.await
.expect_err("non-JSON 200 must reject");
let msg = format!("{err:#}");
assert!(
msg.contains("non-JSON success"),
"unexpected message: {msg}"
);
assert!(
!msg.contains("decode"),
"non-JSON success must not surface as a decode error: {msg}"
);
join.join().expect("refresh stub thread");
}
}
pub const IDENTITY_DEFAULT_BASE: &str = "https://api.identity.sylphx.com";
pub const DEFAULT_CLI_CLIENT_ID: &str = "sylphx-cli";
pub const IDENTITY_DEVICE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
pub enum IdentityDevicePoll {
Pending,
SlowDown,
Done { access_token: String },
}
pub fn identity_base_with_config(
explicit: Option<&str>,
env: impl FnOnce(&str) -> Option<String>,
config_issuer: Option<&str>,
) -> String {
if let Some(u) = explicit.map(str::trim).filter(|u| !u.is_empty()) {
return u.trim_end_matches('/').to_string();
}
if let Some(u) = env("SYLPHX_IDENTITY_URL") {
let u = u.trim().trim_end_matches('/');
if !u.is_empty() {
return u.to_string();
}
}
if let Some(issuer) = config_issuer.map(str::trim).filter(|u| !u.is_empty()) {
if let Ok(normalized) = sylphx_identity_sdk::normalize_api_base(issuer) {
return normalized;
}
}
IDENTITY_DEFAULT_BASE.to_string()
}
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
pub struct CliLoginConfig {
#[serde(default)]
pub issuer: String,
#[serde(default)]
pub client_id: String,
}
const CLI_LOGIN_CONFIG_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
pub async fn fetch_cli_login_config(
http: &Client,
management_base: &str,
) -> Option<CliLoginConfig> {
let url = format!("{}/auth/cli/config", management_v1_root(management_base));
let response = http
.get(&url)
.timeout(CLI_LOGIN_CONFIG_TIMEOUT)
.send()
.await
.ok()?;
if !response.status().is_success() {
return None;
}
let raw = response.text().await.ok()?;
serde_json::from_str::<CliLoginConfig>(&raw).ok()
}
pub fn resolve_cli_client_id(explicit: Option<&str>, config: Option<&CliLoginConfig>) -> String {
explicit
.map(str::trim)
.filter(|id| !id.is_empty())
.or_else(|| config.map(|c| c.client_id.trim()).filter(|id| !id.is_empty()))
.unwrap_or(DEFAULT_CLI_CLIENT_ID)
.to_string()
}
pub struct IdentityDeviceStart {
pub device_code: String,
pub user_code: String,
pub verification_uri_complete: String,
pub interval_secs: u64,
pub expires_in_secs: u64,
}
fn identity_sdk_prepare(
identity_base: &str,
operation_id: &str,
request: &impl serde::Serialize,
) -> Result<sylphx_identity_sdk::PreparedRequest> {
let request = serde_json::to_value(request)
.map_err(|err| anyhow!("identity sdk encode {operation_id}: {err}"))?;
sylphx_identity_sdk::prepare_operation(identity_base, operation_id, &request, None)
.map_err(|err| anyhow!("identity sdk prepare {operation_id}: {err}"))
}
async fn identity_sdk_send(
http: &Client,
prepared: &sylphx_identity_sdk::PreparedRequest,
) -> Result<(reqwest::StatusCode, Value, String)> {
let mut req = match prepared.method.as_str() {
"GET" => http.get(&prepared.url),
"POST" => http.post(&prepared.url),
other => bail!("identity sdk unsupported HTTP method {other}"),
};
for (name, value) in &prepared.headers {
req = req.header(name.as_str(), value.as_str());
}
if let Some(body) = &prepared.body {
req = req.json(body);
}
let response = req
.send()
.await
.with_context(|| format!("identity {} {}", prepared.method, prepared.url))?;
let status = response.status();
let raw = response
.text()
.await
.with_context(|| format!("read identity {} response", prepared.operation_id))?;
let body: Value = serde_json::from_str(&raw).with_context(|| {
format!(
"decode identity {} body from {}: {}",
prepared.operation_id,
prepared.url,
refresh_snippet(&raw)
)
})?;
Ok((status, body, raw))
}
pub async fn identity_device_start(
http: &Client,
identity_base: &str,
client_id: &str,
) -> Result<IdentityDeviceStart> {
let prepared = identity_sdk_prepare(
identity_base,
"identity.oauth.device.authorize",
&sylphx_identity_sdk::StartOauthDeviceAuthorizationRequest {
client_id: client_id.to_string(),
scope: "openid profile email offline_access".into(),
},
)?;
let (status, body, _raw) = identity_sdk_send(http, &prepared).await?;
if !status.is_success() {
let code = refresh_rejection_code(Some(&body));
if code == "invalid_client" || code == "unauthorized_client" {
bail!("identity device start rejected ({code}, HTTP {status}): unknown OAuth client ‘{client_id}’. Register a public CLI client in your Identity organization (token_endpoint_auth_method=none, grant_types includes urn:ietf:params:oauth:grant-type:device_code, scopes openid profile email offline_access) and pass --client-id identity_oauth_client_… (Identity dashboard → OAuth clients). The ‘sylphx-cli’ default is a placeholder, not a real client id.");
}
bail!("identity device start rejected ({code}, HTTP {status})");
}
let started: sylphx_identity_sdk::StartOauthDeviceAuthorizationResponse =
serde_json::from_value(body).context(
"identity device start is not the generated StartOauthDeviceAuthorizationResponse",
)?;
if started.device_code.trim().is_empty() {
bail!("identity device start is missing device_code");
}
Ok(IdentityDeviceStart {
device_code: started.device_code,
user_code: {
let code = started.user_code.trim();
if code.is_empty() {
"?".into()
} else {
started.user_code
}
},
verification_uri_complete: if started.verification_uri_complete.trim().is_empty() {
started.verification_uri
} else {
started.verification_uri_complete
},
interval_secs: started.interval.max(1) as u64,
expires_in_secs: if started.expires_in > 0 {
started.expires_in as u64
} else {
300
},
})
}
pub async fn identity_device_poll(
http: &Client,
identity_base: &str,
client_id: &str,
device_code: &str,
) -> Result<IdentityDevicePoll> {
let prepared = identity_sdk_prepare(
identity_base,
"identity.oauth.token",
&sylphx_identity_sdk::ExchangeOauthTokenRequest {
grant_type: IDENTITY_DEVICE_GRANT.to_string(),
code: String::new(),
redirect_uri: String::new(),
client_id: client_id.to_string(),
client_secret: String::new(),
code_verifier: String::new(),
refresh_token: String::new(),
device_code: device_code.to_string(),
scope: String::new(),
},
)?;
let (status, body, _raw) = identity_sdk_send(http, &prepared).await?;
if status.is_success() {
if let Ok(tokens) =
serde_json::from_value::<sylphx_identity_sdk::ExchangeOauthTokenResponse>(body.clone())
{
if !tokens.access_token.trim().is_empty() {
return Ok(IdentityDevicePoll::Done {
access_token: tokens.access_token,
});
}
}
match refresh_rejection_code(Some(&body)) {
"authorization_pending" => return Ok(IdentityDevicePoll::Pending),
"slow_down" => return Ok(IdentityDevicePoll::SlowDown),
_ => {}
}
bail!("identity device poll approved but did not return access_token");
}
match refresh_rejection_code(Some(&body)) {
"authorization_pending" => Ok(IdentityDevicePoll::Pending),
"slow_down" => Ok(IdentityDevicePoll::SlowDown),
"access_denied" => bail!("device login denied by user"),
"expired_token" => bail!("device login expired; run `sylphx login --identity` again"),
code => bail!("identity device poll rejected ({code}, HTTP {status})"),
}
}
pub fn management_v1_root(management_base: &str) -> String {
let mut base = management_base.trim().trim_end_matches('/').to_string();
if let Some(stripped) = base.strip_suffix("/v1") {
base = stripped.to_string();
}
format!("{base}/v1")
}
pub async fn management_exchange(
http: &Client,
management_base: &str,
identity_access_token: &str,
organization_id: Option<&str>,
) -> Result<OAuthTokenSet> {
let url = format!(
"{}/auth/identity-exchange",
management_v1_root(management_base)
);
let mut payload = json!({ "identity_access_token": identity_access_token });
if let Some(org) = organization_id.map(str::trim).filter(|s| !s.is_empty()) {
payload["organization_id"] = json!(org);
}
let response = http
.post(&url)
.json(&payload)
.send()
.await
.with_context(|| format!("identity exchange POST {url}"))?;
let status = response.status();
let raw = response
.text()
.await
.context("read identity exchange response")?;
let parsed: Option<Value> = serde_json::from_str(&raw).ok();
if !status.is_success() {
let code = refresh_rejection_code(parsed.as_ref());
if code == "identity_org_ambiguous" {
let orgs = parsed
.as_ref()
.and_then(|b| b.get("orgs"))
.map(|o| o.to_string())
.unwrap_or_else(|| "[]".into());
bail!("identity exchange needs an organization (HTTP {status}): pass --org {orgs}");
}
bail!("identity exchange rejected ({code}, HTTP {status}); run `sylphx login --identity` again");
}
let Some(body) = parsed else {
bail!(
"identity exchange returned a non-JSON success payload (oauth_refresh_failed, HTTP {status})"
);
};
token_set_from_value(&body).map_err(|err| anyhow!("{err:#}"))
}
pub async fn refresh_management(http: &Client, session: &OAuthSession) -> Result<OAuthTokenSet> {
let base = session
.management_base_url
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| {
anyhow!("stored session has no Management base; run `sylphx login --identity` again")
})?;
if session.refresh_token.trim().is_empty() {
bail!("stored Management refresh token is empty; run `sylphx login --identity` again");
}
let url = format!("{}/auth/refresh", management_v1_root(base));
let response = http
.post(&url)
.json(&json!({ "refresh_token": session.refresh_token }))
.send()
.await
.with_context(|| format!("Management refresh POST {url}"))?;
let status = response.status();
let raw = response
.text()
.await
.context("read Management refresh response")?;
let parsed: Option<Value> = serde_json::from_str(&raw).ok();
if !status.is_success() {
let code = refresh_rejection_code(parsed.as_ref());
bail!("Management refresh rejected ({code}, HTTP {status}); run `sylphx login --identity` again");
}
let Some(body) = parsed else {
bail!(
"Management refresh returned a non-JSON success payload (oauth_refresh_failed, HTTP {status})"
);
};
token_set_from_value(&body)
.map_err(|err| anyhow!("{err:#}; run `sylphx login --identity` again"))
}
pub async fn revoke_management(http: &Client, session: &OAuthSession) -> Result<()> {
let base = session
.management_base_url
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow!("stored session has no Management base"))?;
let url = format!("{}/auth/sign-out", management_v1_root(base));
let response = http
.post(&url)
.json(&json!({ "refresh_token": session.refresh_token }))
.send()
.await
.with_context(|| format!("Management sign-out POST {url}"))?;
if !response.status().is_success() {
bail!(
"Management sign-out rejected HTTP {}; stored credentials were retained",
response.status()
);
}
Ok(())
}
#[cfg(test)]
mod discovery_tests {
use super::*;
use serde_json::json;
use std::net::TcpListener;
fn bind_discovery_stub() -> (String, TcpListener) {
let listener = TcpListener::bind("127.0.0.1:0").expect("bind discovery stub");
let addr = listener.local_addr().expect("discovery stub addr");
(format!("http://127.0.0.1:{}", addr.port()), listener)
}
fn serve_discovery_stub(
listener: TcpListener,
routes: Vec<(String, u16, String, &'static str)>,
max_requests: usize,
) -> std::thread::JoinHandle<()> {
use std::io::{Read, Write};
use std::time::{Duration, Instant};
listener
.set_nonblocking(true)
.expect("discovery stub nonblocking");
std::thread::spawn(move || {
let deadline = Instant::now() + Duration::from_secs(20);
let mut served = 0;
while served < max_requests && Instant::now() < deadline {
let (mut stream, _) = match listener.accept() {
Ok(pair) => pair,
Err(_) => {
std::thread::sleep(Duration::from_millis(5));
continue;
}
};
let _ = stream.set_read_timeout(Some(Duration::from_secs(2)));
let _ = stream.set_write_timeout(Some(Duration::from_secs(2)));
let mut seen = Vec::new();
let mut tmp = [0u8; 1024];
loop {
match stream.read(&mut tmp) {
Ok(0) => break,
Ok(n) => {
seen.extend_from_slice(&tmp[..n]);
if seen.windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
Err(_) => break,
}
}
let head = String::from_utf8_lossy(&seen);
let path = head
.lines()
.next()
.unwrap_or("")
.split_whitespace()
.nth(1)
.unwrap_or("");
let mut reply = (404u16, String::new(), "text/plain");
for (needle, status, body, content_type) in &routes {
if path.contains(needle.as_str()) {
reply = (*status, body.clone(), *content_type);
break;
}
}
let reason = match reply.0 {
200 => "OK",
404 => "Not Found",
_ => "Error",
};
let resp = format!(
"HTTP/1.1 {} {reason}\r\nContent-Type: {}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
reply.0,
reply.2,
reply.1.len(),
reply.1
);
let _ = stream.write_all(resp.as_bytes());
served += 1;
}
})
}
fn identity_shaped_doc(base: &str) -> serde_json::Value {
json!({
"issuer": format!("{base}/v1"),
"authorization_endpoint": format!("{base}/v1/oauth/authorize"),
"token_endpoint": format!("{base}/v1/oauth/token"),
"revocation_endpoint": format!("{base}/v1/oauth/revoke"),
"device_authorization_endpoint": format!("{base}/v1/oauth/device_authorization"),
"userinfo_endpoint": format!("{base}/v1/oauth/userinfo"),
"grant_types_supported": [
"authorization_code",
"refresh_token",
"urn:ietf:params:oauth:grant-type:device_code"
],
"token_endpoint_auth_methods_supported": [
"client_secret_basic",
"client_secret_post",
"none"
],
})
}
fn openid_route(doc: &serde_json::Value) -> Vec<(String, u16, String, &'static str)> {
vec![(
".well-known/openid-configuration".to_string(),
200,
serde_json::to_string(doc).expect("doc json"),
"application/json",
)]
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn discovers_identity_shaped_doc_via_fallback_with_suffixed_issuer() {
let (base, listener) = bind_discovery_stub();
let doc = identity_shaped_doc(&base);
let join = serve_discovery_stub(listener, openid_route(&doc), 4);
let http = http_client().expect("discovery stub client");
let metadata = discover(&http, &format!("{base}/v1"))
.await
.expect("identity-shaped doc must bind");
assert_eq!(metadata.issuer, format!("{base}/v1"));
assert_eq!(
metadata.device_authorization_endpoint,
format!("{base}/v1/oauth/device_authorization")
);
assert_eq!(
metadata.authorization_endpoint.as_deref(),
Some(format!("{base}/v1/oauth/authorize").as_str())
);
join.join().expect("stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn discovers_root_doc_on_primary_path_without_fallback() {
let (base, listener) = bind_discovery_stub();
let doc = json!({
"issuer": base,
"token_endpoint": format!("{base}/v1/oauth/token"),
"revocation_endpoint": format!("{base}/v1/oauth/revoke"),
"device_authorization_endpoint": format!("{base}/v1/auth/device"),
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none"],
});
let join = serve_discovery_stub(
listener,
vec![(
".well-known/oauth-authorization-server".to_string(),
200,
serde_json::to_string(&doc).expect("doc json"),
"application/json",
)],
1,
);
let http = http_client().expect("discovery stub client");
let metadata = discover(&http, &base)
.await
.expect("root doc must bind on the primary path");
assert_eq!(metadata.issuer, base);
assert!(metadata.authorization_endpoint.is_none());
join.join().expect("stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn rejects_cross_origin_doc_despite_suffix_tolerance() {
let (base, listener) = bind_discovery_stub();
let doc = json!({
"issuer": "http://127.0.0.1:1/v1",
"authorization_endpoint": format!("{base}/v1/oauth/authorize"),
"token_endpoint": format!("{base}/v1/oauth/token"),
"revocation_endpoint": format!("{base}/v1/oauth/revoke"),
"device_authorization_endpoint": format!("{base}/v1/oauth/device_authorization"),
"grant_types_supported": ["authorization_code", "refresh_token"],
"token_endpoint_auth_methods_supported": ["none"],
});
let join = serve_discovery_stub(listener, openid_route(&doc), 4);
let http = http_client().expect("discovery stub client");
let err = discover(&http, &format!("{base}/v1"))
.await
.expect_err("cross-origin issuer must not bind");
assert!(
format!("{err:#}").contains("mismatch"),
"issuer gate lost: {err:#}"
);
join.join().expect("stub thread");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn still_refuses_server_without_public_client() {
let (base, listener) = bind_discovery_stub();
let mut doc = identity_shaped_doc(&base);
doc["token_endpoint_auth_methods_supported"] = json!(["client_secret_post"]);
let join = serve_discovery_stub(listener, openid_route(&doc), 4);
let http = http_client().expect("discovery stub client");
let err = discover(&http, &format!("{base}/v1"))
.await
.expect_err("secret-only server must not admit the public client");
assert!(
format!("{err:#}").contains("does not admit a public-client"),
"public-client gate lost: {err:#}"
);
join.join().expect("stub thread");
}
#[test]
fn issuer_suffix_tolerance_never_crosses_origin() {
assert!(issuer_matches(
"https://api.identity.sylphx.com/v1",
"https://api.identity.sylphx.com/v1"
));
assert!(issuer_matches(
"https://api.identity.sylphx.com",
"https://api.identity.sylphx.com/v1"
));
assert!(issuer_matches(
"https://api.identity.sylphx.com/v1",
"https://api.identity.sylphx.com"
));
assert!(issuer_matches(
"https://api.identity.sylphx.com/v1/",
"https://api.identity.sylphx.com/v1"
));
assert!(!issuer_matches(
"https://api.identity.sylphx.com/v1",
"https://api.identity.sylphx.com/v10"
));
assert!(!issuer_matches(
"https://api.identity.sylphx.com/v1",
"https://evil.example/v1"
));
assert!(!issuer_matches(
"https://api.identity.sylphx.com/v1",
"http://api.identity.sylphx.com/v1"
));
assert!(!issuer_matches(
"http://127.0.0.1:8080/v1",
"http://127.0.0.1:9090/v1"
));
}
#[test]
fn metadata_candidates_lead_with_rfc8414() {
let root = metadata_candidates("https://api.identity.sylphx.com");
assert_eq!(
root,
vec![
"https://api.identity.sylphx.com/.well-known/oauth-authorization-server"
.to_string(),
"https://api.identity.sylphx.com/.well-known/openid-configuration".to_string(),
]
);
let suffixed = metadata_candidates("https://api.identity.sylphx.com/v1");
assert_eq!(
suffixed,
vec![
"https://api.identity.sylphx.com/.well-known/oauth-authorization-server/v1"
.to_string(),
"https://api.identity.sylphx.com/.well-known/oauth-authorization-server"
.to_string(),
"https://api.identity.sylphx.com/v1/.well-known/oauth-authorization-server"
.to_string(),
"https://api.identity.sylphx.com/.well-known/openid-configuration".to_string(),
"https://api.identity.sylphx.com/v1/.well-known/openid-configuration".to_string(),
]
);
}
}