wowlab_sentinel/http/
auth.rs1use 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#[derive(Clone, Debug)]
66pub(crate) struct VerifiedNode {
67 pub public_key: NodePublicKey,
68}
69
70#[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
119pub(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}