Menu
Public repository / ffb1635a956f9bd58bb79123927fa08f6a7ecb1e
AkurAI-Build / src/oauth.rs
Latest change ffb1635a956f9bd58bb79123927fa08f6a7ecb1e - MCP over HTTP as an OAuth-protected resource for the AkurAI IdP by Ólafur Búi Ólafsson
//! OAuth 2.0 protected-resource support for the HTTP MCP endpoint (`POST /mcp`).
//!
//! The AkurAI IdP issues EdDSA JWT access tokens to the public PKCE client
//! `akurai-agent-akurai-build`; `aud` is an array that also names this app's
//! audience. Tokens are verified locally against the IdP JWKS (cached, and
//! refetched once on an unknown `kid` to follow key rotation). Only user
//! tokens bound to a tenant are accepted. Metadata follows RFC 9728.
use std::{
process::Command,
sync::Mutex,
time::{Duration, Instant},
};
use jsonwebtoken::{
Algorithm, DecodingKey, Validation, decode, decode_header,
jwk::{AlgorithmParameters, EllipticCurve, JwkSet},
};
use serde::Deserialize;
use serde_json::{Value, json};
pub const DEFAULT_ISSUER: &str = "https://auth.olibuijr.com";
pub const DEFAULT_AUDIENCE: &str = "akurai-build";
const RESOURCE_NAME: &str = "AkurAI Build MCP";
const SCOPES: [&str; 5] = ["openid", "profile", "email", "groups", "offline_access"];
const JWKS_TTL: Duration = Duration::from_secs(600);
/// Why a presented bearer token was rejected. Never includes token material.
pub type Rejection = &'static str;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Grant {
pub subject: String,
pub tenant: String,
}
#[derive(Deserialize)]
struct AccessClaims {
#[serde(default)]
sub: String,
#[serde(default)]
aud: Value,
#[serde(default)]
tenant_id: Option<String>,
#[serde(default)]
client_id: Option<String>,
}
pub struct ResourceServer {
issuer: String,
audience: String,
public_origin: String,
jwks: Mutex<Option<(Instant, JwkSet)>>,
}
impl ResourceServer {
pub fn new(issuer: &str, audience: &str, public_origin: &str) -> Self {
Self {
issuer: issuer.trim_end_matches('/').to_owned(),
audience: audience.to_owned(),
public_origin: public_origin.trim_end_matches('/').to_owned(),
jwks: Mutex::new(None),
}
}
/// Preloads the key set; used by tests and to avoid a first-call fetch.
pub fn with_jwks(self, jwks: JwkSet) -> Self {
if let Ok(mut cache) = self.jwks.lock() {
*cache = Some((Instant::now(), jwks));
}
self
}
pub fn resource_url(&self) -> String {
format!("{}/mcp", self.public_origin)
}
pub fn metadata_url(&self) -> String {
format!(
"{}/.well-known/oauth-protected-resource/mcp",
self.public_origin
)
}
pub fn metadata(&self) -> Value {
json!({
"resource": self.resource_url(),
"authorization_servers": [self.issuer],
"scopes_supported": SCOPES,
"bearer_methods_supported": ["header"],
"resource_name": RESOURCE_NAME,
})
}
/// `WWW-Authenticate` value; `invalid` marks a presented-but-rejected token.
pub fn challenge(&self, invalid: bool) -> String {
let url = self.metadata_url();
if invalid {
format!("Bearer error=\"invalid_token\", resource_metadata=\"{url}\"")
} else {
format!("Bearer resource_metadata=\"{url}\"")
}
}
/// Browsers must be same-origin; non-browser MCP clients send no Origin.
pub fn origin_allowed(&self, origin: Option<&str>) -> bool {
origin.is_none_or(|origin| origin == self.public_origin)
}
/// Blocking: may fetch the JWKS with curl on a cold or rotated cache.
pub fn authenticate(&self, token: &str) -> Result<Grant, Rejection> {
match validate(token, &self.keys(false)?, &self.issuer, &self.audience) {
Err(UNKNOWN_KID) => validate(token, &self.keys(true)?, &self.issuer, &self.audience),
other => other,
}
}
fn keys(&self, refresh: bool) -> Result<JwkSet, Rejection> {
if !refresh
&& let Ok(cache) = self.jwks.lock()
&& let Some((fetched, jwks)) = cache.as_ref()
&& fetched.elapsed() < JWKS_TTL
{
return Ok(jwks.clone());
}
let jwks = fetch_jwks(&self.issuer)?;
if let Ok(mut cache) = self.jwks.lock() {
*cache = Some((Instant::now(), jwks.clone()));
}
Ok(jwks)
}
}
fn fetch_jwks(issuer: &str) -> Result<JwkSet, Rejection> {
let output = Command::new("curl")
.args(["--fail", "--silent", "--show-error", "--max-time", "10"])
.arg(format!("{issuer}/jwks"))
.output()
.map_err(|_| "JWKS request failed")?;
if !output.status.success() {
return Err("JWKS request failed");
}
serde_json::from_slice(&output.stdout).map_err(|_| "invalid JWKS response")
}
const UNKNOWN_KID: Rejection = "unknown JWT key id";
pub fn audience_contains(aud: &Value, audience: &str) -> bool {
match aud {
Value::String(value) => value == audience,
Value::Array(values) => values.iter().any(|value| value.as_str() == Some(audience)),
_ => false,
}
}
pub fn validate(
token: &str,
jwks: &JwkSet,
issuer: &str,
audience: &str,
) -> Result<Grant, Rejection> {
let header = decode_header(token).map_err(|_| "invalid JWT header")?;
if header.alg != Algorithm::EdDSA {
return Err("unexpected JWT algorithm");
}
let kid = header.kid.as_deref().ok_or("missing JWT key id")?;
let jwk = jwks.find(kid).ok_or(UNKNOWN_KID)?;
if !matches!(
&jwk.algorithm,
AlgorithmParameters::OctetKeyPair(key) if key.curve == EllipticCurve::Ed25519
) {
return Err("unsupported JWKS key");
}
let key = DecodingKey::from_jwk(jwk).map_err(|_| "unsupported JWKS key")?;
let mut validation = Validation::new(Algorithm::EdDSA);
validation.set_issuer(&[issuer]);
validation.validate_aud = false;
validation.leeway = 0;
let claims = decode::<AccessClaims>(token, &key, &validation)
.map_err(|_| "JWT signature or claims validation failed")?
.claims;
if !audience_contains(&claims.aud, audience) {
return Err("token audience does not include this resource");
}
// client_credentials tokens carry client_id or name the client as subject.
if claims.sub.is_empty()
|| claims.client_id.is_some()
|| claims.sub.starts_with("akurai-agent-")
{
return Err("a user access token is required");
}
let tenant = claims
.tenant_id
.filter(|tenant| !tenant.is_empty())
.ok_or("token has no tenant")?;
Ok(Grant {
subject: claims.sub,
tenant,
})
}
#[cfg(test)]
pub(crate) mod testing {
use jsonwebtoken::{EncodingKey, Header, encode, jwk::JwkSet};
use ring::{
rand::SystemRandom,
signature::{Ed25519KeyPair, KeyPair},
};
use serde_json::{Value, json};
pub const ISSUER: &str = "https://auth.example";
pub const KID: &str = "test-key";
/// Ephemeral Ed25519 signer standing in for the IdP.
pub struct TestIdp {
pkcs8: Vec<u8>,
public: Vec<u8>,
}
impl TestIdp {
pub fn new() -> Self {
let pkcs8 = Ed25519KeyPair::generate_pkcs8(&SystemRandom::new())
.expect("generate key")
.as_ref()
.to_vec();
let public = Ed25519KeyPair::from_pkcs8(&pkcs8)
.expect("parse key")
.public_key()
.as_ref()
.to_vec();
Self { pkcs8, public }
}
pub fn jwks(&self) -> JwkSet {
let x = base64url(&self.public);
serde_json::from_value(json!({"keys": [{
"kty": "OKP", "crv": "Ed25519", "alg": "EdDSA", "use": "sig", "kid": KID, "x": x
}]}))
.expect("jwks")
}
pub fn sign(&self, kid: &str, claims: &Value) -> String {
let mut header = Header::new(jsonwebtoken::Algorithm::EdDSA);
header.kid = Some(kid.to_owned());
encode(&header, claims, &EncodingKey::from_ed_der(&self.pkcs8)).expect("sign")
}
}
pub fn user_claims(aud: Value) -> Value {
json!({
"iss": ISSUER,
"sub": "user-1",
"aud": aud,
"exp": 4_102_444_800_u64,
"iat": 1_700_000_000_u64,
"tenant_id": "tenant-1",
"organization_id": "tenant-1",
"scope": "openid profile email groups offline_access",
})
}
fn base64url(bytes: &[u8]) -> String {
const TABLE: &[u8; 64] =
b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let value = chunk.iter().enumerate().fold(0_u32, |acc, (i, byte)| {
acc | (u32::from(*byte) << (16 - 8 * i))
});
for index in 0..=chunk.len() {
out.push(TABLE[((value >> (18 - 6 * index)) & 63) as usize] as char);
}
}
out
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::testing::{ISSUER, KID, TestIdp, user_claims};
use super::*;
const AUD: [&str; 2] = ["akurai-agent-akurai-build", "akurai-build"];
fn server(idp: &TestIdp) -> ResourceServer {
ResourceServer::new(ISSUER, "akurai-build", "https://build.example/").with_jwks(idp.jwks())
}
#[test]
fn metadata_names_resource_and_issuer() {
let server = ResourceServer::new(
"https://auth.example/",
"akurai-build",
"https://build.example",
);
let metadata = server.metadata();
assert_eq!(metadata["resource"], "https://build.example/mcp");
assert_eq!(
metadata["authorization_servers"],
json!(["https://auth.example"])
);
assert_eq!(metadata["bearer_methods_supported"], json!(["header"]));
}
#[test]
fn challenge_points_at_metadata() {
let server = ResourceServer::new(ISSUER, "akurai-build", "https://build.example");
let url = "https://build.example/.well-known/oauth-protected-resource/mcp";
assert_eq!(
server.challenge(false),
format!("Bearer resource_metadata=\"{url}\"")
);
assert_eq!(
server.challenge(true),
format!("Bearer error=\"invalid_token\", resource_metadata=\"{url}\"")
);
}
#[test]
fn origin_must_match_when_present() {
let server = ResourceServer::new(ISSUER, "akurai-build", "https://build.example");
assert!(server.origin_allowed(None));
assert!(server.origin_allowed(Some("https://build.example")));
assert!(!server.origin_allowed(Some("https://evil.example")));
}
#[test]
fn audience_matches_string_or_array() {
assert!(audience_contains(&json!("akurai-build"), "akurai-build"));
assert!(audience_contains(&json!(AUD), "akurai-build"));
assert!(!audience_contains(&json!(["akurai-notes"]), "akurai-build"));
assert!(!audience_contains(&json!(null), "akurai-build"));
}
#[test]
fn accepts_idp_user_token_with_array_audience() {
let idp = TestIdp::new();
let token = idp.sign(KID, &user_claims(json!(AUD)));
assert_eq!(
server(&idp).authenticate(&token),
Ok(Grant {
subject: "user-1".into(),
tenant: "tenant-1".into()
})
);
}
#[test]
fn rejects_other_audience() {
let idp = TestIdp::new();
let token = idp.sign(
KID,
&user_claims(json!(["akurai-agent-akurai-notes", "akurai-notes"])),
);
assert!(server(&idp).authenticate(&token).is_err());
}
#[test]
fn rejects_wrong_issuer_expiry_and_signature() {
let idp = TestIdp::new();
let mut claims = user_claims(json!(AUD));
claims["iss"] = json!("https://evil.example");
assert!(server(&idp).authenticate(&idp.sign(KID, &claims)).is_err());
let mut claims = user_claims(json!(AUD));
claims["exp"] = json!(1_000_u64);
assert!(server(&idp).authenticate(&idp.sign(KID, &claims)).is_err());
let forged = TestIdp::new().sign(KID, &user_claims(json!(AUD)));
assert!(server(&idp).authenticate(&forged).is_err());
assert!(server(&idp).authenticate("not-a-jwt").is_err());
}
#[test]
fn rejects_client_credentials_and_tenantless_tokens() {
let idp = TestIdp::new();
let mut claims = user_claims(json!(AUD));
claims["client_id"] = json!("akurai-agent-akurai-build");
assert!(server(&idp).authenticate(&idp.sign(KID, &claims)).is_err());
let mut claims = user_claims(json!(AUD));
claims["sub"] = json!("akurai-agent-akurai-build");
assert!(server(&idp).authenticate(&idp.sign(KID, &claims)).is_err());
let mut claims = user_claims(json!(AUD));
claims.as_object_mut().expect("object").remove("tenant_id");
assert!(server(&idp).authenticate(&idp.sign(KID, &claims)).is_err());
}
#[test]
fn validate_reports_unknown_kid_for_rotation() {
let idp = TestIdp::new();
let token = idp.sign("rotated", &user_claims(json!(AUD)));
assert_eq!(
validate(&token, &idp.jwks(), ISSUER, "akurai-build"),
Err(UNKNOWN_KID)
);
}
}