oxide-framework-core 0.1.0

Core runtime and framework logic for the Oxide web framework.
Documentation
//! JWT auth, session cookie, and role guards.

use oxide_framework_core::{
    encode_token, ApiResponse, App, AuthClaims, AuthConfig, Authenticated, OptionalAuth, RequireRole,
    RoleName,
};
use serde::Serialize;

const SECRET: &[u8] = b"test-secret-key-for-auth-tests-only";

#[derive(Serialize)]
struct Msg {
    text: String,
}

fn app_with_auth() -> App {
    App::new()
        .disable_request_logging()
        .auth(AuthConfig::new(SECRET))
}

fn token_for(sub: &str, roles: &[&str]) -> String {
    let roles: Vec<String> = roles.iter().map(|s| (*s).to_string()).collect();
    let claims = AuthClaims::new(sub, roles, 3600);
    encode_token(&claims, SECRET).unwrap()
}

#[tokio::test]
async fn bearer_token_sets_claims() {
    let server = app_with_auth()
        .get("/who", |OptionalAuth(opt): OptionalAuth| async move {
            let c = opt.expect("claims");
            ApiResponse::ok(Msg { text: c.sub.clone() })
        })
        .into_test_server()
        .await;

    let client = reqwest::Client::new();
    let res = client
        .get(server.url("/who"))
        .header("Authorization", format!("Bearer {}", token_for("alice", &["user"])))
        .send()
        .await
        .unwrap();
    assert_eq!(res.status(), 200);
    let v: serde_json::Value = res.json().await.unwrap();
    assert_eq!(v["data"]["text"], "alice");
}

#[tokio::test]
async fn missing_token_is_anonymous() {
    let server = app_with_auth()
        .get("/opt", |OptionalAuth(opt): OptionalAuth| async move {
            ApiResponse::ok(Msg {
                text: opt.map(|c| c.sub).unwrap_or_else(|| "anon".into()),
            })
        })
        .into_test_server()
        .await;

    let client = reqwest::Client::new();
    let res = client.get(server.url("/opt")).send().await.unwrap();
    assert_eq!(res.status(), 200);
    let v: serde_json::Value = res.json().await.unwrap();
    assert_eq!(v["data"]["text"], "anon");
}

#[tokio::test]
async fn invalid_bearer_returns_401() {
    let server = app_with_auth()
        .get("/", || async { ApiResponse::ok(()) })
        .into_test_server()
        .await;

    let client = reqwest::Client::new();
    let res = client
        .get(server.url("/"))
        .header("Authorization", "Bearer not-a-valid-jwt")
        .send()
        .await
        .unwrap();
    assert_eq!(res.status(), 401);
}

#[tokio::test]
async fn session_cookie_auth() {
    let server = App::new()
        .disable_request_logging()
        .auth(
            AuthConfig::new(SECRET).with_session_cookie("oxide_session"),
        )
        .get("/who", |OptionalAuth(opt): OptionalAuth| async move {
            ApiResponse::ok(Msg {
                text: opt.map(|c| c.sub).unwrap_or_default(),
            })
        })
        .into_test_server()
        .await;

    let tok = token_for("bob", &[]);
    let client = reqwest::Client::new();
    let res = client
        .get(server.url("/who"))
        .header("Cookie", format!("oxide_session={tok}"))
        .send()
        .await
        .unwrap();
    assert_eq!(res.status(), 200);
    let v: serde_json::Value = res.json().await.unwrap();
    assert_eq!(v["data"]["text"], "bob");
}

struct AdminRole;
impl RoleName for AdminRole {
    const ROLE: &'static str = "admin";
}

#[tokio::test]
async fn require_role_allows() {
    let server = app_with_auth()
        .get(
            "/admin",
            |_r: RequireRole<AdminRole>, Authenticated(c): Authenticated| async move {
                ApiResponse::ok(Msg { text: c.sub })
            },
        )
        .into_test_server()
        .await;

    let client = reqwest::Client::new();
    let res = client
        .get(server.url("/admin"))
        .header(
            "Authorization",
            format!("Bearer {}", token_for("root", &["admin"])),
        )
        .send()
        .await
        .unwrap();
    assert_eq!(res.status(), 200);
}

#[tokio::test]
async fn require_role_forbids() {
    let server = app_with_auth()
        .get(
            "/admin",
            |_r: RequireRole<AdminRole>| async move { ApiResponse::ok(()) },
        )
        .into_test_server()
        .await;

    let client = reqwest::Client::new();
    let res = client
        .get(server.url("/admin"))
        .header(
            "Authorization",
            format!("Bearer {}", token_for("user", &["user"])),
        )
        .send()
        .await
        .unwrap();
    assert_eq!(res.status(), 403);
}

#[tokio::test]
async fn authenticated_extractor_requires_login() {
    let server = app_with_auth()
        .get("/me", |Authenticated(c): Authenticated| async move {
            ApiResponse::ok(Msg { text: c.sub })
        })
        .into_test_server()
        .await;

    let client = reqwest::Client::new();
    let res = client.get(server.url("/me")).send().await.unwrap();
    assert_eq!(res.status(), 401);
}