Menu
Public repository / main
AkurAI-PassVault / crates/passvault-mcp/src/oauth.rs
Latest change fe9611075d08d4b881b528d06a3e3ad5045ec657 - oauth: fall back to AKURAI_PUBLIC_URL like the deployed build by Ólafur Búi Ólafsson
//! OAuth 2.0 protected-resource support for the MCP endpoint.
//!
//! PassVault's `/mcp` accepts EdDSA access tokens from the AkurAI IdP when
//! their `aud` names PassVault (RFC 9728 metadata + RFC 6750 challenges).
//! Signatures are verified locally against the IdP JWKS; the vault owner
//! allowlist is then enforced on the userinfo email for the same token.
use crate::auth::{request_idp, IdpConfig};
use akurai_json::Value;
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
use ed25519_dalek::{Signature, VerifyingKey};
use parking_lot::Mutex;
use std::env;
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
/// Audience the IdP puts on tokens minted for PassVault.
pub const AUDIENCE: &str = "akurai-passvault";
/// Public PKCE client the IdP registers for agents talking to PassVault.
pub const AGENT_CLIENT: &str = "akurai-agent-passvault";
const RESOURCE_NAME: &str = "AkurAI PassVault MCP";
const SCOPES: [&str; 5] = ["openid", "profile", "email", "groups", "offline_access"];
/// Scope needed to learn the caller's email for the owner allowlist.
const REQUIRED_SCOPE: &str = "email";
const JWKS_TTL: Duration = Duration::from_secs(600);
// ---------------------------------------------------------------------------
// Protected resource metadata and challenges
// ---------------------------------------------------------------------------
fn base(base_url: &str) -> &str {
base_url.trim_end_matches('/')
}
pub fn resource_url(base_url: &str) -> String {
format!("{}/mcp", base(base_url))
}
pub fn resource_metadata_url(base_url: &str) -> String {
format!(
"{}/.well-known/oauth-protected-resource/mcp",
base(base_url)
)
}
fn strings(items: &[&str]) -> Value {
Value::Array(items.iter().map(|s| Value::Str((*s).into())).collect())
}
pub fn protected_resource_metadata(base_url: &str, issuer: &str) -> String {
Value::Object(vec![
("resource".into(), Value::Str(resource_url(base_url))),
(
"authorization_servers".into(),
strings(&[issuer.trim_end_matches('/')]),
),
("scopes_supported".into(), strings(&SCOPES)),
("bearer_methods_supported".into(), strings(&["header"])),
("resource_name".into(), Value::Str(RESOURCE_NAME.into())),
])
.to_json()
}
/// `WWW-Authenticate` value; `invalid` marks a presented-but-rejected token.
pub fn www_authenticate(base_url: &str, invalid: bool) -> String {
let url = resource_metadata_url(base_url);
if invalid {
format!("Bearer error=\"invalid_token\", resource_metadata=\"{url}\"")
} else {
format!("Bearer resource_metadata=\"{url}\"")
}
}
/// Browsers must be same-origin; non-browser clients send no Origin.
pub fn origin_allowed(origin: Option<&str>, base_url: &str) -> bool {
origin.is_none_or(|o| o == base(base_url))
}
// ---------------------------------------------------------------------------
// Access-token validation
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, PartialEq)]
pub struct TokenGrant {
pub sub: String,
pub scopes: Vec<String>,
}
fn audience_contains(aud: Option<&Value>, wanted: &str) -> bool {
match aud {
Some(Value::Str(s)) => s == wanted,
Some(Value::Array(items)) => items.iter().any(|v| v.as_str() == Some(wanted)),
_ => false,
}
}
fn decode_json(segment: &str) -> Option<Value> {
let bytes = URL_SAFE_NO_PAD.decode(segment).ok()?;
akurai_json::parse(std::str::from_utf8(&bytes).ok()?).ok()
}
fn find_key(jwks: &Value, kid: &str) -> Option<VerifyingKey> {
let Some(Value::Array(keys)) = jwks.get("keys") else {
return None;
};
let jwk = keys
.iter()
.find(|k| k.get("kid").and_then(Value::as_str) == Some(kid))?;
if jwk.get("kty").and_then(Value::as_str) != Some("OKP")
|| jwk.get("crv").and_then(Value::as_str) != Some("Ed25519")
{
return None;
}
let x = URL_SAFE_NO_PAD
.decode(jwk.get("x").and_then(Value::as_str)?)
.ok()?;
VerifyingKey::from_bytes(&x.try_into().ok()?).ok()
}
pub fn key_id(token: &str) -> Option<String> {
let header = decode_json(token.split('.').next()?)?;
header.get("kid").and_then(Value::as_str).map(String::from)
}
pub fn validate_access_token(
token: &str,
jwks: &Value,
issuer: &str,
accepted_audiences: &[&str],
now: u64,
) -> Result<TokenGrant, &'static str> {
let mut parts = token.split('.');
let (Some(header), Some(payload), Some(signature), None) =
(parts.next(), parts.next(), parts.next(), parts.next())
else {
return Err("access token is not a JWT");
};
let header = decode_json(header).ok_or("invalid JWT header")?;
if header.get("alg").and_then(Value::as_str) != Some("EdDSA") {
return Err("unexpected JWT algorithm");
}
let kid = header
.get("kid")
.and_then(Value::as_str)
.ok_or("missing JWT key id")?;
let key = find_key(jwks, kid).ok_or("unknown JWT key id")?;
let signature = URL_SAFE_NO_PAD
.decode(signature)
.ok()
.and_then(|s| Signature::from_slice(&s).ok())
.ok_or("invalid JWT signature")?;
let signed = &token[..token.rfind('.').unwrap_or_default()];
key.verify_strict(signed.as_bytes(), &signature)
.map_err(|_| "invalid JWT signature")?;
let claims = decode_json(payload).ok_or("invalid JWT claims")?;
if claims.get("iss").and_then(Value::as_str) != Some(issuer.trim_end_matches('/')) {
return Err("access token issuer mismatch");
}
let exp = claims.get("exp").and_then(Value::as_i64).unwrap_or(0);
if exp <= now as i64 {
return Err("access token expired");
}
if claims.get("nbf").and_then(Value::as_i64).unwrap_or(0) > now as i64 {
return Err("access token not yet valid");
}
if !accepted_audiences
.iter()
.any(|a| audience_contains(claims.get("aud"), a))
{
return Err("access token audience mismatch");
}
let sub = claims.get("sub").and_then(Value::as_str).unwrap_or("");
// client_credentials tokens name a client as subject; they have no owner.
if claims.get("client_id").is_some() || sub.is_empty() || accepted_audiences.contains(&sub) {
return Err("invalid user access token");
}
let scopes: Vec<String> = claims
.get("scope")
.and_then(Value::as_str)
.unwrap_or("")
.split_whitespace()
.map(String::from)
.collect();
if !scopes.iter().any(|s| s == REQUIRED_SCOPE) {
return Err("access token lacks the email scope");
}
Ok(TokenGrant {
sub: sub.into(),
scopes,
})
}
/// Owner email from the IdP userinfo response for the same token.
pub fn email_from_userinfo(userinfo: &Value, sub: &str) -> Result<String, &'static str> {
if userinfo.get("sub").and_then(Value::as_str) != Some(sub) {
return Err("userinfo identity mismatch");
}
if userinfo.get("email_verified").and_then(Value::as_bool) == Some(false) {
return Err("verified email is required");
}
userinfo
.get("email")
.and_then(Value::as_str)
.map(|e| e.trim().to_lowercase())
.filter(|e| e.contains('@') && !e.chars().any(char::is_control))
.ok_or("userinfo omitted email")
}
// ---------------------------------------------------------------------------
// Runtime verifier (JWKS cache + userinfo over the loopback IdP link)
// ---------------------------------------------------------------------------
pub struct OAuthVerifier {
idp: IdpConfig,
base_url: String,
jwks: Mutex<Option<(Instant, Value)>>,
}
impl OAuthVerifier {
pub fn new(idp: IdpConfig) -> Self {
let base_url = env::var("AKURAI_PASSVAULT_PUBLIC_URL")
.or_else(|_| env::var("AKURAI_PUBLIC_URL"))
.unwrap_or_else(|_| "https://akurai-passvault.olibuijr.com".into());
Self {
idp,
base_url,
jwks: Mutex::new(None),
}
}
pub fn base_url(&self) -> &str {
&self.base_url
}
pub fn metadata(&self) -> String {
protected_resource_metadata(&self.base_url, &self.idp.issuer)
}
fn fetch_jwks(&self, force: bool) -> Result<Value, &'static str> {
let mut cache = self.jwks.lock();
if let Some((at, jwks)) = cache.as_ref() {
if !force && at.elapsed() < JWKS_TTL {
return Ok(jwks.clone());
}
}
let body = request_idp(&self.idp, "GET", "/jwks", None).map_err(|_| "JWKS unavailable")?;
let jwks = akurai_json::parse(&body).map_err(|_| "JWKS unavailable")?;
*cache = Some((Instant::now(), jwks.clone()));
Ok(jwks)
}
/// Validates a bearer token and returns the allowlisted owner email.
pub fn authenticate(&self, token: &str) -> Result<String, &'static str> {
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or_default();
let accepted = [AUDIENCE, AGENT_CLIENT];
let issuer = &self.idp.issuer;
let mut jwks = self.fetch_jwks(false)?;
if key_id(token).is_some_and(|kid| find_key(&jwks, &kid).is_none()) {
jwks = self.fetch_jwks(true)?; // key rotation
}
let grant = validate_access_token(token, &jwks, issuer, &accepted, now)?;
let userinfo = request_idp(&self.idp, "GET", "/userinfo", Some(token))
.ok()
.and_then(|body| akurai_json::parse(&body).ok())
.ok_or("userinfo unavailable")?;
let email = email_from_userinfo(&userinfo, &grant.sub)?;
if !self.idp.allows(&email) {
return Err("IDP user is not allowed to access PassVault");
}
Ok(email)
}
}
#[cfg(test)]
mod tests {
use super::*;
use ed25519_dalek::{Signer, SigningKey};
const ISS: &str = "https://auth.example";
const BASE: &str = "https://vault.example";
const NOW: u64 = 1_800_000_000;
fn signing_key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
fn jwks() -> Value {
let x = URL_SAFE_NO_PAD.encode(signing_key().verifying_key().to_bytes());
akurai_json::parse(&format!(
r#"{{"keys":[{{"kty":"OKP","crv":"Ed25519","alg":"EdDSA","kid":"k1","x":"{x}"}}]}}"#
))
.unwrap()
}
fn sign_with(header: &str, claims: &str) -> String {
let signed = format!(
"{}.{}",
URL_SAFE_NO_PAD.encode(header),
URL_SAFE_NO_PAD.encode(claims)
);
let sig = signing_key().sign(signed.as_bytes());
format!("{signed}.{}", URL_SAFE_NO_PAD.encode(sig.to_bytes()))
}
fn sign(claims: &str) -> String {
sign_with(r#"{"alg":"EdDSA","kid":"k1","typ":"JWT"}"#, claims)
}
fn claims(aud: &str, extra: &str) -> String {
format!(
r#"{{"iss":"{ISS}","sub":"user-1","aud":{aud},"exp":{},"scope":"openid profile email groups","tenant_id":"t1"{extra}}}"#,
NOW + 300
)
}
fn idp_aud() -> String {
format!(r#"["{AGENT_CLIENT}","{AUDIENCE}"]"#)
}
fn validate(token: &str) -> Result<TokenGrant, &'static str> {
validate_access_token(token, &jwks(), ISS, &[AUDIENCE, AGENT_CLIENT], NOW)
}
#[test]
fn metadata_names_resource_and_issuer() {
let meta = akurai_json::parse(&protected_resource_metadata(BASE, "https://auth.example/"))
.unwrap();
assert_eq!(
meta.get("resource").and_then(Value::as_str),
Some("https://vault.example/mcp")
);
assert_eq!(
meta.get("authorization_servers").unwrap().to_json(),
r#"["https://auth.example"]"#
);
assert!(meta
.get("scopes_supported")
.unwrap()
.to_json()
.contains("\"email\""));
assert_eq!(
meta.get("bearer_methods_supported").unwrap().to_json(),
r#"["header"]"#
);
}
#[test]
fn challenges_point_at_resource_metadata() {
let url = "https://vault.example/.well-known/oauth-protected-resource/mcp";
assert_eq!(
www_authenticate(BASE, false),
format!("Bearer resource_metadata=\"{url}\"")
);
assert_eq!(
www_authenticate(BASE, true),
format!("Bearer error=\"invalid_token\", resource_metadata=\"{url}\"")
);
}
#[test]
fn origin_must_be_absent_or_same_origin() {
assert!(origin_allowed(None, BASE));
assert!(origin_allowed(
Some("https://vault.example"),
"https://vault.example/"
));
assert!(!origin_allowed(Some("https://evil.example"), BASE));
}
#[test]
fn accepts_idp_token_whose_audience_array_names_passvault() {
let grant = validate(&sign(&claims(&idp_aud(), ""))).unwrap();
assert_eq!(grant.sub, "user-1");
assert!(grant.scopes.iter().any(|s| s == "email"));
}
#[test]
fn accepts_string_audience() {
assert!(validate(&sign(&claims(&format!("\"{AUDIENCE}\""), ""))).is_ok());
}
#[test]
fn rejects_other_audiences_issuers_and_expired_tokens() {
let other = sign(&claims(r#"["akurai-agent-mail","akurai-mail"]"#, ""));
assert_eq!(validate(&other), Err("access token audience mismatch"));
let token = sign(&claims(&idp_aud(), "").replace(ISS, "https://evil.example"));
assert_eq!(validate(&token), Err("access token issuer mismatch"));
let expired =
sign(&claims(&idp_aud(), "").replace(&(NOW + 300).to_string(), &NOW.to_string()));
assert_eq!(validate(&expired), Err("access token expired"));
let early = sign(&claims(&idp_aud(), &format!(r#","nbf":{}"#, NOW + 60)));
assert_eq!(validate(&early), Err("access token not yet valid"));
}
#[test]
fn rejects_client_credentials_and_missing_email_scope() {
let client = sign(&claims(&idp_aud(), r#","client_id":"svc""#));
assert_eq!(validate(&client), Err("invalid user access token"));
let no_email =
sign(&claims(&idp_aud(), "").replace("openid profile email groups", "openid"));
assert_eq!(
validate(&no_email),
Err("access token lacks the email scope")
);
}
#[test]
fn rejects_bad_signatures_keys_and_algorithms() {
let token = sign(&claims(&idp_aud(), ""));
let tampered = format!("{}x", &token[..token.len() - 1]);
assert!(validate(&tampered).is_err());
let forged_payload = {
let mut parts: Vec<&str> = token.split('.').collect();
let evil = URL_SAFE_NO_PAD.encode(claims(&idp_aud(), "").replace("user-1", "admin"));
parts[1] = &evil;
parts.join(".")
};
assert_eq!(validate(&forged_payload), Err("invalid JWT signature"));
let unknown = sign_with(r#"{"alg":"EdDSA","kid":"k9"}"#, &claims(&idp_aud(), ""));
assert_eq!(validate(&unknown), Err("unknown JWT key id"));
let none = sign_with(r#"{"alg":"none","kid":"k1"}"#, &claims(&idp_aud(), ""));
assert_eq!(validate(&none), Err("unexpected JWT algorithm"));
assert_eq!(validate("static-token"), Err("access token is not a JWT"));
}
#[test]
fn key_id_reads_header() {
assert_eq!(
key_id(&sign(&claims(&idp_aud(), ""))).as_deref(),
Some("k1")
);
assert_eq!(key_id("opaque"), None);
}
#[test]
fn userinfo_must_match_subject_and_carry_email() {
let ok = akurai_json::parse(
r#"{"sub":"user-1","email":" Owner@Example.com ","email_verified":true}"#,
)
.unwrap();
assert_eq!(
email_from_userinfo(&ok, "user-1").unwrap(),
"owner@example.com"
);
assert_eq!(
email_from_userinfo(&ok, "user-2"),
Err("userinfo identity mismatch")
);
let unverified =
akurai_json::parse(r#"{"sub":"user-1","email":"a@b.c","email_verified":false}"#)
.unwrap();
assert_eq!(
email_from_userinfo(&unverified, "user-1"),
Err("verified email is required")
);
let missing = akurai_json::parse(r#"{"sub":"user-1"}"#).unwrap();
assert_eq!(
email_from_userinfo(&missing, "user-1"),
Err("userinfo omitted email")
);
}
}