use crate::error::ApiError;
use crate::state::AppState;
use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::header::AUTHORIZATION;
use axum::http::request::Parts;
use serde::Serialize;
use sha2::{Digest, Sha256};
pub struct AdminAuth {
pub token_id: Option<i64>,
pub scopes: Vec<String>,
}
impl AdminAuth {
pub fn require_scope(&self, scope: &str) -> Result<(), ApiError> {
if self.scopes.iter().any(|s| s == scope || s == "admin") {
Ok(())
} else {
Err(ApiError::new(
StatusCode::FORBIDDEN,
"insufficient_scope",
&format!("this action requires the '{scope}' scope"),
))
}
}
}
impl FromRequestParts<AppState> for AdminAuth {
type Rejection = ApiError;
async fn from_request_parts(
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
if state.admin_token.is_none() && pat_count(state).await == 0 {
return Err(ApiError::new(
StatusCode::SERVICE_UNAVAILABLE,
"admin_not_configured",
"server has no OXIPAGE_ADMIN_TOKEN and no PAT issued; run `oxipage auth token create`",
));
}
let token = match bearer_token(parts) {
Some(t) => t,
None => return Err(unauthorized()),
};
if let Some(expected) = &state.admin_token
&& constant_time_eq(token.as_bytes(), expected.as_bytes())
{
return Ok(AdminAuth {
token_id: None,
scopes: vec!["admin".into()],
});
}
match verify_pat(state, &token).await.map_err(ApiError::internal)? {
Some((id, scopes)) => {
if !scopes.iter().any(|s| s == "post:write" || s == "admin") {
return Err(ApiError::new(
StatusCode::FORBIDDEN,
"insufficient_scope",
"this route requires the 'post:write' scope",
));
}
Ok(AdminAuth {
token_id: Some(id),
scopes,
})
}
None => Err(unauthorized()),
}
}
}
fn bearer_token(parts: &Parts) -> Option<String> {
parts
.headers
.get(AUTHORIZATION)
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
.map(|s| s.to_string())
}
async fn pat_count(state: &AppState) -> i64 {
let row: Result<(i64,), sqlx::Error> =
sqlx::query_as("SELECT COUNT(*) FROM auth_token WHERE revoked_at IS NULL")
.fetch_one(&state.db)
.await;
row.map(|r| r.0).unwrap_or(0)
}
pub async fn verify_pat(
state: &AppState,
plain: &str,
) -> anyhow::Result<Option<(i64, Vec<String>)>> {
let hash = sha256_hex(plain.as_bytes());
let row: Option<(i64, String)> = sqlx::query_as(
"SELECT id, scopes FROM auth_token
WHERE token_hash = ? AND revoked_at IS NULL",
)
.bind(&hash)
.fetch_optional(&state.db)
.await?;
let Some((id, scopes_json)) = row else {
return Ok(None);
};
let scopes: Vec<String> = serde_json::from_str(&scopes_json).unwrap_or_default();
let _ = sqlx::query(
"UPDATE auth_token SET last_used_at = strftime('%Y-%m-%dT%H:%M:%fZ','now') WHERE id = ?",
)
.bind(id)
.execute(&state.db)
.await;
Ok(Some((id, scopes)))
}
pub fn generate_plain_token() -> String {
let mut bytes = [0u8; 32];
fill_random(&mut bytes);
format!("oxp_{}", base64url_encode(&bytes))
}
fn fill_random(buf: &mut [u8]) {
#[cfg(unix)]
{
use std::io::Read;
if let Ok(mut f) = std::fs::File::open("/dev/urandom")
&& f.read_exact(buf).is_ok()
{
return;
}
}
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
for (i, b) in buf.iter_mut().enumerate() {
*b = ((ts.wrapping_mul((i as u128) + 1)) & 0xff) as u8;
}
}
fn base64url_encode(bytes: &[u8]) -> String {
const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::new();
let mut i = 0;
while i + 3 <= bytes.len() {
let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8) | (bytes[i + 2] as u32);
out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char);
out.push(ALPHA[(n & 0x3f) as usize] as char);
i += 3;
}
let rem = bytes.len() - i;
if rem == 1 {
let n = (bytes[i] as u32) << 16;
out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
} else if rem == 2 {
let n = ((bytes[i] as u32) << 16) | ((bytes[i + 1] as u32) << 8);
out.push(ALPHA[((n >> 18) & 0x3f) as usize] as char);
out.push(ALPHA[((n >> 12) & 0x3f) as usize] as char);
out.push(ALPHA[((n >> 6) & 0x3f) as usize] as char);
}
out
}
pub fn sha256_hex(bytes: &[u8]) -> String {
let mut hasher = Sha256::new();
hasher.update(bytes);
let digest = hasher.finalize();
let mut out = String::with_capacity(64);
for b in digest {
out.push_str(&format!("{b:02x}"));
}
out
}
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct PatRow {
pub id: i64,
pub label: String,
pub token_prefix: String,
pub scopes: String,
pub created_at: String,
pub last_used_at: Option<String>,
pub revoked_at: Option<String>,
}
pub async fn create_pat(
state: &AppState,
label: &str,
scopes: &[String],
) -> anyhow::Result<(String, PatRow)> {
let plain = generate_plain_token();
let hash = sha256_hex(plain.as_bytes());
let prefix: String = plain.chars().take(12).collect();
let scopes_json = serde_json::to_string(scopes)?;
let row: PatRow = sqlx::query_as(
"INSERT INTO auth_token (label, token_hash, token_prefix, scopes)
VALUES (?, ?, ?, ?)
RETURNING id, label, token_prefix, scopes, created_at, last_used_at, revoked_at",
)
.bind(label)
.bind(&hash)
.bind(&prefix)
.bind(&scopes_json)
.fetch_one(&state.db)
.await?;
Ok((plain, row))
}
pub async fn list_pats(state: &AppState) -> anyhow::Result<Vec<PatRow>> {
let rows: Vec<PatRow> = sqlx::query_as(
"SELECT id, label, token_prefix, scopes, created_at, last_used_at, revoked_at
FROM auth_token ORDER BY created_at DESC",
)
.fetch_all(&state.db)
.await?;
Ok(rows)
}
pub async fn revoke_pat(state: &AppState, id: i64) -> anyhow::Result<bool> {
let res = sqlx::query(
"UPDATE auth_token SET revoked_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
WHERE id = ? AND revoked_at IS NULL",
)
.bind(id)
.execute(&state.db)
.await?;
Ok(res.rows_affected() > 0)
}
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
a.iter().zip(b.iter()).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
fn unauthorized() -> ApiError {
ApiError::new(
StatusCode::UNAUTHORIZED,
"unauthorized",
"missing or invalid bearer token",
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sha256_hex_is_stable() {
assert_eq!(
sha256_hex(b"abc"),
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"
);
}
#[test]
fn generated_token_has_prefix_and_length() {
let t = generate_plain_token();
assert!(t.starts_with("oxp_"));
assert!(t.len() >= 40);
}
#[test]
fn constant_time_eq_compares_bytes() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"abcd"));
assert!(!constant_time_eq(b"", b"a"));
}
#[test]
fn require_scope_admin_passes_all() {
let a = AdminAuth {
token_id: None,
scopes: vec!["admin".into()],
};
assert!(a.require_scope("post:write").is_ok());
assert!(a.require_scope("post:publish").is_ok());
}
#[test]
fn require_scope_exact_match() {
let a = AdminAuth {
token_id: Some(1),
scopes: vec!["post:write".into()],
};
assert!(a.require_scope("post:write").is_ok());
assert!(a.require_scope("post:publish").is_err());
}
}