Skip to content

Commit 67ee8c9

Browse files
committed
let's try GET /v1/me/tokens
1 parent b2317f6 commit 67ee8c9

File tree

9 files changed

+310
-3
lines changed

9 files changed

+310
-3
lines changed

nexus/db-model/src/device_auth.rs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use nexus_db_schema::schema::{device_access_token, device_auth_request};
1111

1212
use chrono::{DateTime, Duration, Utc};
1313
use nexus_types::external_api::views;
14-
use omicron_uuid_kinds::{AccessTokenKind, TypedUuid};
14+
use omicron_uuid_kinds::{AccessTokenKind, GenericUuid, TypedUuid};
1515
use rand::{Rng, RngCore, SeedableRng, distributions::Slice, rngs::StdRng};
1616
use uuid::Uuid;
1717

@@ -173,6 +173,16 @@ impl From<DeviceAccessToken> for views::DeviceAccessTokenGrant {
173173
}
174174
}
175175

176+
impl From<DeviceAccessToken> for views::DeviceAccessToken {
177+
fn from(access_token: DeviceAccessToken) -> Self {
178+
Self {
179+
id: access_token.id.into_untyped_uuid(),
180+
time_created: access_token.time_created,
181+
time_expires: access_token.time_expires,
182+
}
183+
}
184+
}
185+
176186
#[cfg(test)]
177187
mod test {
178188
use super::*;

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,17 @@ use crate::authz;
99
use crate::context::OpContext;
1010
use crate::db::model::DeviceAccessToken;
1111
use crate::db::model::DeviceAuthRequest;
12+
use crate::db::pagination::paginated;
1213
use async_bb8_diesel::AsyncRunQueryDsl;
14+
use chrono::Utc;
1315
use diesel::prelude::*;
1416
use nexus_db_errors::ErrorHandler;
1517
use nexus_db_errors::public_error_from_diesel;
1618
use nexus_db_schema::schema::device_access_token;
1719
use omicron_common::api::external::CreateResult;
20+
use omicron_common::api::external::DataPageParams;
1821
use omicron_common::api::external::Error;
22+
use omicron_common::api::external::ListResultVec;
1923
use omicron_common::api::external::LookupResult;
2024
use omicron_common::api::external::LookupType;
2125
use omicron_common::api::external::ResourceType;
@@ -176,4 +180,33 @@ impl DataStore {
176180
)
177181
})
178182
}
183+
184+
pub async fn device_access_tokens_list(
185+
&self,
186+
opctx: &OpContext,
187+
authz_user: &authz::SiloUser,
188+
pagparams: &DataPageParams<'_, Uuid>,
189+
) -> ListResultVec<DeviceAccessToken> {
190+
// TODO: this authz check can't be right can it? or at least, we
191+
// should probably handle this explicitly at the policy level
192+
opctx.authorize(authz::Action::ListChildren, authz_user).await?;
193+
194+
use nexus_db_schema::schema::device_access_token::dsl;
195+
paginated(dsl::device_access_token, dsl::id, &pagparams)
196+
.filter(dsl::silo_user_id.eq(authz_user.id()))
197+
// we don't have time_deleted on tokens. unfortunately this is not
198+
// indexed well. maybe it can be!
199+
.filter(
200+
dsl::time_expires
201+
.is_null()
202+
.or(dsl::time_expires.gt(Utc::now())),
203+
)
204+
// TODO: what if we used a different model struct here so we're not
205+
// pulling less out of the DB and it's harder to accidentally return
206+
// the token itself
207+
.select(DeviceAccessToken::as_select())
208+
.load_async(&*self.pool_connection_authorized(opctx).await?)
209+
.await
210+
.map_err(|e| public_error_from_diesel(e, ErrorHandler::Server))
211+
}
179212
}

nexus/external-api/output/nexus_tags.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,6 +287,10 @@ API operations found with tag "system/status"
287287
OPERATION ID METHOD URL PATH
288288
ping GET /v1/ping
289289

