1
//! Per-user JWT signing/verification for the web layer.
2
//!
3
//! Tokens are signed with each user's own RSA key (private key in their per-user
4
//! DB, public key in the global `users` directory). This module wraps the
5
//! `server` key lookups + the `token` codec into mint/verify helpers the auth
6
//! handlers call.
7
//!
8
//! Verification always reads the authoritative public key from the directory —
9
//! no public-key cache. A cache would risk accepting tokens signed with a
10
//! rotated/revoked key until process restart; correctness wins over saving one
11
//! indexed primary-key lookup. The DoS surface of unauthenticated lookups is
12
//! bounded by the per-address throttle on the auth routes
13
//! (`crate::rate_limit`), not by caching.
14

            
15
use crate::token::{self, TokenDetails, TokenType};
16
use axum::http::StatusCode;
17
use uuid::Uuid;
18

            
19
/// Whether auth cookies should carry the `Secure` attribute (sent only over
20
/// HTTPS). Defaults to `true`; set `INSECURE_COOKIES=1` for local plain-HTTP
21
/// development. Bearer cookies (access/refresh tokens) must be `Secure` in
22
/// production so they can't leak over a downgraded HTTP request.
23
#[must_use]
24
3
pub fn secure_cookies() -> bool {
25
    !matches!(
26
3
        std::env::var("INSECURE_COOKIES").as_deref(),
27
        Ok("1") | Ok("true")
28
    )
29
3
}
30

            
31
/// Mints a token of `token_type` for `user_id`, signed with that user's
32
/// private key.
33
/// `generation` is threaded in by the caller rather than re-read here: login
34
/// already holds it from the password check and refresh from the verified
35
/// token, so minting costs no extra query.
36
pub async fn mint(
37
    user_id: Uuid,
38
    ttl: i64,
39
    token_type: TokenType,
40
    generation: i32,
41
) -> Result<TokenDetails, MintError> {
42
    let private_key = server::auth_keys::private_key_for(user_id)
43
        .await
44
        .map_err(|e| match e {
45
            server::db::DBError::TenantQuarantined => MintError::Quarantined,
46
            server::db::DBError::KeyGen => MintError::KeyUnavailable,
47
            // A budget stall or a transient connect failure is not a missing
48
            // key: reporting it as one sent operators hunting a provisioning
49
            // bug, and told the caller the account was broken rather than busy.
50
            _ => MintError::Unavailable,
51
        })?;
52
    token::generate_jwt_token(user_id, ttl, &private_key, token_type, generation)
53
        .map_err(|_| MintError::Sign)
54
}
55

            
56
/// Verifies a token of the EXPECTED type against its owner's public key.
57
///
58
/// Reads the `sub` claim unverified as a routing hint ONLY, fetches that user's
59
/// authoritative public key from the directory, re-verifies the signature, and
60
/// requires the claimed `token_type` to match `expected` — so an access token
61
/// can never be replayed where a refresh token is required (or vice-versa).
62
/// Returns `Ok(None)` for any *credential* failure — unknown user, missing key,
63
/// bad signature, wrong type, disabled account, or a superseded session
64
/// generation — so callers cannot distinguish failure modes (existence
65
/// masking). `Err` is reserved for the directory being unreadable, which is
66
/// not the user's fault and must not read as an expired session. Always hits the DB for
67
/// the current key, status and generation, so a rotated key, a switched-off
68
/// account or a password change takes effect immediately.
69
3
pub async fn verify(
70
3
    token: &str,
71
3
    expected: TokenType,
72
3
) -> Result<Option<TokenDetails>, server::db::DBError> {
73
3
    let Ok(user_id) = token::unverified_user_id(token) else {
74
1
        return Ok(None);
75
    };
76
    // An unreadable directory is NOT a bad token: folding the two together told
77
    // a user with valid credentials that their session had expired, and sent
78
    // them down a refresh path that consumed it.
79
2
    let Some(identity) = server::auth_keys::public_key_for(user_id).await? else {
80
        return Ok(None);
81
    };
82
2
    let Ok(details) = token::verify_jwt_token(&identity.public_key, token) else {
83
        return Ok(None);
84
    };
85
    // After the signature, so a prober cannot time a disabled account apart
86
    // from an active one. Still ahead of every caller's refresh path, which is
87
    // what stops a stale refresh cookie consuming its Redis entry.
88
2
    if !identity.verified || details.generation != identity.generation {
89
1
        return Ok(None);
90
1
    }
91
1
    if details.token_type != Some(expected) {
92
        return Ok(None);
93
1
    }
94
1
    Ok(Some(details))
95
3
}
96

            
97
#[derive(Debug, Clone, Copy)]
98
pub enum MintError {
99
    /// The user has no stored signing key (a provisioning invariant violation).
100
    KeyUnavailable,
101
    /// The account is quarantined for maintenance.
102
    Quarantined,
103
    /// The tenant database could not be reached right now.
104
    Unavailable,
105
    /// Signing failed (malformed key material).
106
    Sign,
107
}
108

            
109
impl MintError {
110
    /// A tenant that is merely unreachable is a 503; a missing or broken key is
111
    /// ours to fix, so it stays a 500.
112
8
    pub(crate) const fn status(self) -> StatusCode {
113
8
        match self {
114
4
            MintError::Quarantined | MintError::Unavailable => StatusCode::SERVICE_UNAVAILABLE,
115
4
            MintError::KeyUnavailable | MintError::Sign => StatusCode::INTERNAL_SERVER_ERROR,
116
        }
117
8
    }
118
}
119

            
120
impl std::fmt::Display for MintError {
121
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
122
        match self {
123
            MintError::KeyUnavailable => write!(f, "no signing key available for user"),
124
            MintError::Quarantined => write!(f, "account is undergoing maintenance"),
125
            MintError::Unavailable => write!(f, "user database is temporarily unreachable"),
126
            MintError::Sign => write!(f, "failed to sign token"),
127
        }
128
    }
129
}
130

            
131
#[cfg(test)]
132
mod mint_error_tests {
133
    use super::{MintError, StatusCode};
134

            
135
    /// A tenant that is quarantined or briefly unreachable is not a missing
136
    /// key: reporting both as 500 told the caller the account was broken when
137
    /// it was only busy, and sent operators hunting a provisioning bug.
138
    #[test]
139
2
    fn an_unreachable_tenant_is_service_unavailable_not_a_server_error() {
140
2
        assert_eq!(
141
2
            MintError::Quarantined.status(),
142
            StatusCode::SERVICE_UNAVAILABLE
143
        );
144
2
        assert_eq!(
145
2
            MintError::Unavailable.status(),
146
            StatusCode::SERVICE_UNAVAILABLE
147
        );
148
2
        assert_eq!(
149
2
            MintError::KeyUnavailable.status(),
150
            StatusCode::INTERNAL_SERVER_ERROR
151
        );
152
2
        assert_eq!(MintError::Sign.status(), StatusCode::INTERNAL_SERVER_ERROR);
153
2
    }
154
}