Skip to content

Commit 9dffe77

Browse files
committed
make everything less ugly
1 parent 81c3534 commit 9dffe77

File tree

11 files changed

+99
-109
lines changed

11 files changed

+99
-109
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: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use omicron_uuid_kinds::{AccessTokenKind, GenericUuid, TypedUuid};
1515
use rand::{Rng, RngCore, SeedableRng, distributions::Slice, rngs::StdRng};
1616
use uuid::Uuid;
1717

18+
use crate::SqlU32;
1819
use crate::typed_uuid::DbTypedUuid;
1920

2021
/// Default timeout in seconds for client to authenticate for a token request.
@@ -32,7 +33,9 @@ pub struct DeviceAuthRequest {
3233
pub user_code: String,
3334
pub time_created: DateTime<Utc>,
3435
pub time_expires: DateTime<Utc>,
35-
pub requested_ttl_seconds: Option<i64>,
36+
37+
/// TTL requested by the user
38+
pub token_ttl_seconds: Option<SqlU32>,
3639
}
3740

3841
impl DeviceAuthRequest {
@@ -108,7 +111,7 @@ impl DeviceAuthRequest {
108111
time_created: now,
109112
time_expires: now
110113
+ Duration::seconds(CLIENT_AUTHENTICATION_TIMEOUT),
111-
requested_ttl_seconds: requested_ttl_seconds.map(|ttl| ttl as i64),
114+
token_ttl_seconds: requested_ttl_seconds.map(SqlU32::from),
112115
}
113116
}
114117

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: 20 additions & 34 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,45 +119,29 @@ impl super::Nexus {
117119
.silo_auth_settings_view(opctx, &authz_silo)
118120
.await?;
119121

122+
let silo_max_ttl = silo_auth_settings.device_token_max_ttl_seconds;
123+
let requested_ttl = db_request.token_ttl_seconds;
124+
120125
// 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) =
123-
silo_auth_settings.device_token_max_ttl_seconds
124-
{
125-
if requested_ttl > max_ttl {
126-
return Err(Error::invalid_request(&format!(
127-
"Requested TTL {} exceeds maximum allowed TTL {} for this silo",
128-
requested_ttl, max_ttl
129-
)));
130-
}
126+
if let (Some(requested), Some(max)) = (requested_ttl, silo_max_ttl) {
127+
if requested > max.0.into() {
128+
return Err(Error::invalid_request(&format!(
129+
"Requested TTL {} seconds exceeds maximum allowed TTL {} seconds for this silo",
130+
requested, max
131+
)));
131132
}
132133
}
133134

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

153139
let token = DeviceAccessToken::new(
154140
db_request.client_id,
155141
db_request.device_code,
156142
db_request.time_created,
157143
silo_user_id,
158-
token_ttl,
144+
time_expires,
159145
);
160146

161147
if db_request.time_expires < Utc::now() {
@@ -254,7 +240,7 @@ impl super::Nexus {
254240
pub(crate) async fn device_access_token(
255241
&self,
256242
opctx: &OpContext,
257-
params: DeviceAccessTokenRequest,
243+
params: params::DeviceAccessTokenRequest,
258244
) -> Result<Response<Body>, HttpError> {
259245
// RFC 8628 §3.4
260246
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
@@ -7856,9 +7856,8 @@ impl NexusExternalApi for NexusExternalApiImpl {
78567856
}
78577857
};
78587858

7859-
let model = nexus
7860-
.device_auth_request_create(&opctx, params.client_id, params.ttl_seconds)
7861-
.await?;
7859+
let model =
7860+
nexus.device_auth_request_create(&opctx, params).await?;
78627861
nexus.build_oauth_response(
78637862
StatusCode::OK,
78647863
&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.
@@ -436,72 +438,68 @@ async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) {
436438
let testctx = &cptestctx.external_client;
437439

438440
// Set silo max TTL to 10 seconds
439-
let _settings: views::SiloSettings = object_put(
440-
testctx,
441-
"/v1/settings",
442-
&params::SiloSettingsUpdate {
443-
device_token_max_ttl_seconds: NonZeroU32::new(10),
444-
},
445-
)
446-
.await;
447-
448-
let client_id = Uuid::new_v4();
441+
let settings = params::SiloAuthSettingsUpdate {
442+
device_token_max_ttl_seconds: NonZeroU32::new(10).into(),
443+
};
444+
let _: views::SiloAuthSettings =
445+
object_put(testctx, "/v1/auth-settings", &settings).await;
449446

450447
// Test 1: Request TTL above the max should fail at verification time
451-
let authn_params_invalid = DeviceAuthRequest {
452-
client_id,
453-
ttl_seconds: Some(20) // Above the 10 second max
448+
let invalid_ttl = DeviceAuthRequest {
449+
client_id: Uuid::new_v4(),
450+
ttl_seconds: Some(20), // Above the 10 second max
454451
};
455452

456-
let auth_response: DeviceAuthResponse =
453+
let auth_response = NexusRequest::new(
457454
RequestBuilder::new(testctx, Method::POST, "/device/auth")
458-
.allow_non_dropshot_errors()
459-
.body_urlencoded(Some(&authn_params_invalid))
460-
.expect_status(Some(StatusCode::OK))
461-
.execute()
462-
.await
463-
.expect("failed to start client authentication flow")
464-
.parsed_body()
465-
.expect("client authentication response");
455+
.body_urlencoded(Some(&invalid_ttl))
456+
.expect_status(Some(StatusCode::OK)),
457+
)
458+
.execute_and_parse_unwrap::<DeviceAuthResponse>()
459+
.await;
466460

467-
let confirm_params = DeviceAuthVerify { user_code: auth_response.user_code };
461+
let confirm_params =
462+
DeviceAuthVerify { user_code: auth_response.user_code };
468463

469-
// Confirmation should fail because requested TTL exceeds max
470-
let confirm_response = NexusRequest::new(
464+
// Confirmation fails because requested TTL exceeds max
465+
let confirm_error = NexusRequest::new(
471466
RequestBuilder::new(testctx, Method::POST, "/device/confirm")
472467
.body(Some(&confirm_params))
473468
.expect_status(Some(StatusCode::BAD_REQUEST)),
474469
)
475470
.authn_as(AuthnMode::PrivilegedUser)
476-
.execute()
477-
.await
478-
.expect("confirmation should fail for TTL above max");
471+
.execute_and_parse_unwrap::<HttpErrorResponseBody>()
472+
.await;
479473

480474
// Check that the error message mentions TTL
481-
let error_body = String::from_utf8_lossy(&confirm_response.body);
482-
assert!(error_body.contains("TTL") || error_body.contains("ttl"));
475+
assert_eq!(confirm_error.error_code, Some("InvalidRequest".to_string()));
476+
assert_eq!(
477+
confirm_error.message,
478+
"Requested TTL 20 seconds exceeds maximum allowed TTL 10 seconds for this silo"
479+
);
483480

484481
// Test 2: Request TTL below the max should succeed and be used
485-
let authn_params_valid = DeviceAuthRequest {
486-
client_id: Uuid::new_v4(), // New client ID for new flow
487-
ttl_seconds: Some(5) // Below the 10 second max
482+
let valid_ttl = DeviceAuthRequest {
483+
client_id: Uuid::new_v4(),
484+
ttl_seconds: Some(3), // Below the 10 second max
488485
};
489486

490-
let auth_response: DeviceAuthResponse =
487+
let auth_response = NexusRequest::new(
491488
RequestBuilder::new(testctx, Method::POST, "/device/auth")
492-
.allow_non_dropshot_errors()
493-
.body_urlencoded(Some(&authn_params_valid))
494-
.expect_status(Some(StatusCode::OK))
495-
.execute()
496-
.await
497-
.expect("failed to start client authentication flow")
498-
.parsed_body()
499-
.expect("client authentication response");
489+
.body_urlencoded(Some(&valid_ttl))
490+
.expect_status(Some(StatusCode::OK)),
491+
)
492+
.execute_and_parse_unwrap::<DeviceAuthResponse>()
493+
.await;
500494

501495
let device_code = auth_response.device_code;
502496
let user_code = auth_response.user_code;
503497
let confirm_params = DeviceAuthVerify { user_code };
504498

499+
// this time will be pretty close to the now() used on the server when
500+
// calculating expiration time
501+
let t0 = Utc::now();
502+
505503
// Confirmation should succeed
506504
NexusRequest::new(
507505
RequestBuilder::new(testctx, Method::POST, "/device/confirm")
@@ -516,44 +514,39 @@ async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) {
516514
let token_params = DeviceAccessTokenRequest {
517515
grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
518516
device_code,
519-
client_id: authn_params_valid.client_id,
517+
client_id: valid_ttl.client_id,
520518
};
521519

522520
// Get the token
523-
let token: DeviceAccessTokenGrant = NexusRequest::new(
521+
let token_grant = NexusRequest::new(
524522
RequestBuilder::new(testctx, Method::POST, "/device/token")
525523
.allow_non_dropshot_errors()
526524
.body_urlencoded(Some(&token_params))
527525
.expect_status(Some(StatusCode::OK)),
528526
)
529527
.authn_as(AuthnMode::PrivilegedUser)
530-
.execute()
531-
.await
532-
.expect("failed to get token")
533-
.parsed_body()
534-
.expect("failed to deserialize token response");
528+
.execute_and_parse_unwrap::<DeviceAccessTokenGrant>()
529+
.await;
535530

536-
// Verify the token has the correct expiration (5 seconds from now)
531+
// Verify the token has roughly the correct expiration time. One second
532+
// threshold is sufficient to confirm it's not getting the silo max of 10
533+
// seconds. Locally, I saw diffs as low as 14ms.
537534
let tokens = get_tokens_priv(testctx).await;
538-
let our_token = tokens.iter().find(|t| t.time_expires.is_some()).unwrap();
539-
let expires_at = our_token.time_expires.unwrap();
540-
let now = Utc::now();
541-
542-
// Should expire approximately 5 seconds from now (allow some tolerance for test timing)
543-
let expected_expiry = now + chrono::Duration::seconds(5);
544-
let time_diff = (expires_at - expected_expiry).num_seconds().abs();
545-
assert!(time_diff <= 2, "Token expiry should be close to requested TTL");
535+
let time_expires = tokens[0].time_expires.unwrap();
536+
let expected_expires = t0 + Duration::from_secs(3);
537+
let diff_ms = (time_expires - expected_expires).num_milliseconds().abs();
538+
assert!(diff_ms <= 1000, "time diff was {diff_ms} ms. should be near zero");
546539

547540
// Token should work initially
548-
project_list(&testctx, &token.access_token, StatusCode::OK)
541+
project_list(&testctx, &token_grant.access_token, StatusCode::OK)
549542
.await
550543
.expect("token should work initially");
551544

552545
// Wait for token to expire
553-
sleep(Duration::from_secs(6)).await;
546+
sleep(Duration::from_secs(4)).await;
554547

555-
// Token should be expired now
556-
project_list(&testctx, &token.access_token, StatusCode::UNAUTHORIZED)
548+
// Token is expired
549+
project_list(&testctx, &token_grant.access_token, StatusCode::UNAUTHORIZED)
557550
.await
558551
.expect("token should be expired");
559552
}

nexus/types/src/external_api/params.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2467,7 +2467,8 @@ impl TryFrom<String> for RelativeUri {
24672467
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]
24682468
pub struct DeviceAuthRequest {
24692469
pub client_id: Uuid,
2470-
/// Optional TTL for the access token in seconds. If not specified, the silo's max TTL will be used.
2470+
/// Optional lifetime for the access token in seconds. If not specified, the
2471+
/// silo's max TTL will be used (if set).
24712472
pub ttl_seconds: Option<u32>,
24722473
}
24732474

openapi/nexus.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16676,6 +16676,13 @@
1667616676
"client_id": {
1667716677
"type": "string",
1667816678
"format": "uuid"
16679+
},
16680+
"ttl_seconds": {
16681+
"nullable": true,
16682+
"description": "Optional lifetime for the access token in seconds. If not specified, the silo's max TTL will be used (if set).",
16683+
"type": "integer",
16684+
"format": "uint32",
16685+
"minimum": 0
1667916686
}
1668016687
},
1668116688
"required": [

0 commit comments

Comments
 (0)