Skip to content

Commit 80451fb

Browse files
committed
first pass at token-specific TTL (100% claude code)
1 parent 7e375bf commit 80451fb

File tree

8 files changed

+173
-11
lines changed

8 files changed

+173
-11
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: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -78,12 +78,13 @@ 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 = DeviceAuthRequest::new(client_id, requested_ttl_seconds);
8788
self.db_datastore.device_auth_request_create(opctx, auth_request).await
8889
}
8990

@@ -113,17 +114,42 @@ impl super::Nexus {
113114
let silo_settings =
114115
self.db_datastore.silo_settings_view(opctx, &authz_silo).await?;
115116

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+
}
126+
}
127+
}
128+
116129
// 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+
};
146+
117147
let token = DeviceAccessToken::new(
118148
db_request.client_id,
119149
db_request.device_code,
120150
db_request.time_created,
121151
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_settings
125-
.device_token_max_ttl_seconds
126-
.map(|ttl| Utc::now() + Duration::seconds(ttl)),
152+
token_ttl,
127153
);
128154

129155
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
@@ -7840,7 +7840,7 @@ impl NexusExternalApi for NexusExternalApiImpl {
78407840
};
78417841

78427842
let model = nexus
7843-
.device_auth_request_create(&opctx, params.client_id)
7843+
.device_auth_request_create(&opctx, params.client_id, params.ttl_seconds)
78447844
.await?;
78457845
nexus.build_oauth_response(
78467846
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")
@@ -243,7 +243,7 @@ async fn test_device_auth_flow(cptestctx: &ControlPlaneTestContext) {
243243
/// as a string
244244
async fn get_device_token(testctx: &ClientTestContext) -> String {
245245
let client_id = Uuid::new_v4();
246-
let authn_params = DeviceAuthRequest { client_id };
246+
let authn_params = DeviceAuthRequest { client_id, ttl_seconds: None };
247247

248248
// Start a device authentication flow
249249
let auth_response: DeviceAuthResponse =
@@ -393,6 +393,133 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) {
393393
assert_eq!(tokens[0].time_expires, None);
394394
}
395395

396+
#[nexus_test]
397+
async fn test_device_token_request_ttl(cptestctx: &ControlPlaneTestContext) {
398+
let testctx = &cptestctx.external_client;
399+
400+
// 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();
411+
412+
// 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
416+
};
417+
418+
let auth_response: DeviceAuthResponse =
419+
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");
428+
429+
let confirm_params = DeviceAuthVerify { user_code: auth_response.user_code };
430+
431+
// Confirmation should fail because requested TTL exceeds max
432+
let confirm_response = NexusRequest::new(
433+
RequestBuilder::new(testctx, Method::POST, "/device/confirm")
434+
.body(Some(&confirm_params))
435+
.expect_status(Some(StatusCode::BAD_REQUEST)),
436+
)
437+
.authn_as(AuthnMode::PrivilegedUser)
438+
.execute()
439+
.await
440+
.expect("confirmation should fail for TTL above max");
441+
442+
// 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"));
445+
446+
// 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
450+
};
451+
452+
let auth_response: DeviceAuthResponse =
453+
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");
462+
463+
let device_code = auth_response.device_code;
464+
let user_code = auth_response.user_code;
465+
let confirm_params = DeviceAuthVerify { user_code };
466+
467+
// Confirmation should succeed
468+
NexusRequest::new(
469+
RequestBuilder::new(testctx, Method::POST, "/device/confirm")
470+
.body(Some(&confirm_params))
471+
.expect_status(Some(StatusCode::NO_CONTENT)),
472+
)
473+
.authn_as(AuthnMode::PrivilegedUser)
474+
.execute()
475+
.await
476+
.expect("failed to confirm");
477+
478+
let token_params = DeviceAccessTokenRequest {
479+
grant_type: "urn:ietf:params:oauth:grant-type:device_code".to_string(),
480+
device_code,
481+
client_id: authn_params_valid.client_id,
482+
};
483+
484+
// Get the token
485+
let token: DeviceAccessTokenGrant = NexusRequest::new(
486+
RequestBuilder::new(testctx, Method::POST, "/device/token")
487+
.allow_non_dropshot_errors()
488+
.body_urlencoded(Some(&token_params))
489+
.expect_status(Some(StatusCode::OK)),
490+
)
491+
.authn_as(AuthnMode::PrivilegedUser)
492+
.execute()
493+
.await
494+
.expect("failed to get token")
495+
.parsed_body()
496+
.expect("failed to deserialize token response");
497+
498+
// Verify the token has the correct expiration (5 seconds from now)
499+
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");
508+
509+
// Token should work initially
510+
project_list(&testctx, &token.access_token, StatusCode::OK)
511+
.await
512+
.expect("token should work initially");
513+
514+
// Wait for token to expire
515+
sleep(Duration::from_secs(6)).await;
516+
517+
// Token should be expired now
518+
project_list(&testctx, &token.access_token, StatusCode::UNAUTHORIZED)
519+
.await
520+
.expect("token should be expired");
521+
}
522+
396523
async fn get_tokens_priv(
397524
testctx: &ClientTestContext,
398525
) -> Vec<views::DeviceAccessToken> {

nexus/types/src/external_api/params.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2375,6 +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.
2379+
pub ttl_seconds: Option<u32>,
23782380
}
23792381

23802382
#[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)