Skip to content

Commit 3eaaad5

Browse files
authored
Clamp TTL on device token confirmed with existing device token (#9411)
oxidecomputer/customer-support#662
1 parent 434e649 commit 3eaaad5

File tree

13 files changed

+477
-70
lines changed

13 files changed

+477
-70
lines changed

nexus/auth/src/authn/external/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -236,6 +236,7 @@ mod test {
236236
SKIP => SchemeResult::NotRequested,
237237
OK => SchemeResult::Authenticated(authn::Details {
238238
actor: self.actor,
239+
device_token_expiration: None,
239240
}),
240241
FAIL => SchemeResult::Failed(Reason::BadCredentials {
241242
actor: self.actor,

nexus/auth/src/authn/external/scim.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,7 +66,10 @@ where
6666
Ok(None) => SchemeResult::NotRequested,
6767
Ok(Some(token)) => match ctx.scim_token_actor(token).await {
6868
Err(error) => SchemeResult::Failed(error),
69-
Ok(actor) => SchemeResult::Authenticated(Details { actor }),
69+
Ok(actor) => SchemeResult::Authenticated(Details {
70+
actor,
71+
device_token_expiration: None,
72+
}),
7073
},
7174
}
7275
}

nexus/auth/src/authn/external/session_cookie.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,7 +180,10 @@ where
180180
debug!(log, "failed to extend session")
181181
}
182182

183-
SchemeResult::Authenticated(Details { actor })
183+
SchemeResult::Authenticated(Details {
184+
actor,
185+
device_token_expiration: None,
186+
})
184187
}
185188
}
186189

@@ -395,7 +398,10 @@ mod test {
395398
let result = authn_with_cookie(&context, Some("session=abc")).await;
396399
assert!(matches!(
397400
result,
398-
SchemeResult::Authenticated(Details { actor: _ })
401+
SchemeResult::Authenticated(Details {
402+
actor: _,
403+
device_token_expiration: _
404+
})
399405
));
400406

401407
// valid cookie should have updated time_last_used

nexus/auth/src/authn/external/spoof.rs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,10 @@ where
109109
Err(error) => SchemeResult::Failed(error),
110110
Ok(silo_id) => {
111111
let actor = Actor::SiloUser { silo_id, silo_user_id };
112-
SchemeResult::Authenticated(Details { actor })
112+
SchemeResult::Authenticated(Details {
113+
actor,
114+
device_token_expiration: None,
115+
})
113116
}
114117
}
115118
}

nexus/auth/src/authn/external/token.rs

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ use super::SchemeResult;
1111
use super::SiloUserSilo;
1212
use crate::authn;
1313
use async_trait::async_trait;
14+
use chrono::{DateTime, Utc};
1415
use headers::HeaderMapExt;
1516
use headers::authorization::{Authorization, Bearer};
1617

