Skip to content

Commit 5a4689d

Browse files
domenkozarclaude
andcommitted
feat: add JWT creation with custom expiry for offline validation
Add `create_signed_jwt_with_expiry` method to ServiceAccount that allows creating JWTs with custom expiration dates. This enables offline token validation scenarios and flexible token lifetime management. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 86eef03 commit 5a4689d

File tree

2 files changed

+161
-12
lines changed

2 files changed

+161
-12
lines changed

crates/zitadel/src/credentials/jwt.rs

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
use serde::Serialize;
1+
use serde::{Deserialize, Serialize};
22

3-
#[derive(Debug, Serialize)]
3+
#[derive(Debug, Serialize, Deserialize)]
44
pub(super) struct JwtClaims {
5-
iss: String,
6-
sub: String,
7-
iat: i64,
8-
exp: i64,
9-
aud: String,
5+
pub(super) iss: String,
6+
pub(super) sub: String,
7+
pub(super) iat: i64,
8+
pub(super) exp: i64,
9+
pub(super) aud: String,
1010
}
1111

1212
impl JwtClaims {
@@ -21,4 +21,19 @@ impl JwtClaims {
2121
aud: audience.to_string(),
2222
}
2323
}
24+
25+
pub(super) fn new_with_expiry(
26+
sub_and_iss: &str,
27+
audience: &str,
28+
exp: time::OffsetDateTime,
29+
) -> Self {
30+
let iat = time::OffsetDateTime::now_utc();
31+
Self {
32+
iss: sub_and_iss.to_string(),
33+
sub: sub_and_iss.to_string(),
34+
iat: iat.unix_timestamp(),
35+
exp: exp.unix_timestamp(),
36+
aud: audience.to_string(),
37+
}
38+
}
2439
}

crates/zitadel/src/credentials/service_account.rs

