use axum::extract::State;
use axum::http::{HeaderMap, HeaderValue, StatusCode, Uri};
use axum::response::{IntoResponse, Response};
use axum::routing::{get, post};
use axum::{Json, Router};
use serde_json::{json, Map, Value};
use nomoreide_core::agent_profiles::auth::{self, Outcome};
use nomoreide_core::agent_profiles::registry_config as config;
use crate::server::app::AppState;
use crate::server::routes::query::query_value;
pub(crate) fn public() -> Router<AppState> {
Router::new().route("/api/agent-env/auth/finish", get(finish))
}
pub(crate) fn authenticated() -> Router<AppState> {
Router::new()
.route("/api/agent-env/auth/status", get(status))
.route("/api/agent-env/auth/start", post(start))
.route("/api/agent-env/auth/outcome", get(outcome))
.route("/api/agent-env/auth/logout", post(logout))
}
fn api_info() -> Vec<(String, Value)> {
let target = config::api_target();
let frontend = config::frontend_url(Some(&target.api_base_url));
vec![
("apiBaseUrl".to_string(), json!(target.api_base_url)),
("apiMode".to_string(), json!(target.mode)),
("apiSource".to_string(), json!(target.source)),
("apiFrontendUrl".to_string(), json!(frontend)),
]
}
fn envelope(fields: Vec<(String, Value)>) -> Response {
let mut body = Map::new();
for (key, value) in fields {
body.insert(key, value);
}
Json(Value::Object(body)).into_response()
}
async fn status() -> Response {
let mut fields: Vec<(String, Value)> = vec![("ok".to_string(), json!(true))];
let Some(source) = auth::token_source() else {
fields.push(("signedIn".to_string(), json!(false)));
fields.extend(api_info());
return envelope(fields);
};
let source = json!(source.as_str());
match auth::authenticated_get("/me").await {
Ok(response) if response.ok() => {
let user: Value = serde_json::from_str(&response.body).unwrap_or(Value::Null);
fields.push(("signedIn".to_string(), json!(true)));
fields.push(("source".to_string(), source));
fields.push(("user".to_string(), described(&user)));
}
Ok(response) => {
fields.push(("signedIn".to_string(), json!(false)));
fields.push(("source".to_string(), source));
fields.push((
"error".to_string(),
json!(format!("HTTP {}", response.status)),
));
}
Err(reason) => {
fields.push(("signedIn".to_string(), json!(false)));
fields.push(("source".to_string(), source));
fields.push(("error".to_string(), json!(reason)));
}
}
fields.extend(api_info());
envelope(fields)
}
fn described(user: &Value) -> Value {
let mut described = Map::new();
let mut carry = |from: &str, to: &str| {
if let Some(value) = user.get(from) {
if !value.is_null() {
described.insert(to.to_string(), value.clone());
}
}
};
carry("email", "email");
carry("display_name", "displayName");
carry("avatar_url", "avatarUrl");
Value::Object(described)
}
async fn start(State(state): State<AppState>, headers: HeaderMap) -> Response {
let nonce = state.registry_auth.issue();
let target = config::api_target();
let frontend = config::frontend_url(Some(&target.api_base_url));
let host = headers
.get(axum::http::header::HOST)
.and_then(|value| value.to_str().ok())
.unwrap_or("127.0.0.1:4317");
let callback = format!(
"http://{host}/api/agent-env/auth/finish?state={}",
encode(&nonce)
);
let url = format!(
"{frontend}/cli-login?state={}&callback={}",
encode(&nonce),
encode(&callback)
);
envelope(vec![
("ok".to_string(), json!(true)),
("url".to_string(), json!(url)),
("state".to_string(), json!(nonce)),
])
}
async fn finish(State(state): State<AppState>, uri: Uri) -> Response {
let nonce = query_value(&uri, "state").unwrap_or_default();
let token = query_value(&uri, "token").unwrap_or_default();
let refresh_token = query_value(&uri, "refresh_token").unwrap_or_default();
let refused = query_value(&uri, "error").unwrap_or_default();
if nonce.is_empty() || !state.registry_auth.is_live(&nonce) {
return html(
StatusCode::BAD_REQUEST,
"error",
"Invalid or expired sign-in request.",
);
}
if !refused.is_empty() {
state
.registry_auth
.mark(&nonce, Outcome::Error(refused.clone()));
return html(StatusCode::BAD_REQUEST, "error", &refused);
}
if token.trim().is_empty() {
let message = "No token returned by the registry.";
state
.registry_auth
.mark(&nonce, Outcome::Error(message.to_string()));
return html(StatusCode::BAD_REQUEST, "error", message);
}
let refresh = Some(refresh_token.trim()).filter(|value| !value.is_empty());
if let Err(reason) = auth::save_tokens(token.trim(), refresh) {
let message = format!("Failed to save token: {reason}");
state
.registry_auth
.mark(&nonce, Outcome::Error(message.clone()));
return html(StatusCode::INTERNAL_SERVER_ERROR, "error", &message);
}
match auth::authenticated_get("/me").await {
Ok(response) if response.ok() => {
state.registry_auth.mark(&nonce, Outcome::Success);
html(
StatusCode::OK,
"ok",
"You are signed in. You can close this tab.",
)
}
Ok(response) => {
let _ = auth::clear_tokens();
let base = config::api_base_url();
let message = if response.status == 401 {
format!("Token was rejected by the registry API at {base}. The site you signed in through likely targets a different backend.")
} else {
format!(
"Registry API at {base} returned HTTP {} when verifying the token.",
response.status
)
};
state
.registry_auth
.mark(&nonce, Outcome::Error(message.clone()));
html(StatusCode::UNAUTHORIZED, "error", &message)
}
Err(reason) => {
let message = format!("Failed to save token: {reason}");
state
.registry_auth
.mark(&nonce, Outcome::Error(message.clone()));
html(StatusCode::INTERNAL_SERVER_ERROR, "error", &message)
}
}
}
async fn outcome(State(state): State<AppState>, uri: Uri) -> Response {
let nonce = query_value(&uri, "state").unwrap_or_default();
let Some(outcome) = state.registry_auth.read(&nonce) else {
return (
StatusCode::NOT_FOUND,
Json(json!({ "ok": true, "status": "unknown" })),
)
.into_response();
};
let mut fields = vec![
("ok".to_string(), json!(true)),
("status".to_string(), json!(outcome.as_str())),
];
if let Outcome::Error(message) = &outcome {
fields.push(("message".to_string(), json!(message)));
}
envelope(fields)
}
async fn logout() -> Response {
match auth::clear_tokens() {
Ok(()) => Json(json!({ "ok": true })).into_response(),
Err(reason) => crate::server::errors::error(StatusCode::INTERNAL_SERVER_ERROR, &reason),
}
}
fn html(status: StatusCode, kind: &str, message: &str) -> Response {
let tone = if kind == "ok" { "#16a34a" } else { "#dc2626" };
let title = if kind == "ok" {
"Signed in"
} else {
"Sign-in failed"
};
let body = format!(
"<!doctype html><html><head><meta charset=\"utf-8\"><title>{title} — NoMoreIDE</title></head>\n\
<body style=\"font-family:system-ui,sans-serif;display:grid;place-items:center;min-height:90vh;margin:0\">\n\
<div style=\"text-align:center;max-width:28rem;padding:1rem\">\n\
<h1 style=\"color:{tone};font-size:1.25rem\">{title}</h1>\n\
<p style=\"color:#555\">{}</p>\n\
</div></body></html>",
escape(message)
);
(
status,
[(
axum::http::header::CONTENT_TYPE,
HeaderValue::from_static("text/html; charset=utf-8"),
)],
body,
)
.into_response()
}
fn escape(value: &str) -> String {
let mut escaped = String::with_capacity(value.len());
for character in value.chars() {
match character {
'&' => escaped.push_str("&"),
'<' => escaped.push_str("<"),
'>' => escaped.push_str(">"),
'"' => escaped.push_str("""),
'\'' => escaped.push_str("'"),
other => escaped.push(other),
}
}
escaped
}
fn encode(value: &str) -> String {
let mut out = String::with_capacity(value.len());
for byte in value.bytes() {
match byte {
b'A'..=b'Z'
| b'a'..=b'z'
| b'0'..=b'9'
| b'-'
| b'_'
| b'.'
| b'!'
| b'~'
| b'*'
| b'\''
| b'('
| b')' => out.push(byte as char),
other => out.push_str(&format!("%{other:02X}")),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_callback_is_encoded_the_way_the_browser_will_read_it() {
assert_eq!(
encode("http://127.0.0.1:4317/api/agent-env/auth/finish?state=abc"),
"http%3A%2F%2F127.0.0.1%3A4317%2Fapi%2Fagent-env%2Fauth%2Ffinish%3Fstate%3Dabc"
);
}
#[test]
fn a_message_cannot_carry_markup_onto_the_page() {
assert_eq!(
escape("<script>alert('x')</script>"),
"<script>alert('x')</script>"
);
}
#[test]
fn an_ordinary_message_is_left_alone() {
assert_eq!(escape("access_denied"), "access_denied");
}
}