use std::{net::SocketAddr, sync::Arc};
use axum::{
Router,
extract::{ConnectInfo, DefaultBodyLimit, Json, Query, State},
http::{HeaderMap, StatusCode},
response::{Html, IntoResponse, Redirect, Response},
routing::{get, post},
};
use hmac::{Hmac, Mac};
use rusqlite::{Connection, OptionalExtension, TransactionBehavior, params};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use crate::db::models::AuthUser;
use tracing::{info, warn};
use crate::auth::{hex_encode, sha256_hex};
use crate::db::DbPool;
use crate::error::LificError;
use crate::ratelimit::RateLimiter;
type HmacSha256 = Hmac<Sha256>;
const MAX_OAUTH_BODY_BYTES: usize = 64 * 1024;
const MAX_CLIENT_NAME_BYTES: usize = 128;
const MAX_REDIRECT_URIS: usize = 8;
const MAX_REDIRECT_URI_BYTES: usize = 2048;
const MAX_REDIRECT_METADATA_BYTES: usize = 32 * 1024;
const DYNAMIC_CLIENT_RETENTION_DAYS: i64 = 7;
const MAX_DYNAMIC_CLIENT_ROWS: i64 = 1024;
const MAX_DYNAMIC_CLIENT_STORAGE_BYTES: i64 = 4 * 1024 * 1024;
const MAX_DEVICE_CODE_ROWS: i64 = 1024;
static CSRF_SECRET: std::sync::LazyLock<[u8; 32]> = std::sync::LazyLock::new(rand::random);
fn generate_csrf_token(binding: &str) -> String {
let ts = chrono::Utc::now().timestamp();
let mut mac = HmacSha256::new_from_slice(&*CSRF_SECRET).unwrap();
mac.update(ts.to_le_bytes().as_ref());
mac.update(b".");
mac.update(binding.as_bytes());
let sig = hex_encode(&mac.finalize().into_bytes());
format!("{ts}.{sig}")
}
fn validate_csrf_token(token: &str, binding: &str) -> bool {
let Some((ts_str, sig)) = token.split_once('.') else {
return false;
};
let Ok(ts) = ts_str.parse::<i64>() else {
return false;
};
let now = chrono::Utc::now().timestamp();
if now - ts > 600 || ts > now + 60 {
return false;
}
let Ok(sig_bytes) = hex_decode(sig) else {
return false;
};
let mut mac = HmacSha256::new_from_slice(&*CSRF_SECRET).unwrap();
mac.update(ts.to_le_bytes().as_ref());
mac.update(b".");
mac.update(binding.as_bytes());
mac.verify_slice(&sig_bytes).is_ok()
}
struct AuthorizationRequest<'a> {
client_id: &'a str,
redirect_uri: &'a str,
response_type: &'a str,
state: Option<&'a str>,
code_challenge: Option<&'a str>,
code_challenge_method: Option<&'a str>,
scope: Option<&'a str>,
}
impl AuthorizationRequest<'_> {
fn csrf_binding(&self, session: &str) -> String {
let mut binding = String::new();
for value in [
Some(session),
Some(self.client_id),
Some(self.redirect_uri),
Some(self.response_type),
self.state,
self.code_challenge,
self.code_challenge_method,
self.scope,
] {
let value = value.unwrap_or_default();
binding.push_str(&value.len().to_string());
binding.push(':');
binding.push_str(value);
}
binding
}
fn csrf_token(&self, session: &str) -> String {
generate_csrf_token(&self.csrf_binding(session))
}
fn validates_csrf_token(&self, token: &str, session: &str) -> bool {
validate_csrf_token(token, &self.csrf_binding(session))
}
}
fn session_credential(headers: &HeaderMap) -> String {
headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.trim().to_string())
.or_else(|| {
headers
.get("cookie")
.and_then(|v| v.to_str().ok())
.and_then(|cookies| {
cookies.split(';').find_map(|c| {
c.trim()
.strip_prefix("lific_token=")
.map(|v| v.trim().to_string())
})
})
})
.unwrap_or_default()
}
fn authenticated_user_id(db: &DbPool, headers: &HeaderMap) -> Option<i64> {
let token = session_credential(headers);
token.starts_with("lific_sess_").then_some(())?;
db.read()
.ok()
.and_then(|conn| crate::db::queries::users::validate_session(&conn, &token).ok())
.map(|user| user.id)
}
fn authenticated_user_identity(db: &DbPool, headers: &HeaderMap) -> Option<String> {
authenticated_user_id(db, headers).and_then(|user_id| user_identity(db, user_id))
}
fn device_confirmation_token(session: &str, user_code: &str) -> String {
generate_csrf_token(&format!("device-confirmation:{session}:{user_code}"))
}
fn validate_device_confirmation_token(token: &str, session: &str, user_code: &str) -> bool {
validate_csrf_token(token, &format!("device-confirmation:{session}:{user_code}"))
}
#[derive(Clone)]
pub struct OAuthState {
pub db: DbPool,
pub issuer: String, pub issuer_is_explicit: bool,
pub allowed_hosts: Arc<[String]>,
pub register_limiter: Arc<RateLimiter>,
pub trusted_proxies: Arc<[crate::ratelimit::IpNetwork]>,
}
fn effective_issuer(state: &OAuthState, headers: &HeaderMap) -> String {
if state.issuer_is_explicit {
return state.issuer.clone();
}
let Some(host_header) = headers
.get(axum::http::header::HOST)
.and_then(|v| v.to_str().ok())
.map(str::trim)
.filter(|h| !h.is_empty())
else {
return state.issuer.clone();
};
match crate::links::parse_http_authority(host_header) {
Some(authority)
if crate::links::authority_is_allowlisted(&authority, &state.allowed_hosts) =>
{
format!("http://{authority}")
}
Some(_) => {
warn!(
host = %host_header,
issuer = %state.issuer,
"request Host does not match the advertised OAuth issuer; \
set server.public_url for proxied deployments"
);
state.issuer.clone()
}
None => state.issuer.clone(),
}
}
pub(crate) fn validate_redirect_uri(uri: &str) -> Result<(), &'static str> {
let trimmed = uri.trim();
if trimmed.is_empty() {
return Err("redirect_uri must not be empty");
}
if trimmed != uri {
return Err("redirect_uri must not have surrounding whitespace");
}
if trimmed.chars().any(char::is_control) {
return Err("redirect_uri must not contain control characters");
}
let lower_prefix: String = trimmed
.chars()
.take_while(|c| *c != ':')
.flat_map(char::to_lowercase)
.collect();
match lower_prefix.as_str() {
"http" | "https" => {}
_ => return Err("redirect_uri must use http or https scheme"),
}
let after_scheme = &trimmed[lower_prefix.len()..];
if !after_scheme.starts_with("://") {
return Err("redirect_uri must be an absolute URL (scheme://host/...)");
}
let rest = &after_scheme[3..];
let host_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
if rest[..host_end].is_empty() {
return Err("redirect_uri must include a host");
}
if trimmed.contains('#') {
return Err("redirect_uri must not contain a fragment");
}
Ok(())
}
pub fn router(state: OAuthState) -> Router {
Router::new()
.route(
"/.well-known/oauth-protected-resource",
get(protected_resource_metadata),
)
.route(
"/.well-known/oauth-authorization-server",
get(authorization_server_metadata),
)
.route(
"/.well-known/oauth-protected-resource/mcp",
get(protected_resource_metadata),
)
.route("/oauth/register", post(register_client))
.route(
"/oauth/authorize",
get(authorize_page).post(authorize_approve),
)
.route("/oauth/device_authorization", post(device_authorization))
.route("/oauth/device", get(device_page).post(device_approve))
.route("/oauth/token", post(token_exchange))
.route("/oauth/revoke", post(revoke_token))
.route("/register", post(register_client))
.route("/authorize", get(authorize_page).post(authorize_approve))
.route("/device_authorization", post(device_authorization))
.route("/device", get(device_page).post(device_approve))
.route("/token", post(token_exchange))
.route("/revoke", post(revoke_token))
.layer(DefaultBodyLimit::max(MAX_OAUTH_BODY_BYTES))
.with_state(state)
}
async fn protected_resource_metadata(
State(state): State<OAuthState>,
headers: HeaderMap,
) -> Json<serde_json::Value> {
let issuer = effective_issuer(&state, &headers);
let resource = format!("{}/mcp", issuer.trim_end_matches('/'));
Json(serde_json::json!({
"resource": resource,
"authorization_servers": [issuer],
"scopes_supported": ["mcp"],
"bearer_methods_supported": ["header"]
}))
}
async fn authorization_server_metadata(
State(state): State<OAuthState>,
headers: HeaderMap,
) -> Json<serde_json::Value> {
let issuer = effective_issuer(&state, &headers);
Json(serde_json::json!({
"issuer": issuer,
"authorization_endpoint": format!("{issuer}/oauth/authorize"),
"token_endpoint": format!("{issuer}/oauth/token"),
"registration_endpoint": format!("{issuer}/oauth/register"),
"revocation_endpoint": format!("{issuer}/oauth/revoke"),
"device_authorization_endpoint": format!("{issuer}/oauth/device_authorization"),
"scopes_supported": ["mcp"],
"response_types_supported": ["code"],
"response_modes_supported": ["query"],
"grant_types_supported": [
"authorization_code",
"urn:ietf:params:oauth:grant-type:device_code"
],
"token_endpoint_auth_methods_supported": ["none"],
"code_challenge_methods_supported": ["S256"]
}))
}
#[derive(Deserialize)]
struct RegisterRequest {
#[serde(default)]
redirect_uris: Vec<String>,
client_name: Option<String>,
#[serde(default)]
grant_types: Option<Vec<String>>,
#[serde(default)]
response_types: Option<Vec<String>>,
}
async fn register_client(
State(state): State<OAuthState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(req): Json<RegisterRequest>,
) -> Response {
let ip = crate::ratelimit::client_ip(peer.ip(), &headers, &state.trusted_proxies);
let key = format!("oauth_register:{ip}");
if !state.register_limiter.check(&key) {
let retry = state.register_limiter.retry_after(&key);
warn!(ip = %ip, "oauth client registration rate limited");
let mut resp = (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": "too_many_requests",
"error_description": crate::ratelimit::retry_after_message(
"too many client registrations",
retry,
)
})),
)
.into_response();
if retry > 0
&& let Ok(v) = retry.to_string().parse()
{
resp.headers_mut().insert("retry-after", v);
}
return resp;
}
let device_only = matches!(req.grant_types.as_deref(), Some([grant]) if grant == DEVICE_CODE_GRANT)
&& req
.response_types
.as_deref()
.is_none_or(<[String]>::is_empty);
if req.redirect_uris.is_empty() && !device_only {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_redirect_uri",
"error_description": "at least one redirect_uri is required"
})),
)
.into_response();
}
if req.redirect_uris.len() > MAX_REDIRECT_URIS
|| req
.redirect_uris
.iter()
.any(|uri| uri.len() > MAX_REDIRECT_URI_BYTES)
{
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_redirect_uri",
"error_description": "too many or oversized redirect_uris"
})),
)
.into_response();
}
let client_name = req.client_name.unwrap_or_else(|| "MCP Client".into());
if client_name.len() > MAX_CLIENT_NAME_BYTES || client_name.chars().any(|c| c.is_control()) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_client_metadata",
"error_description": "client_name is too long or contains control characters"
})),
)
.into_response();
}
for uri in &req.redirect_uris {
if let Err(reason) = validate_redirect_uri(uri) {
warn!(ip = %ip, uri = %uri, reason = %reason, "rejected oauth registration");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_redirect_uri",
"error_description": reason
})),
)
.into_response();
}
}
let redirect_uris_json =
serde_json::to_string(&req.redirect_uris).unwrap_or_else(|_| "[]".into());
if redirect_uris_json.len() > MAX_REDIRECT_METADATA_BYTES {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_client_metadata",
"error_description": "client metadata is too large"
})),
)
.into_response();
}
let db = state.db;
let conn = match db.write() {
Ok(c) => c,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(),
};
if let Err(error) = conn.execute(
"DELETE FROM oauth_codes WHERE datetime(expires_at) <= datetime('now')",
[],
) {
warn!(%error, "failed to clean up expired OAuth codes");
return (StatusCode::SERVICE_UNAVAILABLE, "database cleanup error").into_response();
}
if let Err(error) = conn.execute("DELETE FROM oauth_tokens WHERE revoked = 1", []) {
warn!(%error, "failed to clean up revoked OAuth tokens");
return (StatusCode::SERVICE_UNAVAILABLE, "database cleanup error").into_response();
}
if let Err(error) = cleanup_expired_device_codes(&conn) {
warn!(%error, "failed to clean up expired OAuth device codes");
return (StatusCode::SERVICE_UNAVAILABLE, "database cleanup error").into_response();
}
if let Err(error) = conn.execute(
"DELETE FROM oauth_clients
WHERE created_at < datetime('now', ?1)
AND NOT EXISTS (SELECT 1 FROM oauth_codes c WHERE c.client_id = oauth_clients.client_id)
AND NOT EXISTS (SELECT 1 FROM oauth_tokens t WHERE t.client_id = oauth_clients.client_id)
AND NOT EXISTS (
SELECT 1 FROM oauth_device_codes d
WHERE d.client_id = oauth_clients.client_id
)",
[format!("-{DYNAMIC_CLIENT_RETENTION_DAYS} days")],
) {
warn!(%error, "failed to clean up stale OAuth clients");
return (StatusCode::SERVICE_UNAVAILABLE, "database cleanup error").into_response();
}
let client_id = uuid_v4();
let client_bytes = (client_id.len() + client_name.len() + redirect_uris_json.len()) as i64;
let storage = conn.query_row(
"SELECT COUNT(*), COALESCE(SUM(
length(CAST(client_id AS BLOB))
+ length(CAST(client_name AS BLOB))
+ length(CAST(redirect_uris AS BLOB))
), 0)
FROM oauth_clients",
[],
|row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)),
);
let (client_count, client_storage_bytes) = match storage {
Ok(storage) => storage,
Err(error) => {
warn!(%error, "failed to inspect OAuth client storage");
return (StatusCode::SERVICE_UNAVAILABLE, "database error").into_response();
}
};
if client_count >= MAX_DYNAMIC_CLIENT_ROWS
|| client_storage_bytes.saturating_add(client_bytes) > MAX_DYNAMIC_CLIENT_STORAGE_BYTES
{
warn!("OAuth dynamic client storage limit reached");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": "temporarily_unavailable",
"error_description": "OAuth registration storage is temporarily full"
})),
)
.into_response();
}
if let Err(e) = conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?1, ?2, ?3)",
params![client_id, client_name, redirect_uris_json],
) {
tracing::error!(error = %e, "failed to register OAuth client");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
drop(conn);
info!(client_id = %client_id, name = %client_name, "OAuth client registered");
(
StatusCode::CREATED,
Json(serde_json::json!({
"client_id": client_id,
"client_name": client_name,
"redirect_uris": req.redirect_uris,
"token_endpoint_auth_method": "none",
"grant_types": req.grant_types.unwrap_or_else(|| vec!["authorization_code".into()]),
"response_types": req.response_types.unwrap_or_else(|| vec!["code".into()])
})),
)
.into_response()
}
fn client_tool_id(db: &DbPool, client_id: &str) -> Option<String> {
let conn = db.read().ok()?;
conn.query_row(
"SELECT tool_id FROM oauth_clients WHERE client_id = ?1",
params![client_id],
|row| row.get(0),
)
.ok()
.flatten()
}
const OAUTH_SCOPE: &str = "mcp";
const ACCESS_TOKEN_EXPIRES_IN: u64 = 3600 * 24 * 30;
const ACCESS_TOKEN_LIFETIME_LABEL: &str = "30 days";
fn oauth_scope_label(scope: &str) -> &str {
match scope {
OAUTH_SCOPE => "MCP issue-tracker access",
_ => scope,
}
}
#[derive(Deserialize)]
struct AuthorizeParams {
client_id: String,
redirect_uri: String,
response_type: String,
state: Option<String>,
code_challenge: Option<String>,
code_challenge_method: Option<String>,
scope: Option<String>,
}
fn valid_authorize_request(
response_type: &str,
scope: Option<&str>,
code_challenge: Option<&str>,
code_challenge_method: Option<&str>,
) -> bool {
response_type == "code"
&& scope == Some(OAUTH_SCOPE)
&& code_challenge.is_some_and(valid_s256_challenge)
&& code_challenge_method == Some("S256")
}
async fn authorize_page(
State(oauth): State<OAuthState>,
headers: HeaderMap,
Query(params): Query<AuthorizeParams>,
) -> Response {
if let Err(reason) = validate_redirect_uri(¶ms.redirect_uri) {
return (
StatusCode::BAD_REQUEST,
Html(format!("<h1>Invalid redirect URI</h1><p>{reason}</p>")),
)
.into_response();
}
let requested_scope = params.scope.as_deref().unwrap_or_default();
if !valid_authorize_request(
¶ms.response_type,
params.scope.as_deref(),
params.code_challenge.as_deref(),
params.code_challenge_method.as_deref(),
) {
return (
StatusCode::BAD_REQUEST,
Html("<h1>Unsupported OAuth request</h1><p>Only authorization-code access to the MCP capability is supported.</p>".to_string()),
)
.into_response();
}
let client_name = oauth
.db
.read()
.ok()
.and_then(|conn| {
conn.query_row(
"SELECT client_name, redirect_uris FROM oauth_clients WHERE client_id = ?1",
params![params.client_id],
|row| {
let name: String = row.get(0)?;
let uris_json: String = row.get(1)?;
Ok((name, uris_json))
},
)
.ok()
})
.and_then(|(name, uris_json)| {
let uris: Vec<String> = serde_json::from_str(&uris_json).ok()?;
uris.iter()
.any(|uri| uri == ¶ms.redirect_uri)
.then_some(name)
});
let Some(client_name) = client_name else {
return (
StatusCode::BAD_REQUEST,
Html(
"<h1>Invalid OAuth client</h1><p>The client or redirect URI is not registered.</p>"
.to_string(),
),
)
.into_response();
};
let session = session_credential(&headers);
let request = AuthorizationRequest {
client_id: ¶ms.client_id,
redirect_uri: ¶ms.redirect_uri,
response_type: ¶ms.response_type,
state: params.state.as_deref(),
code_challenge: params.code_challenge.as_deref(),
code_challenge_method: params.code_challenge_method.as_deref(),
scope: params.scope.as_deref(),
};
let csrf_token = request.csrf_token(&session);
let Some(approving_identity) = authenticated_user_identity(&oauth.db, &headers) else {
return (
StatusCode::UNAUTHORIZED,
Html(
"<h1>Authentication required</h1><p>You must be signed in to review OAuth access. \
<a href=\"/#/login\">Sign in</a>, then start the connection again from the \
application that sent you here.</p>"
.to_string(),
),
)
.into_response();
};
let preset_id = client_tool_id(&oauth.db, ¶ms.client_id);
let tool_pick_list = tool_pick_list_html(preset_id.as_deref());
(
StatusCode::OK,
Html(format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>Lific - Authorize</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{ font-family: system-ui, sans-serif; max-width: 400px; margin: 80px auto; padding: 0 20px; background: #0a0a0a; color: #e0e0e0; }}
h1 {{ font-size: 1.4em; margin-bottom: 0.5em; }}
p {{ color: #888; line-height: 1.5; }}
.client {{ color: #fff; font-weight: 600; }}
.destination {{ color: #ddd; overflow-wrap: anywhere; }}
label {{ display: block; margin-top: 1em; color: #aaa; font-size: 0.9em; }}
select, input {{ width: 100%%; padding: 10px; margin-top: 4px; border-radius: 6px; border: 1px solid #333; background: #141414; color: #e0e0e0; box-sizing: border-box; }}
form {{ margin-top: 2em; }}
button {{ background: #2563eb; color: white; border: none; padding: 12px 32px; border-radius: 6px; font-size: 1em; cursor: pointer; width: 100%; margin-top: 1.5em; }}
button:hover {{ background: #1d4ed8; }}
</style>
</head>
<body>
<h1>Authorize access to Lific</h1>
<p><span class="client">{client_name}</span> wants access to your Lific issue tracker.</p>
<p>Capability requested: <span class="client">MCP issue-tracker access</span>.</p>
<p>After approval, you will be redirected to:<br><span class="destination">{redirect_uri}</span></p>
<p>Token lifetime: <span class="client">{token_lifetime}</span>.</p>
<p>Approving identity: <span class="client">{approving_identity}</span>.</p>
<form method="POST" action="/oauth/authorize">
<input type="hidden" name="client_id" value="{client_id}">
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
<input type="hidden" name="response_type" value="{response_type}">
<input type="hidden" name="state" value="{state}">
<input type="hidden" name="code_challenge" value="{code_challenge}">
<input type="hidden" name="code_challenge_method" value="{code_challenge_method}">
<input type="hidden" name="scope" value="{scope}">
<input type="hidden" name="csrf_token" value="{csrf_token}">
{tool_pick_list}
<button type="submit" name="decision" value="approve">Approve</button>
<button type="submit" name="decision" value="deny" style="background:#444">Deny</button>
</form>
</body>
</html>"#,
client_id = html_escape(¶ms.client_id),
client_name = html_escape(&client_name),
redirect_uri = html_escape(¶ms.redirect_uri),
response_type = html_escape(¶ms.response_type),
state = html_escape(params.state.as_deref().unwrap_or("")),
code_challenge = html_escape(params.code_challenge.as_deref().unwrap_or("")),
code_challenge_method =
html_escape(params.code_challenge_method.as_deref().unwrap_or("S256")),
scope = html_escape(requested_scope),
csrf_token = html_escape(&csrf_token),
token_lifetime = ACCESS_TOKEN_LIFETIME_LABEL,
approving_identity = html_escape(&approving_identity),
tool_pick_list = tool_pick_list,
)),
)
.into_response()
}
#[derive(Deserialize)]
struct ApproveForm {
client_id: String,
redirect_uri: String,
#[allow(dead_code)]
response_type: String,
state: Option<String>,
code_challenge: Option<String>,
code_challenge_method: Option<String>,
scope: Option<String>,
csrf_token: Option<String>,
tool: Option<String>,
tool_custom: Option<String>,
decision: Option<String>,
}
#[derive(Clone, Copy)]
enum ConsentDecision {
Approve,
Deny,
}
impl ConsentDecision {
fn parse(value: Option<&str>) -> Option<Self> {
match value {
Some("approve") => Some(Self::Approve),
Some("deny") => Some(Self::Deny),
_ => None,
}
}
}
fn invalid_decision_page() -> Response {
(
StatusCode::BAD_REQUEST,
Html("<h1>Invalid decision</h1><p>Choose Approve or Deny.</p>".to_string()),
)
.into_response()
}
fn invalid_session_page() -> Response {
(
StatusCode::UNAUTHORIZED,
Html("<h1>Invalid session</h1><p>Your session has expired or is invalid. <a href=\"/#/login\">Sign in again</a></p>".to_string()),
)
.into_response()
}
fn stale_session_page() -> Response {
(
StatusCode::UNAUTHORIZED,
Html(
"<h1>Your sign-in is too old to connect a tool</h1>\
<p>Connecting a tool gives it lasting access to your account, so Lific requires a \
sign-in from the last 15 minutes. Yours is older than that. Nothing has been \
connected and nothing has changed.</p>\
<p>To continue:</p>\
<ol>\
<li>Open Lific and <strong>sign out</strong>. Simply reloading or revisiting the \
login page will not help, because your existing sign-in is still valid, just old.</li>\
<li>Sign back in. (On an instance that signs in without a password, signing out and \
reloading is enough.)</li>\
<li>Start the connection again from your MCP client. This page cannot resume it for \
you.</li>\
</ol>\
<p><a href=\"/#/settings\">Open Lific settings</a></p>"
.to_string(),
),
)
.into_response()
}
fn recent_approver(
conn: &rusqlite::Connection,
token: &str,
) -> Result<crate::db::models::User, ApprovalRefusal> {
let user = crate::db::queries::users::validate_session(conn, token)
.map_err(|_| ApprovalRefusal::InvalidSession)?;
match crate::db::queries::users::session_is_recent(conn, token) {
Ok(true) => Ok(user),
Ok(false) => Err(ApprovalRefusal::StaleSession),
Err(e) => {
tracing::error!(error = %e, "failed to check session recency");
Err(ApprovalRefusal::Database)
}
}
}
enum ApprovalRefusal {
InvalidSession,
StaleSession,
Database,
}
impl ApprovalRefusal {
fn into_response(self) -> Response {
match self {
Self::InvalidSession => invalid_session_page(),
Self::StaleSession => stale_session_page(),
Self::Database => (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(),
}
}
}
async fn authorize_approve(
State(oauth): State<OAuthState>,
headers: axum::http::HeaderMap,
axum::Form(form): axum::Form<ApproveForm>,
) -> Response {
if !valid_authorize_request(
&form.response_type,
form.scope.as_deref(),
form.code_challenge.as_deref(),
form.code_challenge_method.as_deref(),
) {
return (
StatusCode::BAD_REQUEST,
Html("<h1>Unsupported OAuth request</h1><p>Only authorization-code access to the MCP capability is supported.</p>".to_string()),
)
.into_response();
}
let decision = match ConsentDecision::parse(form.decision.as_deref()) {
Some(decision) => decision,
None => return invalid_decision_page(),
};
let credential = session_credential(&headers);
let request = AuthorizationRequest {
client_id: &form.client_id,
redirect_uri: &form.redirect_uri,
response_type: &form.response_type,
state: form.state.as_deref(),
code_challenge: form.code_challenge.as_deref(),
code_challenge_method: form.code_challenge_method.as_deref(),
scope: form.scope.as_deref(),
};
match &form.csrf_token {
Some(token) if request.validates_csrf_token(token, &credential) => {}
_ => {
return (
StatusCode::FORBIDDEN,
Html("<h1>Invalid or expired form</h1><p>Please go back and try again. <a href=\"/#/\">Return to Lific</a></p>".to_string()),
)
.into_response();
}
}
let Some(token) = (!credential.is_empty()).then_some(credential) else {
return (
StatusCode::UNAUTHORIZED,
Html("<h1>Authentication required</h1><p>You must be signed in to approve OAuth access. <a href=\"/#/login\">Sign in</a></p>".to_string()),
)
.into_response();
};
if !token.starts_with("lific_sess_") {
return invalid_session_page();
}
let redirect_ok = validate_redirect_uri(&form.redirect_uri).is_ok()
&& if let Ok(conn) = oauth.db.read() {
let registered: Result<String, _> = conn.query_row(
"SELECT redirect_uris FROM oauth_clients WHERE client_id = ?1",
params![form.client_id],
|row| row.get(0),
);
match registered {
Ok(uris_json) => {
let uris: Vec<String> = serde_json::from_str(&uris_json).unwrap_or_default();
uris.iter().any(|u| u == &form.redirect_uri)
}
Err(_) => false,
}
} else {
false
};
if !redirect_ok {
return (
StatusCode::BAD_REQUEST,
Html("Invalid client_id or redirect_uri does not match registered URIs.".to_string()),
)
.into_response();
}
match decision {
ConsentDecision::Deny => {
let mut redirect_url = form.redirect_uri.clone();
redirect_url.push_str(if redirect_url.contains('?') { "&" } else { "?" });
redirect_url.push_str("error=access_denied");
if let Some(state) = &form.state
&& !state.is_empty()
{
let encoded = urlencoding::encode(state);
redirect_url.push_str(&format!("&state={encoded}"));
}
info!(client_id = %form.client_id, "OAuth authorization denied");
return Redirect::to(&redirect_url).into_response();
}
ConsentDecision::Approve => {}
}
let code = uuid_v4();
let expires = chrono::Utc::now() + chrono::Duration::minutes(10);
let scope = form.scope.as_deref().unwrap_or("mcp");
let conn = match oauth.db.write() {
Ok(c) => c,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(),
};
let tx = match rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate) {
Ok(tx) => tx,
Err(e) => {
tracing::error!(error = %e, "failed to open OAuth authorization transaction");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
};
let approver = match recent_approver(&tx, &token) {
Ok(user) => user,
Err(refusal) => return refusal.into_response(),
};
let bot_id = match resolve_approval_bot(
&tx,
&form.tool,
&form.tool_custom,
Some(approver.id),
Some(&form.client_id),
) {
Ok(id) => id,
Err((status, msg)) => {
return (status, Html(msg)).into_response();
}
};
if let Err(e) = tx.execute(
"INSERT INTO oauth_codes (code, client_id, redirect_uri, code_challenge, code_challenge_method, expires_at, scope, user_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)",
params![
code,
form.client_id,
form.redirect_uri,
form.code_challenge.unwrap_or_default(),
form.code_challenge_method.unwrap_or_else(|| "S256".into()),
expires.to_rfc3339(),
scope,
bot_id,
],
) {
tracing::error!(error = %e, "failed to store OAuth authorization code");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
if let Err(e) = tx.commit() {
tracing::error!(error = %e, "failed to commit OAuth authorization");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
drop(conn);
let mut redirect_url = form.redirect_uri.clone();
redirect_url.push_str(if redirect_url.contains('?') { "&" } else { "?" });
redirect_url.push_str(&format!("code={code}"));
if let Some(state) = &form.state
&& !state.is_empty()
{
let encoded = urlencoding::encode(state);
redirect_url.push_str(&format!("&state={encoded}"));
}
info!(client_id = %form.client_id, "OAuth authorization approved");
Redirect::to(&redirect_url).into_response()
}
const DEVICE_CODE_GRANT: &str = "urn:ietf:params:oauth:grant-type:device_code";
const DEVICE_CODE_EXPIRES_IN: u64 = 900;
const DEVICE_CODE_INTERVAL: i64 = 5;
const USER_CODE_ALPHABET: &[u8] = b"BCDFGHJKLMNPQRSTVWXZ";
fn generate_user_code() -> String {
let pick = |buf: &mut String| {
for _ in 0..4 {
let idx = (rand::random::<u8>() as usize) % USER_CODE_ALPHABET.len();
buf.push(USER_CODE_ALPHABET[idx] as char);
}
};
let mut out = String::with_capacity(9);
pick(&mut out);
out.push('-');
pick(&mut out);
out
}
const RESERVED_TOOL_IDS: &[&str] = &["admin", "system"];
fn resolve_tool(raw: &str) -> Result<(String, String), LificError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(LificError::BadRequest("tool cannot be empty".into()));
}
if let Some(client) = crate::cli::connect::clients::find_client(trimmed) {
return Ok((client.id.to_string(), client.display.to_string()));
}
let slug: String = trimmed
.to_lowercase()
.chars()
.map(|c| if c.is_alphanumeric() { c } else { '-' })
.collect::<String>()
.split('-')
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join("-");
if slug.is_empty() {
return Err(LificError::BadRequest("tool id cannot be empty".into()));
}
if RESERVED_TOOL_IDS.contains(&slug.as_str()) {
return Err(LificError::BadRequest(format!(
"tool id '{slug}' is reserved"
)));
}
Ok((slug, trimmed.to_string()))
}
const CUSTOM_TOOL_OPTION: &str = "__custom__";
fn tool_pick_list_html(preset_id: Option<&str>) -> String {
let is_custom = preset_id.is_some_and(|id| {
id != CUSTOM_TOOL_OPTION && crate::cli::connect::clients::find_client(id).is_none()
});
let mut options = String::new();
for c in crate::cli::connect::clients::all_clients() {
let selected = preset_id == Some(c.id);
let sel_attr = if selected { " selected" } else { "" };
options.push_str(&format!(
"<option value=\"{}\"{sel_attr}>{}</option>",
html_escape(c.id),
html_escape(c.display)
));
}
let custom_option = if is_custom { " selected" } else { "" };
options.push_str(&format!(
"<option value=\"{}\"{custom_option}>Custom tool…</option>",
CUSTOM_TOOL_OPTION
));
let placeholder_sel = if preset_id.is_some() { "" } else { " selected" };
let (custom_visible, custom_value) = if is_custom {
("block", html_escape(preset_id.unwrap_or_default()))
} else {
("none", String::new())
};
format!(
"<label for=\"tool\">Which tool is connecting?</label>
<select name=\"tool\" id=\"tool\">
<option value=\"\"{placeholder_sel} disabled>Select a tool…</option>
{options}
</select>
<div id=\"custom_tool\" style=\"display:{custom_visible};\">
<label for=\"tool_custom\">Custom tool name</label>
<input type=\"text\" name=\"tool_custom\" id=\"tool_custom\" placeholder=\"e.g. my-agent\" value=\"{custom_value}\">
</div>
<script>
var tool = document.getElementById('tool');
var custom = document.getElementById('custom_tool');
tool.addEventListener('change', function () {{
custom.style.display = tool.value === '{custom_option_value}' ? 'block' : 'none';
}});
</script>",
custom_option_value = CUSTOM_TOOL_OPTION,
)
}
fn resolve_approval_bot(
conn: &rusqlite::Connection,
tool: &Option<String>,
tool_custom: &Option<String>,
approving_user_id: Option<i64>,
client_id: Option<&str>,
) -> Result<i64, (StatusCode, String)> {
let tool_text = match (tool, tool_custom) {
(Some(id), _) if !id.trim().is_empty() && id.trim() != CUSTOM_TOOL_OPTION => id.clone(),
(Some(id), Some(name)) if id.trim() == CUSTOM_TOOL_OPTION && !name.trim().is_empty() => {
name.clone()
}
_ => {
return Err((
StatusCode::BAD_REQUEST,
"Pick which tool is connecting".into(),
));
}
};
let (tool_id, display_name) = match resolve_tool(&tool_text) {
Ok(v) => v,
Err(e) => return Err((StatusCode::BAD_REQUEST, e.to_string())),
};
let Some(owner_id) = approving_user_id else {
return Err((
StatusCode::BAD_REQUEST,
"No operator to attribute to — sign in as a human first".into(),
));
};
if let Some(client_id) = client_id
&& let Err(e) = conn.execute(
"UPDATE oauth_clients SET tool_id = ?1 WHERE client_id = ?2",
params![tool_id, client_id],
)
{
tracing::error!(error = %e, client_id, "failed to remember client tool");
}
match crate::db::queries::users::ensure_bot(conn, owner_id, &tool_id, &display_name) {
Ok(bot) => Ok(bot.id),
Err(e) => {
tracing::error!(error = %e, "failed to mint OAuth tool bot");
Err((StatusCode::INTERNAL_SERVER_ERROR, "database error".into()))
}
}
}
fn normalize_user_code(input: &str) -> String {
let cleaned: String = input
.chars()
.filter(|c| c.is_ascii_alphanumeric())
.map(|c| c.to_ascii_uppercase())
.collect();
if cleaned.len() == 8 {
format!("{}-{}", &cleaned[..4], &cleaned[4..])
} else {
cleaned
}
}
fn cleanup_expired_device_codes(conn: &Connection) -> rusqlite::Result<usize> {
conn.execute(
"DELETE FROM oauth_device_codes WHERE datetime(expires_at) <= datetime('now')",
[],
)
}
#[derive(Deserialize)]
struct DeviceAuthRequest {
client_id: Option<String>,
#[serde(default)]
scope: Option<String>,
}
async fn device_authorization(
State(state): State<OAuthState>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
body: axum::body::Bytes,
) -> Response {
let ip = crate::ratelimit::client_ip(peer.ip(), &headers, &state.trusted_proxies);
let key = format!("oauth_device_authorization:{ip}");
if !state.register_limiter.check(&key) {
let retry = state.register_limiter.retry_after(&key);
warn!(ip = %ip, "oauth device authorization rate limited");
let mut resp = (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": "too_many_requests",
"error_description": crate::ratelimit::retry_after_message(
"too many device authorization requests",
retry,
)
})),
)
.into_response();
if retry > 0
&& let Ok(v) = retry.to_string().parse()
{
resp.headers_mut().insert("retry-after", v);
}
return resp;
}
let content_type = headers
.get("content-type")
.and_then(|v| v.to_str().ok())
.unwrap_or("");
let req: DeviceAuthRequest = if content_type.contains("application/json") {
serde_json::from_slice(&body).unwrap_or(DeviceAuthRequest {
client_id: None,
scope: None,
})
} else {
serde_urlencoded::from_bytes(&body).unwrap_or(DeviceAuthRequest {
client_id: None,
scope: None,
})
};
let requested_scope = req.scope.as_deref().unwrap_or(OAUTH_SCOPE);
if requested_scope != OAUTH_SCOPE {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_scope",
"error_description": "only the mcp capability is supported"
})),
)
.into_response();
}
let Some(client_id) = req.client_id.as_deref() else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_request",
"error_description": "missing client_id: register a client at /oauth/register \
and pass its client_id (older Lific CLIs must be upgraded)"
})),
)
.into_response();
};
let client_name: String = match state.db.read().ok().and_then(|conn| {
conn.query_row(
"SELECT client_name FROM oauth_clients WHERE client_id = ?1",
params![client_id],
|row| row.get(0),
)
.ok()
}) {
Some(name) => name,
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "invalid_client",
"error_description": "unknown client_id"
})),
)
.into_response();
}
};
let device_code = format!("{}{}", uuid_v4(), uuid_v4()).replace('-', "");
let device_code_hash = sha256_hex(device_code.as_bytes());
let mut user_code = generate_user_code();
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(DEVICE_CODE_EXPIRES_IN as i64);
let conn = match state.db.write() {
Ok(c) => c,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(),
};
if let Err(error) = cleanup_expired_device_codes(&conn) {
warn!(%error, "failed to clean up expired OAuth device codes");
return (StatusCode::SERVICE_UNAVAILABLE, "database cleanup error").into_response();
}
let device_count: i64 =
match conn.query_row("SELECT COUNT(*) FROM oauth_device_codes", [], |row| {
row.get(0)
}) {
Ok(count) => count,
Err(error) => {
warn!(%error, "failed to inspect OAuth device-code storage");
return (StatusCode::SERVICE_UNAVAILABLE, "database error").into_response();
}
};
if device_count >= MAX_DEVICE_CODE_ROWS {
warn!("OAuth device-code storage limit reached");
return (
StatusCode::TOO_MANY_REQUESTS,
Json(serde_json::json!({
"error": "temporarily_unavailable",
"error_description": "OAuth device authorization storage is temporarily full"
})),
)
.into_response();
}
let mut inserted = false;
for _ in 0..5 {
let res = conn.execute(
"INSERT INTO oauth_device_codes
(device_code_hash, user_code, client_name, expires_at, interval_seconds, status, scope, client_id)
VALUES (?1, ?2, ?3, ?4, ?5, 'pending', ?6, ?7)",
params![
device_code_hash,
user_code,
client_name,
expires_at.to_rfc3339(),
DEVICE_CODE_INTERVAL,
OAUTH_SCOPE,
client_id,
],
);
match res {
Ok(_) => {
inserted = true;
break;
}
Err(_) => {
user_code = generate_user_code();
}
}
}
drop(conn);
if !inserted {
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
let verification_uri = format!(
"{}/oauth/device",
effective_issuer(&state, &headers).trim_end_matches('/')
);
let verification_uri_complete = format!(
"{verification_uri}?user_code={}",
urlencoding::encode(&user_code)
);
info!(user_code = %user_code, "OAuth device authorization issued");
(
StatusCode::OK,
Json(serde_json::json!({
"device_code": device_code,
"user_code": user_code,
"verification_uri": verification_uri,
"verification_uri_complete": verification_uri_complete,
"scope": OAUTH_SCOPE,
"expires_in": DEVICE_CODE_EXPIRES_IN,
"interval": DEVICE_CODE_INTERVAL,
})),
)
.into_response()
}
struct DeviceConsent {
client_id: String,
client_name: String,
scope: String,
}
fn pending_device_consent(db: &DbPool, user_code: &str) -> Option<DeviceConsent> {
let conn = db.read().ok()?;
let (client_id, client_name, scope, expires_at, status): (
String,
String,
String,
String,
String,
) = conn
.query_row(
"SELECT dc.client_id, clients.client_name, dc.scope, dc.expires_at, dc.status
FROM oauth_device_codes dc
JOIN oauth_clients clients ON clients.client_id = dc.client_id
WHERE dc.user_code = ?1",
params![user_code],
|row| {
Ok((
row.get(0)?,
row.get(1)?,
row.get(2)?,
row.get(3)?,
row.get(4)?,
))
},
)
.ok()?;
let expires_at = chrono::DateTime::parse_from_rfc3339(&expires_at)
.ok()?
.with_timezone(&chrono::Utc);
if status != "pending" || chrono::Utc::now() >= expires_at {
return None;
}
Some(DeviceConsent {
client_id,
client_name,
scope,
})
}
fn user_identity(db: &DbPool, user_id: i64) -> Option<String> {
let conn = db.read().ok()?;
conn.query_row(
"SELECT COALESCE(NULLIF(display_name, ''), username) FROM users WHERE id = ?1",
params![user_id],
|row| row.get(0),
)
.ok()
}
async fn device_page(headers: HeaderMap, Query(q): Query<DevicePageQuery>) -> Html<String> {
let csrf_token = generate_csrf_token(&session_credential(&headers));
let prefill = q
.user_code
.as_deref()
.map(normalize_user_code)
.unwrap_or_default();
Html(format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>Lific - Device Login</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{ font-family: system-ui, sans-serif; max-width: 400px; margin: 80px auto; padding: 0 20px; background: #0a0a0a; color: #e0e0e0; }}
h1 {{ font-size: 1.4em; margin-bottom: 0.5em; }}
p {{ color: #888; line-height: 1.5; }}
label {{ display: block; margin-top: 1.5em; color: #aaa; font-size: 0.9em; }}
input[type=text], select {{ width: 100%%; box-sizing: border-box; margin-top: 0.4em; padding: 12px; border-radius: 6px; border: 1px solid #333; background: #111; color: #fff; }}
input[type=text] {{ font-size: 1.2em; letter-spacing: 0.15em; text-align: center; text-transform: uppercase; }}
.buttons {{ display: flex; gap: 12px; margin-top: 2em; }}
button {{ flex: 1; color: white; border: none; padding: 12px 24px; border-radius: 6px; font-size: 1em; cursor: pointer; }}
button.approve {{ background: #2563eb; }}
button.approve:hover {{ background: #1d4ed8; }}
button.deny {{ background: #444; }}
button.deny:hover {{ background: #555; }}
</style>
</head>
<body>
<h1>Connect a device to Lific</h1>
<p>Enter the code shown on the device or terminal that's signing in. You'll see what it is asking for before anything is approved.</p>
<form method="POST" action="/oauth/device">
<label for="user_code">Device code</label>
<input type="text" id="user_code" name="user_code" value="{user_code}" autocomplete="off" autocapitalize="characters" spellcheck="false" required>
<input type="hidden" name="csrf_token" value="{csrf_token}">
<div class="buttons">
<button type="submit" name="decision" value="approve" class="approve">Continue</button>
<button type="submit" name="decision" value="deny" class="deny">Deny</button>
</div>
</form>
</body>
</html>"#,
user_code = html_escape(&prefill),
csrf_token = html_escape(&csrf_token),
))
}
#[derive(Deserialize)]
struct DevicePageQuery {
#[serde(default)]
user_code: Option<String>,
}
#[derive(Deserialize)]
struct DeviceApproveForm {
user_code: String,
decision: Option<String>,
csrf_token: Option<String>,
confirmation_token: Option<String>,
tool: Option<String>,
tool_custom: Option<String>,
}
fn device_confirmation_page(
user_code: &str,
csrf_token: &str,
confirmation_token: &str,
consent: &DeviceConsent,
approving_identity: &str,
) -> Html<String> {
let tool_pick_list = tool_pick_list_html(None);
Html(format!(
r#"<!DOCTYPE html>
<html>
<head>
<title>Lific - Confirm Device Access</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body {{ font-family: system-ui, sans-serif; max-width: 400px; margin: 80px auto; padding: 0 20px; background: #0a0a0a; color: #e0e0e0; }}
h1 {{ font-size: 1.4em; margin-bottom: 0.5em; }}
p {{ color: #888; line-height: 1.5; }}
.value {{ color: #fff; font-weight: 600; overflow-wrap: anywhere; }}
label {{ display: block; margin-top: 1em; color: #aaa; font-size: 0.9em; }}
select, input {{ width: 100%%; padding: 10px; margin-top: 4px; border-radius: 6px; border: 1px solid #333; background: #141414; color: #e0e0e0; box-sizing: border-box; }}
form {{ margin-top: 2em; }}
button {{ color: white; border: none; padding: 12px 24px; border-radius: 6px; font-size: 1em; cursor: pointer; width: 100%%; margin-top: 1em; }}
button.approve {{ background: #2563eb; }}
button.deny {{ background: #444; }}
</style>
</head>
<body>
<h1>Confirm device access</h1>
<p><span class="value">{client_name}</span> is requesting access to Lific.</p>
<p>Registered client ID: <span class="value">{client_id}</span>.</p>
<p>Capability: <span class="value">{scope}</span>.</p>
<p>Token lifetime: <span class="value">{token_lifetime}</span>.</p>
<p>Approving identity: <span class="value">{approving_identity}</span>.</p>
<form method="POST" action="/oauth/device">
<input type="hidden" name="user_code" value="{user_code}">
<input type="hidden" name="csrf_token" value="{csrf_token}">
<input type="hidden" name="confirmation_token" value="{confirmation_token}">
{tool_pick_list}
<button type="submit" name="decision" value="approve" class="approve">Approve</button>
<button type="submit" name="decision" value="deny" class="deny">Deny</button>
</form>
</body>
</html>"#,
client_name = html_escape(&consent.client_name),
client_id = html_escape(&consent.client_id),
scope = html_escape(oauth_scope_label(&consent.scope)),
token_lifetime = ACCESS_TOKEN_LIFETIME_LABEL,
approving_identity = html_escape(approving_identity),
user_code = html_escape(user_code),
csrf_token = html_escape(csrf_token),
confirmation_token = html_escape(confirmation_token),
tool_pick_list = tool_pick_list,
))
}
async fn device_approve(
State(oauth): State<OAuthState>,
headers: HeaderMap,
axum::Form(form): axum::Form<DeviceApproveForm>,
) -> Response {
let credential = session_credential(&headers);
match &form.csrf_token {
Some(token) if validate_csrf_token(token, &credential) => {}
_ => {
return (
StatusCode::FORBIDDEN,
Html("<h1>Invalid or expired form</h1><p>Please go back and try again. <a href=\"/#/\">Return to Lific</a></p>".to_string()),
)
.into_response();
}
}
let Some(token) = (!credential.is_empty()).then_some(credential) else {
return (
StatusCode::UNAUTHORIZED,
Html("<h1>Authentication required</h1><p>You must be signed in to approve a device. <a href=\"/#/login\">Sign in</a></p>".to_string()),
)
.into_response();
};
if !token.starts_with("lific_sess_") {
return invalid_session_page();
}
let normalized = normalize_user_code(&form.user_code);
let decision = match ConsentDecision::parse(form.decision.as_deref()) {
Some(decision) => decision,
None => return invalid_decision_page(),
};
match (decision, form.confirmation_token.as_deref()) {
(ConsentDecision::Approve, None) => {
let Some(approving_user_id) = authenticated_user_id(&oauth.db, &headers) else {
return invalid_session_page();
};
let Some(consent) = pending_device_consent(&oauth.db, &normalized) else {
return (
StatusCode::BAD_REQUEST,
Html(format!(
"<h1>Unknown or expired code</h1><p>The code <code>{}</code> was not found, has expired, or was already used. <a href=\"/oauth/device\">Try again</a></p>",
html_escape(&normalized)
)),
)
.into_response();
};
let confirmation_token = device_confirmation_token(&token, &normalized);
let approving_identity = user_identity(&oauth.db, approving_user_id)
.unwrap_or_else(|| "Unknown authenticated identity".into());
return device_confirmation_page(
&normalized,
form.csrf_token.as_deref().unwrap_or_default(),
&confirmation_token,
&consent,
&approving_identity,
)
.into_response();
}
(ConsentDecision::Approve, Some(confirmation))
if !validate_device_confirmation_token(confirmation, &token, &normalized) =>
{
return (
StatusCode::FORBIDDEN,
Html("<h1>Invalid or expired confirmation</h1><p>Start the device approval again.</p>".to_string()),
)
.into_response();
}
(ConsentDecision::Approve, Some(_)) | (ConsentDecision::Deny, _) => {}
}
let conn = match oauth.db.write() {
Ok(c) => c,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(),
};
let tx = match rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate) {
Ok(tx) => tx,
Err(e) => {
tracing::error!(error = %e, "failed to open device approval transaction");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
};
let (new_status, target_user_id) = match decision {
ConsentDecision::Deny => {
let approver = match crate::db::queries::users::validate_session(&tx, &token) {
Ok(user) => user,
Err(_) => return invalid_session_page(),
};
("denied", approver.id)
}
ConsentDecision::Approve => {
let approver = match recent_approver(&tx, &token) {
Ok(user) => user,
Err(refusal) => return refusal.into_response(),
};
let target_user_id = match resolve_approval_bot(
&tx,
&form.tool,
&form.tool_custom,
Some(approver.id),
None,
) {
Ok(id) => id,
Err((status, msg)) => return (status, Html(msg)).into_response(),
};
("approved", target_user_id)
}
};
let updated = tx
.execute(
"UPDATE oauth_device_codes
SET status = ?1, user_id = ?2
WHERE user_code = ?3 AND status = 'pending'
AND datetime(expires_at) > datetime('now')",
params![new_status, target_user_id, normalized],
)
.unwrap_or(0);
if updated == 0 {
return (
StatusCode::BAD_REQUEST,
Html(format!(
"<h1>Unknown or expired code</h1><p>The code <code>{}</code> was not found, has expired, or was already used. <a href=\"/oauth/device\">Try again</a></p>",
html_escape(&normalized)
)),
)
.into_response();
}
if let Err(e) = tx.commit() {
tracing::error!(error = %e, "failed to commit device approval");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
drop(conn);
info!(user_code = %normalized, decision = %new_status, "OAuth device verification");
match decision {
ConsentDecision::Deny => (
StatusCode::OK,
Html("<h1>Access denied</h1><p>The device will not be connected. You can close this page.</p>".to_string()),
)
.into_response(),
ConsentDecision::Approve => (
StatusCode::OK,
Html("<h1>Device approved</h1><p>You're all set. Return to the device or terminal — it will finish signing in automatically.</p>".to_string()),
)
.into_response(),
}
}
#[derive(Deserialize)]
struct TokenRequest {
grant_type: String,
code: Option<String>,
redirect_uri: Option<String>,
client_id: Option<String>,
code_verifier: Option<String>,
#[allow(dead_code)]
refresh_token: Option<String>,
device_code: Option<String>,
}
#[derive(Serialize)]
struct TokenResponse {
access_token: String,
token_type: String,
expires_in: u64,
scope: String,
}
async fn token_exchange(
State(state): State<OAuthState>,
axum::Form(req): axum::Form<TokenRequest>,
) -> Response {
if req.grant_type == DEVICE_CODE_GRANT {
return device_token_exchange(&state, &req);
}
if req.grant_type != "authorization_code" {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "unsupported_grant_type"})),
)
.into_response();
}
let Some(code) = &req.code else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid_request", "error_description": "missing code"})),
)
.into_response();
};
let Some(code_verifier) = &req.code_verifier else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid_request", "error_description": "missing code_verifier"})),
)
.into_response();
};
let conn = match state.db.write() {
Ok(c) => c,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(),
};
let conn = match rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate) {
Ok(tx) => tx,
Err(e) => {
tracing::error!(error = %e, "failed to open OAuth token transaction");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
};
struct AuthCodeRow {
client_id: String,
redirect_uri: String,
code_challenge: String,
challenge_method: String,
used: i64,
scope: String,
user_id: Option<i64>,
}
let code_row: Result<AuthCodeRow, _> = conn.query_row(
"SELECT client_id, redirect_uri, code_challenge, code_challenge_method, used, scope, user_id \
FROM oauth_codes WHERE code = ?1 AND datetime(expires_at) > datetime('now')",
params![code],
|row| {
Ok(AuthCodeRow {
client_id: row.get(0)?,
redirect_uri: row.get(1)?,
code_challenge: row.get(2)?,
challenge_method: row.get(3)?,
used: row.get(4)?,
scope: row.get(5)?,
user_id: row.get(6)?,
})
},
);
let AuthCodeRow {
client_id: stored_client_id,
redirect_uri: stored_redirect_uri,
code_challenge,
challenge_method,
used,
scope,
user_id: code_user_id,
} = match code_row {
Ok(row) => row,
Err(_) => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid_grant"})),
)
.into_response();
}
};
if used != 0 {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid_grant", "error_description": "code already used"})),
)
.into_response();
}
let Some(client_id) = &req.client_id else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid_request", "error_description": "missing client_id"})),
)
.into_response();
};
if *client_id != stored_client_id {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid_grant"})),
)
.into_response();
}
match &req.redirect_uri {
Some(uri) if *uri != stored_redirect_uri => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid_grant", "error_description": "redirect_uri mismatch"})),
)
.into_response();
}
None => {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid_request", "error_description": "missing redirect_uri"})),
)
.into_response();
}
_ => {} }
if !validate_pkce(code_verifier, &code_challenge, &challenge_method) {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid_grant", "error_description": "PKCE verification failed"})),
)
.into_response();
}
let Some(code_user_id) = code_user_id else {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid_grant", "error_description": "this authorization is not bound to an identity; reconnect to authorize again"})),
)
.into_response();
};
let live = crate::db::queries::users::get_user_by_id(&conn, code_user_id)
.and_then(|user| crate::db::queries::users::credential_is_live(&conn, &user))
.unwrap_or(false);
if !live {
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({"error": "invalid_grant", "error_description": "authorizing user is no longer active"})),
)
.into_response();
}
if let Err(e) = conn.execute(
"UPDATE oauth_codes SET used = 1 WHERE code = ?1",
params![code],
) {
tracing::error!(error = %e, "failed to mark OAuth code as used");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
let access_token = format!("lific_at_{}", uuid_v4());
let token_hash = sha256_hex(access_token.as_bytes());
let expires_in = ACCESS_TOKEN_EXPIRES_IN;
let expires_at = chrono::Utc::now() + chrono::Duration::seconds(expires_in as i64);
if let Err(e) = conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, user_id) VALUES (?1, ?2, ?3, ?4, ?5)",
params![token_hash, stored_client_id, expires_at.to_rfc3339(), scope, code_user_id],
) {
tracing::error!(error = %e, "failed to store OAuth token");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
if let Err(e) = conn.commit() {
tracing::error!(error = %e, "failed to commit OAuth token exchange");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
info!(client_id = %stored_client_id, scope = %scope, "OAuth token issued");
Json(TokenResponse {
access_token,
token_type: "Bearer".into(),
expires_in,
scope,
})
.into_response()
}
fn device_token_exchange(state: &OAuthState, req: &TokenRequest) -> Response {
let Some(device_code) = req.device_code.as_deref().filter(|c| !c.is_empty()) else {
return device_error(
StatusCode::BAD_REQUEST,
"invalid_request",
Some("missing device_code"),
);
};
let device_code_hash = sha256_hex(device_code.as_bytes());
let conn = match state.db.write() {
Ok(c) => c,
Err(_) => return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response(),
};
let conn = match rusqlite::Transaction::new_unchecked(&conn, TransactionBehavior::Immediate) {
Ok(tx) => tx,
Err(e) => {
tracing::error!(error = %e, "failed to open device token transaction");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
};
macro_rules! finish {
($conn:expr, $response:expr) => {{
if let Err(e) = $conn.commit() {
tracing::error!(error = %e, "failed to commit device poll bookkeeping");
}
return $response;
}};
}
struct DeviceRow {
client_id: Option<String>,
status: String,
user_id: Option<i64>,
scope: String,
expires_at: String,
interval_seconds: i64,
last_polled_at: Option<String>,
}
let row: Result<DeviceRow, _> = conn.query_row(
"SELECT client_id, status, user_id, scope, expires_at, interval_seconds, last_polled_at
FROM oauth_device_codes WHERE device_code_hash = ?1",
params![device_code_hash],
|r| {
Ok(DeviceRow {
client_id: r.get(0)?,
status: r.get(1)?,
user_id: r.get(2)?,
scope: r.get(3)?,
expires_at: r.get(4)?,
interval_seconds: r.get(5)?,
last_polled_at: r.get(6)?,
})
},
);
let row = match row {
Ok(r) => r,
Err(_) => return device_error(StatusCode::BAD_REQUEST, "invalid_grant", None),
};
let now = chrono::Utc::now();
let expired = chrono::DateTime::parse_from_rfc3339(&row.expires_at)
.map_or(true, |t| now >= t.with_timezone(&chrono::Utc));
if expired {
let _ = conn.execute(
"DELETE FROM oauth_device_codes WHERE device_code_hash = ?1",
params![device_code_hash],
);
finish!(
conn,
device_error(StatusCode::BAD_REQUEST, "expired_token", None)
);
}
if let Some(last) = &row.last_polled_at
&& let Ok(last_t) = chrono::DateTime::parse_from_rfc3339(last)
{
let elapsed = now
.signed_duration_since(last_t.with_timezone(&chrono::Utc))
.num_seconds();
if elapsed < row.interval_seconds {
finish!(
conn,
device_error(StatusCode::BAD_REQUEST, "slow_down", None)
);
}
}
let _ = conn.execute(
"UPDATE oauth_device_codes SET last_polled_at = ?1 WHERE device_code_hash = ?2",
params![now.to_rfc3339(), device_code_hash],
);
match row.status.as_str() {
"pending" => finish!(
conn,
device_error(StatusCode::BAD_REQUEST, "authorization_pending", None)
),
"denied" => finish!(
conn,
device_error(StatusCode::BAD_REQUEST, "access_denied", None)
),
"consumed" => finish!(
conn,
device_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
Some("device code already used")
)
),
"approved" => {
let Some(approved_user_id) = row.user_id else {
finish!(
conn,
device_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
Some(
"this authorization is not bound to an identity; reconnect to \
authorize again"
),
)
);
};
let live = crate::db::queries::users::get_user_by_id(&conn, approved_user_id)
.and_then(|user| crate::db::queries::users::credential_is_live(&conn, &user))
.unwrap_or(false);
if !live {
finish!(
conn,
device_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
Some("authorizing user is no longer active"),
)
);
}
let scope = row.scope.as_str();
let Some(client_id) = row.client_id.as_deref() else {
finish!(
conn,
device_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
Some("device authorization has no registered client"),
)
);
};
let access_token = format!("lific_at_{}", uuid_v4());
let token_hash = sha256_hex(access_token.as_bytes());
let expires_in = ACCESS_TOKEN_EXPIRES_IN;
let expires_at = now + chrono::Duration::seconds(expires_in as i64);
let tx = conn;
if let Err(e) = tx.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, user_id)
VALUES (?1, ?2, ?3, ?4, ?5)",
params![
token_hash,
client_id,
expires_at.to_rfc3339(),
scope,
approved_user_id
],
) {
tracing::error!(error = %e, "failed to store device OAuth token");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
match tx.execute(
"UPDATE oauth_device_codes SET status = 'consumed'
WHERE device_code_hash = ?1 AND status = 'approved'",
params![device_code_hash],
) {
Ok(1) => {}
Ok(n) => {
tracing::error!(
rows = n,
"device code not consumed exactly once; refusing to issue token"
);
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
Err(e) => {
tracing::error!(error = %e, "failed to consume device code");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
}
if let Err(e) = tx.commit() {
tracing::error!(error = %e, "failed to commit device token exchange");
return (StatusCode::INTERNAL_SERVER_ERROR, "database error").into_response();
}
info!(scope = %scope, "OAuth device token issued");
Json(TokenResponse {
access_token,
token_type: "Bearer".into(),
expires_in,
scope: scope.into(),
})
.into_response()
}
_ => finish!(
conn,
device_error(StatusCode::BAD_REQUEST, "invalid_grant", None)
),
}
}
fn device_error(status: StatusCode, error: &str, description: Option<&str>) -> Response {
let body = match description {
Some(d) => serde_json::json!({"error": error, "error_description": d}),
None => serde_json::json!({"error": error}),
};
(status, Json(body)).into_response()
}
#[derive(Deserialize)]
struct RevokeRequest {
token: String,
#[allow(dead_code)]
token_type_hint: Option<String>,
}
async fn revoke_token(
State(state): State<OAuthState>,
headers: axum::http::HeaderMap,
axum::Form(req): axum::Form<RevokeRequest>,
) -> Response {
let caller_token = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.trim().to_string());
let is_authenticated = match &caller_token {
Some(t) if t.starts_with("lific_sess_") => match state.db.read() {
Ok(conn) => crate::db::queries::users::validate_session(&conn, t).is_ok(),
Err(_) => false,
},
Some(t) if t.starts_with("lific_at_") => authenticate_oauth_token(&state.db, t).is_some(),
Some(_) => false,
None => false,
};
if !is_authenticated {
return (StatusCode::UNAUTHORIZED, "authentication required").into_response();
}
let token_hash = sha256_hex(req.token.as_bytes());
match state.db.write() {
Ok(conn) => {
if let Err(e) = conn.execute(
"UPDATE oauth_tokens SET revoked = 1 WHERE access_token = ?1",
params![token_hash],
) {
tracing::error!(error = %e, "failed to revoke OAuth token");
}
}
Err(e) => tracing::error!(error = %e, "failed to acquire DB lock for token revocation"),
}
StatusCode::OK.into_response()
}
fn valid_s256_challenge(challenge: &str) -> bool {
challenge.len() == 43
&& challenge
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_'))
}
fn validate_pkce(verifier: &str, challenge: &str, method: &str) -> bool {
if !(43..=128).contains(&verifier.len())
|| !verifier
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~'))
|| challenge.is_empty()
{
return false;
}
match method {
"S256" => {
let hash = Sha256::digest(verifier.as_bytes());
let computed = base64_url_encode(&hash);
computed == challenge
}
_ => false, }
}
#[cfg(test)]
mod pkce_tests {
use super::*;
#[test]
fn verifier_must_use_rfc7636_length_and_characters() {
for verifier in [
"a".repeat(42),
"a".repeat(129),
format!("{}:", "a".repeat(42)),
] {
let challenge = base64_url_encode(&Sha256::digest(verifier.as_bytes()));
assert!(!validate_pkce(&verifier, &challenge, "S256"));
}
let verifier = format!("{}-._~", "a".repeat(39));
let challenge = base64_url_encode(&Sha256::digest(verifier.as_bytes()));
assert!(validate_pkce(&verifier, &challenge, "S256"));
}
}
fn hex_decode(s: &str) -> Result<Vec<u8>, ()> {
if !s.len().is_multiple_of(2) {
return Err(());
}
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).map_err(|_| ()))
.collect()
}
fn base64_url_encode(bytes: &[u8]) -> String {
use base64::Engine;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
URL_SAFE_NO_PAD.encode(bytes)
}
fn uuid_v4() -> String {
let bytes: [u8; 16] = rand::random();
format!(
"{:08x}-{:04x}-4{:03x}-{:04x}-{:012x}",
u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]),
u16::from_be_bytes([bytes[4], bytes[5]]),
u16::from_be_bytes([bytes[6], bytes[7]]) & 0x0fff,
u16::from_be_bytes([bytes[8], bytes[9]]) & 0x3fff | 0x8000,
u64::from_be_bytes([
0, 0, bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]
])
)
}
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OAuthCredential {
Bound(AuthUser),
LegacyUnbound,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthReject {
Invalid,
DeadBinding,
Unavailable,
}
pub fn resolve_oauth_credential(db: &DbPool, token: &str) -> Result<OAuthCredential, OAuthReject> {
if !token.starts_with("lific_at_") {
return Err(OAuthReject::Invalid);
}
let token_hash = sha256_hex(token.as_bytes());
let conn = db.read().map_err(|error| {
tracing::error!(%error, "OAuth token lookup could not read the database");
OAuthReject::Unavailable
})?;
struct CredentialRow {
bound_user_id: Option<i64>,
user_id: Option<i64>,
username: Option<String>,
display_name: Option<String>,
is_admin: Option<bool>,
is_active: Option<bool>,
is_bot: Option<bool>,
owner_id: Option<i64>,
owner_is_active: Option<bool>,
}
let row = conn
.query_row(
"SELECT token.user_id,
user.id, user.username, user.display_name, user.is_admin,
user.is_active, user.is_bot, user.owner_id, owner.is_active
FROM oauth_tokens token
LEFT JOIN users user ON user.id = token.user_id
LEFT JOIN users owner ON owner.id = user.owner_id
WHERE token.access_token = ?1 AND token.revoked = 0
AND datetime(token.expires_at) > datetime('now')",
params![token_hash],
|row| {
Ok(CredentialRow {
bound_user_id: row.get(0)?,
user_id: row.get(1)?,
username: row.get(2)?,
display_name: row.get(3)?,
is_admin: row.get(4)?,
is_active: row.get(5)?,
is_bot: row.get(6)?,
owner_id: row.get(7)?,
owner_is_active: row.get(8)?,
})
},
)
.optional()
.map_err(|error| {
tracing::error!(%error, "OAuth token lookup failed");
OAuthReject::Unavailable
})?
.ok_or(OAuthReject::Invalid)?;
let Some(bound_user_id) = row.bound_user_id else {
return Ok(OAuthCredential::LegacyUnbound);
};
let (
Some(user_id),
Some(username),
Some(display_name),
Some(is_admin),
Some(is_active),
Some(is_bot),
) = (
row.user_id,
row.username,
row.display_name,
row.is_admin,
row.is_active,
row.is_bot,
)
else {
return Err(OAuthReject::DeadBinding);
};
debug_assert_eq!(user_id, bound_user_id);
if !is_active || (is_bot && row.owner_id.is_some() && row.owner_is_active == Some(false)) {
return Err(OAuthReject::DeadBinding);
}
Ok(OAuthCredential::Bound(AuthUser {
id: user_id,
username,
display_name,
is_admin,
}))
}
fn authenticate_oauth_token(db: &DbPool, token: &str) -> Option<Option<i64>> {
match resolve_oauth_credential(db, token) {
Ok(OAuthCredential::Bound(user)) => Some(Some(user.id)),
Ok(OAuthCredential::LegacyUnbound) => Some(None),
Err(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use axum::extract::connect_info::MockConnectInfo;
use axum::http::{Request, StatusCode};
use http_body_util::BodyExt;
use tower::ServiceExt;
fn test_oauth_app() -> (Router, DbPool) {
test_oauth_app_with_register_limit(1000)
}
fn test_oauth_app_with_register_limit(cap: usize) -> (Router, DbPool) {
let db = crate::db::open_memory().expect("test db");
let state = OAuthState {
db: db.clone(),
issuer: "https://example.com".into(),
issuer_is_explicit: true,
allowed_hosts: test_allowed_hosts(),
register_limiter: Arc::new(RateLimiter::new(cap, std::time::Duration::from_secs(3600))),
trusted_proxies: default_trusted_proxies(),
};
(
router(state).layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 4242)))),
db,
)
}
fn test_allowed_hosts() -> Arc<[String]> {
vec![
"localhost".to_string(),
"127.0.0.1".to_string(),
"::1".to_string(),
]
.into()
}
fn default_trusted_proxies() -> Arc<[crate::ratelimit::IpNetwork]> {
Arc::<[crate::ratelimit::IpNetwork]>::from(
crate::ratelimit::parse_trusted_proxies(&["127.0.0.0/8".into()])
.expect("test trusted proxy range must parse"),
)
}
fn test_oauth_app_implicit_issuer() -> Router {
let db = crate::db::open_memory().expect("test db");
let state = OAuthState {
db,
issuer: "http://127.0.0.1:3456".into(),
issuer_is_explicit: false,
allowed_hosts: test_allowed_hosts(),
register_limiter: Arc::new(RateLimiter::new(
1000,
std::time::Duration::from_secs(3600),
)),
trusted_proxies: default_trusted_proxies(),
};
router(state).layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 4242))))
}
async fn register_named_client_helper(
app: &Router,
redirect_uri: &str,
client_name: &str,
) -> String {
let body = serde_json::json!({
"redirect_uris": [redirect_uri],
"client_name": client_name
});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
val["client_id"].as_str().unwrap().to_string()
}
async fn register_client_helper(app: &Router, redirect_uri: &str) -> String {
register_named_client_helper(app, redirect_uri, "Test Client").await
}
fn bound_user(db: &DbPool, token: &str) -> Option<i64> {
match resolve_oauth_credential(db, token) {
Ok(OAuthCredential::Bound(user)) => Some(user.id),
_ => None,
}
}
fn create_test_session(db: &DbPool) -> String {
let conn = db.write().unwrap();
let user = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "oauthtest".into(),
email: "oauth@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
let session = crate::db::queries::users::create_session(&conn, user.id, None).unwrap();
session.token
}
fn authorize_body(client_id: &str, redirect_uri: &str, binding: &str) -> String {
let challenge = test_code_challenge();
let request = AuthorizationRequest {
client_id,
redirect_uri,
response_type: "code",
state: None,
code_challenge: Some(&challenge),
code_challenge_method: Some("S256"),
scope: Some("mcp"),
};
let csrf = request.csrf_token(binding);
format!(
"client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=mcp&csrf_token={}&tool=claude-code&decision=approve",
client_id,
urlencoding::encode(redirect_uri),
urlencoding::encode(&challenge),
urlencoding::encode(&csrf),
)
}
fn test_code_challenge() -> String {
base64_url_encode(&Sha256::digest(
b"test_verifier_abcdefghijklmnopqrstuvwxyz_0123456789",
))
}
#[tokio::test]
async fn authorize_rejects_missing_auth() {
let (app, _db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let body = authorize_body(&client_id, "http://localhost/callback", "");
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn authorize_consent_identifies_client_and_capability() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let challenge = test_code_challenge();
let uri = format!(
"/oauth/authorize?client_id={client_id}&redirect_uri={}&response_type=code&scope=mcp&code_challenge={challenge}&code_challenge_method=S256",
urlencoding::encode("http://localhost/callback"),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(uri)
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let body = String::from_utf8(
resp.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
assert!(body.contains("Test Client"));
assert!(body.contains("MCP issue-tracker access"));
assert!(body.contains("http://localhost/callback"));
assert!(body.contains("30 days"));
assert!(body.contains("oauthtest"));
assert!(!body.contains("An application wants"));
}
#[tokio::test]
async fn authorize_consent_signed_out_offers_a_way_to_sign_in() {
let (app, _db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let challenge = test_code_challenge();
let uri = format!(
"/oauth/authorize?client_id={client_id}&redirect_uri={}&response_type=code&scope=mcp&code_challenge={challenge}&code_challenge_method=S256",
urlencoding::encode("http://localhost/callback"),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(uri)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let body = String::from_utf8(
resp.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
assert!(
body.contains("/#/login"),
"signed-out consent must link to sign-in: {body}"
);
}
#[tokio::test]
async fn authorize_consent_rejects_unknown_capability_or_client() {
let (app, _db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let challenge = test_code_challenge();
let bad_scope = format!(
"/oauth/authorize?client_id={client_id}&redirect_uri={}&response_type=code&scope=admin&code_challenge={challenge}&code_challenge_method=S256",
urlencoding::encode("http://localhost/callback"),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(bad_scope)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let unknown = format!(
"/oauth/authorize?client_id=unknown&redirect_uri=http%3A%2F%2Flocalhost%2Fcallback&response_type=code&scope=mcp&code_challenge={challenge}&code_challenge_method=S256"
);
let resp = app
.oneshot(
Request::builder()
.uri(unknown)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn authorize_consent_rejects_missing_scope_or_pkce() {
let (app, _db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let challenge = test_code_challenge();
let base = format!(
"/oauth/authorize?client_id={client_id}&redirect_uri={}&response_type=code",
urlencoding::encode("http://localhost/callback")
);
for suffix in [
"&code_challenge=__CHALLENGE__&code_challenge_method=S256",
"&scope=mcp&code_challenge_method=S256",
"&scope=mcp&code_challenge=__CHALLENGE__",
"&scope=mcp&code_challenge=&code_challenge_method=S256",
"&scope=mcp&code_challenge=__CHALLENGE__&code_challenge_method=plain",
] {
let suffix = suffix.replace("__CHALLENGE__", &challenge);
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("{base}{suffix}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "suffix={suffix}");
}
}
#[tokio::test]
async fn authorize_deny_redirects_without_issuing_code() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let challenge = test_code_challenge();
let request = AuthorizationRequest {
client_id: &client_id,
redirect_uri: "http://localhost/callback",
response_type: "code",
state: Some("opaque-state"),
code_challenge: Some(&challenge),
code_challenge_method: Some("S256"),
scope: Some("mcp"),
};
let csrf = request.csrf_token(&session_token);
let body = format!(
"client_id={client_id}&redirect_uri={}&response_type=code&scope=mcp&code_challenge={challenge}&code_challenge_method=S256&state=opaque-state&csrf_token={csrf}&decision=deny",
urlencoding::encode("http://localhost/callback"),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
if !resp.status().is_redirection() {
let status = resp.status();
let body = resp.into_body().collect().await.unwrap().to_bytes();
panic!(
"deny status={status}, body={}",
String::from_utf8_lossy(&body)
);
}
let location = resp.headers().get("location").unwrap().to_str().unwrap();
assert!(location.starts_with("http://localhost/callback?error=access_denied"));
assert!(location.contains("state=opaque-state"));
let conn = db.read().unwrap();
let count: i64 = conn
.query_row("SELECT COUNT(*) FROM oauth_codes", [], |row| row.get(0))
.unwrap();
assert_eq!(count, 0, "denial must not mint an authorization code");
}
#[tokio::test]
async fn authorize_rejects_a_tampered_request_binding() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let original_client = register_client_helper(&app, "http://localhost/original").await;
let tampered_client = register_client_helper(&app, "http://localhost/tampered").await;
let challenge = test_code_challenge();
let request = AuthorizationRequest {
client_id: &original_client,
redirect_uri: "http://localhost/original",
response_type: "code",
state: None,
code_challenge: Some(&challenge),
code_challenge_method: Some("S256"),
scope: Some("mcp"),
};
let csrf = request.csrf_token(&session_token);
let body = format!(
"client_id={tampered_client}&redirect_uri={}&response_type=code&scope=mcp&code_challenge={challenge}&code_challenge_method=S256&csrf_token={csrf}&decision=approve",
urlencoding::encode("http://localhost/tampered")
);
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn authorize_rejects_malformed_pkce_challenge() {
let (app, _db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let uri = format!(
"/oauth/authorize?client_id={client_id}&redirect_uri={}&response_type=code&scope=mcp&code_challenge=abc&code_challenge_method=S256",
urlencoding::encode("http://localhost/callback")
);
let resp = app
.oneshot(
Request::builder()
.uri(uri)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn authorize_requires_an_explicit_approval_decision() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
for decision in [None, Some("maybe")] {
let mut body = authorize_body(&client_id, "http://localhost/callback", &session_token);
body = body.replace("&decision=approve", "");
if let Some(decision) = decision {
body.push_str(&format!("&decision={decision}"));
}
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
}
#[tokio::test]
async fn authorize_rejects_garbage_bearer_token() {
let (app, _db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let body = authorize_body(
&client_id,
"http://localhost/callback",
"lific_sess_fake_garbage_token",
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("authorization", "Bearer lific_sess_fake_garbage_token")
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn authorize_rejects_fake_cookie_token() {
let (app, _db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let body = authorize_body(
&client_id,
"http://localhost/callback",
"lific_sess_fake_garbage_token",
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", "lific_token=lific_sess_fake_garbage_token")
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn authorize_accepts_valid_session_token() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let body = authorize_body(&client_id, "http://localhost/callback", &session_token);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("authorization", format!("Bearer {session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status().is_redirection() || resp.status() == StatusCode::SEE_OTHER,
"expected redirect, got {}",
resp.status()
);
}
#[tokio::test]
async fn authorize_accepts_valid_cookie_session() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let body = authorize_body(&client_id, "http://localhost/callback", &session_token);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status().is_redirection(),
"expected redirect, got {}",
resp.status()
);
}
#[tokio::test]
async fn authorize_rejects_unbound_csrf_replayed_with_victim_session() {
let (app, db) = test_oauth_app();
let victim_session = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let body = authorize_body(&client_id, "http://localhost/callback", "");
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={victim_session}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::FORBIDDEN,
"harvested unbound CSRF must be rejected against a victim session"
);
}
#[tokio::test]
async fn authorize_rejects_csrf_bound_to_a_different_session() {
let (app, db) = test_oauth_app();
let victim_session = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let body = authorize_body(
&client_id,
"http://localhost/callback",
"lific_sess_some_other_session",
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("authorization", format!("Bearer {victim_session}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[test]
fn csrf_token_is_bound_to_its_session() {
let t = generate_csrf_token("session-A");
assert!(validate_csrf_token(&t, "session-A"));
assert!(!validate_csrf_token(&t, "session-B"));
assert!(!validate_csrf_token(&t, ""));
}
#[test]
fn csrf_rejects_tampered_and_malformed_signatures() {
let t = generate_csrf_token("sess");
assert!(
validate_csrf_token(&t, "sess"),
"honest token must validate"
);
let (ts, sig) = t.split_once('.').unwrap();
let mut bad = sig.to_string();
let first = bad.remove(0);
let flipped = if first == '0' { '1' } else { '0' };
bad.insert(0, flipped);
assert!(!validate_csrf_token(&format!("{ts}.{bad}"), "sess"));
assert!(!validate_csrf_token(&format!("{ts}.zzzz"), "sess"));
assert!(!validate_csrf_token(&format!("{ts}.abc"), "sess"));
assert!(!validate_csrf_token(&format!("{ts}."), "sess"));
}
#[test]
fn hex_decode_roundtrips_and_rejects_bad_input() {
assert_eq!(hex_decode("00ff10").unwrap(), vec![0x00, 0xff, 0x10]);
assert_eq!(hex_decode(&hex_encode(b"lific")).unwrap(), b"lific");
assert!(hex_decode("abc").is_err(), "odd length rejected");
assert!(hex_decode("zz").is_err(), "non-hex rejected");
}
#[tokio::test]
async fn metadata_does_not_advertise_refresh_token() {
let (app, _) = test_oauth_app();
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/.well-known/oauth-authorization-server")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let grants = val["grant_types_supported"].as_array().unwrap();
assert!(
!grants.iter().any(|g| g == "refresh_token"),
"metadata should not advertise refresh_token grant"
);
assert!(grants.iter().any(|g| g == "authorization_code"));
}
#[tokio::test]
async fn register_defaults_do_not_include_refresh_token() {
let (app, _) = test_oauth_app();
let body = serde_json::json!({
"redirect_uris": ["http://localhost/callback"],
"client_name": "Test"
});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let grants = val["grant_types"].as_array().unwrap();
assert!(
!grants.iter().any(|g| g == "refresh_token"),
"client registration should not default to refresh_token"
);
}
#[tokio::test]
async fn registration_allows_no_redirect_only_for_device_clients() {
let (app, _) = test_oauth_app();
for (body, expected) in [
(
serde_json::json!({
"redirect_uris": [],
"client_name": "Device Client",
"grant_types": [DEVICE_CODE_GRANT],
"response_types": [],
}),
StatusCode::CREATED,
),
(
serde_json::json!({
"redirect_uris": [],
"client_name": "Authorization Client",
}),
StatusCode::BAD_REQUEST,
),
] {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), expected, "body={body}");
}
}
fn plant_aged_client_token(
db: &DbPool,
client_id: &str,
access_token: &str,
revoked: i64,
expires_at: chrono::DateTime<chrono::Utc>,
) {
let conn = db.write().unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, revoked)
VALUES (?1, ?2, ?3, 'mcp', ?4)",
params![access_token, client_id, expires_at.to_rfc3339(), revoked],
)
.unwrap();
conn.execute(
"UPDATE oauth_clients SET created_at = datetime('now', '-30 days')
WHERE client_id = ?1",
params![client_id],
)
.unwrap();
}
fn client_exists(db: &DbPool, client_id: &str) -> bool {
db.read()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM oauth_clients WHERE client_id = ?1",
params![client_id],
|row| row.get::<_, i64>(0),
)
.unwrap()
> 0
}
#[tokio::test]
async fn a_revoked_grant_stops_pinning_its_client() {
let (app, db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
plant_aged_client_token(
&db,
&client_id,
"revoked-hash",
1,
chrono::Utc::now() + chrono::Duration::days(30),
);
let _ = register_client_helper(&app, "http://localhost/other").await;
assert!(
!client_exists(&db, &client_id),
"a revoked grant must not pin its client forever"
);
}
#[tokio::test]
async fn a_stale_device_grant_cannot_break_registration() {
let (app, db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
{
let conn = db.write().unwrap();
conn.execute(
"INSERT INTO oauth_device_codes
(device_code_hash, user_code, client_name, expires_at, interval_seconds,
status, scope, client_id)
VALUES ('stale-hash', 'BCDF-GHJK', 'Stale', ?1, 5, 'pending', 'mcp', ?2)",
params![
(chrono::Utc::now() - chrono::Duration::days(8)).to_rfc3339(),
client_id
],
)
.unwrap();
conn.execute(
"UPDATE oauth_clients SET created_at = datetime('now', '-30 days')
WHERE client_id = ?1",
params![client_id],
)
.unwrap();
}
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.body(axum::body::Body::from(
serde_json::json!({
"redirect_uris": ["http://localhost/other"],
"client_name": "Later Client",
})
.to_string(),
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::CREATED,
"a leftover device grant must not take registration down"
);
assert!(
!client_exists(&db, &client_id),
"the expired device grant should have been swept, freeing its client"
);
}
#[tokio::test]
async fn a_live_or_merely_expired_grant_still_pins_its_client() {
for (label, expires_at) in [
("live", chrono::Utc::now() + chrono::Duration::days(30)),
("expired", chrono::Utc::now() - chrono::Duration::days(30)),
] {
let (app, db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
plant_aged_client_token(&db, &client_id, "unrevoked-hash", 0, expires_at);
let _ = register_client_helper(&app, "http://localhost/other").await;
assert!(
client_exists(&db, &client_id),
"an unrevoked ({label}) grant must keep its client"
);
}
}
#[tokio::test]
async fn metadata_advertises_only_public_client_auth() {
let (app, _) = test_oauth_app();
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/.well-known/oauth-authorization-server")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let methods = val["token_endpoint_auth_methods_supported"]
.as_array()
.unwrap();
assert_eq!(
methods,
&vec![serde_json::Value::from("none")],
"only `none` is implemented, so only `none` may be advertised"
);
}
#[tokio::test]
async fn register_reports_none_auth_method_even_when_a_secret_is_requested() {
let (app, _) = test_oauth_app();
let body = serde_json::json!({
"redirect_uris": ["http://localhost/callback"],
"client_name": "Test",
"token_endpoint_auth_method": "client_secret_post"
});
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::CREATED);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(
val["token_endpoint_auth_method"], "none",
"the response states what was registered, not what was asked for"
);
assert!(
val.get("client_secret").is_none(),
"no client secret is ever issued"
);
}
#[tokio::test]
async fn protected_resource_metadata_resource_includes_mcp_path() {
let (app, _) = test_oauth_app();
for path in [
"/.well-known/oauth-protected-resource",
"/.well-known/oauth-protected-resource/mcp",
] {
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(path)
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "path {path}");
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(val["resource"], "https://example.com/mcp", "path {path}");
assert_eq!(
val["authorization_servers"][0], "https://example.com",
"path {path}"
);
}
}
async fn get_metadata(app: &Router, path: &str, headers: &[(&str, &str)]) -> serde_json::Value {
let mut builder = Request::builder().uri(path);
for (name, value) in headers {
builder = builder.header(*name, *value);
}
let resp = app
.clone()
.oneshot(builder.body(axum::body::Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "path {path}");
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
#[tokio::test]
async fn metadata_derives_issuer_from_allowlisted_host() {
let app = test_oauth_app_implicit_issuer();
let val = get_metadata(
&app,
"/.well-known/oauth-authorization-server",
&[("host", "localhost:3456")],
)
.await;
assert_eq!(val["issuer"], "http://localhost:3456");
assert_eq!(val["token_endpoint"], "http://localhost:3456/oauth/token");
let val = get_metadata(
&app,
"/.well-known/oauth-protected-resource",
&[("host", "localhost:3456")],
)
.await;
assert_eq!(val["resource"], "http://localhost:3456/mcp");
assert_eq!(val["authorization_servers"][0], "http://localhost:3456");
}
#[tokio::test]
async fn metadata_derives_issuer_from_ipv6_loopback_host() {
let app = test_oauth_app_implicit_issuer();
let val = get_metadata(
&app,
"/.well-known/oauth-authorization-server",
&[("host", "[::1]:3456")],
)
.await;
assert_eq!(val["issuer"], "http://[::1]:3456");
}
#[tokio::test]
async fn metadata_falls_back_to_static_issuer_for_unallowlisted_host() {
let app = test_oauth_app_implicit_issuer();
let val = get_metadata(
&app,
"/.well-known/oauth-authorization-server",
&[("host", "evil.example.com")],
)
.await;
assert_eq!(
val["issuer"], "http://127.0.0.1:3456",
"unallowlisted Host must not control the advertised issuer"
);
let val = get_metadata(
&app,
"/.well-known/oauth-protected-resource",
&[("host", "evil.example.com")],
)
.await;
assert_eq!(val["resource"], "http://127.0.0.1:3456/mcp");
}
#[tokio::test]
async fn metadata_ignores_forwarded_headers() {
let app = test_oauth_app_implicit_issuer();
let val = get_metadata(
&app,
"/.well-known/oauth-authorization-server",
&[
("host", "localhost:3456"),
("x-forwarded-host", "evil.example.com"),
("x-forwarded-proto", "https"),
],
)
.await;
assert_eq!(
val["issuer"], "http://localhost:3456",
"X-Forwarded-* must never influence the advertised issuer"
);
}
#[tokio::test]
async fn explicit_public_url_issuer_is_never_overridden_by_host() {
let (app, _) = test_oauth_app();
for host in ["localhost:3456", "evil.example.com"] {
let val = get_metadata(
&app,
"/.well-known/oauth-authorization-server",
&[("host", host)],
)
.await;
assert_eq!(val["issuer"], "https://example.com", "host {host}");
}
}
#[tokio::test]
async fn revoke_token_invalidates_access() {
let (app, db) = test_oauth_app();
let token = "lific_at_test-revoke-token";
let token_hash = sha256_hex(token.as_bytes());
let expires = (chrono::Utc::now() + chrono::Duration::hours(24)).to_rfc3339();
{
let conn = db.write().unwrap();
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES ('test-client', 'Test', '[\"http://localhost\"]')",
[],
).unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope) VALUES (?1, 'test-client', ?2, 'mcp')",
params![token_hash, expires],
).unwrap();
}
assert!(resolve_oauth_credential(&db, token).is_ok());
let body = format!("token={token}");
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/revoke")
.header("content-type", "application/x-www-form-urlencoded")
.header("authorization", format!("Bearer {token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
assert!(resolve_oauth_credential(&db, token).is_err());
}
#[tokio::test]
async fn revoke_unauthenticated_returns_401() {
let (app, _) = test_oauth_app();
let body = "token=lific_at_nonexistent";
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/revoke")
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn revoke_unknown_token_returns_200() {
let (app, db) = test_oauth_app();
let auth_token = "lific_at_auth-for-revoke";
let auth_hash = sha256_hex(auth_token.as_bytes());
let expires = (chrono::Utc::now() + chrono::Duration::hours(24)).to_rfc3339();
{
let conn = db.write().unwrap();
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES ('revoke-test', 'Test', '[\"http://localhost\"]')",
[],
).unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope) VALUES (?1, 'revoke-test', ?2, 'mcp')",
params![auth_hash, expires],
).unwrap();
}
let body = "token=lific_at_nonexistent";
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/revoke")
.header("content-type", "application/x-www-form-urlencoded")
.header("authorization", format!("Bearer {auth_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
}
#[tokio::test]
async fn metadata_includes_revocation_endpoint() {
let (app, _) = test_oauth_app();
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/.well-known/oauth-authorization-server")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(val["revocation_endpoint"].as_str().is_some());
assert!(
val["revocation_endpoint"]
.as_str()
.unwrap()
.ends_with("/oauth/revoke")
);
}
#[test]
fn validate_redirect_uri_accepts_http_and_https() {
assert!(validate_redirect_uri("http://localhost/callback").is_ok());
assert!(validate_redirect_uri("http://127.0.0.1:8080/cb").is_ok());
assert!(validate_redirect_uri("https://app.example.com/oauth/callback").is_ok());
assert!(validate_redirect_uri("HTTP://localhost/callback").is_ok());
assert!(validate_redirect_uri("HTTPS://example.com/").is_ok());
}
#[test]
fn validate_redirect_uri_rejects_dangerous_schemes() {
for evil in [
"javascript:alert(1)",
"JavaScript:alert(1)",
"data:text/html,<script>alert(1)</script>",
"file:///etc/passwd",
"vbscript:msgbox()",
"about:blank",
"blob:https://evil/x",
"ftp://example.com/",
"myapp://callback",
] {
assert!(
validate_redirect_uri(evil).is_err(),
"should reject: {evil}"
);
}
}
#[test]
fn validate_redirect_uri_rejects_log_injection_characters() {
assert!(validate_redirect_uri(" http://localhost/callback").is_err());
assert!(validate_redirect_uri("http://localhost/callback\nforged=entry").is_err());
}
#[test]
fn validate_redirect_uri_rejects_malformed() {
assert!(validate_redirect_uri("").is_err());
assert!(validate_redirect_uri(" ").is_err());
assert!(validate_redirect_uri("http:evil").is_err());
assert!(validate_redirect_uri("not-a-url").is_err());
assert!(validate_redirect_uri("https://").is_err());
assert!(validate_redirect_uri("http:///path").is_err());
}
#[test]
fn validate_redirect_uri_rejects_fragments() {
assert!(validate_redirect_uri("https://example.com/callback#fragment").is_err());
assert!(validate_redirect_uri("https://example.com/callback?mode=full").is_ok());
}
#[tokio::test]
async fn register_rejects_javascript_redirect_uri() {
let (app, _) = test_oauth_app();
let body = serde_json::json!({
"redirect_uris": ["javascript:alert(1)"],
"client_name": "Evil"
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert_eq!(val["error"], "invalid_redirect_uri");
}
#[tokio::test]
async fn register_rejects_when_any_redirect_is_invalid() {
let (app, _) = test_oauth_app();
let body = serde_json::json!({
"redirect_uris": ["http://localhost/cb", "data:text/html,x"],
"client_name": "Mixed"
});
let resp = app
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn register_rejects_oversized_client_metadata() {
let (app, _) = test_oauth_app();
let body = serde_json::json!({
"redirect_uris": ["http://localhost/callback"],
"client_name": "x".repeat(MAX_CLIENT_NAME_BYTES + 1)
});
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn register_rejects_too_many_redirect_uris() {
let (app, _) = test_oauth_app();
let body = serde_json::json!({
"redirect_uris": (0..=MAX_REDIRECT_URIS)
.map(|_| "http://localhost/callback")
.collect::<Vec<_>>()
});
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn register_storage_cap_is_persistent_and_global() {
let (app, db) = test_oauth_app();
{
let conn = db.write().unwrap();
for index in 0..MAX_DYNAMIC_CLIENT_ROWS {
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES (?1, 'Test', '[\"http://localhost/callback\"]')",
params![format!("cap-client-{index}")],
)
.unwrap();
}
}
let body = serde_json::json!({
"redirect_uris": ["http://localhost/callback"],
"client_name": "After restart"
});
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/register")
.header("content-type", "application/json")
.header("x-forwarded-for", "198.51.100.200")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn register_storage_cap_counts_utf8_bytes() {
let (app, db) = test_oauth_app();
let multibyte_name = "é".repeat(7_000);
{
let conn = db.write().unwrap();
for index in 0..512 {
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris)
VALUES (?1, ?2, '[\"http://localhost/callback\"]')",
params![format!("utf8-cap-client-{index}"), &multibyte_name],
)
.unwrap();
}
}
let body = serde_json::json!({
"redirect_uris": ["http://localhost/callback"],
"client_name": "After UTF-8 cap"
});
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[tokio::test]
async fn device_storage_cap_rejects_new_sources() {
let (app, db) = test_oauth_app();
let expires_at = (chrono::Utc::now() + chrono::Duration::minutes(5)).to_rfc3339();
{
let conn = db.write().unwrap();
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES ('device-cap-client', 'Device cap test', '[]')",
[],
)
.unwrap();
for index in 0..MAX_DEVICE_CODE_ROWS {
conn.execute(
"INSERT INTO oauth_device_codes
(device_code_hash, user_code, expires_at, interval_seconds, status)
VALUES (?1, ?2, ?3, 5, 'pending')",
params![
format!("device-hash-{index}"),
format!("ABCD-{index:04}"),
expires_at
],
)
.unwrap();
}
}
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/device_authorization")
.header("content-type", "application/x-www-form-urlencoded")
.header("x-forwarded-for", "198.51.100.201")
.body(axum::body::Body::from(
"scope=mcp&client_id=device-cap-client",
))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
}
#[test]
fn rfc3339_expiry_is_rejected_even_before_utc_midnight() {
let (_, db) = test_oauth_app();
let token = "lific_at_expired-rfc3339";
let token_hash = hex_encode(&Sha256::digest(token.as_bytes()));
let expires = (chrono::Utc::now() - chrono::Duration::minutes(5)).to_rfc3339();
let conn = db.write().unwrap();
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES ('expiry-client', 'Test', '[\"http://localhost\"]')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope) VALUES (?1, 'expiry-client', ?2, 'mcp')",
params![token_hash, expires],
)
.unwrap();
drop(conn);
assert!(matches!(
resolve_oauth_credential(&db, token),
Err(OAuthReject::Invalid)
));
}
#[tokio::test]
async fn register_rate_limits_after_cap() {
let (app, _) = test_oauth_app_with_register_limit(2);
let body = serde_json::json!({
"redirect_uris": ["http://localhost/callback"],
"client_name": "RL Test"
});
let send = || {
let app = app.clone();
let body = body.clone();
async move {
app.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.header("x-forwarded-for", "192.0.2.42")
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap()
}
};
assert_eq!(send().await.status(), StatusCode::CREATED);
assert_eq!(send().await.status(), StatusCode::CREATED);
let limited = send().await;
assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS);
assert!(limited.headers().get("retry-after").is_some());
}
#[tokio::test]
async fn register_rate_limit_is_per_ip() {
let (app, _) = test_oauth_app_with_register_limit(1);
let body = serde_json::json!({
"redirect_uris": ["http://localhost/callback"],
"client_name": "Per-IP Test"
});
let send = |ip: &'static str| {
let app = app.clone();
let body = body.clone();
async move {
app.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/register")
.header("content-type", "application/json")
.header("x-forwarded-for", ip)
.body(axum::body::Body::from(serde_json::to_vec(&body).unwrap()))
.unwrap(),
)
.await
.unwrap()
}
};
assert_eq!(send("198.51.100.1").await.status(), StatusCode::CREATED);
assert_eq!(
send("198.51.100.1").await.status(),
StatusCode::TOO_MANY_REQUESTS
);
assert_eq!(send("198.51.100.2").await.status(), StatusCode::CREATED);
}
#[tokio::test]
async fn token_is_bound_to_approving_user() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db); let client_id = register_client_helper(&app, "http://localhost/callback").await;
let user_id: i64 = {
let conn = db.read().unwrap();
conn.query_row(
"SELECT id FROM users WHERE username = 'oauthtest'",
[],
|r| r.get(0),
)
.unwrap()
};
let verifier = "test_verifier_abcdefghijklmnopqrstuvwxyz_0123456789";
let challenge = base64_url_encode(&Sha256::digest(verifier.as_bytes()));
let request = AuthorizationRequest {
client_id: &client_id,
redirect_uri: "http://localhost/callback",
response_type: "code",
state: None,
code_challenge: Some(&challenge),
code_challenge_method: Some("S256"),
scope: Some(OAUTH_SCOPE),
};
let csrf = request.csrf_token(&session_token);
let body = format!(
"client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=mcp&csrf_token={}&tool=claude-code&decision=approve",
client_id,
urlencoding::encode("http://localhost/callback"),
urlencoding::encode(&challenge),
urlencoding::encode(&csrf),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status().is_redirection(),
"approve should redirect, got {}",
resp.status()
);
let location = resp
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.to_string();
let code = location
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap()
.to_string();
let (bot_id, bot_username): (i64, String) = {
let conn = db.read().unwrap();
conn.query_row(
"SELECT id, username FROM users WHERE username = 'claude-code-oauthtest'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap()
};
assert!(!bot_username.is_empty());
{
let conn = db.read().unwrap();
let code_user: Option<i64> = conn
.query_row(
"SELECT user_id FROM oauth_codes WHERE code = ?1",
params![code],
|r| r.get(0),
)
.unwrap();
assert_eq!(
code_user,
Some(bot_id),
"code should bind the tool bot, not the approver"
);
}
let token_body = format!(
"grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}",
code,
urlencoding::encode("http://localhost/callback"),
client_id,
verifier,
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/token")
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(token_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(
resp.status(),
StatusCode::OK,
"token exchange should succeed"
);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let val: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let access_token = val["access_token"].as_str().unwrap();
assert_eq!(bound_user(&db, access_token), Some(bot_id));
assert_ne!(bot_id, user_id, "bot must differ from the approving human");
}
#[tokio::test]
async fn reapproval_reuses_the_same_tool_bot() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db); let client_id = register_client_helper(&app, "http://localhost/callback").await;
let challenge = test_code_challenge();
let approve = |app: Router| {
let request = AuthorizationRequest {
client_id: &client_id,
redirect_uri: "http://localhost/callback",
response_type: "code",
state: None,
code_challenge: Some(&challenge),
code_challenge_method: Some("S256"),
scope: Some(OAUTH_SCOPE),
};
let csrf = request.csrf_token(&session_token);
let body = format!(
"client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=mcp&csrf_token={}&tool=opencode&decision=approve",
client_id,
urlencoding::encode("http://localhost/callback"),
urlencoding::encode(&challenge),
urlencoding::encode(&csrf),
);
app.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
};
assert!(
approve(app.clone())
.await
.unwrap()
.status()
.is_redirection()
);
let first_id: i64 = {
let conn = db.read().unwrap();
conn.query_row(
"SELECT id FROM users WHERE username = 'opencode-oauthtest'",
[],
|r| r.get(0),
)
.unwrap()
};
assert!(
approve(app.clone())
.await
.unwrap()
.status()
.is_redirection()
);
let second_id: i64 = {
let conn = db.read().unwrap();
conn.query_row(
"SELECT id FROM users WHERE username = 'opencode-oauthtest'",
[],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(
first_id, second_id,
"re-approval must reuse the same bot, not mint a duplicate"
);
}
#[tokio::test]
async fn approve_persists_remembered_tool_on_client() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let body = authorize_body(&client_id, "http://localhost/callback", &session_token);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert!(resp.status().is_redirection());
let tool_id: Option<String> = {
let conn = db.read().unwrap();
conn.query_row(
"SELECT tool_id FROM oauth_clients WHERE client_id = ?1",
params![client_id],
|r| r.get(0),
)
.unwrap()
};
assert_eq!(tool_id.as_deref(), Some("claude-code"));
}
#[tokio::test]
async fn authorize_page_prefills_remembered_known_tool() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let challenge = test_code_challenge();
{
let conn = db.write().unwrap();
conn.execute(
"UPDATE oauth_clients SET tool_id = 'opencode' WHERE client_id = ?1",
params![client_id],
)
.unwrap();
}
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/oauth/authorize?client_id={client_id}&redirect_uri={}&response_type=code&scope=mcp&code_challenge={challenge}&code_challenge_method=S256", urlencoding::encode("http://localhost/callback")))
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&bytes);
assert!(
html.contains("value=\"opencode\" selected"),
"known tool should be pre-selected, html={html}"
);
assert!(
!html.contains("value=\"\" selected"),
"placeholder must not also be selected when a tool is remembered, html={html}"
);
}
#[tokio::test]
async fn authorize_page_prefills_remembered_custom_tool() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let challenge = test_code_challenge();
{
let conn = db.write().unwrap();
conn.execute(
"UPDATE oauth_clients SET tool_id = 'my-editor' WHERE client_id = ?1",
params![client_id],
)
.unwrap();
}
let resp = app
.clone()
.oneshot(
Request::builder()
.uri(format!("/oauth/authorize?client_id={client_id}&redirect_uri={}&response_type=code&scope=mcp&code_challenge={challenge}&code_challenge_method=S256", urlencoding::encode("http://localhost/callback")))
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&bytes);
assert!(
html.contains("display:block"),
"custom tool field should be revealed, html={html}"
);
assert!(
html.contains("value=\"my-editor\""),
"custom field should prefill the remembered slug, html={html}"
);
}
#[tokio::test]
async fn authorize_requires_a_tool_choice() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let challenge = test_code_challenge();
let request = AuthorizationRequest {
client_id: &client_id,
redirect_uri: "http://localhost/callback",
response_type: "code",
state: None,
code_challenge: Some(&challenge),
code_challenge_method: Some("S256"),
scope: Some(OAUTH_SCOPE),
};
let csrf = request.csrf_token(&session_token);
let body = format!(
"client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=mcp&csrf_token={}&decision=approve",
client_id,
urlencoding::encode("http://localhost/callback"),
urlencoding::encode(&challenge),
urlencoding::encode(&csrf),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn authorize_rejects_reserved_free_text_tool() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let challenge = test_code_challenge();
let request = AuthorizationRequest {
client_id: &client_id,
redirect_uri: "http://localhost/callback",
response_type: "code",
state: None,
code_challenge: Some(&challenge),
code_challenge_method: Some("S256"),
scope: Some(OAUTH_SCOPE),
};
let csrf = request.csrf_token(&session_token);
let body = format!(
"client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=mcp&csrf_token={}&tool=__custom__&tool_custom=admin&decision=approve",
client_id,
urlencoding::encode("http://localhost/callback"),
urlencoding::encode(&challenge),
urlencoding::encode(&csrf),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn authorize_custom_tool_choice_mints_a_sanitized_bot() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let challenge = test_code_challenge();
let request = AuthorizationRequest {
client_id: &client_id,
redirect_uri: "http://localhost/callback",
response_type: "code",
state: None,
code_challenge: Some(&challenge),
code_challenge_method: Some("S256"),
scope: Some(OAUTH_SCOPE),
};
let csrf = request.csrf_token(&session_token);
let body = format!(
"client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=mcp&csrf_token={}&tool=__custom__&tool_custom=My Editor&decision=approve",
client_id,
urlencoding::encode("http://localhost/callback"),
urlencoding::encode(&challenge),
urlencoding::encode(&csrf),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status().is_redirection(),
"custom free-text tool should approve, got {}",
resp.status()
);
let bot: (String, String) = {
let conn = db.read().unwrap();
conn.query_row(
"SELECT username, display_name FROM users WHERE username = 'my-editor-oauthtest'",
[],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap()
};
assert_eq!(bot.0, "my-editor-oauthtest");
assert_eq!(bot.1, "My Editor");
}
#[tokio::test]
async fn legacy_token_without_user_resolves_to_none() {
let (_, db) = test_oauth_app();
let token = "lific_at_legacy-no-user-binding";
let token_hash = sha256_hex(token.as_bytes());
let expires = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
{
let conn = db.write().unwrap();
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris) VALUES ('legacy-c', 'Test', '[\"http://localhost\"]')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope) VALUES (?1, 'legacy-c', ?2, 'mcp')",
params![token_hash, expires],
)
.unwrap();
}
assert!(
resolve_oauth_credential(&db, token).is_ok(),
"token still valid"
);
assert_eq!(
bound_user(&db, token),
None,
"legacy token has no bound user"
);
}
async fn request_device_code(app: &Router, client_name: Option<&str>) -> serde_json::Value {
let client_id = register_named_client_helper(
app,
"http://localhost/callback",
client_name.unwrap_or("Test Device Client"),
)
.await;
let body = format!("scope=mcp&client_id={}", urlencoding::encode(&client_id));
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device_authorization")
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
serde_json::from_slice(&bytes).unwrap()
}
async fn poll_device_token(app: &Router, device_code: &str) -> (StatusCode, serde_json::Value) {
let body = format!(
"grant_type={}&device_code={}",
urlencoding::encode("urn:ietf:params:oauth:grant-type:device_code"),
urlencoding::encode(device_code),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/token")
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let val = serde_json::from_slice(&bytes).unwrap_or_else(|_| serde_json::json!({}));
(status, val)
}
#[tokio::test]
async fn device_authorization_returns_wellformed_response() {
let (app, _db) = test_oauth_app();
let v = request_device_code(&app, Some("My CLI")).await;
assert!(v["device_code"].as_str().is_some());
let user_code = v["user_code"].as_str().unwrap();
assert_eq!(user_code.len(), 9);
assert_eq!(&user_code[4..5], "-");
for c in user_code.chars().filter(|c| *c != '-') {
assert!(
USER_CODE_ALPHABET.contains(&(c as u8)),
"user_code char {c} not in alphabet"
);
}
assert_eq!(v["expires_in"], 900);
assert_eq!(v["interval"], 5);
assert_eq!(v["scope"], OAUTH_SCOPE);
let vuri = v["verification_uri"].as_str().unwrap();
assert!(vuri.ends_with("/oauth/device"));
let vuc = v["verification_uri_complete"].as_str().unwrap();
assert!(vuc.contains("user_code="));
}
#[tokio::test]
async fn device_authorization_defaults_an_omitted_scope_to_mcp() {
let (app, db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device_authorization")
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(format!(
"client_id={}",
urlencoding::encode(&client_id)
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let v: serde_json::Value =
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(v["scope"], OAUTH_SCOPE);
let stored: String = db
.read()
.unwrap()
.query_row(
"SELECT scope FROM oauth_device_codes WHERE user_code = ?1",
params![v["user_code"].as_str().unwrap()],
|row| row.get(0),
)
.unwrap();
assert_eq!(stored, OAUTH_SCOPE);
}
#[tokio::test]
async fn device_authorization_rejects_an_unsupported_scope() {
let (app, _db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device_authorization")
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(format!(
"client_id={}&scope=admin",
urlencoding::encode(&client_id)
)))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
let v: serde_json::Value =
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
assert_eq!(v["error"], "invalid_scope");
}
#[tokio::test]
async fn device_authorization_requires_a_registered_client() {
let (app, db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
for body in [
"scope=mcp".to_string(),
"scope=mcp&client_id=unknown".to_string(),
] {
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device_authorization")
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
let body = format!(
"scope=mcp&client_id={}&client_name=Impostor",
urlencoding::encode(&client_id)
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device_authorization")
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let response: serde_json::Value =
serde_json::from_slice(&resp.into_body().collect().await.unwrap().to_bytes()).unwrap();
let user_code = response["user_code"].as_str().unwrap();
let consent = pending_device_consent(&db, user_code).unwrap();
assert_eq!(consent.client_name, "Test Client");
assert_eq!(consent.client_id, client_id);
}
#[tokio::test]
async fn device_code_stored_only_as_hash() {
let (app, db) = test_oauth_app();
let v = request_device_code(&app, None).await;
let device_code = v["device_code"].as_str().unwrap();
let hash = sha256_hex(device_code.as_bytes());
let conn = db.read().unwrap();
let by_hash: i64 = conn
.query_row(
"SELECT COUNT(*) FROM oauth_device_codes WHERE device_code_hash = ?1",
params![hash],
|r| r.get(0),
)
.unwrap();
assert_eq!(by_hash, 1);
let by_raw: i64 = conn
.query_row(
"SELECT COUNT(*) FROM oauth_device_codes WHERE device_code_hash = ?1",
params![device_code],
|r| r.get(0),
)
.unwrap();
assert_eq!(by_raw, 0, "raw device_code must not be stored");
}
#[tokio::test]
async fn device_metadata_advertises_endpoint_and_grant() {
let (app, _) = test_oauth_app();
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/.well-known/oauth-authorization-server")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
assert!(
v["device_authorization_endpoint"]
.as_str()
.unwrap()
.ends_with("/oauth/device_authorization")
);
let grants = v["grant_types_supported"].as_array().unwrap();
assert!(
grants
.iter()
.any(|g| g == "urn:ietf:params:oauth:grant-type:device_code"),
"metadata must advertise the device grant"
);
}
#[tokio::test]
async fn device_polling_pending_then_approved_end_to_end() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db); let user_id: i64 = {
let conn = db.read().unwrap();
conn.query_row(
"SELECT id FROM users WHERE username = 'oauthtest'",
[],
|r| r.get(0),
)
.unwrap()
};
let v = request_device_code(&app, Some("laptop <img>")).await;
let device_code = v["device_code"].as_str().unwrap().to_string();
let user_code = v["user_code"].as_str().unwrap().to_string();
let device_hash = sha256_hex(device_code.as_bytes());
let (status, body) = poll_device_token(&app, &device_code).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "authorization_pending");
let reset_last_poll = |db: &DbPool| {
let conn = db.write().unwrap();
conn.execute(
"UPDATE oauth_device_codes SET last_polled_at = NULL WHERE device_code_hash = ?1",
params![device_hash],
)
.unwrap();
};
let csrf = generate_csrf_token(&session_token);
let approve_body = format!(
"user_code={}&decision=approve&csrf_token={}&tool=claude-code",
urlencoding::encode(&user_code),
urlencoding::encode(&csrf),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(approve_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "approval should succeed");
let confirmation_page = String::from_utf8(
resp.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
let status: String = db
.read()
.unwrap()
.query_row(
"SELECT status FROM oauth_device_codes WHERE user_code = ?1",
params![user_code],
|row| row.get(0),
)
.unwrap();
assert_eq!(status, "pending", "looking up a device must not approve it");
assert!(confirmation_page.contains("laptop <img>"));
assert!(!confirmation_page.contains("laptop <img>"));
assert!(confirmation_page.contains("MCP issue-tracker access"));
assert!(confirmation_page.contains("30 days"));
assert!(confirmation_page.contains("oauthtest"));
assert!(confirmation_page.contains("name=\"confirmation_token\""));
let confirmation_token = confirmation_page
.split("name=\"confirmation_token\" value=\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.expect("confirmation token")
.to_string();
let confirm_body = format!(
"user_code={}&decision=approve&csrf_token={}&confirmation_token={}&tool=claude-code",
urlencoding::encode(&user_code),
urlencoding::encode(&csrf),
urlencoding::encode(&confirmation_token),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(confirm_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK, "confirmation should approve");
let bot_id: i64 = {
let conn = db.read().unwrap();
conn.query_row(
"SELECT id FROM users WHERE username = 'claude-code-oauthtest'",
[],
|r| r.get(0),
)
.unwrap()
};
{
let conn = db.read().unwrap();
let (st, uid): (String, Option<i64>) = conn
.query_row(
"SELECT status, user_id FROM oauth_device_codes WHERE user_code = ?1",
params![user_code],
|r| Ok((r.get(0)?, r.get(1)?)),
)
.unwrap();
assert_eq!(st, "approved");
assert_eq!(uid, Some(bot_id));
}
reset_last_poll(&db);
let (status, body) = poll_device_token(&app, &device_code).await;
assert_eq!(status, StatusCode::OK, "expected token, got {body}");
let access_token = body["access_token"].as_str().unwrap();
assert!(access_token.starts_with("lific_at_"));
assert_eq!(bound_user(&db, access_token), Some(bot_id));
assert_ne!(bot_id, user_id, "bot must differ from the approving human");
reset_last_poll(&db);
let (status, body) = poll_device_token(&app, &device_code).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "invalid_grant");
}
#[tokio::test]
async fn device_polling_slow_down_when_too_fast() {
let (app, _db) = test_oauth_app();
let v = request_device_code(&app, None).await;
let device_code = v["device_code"].as_str().unwrap().to_string();
let (_, body) = poll_device_token(&app, &device_code).await;
assert_eq!(body["error"], "authorization_pending");
let (status, body) = poll_device_token(&app, &device_code).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "slow_down");
}
#[tokio::test]
async fn device_expired_token_after_expiry() {
let (app, db) = test_oauth_app();
let v = request_device_code(&app, None).await;
let device_code = v["device_code"].as_str().unwrap().to_string();
let hash = sha256_hex(device_code.as_bytes());
{
let conn = db.write().unwrap();
let past = (chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339();
conn.execute(
"UPDATE oauth_device_codes SET expires_at = ?1 WHERE device_code_hash = ?2",
params![past, hash],
)
.unwrap();
}
let (status, body) = poll_device_token(&app, &device_code).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "expired_token");
}
#[tokio::test]
async fn device_denied_path() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let v = request_device_code(&app, None).await;
let device_code = v["device_code"].as_str().unwrap().to_string();
let user_code = v["user_code"].as_str().unwrap().to_string();
let csrf = generate_csrf_token(&session_token);
let deny_body = format!(
"user_code={}&decision=deny&csrf_token={}",
urlencoding::encode(&user_code),
urlencoding::encode(&csrf),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(deny_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let (status, body) = poll_device_token(&app, &device_code).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "access_denied");
}
#[tokio::test]
async fn device_requires_an_explicit_approval_decision() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let response = request_device_code(&app, None).await;
let user_code = response["user_code"].as_str().unwrap();
let csrf = generate_csrf_token(&session);
for decision in [None, Some("maybe")] {
let mut body = format!(
"user_code={}&csrf_token={}",
urlencoding::encode(user_code),
urlencoding::encode(&csrf),
);
if let Some(decision) = decision {
body.push_str(&format!("&decision={decision}"));
}
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
let status: String = db
.read()
.unwrap()
.query_row(
"SELECT status FROM oauth_device_codes WHERE user_code = ?1",
params![user_code],
|row| row.get(0),
)
.unwrap();
assert_eq!(status, "pending");
}
#[tokio::test]
async fn device_verification_requires_login() {
let (app, _db) = test_oauth_app();
let v = request_device_code(&app, None).await;
let user_code = v["user_code"].as_str().unwrap().to_string();
let csrf = generate_csrf_token("");
let body = format!(
"user_code={}&decision=approve&csrf_token={}",
urlencoding::encode(&user_code),
urlencoding::encode(&csrf),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn device_approve_rejects_unbound_csrf() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let v = request_device_code(&app, None).await;
let user_code = v["user_code"].as_str().unwrap().to_string();
let csrf = generate_csrf_token(""); let body = format!(
"user_code={}&decision=approve&csrf_token={}",
urlencoding::encode(&user_code),
urlencoding::encode(&csrf),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn device_invalid_user_code_returns_error_page() {
let (app, db) = test_oauth_app();
let session_token = create_test_session(&db);
let csrf = generate_csrf_token(&session_token);
let body = format!(
"user_code={}&decision=approve&csrf_token={}",
"ZZZZ-ZZZZ",
urlencoding::encode(&csrf),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn device_unknown_device_code_is_invalid_grant() {
let (app, _db) = test_oauth_app();
let (status, body) = poll_device_token(&app, "totally-unknown-device-code").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "invalid_grant");
}
fn approve_device_code(db: &DbPool, device_hash: &str) {
let conn = db.write().unwrap();
let approver = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "device-approver".into(),
email: "device-approver@test.com".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: false,
is_bot: false,
},
)
.unwrap();
conn.execute(
"UPDATE oauth_device_codes
SET status = 'approved', user_id = ?2, last_polled_at = NULL
WHERE device_code_hash = ?1",
params![device_hash, approver.id],
)
.unwrap();
}
fn device_status(db: &DbPool, device_hash: &str) -> String {
let conn = db.read().unwrap();
conn.query_row(
"SELECT status FROM oauth_device_codes WHERE device_code_hash = ?1",
params![device_hash],
|r| r.get(0),
)
.unwrap()
}
fn token_count(db: &DbPool) -> i64 {
let conn = db.read().unwrap();
conn.query_row("SELECT COUNT(*) FROM oauth_tokens", [], |r| r.get(0))
.unwrap()
}
#[tokio::test]
async fn device_consumed_code_cannot_mint_a_second_token() {
let (app, db) = test_oauth_app();
let v = request_device_code(&app, None).await;
let device_code = v["device_code"].as_str().unwrap().to_string();
let hash = sha256_hex(device_code.as_bytes());
approve_device_code(&db, &hash);
let (status, body) = poll_device_token(&app, &device_code).await;
assert_eq!(status, StatusCode::OK, "expected token, got {body}");
assert_eq!(body["scope"], "mcp");
assert_eq!(token_count(&db), 1);
assert_eq!(device_status(&db, &hash), "consumed");
{
let conn = db.write().unwrap();
conn.execute(
"UPDATE oauth_device_codes SET last_polled_at = NULL WHERE device_code_hash = ?1",
params![hash],
)
.unwrap();
}
let (status, body) = poll_device_token(&app, &device_code).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "invalid_grant");
assert_eq!(token_count(&db), 1, "replay must not mint a second token");
}
#[tokio::test]
async fn device_token_is_rolled_back_when_the_code_cannot_be_consumed() {
let (app, db) = test_oauth_app();
let v = request_device_code(&app, None).await;
let device_code = v["device_code"].as_str().unwrap().to_string();
let hash = sha256_hex(device_code.as_bytes());
approve_device_code(&db, &hash);
{
let conn = db.write().unwrap();
conn.execute_batch(
"CREATE TRIGGER block_consume
BEFORE UPDATE OF status ON oauth_device_codes
WHEN NEW.status = 'consumed'
BEGIN SELECT RAISE(ABORT, 'consume blocked'); END;",
)
.unwrap();
}
let (status, _) = poll_device_token(&app, &device_code).await;
assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
assert_eq!(token_count(&db), 0, "no token may survive a failed consume");
assert_eq!(device_status(&db, &hash), "approved");
}
#[tokio::test]
async fn device_page_prefills_user_code_from_query() {
let (app, _db) = test_oauth_app();
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/oauth/device?user_code=bcdfghjk")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let html = String::from_utf8_lossy(&bytes);
assert!(
html.contains("value=\"BCDF-GHJK\""),
"prefill missing: {html}"
);
}
#[tokio::test]
async fn device_code_entry_asks_only_for_the_code() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let resp = app
.clone()
.oneshot(
Request::builder()
.uri("/oauth/device")
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let entry = String::from_utf8_lossy(&bytes).to_string();
assert!(entry.contains("name=\"user_code\""));
assert!(
!entry.contains("name=\"tool\""),
"code entry must not ask which tool is connecting: {entry}"
);
let v = request_device_code(&app, Some("laptop")).await;
let body = format!(
"user_code={}&decision=approve&csrf_token={}",
urlencoding::encode(v["user_code"].as_str().unwrap()),
urlencoding::encode(&generate_csrf_token(&session)),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
let confirm = String::from_utf8_lossy(&bytes);
assert!(
confirm.contains("name=\"tool\""),
"confirmation must ask which tool is connecting: {confirm}"
);
}
#[test]
fn normalize_user_code_handles_spacing_and_case() {
assert_eq!(normalize_user_code("bcdf-ghjk"), "BCDF-GHJK");
assert_eq!(normalize_user_code("bcdf ghjk"), "BCDF-GHJK");
assert_eq!(normalize_user_code("BCDFGHJK"), "BCDF-GHJK");
assert_eq!(normalize_user_code(" bcdfghjk "), "BCDF-GHJK");
}
#[test]
fn generate_user_code_is_wellformed() {
for _ in 0..50 {
let c = generate_user_code();
assert_eq!(c.len(), 9);
assert_eq!(&c[4..5], "-");
for ch in c.chars().filter(|c| *c != '-') {
assert!(USER_CODE_ALPHABET.contains(&(ch as u8)));
}
}
}
#[test]
fn resolve_tool_known_registry_id_keeps_display_name() {
let (id, display) = resolve_tool("claude-code").unwrap();
assert_eq!(id, "claude-code");
assert_eq!(display, "Claude Code");
}
#[test]
fn resolve_tool_unregistered_tool_is_sanitized() {
let (id, display) = resolve_tool("My Editor").unwrap();
assert_eq!(id, "my-editor");
assert_eq!(display, "My Editor");
}
#[test]
fn resolve_tool_rejects_reserved_words() {
for reserved in ["admin", "system"] {
assert!(
resolve_tool(reserved).is_err(),
"{reserved} is a reserved tool id"
);
}
}
#[test]
fn resolve_tool_rejects_empty_or_only_symbols() {
assert!(resolve_tool("").is_err());
assert!(resolve_tool(" ").is_err());
}
mod credential_resolution {
use super::*;
struct Fixture {
db: DbPool,
owner_id: i64,
bot_id: i64,
token: String,
}
fn fixture() -> Fixture {
let db = crate::db::open_memory().unwrap();
let (owner_id, bot_id) = {
let conn = db.write().unwrap();
let owner = crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "owner".into(),
email: "owner@test.local".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
let bot = crate::db::queries::users::create_bot_user(
&conn,
owner.id,
"zed-owner",
"Zed",
Some("zed"),
)
.unwrap();
conn.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris)
VALUES ('c', 'Test', '[\"http://localhost\"]')",
[],
)
.unwrap();
(owner.id, bot.id)
};
let token = insert_token(&db, "bound", Some(bot_id));
Fixture {
db,
owner_id,
bot_id,
token,
}
}
fn insert_token(db: &DbPool, suffix: &str, user_id: Option<i64>) -> String {
let token = format!("lific_at_{suffix}");
let hash = sha256_hex(token.as_bytes());
let expires = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
db.write()
.unwrap()
.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, user_id)
VALUES (?1, 'c', ?2, 'mcp', ?3)",
params![hash, expires, user_id],
)
.unwrap();
token
}
#[test]
fn a_live_bound_token_resolves_to_its_user() {
let f = fixture();
assert_eq!(
resolve_oauth_credential(&f.db, &f.token),
Ok(OAuthCredential::Bound(crate::db::models::AuthUser {
id: f.bot_id,
username: "zed-owner".into(),
display_name: "Zed".into(),
is_admin: false,
})),
);
}
#[test]
fn a_bound_token_never_degrades_to_unbound() {
for (label, mutate) in [
(
"revoked",
Box::new(|f: &Fixture| {
f.db.write()
.unwrap()
.execute("UPDATE oauth_tokens SET revoked = 1", [])
.unwrap();
}) as Box<dyn Fn(&Fixture)>,
),
(
"expired",
Box::new(|f: &Fixture| {
let past = (chrono::Utc::now() - chrono::Duration::minutes(1)).to_rfc3339();
f.db.write()
.unwrap()
.execute("UPDATE oauth_tokens SET expires_at = ?1", params![past])
.unwrap();
}),
),
(
"bot deleted",
Box::new(|f: &Fixture| {
f.db.write()
.unwrap()
.execute("DELETE FROM users WHERE id = ?1", params![f.bot_id])
.unwrap();
}),
),
(
"owner deactivated",
Box::new(|f: &Fixture| {
let conn = f.db.write().unwrap();
crate::db::queries::users::create_user(
&conn,
&crate::db::models::CreateUser {
username: "spare".into(),
email: "spare@test.local".into(),
password: "testpassword1".into(),
display_name: None,
is_admin: true,
is_bot: false,
},
)
.unwrap();
crate::db::queries::users::set_active(&conn, f.owner_id, false).unwrap();
}),
),
] {
let f = fixture();
mutate(&f);
let outcome = resolve_oauth_credential(&f.db, &f.token);
assert!(
outcome.is_err(),
"{label}: must not authenticate, got {outcome:?}"
);
assert_ne!(
outcome,
Ok(OAuthCredential::LegacyUnbound),
"{label}: a dead binding must never read as unbound, which is the operator"
);
}
}
#[test]
fn a_genuinely_unbound_legacy_token_still_resolves_to_the_operator_fallback() {
let f = fixture();
let legacy = insert_token(&f.db, "legacy", None);
assert_eq!(
resolve_oauth_credential(&f.db, &legacy),
Ok(OAuthCredential::LegacyUnbound)
);
}
#[test]
fn an_unknown_or_wrong_shaped_token_is_invalid() {
let f = fixture();
for token in ["lific_at_never-issued", "lific_sess_wrong-shape", ""] {
assert_eq!(
resolve_oauth_credential(&f.db, token),
Err(OAuthReject::Invalid),
"{token}"
);
}
}
#[tokio::test]
async fn a_revoked_bot_token_is_refused_by_the_middleware_not_promoted() {
use tower::ServiceExt;
let f = fixture();
let auth_state = crate::auth::AuthState {
db: f.db.clone(),
manager: crate::auth::create_key_manager().unwrap(),
public_url: "https://example.com".into(),
required: true,
};
let app = crate::api::router(f.db.clone(), &[])
.layer(axum::Extension(crate::realtime::RealtimeHub::new()))
.layer(axum::Extension(crate::config::AuthConfig {
allow_signup: true,
required: true,
secure_cookies: false,
}))
.layer(axum::middleware::from_fn_with_state(
auth_state,
crate::auth::require_api_key,
));
let call = |token: String| {
let app = app.clone();
async move {
app.oneshot(
Request::builder()
.uri("/api/auth/me")
.header("authorization", format!("Bearer {token}"))
.body(axum::body::Body::empty())
.unwrap(),
)
.await
.unwrap()
}
};
assert_eq!(call(f.token.clone()).await.status(), StatusCode::OK);
f.db.write()
.unwrap()
.execute("UPDATE oauth_tokens SET revoked = 1", [])
.unwrap();
let resp = call(f.token.clone()).await;
assert_eq!(
resp.status(),
StatusCode::UNAUTHORIZED,
"a revoked token must not authenticate at all, let alone as the operator"
);
}
}
mod expiry_is_compared_as_a_datetime {
use super::*;
fn rfc3339_from_now(minutes: i64) -> String {
(chrono::Utc::now() + chrono::Duration::minutes(minutes)).to_rfc3339()
}
fn seeded_db() -> DbPool {
let db = crate::db::open_memory().unwrap();
db.write()
.unwrap()
.execute(
"INSERT INTO oauth_clients (client_id, client_name, redirect_uris)
VALUES ('c', 'Test', '[\"http://localhost\"]')",
[],
)
.unwrap();
db
}
fn insert_token(db: &DbPool, name: &str, minutes: i64) -> String {
let token = format!("lific_at_{name}");
db.write()
.unwrap()
.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope)
VALUES (?1, 'c', ?2, 'mcp')",
params![sha256_hex(token.as_bytes()), rfc3339_from_now(minutes)],
)
.unwrap();
token
}
#[test]
fn an_access_token_expiring_in_a_minute_is_still_valid() {
let db = seeded_db();
let token = insert_token(&db, "live", 1);
assert!(resolve_oauth_credential(&db, &token).is_ok());
}
#[test]
fn an_access_token_that_expired_a_minute_ago_is_refused() {
let db = seeded_db();
let token = insert_token(&db, "dead", -1);
assert_eq!(
resolve_oauth_credential(&db, &token),
Err(OAuthReject::Invalid)
);
}
fn insert_code(db: &DbPool, code: &str, minutes: i64) {
db.write()
.unwrap()
.execute(
"INSERT INTO oauth_codes
(code, client_id, redirect_uri, code_challenge, expires_at, user_id)
VALUES (?1, 'c', 'http://localhost', 'x', ?2, NULL)",
params![code, rfc3339_from_now(minutes)],
)
.unwrap();
}
fn code_is_visible(db: &DbPool, code: &str) -> bool {
db.read()
.unwrap()
.query_row(
"SELECT 1 FROM oauth_codes
WHERE code = ?1 AND datetime(expires_at) > datetime('now')",
params![code],
|_| Ok(()),
)
.is_ok()
}
#[test]
fn an_authorization_code_expiring_in_a_minute_is_still_exchangeable() {
let db = seeded_db();
insert_code(&db, "live-code", 1);
assert!(code_is_visible(&db, "live-code"));
}
#[test]
fn an_authorization_code_that_expired_a_minute_ago_is_gone() {
let db = seeded_db();
insert_code(&db, "dead-code", -1);
assert!(!code_is_visible(&db, "dead-code"));
}
fn insert_device_code(db: &DbPool, hash: &str, user_code: &str, minutes: i64) {
db.write()
.unwrap()
.execute(
"INSERT INTO oauth_device_codes
(device_code_hash, user_code, expires_at, status)
VALUES (?1, ?2, ?3, 'pending')",
params![hash, user_code, rfc3339_from_now(minutes)],
)
.unwrap();
}
fn device_codes(db: &DbPool) -> i64 {
db.read()
.unwrap()
.query_row("SELECT COUNT(*) FROM oauth_device_codes", [], |r| r.get(0))
.unwrap()
}
#[test]
fn the_device_code_sweep_removes_only_the_expired_one() {
let db = seeded_db();
insert_device_code(&db, "live", "BCDF-GHJK", 1);
insert_device_code(&db, "dead", "BCDF-GHJL", -1);
assert_eq!(device_codes(&db), 2);
let conn = db.write().unwrap();
cleanup_expired_device_codes(&conn).unwrap();
assert_eq!(device_codes(&db), 1, "only the expired grant is swept");
let survivor: String = db
.read()
.unwrap()
.query_row("SELECT device_code_hash FROM oauth_device_codes", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(survivor, "live");
}
#[test]
fn only_an_unexpired_device_code_can_be_approved() {
let db = seeded_db();
insert_device_code(&db, "live", "BCDF-GHJK", 1);
insert_device_code(&db, "dead", "BCDF-GHJL", -1);
let approve = |user_code: &str| -> usize {
db.write()
.unwrap()
.execute(
"UPDATE oauth_device_codes SET status = 'approved'
WHERE user_code = ?1 AND status = 'pending'
AND datetime(expires_at) > datetime('now')",
params![user_code],
)
.unwrap()
};
assert_eq!(approve("BCDF-GHJK"), 1, "the live grant is approvable");
assert_eq!(approve("BCDF-GHJL"), 0, "the expired grant is not");
}
}
mod grant_lifetime {
use super::*;
const VERIFIER: &str = "test_verifier_abcdefghijklmnopqrstuvwxyz_0123456789";
fn challenge() -> String {
base64_url_encode(&Sha256::digest(VERIFIER.as_bytes()))
}
fn authorization_approval_body(client_id: &str, credential: &str) -> String {
let challenge = challenge();
let csrf = AuthorizationRequest {
client_id,
redirect_uri: "http://localhost/callback",
response_type: "code",
state: None,
code_challenge: Some(&challenge),
code_challenge_method: Some("S256"),
scope: Some(OAUTH_SCOPE),
}
.csrf_token(credential);
format!(
"client_id={}&redirect_uri={}&response_type=code&code_challenge={}&code_challenge_method=S256&scope=mcp&csrf_token={}&tool=claude-code&decision=approve",
client_id,
urlencoding::encode("http://localhost/callback"),
urlencoding::encode(&challenge),
urlencoding::encode(&csrf),
)
}
fn owner_id(db: &DbPool) -> i64 {
db.read()
.unwrap()
.query_row(
"SELECT id FROM users WHERE username = 'oauthtest'",
[],
|r| r.get(0),
)
.unwrap()
}
async fn approve_code(app: &Router, client_id: &str, session: &str) -> String {
let body = authorization_approval_body(client_id, session);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert!(
resp.status().is_redirection(),
"approve should redirect, got {}",
resp.status()
);
let location = resp
.headers()
.get("location")
.unwrap()
.to_str()
.unwrap()
.to_string();
location
.split("code=")
.nth(1)
.unwrap()
.split('&')
.next()
.unwrap()
.to_string()
}
async fn exchange_code(
app: &Router,
client_id: &str,
code: &str,
) -> (StatusCode, serde_json::Value) {
let body = format!(
"grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&code_verifier={}",
code,
urlencoding::encode("http://localhost/callback"),
client_id,
VERIFIER,
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/token")
.header("content-type", "application/x-www-form-urlencoded")
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
let status = resp.status();
let bytes = resp.into_body().collect().await.unwrap().to_bytes();
(
status,
serde_json::from_slice(&bytes).unwrap_or_else(|_| serde_json::json!({})),
)
}
#[tokio::test]
async fn an_ordinary_authorization_code_flow_still_works() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let code = approve_code(&app, &client_id, &session).await;
let (status, body) = exchange_code(&app, &client_id, &code).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert!(
body["access_token"]
.as_str()
.unwrap()
.starts_with("lific_at_")
);
}
#[tokio::test]
async fn a_code_approved_before_a_lockdown_cannot_mint_after_it() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let code = approve_code(&app, &client_id, &session).await;
{
let conn = db.write().unwrap();
crate::db::queries::users::lock_down_account(&conn, owner_id(&db)).unwrap();
}
let (status, body) = exchange_code(&app, &client_id, &code).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "invalid_grant");
let tokens: i64 = db
.read()
.unwrap()
.query_row("SELECT COUNT(*) FROM oauth_tokens", [], |r| r.get(0))
.unwrap();
assert_eq!(tokens, 0, "no token was minted");
}
#[tokio::test]
async fn a_code_whose_bot_owner_went_inactive_cannot_be_exchanged() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let code = approve_code(&app, &client_id, &session).await;
{
let conn = db.write().unwrap();
crate::db::queries::users::set_active(&conn, owner_id(&db), false).unwrap();
}
let (status, body) = exchange_code(&app, &client_id, &code).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "invalid_grant");
assert_eq!(
body["error_description"],
"authorizing user is no longer active"
);
}
#[tokio::test]
async fn a_legacy_unbound_code_cannot_be_exchanged() {
let (app, db) = test_oauth_app();
let client_id = register_client_helper(&app, "http://localhost/callback").await;
db.write()
.unwrap()
.execute(
"INSERT INTO oauth_codes
(code, client_id, redirect_uri, code_challenge, code_challenge_method,
expires_at, scope, user_id)
VALUES ('legacy-code', ?1, 'http://localhost/callback', ?2, 'S256',
?3, 'mcp', NULL)",
params![
client_id,
challenge(),
(chrono::Utc::now() + chrono::Duration::minutes(10)).to_rfc3339(),
],
)
.unwrap();
let (status, body) = exchange_code(&app, &client_id, "legacy-code").await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "invalid_grant");
let conn = db.read().unwrap();
let tokens: i64 = conn
.query_row("SELECT COUNT(*) FROM oauth_tokens", [], |r| r.get(0))
.unwrap();
assert_eq!(tokens, 0, "no unbound token was minted");
let used: i64 = conn
.query_row(
"SELECT used FROM oauth_codes WHERE code = 'legacy-code'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(used, 0, "the refused exchange rolled back cleanly");
}
#[tokio::test]
async fn a_legacy_unbound_device_approval_cannot_be_exchanged() {
let (app, db) = test_oauth_app();
let v = request_device_code(&app, Some("My CLI")).await;
db.write()
.unwrap()
.execute(
"UPDATE oauth_device_codes
SET status = 'approved', user_id = NULL, last_polled_at = NULL",
[],
)
.unwrap();
let (status, body) = poll_device_token(&app, v["device_code"].as_str().unwrap()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "invalid_grant");
let conn = db.read().unwrap();
let tokens: i64 = conn
.query_row("SELECT COUNT(*) FROM oauth_tokens", [], |r| r.get(0))
.unwrap();
assert_eq!(tokens, 0, "no unbound token was minted");
let status: String = conn
.query_row("SELECT status FROM oauth_device_codes", [], |r| r.get(0))
.unwrap();
assert_eq!(
status, "approved",
"the grant is not consumed by a refused exchange"
);
let polled: Option<String> = conn
.query_row("SELECT last_polled_at FROM oauth_device_codes", [], |r| {
r.get(0)
})
.unwrap();
assert!(polled.is_some());
}
#[tokio::test]
async fn an_aged_session_may_not_approve_an_authorization_request() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
db.write()
.unwrap()
.execute(
"UPDATE sessions SET created_at = datetime('now', '-16 minutes')",
[],
)
.unwrap();
let body = authorization_approval_body(&client_id, &session);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let page = String::from_utf8(
resp.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
for phrase in [
"15 minutes",
"sign out",
"Sign back in",
"Start the connection again from your MCP client",
"nothing has changed",
] {
assert!(
page.to_lowercase().contains(&phrase.to_lowercase()),
"the page must say {phrase:?}: {page}"
);
}
for forbidden in ["automatically", "will be retried", "try again shortly"] {
assert!(
!page.to_lowercase().contains(forbidden),
"the page must not promise a retry it does not do: {page}"
);
}
let conn = db.read().unwrap();
let codes: i64 = conn
.query_row("SELECT COUNT(*) FROM oauth_codes", [], |r| r.get(0))
.unwrap();
assert_eq!(codes, 0, "no code was issued");
let bots: i64 = conn
.query_row("SELECT COUNT(*) FROM users WHERE is_bot = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(bots, 0, "no bot was minted either");
assert!(crate::db::queries::users::validate_session(&conn, &session).is_ok());
}
#[tokio::test]
async fn an_aged_session_may_not_approve_a_device() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let v = request_device_code(&app, Some("My CLI")).await;
db.write()
.unwrap()
.execute(
"UPDATE sessions SET created_at = datetime('now', '-16 minutes')",
[],
)
.unwrap();
let user_code = v["user_code"].as_str().unwrap();
let (csrf, confirmation_token) = device_confirmation(&app, &session, user_code).await;
let body = format!(
"user_code={}&decision=approve&csrf_token={}&confirmation_token={}&tool=claude-code",
urlencoding::encode(user_code),
urlencoding::encode(&csrf),
urlencoding::encode(&confirmation_token),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let conn = db.read().unwrap();
let pending: i64 = conn
.query_row(
"SELECT COUNT(*) FROM oauth_device_codes WHERE status = 'pending'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(pending, 1, "the grant is still waiting, not approved");
let bots: i64 = conn
.query_row("SELECT COUNT(*) FROM users WHERE is_bot = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(bots, 0);
}
#[tokio::test]
async fn an_aged_session_may_still_deny_a_device() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let v = request_device_code(&app, Some("Unknown device")).await;
db.write()
.unwrap()
.execute(
"UPDATE sessions SET created_at = datetime('now', '-16 minutes')",
[],
)
.unwrap();
let body = format!(
"user_code={}&decision=deny&csrf_token={}",
urlencoding::encode(v["user_code"].as_str().unwrap()),
urlencoding::encode(&generate_csrf_token(&session)),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let conn = db.read().unwrap();
let denied: i64 = conn
.query_row(
"SELECT COUNT(*) FROM oauth_device_codes WHERE status = 'denied'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(denied, 1, "the grant is refused");
let bots: i64 = conn
.query_row("SELECT COUNT(*) FROM users WHERE is_bot = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(bots, 0, "denying resolves no tool and mints no bot");
drop(conn);
db.write()
.unwrap()
.execute("UPDATE oauth_device_codes SET last_polled_at = NULL", [])
.unwrap();
let (status, body) = poll_device_token(&app, v["device_code"].as_str().unwrap()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "access_denied");
}
#[tokio::test]
async fn a_revoked_session_may_not_deny_a_device() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let v = request_device_code(&app, None).await;
db.write()
.unwrap()
.execute("DELETE FROM sessions", [])
.unwrap();
let body = format!(
"user_code={}&decision=deny&csrf_token={}",
urlencoding::encode(v["user_code"].as_str().unwrap()),
urlencoding::encode(&generate_csrf_token(&session)),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let pending: i64 = db
.read()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM oauth_device_codes WHERE status = 'pending'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(pending, 1);
}
#[tokio::test]
async fn an_oauth_token_may_not_approve_an_authorization_request() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
let access_token = {
let token = "lific_at_tool-held-token".to_string();
let hash = crate::auth::sha256_hex(token.as_bytes());
let expires = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let conn = db.write().unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, user_id)
VALUES (?1, ?2, ?3, 'mcp', ?4)",
params![hash, client_id, expires, owner_id(&db)],
)
.unwrap();
token
};
let _ = session;
let body = authorization_approval_body(&client_id, &access_token);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("authorization", format!("Bearer {access_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let codes: i64 = db
.read()
.unwrap()
.query_row("SELECT COUNT(*) FROM oauth_codes", [], |r| r.get(0))
.unwrap();
assert_eq!(codes, 0, "no code was issued");
}
#[tokio::test]
async fn a_revoked_session_cannot_approve_and_mints_no_bot() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let client_id = register_client_helper(&app, "http://localhost/callback").await;
db.write()
.unwrap()
.execute("DELETE FROM sessions", [])
.unwrap();
let body = authorization_approval_body(&client_id, &session);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/authorize")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let conn = db.read().unwrap();
let codes: i64 = conn
.query_row("SELECT COUNT(*) FROM oauth_codes", [], |r| r.get(0))
.unwrap();
assert_eq!(codes, 0);
let bots: i64 = conn
.query_row("SELECT COUNT(*) FROM users WHERE is_bot = 1", [], |r| {
r.get(0)
})
.unwrap();
assert_eq!(bots, 0, "a refused approval mints no bot either");
}
async fn device_confirmation(
app: &Router,
session: &str,
user_code: &str,
) -> (String, String) {
let csrf = generate_csrf_token(session);
let lookup_body = format!(
"user_code={}&decision=approve&csrf_token={}&tool=claude-code",
urlencoding::encode(user_code),
urlencoding::encode(&csrf),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session}"))
.body(axum::body::Body::from(lookup_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
let page = String::from_utf8(
resp.into_body()
.collect()
.await
.unwrap()
.to_bytes()
.to_vec(),
)
.unwrap();
let confirmation_token = page
.split("name=\"confirmation_token\" value=\"")
.nth(1)
.and_then(|rest| rest.split('"').next())
.expect("confirmation token")
.to_string();
(csrf, confirmation_token)
}
async fn approve_device(app: &Router, db: &DbPool, session: &str, user_code: &str) {
let (csrf, confirmation_token) = device_confirmation(app, session, user_code).await;
let confirm_body = format!(
"user_code={}&decision=approve&csrf_token={}&confirmation_token={}&tool=claude-code",
urlencoding::encode(user_code),
urlencoding::encode(&csrf),
urlencoding::encode(&confirmation_token),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("cookie", format!("lific_token={session}"))
.body(axum::body::Body::from(confirm_body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::OK);
db.write()
.unwrap()
.execute("UPDATE oauth_device_codes SET last_polled_at = NULL", [])
.unwrap();
}
#[tokio::test]
async fn an_ordinary_device_flow_still_works() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let v = request_device_code(&app, Some("My CLI")).await;
let client_id: String = db
.read()
.unwrap()
.query_row(
"SELECT client_id FROM oauth_device_codes WHERE user_code = ?1",
params![v["user_code"].as_str().unwrap()],
|row| row.get(0),
)
.unwrap();
approve_device(&app, &db, &session, v["user_code"].as_str().unwrap()).await;
let (status, body) = poll_device_token(&app, v["device_code"].as_str().unwrap()).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert!(
body["access_token"]
.as_str()
.unwrap()
.starts_with("lific_at_")
);
let token_client_id: String = db
.read()
.unwrap()
.query_row("SELECT client_id FROM oauth_tokens", [], |row| row.get(0))
.unwrap();
assert_eq!(token_client_id, client_id);
}
#[tokio::test]
async fn a_device_approved_before_a_lockdown_is_denied_after_it() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let v = request_device_code(&app, Some("My CLI")).await;
approve_device(&app, &db, &session, v["user_code"].as_str().unwrap()).await;
{
let conn = db.write().unwrap();
crate::db::queries::users::lock_down_account(&conn, owner_id(&db)).unwrap();
}
let (status, body) = poll_device_token(&app, v["device_code"].as_str().unwrap()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "access_denied", "the denial wins cleanly");
let tokens: i64 = db
.read()
.unwrap()
.query_row("SELECT COUNT(*) FROM oauth_tokens", [], |r| r.get(0))
.unwrap();
assert_eq!(tokens, 0);
}
#[tokio::test]
async fn a_device_bound_to_a_bot_with_an_inactive_owner_cannot_exchange() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let v = request_device_code(&app, Some("My CLI")).await;
approve_device(&app, &db, &session, v["user_code"].as_str().unwrap()).await;
{
let conn = db.write().unwrap();
crate::db::queries::users::set_active(&conn, owner_id(&db), false).unwrap();
}
let (status, body) = poll_device_token(&app, v["device_code"].as_str().unwrap()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body["error"], "invalid_grant");
assert_eq!(
body["error_description"],
"authorizing user is no longer active"
);
}
#[tokio::test]
async fn an_oauth_token_may_not_approve_a_device() {
let (app, db) = test_oauth_app();
let session = create_test_session(&db);
let v = request_device_code(&app, Some("My CLI")).await;
let access_token = {
let token = "lific_at_device-approver".to_string();
let hash = crate::auth::sha256_hex(token.as_bytes());
let expires = (chrono::Utc::now() + chrono::Duration::hours(1)).to_rfc3339();
let conn = db.write().unwrap();
conn.execute(
"INSERT OR IGNORE INTO oauth_clients (client_id, client_name, redirect_uris)
VALUES ('device', 'Device Authorization', '[]')",
[],
)
.unwrap();
conn.execute(
"INSERT INTO oauth_tokens (access_token, client_id, expires_at, scope, user_id)
VALUES (?1, 'device', ?2, 'mcp', ?3)",
params![hash, expires, owner_id(&db)],
)
.unwrap();
token
};
let _ = session;
let body = format!(
"user_code={}&decision=approve&csrf_token={}&tool=claude-code",
urlencoding::encode(v["user_code"].as_str().unwrap()),
urlencoding::encode(&generate_csrf_token(&access_token)),
);
let resp = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/oauth/device")
.header("content-type", "application/x-www-form-urlencoded")
.header("authorization", format!("Bearer {access_token}"))
.body(axum::body::Body::from(body))
.unwrap(),
)
.await
.unwrap();
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
let still_pending: i64 = db
.read()
.unwrap()
.query_row(
"SELECT COUNT(*) FROM oauth_device_codes WHERE status = 'pending'",
[],
|r| r.get(0),
)
.unwrap();
assert_eq!(still_pending, 1);
}
}
}