use crate::shared::BoxFuture;
use std::sync::{Arc, RwLock};
use tokio::{
io::{AsyncReadExt, AsyncWriteExt},
net::TcpListener,
sync::Mutex,
};
use crate::error::{Error, ErrorCode};
use volga_oauth_client::{
AuthorizationServerMetadata, BearerChallenge, ClientConfig, ClientError, ClientMetadata,
DiscoveryClient, OAuthClient, RegistrationClient, canonicalize_resource_uri,
protected_resource_metadata_url,
};
pub use volga_oauth_client::{InMemoryTokenStore, TokenSet, TokenStore};
const DEFAULT_AUTH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300);
const DEFAULT_CLIENT_NAME: &str = "neva MCP client";
#[derive(Debug, Clone)]
pub struct CallbackParams {
pub code: String,
pub state: String,
pub iss: Option<String>,
}
impl CallbackParams {
pub fn from_query(query: &str) -> Result<Self, Error> {
let mut code = None;
let mut state = None;
let mut iss = None;
let mut error = None;
let mut error_description = None;
for (key, value) in form_urlencoded_parse(query) {
match key.as_str() {
"code" => code = Some(value),
"state" => state = Some(value),
"iss" => iss = Some(value),
"error" => error = Some(value),
"error_description" => error_description = Some(value),
_ => {}
}
}
if let Some(error) = error {
let description = error_description.unwrap_or_default();
return Err(Error::new(
ErrorCode::InvalidRequest,
format!("authorization failed: {error}: {description}"),
));
}
match (code, state) {
(Some(code), Some(state)) => Ok(Self { code, state, iss }),
_ => Err(Error::new(
ErrorCode::InvalidRequest,
"authorization response is missing `code` or `state`",
)),
}
}
}
fn form_urlencoded_parse(query: &str) -> impl Iterator<Item = (String, String)> + '_ {
query.split('&').filter_map(|pair| {
let (key, value) = pair.split_once('=')?;
Some((percent_decode(key)?, percent_decode(value)?))
})
}
fn percent_decode(s: &str) -> Option<String> {
let mut out = Vec::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'%' => {
let hex = bytes.get(i + 1..i + 3)?;
let hex = std::str::from_utf8(hex).ok()?;
out.push(u8::from_str_radix(hex, 16).ok()?);
i += 3;
}
b'+' => {
out.push(b' ');
i += 1;
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8(out).ok()
}
pub trait AuthorizationHandler: Send + Sync + 'static {
fn redirect_uri(&self) -> BoxFuture<'_, Result<String, Error>>;
fn authorize(&self, authorization_url: String) -> BoxFuture<'_, Result<CallbackParams, Error>>;
}
pub struct LoopbackHandler {
port: u16,
open_browser: bool,
timeout: std::time::Duration,
listener: Mutex<Option<TcpListener>>,
}
impl std::fmt::Debug for LoopbackHandler {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoopbackHandler")
.field("port", &self.port)
.field("open_browser", &self.open_browser)
.field("timeout", &self.timeout)
.finish()
}
}
impl Default for LoopbackHandler {
fn default() -> Self {
Self {
port: 0,
open_browser: true,
timeout: DEFAULT_AUTH_TIMEOUT,
listener: Mutex::new(None),
}
}
}
impl LoopbackHandler {
pub fn new() -> Self {
Self::default()
}
pub fn with_port(mut self, port: u16) -> Self {
self.port = port;
self
}
pub fn without_browser(mut self) -> Self {
self.open_browser = false;
self
}
pub fn with_timeout(mut self, timeout: std::time::Duration) -> Self {
self.timeout = timeout;
self
}
async fn accept_callback(&self) -> Result<CallbackParams, Error> {
let listener = self.listener.lock().await.take().ok_or_else(|| {
Error::new(
ErrorCode::InternalError,
"loopback listener is not bound; `redirect_uri` must be called first",
)
})?;
let (mut stream, _) = listener.accept().await.map_err(Error::from)?;
let mut buf = vec![0u8; 8192];
let mut len = 0;
loop {
let n = stream.read(&mut buf[len..]).await.map_err(Error::from)?;
len += n;
if n == 0 || len == buf.len() || buf[..len].windows(4).any(|w| w == b"\r\n\r\n") {
break;
}
}
let params = parse_callback_request(&buf[..len]);
let (status, body) = match ¶ms {
Ok(_) => (
"200 OK",
"<html><body><h3>Authorization complete.</h3>You can close this tab and return to the application.</body></html>",
),
Err(_) => (
"400 Bad Request",
"<html><body><h3>Authorization failed.</h3>Check the application logs.</body></html>",
),
};
let resp = format!(
"HTTP/1.1 {status}\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
let _ = stream.write_all(resp.as_bytes()).await;
let _ = stream.shutdown().await;
params
}
}
fn parse_callback_request(raw: &[u8]) -> Result<CallbackParams, Error> {
let line = raw
.split(|&b| b == b'\r' || b == b'\n')
.next()
.unwrap_or_default();
let line = std::str::from_utf8(line)
.map_err(|_| Error::new(ErrorCode::InvalidRequest, "malformed callback request"))?;
let target = line
.split(' ')
.nth(1)
.ok_or_else(|| Error::new(ErrorCode::InvalidRequest, "malformed callback request"))?;
let query = target.split_once('?').map(|(_, q)| q).unwrap_or_default();
CallbackParams::from_query(query)
}
impl AuthorizationHandler for LoopbackHandler {
fn redirect_uri(&self) -> BoxFuture<'_, Result<String, Error>> {
Box::pin(async move {
let listener = TcpListener::bind(("127.0.0.1", self.port))
.await
.map_err(Error::from)?;
let port = listener.local_addr().map_err(Error::from)?.port();
*self.listener.lock().await = Some(listener);
Ok(format!("http://127.0.0.1:{port}/callback"))
})
}
fn authorize(&self, authorization_url: String) -> BoxFuture<'_, Result<CallbackParams, Error>> {
Box::pin(async move {
#[cfg(feature = "tracing")]
tracing::info!(logger = "neva", "authorize at: {authorization_url}");
if self.open_browser {
open_in_browser(&authorization_url);
}
tokio::time::timeout(self.timeout, self.accept_callback())
.await
.map_err(|_| Error::new(ErrorCode::InternalError, "authorization timed out"))?
})
}
}
fn open_in_browser(url: &str) {
#[cfg(target_os = "macos")]
let result = std::process::Command::new("open").arg(url).spawn();
#[cfg(target_os = "linux")]
let result = std::process::Command::new("xdg-open").arg(url).spawn();
#[cfg(target_os = "windows")]
let result = std::process::Command::new("cmd")
.args(["/C", "start", "", url])
.spawn();
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
let result: std::io::Result<std::process::Child> = Err(std::io::Error::other(
"no known browser launcher for this platform",
));
if let Err(_err) = result {
#[cfg(feature = "tracing")]
tracing::warn!(logger = "neva", "failed to open the browser: {_err}");
}
}
pub struct OAuthClientConfig {
client_id: Option<String>,
client_secret: Option<String>,
scopes: Option<Vec<String>>,
require_https: bool,
store: Arc<dyn TokenStore>,
handler: Arc<dyn AuthorizationHandler>,
}
impl std::fmt::Debug for OAuthClientConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthClientConfig")
.field("client_id", &self.client_id)
.field("scopes", &self.scopes)
.field("require_https", &self.require_https)
.finish()
}
}
impl Default for OAuthClientConfig {
fn default() -> Self {
Self {
client_id: None,
client_secret: None,
scopes: None,
require_https: true,
store: Arc::new(InMemoryTokenStore::new()),
handler: Arc::new(LoopbackHandler::new()),
}
}
}
impl OAuthClientConfig {
pub fn with_client_id(mut self, client_id: impl Into<String>) -> Self {
self.client_id = Some(client_id.into());
self
}
pub fn with_client_secret(mut self, secret: impl Into<String>) -> Self {
self.client_secret = Some(secret.into());
self
}
pub fn with_scopes<I, S>(mut self, scopes: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.scopes = Some(scopes.into_iter().map(Into::into).collect());
self
}
pub fn require_https(mut self, required: bool) -> Self {
self.require_https = required;
self
}
pub fn with_token_store(mut self, store: impl TokenStore + 'static) -> Self {
self.store = Arc::new(store);
self
}
pub fn with_handler(mut self, handler: impl AuthorizationHandler) -> Self {
self.handler = Arc::new(handler);
self
}
fn client_config(&self) -> ClientConfig {
ClientConfig::new().require_https(self.require_https)
}
}
struct FlowState {
client: OAuthClient,
metadata: AuthorizationServerMetadata,
}
const REFRESH_LEEWAY: std::time::Duration = std::time::Duration::from_secs(30);
pub(crate) struct OAuthSession {
config: OAuthClientConfig,
resource: String,
token: RwLock<Option<Arc<str>>>,
flow: Mutex<Option<FlowState>>,
}
impl std::fmt::Debug for OAuthSession {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("OAuthSession")
.field("resource", &self.resource)
.finish()
}
}
impl OAuthSession {
pub(crate) fn new(config: OAuthClientConfig, server_url: &str) -> Result<Self, Error> {
let resource = canonicalize_resource_uri(server_url)
.map_err(|err| Error::new(ErrorCode::InternalError, err.to_string()))?;
let token = config
.store
.get(&resource)
.filter(|tokens| !tokens.is_expired())
.map(|tokens| tokens.access_token.into());
Ok(Self {
config,
resource,
token: RwLock::new(token),
flow: Mutex::new(None),
})
}
pub(crate) fn bearer(&self) -> Option<Arc<str>> {
self.token
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.clone()
}
fn set_token(&self, token: Arc<str>) {
*self
.token
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(token);
}
pub(crate) async fn refreshed_bearer(&self) -> Option<Arc<str>> {
let stale = self
.config
.store
.get(&self.resource)
.is_some_and(|tokens| tokens.expires_within(REFRESH_LEEWAY));
if !stale {
return self.bearer();
}
let mut flow = self.flow.lock().await;
self.maintain(&mut flow).await.or_else(|| self.bearer())
}
async fn maintain(&self, state: &mut Option<FlowState>) -> Option<Arc<str>> {
let FlowState { client, metadata } = state.as_ref()?;
match client.token(&self.resource, metadata).await {
Ok(Some(tokens)) => {
let token: Arc<str> = tokens.access_token.into();
self.set_token(token.clone());
Some(token)
}
Ok(None) => None,
Err(_err) => {
#[cfg(feature = "tracing")]
tracing::warn!(logger = "neva", "token refresh failed: {_err}");
None
}
}
}
pub(crate) async fn authorize(
&self,
www_authenticate: Option<&str>,
used: Option<&str>,
) -> Result<Arc<str>, Error> {
let mut flight = self.flow.lock().await;
if let Some(current) = self.bearer()
&& used != Some(&*current)
{
return Ok(current);
}
if let Some(token) = self.maintain(&mut flight).await
&& used != Some(&*token)
{
return Ok(token);
}
let metadata_url = www_authenticate
.and_then(|header| BearerChallenge::parse(header).ok())
.and_then(|challenge| challenge.resource_metadata().map(str::to_owned));
let metadata_url = match metadata_url {
Some(url) => url,
None => protected_resource_metadata_url(&self.resource)
.map_err(|err| Error::new(ErrorCode::InternalError, err.to_string()))?,
};
let discovery = DiscoveryClient::with_config(self.config.client_config());
let resource_metadata = discovery
.fetch_resource_metadata_from_url(&metadata_url, Some(&self.resource))
.await
.map_err(flow_error)?;
let server_metadata = discovery
.discover_authorization_server(&resource_metadata)
.await
.map_err(flow_error)?;
let redirect_uri = self.config.handler.redirect_uri().await?;
let client = self.build_client(&server_metadata, &redirect_uri).await?;
let scopes = self
.config
.scopes
.clone()
.unwrap_or_else(|| resource_metadata.scopes_supported.clone());
let request = client
.authorization_request(&server_metadata)
.with_scopes(scopes)
.with_resource(self.resource.clone())
.build()
.map_err(flow_error)?;
let params = self.config.handler.authorize(request.url.clone()).await?;
if !request.matches_state(¶ms.state) {
return Err(Error::new(
ErrorCode::InvalidRequest,
"authorization response `state` mismatch",
));
}
validate_issuer(¶ms, &server_metadata)?;
let tokens = client
.exchange_code(&server_metadata, ¶ms.code, &request)
.await
.map_err(flow_error)?;
self.config.store.put(&self.resource, &tokens);
*flight = Some(FlowState {
client,
metadata: server_metadata,
});
let token: Arc<str> = tokens.access_token.into();
self.set_token(token.clone());
Ok(token)
}
async fn build_client(
&self,
server_metadata: &AuthorizationServerMetadata,
redirect_uri: &str,
) -> Result<OAuthClient, Error> {
let client = match &self.config.client_id {
Some(client_id) => {
let mut client = OAuthClient::new(client_id.clone());
if let Some(secret) = &self.config.client_secret {
client = client.with_secret(secret.clone());
}
client
}
None => {
let registration = RegistrationClient::with_config(self.config.client_config());
let response = registration
.register(server_metadata, ®istration_metadata(redirect_uri))
.await
.map_err(flow_error)?;
OAuthClient::from_registration(&response).map_err(flow_error)?
}
};
Ok(client
.with_config(self.config.client_config())
.with_redirect_uri(redirect_uri)
.with_token_store(self.config.store.clone()))
}
}
fn registration_metadata(redirect_uri: &str) -> ClientMetadata {
let mut metadata = ClientMetadata::default()
.with_redirect_uris([redirect_uri])
.with_grant_types(["authorization_code", "refresh_token"])
.with_response_types(["code"])
.with_token_endpoint_auth_method("none")
.with_client_name(DEFAULT_CLIENT_NAME);
if is_loopback_redirect(redirect_uri) {
metadata = metadata.with_application_type("native");
}
metadata
}
fn is_loopback_redirect(uri: &str) -> bool {
let Some(rest) = uri
.strip_prefix("http://")
.or_else(|| uri.strip_prefix("https://"))
else {
return false;
};
let authority = rest.split(['/', '?']).next().unwrap_or_default();
let host = match authority.split_once(']') {
Some((bracketed, _)) => &authority[..bracketed.len() + 1],
None => authority
.rsplit_once(':')
.map_or(authority, |(host, _port)| host),
};
matches!(host, "127.0.0.1" | "localhost" | "[::1]")
}
fn validate_issuer(
params: &CallbackParams,
metadata: &AuthorizationServerMetadata,
) -> Result<(), Error> {
let supported = metadata
.additional_fields
.get("authorization_response_iss_parameter_supported")
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
match (¶ms.iss, supported) {
(Some(iss), _) if *iss != metadata.issuer => Err(Error::new(
ErrorCode::InvalidRequest,
format!(
"authorization response `iss` mismatch: expected {}, got {iss}",
metadata.issuer
),
)),
(None, true) => Err(Error::new(
ErrorCode::InvalidRequest,
"authorization server advertises RFC 9207 but the response carries no `iss`",
)),
_ => Ok(()),
}
}
fn flow_error(err: ClientError) -> Error {
Error::new(
ErrorCode::InternalError,
format!("OAuth flow failed: {err}"),
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn it_parses_callback_query() {
let params =
CallbackParams::from_query("code=abc&state=xyz&iss=https%3A%2F%2Fauth.example.com")
.unwrap();
assert_eq!(params.code, "abc");
assert_eq!(params.state, "xyz");
assert_eq!(params.iss.as_deref(), Some("https://auth.example.com"));
}
#[test]
fn it_rejects_error_responses() {
let err = CallbackParams::from_query("error=access_denied&error_description=nope&state=s")
.unwrap_err();
assert!(err.to_string().contains("access_denied"));
}
#[test]
fn it_rejects_missing_code_or_state() {
assert!(CallbackParams::from_query("code=abc").is_err());
assert!(CallbackParams::from_query("state=xyz").is_err());
}
#[test]
fn token_endpoint_futures_are_send() {
fn assert_send<T: Send>(_: T) {}
let client = OAuthClient::new("client-id");
let metadata = as_metadata(None)
.with_authorization_endpoint("https://auth.example.com/authorize")
.with_token_endpoint("https://auth.example.com/token");
let request = client
.authorization_request(&metadata)
.with_scopes(["openid"])
.build()
.unwrap();
assert_send(client.exchange_code(&metadata, "code", &request));
assert_send(client.refresh(&metadata, "refresh-token"));
}
#[test]
fn loopback_redirects_are_detected() {
assert!(is_loopback_redirect("http://127.0.0.1:8919/callback"));
assert!(is_loopback_redirect("http://localhost/callback"));
assert!(is_loopback_redirect("http://[::1]:9000/callback"));
assert!(!is_loopback_redirect("https://my.app/oauth/callback"));
assert!(!is_loopback_redirect("res://localhost"));
}
#[test]
fn loopback_registration_declares_a_native_client() {
let metadata = registration_metadata("http://127.0.0.1:8919/callback");
assert_eq!(metadata.application_type.as_deref(), Some("native"));
assert_eq!(metadata.token_endpoint_auth_method.as_deref(), Some("none"));
let json = serde_json::to_value(&metadata).unwrap();
assert_eq!(json["application_type"], serde_json::json!("native"));
}
#[test]
fn web_registration_stays_a_web_client() {
let metadata = registration_metadata("https://my.app/oauth/callback");
assert!(metadata.application_type.is_none());
let json = serde_json::to_value(&metadata).unwrap();
assert!(json.get("application_type").is_none());
}
fn as_metadata(supported: Option<bool>) -> AuthorizationServerMetadata {
let mut metadata = AuthorizationServerMetadata::new("https://auth.example.com");
if let Some(supported) = supported {
metadata = metadata
.with_additional_field("authorization_response_iss_parameter_supported", supported);
}
metadata
}
fn callback(iss: Option<&str>) -> CallbackParams {
CallbackParams {
code: "c".into(),
state: "s".into(),
iss: iss.map(str::to_owned),
}
}
#[test]
fn iss_mismatch_is_rejected() {
let err = validate_issuer(
&callback(Some("https://evil.example.com")),
&as_metadata(None),
)
.unwrap_err();
assert!(err.to_string().contains("mismatch"));
}
#[test]
fn missing_iss_with_rfc9207_support_is_rejected() {
assert!(validate_issuer(&callback(None), &as_metadata(Some(true))).is_err());
}
#[test]
fn matching_iss_passes() {
assert!(
validate_issuer(
&callback(Some("https://auth.example.com")),
&as_metadata(Some(true))
)
.is_ok()
);
}
#[test]
fn missing_iss_without_support_passes() {
assert!(validate_issuer(&callback(None), &as_metadata(None)).is_ok());
assert!(validate_issuer(&callback(None), &as_metadata(Some(false))).is_ok());
}
#[tokio::test]
async fn loopback_handler_round_trip() {
let handler = LoopbackHandler::new().without_browser();
let redirect = handler.redirect_uri().await.unwrap();
assert!(redirect.starts_with("http://127.0.0.1:"));
let addr = redirect
.strip_prefix("http://")
.and_then(|rest| rest.split('/').next())
.unwrap()
.to_owned();
let callback = tokio::spawn(async move {
let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap();
stream
.write_all(b"GET /callback?code=abc&state=xyz HTTP/1.1\r\nHost: x\r\n\r\n")
.await
.unwrap();
let mut resp = String::new();
stream.read_to_string(&mut resp).await.unwrap();
resp
});
let params = handler
.authorize("http://unused.example".into())
.await
.unwrap();
assert_eq!(params.code, "abc");
assert_eq!(params.state, "xyz");
let browser_view = callback.await.unwrap();
assert!(browser_view.starts_with("HTTP/1.1 200"));
}
async fn spawn_token_endpoint(body: &'static str) -> std::net::SocketAddr {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut stream, _) = listener.accept().await.unwrap();
let mut buf = [0u8; 4096];
let _ = stream.read(&mut buf).await;
let resp = format!(
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
);
stream.write_all(resp.as_bytes()).await.unwrap();
});
addr
}
fn stale_tokens() -> TokenSet {
TokenSet {
access_token: "stale-token".into(),
token_type: "Bearer".into(),
refresh_token: Some("refresh-1".into()),
scope: None,
id_token: None,
expires_at: Some(std::time::SystemTime::now()),
}
}
fn session_with(store: Arc<dyn TokenStore>, flow: Option<FlowState>) -> OAuthSession {
let config = OAuthClientConfig {
store,
..OAuthClientConfig::default()
};
OAuthSession {
config,
resource: "http://127.0.0.1:3000/mcp".into(),
token: RwLock::new(Some("stale-token".into())),
flow: Mutex::new(flow),
}
}
#[tokio::test]
async fn stale_token_is_refreshed_without_interaction() {
let addr = spawn_token_endpoint(
r#"{"access_token":"fresh-token","token_type":"Bearer","expires_in":3600}"#,
)
.await;
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
store.put("http://127.0.0.1:3000/mcp", &stale_tokens());
let flow = FlowState {
client: OAuthClient::new("cid")
.with_config(ClientConfig::new().require_https(false))
.with_token_store(store.clone()),
metadata: AuthorizationServerMetadata::new("http://issuer.local")
.with_token_endpoint(format!("http://{addr}/token")),
};
let session = session_with(store.clone(), Some(flow));
let token = session.refreshed_bearer().await;
assert_eq!(token.as_deref(), Some("fresh-token"));
let stored = store.get("http://127.0.0.1:3000/mcp").unwrap();
assert_eq!(stored.access_token, "fresh-token");
assert_eq!(stored.refresh_token.as_deref(), Some("refresh-1"));
assert!(session.flow.lock().await.is_some());
}
#[tokio::test]
async fn fresh_token_skips_refresh() {
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
let mut tokens = stale_tokens();
tokens.expires_at =
Some(std::time::SystemTime::now() + std::time::Duration::from_secs(3600));
store.put("http://127.0.0.1:3000/mcp", &tokens);
let session = session_with(store, None);
assert_eq!(
session.refreshed_bearer().await.as_deref(),
Some("stale-token")
);
}
#[tokio::test]
async fn stale_token_without_flow_state_stays_usable() {
let store: Arc<dyn TokenStore> = Arc::new(InMemoryTokenStore::new());
store.put("http://127.0.0.1:3000/mcp", &stale_tokens());
let session = session_with(store, None);
assert_eq!(
session.refreshed_bearer().await.as_deref(),
Some("stale-token")
);
}
#[tokio::test]
async fn session_serves_stored_unexpired_token() {
let store = InMemoryTokenStore::new();
store.put(
"http://127.0.0.1:3000/mcp",
&TokenSet {
access_token: "stored-token".into(),
token_type: "Bearer".into(),
refresh_token: None,
scope: None,
id_token: None,
expires_at: None,
},
);
let config = OAuthClientConfig::default().with_token_store(store);
let session = OAuthSession::new(config, "http://127.0.0.1:3000/mcp").unwrap();
assert_eq!(session.bearer().as_deref(), Some("stored-token"));
}
}