Skip to content

Commit fb2efbe

Browse files
committed
make everything less ugly
1 parent 21f0605 commit fb2efbe

File tree

11 files changed

+98
-107
lines changed

11 files changed

+98
-107
lines changed

end-to-end-tests/src/bin/bootstrap.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ async fn main() -> Result<()> {
115115
deserialize_byte_stream(
116116
ctx.client
117117
.device_auth_request()
118-
.body(DeviceAuthRequest { client_id })
118+
.body(DeviceAuthRequest { client_id, ttl_seconds: None })
119119
.send()
120120
.await?,
121121
)

nexus/db-model/src/device_auth.rs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,9 @@ pub struct DeviceAuthRequest {
3232
pub user_code: String,
3333
pub time_created: DateTime<Utc>,
3434
pub time_expires: DateTime<Utc>,
35-
pub requested_ttl_seconds: Option<i64>,
35+
36+
/// TTL requested by the user
37+
pub token_ttl_seconds: Option<i64>,
3638
}
3739

3840
impl DeviceAuthRequest {
@@ -108,7 +110,7 @@ impl DeviceAuthRequest {
108110
time_created: now,
109111
time_expires: now
110112
+ Duration::seconds(CLIENT_AUTHENTICATION_TIMEOUT),
111-
requested_ttl_seconds: requested_ttl_seconds.map(|ttl| ttl as i64),
113+
token_ttl_seconds: requested_ttl_seconds.map(i64::from),
112114
}
113115
}
114116

nexus/db-model/src/schema_versions.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ use std::{collections::BTreeMap, sync::LazyLock};
1616
///
1717
/// This must be updated when you change the database schema. Refer to
1818
/// schema/crdb/README.adoc in the root of this repository for details.
19-
pub const SCHEMA_VERSION: Version = Version::new(146, 0, 0);
19+
pub const SCHEMA_VERSION: Version = Version::new(147, 0, 0);
2020

2121
/// List of all past database schema versions, in *reverse* order
2222
///
@@ -28,6 +28,7 @@ static KNOWN_VERSIONS: LazyLock<Vec<KnownVersion>> = LazyLock::new(|| {
2828
// | leaving the first copy as an example for the next person.
2929
// v
3030
// KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"),
31+
KnownVersion::new(147, "device-auth-request-ttl"),
3132
KnownVersion::new(146, "silo-settings-token-expiration"),
3233
KnownVersion::new(145, "token-and-session-ids"),
3334
KnownVersion::new(144, "inventory-omicron-sled-config"),

nexus/db-schema/src/schema.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1360,7 +1360,7 @@ table! {
13601360
device_code -> Text,
13611361
time_created -> Timestamptz,
13621362
time_expires -> Timestamptz,
1363-
requested_ttl_seconds -> Nullable<Int8>,
1363+
token_ttl_seconds -> Nullable<Int8>,
13641364
}
13651365
}
13661366

nexus/src/app/device_auth.rs

Lines changed: 22 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ use nexus_db_queries::context::OpContext;
5353
use nexus_db_queries::db::model::{DeviceAccessToken, DeviceAuthRequest};
5454

5555
use anyhow::anyhow;
56-
use nexus_types::external_api::params::DeviceAccessTokenRequest;
56+
use nexus_types::external_api::params;
5757
use nexus_types::external_api::views;
5858
use omicron_common::api::external::{
5959
CreateResult, DataPageParams, Error, InternalContext, ListResultVec,
@@ -77,14 +77,17 @@ impl super::Nexus {
7777
pub(crate) async fn device_auth_request_create(
7878
&self,
7979
opctx: &OpContext,
80-
client_id: Uuid,
81-
requested_ttl_seconds: Option<u32>,
80+
params: params::DeviceAuthRequest,
8281
) -> CreateResult<DeviceAuthRequest> {
8382
// TODO-correctness: the `user_code` generated for a new request
8483
// is used as a primary key, but may potentially collide with an
8584
// existing outstanding request. So we should retry some (small)
8685
// number of times if inserting the new request fails.
87-
let auth_request = DeviceAuthRequest::new(client_id, requested_ttl_seconds);
86+
87+
// Note that we cannot validate the TTL here against the silo max
88+
// because we do not know what silo we're talking about until verify
89+
let auth_request =
90+
DeviceAuthRequest::new(params.client_id, params.ttl_seconds);
8891
self.db_datastore.device_auth_request_create(opctx, auth_request).await
8992
}
9093

@@ -114,42 +117,29 @@ impl super::Nexus {
114117
let silo_settings =
115118
self.db_datastore.silo_settings_view(opctx, &authz_silo).await?;
116119

117-
// Validate the requested TTL against the silo's max TTL
118-
if let Some(requested_ttl) = db_request.requested_ttl_seconds {
119-
if let Some(max_ttl) = silo_settings.device_token_max_ttl_seconds {
120-
if requested_ttl > max_ttl {
121-
return Err(Error::invalid_request(&format!(
122-
"Requested TTL {} exceeds maximum allowed TTL {} for this silo",
123-
requested_ttl, max_ttl
124-
)));
125-
}
120+
let silo_max_ttl = silo_settings.device_token_max_ttl_seconds;
121+
let requested_ttl = db_request.token_ttl_seconds;
122+
123+
// validate the requested TTL against silo max if both present
124+
if let (Some(requested), Some(max)) = (requested_ttl, silo_max_ttl) {
125+
if requested > max {
126+
return Err(Error::invalid_request(&format!(
127+
"Requested TTL {} seconds exceeds maximum allowed TTL {} seconds for this silo",
128+
requested, max
129+
)));
126130
}
127131
}
128132

129-
// Create an access token record.
130-
let token_ttl = match db_request.requested_ttl_seconds {
131-
Some(requested_ttl) => {
132-
// Use the requested TTL, but cap it at the silo max if there is one
133-
let effective_ttl = match silo_settings.device_token_max_ttl_seconds {
134-
Some(max_ttl) => std::cmp::min(requested_ttl, max_ttl),
135-
None => requested_ttl,
136-
};
137-
Some(Utc::now() + Duration::seconds(effective_ttl))
138-
}
139-
None => {
140-
// Use the silo max TTL if no specific TTL was requested
141-
silo_settings
142-
.device_token_max_ttl_seconds
143-
.map(|ttl| Utc::now() + Duration::seconds(ttl))
144-
}
145-
};
133+
let time_expires = requested_ttl
134+
.or(silo_max_ttl)
135+
.map(|ttl| Utc::now() + Duration::seconds(ttl));
146136

147137
let token = DeviceAccessToken::new(
148138
db_request.client_id,
149139
db_request.device_code,
150140
db_request.time_created,
151141
silo_user_id,
152-
token_ttl,
142+
time_expires,
153143
);
154144

155145
if db_request.time_expires < Utc::now() {
@@ -248,7 +238,7 @@ impl super::Nexus {
248238
pub(crate) async fn device_access_token(
249239
&self,
250240
opctx: &OpContext,
251-
params: DeviceAccessTokenRequest,
241+
params: params::DeviceAccessTokenRequest,
252242
) -> Result<Response<Body>, HttpError> {
253243
// RFC 8628 §3.4
254244
if params.grant_type != "urn:ietf:params:oauth:grant-type:device_code" {

nexus/src/external_api/http_entrypoints.rs

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7839,9 +7839,8 @@ impl NexusExternalApi for NexusExternalApiImpl {
78397839
}
78407840
};
78417841

7842-
let model = nexus
7843-
.device_auth_request_create(&opctx, params.client_id, params.ttl_seconds)
7844-
.await?;
7842+
let model =
7843+
nexus.device_auth_request_create(&opctx, params).await?;
78457844
nexus.build_oauth_response(
78467845
StatusCode::OK,
78477846
&model.into_response(rqctx.server.using_tls(), host),

nexus/tests/integration_tests/device_auth.rs

Lines changed: 54 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
use std::num::NonZeroU32;
66

77
use chrono::Utc;
8-
use dropshot::ResultsPage;
98
use dropshot::test_util::ClientTestContext;
9+
use dropshot::{HttpErrorResponseBody, ResultsPage};
1010
use nexus_auth::authn::USER_TEST_UNPRIVILEGED;
1111
use nexus_db_queries::db::fixed_data::silo::DEFAULT_SILO;
1212
use nexus_db_queries::db::identity::{Asset, Resource};
@@ -398,72 +398,68 @@ async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) {
398398
let testctx = &cptestctx.external_client;
399399

400400
// 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();
401+
let settings = params::SiloSettingsUpdate {
402+
device_token_max_ttl_seconds: NonZeroU32::new(10),
403+
};
404+
let _: views::SiloSettings =
405+
object_put(testctx, "/v1/settings", &settings).await;
411406

412407
// 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
408+
let invalid_ttl = DeviceAuthRequest {
409+
client_id: Uuid::new_v4(),
410+
ttl_seconds: Some(20), // Above the 10 second max
416411
};
417412

418-
let auth_response: DeviceAuthResponse =
413+
let auth_response = NexusRequest::new(
419414
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");
415+
.body_urlencoded(Some(&invalid_ttl))
416+
.expect_status(Some(StatusCode::OK)),
417+
)
418+
.execute_and_parse_unwrap::<DeviceAuthResponse>()
419+
.await;
428420

429-
let confirm_params = DeviceAuthVerify { user_code: auth_response.user_code };
421+
let confirm_params =
422+
DeviceAuthVerify { user_code: auth_response.user_code };
430423

431-
// Confirmation should fail because requested TTL exceeds max
432-
let confirm_response = NexusRequest::new(
424+
// Confirmation fails because requested TTL exceeds max
425+
let confirm_error = NexusRequest::new(
433426
RequestBuilder::new(testctx, Method::POST, "/device/confirm")
434427
.body(Some(&confirm_params))
435428
.expect_status(Some(StatusCode::BAD_REQUEST)),
436429
)
437430
.authn_as(AuthnMode::PrivilegedUser)
438-
.execute()
439-
.await
440-
.expect("confirmation should fail for TTL above max");
431+
.execute_and_parse_unwrap::<HttpErrorResponseBody>()
432+
.await;
441433

442434
// 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"));
435+
assert_eq!(confirm_error.error_code, Some("InvalidRequest".to_string()));
436+
assert_eq!(
437+
confirm_error.message,
438+
"Requested TTL 20 seconds exceeds maximum allowed TTL 10 seconds for this silo"
439+
);
445440

446441
// 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
442+
let valid_ttl = DeviceAuthRequest {
443+
client_id: Uuid::new_v4(),
444+
ttl_seconds: Some(3), // Below the 10 second max
450445
};
451446

452-
let auth_response: DeviceAuthResponse =
447+
let auth_response = NexusRequest::new(
453448
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");
449+
.body_urlencoded(Some(&valid_ttl))
450+
.expect_status(Some(StatusCode::OK)),
451+
)
452+
.execute_and_parse_unwrap::<DeviceAuthResponse>()
453+
.await;
462454

463455
let device_code = auth_response.device_code;
464456
let user_code = auth_response.user_code;
465457
let confirm_params = DeviceAuthVerify { user_code };
466458

459+
// this time will be pretty close to the now() used on the server when
460+
// calculating expiration time
461+
let t0 = Utc::now();
462+
467463
// Confirmation should succeed
468464
NexusRequest::new(
469465
RequestBuilder::new(testctx, Method::POST, "/device/confirm")
@@ -478,44 +474,39 @@ async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) {
478474
let token_params = DeviceAccessTokenRequest {
479475
grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
480476
device_code,
481-
client_id: authn_params_valid.client_id,
477+
client_id: valid_ttl.client_id,
482478
};
483479

484480
// Get the token
485-
let token: DeviceAccessTokenGrant = NexusRequest::new(
481+
let token_grant = NexusRequest::new(
486482
RequestBuilder::new(testctx, Method::POST, "/device/token")
487483
.allow_non_dropshot_errors()
488484
.body_urlencoded(Some(&token_params))
489485
.expect_status(Some(StatusCode::OK)),
490486
)
491487
.authn_as(AuthnMode::PrivilegedUser)
492-
.execute()
493-
.await
494-
.expect("failed to get token")
495-
.parsed_body()
496-
.expect("failed to deserialize token response");
488+
.execute_and_parse_unwrap::<DeviceAccessTokenGrant>()
489+
.await;
497490

498-
// Verify the token has the correct expiration (5 seconds from now)
491+
// Verify the token has roughly the correct expiration time. One second
492+
// threshold is sufficient to confirm it's not getting the silo max of 10
493+
// seconds. Locally, I saw diffs as low as 14ms.
499494
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");
495+
let time_expires = tokens[0].time_expires.unwrap();
496+
let expected_expires = t0 + Duration::from_secs(3);
497+
let diff_ms = (time_expires - expected_expires).num_milliseconds().abs();
498+
assert!(diff_ms <= 1000, "time diff was {diff_ms} ms. should be near zero");
508499

509500
// Token should work initially
510-
project_list(&testctx, &token.access_token, StatusCode::OK)
501+
project_list(&testctx, &token_grant.access_token, StatusCode::OK)
511502
.await
512503
.expect("token should work initially");
513504

514505
// Wait for token to expire
515-
sleep(Duration::from_secs(6)).await;
506+
sleep(Duration::from_secs(4)).await;
516507

517-
// Token should be expired now
518-
project_list(&testctx, &token.access_token, StatusCode::UNAUTHORIZED)
508+
// Token is expired
509+
project_list(&testctx, &token_grant.access_token, StatusCode::UNAUTHORIZED)
519510
.await
520511
.expect("token should be expired");
521512
}

nexus/types/src/external_api/params.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2375,7 +2375,8 @@ impl TryFrom<String> for RelativeUri {
23752375
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
23762376
pub struct DeviceAuthRequest {
23772377
pub client_id: Uuid,
2378-
/// Optional TTL for the access token in seconds. If not specified, the silo's max TTL will be used.
2378+
/// Optional lifetime for the access token in seconds. If not specified, the
2379+
/// silo's max TTL will be used (if set).
23792380
pub ttl_seconds: Option<u32>,
23802381
}
23812382

openapi/nexus.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16626,6 +16626,13 @@
1662616626
"client_id": {
1662716627
"type": "string",
1662816628
"format": "uuid"
16629+
},
16630+
"ttl_seconds": {
16631+
"nullable": true,
16632+
"description": "Optional lifetime for the access token in seconds. If not specified, the silo's max TTL will be used (if set).",
16633+
"type": "integer",
16634+
"format": "uint32",
16635+
"minimum": 0
1662916636
}
1663016637
},
1663116638
"required": [

schema/crdb/dbinit.sql

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2815,7 +2815,7 @@ CREATE TABLE IF NOT EXISTS omicron.public.device_auth_request (
28152815
time_created TIMESTAMPTZ NOT NULL,
28162816
time_expires TIMESTAMPTZ NOT NULL,
28172817
-- requested TTL for the token in seconds (if specified by the user)
2818-
requested_ttl_seconds INT8 CHECK (requested_ttl_seconds > 0)
2818+
token_ttl_seconds INT8 CHECK (token_ttl_seconds > 0)
28192819
);
28202820

28212821
-- Access tokens granted in response to successful device authorization flows.
@@ -5696,7 +5696,7 @@ INSERT INTO omicron.public.db_metadata (
56965696
version,
56975697
target_version
56985698
) VALUES
5699-
(TRUE, NOW(), NOW(), '146.0.0', NULL)
5699+
(TRUE, NOW(), NOW(), '147.0.0', NULL)
57005700
ON CONFLICT DO NOTHING;
57015701

57025702
COMMIT;
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
ALTER TABLE omicron.public.device_auth_request
2-
ADD COLUMN IF NOT EXISTS requested_ttl_seconds INT8 CHECK (requested_ttl_seconds > 0);
2+
ADD COLUMN IF NOT EXISTS token_ttl_seconds INT8 CHECK (token_ttl_seconds > 0);

0 commit comments

Comments
 (0)