290+
API operations found with tag "tokens"
291+
OPERATION ID METHOD URL PATH
292+
current_user_token_list GET /v1/me/tokens
293+
290294
API operations found with tag "vpcs"
291295
OPERATION ID METHOD URL PATH
292296
internet_gateway_create POST /v1/internet-gateways

nexus/external-api/src/lib.rs

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,12 @@ const PUT_UPDATE_REPOSITORY_MAX_BYTES: usize = 4 * GIB;
162162
url = "http://docs.oxide.computer/api/snapshots"
163163
}
164164
},
165+
"tokens" = {
166+
description = "API clients use device access tokens for authentication.",
167+
external_docs = {
168+
url = "http://docs.oxide.computer/api/tokens"
169+
}
170+
},
165171
"vpcs" = {
166172
description = "Virtual Private Clouds (VPCs) provide isolated network environments for managing and deploying services.",
167173
external_docs = {
@@ -3146,6 +3152,19 @@ pub trait NexusExternalApi {
31463152
path_params: Path<params::SshKeyPath>,
31473153
) -> Result<HttpResponseDeleted, HttpError>;
31483154

3155+
/// List device access tokens
3156+
///
3157+
/// List device access tokens for the currently authenticated user.
3158+
#[endpoint {
3159+
method = GET,
3160+
path = "/v1/me/tokens",
3161+
tags = ["tokens"],
3162+
}]
3163+
async fn current_user_token_list(
3164+
rqctx: RequestContext<Self::Context>,
3165+
query_params: Query<PaginatedById>,
3166+
) -> Result<HttpResponseOk<ResultsPage<views::DeviceAccessToken>>, HttpError>;
3167+
31493168
// Support bundles (experimental)
31503169

31513170
/// List all support bundles

nexus/src/app/device_auth.rs

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@ use nexus_db_queries::db::model::{DeviceAccessToken, DeviceAuthRequest};
5555
use anyhow::anyhow;
5656
use nexus_types::external_api::params::DeviceAccessTokenRequest;
5757
use nexus_types::external_api::views;
58-
use omicron_common::api::external::{CreateResult, Error};
58+
use omicron_common::api::external::{
59+
CreateResult, DataPageParams, Error, InternalContext, ListResultVec,
60+
};
5961

6062
use chrono::{Duration, Utc};
6163
use serde::Serialize;
@@ -289,4 +291,22 @@ impl super::Nexus {
289291
.header(header::CONTENT_TYPE, "application/json")
290292
.body(body.into())?)
291293
}
294+
295+
pub(crate) async fn current_user_token_list(
296+
&self,
297+
opctx: &OpContext,
298+
pagparams: &DataPageParams<'_, Uuid>,
299+
) -> ListResultVec<DeviceAccessToken> {
300+
let &actor = opctx
301+
.authn
302+
.actor_required()
303+
.internal_context("loading current user to list tokens")?;
304+
let (.., authz_user) = LookupPath::new(opctx, self.datastore())
305+
.silo_user_id(actor.actor_id())
306+
.lookup_for(authz::Action::ListChildren)
307+
.await?;
308+
self.db_datastore
309+
.device_access_tokens_list(opctx, &authz_user, pagparams)
310+
.await
311+
}
292312
}

nexus/src/external_api/http_entrypoints.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7067,6 +7067,37 @@ impl NexusExternalApi for NexusExternalApiImpl {
70677067
.await
70687068
}
70697069

