@@ -237,12 +237,12 @@ impl ServiceAccount {
237
237
) -> Result < String , ServiceAccountError > {
238
238
let issuer = IssuerUrl :: new ( audience. to_string ( ) )
239
239
. 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 ( ) ?;
241
243
let metadata = CoreProviderMetadata :: discover_async ( issuer, & async_http_client)
242
244
. await
243
- . map_err ( |e| ServiceAccountError :: DiscoveryError {
244
- source : e,
245
- } ) ?;
245
+ . map_err ( |e| ServiceAccountError :: DiscoveryError { source : e } ) ?;
246
246
247
247
let jwt = self . create_signed_jwt ( audience) ?;
248
248
let url = metadata
@@ -271,7 +271,12 @@ impl ServiceAccount {
271
271
// })
272
272
// .await
273
273
// .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 ?;
275
280
276
281
serde_json:: from_slice ( response. bytes ( ) . await ?. to_vec ( ) . as_slice ( ) )
277
282
. map_err ( |e| ServiceAccountError :: Json { source : e } )
@@ -292,6 +297,60 @@ impl ServiceAccount {
292
297
293
298
Ok ( jwt)
294
299
}
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
+ }
295
354
}
296
355
297
356
impl AuthenticationOptions {
@@ -335,6 +394,8 @@ mod tests {
335
394
use std:: io:: Write ;
336
395
337
396
use super :: * ;
397
+ use crate :: credentials:: jwt:: JwtClaims ;
398
+ use jsonwebtoken:: { decode, DecodingKey , Validation } ;
338
399
339
400
const ZITADEL_URL : & str = "https://zitadel-libraries-l8boqa.zitadel.cloud" ;
340
401
const SERVICE_ACCOUNT : & str = r#"
@@ -394,4 +455,77 @@ mod tests {
394
455
395
456
assert_eq ! ( & claims[ 0 ..5 ] , "eyJ0e" ) ;
396
457
}
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
+ }
397
531
}
0 commit comments