Menu
Public repository / 5526f1231762e5175b9d0a8a7a09a61be31302ef
AkurAI-IDP / src/routes/token.rs
Latest change 8e1c2af4737a1aec991c04ba9b8418755775cc6b - tokens: fix refresh audience swap; add role to access tokens by Ólafur Búi Ólafsson
use axum::{
Json, Router,
extract::Request,
http::{StatusCode, header},
response::IntoResponse,
routing::post,
};
use serde::Deserialize;
use serde_json::json;
use std::time::{SystemTime, UNIX_EPOCH};
use crate::config;
use crate::db::with_db;
use crate::lib::audit::{self, log_audit_event};
use crate::lib::clients;
use crate::lib::crypto::{generate_secure_token, sha256};
use crate::lib::jwt::sign_token;
use crate::lib::pkce::verify_code_challenge;
pub fn router() -> Router {
Router::new().route("/token", post(token_endpoint))
}
#[derive(Deserialize, Default)]
struct TokenRequest {
grant_type: Option<String>,
code: Option<String>,
redirect_uri: Option<String>,
client_id: Option<String>,
client_secret: Option<String>,
code_verifier: Option<String>,
refresh_token: Option<String>,
organization_id: Option<String>,
scope: Option<String>,
}
async fn token_endpoint(request: Request) -> impl IntoResponse {
let ip = extract_ip(&request);
// Extract client credentials from Basic auth header if present
let (basic_client_id, basic_client_secret) = extract_basic_auth(&request);
// Parse body as form or JSON
let body_bytes = match axum::body::to_bytes(request.into_body(), 1024 * 64).await {
Ok(b) => b,
Err(_) => {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Failed to read body",
);
}
};
let mut params: TokenRequest =
serde_json::from_slice(&body_bytes).unwrap_or_else(|_| parse_form_urlencoded(&body_bytes));
// Basic auth overrides body params for client credentials
if let Some(id) = basic_client_id {
params.client_id = Some(id);
}
if let Some(secret) = basic_client_secret {
params.client_secret = Some(secret);
}
let grant_type = match ¶ms.grant_type {
Some(gt) => gt.as_str(),
None => {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Missing grant_type",
);
}
};
match grant_type {
"authorization_code" => handle_authorization_code(params, &ip).await,
"refresh_token" => handle_refresh_token(params, &ip).await,
"client_credentials" => handle_client_credentials(params, &ip).await,
_ => token_error(
StatusCode::BAD_REQUEST,
"unsupported_grant_type",
"Unsupported grant_type",
),
}
}
async fn handle_authorization_code(params: TokenRequest, ip: &str) -> axum::response::Response {
let code = match ¶ms.code {
Some(c) => c.clone(),
None => return token_error(StatusCode::BAD_REQUEST, "invalid_request", "Missing code"),
};
let client_id = match ¶ms.client_id {
Some(c) => c.clone(),
None => {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Missing client_id",
);
}
};
let redirect_uri = match ¶ms.redirect_uri {
Some(r) => r.clone(),
None => {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Missing redirect_uri",
);
}
};
let now = now_secs();
// Look up auth code
let auth_code = with_db(|conn| {
conn.query_row(
"SELECT code, client_id, user_id, organization_id, scopes, redirect_uri,
code_challenge, code_challenge_method, nonce, expires_at, used
FROM auth_codes WHERE code = ?1",
rusqlite::params![code],
|row| {
Ok(AuthCode {
code: row.get(0)?,
client_id: row.get(1)?,
user_id: row.get(2)?,
organization_id: row.get(3)?,
scopes: row.get(4)?,
redirect_uri: row.get(5)?,
code_challenge: row.get(6)?,
code_challenge_method: row.get(7)?,
nonce: row.get(8)?,
expires_at: row.get(9)?,
used: row.get::<_, i64>(10)? != 0,
})
},
)
.ok()
});
let auth_code = match auth_code {
Some(ac) => ac,
None => {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"Invalid authorization code",
);
}
};
// Validate
if auth_code.used {
// Code replay — revoke all tokens for this code's user+client
with_db(|conn| {
conn.execute(
"UPDATE refresh_tokens SET revoked = 1 WHERE client_id = ?1 AND user_id = ?2",
rusqlite::params![auth_code.client_id, auth_code.user_id],
)
.ok();
});
return token_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"Authorization code already used",
);
}
if auth_code.expires_at < now {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"Authorization code expired",
);
}
if auth_code.client_id != client_id {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"Client ID mismatch",
);
}
if auth_code.redirect_uri != redirect_uri {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"Redirect URI mismatch",
);
}
// Verify client secret
if !verify_client_secret(&client_id, params.client_secret.as_deref()) {
return token_error(
StatusCode::UNAUTHORIZED,
"invalid_client",
"Invalid client credentials",
);
}
// Public clients prove possession only through PKCE.
if auth_code.code_challenge.is_none() && is_public_client(&client_id) {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"Public clients require an S256 PKCE challenge",
);
}
// PKCE verification
if let Some(ref challenge) = auth_code.code_challenge {
let method = auth_code.code_challenge_method.as_deref().unwrap_or("S256");
let verifier = match ¶ms.code_verifier {
Some(v) => v,
None => {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Missing code_verifier",
);
}
};
if !verify_code_challenge(verifier, challenge, method) {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"PKCE verification failed",
);
}
}
// Mark code as used
with_db(|conn| {
conn.execute(
"UPDATE auth_codes SET used = 1 WHERE code = ?1",
rusqlite::params![code],
)
.ok();
});
let user = match load_user(&auth_code.user_id, auth_code.organization_id.as_deref()) {
Some(u) => u,
None => return token_error(StatusCode::BAD_REQUEST, "invalid_grant", "User not found"),
};
let scopes = &auth_code.scopes;
let base_url = &config::get().base_url;
// Claims intentionally describe only the selected organization. Other
// memberships stay authoritative in the IDP and are never copied into a
// bearer token.
let id_token_claims = json!({
"iss": base_url,
"sub": user.id,
"aud": client_id,
"exp": now + 3600,
"iat": now,
"claims_version": crate::lib::organizations::CLAIMS_VERSION,
"authorization_version": user.authorization_version,
"email": user.email,
"email_verified": user.email_verified,
"organization_id": user.tenant_id,
"tenant_id": user.tenant_id,
"role": user.role,
"nonce": auth_code.nonce,
});
let access_token_claims =
user_access_claims(base_url, &user, client_audience(&client_id), scopes, now);
let (kid, private_key) = match get_active_signing_key() {
Some(k) => k,
None => {
return token_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"No signing key available",
);
}
};
let access_token = match sign_token(&access_token_claims, &private_key, &kid, 3600) {
Ok(t) => t,
Err(_) => {
return token_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Failed to sign token",
);
}
};
let id_token = match sign_token(&id_token_claims, &private_key, &kid, 3600) {
Ok(t) => t,
Err(_) => {
return token_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Failed to sign token",
);
}
};
// Issue refresh token if offline_access scope.
let refresh_token = if scopes.contains("offline_access") {
let rt = generate_secure_token(32);
let rt_hash = sha256(&rt);
let expires = now + 30 * 24 * 3600;
with_db(|conn| {
conn.execute(
"INSERT INTO refresh_tokens
(token_hash, client_id, user_id, organization_id, scopes, expires_at, revoked)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)",
rusqlite::params![rt_hash, client_id, user.id, user.tenant_id, scopes, expires],
)
.ok();
});
Some(rt)
} else {
None
};
// Audit
let audit_meta = json!({"grant_type": "authorization_code", "client_id": client_id});
with_db(|conn| {
log_audit_event(
conn,
Some(&user.tenant_id),
Some(&user.id),
audit::TOKEN_ISSUED,
Some(ip),
Some(&audit_meta),
);
});
let mut response = json!({
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3600,
"id_token": id_token,
"scope": scopes,
});
if let Some(rt) = refresh_token {
response["refresh_token"] = json!(rt);
}
(StatusCode::OK, Json(response)).into_response()
}
async fn handle_refresh_token(params: TokenRequest, ip: &str) -> axum::response::Response {
let rt = match ¶ms.refresh_token {
Some(t) => t.clone(),
None => {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Missing refresh_token",
);
}
};
let client_id = match ¶ms.client_id {
Some(c) => c.clone(),
None => {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Missing client_id",
);
}
};
if !verify_client_secret(&client_id, params.client_secret.as_deref()) {
return token_error(
StatusCode::UNAUTHORIZED,
"invalid_client",
"Invalid client credentials",
);
}
let rt_hash = sha256(&rt);
let now = now_secs();
// Look up refresh token
let stored = with_db(|conn| {
conn.query_row(
"SELECT token_hash, client_id, user_id, organization_id, scopes, expires_at, revoked
FROM refresh_tokens WHERE token_hash = ?1",
rusqlite::params![rt_hash],
|row| {
Ok(StoredRefreshToken {
token_hash: row.get(0)?,
client_id: row.get(1)?,
user_id: row.get(2)?,
organization_id: row.get(3)?,
scopes: row.get(4)?,
expires_at: row.get(5)?,
revoked: row.get::<_, i64>(6)? != 0,
})
},
)
.ok()
});
let stored = match stored {
Some(s) => s,
None => {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"Invalid refresh token",
);
}
};
if stored.revoked {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"Refresh token revoked",
);
}
if stored.expires_at < now {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"Refresh token expired",
);
}
if stored.client_id != client_id {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_grant",
"Client ID mismatch",
);
}
// Rotate: revoke old, issue new
let new_rt = generate_secure_token(32);
let new_rt_hash = sha256(&new_rt);
let new_expires = now + 30 * 24 * 3600;
let rotation = with_db(|conn| {
conn.execute(
"UPDATE refresh_tokens SET revoked = 1
WHERE token_hash = ?1 AND client_id = ?2",
rusqlite::params![stored.token_hash, client_id],
)?;
conn.execute(
"INSERT INTO refresh_tokens
(token_hash, client_id, user_id, organization_id, scopes, expires_at, revoked)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, 0)",
rusqlite::params![
new_rt_hash,
client_id,
stored.user_id,
stored.organization_id,
stored.scopes,
new_expires
],
)?;
Ok::<_, rusqlite::Error>(())
});
if rotation.is_err() {
return token_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Token rotation failed",
);
}
let user = match load_user(&stored.user_id, stored.organization_id.as_deref()) {
Some(u) => u,
None => return token_error(StatusCode::BAD_REQUEST, "invalid_grant", "User not found"),
};
let base_url = &config::get().base_url;
let access_token_claims = user_access_claims(
base_url,
&user,
client_audience(&client_id),
&stored.scopes,
now,
);
let id_token_claims = json!({
"iss": base_url,
"sub": user.id,
"aud": client_id,
"exp": now + 3600,
"iat": now,
"claims_version": crate::lib::organizations::CLAIMS_VERSION,
"authorization_version": user.authorization_version,
"email": user.email,
"email_verified": user.email_verified,
"organization_id": user.tenant_id,
"tenant_id": user.tenant_id,
"role": user.role,
});
let (kid, private_key) = match get_active_signing_key() {
Some(k) => k,
None => {
return token_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"No signing key",
);
}
};
let access_token = match sign_token(&access_token_claims, &private_key, &kid, 3600) {
Ok(t) => t,
Err(_) => {
return token_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Sign failed",
);
}
};
let id_token = match sign_token(&id_token_claims, &private_key, &kid, 3600) {
Ok(t) => t,
Err(_) => {
return token_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Sign failed",
);
}
};
let audit_meta = json!({"grant_type": "refresh_token", "client_id": client_id});
with_db(|conn| {
log_audit_event(
conn,
Some(&user.tenant_id),
Some(&user.id),
audit::TOKEN_ISSUED,
Some(ip),
Some(&audit_meta),
);
});
let response = json!({
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3600,
"refresh_token": new_rt,
"id_token": id_token,
"scope": stored.scopes,
});
(StatusCode::OK, Json(response)).into_response()
}
async fn handle_client_credentials(params: TokenRequest, ip: &str) -> axum::response::Response {
let client_id = match ¶ms.client_id {
Some(c) => c.clone(),
None => {
return token_error(
StatusCode::BAD_REQUEST,
"invalid_request",
"Missing client_id",
);
}
};
if is_public_client(&client_id)
|| !verify_client_secret(&client_id, params.client_secret.as_deref())
{
return token_error(
StatusCode::UNAUTHORIZED,
"invalid_client",
"Invalid client credentials",
);
}
// Verify client supports client_credentials grant
let client = with_db(|conn| {
conn.query_row(
"SELECT id, tenant_id, grant_types, scopes FROM clients WHERE id = ?1",
rusqlite::params![client_id],
|row| {
Ok(ClientInfo {
id: row.get(0)?,
tenant_id: row.get(1)?,
grant_types: row.get(2)?,
scopes: row.get(3)?,
})
},
)
.ok()
});
let client = match client {
Some(c) => c,
None => {
return token_error(
StatusCode::UNAUTHORIZED,
"invalid_client",
"Client not found",
);
}
};
let grant_types = crate::lib::parse_json_or_space_separated(&client.grant_types);
if !grant_types.iter().any(|g| g == "client_credentials") {
return token_error(
StatusCode::BAD_REQUEST,
"unauthorized_client",
"Grant type not allowed",
);
}
let requested_scope = params.scope.as_deref().unwrap_or(&client.scopes);
let now = now_secs();
let base_url = &config::get().base_url;
let access_token_claims = json!({
"iss": base_url,
"sub": client_id,
"client_id": client_id,
"aud": client_audience(&client_id),
"exp": now + 3600,
"iat": now,
"scope": requested_scope,
"organization_id": client.tenant_id,
"tenant_id": client.tenant_id,
"token_type": "client_credentials",
});
let (kid, private_key) = match get_active_signing_key() {
Some(k) => k,
None => {
return token_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"No signing key",
);
}
};
let access_token = match sign_token(&access_token_claims, &private_key, &kid, 3600) {
Ok(t) => t,
Err(_) => {
return token_error(
StatusCode::INTERNAL_SERVER_ERROR,
"server_error",
"Sign failed",
);
}
};
let audit_meta = json!({"grant_type": "client_credentials", "client_id": client_id});
with_db(|conn| {
log_audit_event(
conn,
Some(&client.tenant_id),
None,
audit::TOKEN_ISSUED,
Some(ip),
Some(&audit_meta),
);
});
let response = json!({
"access_token": access_token,
"token_type": "Bearer",
"expires_in": 3600,
"scope": requested_scope,
});
(StatusCode::OK, Json(response)).into_response()
}
#[allow(dead_code)]
struct AuthCode {
code: String,
client_id: String,
user_id: String,
organization_id: Option<String>,
scopes: String,
redirect_uri: String,
code_challenge: Option<String>,
code_challenge_method: Option<String>,
nonce: Option<String>,
expires_at: i64,
used: bool,
}
struct StoredRefreshToken {
token_hash: String,
client_id: String,
user_id: String,
organization_id: Option<String>,
scopes: String,
expires_at: i64,
revoked: bool,
}
#[allow(dead_code)]
struct ClientInfo {
id: String,
tenant_id: String,
grant_types: String,
scopes: String,
}
/// Access-token claims for a user. `role` is the member's role in the
/// selected organization so resource servers can gate admin actions.
fn user_access_claims(
base_url: &str,
user: &UserInfo,
audience: serde_json::Value,
scopes: &str,
now: i64,
) -> serde_json::Value {
json!({
"iss": base_url,
"sub": user.id,
"aud": audience,
"exp": now + 3600,
"iat": now,
"scope": scopes,
"claims_version": crate::lib::organizations::CLAIMS_VERSION,
"authorization_version": user.authorization_version,
"organization_id": user.tenant_id,
"tenant_id": user.tenant_id,
"role": user.role,
})
}
struct UserInfo {
id: String,
tenant_id: String,
email: String,
email_verified: bool,
role: String,
authorization_version: i64,
}
fn now_secs() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs() as i64
}
fn load_user(user_id: &str, organization_id: Option<&str>) -> Option<UserInfo> {
with_db(|conn| {
let organization_id = organization_id.unwrap_or("");
conn.query_row(
"SELECT u.id, u.tenant_id, u.email, u.email_verified,
m.role, u.authorization_version, m.organization_id
FROM users u
JOIN organization_memberships m
ON m.organization_id = CASE
WHEN ?2 = '' THEN u.tenant_id ELSE ?2 END
AND m.user_id = u.id AND m.status = 'active'
WHERE u.id = ?1",
rusqlite::params![user_id, organization_id],
|row| {
Ok(UserInfo {
id: row.get(0)?,
tenant_id: row.get(6)?,
email: row.get(2)?,
email_verified: row.get::<_, i64>(3)? != 0,
role: row.get(4)?,
authorization_version: row.get(5)?,
})
},
)
.ok()
})
}
fn verify_client_secret(client_id: &str, secret: Option<&str>) -> bool {
with_db(|conn| clients::authenticate(conn, client_id, secret))
}
fn is_public_client(client_id: &str) -> bool {
with_db(|conn| clients::load_policy(conn, client_id)).is_some_and(|p| p.is_public())
}
fn client_audience(client_id: &str) -> serde_json::Value {
with_db(|conn| clients::access_token_audience(conn, client_id))
}
fn get_active_signing_key() -> Option<(String, String)> {
with_db(|conn| {
conn.query_row(
"SELECT kid, private_key_enc FROM signing_keys WHERE active = 1 ORDER BY created_at DESC LIMIT 1",
[],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.ok()
})
}
fn extract_basic_auth(request: &Request) -> (Option<String>, Option<String>) {
let header = match request.headers().get(header::AUTHORIZATION) {
Some(h) => h,
None => return (None, None),
};
let value = match header.to_str().ok() {
Some(v) => v,
None => return (None, None),
};
let encoded = match value.strip_prefix("Basic ") {
Some(e) => e,
None => return (None, None),
};
use base64::Engine;
let decoded = match base64::engine::general_purpose::STANDARD.decode(encoded) {
Ok(d) => d,
Err(_) => return (None, None),
};
let decoded_str = match String::from_utf8(decoded) {
Ok(s) => s,
Err(_) => return (None, None),
};
match decoded_str.split_once(':') {
Some((id, secret)) => (Some(id.to_string()), Some(secret.to_string())),
None => (None, None),
}
}
fn extract_ip(request: &Request) -> String {
// Try X-Forwarded-For first, then peer addr
request
.headers()
.get("x-forwarded-for")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.split(',').next())
.map(|s| s.trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
fn parse_form_urlencoded(bytes: &[u8]) -> TokenRequest {
let s = std::str::from_utf8(bytes).unwrap_or("");
let mut params = TokenRequest::default();
for pair in s.split('&') {
if let Some((key, value)) = pair.split_once('=') {
let value = urldecode(value);
match key {
"grant_type" => params.grant_type = Some(value),
"code" => params.code = Some(value),
"redirect_uri" => params.redirect_uri = Some(value),
"client_id" => params.client_id = Some(value),
"client_secret" => params.client_secret = Some(value),
"code_verifier" => params.code_verifier = Some(value),
"refresh_token" => params.refresh_token = Some(value),
"organization_id" => params.organization_id = Some(value),
_ => {}
}
}
}
params
}
fn urldecode(s: &str) -> String {
let s = s.replace('+', " ");
let mut result = Vec::with_capacity(s.len());
let mut chars = s.bytes();
while let Some(b) = chars.next() {
if b == b'%' {
let hi = chars.next().unwrap_or(b'0');
let lo = chars.next().unwrap_or(b'0');
let hex = [hi, lo];
if let Ok(val) = u8::from_str_radix(std::str::from_utf8(&hex).unwrap_or("00"), 16) {
result.push(val);
}
} else {
result.push(b);
}
}
String::from_utf8(result).unwrap_or_default()
}
fn token_error(status: StatusCode, error: &str, description: &str) -> axum::response::Response {
let body = json!({
"error": error,
"error_description": description,
});
(status, Json(body)).into_response()
}
#[cfg(test)]
mod tests {
use super::*;
fn user() -> UserInfo {
UserInfo {
id: "u1".into(),
tenant_id: "org".into(),
email: "a@x.is".into(),
email_verified: true,
role: "admin".into(),
authorization_version: 3,
}
}
#[test]
fn access_claims_carry_audience_role_and_organization() {
let aud = json!(["akurai-agent-mail", "https://mail.olibuijr.com/mcp"]);
let claims =
user_access_claims("https://auth.example", &user(), aud.clone(), "openid", 100);
assert_eq!(claims["aud"], aud);
assert_eq!(claims["role"], "admin");
assert_eq!(claims["organization_id"], "org");
assert_eq!(claims["sub"], "u1");
assert_eq!(claims["exp"], 3700);
assert!(claims.get("client_id").is_none());
}
}