7070+
async fn current_user_token_list(
7071+
rqctx: RequestContext<Self::Context>,
7072+
query_params: Query<PaginatedById>,
7073+
) -> Result<HttpResponseOk<ResultsPage<views::DeviceAccessToken>>, HttpError>
7074+
{
7075+
let apictx = rqctx.context();
7076+
let handler = async {
7077+
let opctx =
7078+
crate::context::op_context_for_external_api(&rqctx).await?;
7079+
let nexus = &apictx.context.nexus;
7080+
let query = query_params.into_inner();
7081+
let pag_params = data_page_params_for(&rqctx, &query)?;
7082+
let tokens = nexus
7083+
.current_user_token_list(&opctx, &pag_params)
7084+
.await?
7085+
.into_iter()
7086+
.map(views::DeviceAccessToken::from)
7087+
.collect();
7088+
Ok(HttpResponseOk(ScanById::results_page(
7089+
&query,
7090+
tokens,
7091+
&marker_for_id,
7092+
)?))
7093+
};
7094+
apictx
7095+
.context
7096+
.external_latencies
7097+
.instrument_dropshot_handler(&rqctx, handler)
7098+
.await
7099+
}
7100+
70707101
async fn support_bundle_list(
70717102
rqctx: RequestContext<ApiContext>,
70727103
query_params: Query<PaginatedById>,

nexus/tests/integration_tests/device_auth.rs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
use std::num::NonZeroU32;
66

7+
use chrono::Utc;
8+
use dropshot::ResultsPage;
79
use dropshot::test_util::ClientTestContext;
810
use nexus_auth::authn::USER_TEST_UNPRIVILEGED;
911
use nexus_db_queries::db::fixed_data::silo::DEFAULT_SILO;
@@ -138,6 +140,10 @@ async fn test_device_auth_flow(cptestctx: &ControlPlaneTestContext) {
138140
.expect("failed to deserialize OAuth error");
139141
assert_eq!(&error.error, "authorization_pending");
140142

143+
// Check tokens before creating the device token
144+
assert_eq!(get_tokens_priv(testctx).await.len(), 0);
145+
assert_eq!(get_tokens_unpriv(testctx).await.len(), 0);
146+
141147
// Authenticated confirmation should succeed.
142148
NexusRequest::new(
143149
RequestBuilder::new(testctx, Method::POST, "/device/confirm")
@@ -162,10 +168,17 @@ async fn test_device_auth_flow(cptestctx: &ControlPlaneTestContext) {
162168
.expect("failed to get token")
163169
.parsed_body()
164170
.expect("failed to deserialize token response");
171+
165172
assert_eq!(token.token_type, DeviceAccessTokenType::Bearer);
166173
assert_eq!(token.access_token.len(), 52);
167174
assert!(token.access_token.starts_with("oxide-token-"));
168175

176+
// Check token list endpoints after creating the device token
177+
assert_eq!(get_tokens_priv(testctx).await.len(), 0);
178+
let tokens_unpriv_after = get_tokens_unpriv(testctx).await;
179+
assert_eq!(tokens_unpriv_after.len(), 1);
180+
assert_eq!(tokens_unpriv_after[0].time_expires, None);
181+
169182
// now make a request with the token. it 403s because unpriv user has no roles
170183
project_list(&testctx, &token.access_token, StatusCode::FORBIDDEN)
171184
.await
@@ -257,10 +270,18 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) {
257270
object_get(testctx, "/v1/settings").await;
258271
assert_eq!(settings.device_token_max_ttl_seconds, None);
259272

273+
// no tokens in the list
274+
assert_eq!(get_tokens_priv(testctx).await.len(), 0);
275+
260276
// get a token for the privileged user. default silo max token expiration
261277
// is null, so tokens don't expire
262278
let initial_token = get_device_token(testctx).await;
263279

280+
// now there is a token in the list
281+
let tokens = get_tokens_priv(testctx).await;
282+
assert_eq!(tokens.len(), 1);
283+
assert_eq!(tokens[0].time_expires, None);
284+
264285
// test token works on project list
265286
project_list(&testctx, &initial_token, StatusCode::OK)
266287
.await
@@ -299,6 +320,21 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) {
299320
// create token again (this one will have the 3-second expiration)
300321
let expiring_token = get_device_token(testctx).await;
301322

323+
// use a block so we don't touch expiring_token
324+
{
325+
// now there are two tokens in the list
326+
let tokens = get_tokens_priv(testctx).await;
327+
assert_eq!(tokens.len(), 2);
328+
329+
let permanent_token =
330+
tokens.iter().find(|t| t.time_expires.is_none()).unwrap();
331+
let expiring_token =
332+
tokens.iter().find(|t| t.time_expires.is_some()).unwrap();
333+
334+
assert_eq!(permanent_token.time_expires, None);
335+
assert!(expiring_token.time_expires.unwrap() > Utc::now());
336+
}
337+
302338
// immediately use token, it should work
303339
project_list(&testctx, &expiring_token, StatusCode::OK)
304340
.await
@@ -316,6 +352,31 @@ async fn test_device_token_expiration(cptestctx: &ControlPlaneTestContext) {
316352
project_list(&testctx, &initial_token, StatusCode::OK)
317353
.await
318354
.expect("initial token should still work");
355+
356+
// back down to one non-expiring token
357+
let tokens = get_tokens_priv(testctx).await;
358+
assert_eq!(tokens.len(), 1);
359+
assert_eq!(tokens[0].time_expires, None);
360+
}
361+
362+
async fn get_tokens_priv(
363+
testctx: &ClientTestContext,
364+
) -> Vec<views::DeviceAccessToken> {
365+
NexusRequest::object_get(testctx, "/v1/me/tokens")
366+
.authn_as(AuthnMode::PrivilegedUser)
367+
.execute_and_parse_unwrap::<ResultsPage<views::DeviceAccessToken>>()
368+
.await
369+
.items
370+
}
371+
372+
async fn get_tokens_unpriv(
373+
testctx: &ClientTestContext,
374+
) -> Vec<views::DeviceAccessToken> {
375+
NexusRequest::object_get(testctx, "/v1/me/tokens")
376+
.authn_as(AuthnMode::UnprivilegedUser)
377+
.execute_and_parse_unwrap::<ResultsPage<views::DeviceAccessToken>>()
378+
.await
379+
.items
319380
}
320381

321382
async fn project_list(

nexus/types/src/external_api/views.rs

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ use daft::Diffable;
1515
use omicron_common::api::external::{
1616
AffinityPolicy, AllowedSourceIps as ExternalAllowedSourceIps, ByteCount,
1717
Digest, Error, FailureDomain, IdentityMetadata, InstanceState, Name,
18-
ObjectIdentity, RoleName, SimpleIdentityOrName,
18+
ObjectIdentity, RoleName, SimpleIdentity, SimpleIdentityOrName,
1919
};
2020
use omicron_uuid_kinds::{AlertReceiverUuid, AlertUuid};
2121
use oxnet::{Ipv4Net, Ipv6Net};
@@ -987,6 +987,23 @@ pub struct SshKey {
987987
pub public_key: String,
988988
}
989989

990+
/// View of a device access token
991+
#[derive(Clone, Debug, Deserialize, Serialize, JsonSchema, PartialEq)]
992+
pub struct DeviceAccessToken {
993+
/// A unique, immutable, system-controlled identifier for the token.
994+
/// Note that this ID is not the bearer token itself, which starts with
995+
/// "oxide-token-"
996+
pub id: Uuid,
997+
pub time_created: DateTime<Utc>,
998+
pub time_expires: Option<DateTime<Utc>>,
999+
}
1000+
1001+
impl SimpleIdentity for DeviceAccessToken {
1002+
fn id(&self) -> Uuid {
1003+
self.id
1004+
}
1005+
}
1006+
9901007
// OAUTH 2.0 DEVICE AUTHORIZATION REQUESTS & TOKENS
9911008

9921009
/// Response to an initial device authorization request.

0 commit comments

Comments
 (0)