Skip to main content

soma_auth/
util.rs

1#![allow(clippy::redundant_pub_crate)]
2
3use std::fmt::Write as _;
4#[cfg(feature = "http-axum")]
5use std::net::{IpAddr, SocketAddr};
6use std::path::Path;
7#[cfg(feature = "http-axum")]
8use std::time::Duration;
9
10#[cfg(feature = "http-axum")]
11use axum::Json;
12#[cfg(feature = "http-axum")]
13use axum::http::{HeaderValue, StatusCode, header};
14#[cfg(feature = "http-axum")]
15use axum::response::{IntoResponse, Response};
16#[cfg(feature = "http-axum")]
17use base64::Engine;
18#[cfg(feature = "http-axum")]
19use base64::engine::general_purpose::URL_SAFE_NO_PAD;
20use sha2::{Digest, Sha256};
21
22use crate::error::AuthError;
23#[cfg(feature = "http-axum")]
24use crate::error::AuthErrorKind;
25
26/// Extract the `IpAddr` from a `SocketAddr`, normalizing IPv4-mapped IPv6
27/// addresses (`::ffff:a.b.c.d`) back to plain IPv4 so per-IP rate-limiting
28/// keys are consistent regardless of listener address family.
29#[cfg(feature = "http-axum")]
30pub(crate) fn remote_ip(addr: SocketAddr) -> IpAddr {
31    match addr.ip() {
32        IpAddr::V6(v6) => v6
33            .to_ipv4_mapped()
34            .map(IpAddr::V4)
35            .unwrap_or(IpAddr::V6(v6)),
36        v4 => v4,
37    }
38}
39
40pub fn now_unix() -> i64 {
41    let secs = std::time::SystemTime::now()
42        .duration_since(std::time::UNIX_EPOCH)
43        .unwrap_or_default()
44        .as_secs();
45    i64::try_from(secs).unwrap_or(i64::MAX)
46}
47
48#[cfg(feature = "http-axum")]
49pub(crate) fn random_token(bytes: usize) -> Result<String, AuthError> {
50    let mut buf = vec![0_u8; bytes];
51    getrandom::fill(&mut buf)
52        .map_err(|error| AuthError::Storage(format!("generate random token: {error}")))?;
53    Ok(URL_SAFE_NO_PAD.encode(buf))
54}
55
56pub fn fingerprint(value: &str) -> String {
57    let digest = Sha256::digest(value.as_bytes());
58    let mut output = String::with_capacity(12);
59    for byte in &digest[..6] {
60        let _ = write!(&mut output, "{byte:02x}");
61    }
62    output
63}
64
65#[cfg(unix)]
66pub(crate) fn ensure_restrictive_permissions(path: &Path) -> Result<(), AuthError> {
67    use std::os::unix::fs::PermissionsExt;
68
69    let metadata = std::fs::metadata(path)
70        .map_err(|error| AuthError::Storage(format!("stat `{}`: {error}", path.display())))?;
71    let mode = metadata.permissions().mode() & 0o777;
72    if mode & 0o077 != 0 {
73        return Err(AuthError::InsecurePermissions {
74            path: path.to_path_buf(),
75        });
76    }
77    Ok(())
78}
79
80#[cfg(not(unix))]
81pub(crate) fn ensure_restrictive_permissions(_path: &Path) -> Result<(), AuthError> {
82    Ok(())
83}
84
85#[cfg(unix)]
86pub(crate) fn set_restrictive_permissions(path: &Path) -> Result<(), AuthError> {
87    use std::os::unix::fs::PermissionsExt;
88
89    std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
90        .map_err(|error| AuthError::Storage(format!("chmod 0600 `{}`: {error}", path.display())))
91}
92
93#[cfg(not(unix))]
94pub(crate) fn set_restrictive_permissions(_path: &Path) -> Result<(), AuthError> {
95    Ok(())
96}
97
98#[cfg(feature = "http-axum")]
99pub(crate) fn duration_secs_i64(duration: Duration, field: &str) -> Result<i64, AuthError> {
100    i64::try_from(duration.as_secs())
101        .map_err(|_| AuthError::Config(format!("{field} exceeds supported range")))
102}
103
104#[cfg(feature = "http-axum")]
105pub(crate) fn duration_secs_usize(duration: Duration, field: &str) -> Result<usize, AuthError> {
106    usize::try_from(duration.as_secs())
107        .map_err(|_| AuthError::Config(format!("{field} exceeds supported range")))
108}
109
110#[cfg(feature = "http-axum")]
111pub(crate) fn timestamp_usize(timestamp: i64, field: &str) -> Result<usize, AuthError> {
112    usize::try_from(timestamp)
113        .map_err(|_| AuthError::Storage(format!("{field} is negative or exceeds usize range")))
114}
115
116/// Stamp `Cache-Control: no-store` on a response, without the HTTP/1.0-era
117/// `Pragma` companion.
118///
119/// This is the weaker of the crate's two cache stamps and exists because
120/// `authorize.rs`'s browser-facing HTML pages and the `/native/poll` JSON
121/// only ever carried `Cache-Control`. Adding `Pragma` there would be a
122/// behaviour change, so the difference is kept explicit in the name rather
123/// than quietly harmonized. Anything that can carry token material or an
124/// OAuth error object wants [`apply_no_store`] instead.
125#[cfg(feature = "http-axum")]
126pub(crate) fn apply_cache_control_no_store(mut response: Response) -> Response {
127    response
128        .headers_mut()
129        .insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
130    response
131}
132
133/// Stamp the RFC 6749 section 5.1 `Cache-Control: no-store` +
134/// `Pragma: no-cache` pair on a response.
135///
136/// Used by every surface that can return token material or an OAuth error
137/// object: `/token` (success and failure), `/revoke`, `/register`, and
138/// [`AuthError`]'s own `IntoResponse`.
139#[cfg(feature = "http-axum")]
140pub(crate) fn apply_no_store(response: Response) -> Response {
141    let mut response = apply_cache_control_no_store(response);
142    response
143        .headers_mut()
144        .insert(header::PRAGMA, HeaderValue::from_static("no-cache"));
145    response
146}
147
148/// Build the RFC 6749 section 5.2 error object shared by `/token`
149/// (RFC 6749), `/revoke` (RFC 7009 section 2.2.1, which adopts 5.2
150/// wholesale), and `/register` (RFC 7591 section 3.2.2, which uses the same
151/// two-field body with its own registry of `error` codes).
152///
153/// Each endpoint keeps its own variant-to-code, variant-to-status, and
154/// variant-to-description mapping -- those genuinely differ, and RFC 7591's
155/// codes are a different registry from RFC 6749's. Only the assembly is
156/// shared: the JSON body, the [`AuthErrorKind`] response extension the access
157/// log reads, the `Retry-After` seconds conversion, and the cache stamp.
158#[cfg(feature = "http-axum")]
159pub(crate) fn oauth_error_response(
160    status: StatusCode,
161    oauth_error: &'static str,
162    description: String,
163    log_kind: &'static str,
164    retry_after_ms: Option<u64>,
165) -> Response {
166    let body = Json(serde_json::json!({
167        "error": oauth_error,
168        "error_description": description,
169    }));
170    let mut response = (status, body).into_response();
171    response.extensions_mut().insert(AuthErrorKind(log_kind));
172    if let Some(retry_after_ms) = retry_after_ms
173        && let Ok(value) = HeaderValue::from_str(&(retry_after_ms / 1_000).max(1).to_string())
174    {
175        response.headers_mut().insert(header::RETRY_AFTER, value);
176    }
177    apply_no_store(response)
178}
179
180#[cfg(feature = "http-axum")]
181pub(crate) fn expires_at(
182    created_at: i64,
183    duration: Duration,
184    field: &str,
185) -> Result<i64, AuthError> {
186    let ttl = duration_secs_i64(duration, field)?;
187    created_at
188        .checked_add(ttl)
189        .ok_or_else(|| AuthError::Config(format!("{field} exceeds supported range")))
190}