Skip to main content

wowlab_sentinel/http/
auth.rs

1use std::sync::Arc;
2
3use axum::{
4    body::Body,
5    extract::{Request, State},
6    middleware::Next,
7    response::{IntoResponse, Response},
8};
9use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD as BASE64};
10use wowlab_common::{NodePublicKey, time};
11
12use crate::{http::api_error::ApiError, state::ServerState};
13
14#[derive(Debug, thiserror::Error)]
15pub(super) enum AuthError {
16    #[error("missing authorization header")]
17    MissingAuthorization,
18    #[error("invalid token")]
19    InvalidToken {
20        #[source]
21        source: Option<sqlx::Error>,
22    },
23    #[error("missing X-Node-Key")]
24    MissingNodeKey,
25    #[error("missing X-Node-Sig")]
26    MissingNodeSignature,
27    #[error("missing X-Node-Ts")]
28    MissingNodeTimestamp,
29    #[error("invalid timestamp")]
30    InvalidTimestamp(#[source] std::num::ParseIntError),
31    #[error("timestamp expired")]
32    TimestampExpired,
33    #[error("invalid key encoding")]
34    InvalidKeyEncoding(#[source] wowlab_common::InvalidNodePublicKey),
35    #[error("invalid signature length")]
36    InvalidSignatureLength,
37    #[error("invalid signature encoding")]
38    InvalidSignatureEncoding(#[source] base64::DecodeError),
39    #[error("body too large")]
40    BodyTooLarge(#[source] axum::Error),
41    #[error("invalid signature")]
42    InvalidSignature(#[source] wowlab_parsers::CryptoError),
43}
44
45impl AuthError {
46    pub(super) const fn wire_message(&self) -> &'static str {
47        match self {
48            Self::MissingAuthorization => "Missing Authorization header",
49            Self::InvalidToken { .. } => "Invalid token",
50            Self::MissingNodeKey => "Missing X-Node-Key",
51            Self::MissingNodeSignature => "Missing X-Node-Sig",
52            Self::MissingNodeTimestamp => "Missing X-Node-Ts",
53            Self::InvalidTimestamp(_) => "Invalid timestamp",
54            Self::TimestampExpired => "Timestamp expired",
55            Self::InvalidKeyEncoding(_) => "Invalid key encoding",
56            Self::InvalidSignatureLength => "Invalid signature length",
57            Self::InvalidSignatureEncoding(_) => "Invalid signature encoding",
58            Self::BodyTooLarge(_) => "Body too large",
59            Self::InvalidSignature(_) => "Invalid signature",
60        }
61    }
62}
63
64/// A node whose identity has been verified via Ed25519 signature.
65#[derive(Clone, Debug)]
66pub(crate) struct VerifiedNode {
67    pub public_key: NodePublicKey,
68}
69
70/// A user authenticated via Supabase JWT token.
71#[derive(Clone, Debug)]
72pub(crate) struct AuthenticatedUser {
73    pub user_id: uuid::Uuid,
74}
75
76pub(crate) async fn verify_token_api(
77    State(state): State<Arc<ServerState>>,
78    mut request: axum::http::Request<Body>,
79    next: Next,
80) -> Response {
81    let token = request
82        .headers()
83        .get("Authorization")
84        .and_then(|h| h.to_str().ok())
85        .and_then(|h| h.strip_prefix("Bearer "));
86
87    let Some(token) = token else {
88        return ApiError::from(AuthError::MissingAuthorization).into_response();
89    };
90
91    let user_id = match sqlx::query_file_scalar!("queries/auth_get_user_by_token_api.sql", token)
92        .fetch_optional(state.dbs.get::<crate::http::HttpDb>())
93        .await
94    {
95        Ok(user_id) => user_id,
96        Err(source) => {
97            return ApiError::from(AuthError::InvalidToken {
98                source: Some(source),
99            })
100            .into_response();
101        }
102    };
103
104    let Some(user_id) = user_id else {
105        return ApiError::from(AuthError::InvalidToken { source: None }).into_response();
106    };
107
108    request
109        .extensions_mut()
110        .insert(AuthenticatedUser { user_id });
111
112    next.run(request).await
113}
114
115const MAX_CLOCK_SKEW: u64 = 300;
116const MAX_BODY_SIZE: usize = 1024 * 1024;
117const ED25519_SIGNATURE_LEN: usize = 64;
118
119// #t(rust_cyclomatic_complexity) sequential auth validation steps with early error returns
120pub(crate) async fn verify_node(request: Request, next: Next) -> Response {
121    let (parts, body) = request.into_parts();
122
123    let pubkey_b64 = match parts.headers.get("X-Node-Key") {
124        Some(v) => v.to_str().unwrap_or("").to_string(),
125        None => return auth_error(AuthError::MissingNodeKey),
126    };
127    let sig_b64 = match parts.headers.get("X-Node-Sig") {
128        Some(v) => v.to_str().unwrap_or("").to_string(),
129        None => return auth_error(AuthError::MissingNodeSignature),
130    };
131    let ts_str = match parts.headers.get("X-Node-Ts") {
132        Some(v) => v.to_str().unwrap_or("").to_string(),
133        None => return auth_error(AuthError::MissingNodeTimestamp),
134    };
135    let host = parts
136        .headers
137        .get("Host")
138        .and_then(|v| v.to_str().ok())
139        .map_or("", |h| h.split(':').next().unwrap_or(h));
140
141    let timestamp: u64 = match ts_str.parse() {
142        Ok(t) => t,
143        Err(source) => return auth_error(AuthError::InvalidTimestamp(source)),
144    };
145    let now = time::unix_timestamp_secs();
146
147    if now.abs_diff(timestamp) > MAX_CLOCK_SKEW {
148        return auth_error(AuthError::TimestampExpired);
149    }
150
151    let public_key: NodePublicKey = match pubkey_b64.parse() {
152        Ok(k) => k,
153        Err(source) => return auth_error(AuthError::InvalidKeyEncoding(source)),
154    };
155
156    let signature = match BASE64.decode(&sig_b64) {
157        Ok(b) if b.len() == ED25519_SIGNATURE_LEN => b,
158        Ok(_) => return auth_error(AuthError::InvalidSignatureLength),
159        Err(source) => return auth_error(AuthError::InvalidSignatureEncoding(source)),
160    };
161
162    let body_bytes = match axum::body::to_bytes(body, MAX_BODY_SIZE).await {
163        Ok(body) => body,
164        Err(source) => return auth_error(AuthError::BodyTooLarge(source)),
165    };
166
167    let message = wowlab_parsers::build_sign_message(
168        timestamp,
169        parts.method.as_str(),
170        host,
171        parts.uri.path(),
172        &body_bytes,
173    );
174
175    if let Err(source) =
176        wowlab_parsers::verify_signature(public_key.as_bytes(), message.as_bytes(), &signature)
177    {
178        return auth_error(AuthError::InvalidSignature(source));
179    }
180
181    let mut request = Request::from_parts(parts, Body::from(body_bytes));
182
183    request.extensions_mut().insert(VerifiedNode { public_key });
184
185    next.run(request).await
186}
187
188fn auth_error(error: AuthError) -> Response {
189    ApiError::from(error).into_response()
190}
191
192#[cfg(test)]
193mod tests {
194    use googletest::prelude::*;
195
196    use super::*;
197
198    #[gtest]
199    #[tokio::test]
200    async fn node_auth_error_preserves_status_and_wire_body() -> Result<()> {
201        let response = auth_error(AuthError::InvalidSignature(
202            wowlab_parsers::verify_signature(&[0; 32], b"message", &[0; 64])
203                .err()
204                .or_fail()?,
205        ));
206
207        verify_eq!(response.status(), axum::http::StatusCode::UNAUTHORIZED)?;
208        let body = axum::body::to_bytes(response.into_body(), usize::MAX)
209            .await
210            .or_fail()?;
211
212        verify_eq!(body.as_ref(), br#"{"error":"Invalid signature"}"#)?;
213
214        Ok(())
215    }
216}