Skip to content

Commit 99ffcbe

Browse files
authored
[5/n] Expire sessions by token again (#8260)
Built on top of #8231. Fix for an issue with the internals of session expiration discussed in #8137 (comment). When I added ID primary keys to sessions and tokens, I changed the session delete method on the Session trait to operate by ID as well, and that meant a more complex authz situation compared to deleting by token. A bearer token is its own authz, at least according to the implicit policy we decided on. When I changed it to delete by ID, I made the delete function (now removed in this PR) use the user ID from the `opctx` to filter tokens to ensure that users could only delete their own sessions. The problem is that the `opctx` used in that case is the external authenticator op context, which has its own special actor that is distinct from the user in question. This means we would never actually delete a token this way because the query would always come up empty. This is not actually a problem from a correctness point of view because on subsequent requests we always check the expiration time on the session we pull out of the DB anyway. But it does mean that we probably end up with a lot more sessions piling up in the DB than we otherwise would. Not a big deal either way, but I feel more comfortable having it keep working the way it has worked for several years. It's also nice that we are deleting some app code here and replacing it with a test for a behavior that was previously untested. (I added the test in the first commit and confirmed that it fails without the changes from the second commit.)
1 parent 1b08ebd commit 99ffcbe

File tree

7 files changed

+72
-84
lines changed

7 files changed

+72
-84
lines changed

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ pub trait SessionStore {
4545
) -> Option<Self::SessionModel>;
4646

4747
/// Mark session expired
48-
async fn session_expire(&self, id: ConsoleSessionUuid) -> Option<()>;
48+
async fn session_expire(&self, token: String) -> Option<()>;
4949

5050
/// Maximum time session can remain idle before expiring
5151
fn session_idle_timeout(&self) -> Duration;
@@ -133,7 +133,7 @@ where
133133
// expired
134134
let now = Utc::now();
135135
if session.time_last_used() + ctx.session_idle_timeout() < now {
136-
let expired_session = ctx.session_expire(session.id()).await;
136+
let expired_session = ctx.session_expire(token).await;
137137
if expired_session.is_none() {
138138
debug!(log, "failed to expire session")
139139
}
@@ -153,7 +153,7 @@ where
153153
// existed longer than absolute_timeout, it is expired and we can no
154154
// longer extend the session
155155
if session.time_created() + ctx.session_absolute_timeout() < now {
156-
let expired_session = ctx.session_expire(session.id()).await;
156+
let expired_session = ctx.session_expire(token).await;
157157
if expired_session.is_none() {
158158
debug!(log, "failed to expire session")
159159
}
@@ -273,9 +273,9 @@ mod test {
273273
}
274274
}
275275

276-
async fn session_expire(&self, id: ConsoleSessionUuid) -> Option<()> {
276+
async fn session_expire(&self, token: String) -> Option<()> {
277277
let mut sessions = self.sessions.lock().unwrap();
278-
sessions.retain(|s| s.id != id);
278+
sessions.retain(|s| s.token != token);
279279
Some(())
280280
}
281281

nexus/db-queries/src/db/datastore/console_session.rs

Lines changed: 0 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ use nexus_db_schema::schema::console_session;
1717
use omicron_common::api::external::CreateResult;
1818
use omicron_common::api::external::DeleteResult;
1919
use omicron_common::api::external::Error;
20-
use omicron_common::api::external::InternalContext;
2120
use omicron_common::api::external::LookupResult;
2221
use omicron_common::api::external::LookupType;
2322
use omicron_common::api::external::ResourceType;
@@ -135,53 +134,6 @@ impl DataStore {
135134
})
136135
}
137136

138-
// putting "hard" in the name because we don't do this with any other model
139-
pub async fn session_hard_delete(
140-
&self,
141-
opctx: &OpContext,
142-
authz_session: &authz::ConsoleSession,
143-
) -> DeleteResult {
144-
// We don't do a typical authz check here. Instead, knowing that every
145-
// user is allowed to delete their own session, the query below filters
146-
// on the session's silo_user_id matching the current actor's id.
147-
//
148-
// We could instead model this more like other authz checks. That would
149-
// involve fetching the session record from the database, storing the
150-
// associated silo_user_id into the `authz::ConsoleSession`, and having
151-
// an Oso rule saying you can delete a session whose associated silo
152-
// user matches the authenticated actor. This would be a fair bit more
153-
// complicated and more work at runtime work than what we're doing here.
154-
// The tradeoff is that we're effectively encoding policy here, but it
155-
// seems worth it in this case.
156-
let actor = opctx
157-
.authn
158-
.actor_required()
159-
.internal_context("deleting current user's session")?;
160-
161-
// This check shouldn't be required in that there should be no overlap
162-
// between silo user ids and other types of identity ids. But it's easy
163-
// to check, and if we add another type of Actor, we'll be forced here
164-
// to consider if they should be able to have console sessions and log
165-
// out of them.
166-
let silo_user_id = actor
167-
.silo_user_id()
168-
.ok_or_else(|| Error::invalid_request("not a Silo user"))?;
169-
170-
use nexus_db_schema::schema::console_session::dsl;
171-
diesel::delete(dsl::console_session)
172-
.filter(dsl::silo_user_id.eq(silo_user_id))
173-
.filter(dsl::id.eq(authz_session.id().into_untyped_uuid()))
174-
.execute_async(&*self.pool_connection_authorized(opctx).await?)
175-
.await
176-
.map(|_rows_deleted| ())
177-
.map_err(|e| {
178-
Error::internal_error(&format!(
179-
"error deleting session: {:?}",
180-
e
181-
))
182-
})
183-
}
184-
185137
pub async fn session_hard_delete_by_token(
186138
&self,
187139
opctx: &OpContext,

nexus/db-queries/src/db/datastore/mod.rs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -659,17 +659,6 @@ mod test {
659659
.unwrap();
660660
assert!(fetched.time_last_used > session.time_last_used);
661661

662-
// deleting it using `opctx` (which represents the test-privileged user)
663-
// should succeed but not do anything -- you can't delete someone else's
664-
// session
665-
let delete =
666-
datastore.session_hard_delete(&opctx, &authz_session).await;
667-
assert_eq!(delete, Ok(()));
668-
let fetched = datastore
669-
.session_lookup_by_token(&authn_opctx, token.clone())
670-
.await;
671-
assert!(fetched.is_ok());
672-
673662
// delete it and fetch should come back with nothing
674663
let silo_user_opctx = OpContext::for_background(
675664
logctx.log.new(o!()),
@@ -682,7 +671,7 @@ mod test {
682671
Arc::clone(&datastore) as Arc<dyn nexus_auth::storage::Storage>,
683672
);
684673
let delete = datastore
685-
.session_hard_delete(&silo_user_opctx, &authz_session)
674+
.session_hard_delete_by_token(&silo_user_opctx, token.clone())
686675
.await;
687676
assert_eq!(delete, Ok(()));
688677
let fetched = datastore
@@ -695,7 +684,7 @@ mod test {
695684

696685
// deleting an already nonexistent is considered a success
697686
let delete_again =
698-
datastore.session_hard_delete(&opctx, &authz_session).await;
687+
datastore.session_hard_delete_by_token(&opctx, token.clone()).await;
699688
assert_eq!(delete_again, Ok(()));
700689

701690
let delete_again =

nexus/src/app/session.rs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -80,19 +80,6 @@ impl super::Nexus {
8080
self.db_datastore.session_update_last_used(opctx, &authz_session).await
8181
}
8282

83-
pub(crate) async fn session_hard_delete(
84-
&self,
85-
opctx: &OpContext,
86-
id: ConsoleSessionUuid,
87-
) -> DeleteResult {
88-
let authz_session = authz::ConsoleSession::new(
89-
authz::FLEET,
90-
id,
91-
LookupType::ById(id.into_untyped_uuid()),
92-
);
93-
self.db_datastore.session_hard_delete(opctx, &authz_session).await
94-
}
95-
9683
pub(crate) async fn session_hard_delete_by_token(
9784
&self,
9885
opctx: &OpContext,

nexus/src/context.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -477,9 +477,9 @@ impl SessionStore for ServerContext {
477477
self.nexus.session_update_last_used(&opctx, id).await.ok()
478478
}
479479

480-
async fn session_expire(&self, id: ConsoleSessionUuid) -> Option<()> {
480+
async fn session_expire(&self, token: String) -> Option<()> {
481481
let opctx = self.nexus.opctx_external_authn();
482-
self.nexus.session_hard_delete(opctx, id).await.ok()
482+
self.nexus.session_hard_delete_by_token(opctx, token).await.ok()
483483
}
484484

485485
fn session_idle_timeout(&self) -> Duration {

nexus/tests/integration_tests/authn_http.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -397,9 +397,9 @@ impl session_cookie::SessionStore for WhoamiServerState {
397397
}
398398
}
399399

400-
async fn session_expire(&self, id: ConsoleSessionUuid) -> Option<()> {
400+
async fn session_expire(&self, token: String) -> Option<()> {
401401
let mut sessions = self.sessions.lock().unwrap();
402-
sessions.retain(|s| s.id != id);
402+
sessions.retain(|s| s.token != token);
403403
Some(())
404404
}
405405

nexus/tests/integration_tests/console_api.rs

Lines changed: 61 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ use dropshot::ResultsPage;
88
use dropshot::test_util::ClientTestContext;
99
use http::header::HeaderName;
1010
use http::{StatusCode, header, method::Method};
11+
use nexus_auth::context::OpContext;
1112
use std::env::current_dir;
1213

1314
use crate::integration_tests::saml::SAML_RESPONSE_IDP_DESCRIPTOR;
@@ -30,7 +31,7 @@ use nexus_test_utils_macros::nexus_test;
3031
use nexus_types::external_api::params::{self, ProjectCreate};
3132
use nexus_types::external_api::shared::{SiloIdentityMode, SiloRole};
3233
use nexus_types::external_api::{shared, views};
33-
use omicron_common::api::external::IdentityMetadataCreateParams;
34+
use omicron_common::api::external::{Error, IdentityMetadataCreateParams};
3435
use omicron_sled_agent::sim;
3536
use omicron_test_utils::dev::poll::{CondCheckError, wait_for_condition};
3637

@@ -918,3 +919,62 @@ async fn expect_redirect(testctx: &ClientTestContext, from: &str, to: &str) {
918919
.await
919920
.expect("did not find expected redirect");
920921
}
922+
923+
/// Make sure an expired session gets deleted when you try to use it
924+
///
925+
/// This is not the best kind of test because it breaks the API abstraction
926+
/// boundary, but in this case it's necessary because by design we do not
927+
/// expose through the API why authn failed, i.e., whether it's because
928+
/// the session was found but is expired. vs not found at all
929+
#[tokio::test]
930+
async fn test_session_idle_timeout_deletes_session() {
931+
// set idle timeout to 1 second so we can test expiration
932+
let mut config = load_test_config();
933+
config.pkg.console.session_idle_timeout_minutes = 0;
934+
let cptestctx = test_setup_with_config::<omicron_nexus::Server>(
935+
"test_session_idle_timeout_deletes_session",
936+
&mut config,
937+
sim::SimMode::Explicit,
938+
None,
939+
0,
940+
)
941+
.await;
942+
let testctx = &cptestctx.external_client;
943+
944+
// Start session
945+
let session_cookie = log_in_and_extract_token(&cptestctx).await;
946+
947+
// sleep here not necessary given TTL of 0
948+
949+
// Make a request with the expired session cookie
950+
let me_response = RequestBuilder::new(testctx, Method::GET, "/v1/me")
951+
.header(header::COOKIE, &session_cookie)
952+
.expect_status(Some(StatusCode::UNAUTHORIZED))
953+
.execute()
954+
.await
955+
.expect("first request with expired cookie did not 401");
956+
957+
let error_body: dropshot::HttpErrorResponseBody =
958+
me_response.parsed_body().unwrap();
959+
assert_eq!(error_body.message, "credentials missing or invalid");
960+
961+
// check that the session actually got deleted
962+
963+
let nexus = &cptestctx.server.server_context().nexus;
964+
let datastore = nexus.datastore();
965+
let opctx =
966+
OpContext::for_tests(cptestctx.logctx.log.new(o!()), datastore.clone());
967+
968+
let token = session_cookie.strip_prefix("session=").unwrap();
969+
let db_token_error = nexus
970+
.datastore()
971+
.session_lookup_by_token(&opctx, token.to_string())
972+
.await
973+
.expect_err("session should be deleted");
974+
assert_matches::assert_matches!(
975+
db_token_error,
976+
Error::ObjectNotFound { .. }
977+
);
978+
979+
cptestctx.teardown().await;
980+
}

0 commit comments

Comments
 (0)