@@ -55,7 +55,7 @@ async fn test_device_auth_flow(cptestctx: &ControlPlaneTestContext) {
5555 . expect ( "failed to reject device auth start without client_id" ) ;
5656
5757 let client_id = Uuid :: new_v4 ( ) ;
58- let authn_params = DeviceAuthRequest { client_id } ;
58+ let authn_params = DeviceAuthRequest { client_id, ttl_seconds : None } ;
5959
6060 // Using a JSON encoded body fails.
6161 RequestBuilder :: new ( testctx, Method :: POST , "/device/auth" )
@@ -235,7 +235,7 @@ async fn test_device_auth_flow(cptestctx: &ControlPlaneTestContext) {
235235/// as a string
236236async fn get_device_token ( testctx : & ClientTestContext ) -> String {
237237 let client_id = Uuid :: new_v4 ( ) ;
238- let authn_params = DeviceAuthRequest { client_id } ;
238+ let authn_params = DeviceAuthRequest { client_id, ttl_seconds : None } ;
239239
240240 // Start a device authentication flow
241241 let auth_response: DeviceAuthResponse =
@@ -385,6 +385,133 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) {
385385 assert_eq ! ( tokens[ 0 ] . time_expires, None ) ;
386386}
387387
388+ #[ nexus_test]
389+ async fn test_device_token_request_ttl ( cptestctx : & ControlPlaneTestContext ) {
390+ let testctx = & cptestctx. external_client ;
391+
392+ // Set silo max TTL to 10 seconds
393+ let _settings: views:: SiloSettings = object_put (
394+ testctx,
395+ "/v1/settings" ,
396+ & params:: SiloSettingsUpdate {
397+ device_token_max_ttl_seconds : NonZeroU32 :: new ( 10 ) ,
398+ } ,
399+ )
400+ . await ;
401+
402+ let client_id = Uuid :: new_v4 ( ) ;
403+
404+ // Test 1: Request TTL above the max should fail at verification time
405+ let authn_params_invalid = DeviceAuthRequest {
406+ client_id,
407+ ttl_seconds : Some ( 20 ) // Above the 10 second max
408+ } ;
409+
410+ let auth_response: DeviceAuthResponse =
411+ RequestBuilder :: new ( testctx, Method :: POST , "/device/auth" )
412+ . allow_non_dropshot_errors ( )
413+ . body_urlencoded ( Some ( & authn_params_invalid) )
414+ . expect_status ( Some ( StatusCode :: OK ) )
415+ . execute ( )
416+ . await
417+ . expect ( "failed to start client authentication flow" )
418+ . parsed_body ( )
419+ . expect ( "client authentication response" ) ;
420+
421+ let confirm_params = DeviceAuthVerify { user_code : auth_response. user_code } ;
422+
423+ // Confirmation should fail because requested TTL exceeds max
424+ let confirm_response = NexusRequest :: new (
425+ RequestBuilder :: new ( testctx, Method :: POST , "/device/confirm" )
426+ . body ( Some ( & confirm_params) )
427+ . expect_status ( Some ( StatusCode :: BAD_REQUEST ) ) ,
428+ )
429+ . authn_as ( AuthnMode :: PrivilegedUser )
430+ . execute ( )
431+ . await
432+ . expect ( "confirmation should fail for TTL above max" ) ;
433+
434+ // Check that the error message mentions TTL
435+ let error_body = String :: from_utf8_lossy ( & confirm_response. body ) ;
436+ assert ! ( error_body. contains( "TTL" ) || error_body. contains( "ttl" ) ) ;
437+
438+ // Test 2: Request TTL below the max should succeed and be used
439+ let authn_params_valid = DeviceAuthRequest {
440+ client_id : Uuid :: new_v4 ( ) , // New client ID for new flow
441+ ttl_seconds : Some ( 5 ) // Below the 10 second max
442+ } ;
443+
444+ let auth_response: DeviceAuthResponse =
445+ RequestBuilder :: new ( testctx, Method :: POST , "/device/auth" )
446+ . allow_non_dropshot_errors ( )
447+ . body_urlencoded ( Some ( & authn_params_valid) )
448+ . expect_status ( Some ( StatusCode :: OK ) )
449+ . execute ( )
450+ . await
451+ . expect ( "failed to start client authentication flow" )
452+ . parsed_body ( )
453+ . expect ( "client authentication response" ) ;
454+
455+ let device_code = auth_response. device_code ;
456+ let user_code = auth_response. user_code ;
457+ let confirm_params = DeviceAuthVerify { user_code } ;
458+
459+ // Confirmation should succeed
460+ NexusRequest :: new (
461+ RequestBuilder :: new ( testctx, Method :: POST , "/device/confirm" )
462+ . body ( Some ( & confirm_params) )
463+ . expect_status ( Some ( StatusCode :: NO_CONTENT ) ) ,
464+ )
465+ . authn_as ( AuthnMode :: PrivilegedUser )
466+ . execute ( )
467+ . await
468+ . expect ( "failed to confirm" ) ;
469+
470+ let token_params = DeviceAccessTokenRequest {
471+ grant_type : "urn:ietf:params:oauth:grant-type:device_code" . to_string ( ) ,
472+ device_code,
473+ client_id : authn_params_valid. client_id ,
474+ } ;
475+
476+ // Get the token
477+ let token: DeviceAccessTokenGrant = NexusRequest :: new (
478+ RequestBuilder :: new ( testctx, Method :: POST , "/device/token" )
479+ . allow_non_dropshot_errors ( )
480+ . body_urlencoded ( Some ( & token_params) )
481+ . expect_status ( Some ( StatusCode :: OK ) ) ,
482+ )
483+ . authn_as ( AuthnMode :: PrivilegedUser )
484+ . execute ( )
485+ . await
486+ . expect ( "failed to get token" )
487+ . parsed_body ( )
488+ . expect ( "failed to deserialize token response" ) ;
489+
490+ // Verify the token has the correct expiration (5 seconds from now)
491+ let tokens = get_tokens_priv ( testctx) . await ;
492+ let our_token = tokens. iter ( ) . find ( |t| t. time_expires . is_some ( ) ) . unwrap ( ) ;
493+ let expires_at = our_token. time_expires . unwrap ( ) ;
494+ let now = Utc :: now ( ) ;
495+
496+ // Should expire approximately 5 seconds from now (allow some tolerance for test timing)
497+ let expected_expiry = now + chrono:: Duration :: seconds ( 5 ) ;
498+ let time_diff = ( expires_at - expected_expiry) . num_seconds ( ) . abs ( ) ;
499+ assert ! ( time_diff <= 2 , "Token expiry should be close to requested TTL" ) ;
500+
501+ // Token should work initially
502+ project_list ( & testctx, & token. access_token , StatusCode :: OK )
503+ . await
504+ . expect ( "token should work initially" ) ;
505+
506+ // Wait for token to expire
507+ sleep ( Duration :: from_secs ( 6 ) ) . await ;
508+
509+ // Token should be expired now
510+ project_list ( & testctx, & token. access_token , StatusCode :: UNAUTHORIZED )
511+ . await
512+ . expect ( "token should be expired" ) ;
513+ }
514+
388515async fn get_tokens_priv (
389516 testctx : & ClientTestContext ,
390517) -> Vec < views:: DeviceAccessToken > {
0 commit comments