@@ -63,9 +64,14 @@ where
6364
match parse_token(headers.typed_get().as_ref()) {
6465
Err(error) => SchemeResult::Failed(error),
6566
Ok(None) => SchemeResult::NotRequested,
66-
Ok(Some(token)) => match ctx.token_actor(token).await {
67+
Ok(Some(token)) => match ctx.authenticate_token(token).await {
6768
Err(error) => SchemeResult::Failed(error),
68-
Ok(actor) => SchemeResult::Authenticated(Details { actor }),
69+
Ok((actor, device_token_expiration)) => {
70+
SchemeResult::Authenticated(Details {
71+
actor,
72+
device_token_expiration,
73+
})
74+
}
6975
},
7076
}
7177
}
@@ -91,7 +97,12 @@ fn parse_token(
9197
/// A context that can look up a Silo user and client ID from a token.
9298
#[async_trait]
9399
pub trait TokenContext {
94-
async fn token_actor(&self, token: String) -> Result<authn::Actor, Reason>;
100+
/// Returns the actor authenticated by the token and the token's expiration
101+
/// time (if any).
102+
async fn authenticate_token(
103+
&self,
104+
token: String,
105+
) -> Result<(authn::Actor, Option<DateTime<Utc>>), Reason>;
95106
}
96107

97108
#[cfg(test)]

nexus/auth/src/authn/mod.rs

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ pub use nexus_db_fixed_data::user_builtin::USER_SAGA_RECOVERY;
3838
pub use nexus_db_fixed_data::user_builtin::USER_SERVICE_BALANCER;
3939

4040
use crate::authz;
41+
use chrono::{DateTime, Utc};
4142
use newtype_derive::NewtypeDisplay;
4243
use nexus_db_fixed_data::silo::DEFAULT_SILO;
4344
use nexus_types::external_api::shared::FleetRole;
@@ -84,7 +85,7 @@ impl Context {
8485
&self,
8586
) -> Result<&Actor, omicron_common::api::external::Error> {
8687
match &self.kind {
87-
Kind::Authenticated(Details { actor }, ..) => Ok(actor),
88+
Kind::Authenticated(Details { actor, .. }, ..) => Ok(actor),
8889
Kind::Unauthenticated => {
8990
Err(omicron_common::api::external::Error::Unauthenticated {
9091
internal_message: "Actor required".to_string(),
@@ -93,6 +94,21 @@ impl Context {
9394
}
9495
}
9596

97+
/// Returns the expiration time if authenticated via a device token.
98+
///
99+
/// This is used to prevent token lifetime extension during token creation:
100+
/// a new token created using an existing token should not outlive the
101+
/// token used to authenticate.
102+
pub fn device_token_expiration(&self) -> Option<DateTime<Utc>> {
103+
match &self.kind {
104+
Kind::Authenticated(
105+
Details { device_token_expiration, .. },
106+
..,
107+
) => *device_token_expiration,
108+
Kind::Unauthenticated => None,
109+
}
110+
}
111+
96112
/// Returns the current actor's Silo if they have one or an appropriate
97113
/// error otherwise
98114
///
@@ -216,7 +232,10 @@ impl Context {
216232
fn context_for_builtin_user(user_builtin_id: BuiltInUserUuid) -> Context {
217233
Context {
218234
kind: Kind::Authenticated(
219-
Details { actor: Actor::UserBuiltin { user_builtin_id } },
235+
Details {
236+
actor: Actor::UserBuiltin { user_builtin_id },
237+
device_token_expiration: None,
238+
},
220239
None,
221240
),
222241
schemes_tried: Vec::new(),
@@ -234,6 +253,7 @@ impl Context {
234253
silo_user_id: USER_TEST_PRIVILEGED.id(),
235254
silo_id: USER_TEST_PRIVILEGED.silo_id,
236255
},
256+
device_token_expiration: None,
237257
},
238258
Some(SiloAuthnPolicy::try_from(&*DEFAULT_SILO).unwrap()),
239259
),
@@ -261,7 +281,10 @@ impl Context {
261281
) -> Context {
262282
Context {
263283
kind: Kind::Authenticated(
264-
Details { actor: Actor::SiloUser { silo_user_id, silo_id } },
284+
Details {
285+
actor: Actor::SiloUser { silo_user_id, silo_id },
286+
device_token_expiration: None,
287+
},
265288
Some(silo_authn_policy),
266289
),
267290
schemes_tried: Vec::new(),
@@ -273,7 +296,10 @@ impl Context {
273296
pub fn for_scim(silo_id: Uuid) -> Context {
274297
Context {
275298
kind: Kind::Authenticated(
276-
Details { actor: Actor::Scim { silo_id } },
299+
Details {
300+
actor: Actor::Scim { silo_id },
301+
device_token_expiration: None,
302+
},
277303
// This should never be non-empty, we don't want the SCIM user
278304
// to ever have associated roles.
279305
Some(SiloAuthnPolicy::new(BTreeMap::default())),
@@ -391,6 +417,11 @@ enum Kind {
391417
pub struct Details {
392418
/// the actor performing the request
393419
actor: Actor,
420+
/// When the device token expires. Present only when authenticating via
421+
/// a device token. This is a slightly awkward fit but is included here
422+
/// because we need to use this to clamp the expiration time when device
423+
/// tokens are confirmed using an existing device token.
424+
device_token_expiration: Option<DateTime<Utc>>,
394425
}
395426

396427
/// Who is performing an operation

nexus/external-api/src/lib.rs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4130,6 +4130,14 @@ pub trait NexusExternalApi {
41304130
/// This endpoint is designed to be accessed by the user agent (browser),
41314131
/// not the client requesting the token. So we do not actually return the
41324132
/// token here; it will be returned in response to the poll on `/device/token`.
4133+
///
4134+
/// Some special logic applies when authenticating this request with an
4135+
/// existing device token instead of a console session: the requested
4136+
/// TTL must not produce an expiration time later than the authenticating
4137+
/// token's expiration. If no TTL was specified in the initial grant
4138+
/// request, the expiration will be the lesser of the silo max and the
4139+
/// authenticating token's expiration time. To get the longest allowed
4140+
/// lifetime, omit the TTL and authenticate with a web console session.
41334141
#[endpoint {
41344142
method = POST,
41354143
path = "/device/confirm",

nexus/src/app/device_auth.rs

Lines changed: 66 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -123,20 +123,67 @@ impl super::Nexus {
123123
let silo_max_ttl = silo_auth_settings.device_token_max_ttl_seconds;
124124
let requested_ttl = db_request.token_ttl_seconds;
125125

126-
// Validate the requested TTL against the silo's max TTL
127-
if let (Some(requested), Some(max)) = (requested_ttl, silo_max_ttl) {
128-
if requested > max.0.into() {
129-
return Err(Error::invalid_request(&format!(
130-
"Requested TTL {} seconds exceeds maximum \
131-
allowed TTL for this silo of {} seconds",
132-
requested, max
133-
)));
126+
// This logic is a bit gnarly, but we landed on it as the least bad
127+
// option. Error out if the user requests a token TTL that is longer
128+
// than allowed, i.e., either
129+
//
130+
// a) it is longer than the silo max TTL, or
131+
// b) this request was authenticated with a device token and the TTL
132+
// would produce an expiration later than the current token's.
133+
//
134+
// If the user does not request a specific TTL, we do not error out.
135+
// We calculate the token TTL as min(silo max TTL, current token TTL
136+
// if present). Token confirm requests authenticated with a console
137+
// session can get device tokens with TTLs up to the silo max.
138+
139+
let time_expires = if let Some(requested_ttl) = requested_ttl {
140+
// If the user requested a TTL, validate it against the silo max
141+
// TTL as well as the expiration time of the token being used (if a
142+
// token is being used)
143+
144+
// Validate the requested TTL against the silo's max TTL
145+
if let Some(max) = silo_max_ttl {
146+
if requested_ttl > max.0.into() {
147+
return Err(Error::invalid_request(&format!(
148+
"Requested TTL {} seconds exceeds maximum allowed \
149+
TTL for this silo of {} seconds",
150+
requested_ttl, max
151+
)));
152+
}
153+
};
154+
155+
let requested_exp =
156+
Utc::now() + Duration::seconds(requested_ttl.0.into());
157+
158+
// If currently authenticated via token, error if requested exceeds it
159+
if let Some(auth_exp) = opctx.authn.device_token_expiration() {
160+
if requested_exp > auth_exp {
161+
return Err(Error::invalid_request(
162+
"Requested token TTL would exceed the expiration time \
163+
of the token being used to authenticate the confirm \
164+
request. To get the full requested TTL, confirm \
165+
this token using a web console session. Alternatively, \
166+
omit requested TTL to get a token with the longest \
167+
allowed lifetime, determined by the lesser of the silo \
168+
max and the current token's expiration time.",
169+
));
170+
}
134171
}
135-
}
136172

137-
let time_expires = requested_ttl
138-
.or(silo_max_ttl)
139-
.map(|ttl| Utc::now() + Duration::seconds(ttl.0.into()));
173+
Some(requested_exp)
174+
} else {
175+
// No explicit TTL requested. Rather than erroring out if silo max
176+
// exceeds TTL exceeds expiration time of current token, just clamp.
177+
let silo_max_exp = silo_max_ttl
178+
.map(|ttl| Utc::now() + Duration::seconds(ttl.0.into()));
179+
// a.min(b) doesn't do it because None is always less than Some(_)
180+
match (silo_max_exp, opctx.authn.device_token_expiration()) {
181+
(Some(silo_exp), Some(token_exp)) => {
182+
Some(silo_exp.min(token_exp))
183+
}
184+
(a, b) => a.or(b),
185+
}
186+
};
140187

141188
let token = DeviceAccessToken::new(
142189
db_request.client_id,
@@ -193,11 +240,12 @@ impl super::Nexus {
193240

194241
/// Look up the actor for which a token was granted.
195242
/// Corresponds to a request *after* completing the flow above.
196-
pub(crate) async fn device_access_token_actor(
243+
/// Returns the actor and the token's expiration time (if any).
244+
pub(crate) async fn authenticate_token(
197245
&self,
198246
opctx: &OpContext,
199247
token: String,
200-
) -> Result<Actor, Reason> {
248+
) -> Result<(Actor, Option<chrono::DateTime<Utc>>), Reason> {
201249
let (.., db_access_token) = self
202250
.db_datastore
203251
.device_token_lookup_by_token(opctx, token)
@@ -222,7 +270,9 @@ impl super::Nexus {
222270
})?;
223271
let silo_id = db_silo_user.silo_id;
224272

225-
if let Some(time_expires) = db_access_token.time_expires {
273+
let expiration = db_access_token.time_expires;
274+
275+
if let Some(time_expires) = expiration {
226276
let now = Utc::now();
227277
if time_expires < now {
228278
return Err(Reason::BadCredentials {
@@ -236,7 +286,7 @@ impl super::Nexus {
236286
}
237287
}
238288

239-
Ok(Actor::SiloUser { silo_user_id, silo_id })
289+
Ok((Actor::SiloUser { silo_user_id, silo_id }, expiration))
240290
}
241291

242292
pub(crate) async fn device_access_token(

nexus/src/context.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -458,12 +458,15 @@ impl authn::external::SiloUserSilo for ServerContext {
458458

459459
#[async_trait]
460460
impl authn::external::token::TokenContext for ServerContext {
461-
async fn token_actor(
461+
async fn authenticate_token(
462462
&self,
463463
token: String,
464-
) -> Result<authn::Actor, authn::Reason> {
464+
) -> Result<
465+
(authn::Actor, Option<chrono::DateTime<chrono::Utc>>),
466+
authn::Reason,
467+
> {
465468
let opctx = self.nexus.opctx_external_authn();
466-
self.nexus.device_access_token_actor(opctx, token).await
469+
self.nexus.authenticate_token(opctx, token).await
467470
}
468471
}
469472

0 commit comments

Comments
 (0)