use super::store::{SharedTokenStore, TokenStore};
use super::{Token, TokenError, authenticate};
use rustlavel_core::{Error, Json};
use rustlavel_http::handler::BoxFuture;
use rustlavel_http::middleware::{Middleware, Next};
use rustlavel_http::response::IntoResponse;
use rustlavel_http::{Request, Response, Status};
use std::sync::Arc;
pub const AUTHORIZATION_HEADER: &str = "authorization";
pub const BEARER_SCHEME: &str = "Bearer";
pub fn bearer(request: &Request) -> Option<&str> {
let (scheme, credentials) = request.header(AUTHORIZATION_HEADER)?.split_once(' ')?;
scheme.eq_ignore_ascii_case(BEARER_SCHEME).then(|| credentials.trim())
}
pub trait TokenExt {
fn api_token(&self) -> Option<&Token>;
fn token_can(&self, scope: &str) -> bool;
}
impl TokenExt for Request {
fn api_token(&self) -> Option<&Token> {
self.extension::<Token>()
}
fn token_can(&self, scope: &str) -> bool {
self.api_token().is_some_and(|token| token.can(scope))
}
}
fn forbidden(scope: &str) -> Response {
Response::new(Status::FORBIDDEN).with_json(Json::object([
("message", Json::from("This API token is missing a required scope.")),
("scope", Json::from(scope)),
]))
}
fn missing_token_middleware() -> Error {
Error::msg(
"the `scope` middleware needs an authenticated API token. Register the `api` middleware \
before it: `router.middleware(RequireApiToken::shared(store))`.",
)
}
pub struct RequireApiToken {
store: SharedTokenStore,
scope: Option<String>,
}
impl RequireApiToken {
pub fn new(store: impl TokenStore) -> Self {
RequireApiToken::shared(Arc::new(store))
}
pub fn shared(store: SharedTokenStore) -> Self {
RequireApiToken { store, scope: None }
}
pub fn scope(mut self, scope: impl Into<String>) -> Self {
self.scope = Some(scope.into());
self
}
}
impl std::fmt::Debug for RequireApiToken {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RequireApiToken").field("scope", &self.scope).finish_non_exhaustive()
}
}
impl Middleware for RequireApiToken {
fn handle(&self, mut request: Request, next: Next) -> BoxFuture<Response> {
let store = Arc::clone(&self.store);
let required = self.scope.clone();
Box::pin(async move {
let Some(presented) = bearer(&request).map(str::to_string) else {
return TokenError::Missing.into_response();
};
let token = match authenticate(&*store, &presented).await {
Ok(token) => token,
Err(error) => return error.into_response(),
};
if let Some(scope) = &required
&& !token.can(scope)
{
return forbidden(scope);
}
request.extend(token.identity().clone());
request.extend(token);
next.run(request).await
})
}
}
#[derive(Debug, Clone)]
pub struct RequireScope {
scope: String,
}
impl RequireScope {
pub fn new(scope: impl Into<String>) -> Self {
RequireScope { scope: scope.into() }
}
pub fn scope(&self) -> &str {
&self.scope
}
}
impl Middleware for RequireScope {
fn handle(&self, request: Request, next: Next) -> BoxFuture<Response> {
let scope = self.scope.clone();
Box::pin(async move {
let Some(token) = request.api_token() else {
return missing_token_middleware().into_response();
};
if !token.can(&scope) {
return forbidden(&scope);
}
next.run(request).await
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::guard::{AuthExt, Identity};
use crate::tokens::store::MemoryTokenStore;
use crate::tokens::{NewToken, PlainTextToken, Scopes};
use rustlavel_http::{Method, Router, TestClient};
fn store() -> SharedTokenStore {
Arc::new(MemoryTokenStore::new())
}
async fn issue(store: &SharedTokenStore, owner: &str, scopes: Scopes) -> PlainTextToken {
NewToken::new(Identity::new(owner), "iPhone")
.scopes(scopes)
.issue(&**store)
.await
.expect("the memory store never fails")
}
fn router(store: &SharedTokenStore) -> Router {
let mut router = Router::new();
let outer = Arc::clone(store);
let inline = Arc::clone(store);
router.group("/api", move |r| {
r.middleware(RequireApiToken::shared(outer));
r.get("/me", |request: Request| async move {
let identity = request.identity().expect("the middleware attaches an identity");
let token = request.api_token().expect("and the token");
format!(
"user {} via {} [{}] write={}",
identity.id(),
token.name(),
token.scopes(),
request.token_can("posts:write")
)
});
r.group("/posts", |r| {
r.middleware(RequireScope::new("posts:write"));
r.get("", |_request: Request| async { "the posts" });
});
});
router.group("/inline", move |r| {
r.middleware(RequireApiToken::shared(inline).scope("posts:write"));
r.get("", |_request: Request| async { "the posts" });
});
router.group("/loose", |r| {
r.middleware(RequireScope::new("posts:write"));
r.get("", |_request: Request| async { "unreachable" });
});
router
}
fn bearing(path: &str, credential: &str) -> Request {
Request::new(Method::Get, path).with_header("authorization", format!("Bearer {credential}"))
}
#[tokio::test]
async fn a_valid_token_reaches_the_handler_with_its_identity_and_token() {
let store = store();
let issued = issue(&store, "41", Scopes::new().with("posts:read")).await;
TestClient::new(router(&store))
.send(bearing("/api/me", issued.plain_text()))
.await
.assert_ok()
.assert_see("user 41 via iPhone [posts:read] write=false");
}
#[tokio::test]
async fn the_middleware_answers_401_without_a_usable_authorization_header() {
let store = store();
let issued = issue(&store, "41", Scopes::any()).await;
let client = TestClient::new(router(&store));
client
.get("/api/me")
.await
.assert_status(401)
.assert_json("message", "Unauthenticated.")
.assert_header("www-authenticate", "Bearer");
for value in [
format!("Basic {}", issued.plain_text()),
"Bearer".to_string(),
String::new(),
" ".to_string(),
] {
client
.send(Request::new(Method::Get, "/api/me").with_header("authorization", value))
.await
.assert_status(401);
}
}
#[tokio::test]
async fn the_middleware_answers_401_to_garbage_rather_than_500() {
let store = store();
let issued = issue(&store, "41", Scopes::any()).await;
let client = TestClient::new(router(&store));
let wrong_secret = format!("{}|not-the-secret", issued.token().id());
let huge = "a".repeat(20_000);
for credential in
["no-pipe", "|", "a|", "|b", "\0", "%%%", wrong_secret.as_str(), huge.as_str()]
{
let response = client.send(bearing("/api/me", credential)).await;
assert_eq!(
response.status(),
401,
"{credential:?} produced {} instead of a 401",
response.status()
);
response.assert_json("message", "Invalid API token.");
}
}
#[tokio::test]
async fn the_scheme_is_matched_case_insensitively_and_extra_spacing_is_ignored() {
let store = store();
let issued = issue(&store, "41", Scopes::any()).await;
let client = TestClient::new(router(&store));
for value in [
format!("bearer {}", issued.plain_text()),
format!("BEARER {}", issued.plain_text()),
format!("Bearer {}", issued.plain_text()),
] {
client
.send(Request::new(Method::Get, "/api/me").with_header("authorization", value))
.await
.assert_ok();
}
}
#[tokio::test]
async fn an_expired_token_is_refused_by_the_middleware() {
let store = store();
let issued = NewToken::new(Identity::new("41"), "expired")
.any()
.expires_at(1)
.issue(&*store)
.await
.unwrap();
TestClient::new(router(&store))
.send(bearing("/api/me", issued.plain_text()))
.await
.assert_status(401)
.assert_json("message", "This API token has expired.")
.assert_header("www-authenticate", r#"Bearer error="invalid_token""#);
}
#[tokio::test]
async fn a_revoked_token_stops_working_on_the_next_request() {
let store = store();
let issued = issue(&store, "41", Scopes::any()).await;
let client = TestClient::new(router(&store));
client.send(bearing("/api/me", issued.plain_text())).await.assert_ok();
store.delete(issued.token().id()).await.unwrap();
client.send(bearing("/api/me", issued.plain_text())).await.assert_status(401);
}
#[tokio::test]
async fn the_scope_middleware_answers_403_when_the_scope_is_missing() {
let store = store();
let reader = issue(&store, "41", Scopes::new().with("posts:read")).await;
let client = TestClient::new(router(&store));
for path in ["/api/posts", "/inline"] {
client
.send(bearing(path, reader.plain_text()))
.await
.assert_status(403)
.assert_json("message", "This API token is missing a required scope.")
.assert_json("scope", "posts:write");
}
}
#[tokio::test]
async fn the_scope_middleware_lets_a_scoped_token_through() {
let store = store();
let writer = issue(&store, "41", Scopes::new().with("posts:write")).await;
let wildcard = issue(&store, "41", Scopes::any()).await;
let client = TestClient::new(router(&store));
for token in [&writer, &wildcard] {
for path in ["/api/posts", "/inline"] {
let response = client.send(bearing(path, token.plain_text())).await;
response.assert_ok().assert_see("the posts");
}
}
}
#[tokio::test]
async fn the_scope_middleware_still_needs_a_token_first() {
let store = store();
let writer = issue(&store, "41", Scopes::new().with("posts:write")).await;
TestClient::new(router(&store))
.send(bearing("/loose", writer.plain_text()))
.await
.assert_status(500);
}
#[test]
fn the_missing_middleware_error_says_how_to_fix_it() {
let message = missing_token_middleware().to_string();
assert!(message.contains("needs an authenticated API token"), "message was {message}");
assert!(message.contains("RequireApiToken::shared"), "message was {message}");
}
#[test]
fn the_bearer_scheme_is_read_from_the_header_and_nothing_else() {
let get = |value: &str| {
let request = Request::new(Method::Get, "/").with_header("authorization", value);
bearer(&request).map(str::to_string)
};
assert_eq!(get("Bearer 7|secret").as_deref(), Some("7|secret"));
assert_eq!(get("bearer 7|secret").as_deref(), Some("7|secret"));
assert_eq!(get("Basic 7|secret"), None);
assert_eq!(get("Bearer"), None);
assert_eq!(get(""), None);
assert_eq!(bearer(&Request::new(Method::Get, "/")), None);
}
#[test]
fn a_request_without_a_token_can_do_nothing() {
let request = Request::new(Method::Get, "/");
assert!(request.api_token().is_none());
assert!(!request.token_can("posts:read"));
assert!(!request.token_can("*"));
}
}