Lines changed: 139 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -237,12 +237,12 @@ impl ServiceAccount {
237237
) -> Result<String, ServiceAccountError> {
238238
let issuer = IssuerUrl::new(audience.to_string())
239239
.map_err(|e| ServiceAccountError::AudienceUrl { source: e })?;
240-
let async_http_client = reqwest::ClientBuilder::new().redirect(reqwest::redirect::Policy::none()).build()?;
240+
let async_http_client = reqwest::ClientBuilder::new()
241+
.redirect(reqwest::redirect::Policy::none())
242+
.build()?;
241243
let metadata = CoreProviderMetadata::discover_async(issuer, &async_http_client)
242244
.await
243-
.map_err(|e| ServiceAccountError::DiscoveryError {
244-
source: e,
245-
})?;
245+
.map_err(|e| ServiceAccountError::DiscoveryError { source: e })?;
246246

247247
let jwt = self.create_signed_jwt(audience)?;
248248
let url = metadata
@@ -271,7 +271,12 @@ impl ServiceAccount {
271271
// })
272272
// .await
273273
// .map_err(|e| ServiceAccountError::HttpError { source: e })?;
274-
let response = async_http_client.post(url).headers(headers).body(body).send().await?;
274+
let response = async_http_client
275+
.post(url)
276+
.headers(headers)
277+
.body(body)
278+
.send()
279+
.await?;
275280

276281
serde_json::from_slice(response.bytes().await?.to_vec().as_slice())
277282
.map_err(|e| ServiceAccountError::Json { source: e })
@@ -292,6 +297,60 @@ impl ServiceAccount {
292297

293298
Ok(jwt)
294299
}
300+
301+
/// Create a (RSA) signed JWT token with a custom expiry date that can be used
302+
/// for offline validation or other authentication purposes.
303+
///
304+
/// The function returns a signed JWT token with the specified expiry date.
305+
/// This is useful for creating long-lived tokens for offline validation scenarios
306+
/// or short-lived tokens for enhanced security.
307+
///
308+
/// ### Errors
309+
///
310+
/// This method may fail when:
311+
/// - The key in the service account is not a valid PEM encoded RSA private key.
312+
/// - The JWT encoding fails.
313+
///
314+
/// ### Example
315+
///
316+
/// ```
317+
/// # #[tokio::main]
318+
/// # async fn main() -> Result<(), Box<dyn std::error::Error>>{
319+
/// # const SERVICE_ACCOUNT: &str = r#"
320+
/// # {
321+
/// # "type": "serviceaccount",
322+
/// # "keyId": "181828078849229057",
323+
/// # "key": "-----BEGIN RSA PRIVATE KEY-----\nMIIEpQIBAAKCAQEA9VIWALQqzx1ypi42t7MG4KSOMldD10brsEUjTcjqxhl6TJrP\nsjaNKWArnV/XH+6ZKRd55mUEFFx9VflqdwQtMVPjZKXpV4cFDiPwf1Z1h1DS6im4\nSo7eKR7OGb7TLBhwt7i2UPF4WnxBhTp/M6pG5kCJ1t8glIo5yRbrILXObRmvNWMz\nVIFAyw68NDZGYNhnR8AT43zjeJTFXG/suuEoXO/mMmMjsYY8kS0BbiQeq5t5hIrr\na/odswkDPn5Zd4P91iJHDnYlgfJuo3oRmgpOj/dDsl+vTol+vveeMO4TXPwZcl36\ngUNPok7nd6BA3gqmOS+fMImzmZB42trghARXXwIDAQABAoIBAQCbMOGQcml+ep+T\ntzqQPWYFaLQ37nKRVmE1Mpeh1o+G4Ik4utrXX6EvYpJUzVN29ObZUuufr5nEE7qK\nT+1k+zRntyzr9/VElLrC9kNnGtfg0WWMEvZt3DF4i+9P5CMNCy0LXIOhcxBzFZYR\nZS8hDQArGvrX/nFK5qKlrqTyHXFIHDFa6z59ErhXEnsTgRvx/Mo+6UkdBkHsKnlJ\nAbXqXFbfz6nDsF1DgRra5ODn1k8nZqnC/YcssE7/dlbuByz10ECkOSzqYcfufnsb\n9N1Ld4Xlj3yzsqPFzEJyHHm9eEHQXsPavaXiM64/+zpsksLscEIE/0KtIy5tngpZ\nSCqZAcj5AoGBAPb1bQFWUBmmUuSTtSymsxgXghJiJ3r+jJgdGbkv2IsRTs4En5Sz\n0SbPE1YWmMDDgTacJlB4/XiaojQ/j1EEY17inxYomE72UL6/ET7ycsEw3e9ALuD5\np0y2Sdzes2biH30bw5jD8kJ+hV18T745KtzrwSH4I0lAjnkmiH+0S67VAoGBAP5N\nTtAp/Qdxh9GjNSw1J7KRLtJrrr0pPrJ9av4GoFoWlz+Qw2X3dl8rjG3Bqz9LPV7A\ngiHMel8WTmdIM/S3F4Q3ufEfE+VzG+gncWd9SJfX5/LVhatPzTGLNsY7AYGEpSwT\n5/0anS1mHrLwsVcPrZnigekr5A5mfZl6nxtOnE9jAoGBALACqacbUkmFrmy1DZp+\nUQSptI3PoR3bEG9VxkCjZi1vr3/L8cS1CCslyT1BK6uva4d1cSVHpjfv1g1xA38V\nppE46XOMiUk16sSYPv1jJQCmCHd9givcIy3cefZOTwTTwueTAyv888wKipjfgaIs\n8my0JllEljmeJi0Ylo6V/J7lAoGBAIFqRlmZhLNtC3mcXUsKIhG14OYk9uA9RTMA\nsJpmNOSj6oTm3wndTdhRCT4x+TxUxf6aaZ9ZuEz7xRq6m/ZF1ynqUi5ramyyj9kt\neYD5OSBNODVUhJoSGpLEDjQDg1iucIBmAQHFsYeRGL5nz1hHGkneA87uDzlk3zZk\nOORktReRAoGAGUfU2UfaniAlqrZsSma3ZTlvJWs1x8cbVDyKTYMX5ShHhp+cA86H\nYjSSol6GI2wQPP+qIvZ1E8XyzD2miMJabl92/WY0tHejNNBEHwD8uBZKrtMoFWM7\nWJNl+Xneu/sT8s4pP2ng6QE7jpHXi2TUNmSlgQry9JN2AmA9TuSTW2Y=\n-----END RSA PRIVATE KEY-----\n",
324+
/// # "userId": "181828061098934529"
325+
/// # }"#;
326+
/// # const ZITADEL_URL: &str = "https://zitadel-libraries-l8boqa.zitadel.cloud";
327+
/// use zitadel::credentials::ServiceAccount;
328+
/// use time::{OffsetDateTime, Duration};
329+
///
330+
/// let service_account = ServiceAccount::load_from_json(SERVICE_ACCOUNT)?;
331+
///
332+
/// // Create a JWT that expires in 24 hours
333+
/// let expiry = OffsetDateTime::now_utc() + Duration::hours(24);
334+
/// let jwt = service_account.create_signed_jwt_with_expiry(ZITADEL_URL, expiry)?;
335+
///
336+
/// println!("JWT: {}", jwt);
337+
/// # Ok(())
338+
/// # }
339+
/// ```
340+
pub fn create_signed_jwt_with_expiry(
341+
&self,
342+
audience: &str,
343+
expiry: time::OffsetDateTime,
344+
) -> Result<String, ServiceAccountError> {
345+
let key = EncodingKey::from_rsa_pem(self.key.as_bytes())
346+
.map_err(|e| ServiceAccountError::Key { source: e })?;
347+
let mut header = Header::new(Algorithm::RS256);
348+
header.kid = Some(self.key_id.to_string());
349+
let claims = JwtClaims::new_with_expiry(&self.user_id, audience, expiry);
350+
let jwt = encode(&header, &claims, &key)?;
351+
352+
Ok(jwt)
353+
}
295354
}
296355

297356
impl AuthenticationOptions {
@@ -335,6 +394,8 @@ mod tests {
335394
use std::io::Write;
336395

337396
use super::*;
397+
use crate::credentials::jwt::JwtClaims;
398+
use jsonwebtoken::{decode, DecodingKey, Validation};
338399

339400
const ZITADEL_URL: &str = "https://zitadel-libraries-l8boqa.zitadel.cloud";
340401
const SERVICE_ACCOUNT: &str = r#"
@@ -394,4 +455,77 @@ mod tests {
394455

395456
assert_eq!(&claims[0..5], "eyJ0e");
396457
}
458+
459+
#[test]
460+
fn creates_a_signed_jwt_with_custom_expiry() {
461+
let sa = ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap();
462+
let future_time = time::OffsetDateTime::now_utc() + time::Duration::days(7);
463+
let jwt = sa
464+
.create_signed_jwt_with_expiry(ZITADEL_URL, future_time)
465+
.unwrap();
466+
467+
assert_eq!(&jwt[0..5], "eyJ0e");
468+
469+
// Verify it's a valid JWT format
470+
let parts: Vec<&str> = jwt.split('.').collect();
471+
assert_eq!(parts.len(), 3);
472+
473+
// For testing, we'll decode without verification first to check the claims
474+
// In production, you would use the public key from JWKS endpoint
475+
let mut validation = Validation::new(Algorithm::RS256);
476+
validation.insecure_disable_signature_validation();
477+
validation.set_audience(&[ZITADEL_URL]);
478+
479+
let token_data =
480+
decode::<JwtClaims>(&jwt, &DecodingKey::from_secret(&[]), &validation).unwrap();
481+
482+
// Verify the expiry time matches what we set
483+
assert_eq!(token_data.claims.exp, future_time.unix_timestamp());
484+
485+
// Verify the token is not expired (should be valid for 7 days)
486+
assert!(token_data.claims.exp > time::OffsetDateTime::now_utc().unix_timestamp());
487+
}
488+
489+
#[test]
490+
fn verifies_expired_jwt() {
491+
let sa = ServiceAccount::load_from_json(SERVICE_ACCOUNT).unwrap();
492+
493+
// Create a JWT that expired 1 hour ago
494+
let past_time = time::OffsetDateTime::now_utc() - time::Duration::hours(1);
495+
let jwt = sa
496+
.create_signed_jwt_with_expiry(ZITADEL_URL, past_time)
497+
.unwrap();
498+
499+
// Decode without signature validation and without expiry check to inspect claims
500+
let mut validation = Validation::new(Algorithm::RS256);
501+
validation.insecure_disable_signature_validation();
502+
validation.set_audience(&[ZITADEL_URL]);
503+
validation.validate_exp = false; // Disable expiry validation to inspect the token
504+
505+
let token_data =
506+
decode::<JwtClaims>(&jwt, &DecodingKey::from_secret(&[]), &validation).unwrap();
507+
508+
// Verify the token is expired
509+
assert!(token_data.claims.exp < time::OffsetDateTime::now_utc().unix_timestamp());
510+
511+
// Now try with validation enabled (not checking signature, but checking exp)
512+
let mut validation_with_exp = Validation::new(Algorithm::RS256);
513+
validation_with_exp.insecure_disable_signature_validation();
514+
validation_with_exp.set_audience(&[ZITADEL_URL]);
515+
516+
// This should fail because the token is expired
517+
let result =
518+
decode::<JwtClaims>(&jwt, &DecodingKey::from_secret(&[]), &validation_with_exp);
519+
assert!(result.is_err());
520+
521+
if let Err(e) = result {
522+
match e.kind() {
523+
jsonwebtoken::errors::ErrorKind::ExpiredSignature => {
524+
// Expected error
525+
assert!(true);
526+
}
527+
_ => panic!("Expected ExpiredSignature error, got: {:?}", e),
528+
}
529+
}
530+
}
397531
}

0 commit comments

Comments
 (0)