Skip to content

Commit 2c789de

Browse files
committed
make everything less ugly
1 parent 956ad32 commit 2c789de

File tree

11 files changed

+99
-108
lines changed

11 files changed

+99
-108
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: 21 additions & 33 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, ListResultVec,
@@ -77,15 +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.
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
8789
let auth_request =
88-
DeviceAuthRequest::new(client_id, requested_ttl_seconds);
90+
DeviceAuthRequest::new(params.client_id, params.ttl_seconds);
8991
self.db_datastore.device_auth_request_create(opctx, auth_request).await
9092
}
9193

@@ -117,43 +119,29 @@ impl super::Nexus {
117119
.silo_auth_settings_view(opctx, &authz_silo)
118120
.await?;
119121

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

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

151139
let token = DeviceAccessToken::new(
152140
db_request.client_id,
153141
db_request.device_code,
154142
db_request.time_created,
155143
silo_user_id,
156-
token_ttl,
144+
time_expires,
157145
);
158146

159147
if db_request.time_expires < Utc::now() {
@@ -252,7 +240,7 @@ impl super::Nexus {
252240
pub(crate) async fn device_access_token(
253241
&self,
254242
opctx: &OpContext,
255-
params: DeviceAccessTokenRequest,
243+
params: params::DeviceAccessTokenRequest,
256244
) -> Result<Response<Body>, HttpError> {
257245
// RFC 8628 §3.4
258246
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
@@ -7854,9 +7854,8 @@ impl NexusExternalApi for NexusExternalApiImpl {
78547854
}
78557855
};
78567856

7857-
let model = nexus
7858-
.device_auth_request_create(&opctx, params.client_id, params.ttl_seconds)
7859-
.await?;
7857+
let model =
7858+
nexus.device_auth_request_create(&opctx, params).await?;
78607859
nexus.build_oauth_response(
78617860
StatusCode::OK,
78627861
&model.into_response(rqctx.server.using_tls(), host),

nexus/tests/integration_tests/device_auth.rs

Lines changed: 56 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};
@@ -55,6 +55,8 @@ 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+
// note that this exercises ttl_seconds being omitted from the body because
59+
// it's URL encoded, so None means it's omitted
5860
let authn_params = DeviceAuthRequest { client_id, ttl_seconds: None };
5961

6062
// Using a JSON encoded body fails.
@@ -426,72 +428,68 @@ async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) {
426428
let testctx = &cptestctx.external_client;
427429

428430
// 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();
431+
let settings = params::SiloSettingsUpdate {
432+
device_token_max_ttl_seconds: NonZeroU32::new(10).into(),
433+
};
434+
let _: views::SiloSettings =
435+
object_put(testctx, "/v1/settings", &settings).await;
439436

440437
// 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
438+
let invalid_ttl = DeviceAuthRequest {
439+
client_id: Uuid::new_v4(),
440+
ttl_seconds: Some(20), // Above the 10 second max
444441
};
445442

446-
let auth_response: DeviceAuthResponse =
443+
let auth_response = NexusRequest::new(
447444
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");
445+
.body_urlencoded(Some(&invalid_ttl))
446+
.expect_status(Some(StatusCode::OK)),
447+
)
448+
.execute_and_parse_unwrap::<DeviceAuthResponse>()
449+
.await;
456450

457-
let confirm_params = DeviceAuthVerify { user_code: auth_response.user_code };
451+
let confirm_params =
452+
DeviceAuthVerify { user_code: auth_response.user_code };
458453

459-
// Confirmation should fail because requested TTL exceeds max
460-
let confirm_response = NexusRequest::new(
454+
// Confirmation fails because requested TTL exceeds max
455+
let confirm_error = NexusRequest::new(
461456
RequestBuilder::new(testctx, Method::POST, "/device/confirm")
462457
.body(Some(&confirm_params))
463458
.expect_status(Some(StatusCode::BAD_REQUEST)),
464459
)
465460
.authn_as(AuthnMode::PrivilegedUser)
466-
.execute()
467-
.await
468-
.expect("confirmation should fail for TTL above max");
461+
.execute_and_parse_unwrap::<HttpErrorResponseBody>()
462+
.await;
469463

470464
// 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"));
465+
assert_eq!(confirm_error.error_code, Some("InvalidRequest".to_string()));
466+
assert_eq!(
467+
confirm_error.message,
468+
"Requested TTL 20 seconds exceeds maximum allowed TTL 10 seconds for this silo"
469+
);
473470

474471
// 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
472+
let valid_ttl = DeviceAuthRequest {
473+
client_id: Uuid::new_v4(),
474+
ttl_seconds: Some(3), // Below the 10 second max
478475
};
479476

480-
let auth_response: DeviceAuthResponse =
477+
let auth_response = NexusRequest::new(
481478
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");
479+
.body_urlencoded(Some(&valid_ttl))
480+
.expect_status(Some(StatusCode::OK)),
481+
)
482+
.execute_and_parse_unwrap::<DeviceAuthResponse>()
483+
.await;
490484

491485
let device_code = auth_response.device_code;
492486
let user_code = auth_response.user_code;
493487
let confirm_params = DeviceAuthVerify { user_code };
494488

489+
// this time will be pretty close to the now() used on the server when
490+
// calculating expiration time
491+
let t0 = Utc::now();
492+
495493
// Confirmation should succeed
496494
NexusRequest::new(
497495
RequestBuilder::new(testctx, Method::POST, "/device/confirm")
@@ -506,44 +504,39 @@ async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) {
506504
let token_params = DeviceAccessTokenRequest {
507505
grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
508506
device_code,
509-
client_id: authn_params_valid.client_id,
507+
client_id: valid_ttl.client_id,
510508
};
511509

512510
// Get the token
513-
let token: DeviceAccessTokenGrant = NexusRequest::new(
511+
let token_grant = NexusRequest::new(
514512
RequestBuilder::new(testctx, Method::POST, "/device/token")
515513
.allow_non_dropshot_errors()
516514
.body_urlencoded(Some(&token_params))
517515
.expect_status(Some(StatusCode::OK)),
518516
)
519517
.authn_as(AuthnMode::PrivilegedUser)
520-
.execute()
521-
.await
522-
.expect("failed to get token")
523-
.parsed_body()
524-
.expect("failed to deserialize token response");
518+
.execute_and_parse_unwrap::<DeviceAccessTokenGrant>()
519+
.await;
525520

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

537530
// Token should work initially
538-
project_list(&testctx, &token.access_token, StatusCode::OK)
531+
project_list(&testctx, &token_grant.access_token, StatusCode::OK)
539532
.await
540533
.expect("token should work initially");
541534

542535
// Wait for token to expire
543-
sleep(Duration::from_secs(6)).await;
536+
sleep(Duration::from_secs(4)).await;
544537

545-
// Token should be expired now
546-
project_list(&testctx, &token.access_token, StatusCode::UNAUTHORIZED)
538+
// Token is expired
539+
project_list(&testctx, &token_grant.access_token, StatusCode::UNAUTHORIZED)
547540
.await
548541
.expect("token should be expired");
549542
}

nexus/types/src/external_api/params.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2382,7 +2382,8 @@ impl TryFrom<String> for RelativeUri {
23822382
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
23832383
pub struct DeviceAuthRequest {
23842384
pub client_id: Uuid,
2385-
/// Optional TTL for the access token in seconds. If not specified, the silo's max TTL will be used.
2385+
/// Optional lifetime for the access token in seconds. If not specified, the
2386+
/// silo's max TTL will be used (if set).
23862387
pub ttl_seconds: Option<u32>,
23872388
}
23882389

openapi/nexus.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16658,6 +16658,13 @@
1665816658
"client_id": {
1665916659
"type": "string",
1666016660
"format": "uuid"
16661+
},
16662+
"ttl_seconds": {
16663+
"nullable": true,
16664+
"description": "Optional lifetime for the access token in seconds. If not specified, the silo's max TTL will be used (if set).",
16665+
"type": "integer",
16666+
"format": "uint32",
16667+
"minimum": 0
1666116668
}
1666216669
},
1666316670
"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;

0 commit comments

Comments
 (0)