@@ -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 =
@@ -421,6 +421,133 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) {
421421 assert_eq ! ( settings. device_token_max_ttl_seconds, None ) ;
422422}
423423
424+ #[ nexus_test]
425+ async fn test_device_token_request_ttl ( cptestctx : & ControlPlaneTestContext ) {
426+ let testctx = & cptestctx. external_client ;
427+
428+ // Set silo max TTL to 10 seconds
429+ let _settings: views:: SiloSettings = object_put (
430+ testctx,
431+ "/v1/settings" ,
432+ & params:: SiloSettingsUpdate {
433+ device_token_max_ttl_seconds : NonZeroU32 :: new ( 10 ) ,
434+ } ,
435+ )
436+ . await ;
437+
438+ let client_id = Uuid :: new_v4 ( ) ;
439+
440+ // Test 1: Request TTL above the max should fail at verification time
441+ let authn_params_invalid = DeviceAuthRequest {
442+ client_id,
443+ ttl_seconds : Some ( 20 ) // Above the 10 second max
444+ } ;
445+
446+ let auth_response: DeviceAuthResponse =
447+ RequestBuilder :: new ( testctx, Method :: POST , "/device/auth" )
448+ . allow_non_dropshot_errors ( )
449+ . body_urlencoded ( Some ( & authn_params_invalid) )
450+ . expect_status ( Some ( StatusCode :: OK ) )
451+ . execute ( )
452+ . await
453+ . expect ( "failed to start client authentication flow" )
454+ . parsed_body ( )
455+ . expect ( "client authentication response" ) ;
456+
457+ let confirm_params = DeviceAuthVerify { user_code : auth_response. user_code } ;
458+
459+ // Confirmation should fail because requested TTL exceeds max
460+ let confirm_response = NexusRequest :: new (
461+ RequestBuilder :: new ( testctx, Method :: POST , "/device/confirm" )
462+ . body ( Some ( & confirm_params) )
463+ . expect_status ( Some ( StatusCode :: BAD_REQUEST ) ) ,
464+ )
465+ . authn_as ( AuthnMode :: PrivilegedUser )
466+ . execute ( )
467+ . await
468+ . expect ( "confirmation should fail for TTL above max" ) ;
469+
470+ // Check that the error message mentions TTL
471+ let error_body = String :: from_utf8_lossy ( & confirm_response. body ) ;
472+ assert ! ( error_body. contains( "TTL" ) || error_body. contains( "ttl" ) ) ;
473+
474+ // Test 2: Request TTL below the max should succeed and be used
475+ let authn_params_valid = DeviceAuthRequest {
476+ client_id : Uuid :: new_v4 ( ) , // New client ID for new flow
477+ ttl_seconds : Some ( 5 ) // Below the 10 second max
478+ } ;
479+
480+ let auth_response: DeviceAuthResponse =
481+ RequestBuilder :: new ( testctx, Method :: POST , "/device/auth" )
482+ . allow_non_dropshot_errors ( )
483+ . body_urlencoded ( Some ( & authn_params_valid) )
484+ . expect_status ( Some ( StatusCode :: OK ) )
485+ . execute ( )
486+ . await
487+ . expect ( "failed to start client authentication flow" )
488+ . parsed_body ( )
489+ . expect ( "client authentication response" ) ;
490+
491+ let device_code = auth_response. device_code ;
492+ let user_code = auth_response. user_code ;
493+ let confirm_params = DeviceAuthVerify { user_code } ;
494+
495+ // Confirmation should succeed
496+ NexusRequest :: new (
497+ RequestBuilder :: new ( testctx, Method :: POST , "/device/confirm" )
498+ . body ( Some ( & confirm_params) )
499+ . expect_status ( Some ( StatusCode :: NO_CONTENT ) ) ,
500+ )
501+ . authn_as ( AuthnMode :: PrivilegedUser )
502+ . execute ( )
503+ . await
504+ . expect ( "failed to confirm" ) ;
505+
506+ let token_params = DeviceAccessTokenRequest {
507+ grant_type : "urn:ietf:params:oauth:grant-type:device_code" . to_string ( ) ,
508+ device_code,
509+ client_id : authn_params_valid. client_id ,
510+ } ;
511+
512+ // Get the token
513+ let token: DeviceAccessTokenGrant = NexusRequest :: new (
514+ RequestBuilder :: new ( testctx, Method :: POST , "/device/token" )
515+ . allow_non_dropshot_errors ( )
516+ . body_urlencoded ( Some ( & token_params) )
517+ . expect_status ( Some ( StatusCode :: OK ) ) ,
518+ )
519+ . authn_as ( AuthnMode :: PrivilegedUser )
520+ . execute ( )
521+ . await
522+ . expect ( "failed to get token" )
523+ . parsed_body ( )
524+ . expect ( "failed to deserialize token response" ) ;
525+
526+ // Verify the token has the correct expiration (5 seconds from now)
527+ let tokens = get_tokens_priv ( testctx) . await ;
528+ let our_token = tokens. iter ( ) . find ( |t| t. time_expires . is_some ( ) ) . unwrap ( ) ;
529+ let expires_at = our_token. time_expires . unwrap ( ) ;
530+ let now = Utc :: now ( ) ;
531+
532+ // Should expire approximately 5 seconds from now (allow some tolerance for test timing)
533+ let expected_expiry = now + chrono:: Duration :: seconds ( 5 ) ;
534+ let time_diff = ( expires_at - expected_expiry) . num_seconds ( ) . abs ( ) ;
535+ assert ! ( time_diff <= 2 , "Token expiry should be close to requested TTL" ) ;
536+
537+ // Token should work initially
538+ project_list ( & testctx, & token. access_token , StatusCode :: OK )
539+ . await
540+ . expect ( "token should work initially" ) ;
541+
542+ // Wait for token to expire
543+ sleep ( Duration :: from_secs ( 6 ) ) . await ;
544+
545+ // Token should be expired now
546+ project_list ( & testctx, & token. access_token , StatusCode :: UNAUTHORIZED )
547+ . await
548+ . expect ( "token should be expired" ) ;
549+ }
550+
424551async fn get_tokens_priv (
425552 testctx : & ClientTestContext ,
426553) -> Vec < views:: DeviceAccessToken > {
0 commit comments