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