Skip to main content

doido_auth/
jwt.rs

1//! JWT bearer strategy — sign/verify access and refresh tokens.
2
3use crate::config::JwtConfig;
4use crate::error::AuthError;
5use crate::identity::AuthIdentity;
6use crate::strategy::AuthStrategy;
7use async_trait::async_trait;
8use chrono::{Duration, Utc};
9use doido_core::Result;
10use doido_model::sea_orm::DatabaseConnection;
11use http::header;
12use http::request::Parts;
13use jsonwebtoken::{decode, encode, Algorithm, DecodingKey, EncodingKey, Header, Validation};
14use serde::{Deserialize, Serialize};
15use serde_json::Value;
16
17/// JWT claims for access and refresh tokens.
18#[derive(Debug, Clone, Serialize, Deserialize)]
19pub struct JwtClaims {
20    pub sub: Value,
21    pub exp: i64,
22    pub iat: i64,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub iss: Option<String>,
25    #[serde(default, skip_serializing_if = "Option::is_none")]
26    pub typ: Option<String>,
27}
28
29/// Issued token pair.
30#[derive(Debug, Clone, Serialize, Deserialize)]
31pub struct TokenPair {
32    pub access_token: String,
33    pub refresh_token: String,
34    pub token_type: String,
35    pub expires_in: u64,
36}
37
38/// JWT bearer strategy.
39pub struct JwtStrategy {
40    config: JwtConfig,
41    encoding: EncodingKey,
42    decoding: DecodingKey,
43}
44
45impl JwtStrategy {
46    pub fn new(config: JwtConfig) -> Result<Self, AuthError> {
47        config.validate()?;
48        let secret = config.secret.as_bytes();
49        Ok(Self {
50            config: config.clone(),
51            encoding: EncodingKey::from_secret(secret),
52            decoding: DecodingKey::from_secret(secret),
53        })
54    }
55
56    fn validation(&self) -> Validation {
57        let mut validation = Validation::new(Algorithm::HS256);
58        if let Some(iss) = &self.config.issuer {
59            validation.set_issuer(&[iss.as_str()]);
60        }
61        validation
62    }
63
64    fn issue_token(&self, user_id: &Value, ttl_secs: u64, typ: &str) -> Result<String, AuthError> {
65        let now = Utc::now();
66        let claims = JwtClaims {
67            sub: user_id.clone(),
68            iat: now.timestamp(),
69            exp: (now + Duration::seconds(ttl_secs as i64)).timestamp(),
70            iss: self.config.issuer.clone(),
71            typ: Some(typ.into()),
72        };
73        encode(&Header::default(), &claims, &self.encoding).map_err(AuthError::from)
74    }
75
76    /// Issue an access + refresh token pair for `user_id`.
77    pub fn issue_tokens(&self, user_id: &Value) -> Result<TokenPair, AuthError> {
78        Ok(TokenPair {
79            access_token: self.issue_token(user_id, self.config.access_ttl, "access")?,
80            refresh_token: self.issue_token(user_id, self.config.refresh_ttl, "refresh")?,
81            token_type: "Bearer".into(),
82            expires_in: self.config.access_ttl,
83        })
84    }
85
86    /// Verify a token and return its claims.
87    pub fn verify_token(&self, token: &str) -> Result<JwtClaims, AuthError> {
88        decode::<JwtClaims>(token, &self.decoding, &self.validation())
89            .map(|data| data.claims)
90            .map_err(AuthError::from)
91    }
92
93    fn bearer_token(parts: &Parts) -> Option<String> {
94        let value = parts.headers.get(header::AUTHORIZATION)?.to_str().ok()?;
95        let token = value.strip_prefix("Bearer ")?.trim();
96        if token.is_empty() {
97            None
98        } else {
99            Some(token.to_string())
100        }
101    }
102}
103
104#[async_trait]
105impl AuthStrategy for JwtStrategy {
106    fn name(&self) -> &str {
107        "jwt"
108    }
109
110    async fn authenticate(
111        &self,
112        parts: &Parts,
113        _db: &DatabaseConnection,
114    ) -> Result<Option<AuthIdentity>> {
115        let token = match Self::bearer_token(parts) {
116            Some(t) => t,
117            None => return Ok(None),
118        };
119        let claims = self.verify_token(&token)?;
120        Ok(Some(AuthIdentity {
121            user_id: claims.sub,
122        }))
123    }
124}