Skip to content

Commit c877f93

Browse files
committed
first pass at token-specific TTL (100% claude code)
1 parent f6a9934 commit c877f93

File tree

8 files changed

+179
-13
lines changed

8 files changed

+179
-13
lines changed

nexus/db-model/src/device_auth.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ 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>,
3536
}
3637

3738
impl DeviceAuthRequest {
@@ -98,7 +99,7 @@ fn generate_user_code() -> String {
9899
}
99100

100101
impl DeviceAuthRequest {
101-
pub fn new(client_id: Uuid) -> Self {
102+
pub fn new(client_id: Uuid, requested_ttl_seconds: Option<u32>) -> Self {
102103
let now = Utc::now();
103104
Self {
104105
client_id,
@@ -107,6 +108,7 @@ impl DeviceAuthRequest {
107108
time_created: now,
108109
time_expires: now
109110
+ Duration::seconds(CLIENT_AUTHENTICATION_TIMEOUT),
111+
requested_ttl_seconds: requested_ttl_seconds.map(|ttl| ttl as i64),
110112
}
111113
}
112114

nexus/db-schema/src/schema.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1360,6 +1360,7 @@ table! {
13601360
device_code -> Text,
13611361
time_created -> Timestamptz,
13621362
time_expires -> Timestamptz,
1363+
requested_ttl_seconds -> Nullable<Int8>,
13631364
}
13641365
}
13651366

nexus/src/app/device_auth.rs

Lines changed: 38 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,14 @@ impl super::Nexus {
7878
&self,
7979
opctx: &OpContext,
8080
client_id: Uuid,
81+
requested_ttl_seconds: Option<u32>,
8182
) -> CreateResult<DeviceAuthRequest> {
8283
// TODO-correctness: the `user_code` generated for a new request
8384
// is used as a primary key, but may potentially collide with an
8485
// existing outstanding request. So we should retry some (small)
8586
// number of times if inserting the new request fails.
86-
let auth_request = DeviceAuthRequest::new(client_id);
87+
let auth_request =
88+
DeviceAuthRequest::new(client_id, requested_ttl_seconds);
8789
self.db_datastore.device_auth_request_create(opctx, auth_request).await
8890
}
8991

@@ -110,20 +112,48 @@ impl super::Nexus {
110112
.await?;
111113
assert_eq!(authz_user.id(), silo_user_id);
112114

113-
let silo_auth_settings =
114-
self.db_datastore.silo_auth_settings_view(opctx, &authz_silo).await?;
115+
let silo_auth_settings = self
116+
.db_datastore
117+
.silo_auth_settings_view(opctx, &authz_silo)
118+
.await?;
119+
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+
}
129+
}
130+
}
115131

116132
// 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+
};
150+
117151
let token = DeviceAccessToken::new(
118152
db_request.client_id,
119153
db_request.device_code,
120154
db_request.time_created,
121155
silo_user_id,
122-
// Token gets the max TTL for the silo (if there is one) until we
123-
// build a way for the user to ask for a different TTL
124-
silo_auth_settings
125-
.device_token_max_ttl_seconds
126-
.map(|ttl| Utc::now() + Duration::seconds(ttl)),
156+
token_ttl,
127157
);
128158

129159
if db_request.time_expires < Utc::now() {

nexus/src/external_api/http_entrypoints.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7855,7 +7855,7 @@ impl NexusExternalApi for NexusExternalApiImpl {
78557855
};
78567856

78577857
let model = nexus
7858-
.device_auth_request_create(&opctx, params.client_id)
7858+
.device_auth_request_create(&opctx, params.client_id, params.ttl_seconds)
78597859
.await?;
78607860
nexus.build_oauth_response(
78617861
StatusCode::OK,

nexus/tests/integration_tests/device_auth.rs

Lines changed: 129 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -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
236236
async 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+
424551
async fn get_tokens_priv(
425552
testctx: &ClientTestContext,
426553
) -> Vec<views::DeviceAccessToken> {

nexus/types/src/external_api/params.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2382,6 +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.
2386+
pub ttl_seconds: Option<u32>,
23852387
}
23862388

23872389
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema)]

schema/crdb/dbinit.sql

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2813,7 +2813,9 @@ CREATE TABLE IF NOT EXISTS omicron.public.device_auth_request (
28132813
client_id UUID NOT NULL,
28142814
device_code STRING(40) NOT NULL,
28152815
time_created TIMESTAMPTZ NOT NULL,
2816-
time_expires TIMESTAMPTZ NOT NULL
2816+
time_expires TIMESTAMPTZ NOT NULL,
2817+
-- requested TTL for the token in seconds (if specified by the user)
2818+
requested_ttl_seconds INT8 CHECK (requested_ttl_seconds > 0)
28172819
);
28182820

28192821
-- Access tokens granted in response to successful device authorization flows.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
ALTER TABLE omicron.public.device_auth_request
2+
ADD COLUMN IF NOT EXISTS requested_ttl_seconds INT8 CHECK (requested_ttl_seconds > 0);

0 commit comments

Comments
 (0)