Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

New API feature: allow API clients to authenticate via authentication header #1367

Merged
merged 2 commits into from
Mar 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 80 additions & 5 deletions packages/axum-rest-tracker-api-server/src/v1/middlewares/auth.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,20 @@
//! Authentication middleware for the API.
//!
//! It uses a "token" GET param to authenticate the user. URLs must be of the
//! form:
//! It uses a "token" to authenticate the user. The token must be one of the
//! `access_tokens` in the tracker [HTTP API configuration](torrust_tracker_configuration::HttpApi).
//!
//! There are two ways to provide the token:
//!
//! 1. As a `Bearer` token in the `Authorization` header.
//! 2. As a `token` GET param in the URL.
//!
//! Using the `Authorization` header:
//!
//! ```console
//! curl -H "Authorization: Bearer MyAccessToken" http://<host>:<port>/api/v1/<context>
//! ```
//!
//! Using the `token` GET param:
//!
//! `http://<host>:<port>/api/v1/<context>?token=<token>`.
//!
Expand All @@ -21,6 +34,12 @@
//! All the tokes have the same permissions, so it is not possible to have
//! different permissions for different tokens. The label is only used to
//! identify the token.
//!
//! NOTICE: The token is not encrypted, so it is recommended to use HTTPS to
//! protect the token from being intercepted.
//!
//! NOTICE: If both the `Authorization` header and the `token` GET param are
//! provided, the `Authorization` header will be used.
use std::sync::Arc;

use axum::extract::{self};
Expand All @@ -32,6 +51,8 @@ use torrust_tracker_configuration::AccessTokens;

use crate::v1::responses::unhandled_rejection_response;

pub const AUTH_BEARER_TOKEN_HEADER_PREFIX: &str = "Bearer";

/// Container for the `token` extracted from the query params.
#[derive(Deserialize, Debug)]
pub struct QueryParams {
Expand All @@ -43,16 +64,29 @@ pub struct State {
pub access_tokens: Arc<AccessTokens>,
}

/// Middleware for authentication using a "token" GET param.
/// Middleware for authentication.
///
/// The token must be one of the tokens in the tracker [HTTP API configuration](torrust_tracker_configuration::HttpApi).
pub async fn auth(
extract::State(state): extract::State<State>,
extract::Query(params): extract::Query<QueryParams>,
request: Request<axum::body::Body>,
next: Next,
) -> Response {
let Some(token) = params.token else {
return AuthError::Unauthorized.into_response();
let token_from_header = match extract_bearer_token_from_header(&request) {
Ok(token) => token,
Err(err) => return err.into_response(),
};

let token_from_get_param = params.token.clone();

let provided_tokens = (token_from_header, token_from_get_param);

let token = match provided_tokens {
(Some(token_from_header), Some(_token_from_get_param)) => token_from_header,
(Some(token_from_header), None) => token_from_header,
(None, Some(token_from_get_param)) => token_from_get_param,
(None, None) => return AuthError::Unauthorized.into_response(),
};

if !authenticate(&token, &state.access_tokens) {
Expand All @@ -62,18 +96,50 @@ pub async fn auth(
next.run(request).await
}

fn extract_bearer_token_from_header(request: &Request<axum::body::Body>) -> Result<Option<String>, AuthError> {
let headers = request.headers();

let header_value = headers
.get(axum::http::header::AUTHORIZATION)
.and_then(|header_value| header_value.to_str().ok());

match header_value {
None => Ok(None),
Some(header_value) => {
if header_value == AUTH_BEARER_TOKEN_HEADER_PREFIX {
// Empty token
return Ok(Some(String::new()));
}

if !header_value.starts_with(&format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} ").to_string()) {
// Invalid token type. Missing "Bearer" prefix.
return Err(AuthError::UnknownTokenProvided);
}

Ok(header_value
.strip_prefix(&format!("{AUTH_BEARER_TOKEN_HEADER_PREFIX} ").to_string())
.map(std::string::ToString::to_string))
}
}
}

enum AuthError {
/// Missing token for authentication.
Unauthorized,

/// Token was provided but it is not valid.
TokenNotValid,

/// Token was provided but it is not in a format that the server can't understands.
UnknownTokenProvided,
}

impl IntoResponse for AuthError {
fn into_response(self) -> Response {
match self {
AuthError::Unauthorized => unauthorized_response(),
AuthError::TokenNotValid => token_not_valid_response(),
AuthError::UnknownTokenProvided => unknown_auth_data_provided_response(),
}
}
}
Expand All @@ -93,3 +159,12 @@ pub fn unauthorized_response() -> Response {
pub fn token_not_valid_response() -> Response {
unhandled_rejection_response("token not valid".to_string())
}

/// `500` error response when the provided token type is not valid.
///
/// The client has provided authentication information that the server does not
/// understand.
#[must_use]
pub fn unknown_auth_data_provided_response() -> Response {
unhandled_rejection_response("unknown token provided".to_string())
}
Loading