diff --git a/nexus/auth/src/authn/external/mod.rs b/nexus/auth/src/authn/external/mod.rs index e951819830e..27875e5d5a1 100644 --- a/nexus/auth/src/authn/external/mod.rs +++ b/nexus/auth/src/authn/external/mod.rs @@ -236,6 +236,7 @@ mod test { SKIP => SchemeResult::NotRequested, OK => SchemeResult::Authenticated(authn::Details { actor: self.actor, + device_token_expiration: None, }), FAIL => SchemeResult::Failed(Reason::BadCredentials { actor: self.actor, diff --git a/nexus/auth/src/authn/external/scim.rs b/nexus/auth/src/authn/external/scim.rs index d8b502f17ba..897810644a3 100644 --- a/nexus/auth/src/authn/external/scim.rs +++ b/nexus/auth/src/authn/external/scim.rs @@ -66,7 +66,10 @@ where Ok(None) => SchemeResult::NotRequested, Ok(Some(token)) => match ctx.scim_token_actor(token).await { Err(error) => SchemeResult::Failed(error), - Ok(actor) => SchemeResult::Authenticated(Details { actor }), + Ok(actor) => SchemeResult::Authenticated(Details { + actor, + device_token_expiration: None, + }), }, } } diff --git a/nexus/auth/src/authn/external/session_cookie.rs b/nexus/auth/src/authn/external/session_cookie.rs index d4b3b560983..2b9cd7c325b 100644 --- a/nexus/auth/src/authn/external/session_cookie.rs +++ b/nexus/auth/src/authn/external/session_cookie.rs @@ -180,7 +180,10 @@ where debug!(log, "failed to extend session") } - SchemeResult::Authenticated(Details { actor }) + SchemeResult::Authenticated(Details { + actor, + device_token_expiration: None, + }) } } @@ -395,7 +398,10 @@ mod test { let result = authn_with_cookie(&context, Some("session=abc")).await; assert!(matches!( result, - SchemeResult::Authenticated(Details { actor: _ }) + SchemeResult::Authenticated(Details { + actor: _, + device_token_expiration: _ + }) )); // valid cookie should have updated time_last_used diff --git a/nexus/auth/src/authn/external/spoof.rs b/nexus/auth/src/authn/external/spoof.rs index 8e68691c266..ee840bed111 100644 --- a/nexus/auth/src/authn/external/spoof.rs +++ b/nexus/auth/src/authn/external/spoof.rs @@ -109,7 +109,10 @@ where Err(error) => SchemeResult::Failed(error), Ok(silo_id) => { let actor = Actor::SiloUser { silo_id, silo_user_id }; - SchemeResult::Authenticated(Details { actor }) + SchemeResult::Authenticated(Details { + actor, + device_token_expiration: None, + }) } } } diff --git a/nexus/auth/src/authn/external/token.rs b/nexus/auth/src/authn/external/token.rs index 2cdeef91173..5e13e41b03f 100644 --- a/nexus/auth/src/authn/external/token.rs +++ b/nexus/auth/src/authn/external/token.rs @@ -11,6 +11,7 @@ use super::SchemeResult; use super::SiloUserSilo; use crate::authn; use async_trait::async_trait; +use chrono::{DateTime, Utc}; use headers::HeaderMapExt; use headers::authorization::{Authorization, Bearer}; @@ -63,9 +64,14 @@ where match parse_token(headers.typed_get().as_ref()) { Err(error) => SchemeResult::Failed(error), Ok(None) => SchemeResult::NotRequested, - Ok(Some(token)) => match ctx.token_actor(token).await { + Ok(Some(token)) => match ctx.authenticate_token(token).await { Err(error) => SchemeResult::Failed(error), - Ok(actor) => SchemeResult::Authenticated(Details { actor }), + Ok((actor, device_token_expiration)) => { + SchemeResult::Authenticated(Details { + actor, + device_token_expiration, + }) + } }, } } @@ -91,7 +97,12 @@ fn parse_token( /// A context that can look up a Silo user and client ID from a token. #[async_trait] pub trait TokenContext { - async fn token_actor(&self, token: String) -> Result; + /// Returns the actor authenticated by the token and the token's expiration + /// time (if any). + async fn authenticate_token( + &self, + token: String, + ) -> Result<(authn::Actor, Option>), Reason>; } #[cfg(test)] diff --git a/nexus/auth/src/authn/mod.rs b/nexus/auth/src/authn/mod.rs index e9e9a25848c..849bc5f4baa 100644 --- a/nexus/auth/src/authn/mod.rs +++ b/nexus/auth/src/authn/mod.rs @@ -38,6 +38,7 @@ pub use nexus_db_fixed_data::user_builtin::USER_SAGA_RECOVERY; pub use nexus_db_fixed_data::user_builtin::USER_SERVICE_BALANCER; use crate::authz; +use chrono::{DateTime, Utc}; use newtype_derive::NewtypeDisplay; use nexus_db_fixed_data::silo::DEFAULT_SILO; use nexus_types::external_api::shared::FleetRole; @@ -84,7 +85,7 @@ impl Context { &self, ) -> Result<&Actor, omicron_common::api::external::Error> { match &self.kind { - Kind::Authenticated(Details { actor }, ..) => Ok(actor), + Kind::Authenticated(Details { actor, .. }, ..) => Ok(actor), Kind::Unauthenticated => { Err(omicron_common::api::external::Error::Unauthenticated { internal_message: "Actor required".to_string(), @@ -93,6 +94,21 @@ impl Context { } } + /// Returns the expiration time if authenticated via a device token. + /// + /// This is used to prevent token lifetime extension during token creation: + /// a new token created using an existing token should not outlive the + /// token used to authenticate. + pub fn device_token_expiration(&self) -> Option> { + match &self.kind { + Kind::Authenticated( + Details { device_token_expiration, .. }, + .., + ) => *device_token_expiration, + Kind::Unauthenticated => None, + } + } + /// Returns the current actor's Silo if they have one or an appropriate /// error otherwise /// @@ -216,7 +232,10 @@ impl Context { fn context_for_builtin_user(user_builtin_id: BuiltInUserUuid) -> Context { Context { kind: Kind::Authenticated( - Details { actor: Actor::UserBuiltin { user_builtin_id } }, + Details { + actor: Actor::UserBuiltin { user_builtin_id }, + device_token_expiration: None, + }, None, ), schemes_tried: Vec::new(), @@ -234,6 +253,7 @@ impl Context { silo_user_id: USER_TEST_PRIVILEGED.id(), silo_id: USER_TEST_PRIVILEGED.silo_id, }, + device_token_expiration: None, }, Some(SiloAuthnPolicy::try_from(&*DEFAULT_SILO).unwrap()), ), @@ -261,7 +281,10 @@ impl Context { ) -> Context { Context { kind: Kind::Authenticated( - Details { actor: Actor::SiloUser { silo_user_id, silo_id } }, + Details { + actor: Actor::SiloUser { silo_user_id, silo_id }, + device_token_expiration: None, + }, Some(silo_authn_policy), ), schemes_tried: Vec::new(), @@ -273,7 +296,10 @@ impl Context { pub fn for_scim(silo_id: Uuid) -> Context { Context { kind: Kind::Authenticated( - Details { actor: Actor::Scim { silo_id } }, + Details { + actor: Actor::Scim { silo_id }, + device_token_expiration: None, + }, // This should never be non-empty, we don't want the SCIM user // to ever have associated roles. Some(SiloAuthnPolicy::new(BTreeMap::default())), @@ -391,6 +417,11 @@ enum Kind { pub struct Details { /// the actor performing the request actor: Actor, + /// When the device token expires. Present only when authenticating via + /// a device token. This is a slightly awkward fit but is included here + /// because we need to use this to clamp the expiration time when device + /// tokens are confirmed using an existing device token. + device_token_expiration: Option>, } /// Who is performing an operation diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 2c6161c42f2..8901f5d889c 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -4130,6 +4130,14 @@ pub trait NexusExternalApi { /// This endpoint is designed to be accessed by the user agent (browser), /// not the client requesting the token. So we do not actually return the /// token here; it will be returned in response to the poll on `/device/token`. + /// + /// Some special logic applies when authenticating this request with an + /// existing device token instead of a console session: the requested + /// TTL must not produce an expiration time later than the authenticating + /// token's expiration. If no TTL was specified in the initial grant + /// request, the expiration will be the lesser of the silo max and the + /// authenticating token's expiration time. To get the longest allowed + /// lifetime, omit the TTL and authenticate with a web console session. #[endpoint { method = POST, path = "/device/confirm", diff --git a/nexus/src/app/device_auth.rs b/nexus/src/app/device_auth.rs index 0a97c21cc07..138121675c9 100644 --- a/nexus/src/app/device_auth.rs +++ b/nexus/src/app/device_auth.rs @@ -123,20 +123,67 @@ impl super::Nexus { let silo_max_ttl = silo_auth_settings.device_token_max_ttl_seconds; let requested_ttl = db_request.token_ttl_seconds; - // Validate the requested TTL against the silo's max TTL - if let (Some(requested), Some(max)) = (requested_ttl, silo_max_ttl) { - if requested > max.0.into() { - return Err(Error::invalid_request(&format!( - "Requested TTL {} seconds exceeds maximum \ - allowed TTL for this silo of {} seconds", - requested, max - ))); + // This logic is a bit gnarly, but we landed on it as the least bad + // option. Error out if the user requests a token TTL that is longer + // than allowed, i.e., either + // + // a) it is longer than the silo max TTL, or + // b) this request was authenticated with a device token and the TTL + // would produce an expiration later than the current token's. + // + // If the user does not request a specific TTL, we do not error out. + // We calculate the token TTL as min(silo max TTL, current token TTL + // if present). Token confirm requests authenticated with a console + // session can get device tokens with TTLs up to the silo max. + + let time_expires = if let Some(requested_ttl) = requested_ttl { + // If the user requested a TTL, validate it against the silo max + // TTL as well as the expiration time of the token being used (if a + // token is being used) + + // Validate the requested TTL against the silo's max TTL + if let Some(max) = silo_max_ttl { + if requested_ttl > max.0.into() { + return Err(Error::invalid_request(&format!( + "Requested TTL {} seconds exceeds maximum allowed \ + TTL for this silo of {} seconds", + requested_ttl, max + ))); + } + }; + + let requested_exp = + Utc::now() + Duration::seconds(requested_ttl.0.into()); + + // If currently authenticated via token, error if requested exceeds it + if let Some(auth_exp) = opctx.authn.device_token_expiration() { + if requested_exp > auth_exp { + return Err(Error::invalid_request( + "Requested token TTL would exceed the expiration time \ + of the token being used to authenticate the confirm \ + request. To get the full requested TTL, confirm \ + this token using a web console session. Alternatively, \ + omit requested TTL to get a token with the longest \ + allowed lifetime, determined by the lesser of the silo \ + max and the current token's expiration time.", + )); + } } - } - let time_expires = requested_ttl - .or(silo_max_ttl) - .map(|ttl| Utc::now() + Duration::seconds(ttl.0.into())); + Some(requested_exp) + } else { + // No explicit TTL requested. Rather than erroring out if silo max + // exceeds TTL exceeds expiration time of current token, just clamp. + let silo_max_exp = silo_max_ttl + .map(|ttl| Utc::now() + Duration::seconds(ttl.0.into())); + // a.min(b) doesn't do it because None is always less than Some(_) + match (silo_max_exp, opctx.authn.device_token_expiration()) { + (Some(silo_exp), Some(token_exp)) => { + Some(silo_exp.min(token_exp)) + } + (a, b) => a.or(b), + } + }; let token = DeviceAccessToken::new( db_request.client_id, @@ -193,11 +240,12 @@ impl super::Nexus { /// Look up the actor for which a token was granted. /// Corresponds to a request *after* completing the flow above. - pub(crate) async fn device_access_token_actor( + /// Returns the actor and the token's expiration time (if any). + pub(crate) async fn authenticate_token( &self, opctx: &OpContext, token: String, - ) -> Result { + ) -> Result<(Actor, Option>), Reason> { let (.., db_access_token) = self .db_datastore .device_token_lookup_by_token(opctx, token) @@ -222,7 +270,9 @@ impl super::Nexus { })?; let silo_id = db_silo_user.silo_id; - if let Some(time_expires) = db_access_token.time_expires { + let expiration = db_access_token.time_expires; + + if let Some(time_expires) = expiration { let now = Utc::now(); if time_expires < now { return Err(Reason::BadCredentials { @@ -236,7 +286,7 @@ impl super::Nexus { } } - Ok(Actor::SiloUser { silo_user_id, silo_id }) + Ok((Actor::SiloUser { silo_user_id, silo_id }, expiration)) } pub(crate) async fn device_access_token( diff --git a/nexus/src/context.rs b/nexus/src/context.rs index b2684b1cc05..9a38dc52a67 100644 --- a/nexus/src/context.rs +++ b/nexus/src/context.rs @@ -458,12 +458,15 @@ impl authn::external::SiloUserSilo for ServerContext { #[async_trait] impl authn::external::token::TokenContext for ServerContext { - async fn token_actor( + async fn authenticate_token( &self, token: String, - ) -> Result { + ) -> Result< + (authn::Actor, Option>), + authn::Reason, + > { let opctx = self.nexus.opctx_external_authn(); - self.nexus.device_access_token_actor(opctx, token).await + self.nexus.authenticate_token(opctx, token).await } } diff --git a/nexus/test-utils/src/resource_helpers.rs b/nexus/test-utils/src/resource_helpers.rs index b15d673ef99..54ce471d07c 100644 --- a/nexus/test-utils/src/resource_helpers.rs +++ b/nexus/test-utils/src/resource_helpers.rs @@ -1280,33 +1280,56 @@ pub async fn projects_list( .collect() } -/// Log in with test suite password, return session cookie -pub async fn create_console_session( - cptestctx: &ControlPlaneTestContext, +/// Log in and return session token +pub async fn create_session_for_user( + testctx: &ClientTestContext, + silo_name: &str, + username: &str, + password: &str, ) -> String { - let testctx = &cptestctx.external_client; - let url = format!("/v1/login/{}/local", cptestctx.silo_name); + let url = format!("/v1/login/{silo_name}/local"); let credentials = test_params::UsernamePasswordCredentials { - username: cptestctx.user_name.as_ref().parse().unwrap(), - password: TEST_SUITE_PASSWORD.to_string(), + username: username.parse().unwrap(), + password: password.to_string(), }; - let login = RequestBuilder::new(&testctx, Method::POST, &url) + let login_response = RequestBuilder::new(&testctx, Method::POST, &url) .body(Some(&credentials)) .expect_status(Some(StatusCode::NO_CONTENT)) .execute() .await .expect("failed to log in"); - let session_cookie = { - let header_name = header::SET_COOKIE; - login.headers.get(header_name).unwrap().to_str().unwrap().to_string() - }; - let (session_token, rest) = session_cookie.split_once("; ").unwrap(); - - assert!(session_token.starts_with("session=")); - assert_eq!(rest, "Path=/; HttpOnly; SameSite=Lax; Max-Age=86400"); + let cookie_header = login_response + .headers + .get(header::SET_COOKIE) + .expect("missing session cookie") + .to_str() + .expect("session cookie not a string"); + + cookie_header + .split_once("session=") + .expect("malformed cookie") + .1 + .split_once(';') + .expect("malformed cookie") + .0 + .to_string() +} + +/// Log in with test suite password, return session cookie (formatted for Cookie +/// header) +pub async fn create_console_session( + cptestctx: &ControlPlaneTestContext, +) -> String { + let token = create_session_for_user( + &cptestctx.external_client, + cptestctx.silo_name.as_str(), + cptestctx.user_name.as_ref(), + TEST_SUITE_PASSWORD, + ) + .await; - session_token.to_string() + format!("session={}", token) } #[derive(Debug)] diff --git a/nexus/tests/integration_tests/device_auth.rs b/nexus/tests/integration_tests/device_auth.rs index a500f84acbc..5e8f2230e59 100644 --- a/nexus/tests/integration_tests/device_auth.rs +++ b/nexus/tests/integration_tests/device_auth.rs @@ -14,8 +14,8 @@ use nexus_db_queries::db::fixed_data::silo::DEFAULT_SILO; use nexus_db_queries::db::identity::{Asset, Resource}; use nexus_test_utils::http_testing::TestResponse; use nexus_test_utils::resource_helpers::{ - create_local_user, object_delete_error, object_get, object_put, - object_put_error, test_params, + create_local_user, create_session_for_user, object_delete_error, + object_get, object_put, object_put_error, test_params, }; use nexus_test_utils::{ http_testing::{AuthnMode, NexusRequest, RequestBuilder}, @@ -633,6 +633,262 @@ async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) { .expect("token should be expired"); } +#[nexus_test] +async fn test_device_token_cannot_extend_expiration( + cptestctx: &ControlPlaneTestContext, +) { + let testctx = &cptestctx.external_client; + + // get silo belonging to privileged user to make sure local user for session + // testing is created in the same silo + let me = object_get::(testctx, "/v1/me").await; + let silo_name = me.silo_name.as_str(); + + // Set silo max TTL to 15 seconds + let settings = params::SiloAuthSettingsUpdate { + device_token_max_ttl_seconds: NonZeroU32::new(15).into(), + }; + let _: views::SiloAuthSettings = + object_put(testctx, "/v1/auth-settings", &settings).await; + + // First, test that session auth does NOT clamp token TTL + // Create a local user and get a session token + let silo_url = format!("/v1/system/silos/{silo_name}"); + let test_silo: views::Silo = object_get(testctx, &silo_url).await; + let _test_user = create_local_user( + testctx, + &test_silo, + &"session-test-user".parse().unwrap(), + test_params::UserPassword::Password("test-password".to_string()), + ) + .await; + + let session_token = create_session_for_user( + testctx, + silo_name, + "session-test-user", + "test-password", + ) + .await; + + // we can use the same client_id for everything because the device codes differentiate + let client_id = Uuid::new_v4(); + + // Test session auth with explicit TTL = silo max (15 seconds) + let session_request_with_ttl = + DeviceAuthRequest { client_id, ttl_seconds: NonZeroU32::new(15) }; + + let session_auth_response = NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/auth") + .body_urlencoded(Some(&session_request_with_ttl)) + .expect_status(Some(StatusCode::OK)), + ) + .execute_and_parse_unwrap::() + .await; + + let session_time = Utc::now(); + + NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/confirm") + .body(Some(&DeviceAuthVerify { + user_code: session_auth_response.user_code, + })) + .expect_status(Some(StatusCode::NO_CONTENT)), + ) + .authn_as(AuthnMode::Session(session_token.clone())) + .execute() + .await + .expect("failed to confirm with session auth"); + + let session_token_grant = fetch_device_token( + testctx, + session_auth_response.device_code, + client_id, + AuthnMode::Session(session_token.clone()), + ) + .await; + + // Verify session-authenticated token gets full silo max (15 seconds) + let session_expiration = session_token_grant.time_expires.unwrap(); + let session_ttl_secs = (session_expiration - session_time).num_seconds(); + assert!( + 14 <= session_ttl_secs && session_ttl_secs <= 16, + "should be full silo max (~15 seconds), got {session_ttl_secs}" + ); + + // Test session auth with no TTL specified, gets silo max + let session_request_no_ttl = + DeviceAuthRequest { client_id, ttl_seconds: None }; + + let session_auth_response2 = NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/auth") + .body_urlencoded(Some(&session_request_no_ttl)) + .expect_status(Some(StatusCode::OK)), + ) + .execute_and_parse_unwrap::() + .await; + + let session_time2 = Utc::now(); + + NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/confirm") + .body(Some(&DeviceAuthVerify { + user_code: session_auth_response2.user_code, + })) + .expect_status(Some(StatusCode::NO_CONTENT)), + ) + .authn_as(AuthnMode::Session(session_token.clone())) + .execute() + .await + .expect("failed to confirm with session auth (no TTL)"); + + let session_token_grant2 = fetch_device_token( + testctx, + session_auth_response2.device_code, + client_id, + AuthnMode::Session(session_token), + ) + .await; + + // When no TTL is specified, token still gets silo max expiration + let session_expiration2 = session_token_grant2.time_expires.unwrap(); + let session_ttl_secs2 = (session_expiration2 - session_time2).num_seconds(); + assert!( + 14 <= session_ttl_secs2 && session_ttl_secs2 <= 16, + "should be silo max (~15 seconds), got {session_ttl_secs2}" + ); + + // Now test device token auth with clamping + // Create an initial token with 8 second TTL + let client_id = Uuid::new_v4(); + let initial_request = + DeviceAuthRequest { client_id, ttl_seconds: NonZeroU32::new(8) }; + + let auth_response_1 = NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/auth") + .body_urlencoded(Some(&initial_request)) + .expect_status(Some(StatusCode::OK)), + ) + .execute_and_parse_unwrap::() + .await; + + let initial_time = Utc::now(); + + // Confirm with privileged user to create the token + NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/confirm") + .body(Some(&DeviceAuthVerify { + user_code: auth_response_1.user_code, + })) + .expect_status(Some(StatusCode::NO_CONTENT)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .expect("failed to confirm initial token"); + + // Fetch the initial token + let initial_token_grant = fetch_device_token( + testctx, + auth_response_1.device_code, + client_id, + AuthnMode::PrivilegedUser, + ) + .await; + + let initial_token = initial_token_grant.access_token; + let initial_expiration = initial_token_grant.time_expires.unwrap(); + + // Verify initial token expires in roughly 8 seconds + let initial_ttl_secs = (initial_expiration - initial_time).num_seconds(); + assert!( + 7 <= initial_ttl_secs && initial_ttl_secs <= 9, + "initial token should expire in ~8 seconds, got {initial_ttl_secs}" + ); + + // Now use the initial token to authenticate and start a NEW device auth flow + // Request a token with 12 second TTL (which is less than silo max of 15) + // This should ERROR because 12 seconds exceeds the ~8 seconds remaining on the auth token + let second_request = + DeviceAuthRequest { client_id, ttl_seconds: NonZeroU32::new(12) }; + + let auth_response_2 = NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/auth") + .body_urlencoded(Some(&second_request)) + .expect_status(Some(StatusCode::OK)), + ) + .execute_and_parse_unwrap::() + .await; + + // Attempting to confirm with the initial token should FAIL because the requested + // TTL exceeds the auth token's expiration + let error = NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/confirm") + .body(Some(&DeviceAuthVerify { + user_code: auth_response_2.user_code, + })) + .header(header::AUTHORIZATION, format!("Bearer {initial_token}")) + .expect_status(Some(StatusCode::BAD_REQUEST)), + ) + .execute_and_parse_unwrap::() + .await; + + assert!(error.message.contains("Requested token TTL would exceed")); + + // Now start a third flow WITHOUT specifying a TTL + // This should succeed with clamping to the auth token expiration + let third_request = DeviceAuthRequest { client_id, ttl_seconds: None }; + + let auth_response_3 = NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/auth") + .body_urlencoded(Some(&third_request)) + .expect_status(Some(StatusCode::OK)), + ) + .execute_and_parse_unwrap::() + .await; + + // Confirm using the initial token + NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/confirm") + .body(Some(&DeviceAuthVerify { + user_code: auth_response_3.user_code, + })) + .header(header::AUTHORIZATION, format!("Bearer {initial_token}")) + .expect_status(Some(StatusCode::NO_CONTENT)), + ) + .execute() + .await + .expect("failed to confirm third token"); + + // Fetch the third token (no auth needed, just retrieving what was created at confirm time) + let third_token_grant = NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/token") + .allow_non_dropshot_errors() + .body_urlencoded(Some(&DeviceAccessTokenRequest { + grant_type: "urn:ietf:params:oauth:grant-type:device_code" + .to_string(), + device_code: auth_response_3.device_code, + client_id, + })) + .expect_status(Some(StatusCode::OK)), + ) + .execute_and_parse_unwrap::() + .await; + + let third_expiration = third_token_grant.time_expires.unwrap(); + + // The third token should be clamped to the initial token's expiration + let time_diff_ms = + (third_expiration - initial_expiration).num_milliseconds().abs(); + + assert!( + time_diff_ms <= 1000, + "third token expiration should be clamped to initial token expiration. \ + Initial: {initial_expiration}, Third: {third_expiration}, \ + Diff: {time_diff_ms}ms" + ); +} + #[nexus_test] async fn test_admin_logout_deletes_tokens_and_sessions( cptestctx: &ControlPlaneTestContext, @@ -870,24 +1126,26 @@ async fn list_user_sessions( .items } -async fn create_session_for_user( +async fn fetch_device_token( testctx: &ClientTestContext, - silo_name: &str, - username: &str, - password: &str, -) { - let url = format!("/v1/login/{}/local", silo_name); - let credentials = test_params::UsernamePasswordCredentials { - username: username.parse().unwrap(), - password: password.to_string(), - }; - let _login = RequestBuilder::new(&testctx, Method::POST, &url) - .body(Some(&credentials)) - .expect_status(Some(StatusCode::NO_CONTENT)) - .execute() - .await - .expect("failed to log in"); - // We don't need to extract the token, just creating the session is enough + device_code: String, + client_id: Uuid, + authn_mode: AuthnMode, +) -> DeviceAccessTokenGrant { + NexusRequest::new( + RequestBuilder::new(testctx, Method::POST, "/device/token") + .allow_non_dropshot_errors() + .body_urlencoded(Some(&DeviceAccessTokenRequest { + grant_type: "urn:ietf:params:oauth:grant-type:device_code" + .to_string(), + device_code, + client_id, + })) + .expect_status(Some(StatusCode::OK)), + ) + .authn_as(authn_mode) + .execute_and_parse_unwrap::() + .await } async fn get_tokens_unpriv( diff --git a/nexus/types/src/external_api/params.rs b/nexus/types/src/external_api/params.rs index e7fff2efd5e..ef03678b0d5 100644 --- a/nexus/types/src/external_api/params.rs +++ b/nexus/types/src/external_api/params.rs @@ -2590,8 +2590,18 @@ impl TryFrom for RelativeUri { #[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)] pub struct DeviceAuthRequest { pub client_id: Uuid, - /// Optional lifetime for the access token in seconds. If not specified, the - /// silo's max TTL will be used (if set). + /// Optional lifetime for the access token in seconds. + /// + /// This value will be validated during the confirmation step. If not + /// specified, it defaults to the silo's max TTL, which can be seen at + /// `/v1/auth-settings`. If specified, must not exceed the silo's max TTL. + /// + /// Some special logic applies when authenticating the confirmation request + /// with an existing device token: the requested TTL must not produce an + /// expiration time later than the authenticating token's expiration. If no + /// TTL is specified, the expiration will be the lesser of the silo max and + /// the authenticating token's expiration time. To get the longest allowed + /// lifetime, omit the TTL and authenticate with a web console session. pub ttl_seconds: Option, } diff --git a/openapi/nexus.json b/openapi/nexus.json index 621cb8ccaf5..4a418f0d323 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -46,7 +46,7 @@ "console-auth" ], "summary": "Confirm an OAuth 2.0 Device Authorization Grant", - "description": "This endpoint is designed to be accessed by the user agent (browser), not the client requesting the token. So we do not actually return the token here; it will be returned in response to the poll on `/device/token`.", + "description": "This endpoint is designed to be accessed by the user agent (browser), not the client requesting the token. So we do not actually return the token here; it will be returned in response to the poll on `/device/token`.\n\nSome special logic applies when authenticating this request with an existing device token instead of a console session: the requested TTL must not produce an expiration time later than the authenticating token's expiration. If no TTL was specified in the initial grant request, the expiration will be the lesser of the silo max and the authenticating token's expiration time. To get the longest allowed lifetime, omit the TTL and authenticate with a web console session.", "operationId": "device_auth_confirm", "requestBody": { "content": { @@ -18288,7 +18288,7 @@ }, "ttl_seconds": { "nullable": true, - "description": "Optional lifetime for the access token in seconds. If not specified, the silo's max TTL will be used (if set).", + "description": "Optional lifetime for the access token in seconds.\n\nThis value will be validated during the confirmation step. If not specified, it defaults to the silo's max TTL, which can be seen at `/v1/auth-settings`. If specified, must not exceed the silo's max TTL.\n\nSome special logic applies when authenticating the confirmation request with an existing device token: the requested TTL must not produce an expiration time later than the authenticating token's expiration. If no TTL is specified, the expiration will be the lesser of the silo max and the authenticating token's expiration time. To get the longest allowed lifetime, omit the TTL and authenticate with a web console session.", "type": "integer", "format": "uint32", "minimum": 1