Skip to main content

better_auth_api/plugins/
jwt.rs

1//! Persisted JWT signing keys and public JWKS discovery.
2
3use crate::plugins::endpoint_context::EndpointContext;
4use better_auth_core::{
5    AuthContext, AuthError, AuthInitContext, AuthPlugin, AuthRequest, AuthResponse, AuthResult,
6    AuthRoute, AuthSchema, CreateJwk, HttpMethod,
7};
8use chrono::{Duration, Utc};
9use josekit::{
10    jwk::{self, Jwk},
11    jws::{self, JwsHeader, JwsSigner},
12};
13use serde_json::{Map, Value, json};
14
15mod adapter;
16mod cache;
17mod callbacks;
18mod claims;
19mod native;
20mod options;
21mod overrides;
22mod signing;
23mod verification;
24pub use callbacks::{JwtAdapterFuture, JwtCallbacks};
25pub use native::JwtApi;
26pub use options::*;
27pub use overrides::{JwtCallOverrides, JwtKeyOptions, JwtTokenOptions};
28
29/// Supported asymmetric signing algorithms.
30#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, serde::Deserialize)]
31pub enum JwtAlgorithm {
32    /// Ed25519, the upstream default.
33    #[default]
34    #[serde(rename = "EdDSA")]
35    EdDsa,
36    /// RSA PKCS#1 v1.5 with SHA-256 and a 2048-bit key.
37    #[serde(rename = "RS256")]
38    Rs256,
39    /// ECDSA P-256 with SHA-256.
40    #[serde(rename = "ES256")]
41    Es256,
42    /// ECDSA P-521 with SHA-512.
43    #[serde(rename = "ES512")]
44    Es512,
45    /// RSA PSS with SHA-256.
46    #[serde(rename = "PS256")]
47    Ps256,
48}
49
50impl JwtAlgorithm {
51    fn name(self) -> &'static str {
52        match self {
53            Self::EdDsa => "EdDSA",
54            Self::Rs256 => "RS256",
55            Self::Es256 => "ES256",
56            Self::Es512 => "ES512",
57            Self::Ps256 => "PS256",
58        }
59    }
60    fn curve(self) -> Option<&'static str> {
61        match self {
62            Self::EdDsa => Some("Ed25519"),
63            Self::Es256 => Some("P-256"),
64            Self::Es512 => Some("P-521"),
65            Self::Rs256 | Self::Ps256 => None,
66        }
67    }
68    fn generate(self, modulus_length: u32) -> Result<Jwk, josekit::JoseError> {
69        match self {
70            Self::EdDsa => Jwk::generate_ed_key(jwk::Ed25519),
71            Self::Rs256 | Self::Ps256 => Jwk::generate_rsa_key(modulus_length),
72            Self::Es256 => Jwk::generate_ec_key(jwk::P_256),
73            Self::Es512 => Jwk::generate_ec_key(jwk::P_521),
74        }
75    }
76}
77
78/// Local signing and discovery options.
79#[derive(Clone, better_auth_core::PluginConfig)]
80#[plugin(name = "JwtPlugin")]
81pub struct JwtPluginConfig {
82    /// Use locally managed keys for JWT session cookie caches.
83    #[config(default = false)]
84    pub session_cookie_cache: bool,
85    /// RSA modulus length for the primary algorithm.
86    #[config(default = 2048)]
87    pub modulus_length: u32,
88    /// Additional algorithms that explicit signing requests may provision.
89    #[config(default = Vec::new())]
90    pub key_pair_configs: Vec<JwtKeyPairConfig>,
91    /// Remote discovery URL. Disables local discovery; does not change the verifier's adapter.
92    #[config(default = None)]
93    pub remote_url: Option<String>,
94    /// Application-managed signing callback. Requires `remote_url`.
95    #[config(default = None)]
96    pub custom_sign: Option<JwtCustomSign>,
97    /// Session payload callback, before default claims and subject selection.
98    #[config(default = None)]
99    pub define_payload: Option<JwtDefinePayload>,
100    /// Session subject callback.
101    #[config(default = None)]
102    pub get_subject: Option<JwtGetSubject>,
103    /// Public discovery path.
104    #[config(default = "/jwks".to_owned())]
105    pub jwks_path: String,
106    /// Algorithm used for newly generated keys.
107    #[config(default = JwtAlgorithm::EdDsa)]
108    pub algorithm: JwtAlgorithm,
109    /// Relative token lifetime or absolute expiration timestamp.
110    #[config(default = JwtExpiration::After(Duration::minutes(15)), skip)]
111    pub expiration_time: JwtExpiration,
112    /// Issuer claim; defaults to the configured base URL.
113    #[config(default = None)]
114    pub issuer: Option<String>,
115    /// Audience claim; defaults to the configured base URL.
116    #[config(default = None)]
117    pub audience: Option<JwtAudience>,
118    /// Lifetime of signing keys; no automatic rotation when absent.
119    #[config(default = None)]
120    pub rotation_interval: Option<Duration>,
121    /// Keep expired public keys discoverable for previously issued tokens.
122    #[config(default = Duration::days(30))]
123    pub grace_period: Duration,
124    /// Store plaintext private JWKs instead of secret-encrypted JWKs.
125    #[config(default = false)]
126    pub disable_private_key_encryption: bool,
127    /// Suppress the JWT header on successful session responses.
128    #[config(default = false)]
129    pub disable_setting_jwt_header: bool,
130}
131
132/// Issue session JWTs with keys persisted through `JwksStore`.
133pub struct JwtPlugin {
134    config: JwtPluginConfig,
135}
136
137impl JwtPlugin {
138    /// Set a relative duration, an absolute date, or a NumericDate in seconds.
139    pub fn expiration_time(mut self, expiration: impl Into<JwtExpiration>) -> Self {
140        self.config.expiration_time = expiration.into();
141        self
142    }
143}
144
145#[async_trait::async_trait]
146impl<S: AuthSchema> AuthPlugin<S> for JwtPlugin {
147    fn name(&self) -> &'static str {
148        "jwt"
149    }
150    fn routes(&self) -> Vec<AuthRoute> {
151        vec![
152            AuthRoute::get(&self.config.jwks_path, "getJSONWebKeySet"),
153            AuthRoute::get("/token", "getJSONWebToken").require_headers(true),
154        ]
155    }
156    async fn on_init(&self, ctx: &mut AuthInitContext<S>) -> AuthResult<()> {
157        ctx.extensions.insert(self.config.clone());
158        if self.config.custom_sign.is_some() && self.config.remote_url.is_none() {
159            return Err(AuthError::config("custom_sign requires remote_url"));
160        }
161        if !self.config.jwks_path.starts_with('/') || self.config.jwks_path.contains("..") {
162            return Err(AuthError::config(
163                "jwks_path must start with '/' and must not contain '..'",
164            ));
165        }
166        if self.config.session_cookie_cache {
167            if !ctx
168                .config
169                .session
170                .cookie_cache
171                .as_ref()
172                .is_some_and(|cache| {
173                    matches!(
174                        cache.strategy(),
175                        better_auth_core::config::CookieCacheStrategy::Jwt
176                    )
177                })
178            {
179                return Err(AuthError::config(
180                    "session_cookie_cache requires the JWT cookie cache strategy",
181                ));
182            }
183            if self.config.custom_sign.is_some() {
184                return Err(AuthError::config(
185                    "session_cookie_cache requires locally managed JWT keys",
186                ));
187            }
188            let signer: std::sync::Arc<dyn better_auth_core::session::SessionCookieSigner<S>> =
189                std::sync::Arc::new(cache::CookieSigner {
190                    plugin: Self::with_config(self.config.clone()),
191                    runtime: ctx.runtime(),
192                });
193            ctx.extensions.insert(signer);
194        }
195        Ok(())
196    }
197    async fn on_request(
198        &self,
199        req: &AuthRequest,
200        ctx: &AuthContext<S>,
201    ) -> AuthResult<Option<AuthResponse>> {
202        if req.method() != &HttpMethod::Get
203            || (req.path() != self.config.jwks_path && req.path() != "/token")
204        {
205            return Ok(None);
206        }
207        let mut endpoint = EndpointContext::new(Some(req), request_body(req)?, ctx);
208        if req.path() == self.config.jwks_path {
209            return self.jwks(&endpoint).await.map(Some);
210        }
211        if req.path() == "/token" {
212            let (user, session) = ctx
213                .require_session(req)
214                .await
215                .map_err(|error| match error {
216                    AuthError::Unauthenticated => AuthError::Upstream {
217                        status: 401,
218                        code: "UNAUTHORIZED",
219                        message: "Unauthorized",
220                    },
221                    error => error,
222                })?;
223            endpoint.session = Some((user.clone(), session.clone()));
224            let token = self
225                .sign_session(json!({"user": user, "session": session}), &endpoint)
226                .await?;
227            return Ok(Some(AuthResponse::json(200, &json!({"token": token}))?));
228        }
229        Ok(None)
230    }
231    async fn after_request(
232        &self,
233        req: &AuthRequest,
234        response: &mut AuthResponse,
235        ctx: &AuthContext<S>,
236    ) -> AuthResult<()> {
237        if !(req.path() == "/get-session") {
238            return Ok(());
239        }
240        better_auth_core::observability::instrumentation::with_endpoint_hook(
241            &ctx.config,
242            req,
243            "after",
244            "plugin:jwt",
245            async {
246                if self.config.disable_setting_jwt_header || req.path() != "/get-session" {
247                    return Ok(());
248                }
249                let snapshot = req.session_snapshot()?;
250                let Some(data) = snapshot
251                    .clone()
252                    .map(better_auth_core::session::NativeSessionData::from)
253                    .or(req.new_session()?)
254                else {
255                    return Ok(());
256                };
257                let payload = serde_json::to_value(&data)?;
258                let mut endpoint = EndpointContext::new(Some(req), request_body(req)?, ctx);
259                endpoint.session = snapshot.map(|data| (data.user, data.session));
260                endpoint.response = Some(response);
261                let token = self.sign_session(payload, &endpoint).await?;
262                let _ = response.headers.insert("set-auth-jwt", token);
263                let mut exposed: Vec<_> = response
264                    .headers
265                    .get("access-control-expose-headers")
266                    .map(|value| {
267                        value
268                            .split(',')
269                            .map(str::trim)
270                            .filter(|value| !value.is_empty())
271                            .map(str::to_owned)
272                            .collect()
273                    })
274                    .unwrap_or_default();
275                if !exposed.iter().any(|value| value == "set-auth-jwt") {
276                    exposed.push("set-auth-jwt".into());
277                }
278                let _ = response
279                    .headers
280                    .insert("Access-Control-Expose-Headers", exposed.join(", "));
281                Ok(())
282            },
283        )
284        .await
285    }
286}
287
288impl JwtPlugin {
289    async fn create_key<S: AuthSchema>(
290        &self,
291        endpoint: &EndpointContext<'_, S>,
292    ) -> AuthResult<better_auth_core::Jwk> {
293        self.create_key_pair_in_endpoint(
294            JwtKeyPairConfig {
295                algorithm: self.config.algorithm,
296                modulus_length: self.config.modulus_length,
297            },
298            endpoint,
299        )
300        .await
301    }
302
303    /// Provision a local key with explicit parameters.
304    pub async fn create_key_pair<S: AuthSchema>(
305        &self,
306        parameters: JwtKeyPairConfig,
307        ctx: &AuthContext<S>,
308    ) -> AuthResult<better_auth_core::Jwk> {
309        ctx.with_native_context(Default::default(), |resolved| async move {
310            let mut endpoint =
311                EndpointContext::new(None, better_auth_core::FieldValue::Null, &resolved);
312            endpoint.path = Some("virtual:");
313            self.create_key_pair_in_endpoint(parameters, &endpoint)
314                .await
315        })
316        .await
317    }
318
319    /// Provision a key with the supplied endpoint context and active transaction.
320    pub async fn create_key_pair_in_endpoint<S: AuthSchema>(
321        &self,
322        parameters: JwtKeyPairConfig,
323        endpoint: &EndpointContext<'_, S>,
324    ) -> AuthResult<better_auth_core::Jwk> {
325        if matches!(
326            parameters.algorithm,
327            JwtAlgorithm::Rs256 | JwtAlgorithm::Ps256
328        ) && parameters.modulus_length < 2048
329        {
330            return Err(AuthError::config(
331                "RSA modulus_length must be at least 2048",
332            ));
333        }
334        let mut key = parameters
335            .algorithm
336            .generate(parameters.modulus_length)
337            .map_err(jose_error)?;
338        key.set_parameter("use", None).map_err(jose_error)?;
339        let public_key = key.to_public_key().map_err(jose_error)?.to_string();
340        let private_key = if self.config.disable_private_key_encryption {
341            key.to_string()
342        } else {
343            serde_json::to_string(&super::symmetric::encrypt(
344                endpoint.auth.config.encryption_secret(),
345                &key.to_string(),
346            )?)?
347        };
348        self.persist_key(
349            CreateJwk {
350                additional_fields: Default::default(),
351                public_key,
352                private_key,
353                created_at: Utc::now().into(),
354                expires_at: self
355                    .config
356                    .rotation_interval
357                    .filter(|interval| !interval.is_zero())
358                    .map(|interval| (Utc::now() + interval).into()),
359                alg: parameters.algorithm.name().to_owned(),
360                crv: parameters.algorithm.curve().map(str::to_owned),
361            },
362            endpoint,
363        )
364        .await
365    }
366
367    async fn jwks<S: AuthSchema>(
368        &self,
369        endpoint: &EndpointContext<'_, S>,
370    ) -> AuthResult<AuthResponse> {
371        if self.config.remote_url.is_some() {
372            let mut response = AuthResponse::new(404);
373            let _ = response.headers.insert("content-type", "application/json");
374            return Ok(response);
375        }
376        let mut keys = self.read_keys(endpoint).await?;
377        if keys.as_ref().is_none_or(Vec::is_empty) {
378            let _ = self.create_key(endpoint).await?;
379            keys = self.read_keys(endpoint).await?;
380        }
381        let keys = keys.filter(|keys| !keys.is_empty()).ok_or_else(|| {
382            AuthError::internal("No key sets found. Make sure you have a key in your database.")
383        })?;
384        let mut public = Vec::new();
385        for key in keys.into_iter().filter(|key| {
386            key.expires_at.as_ref().is_none_or(|expiry| {
387                expiry.milliseconds() + self.config.grace_period.num_milliseconds() as f64
388                    > Utc::now().timestamp_millis() as f64
389            })
390        }) {
391            let mut value: Map<String, Value> = serde_json::from_str(&key.public_key)?;
392            let _ = value.entry("alg").or_insert_with(|| {
393                key.alg
394                    .unwrap_or_else(|| self.config.algorithm.name().to_owned())
395                    .into()
396            });
397            if let Some(curve) = key.crv {
398                let _ = value.entry("crv").or_insert(curve.into());
399            }
400            if let Some(id) = key.id.json()? {
401                let _ = value.insert("kid".into(), id);
402            }
403            public.push(value);
404        }
405        Ok(AuthResponse::json(200, &json!({"keys": public}))?)
406    }
407}
408
409fn request_body(request: &AuthRequest) -> AuthResult<better_auth_core::FieldValue> {
410    if let Some(context) = better_auth_core::hooks::current_request_hook_context() {
411        return Ok(context.body);
412    }
413    request.input_field_value()
414}
415
416fn jose_error(error: josekit::JoseError) -> AuthError {
417    AuthError::internal(format!("JWT signing key: {error}"))
418}
419
420#[cfg(test)]
421mod tests;