Menu
akurai-tasks
publicLatest change 765ec448ced1bf9f878cb3179ca9575d0f46d0a9 - feat(mcp): advertise OAuth via RFC 9728 discovery by Ólafur Búi Ólafsson
use akurai_json::{parse, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::fs::File;
use std::io::{Read, Write};
use std::net::TcpStream;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Clone)]
pub struct OidcConfig {
pub issuer: String,
pub internal_url: String,
pub client_id: String,
pub client_secret: String,
pub redirect_uri: String,
pub allowed_emails: Vec<String>,
}
/// The `aud` claim AkurAI IDP mints for tokens scoped to this service.
///
/// Must stay in sync with the `akurai-tasks` audience registered for the
/// `opencode-tasks` client in AkurAI-IDP. The `/mcp` route already validates
/// this audience; the constant names it in one place so discovery and
/// validation cannot drift apart.
pub const AUDIENCE: &str = "akurai-tasks";
/// RFC 9728 OAuth 2.0 Protected Resource Metadata.
///
/// MCP clients (the official TypeScript SDK, and therefore opencode) begin the
/// OAuth flow by probing `/.well-known/oauth-protected-resource` on the MCP
/// server to learn which authorization server to talk to. Without it the client
/// treats AkurAI-Tasks itself as the authorization server, probes well-known
/// endpoints that do not exist here, receives the static-file 404, and the flow
/// dies before it ever reaches AkurAI IDP.
#[derive(Clone, Debug)]
pub struct ResourceMetadata {
/// Canonical MCP resource identifier, e.g. `https://host/mcp`.
pub resource: String,
/// Authorization server issuer, e.g. `https://auth.olibuijr.com`.
pub issuer: String,
}
impl ResourceMetadata {
/// Build metadata for a public base URL. An empty `public_url` falls back to
/// `AKURAI_TASKS_PUBLIC_URL`, then `AKURAI_PUBLIC_URL`, then the production
/// hostname.
pub fn new(public_url: &str, issuer: &str) -> Self {
let base = if public_url.is_empty() {
std::env::var("AKURAI_TASKS_PUBLIC_URL")
.or_else(|_| std::env::var("AKURAI_PUBLIC_URL"))
.unwrap_or_else(|_| "https://akurai-tasks.olibuijr.com".into())
} else {
public_url.to_string()
};
Self {
resource: format!("{}/mcp", base.trim_end_matches('/')),
issuer: issuer.trim_end_matches('/').to_string(),
}
}
/// True when `path` is one of the metadata paths clients probe.
///
/// The SDK tries the path-scoped form first (`.../oauth-protected-resource/mcp`,
/// RFC 9728 §3.1) and falls back to the root form, so both must answer.
pub fn is_metadata_path(path: &str) -> bool {
matches!(
path.trim_end_matches('/'),
"/.well-known/oauth-protected-resource" | "/.well-known/oauth-protected-resource/mcp"
)
}
/// Absolute URL of the metadata document, advertised in `WWW-Authenticate`.
pub fn metadata_url(&self) -> String {
let base = self
.resource
.strip_suffix("/mcp")
.unwrap_or(&self.resource)
.trim_end_matches('/');
format!("{base}/.well-known/oauth-protected-resource")
}
/// `WWW-Authenticate` challenge value for an unauthenticated `/mcp` request.
///
/// The SDK reads `resource_metadata` from this header to skip straight to
/// the right document instead of guessing well-known paths.
pub fn challenge(&self) -> String {
format!("Bearer resource_metadata=\"{}\"", self.metadata_url())
}
/// The metadata document itself.
///
/// `resource` must exactly match the URL the client requested: the SDK's
/// `checkResourceAllowed` compares origins and requires the configured path
/// to be a prefix of the requested one, otherwise it aborts the flow.
pub fn document(&self) -> String {
format!(
concat!(
"{{",
r#""resource":"{resource}","#,
r#""authorization_servers":["{issuer}"],"#,
r#""scopes_supported":["openid","profile","email","groups"],"#,
r#""bearer_methods_supported":["header"]"#,
"}}"
),
resource = self.resource,
issuer = self.issuer,
)
}
}
#[derive(Clone)]
pub(crate) struct Pending {
pub verifier: String,
pub nonce: String,
pub expires_at: i64,
pub completed: Option<Identity>,
}
#[derive(Clone, Debug)]
pub(crate) struct Identity {
pub sub: String,
pub email: String,
}
pub(crate) fn verify_access_token(
token: &str,
config: &OidcConfig,
audience: &str,
) -> Result<Identity, String> {
let payload = token
.split('.')
.nth(1)
.ok_or_else(|| "access token is not a JWT".to_string())
.and_then(base64url_decode)?;
let claims =
parse(&String::from_utf8(payload).map_err(|_| "access token payload is not UTF-8")?)
.map_err(|error| format!("access token payload: {error}"))?;
if claims.get("iss").and_then(Value::as_str) != Some(config.issuer.trim_end_matches('/')) {
return Err("access token issuer mismatch".into());
}
if claims.get("aud").and_then(Value::as_str) != Some(audience) {
return Err("access token audience mismatch".into());
}
let response = request(
&config.internal_url,
"GET",
"/userinfo",
&[("Authorization", &format!("Bearer {token}"))],
None,
)?;
let user = parse(&response).map_err(|error| format!("userinfo response: {error}"))?;
let identity = Identity {
sub: user
.get("sub")
.and_then(Value::as_str)
.ok_or("userinfo omitted subject")?
.to_string(),
email: user
.get("email")
.and_then(Value::as_str)
.unwrap_or("")
.to_string(),
};
if !config.allowed_emails.is_empty()
&& !config
.allowed_emails
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(&identity.email))
{
return Err("IDP identity is not allowed".into());
}
Ok(identity)
}
pub(crate) fn begin(
config: &OidcConfig,
pending: &mut BTreeMap<String, Pending>,
) -> Result<String, String> {
if config.client_id.is_empty() || config.client_secret.is_empty() {
return Err("OIDC client is not configured".into());
}
let state = random_token(24)?;
let verifier = random_token(32)?;
let nonce = random_token(24)?;
let challenge = base64url(&Sha256::digest(verifier.as_bytes()));
pending.retain(|_, value| value.expires_at > now());
pending.insert(
state.clone(),
Pending {
verifier,
nonce: nonce.clone(),
expires_at: now() + 300,
completed: None,
},
);
Ok(format!(
"{}/authorize?client_id={}&redirect_uri={}&response_type=code&scope=openid%20profile%20email&state={}&nonce={}&code_challenge={}&code_challenge_method=S256",
config.issuer.trim_end_matches('/'), encode(&config.client_id), encode(&config.redirect_uri), encode(&state), encode(&nonce), encode(&challenge)
))
}
pub(crate) fn callback(
config: &OidcConfig,
query: Option<&str>,
pending: &mut BTreeMap<String, Pending>,
) -> Result<Identity, String> {
let params = form(query.unwrap_or(""));
let state = params.get("state").ok_or("OIDC state missing")?;
let code = params
.get("code")
.ok_or("OIDC authorization code missing")?;
let flow = pending.get_mut(state).ok_or("OIDC state is invalid")?;
if flow.expires_at <= now() {
return Err("OIDC state expired".into());
}
if let Some(identity) = &flow.completed {
return Ok(identity.clone());
}
let token_body = format!("grant_type=authorization_code&code={}&redirect_uri={}&client_id={}&client_secret={}&code_verifier={}", encode(code), encode(&config.redirect_uri), encode(&config.client_id), encode(&config.client_secret), encode(&flow.verifier));
let token_response = request(
&config.internal_url,
"POST",
"/token",
&[("Content-Type", "application/x-www-form-urlencoded")],
Some(&token_body),
)?;
let token = parse(&token_response).map_err(|error| format!("OIDC token response: {error}"))?;
let access = token
.get("access_token")
.and_then(Value::as_str)
.ok_or("OIDC access token missing")?;
let id_token = token
.get("id_token")
.and_then(Value::as_str)
.ok_or("OIDC ID token missing")?;
validate_nonce(id_token, &flow.nonce)?;
let userinfo = request(
&config.internal_url,
"GET",
"/userinfo",
&[("Authorization", &format!("Bearer {access}"))],
None,
)?;
let user = parse(&userinfo).map_err(|error| format!("OIDC userinfo response: {error}"))?;
let sub = user
.get("sub")
.and_then(Value::as_str)
.ok_or("OIDC subject missing")?
.to_string();
let email = user
.get("email")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
if !config.allowed_emails.is_empty()
&& !config
.allowed_emails
.iter()
.any(|allowed| allowed.eq_ignore_ascii_case(&email))
{
return Err("OIDC identity is not allowed".into());
}
let identity = Identity { sub, email };
flow.completed = Some(identity.clone());
Ok(identity)
}
fn validate_nonce(token: &str, expected: &str) -> Result<(), String> {
let payload = token
.split('.')
.nth(1)
.ok_or("OIDC ID token is malformed")?;
let bytes = base64url_decode(payload)?;
let json = parse(&String::from_utf8(bytes).map_err(|_| "OIDC ID token payload is not UTF-8")?)
.map_err(|error| format!("OIDC ID token payload: {error}"))?;
if json.get("nonce").and_then(Value::as_str) != Some(expected) {
return Err("OIDC nonce mismatch".into());
}
Ok(())
}
fn request(
base: &str,
method: &str,
path: &str,
headers: &[(&str, &str)],
body: Option<&str>,
) -> Result<String, String> {
let without_scheme = base
.strip_prefix("http://")
.ok_or("OIDC internal URL must use loopback HTTP")?;
let (host, port) = without_scheme
.split_once(':')
.unwrap_or((without_scheme, "80"));
if !matches!(host, "127.0.0.1" | "localhost") {
return Err("OIDC internal URL must be loopback".into());
}
let port: u16 = port.parse().map_err(|_| "OIDC internal port is invalid")?;
let mut stream = TcpStream::connect_timeout(
&format!("{host}:{port}")
.parse()
.map_err(|_| "OIDC internal address invalid")?,
Duration::from_secs(5),
)
.map_err(|error| format!("OIDC connect: {error}"))?;
stream
.set_read_timeout(Some(Duration::from_secs(10)))
.map_err(|error| format!("OIDC timeout: {error}"))?;
let body = body.unwrap_or("");
let mut wire = format!("{method} {path} HTTP/1.1\r\nHost: {host}:{port}\r\nConnection: close\r\nContent-Length: {}\r\n", body.len());
for (name, value) in headers {
wire.push_str(&format!("{name}: {value}\r\n"));
}
wire.push_str("\r\n");
wire.push_str(body);
stream
.write_all(wire.as_bytes())
.map_err(|error| format!("OIDC write: {error}"))?;
let mut bytes = Vec::new();
stream
.read_to_end(&mut bytes)
.map_err(|error| format!("OIDC read: {error}"))?;
let response = String::from_utf8_lossy(&bytes);
let status = response
.lines()
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|value| value.parse::<u16>().ok())
.unwrap_or(0);
let content = response
.split_once("\r\n\r\n")
.map(|(_, body)| body)
.unwrap_or("");
if !(200..300).contains(&status) {
return Err(format!("OIDC endpoint returned HTTP {status}"));
}
Ok(content.to_string())
}
fn form(query: &str) -> BTreeMap<String, String> {
query
.split('&')
.filter_map(|pair| pair.split_once('='))
.map(|(key, value)| (decode(key), decode(value)))
.collect()
}
fn encode(value: &str) -> String {
let mut out = String::new();
for byte in value.bytes() {
if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') {
out.push(byte as char);
} else {
out.push_str(&format!("%{byte:02X}"));
}
}
out
}
fn decode(value: &str) -> String {
let bytes = value.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut index = 0;
while index < bytes.len() {
if bytes[index] == b'%' && index + 2 < bytes.len() {
if let Ok(byte) = u8::from_str_radix(&value[index + 1..index + 3], 16) {
out.push(byte);
index += 3;
continue;
}
}
out.push(if bytes[index] == b'+' {
b' '
} else {
bytes[index]
});
index += 1;
}
String::from_utf8_lossy(&out).into_owned()
}
fn random_token(bytes: usize) -> Result<String, String> {
let mut data = vec![0u8; bytes];
File::open("/dev/urandom")
.and_then(|mut file| file.read_exact(&mut data))
.map_err(|error| format!("secure random: {error}"))?;
Ok(base64url(&data))
}
fn base64url(bytes: &[u8]) -> String {
const TABLE: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
for chunk in bytes.chunks(3) {
let n = ((chunk[0] as u32) << 16)
| ((chunk.get(1).copied().unwrap_or(0) as u32) << 8)
| chunk.get(2).copied().unwrap_or(0) as u32;
out.push(TABLE[((n >> 18) & 63) as usize] as char);
out.push(TABLE[((n >> 12) & 63) as usize] as char);
if chunk.len() > 1 {
out.push(TABLE[((n >> 6) & 63) as usize] as char);
}
if chunk.len() > 2 {
out.push(TABLE[(n & 63) as usize] as char);
}
}
out
}
fn base64url_decode(value: &str) -> Result<Vec<u8>, String> {
fn digit(byte: u8) -> Option<u8> {
match byte {
b'A'..=b'Z' => Some(byte - b'A'),
b'a'..=b'z' => Some(byte - b'a' + 26),
b'0'..=b'9' => Some(byte - b'0' + 52),
b'-' => Some(62),
b'_' => Some(63),
_ => None,
}
}
let mut out = Vec::new();
for chunk in value.as_bytes().chunks(4) {
if chunk.len() < 2 {
return Err("invalid base64url".into());
}
let a = digit(chunk[0]).ok_or("invalid base64url")? as u32;
let b = digit(chunk[1]).ok_or("invalid base64url")? as u32;
let c = chunk.get(2).and_then(|value| digit(*value)).unwrap_or(0) as u32;
let d = chunk.get(3).and_then(|value| digit(*value)).unwrap_or(0) as u32;
let n = (a << 18) | (b << 12) | (c << 6) | d;
out.push((n >> 16) as u8);
if chunk.len() > 2 {
out.push((n >> 8) as u8);
}
if chunk.len() > 3 {
out.push(n as u8);
}
}
Ok(out)
}
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_trims_the_issuer_and_derives_the_mcp_resource() {
let metadata = ResourceMetadata::new("https://tasks.invalid/", "https://issuer.invalid/");
assert_eq!(metadata.resource, "https://tasks.invalid/mcp");
assert_eq!(metadata.issuer, "https://issuer.invalid");
}
#[test]
fn the_metadata_url_is_root_scoped_not_path_scoped() {
// RFC 9728 locates the document at the origin root even when the
// protected resource itself lives at /mcp.
let metadata = ResourceMetadata::new("https://tasks.invalid", "https://issuer.invalid");
assert_eq!(
metadata.metadata_url(),
"https://tasks.invalid/.well-known/oauth-protected-resource"
);
}
#[test]
fn both_probed_well_known_paths_are_recognised() {
assert!(ResourceMetadata::is_metadata_path(
"/.well-known/oauth-protected-resource"
));
assert!(ResourceMetadata::is_metadata_path(
"/.well-known/oauth-protected-resource/mcp"
));
assert!(ResourceMetadata::is_metadata_path(
"/.well-known/oauth-protected-resource/"
));
assert!(!ResourceMetadata::is_metadata_path(
"/.well-known/oauth-authorization-server"
));
assert!(!ResourceMetadata::is_metadata_path("/mcp"));
}
#[test]
fn the_challenge_exposes_the_resource_metadata_parameter() {
let metadata = ResourceMetadata::new("https://tasks.invalid", "https://issuer.invalid");
assert_eq!(
metadata.challenge(),
"Bearer resource_metadata=\"https://tasks.invalid/.well-known/oauth-protected-resource\""
);
}
#[test]
fn the_document_is_valid_json_naming_the_resource_and_authorization_server() {
let metadata = ResourceMetadata::new("https://tasks.invalid", "https://issuer.invalid");
let document = parse(&metadata.document()).expect("metadata must be valid JSON");
assert_eq!(
document.get("resource").and_then(Value::as_str),
Some("https://tasks.invalid/mcp")
);
let Some(Value::Array(servers)) = document.get("authorization_servers") else {
panic!("authorization_servers must be an array");
};
assert_eq!(servers.len(), 1);
assert_eq!(servers[0].as_str(), Some("https://issuer.invalid"));
}
#[test]
fn an_access_token_is_rejected_offline_on_issuer_or_audience_mismatch() {
// Neither case may reach /userinfo: internal_url points at a closed
// port, so a network attempt would surface as a connection error.
let config = OidcConfig {
issuer: "https://issuer.invalid".into(),
internal_url: "http://127.0.0.1:1".into(),
client_id: "client".into(),
client_secret: "secret".into(),
redirect_uri: "https://tasks.invalid/auth/callback".into(),
allowed_emails: Vec::new(),
};
let token = |claims: &str| format!("header.{}.signature", base64url(claims.as_bytes()));
let wrong_issuer = token(r#"{"iss":"https://evil.invalid","aud":"akurai-tasks"}"#);
assert_eq!(
verify_access_token(&wrong_issuer, &config, AUDIENCE).unwrap_err(),
"access token issuer mismatch"
);
let wrong_audience = token(r#"{"iss":"https://issuer.invalid","aud":"akurai-notes"}"#);
assert_eq!(
verify_access_token(&wrong_audience, &config, AUDIENCE).unwrap_err(),
"access token audience mismatch"
);
}
#[test]
fn an_access_token_must_be_a_jwt() {
let config = OidcConfig {
issuer: "https://issuer.invalid".into(),
internal_url: "http://127.0.0.1:1".into(),
client_id: "client".into(),
client_secret: "secret".into(),
redirect_uri: "https://tasks.invalid/auth/callback".into(),
allowed_emails: Vec::new(),
};
assert!(verify_access_token("not-a-jwt", &config, AUDIENCE).is_err());
}
#[test]
fn completed_callback_replays_without_reusing_authorization_code() {
let config = OidcConfig {
issuer: "https://issuer.invalid".into(),
internal_url: "http://127.0.0.1:1".into(),
client_id: "client".into(),
client_secret: "secret".into(),
redirect_uri: "https://tasks.invalid/auth/callback".into(),
allowed_emails: Vec::new(),
};
let mut pending = BTreeMap::from([(
"state".into(),
Pending {
verifier: "verifier".into(),
nonce: "nonce".into(),
expires_at: now() + 300,
completed: Some(Identity {
sub: "subject".into(),
email: "agent@example.com".into(),
}),
},
)]);
let replay = callback(&config, Some("state=state&code=already-used"), &mut pending)
.expect("completed callback should replay");
assert_eq!(replay.sub, "subject");
assert_eq!(replay.email, "agent@example.com");
}
}