Skip to content

Commit 4e70f83

Browse files
committed
Add Admin API to regenerate a personal session (getting a new PAT)
1 parent 1030ec9 commit 4e70f83

File tree

3 files changed

+262
-0
lines changed

3 files changed

+262
-0
lines changed

crates/handlers/src/admin/v1/mod.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,13 @@ where
106106
self::personal_sessions::revoke_doc,
107107
),
108108
)
109+
.api_route(
110+
"/personal-sessions/{id}/regenerate",
111+
post_with(
112+
self::personal_sessions::regenerate,
113+
self::personal_sessions::regenerate_doc,
114+
),
115+
)
109116
.api_route(
110117
"/policy-data",
111118
post_with(self::policy_data::set, self::policy_data::set_doc),

crates/handlers/src/admin/v1/personal_sessions/mod.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,13 @@
66
mod add;
77
mod get;
88
mod list;
9+
mod regenerate;
910
mod revoke;
1011

1112
pub use self::{
1213
add::{doc as add_doc, handler as add},
1314
get::{doc as get_doc, handler as get},
1415
list::{doc as list_doc, handler as list},
16+
regenerate::{doc as regenerate_doc, handler as regenerate},
1517
revoke::{doc as revoke_doc, handler as revoke},
1618
};
Lines changed: 253 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,253 @@
1+
// Copyright 2025 New Vector Ltd.
2+
//
3+
// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
4+
// Please see LICENSE files in the repository root for full details.
5+
6+
use aide::{NoApi, OperationIo, transform::TransformOperation};
7+
use axum::{Json, response::IntoResponse};
8+
use chrono::Duration;
9+
use hyper::StatusCode;
10+
use mas_axum_utils::record_error;
11+
use mas_data_model::{BoxRng, TokenType, personal::session::PersonalSessionOwner};
12+
use schemars::JsonSchema;
13+
use serde::Deserialize;
14+
use tracing::error;
15+
16+
use crate::{
17+
admin::{
18+
call_context::CallContext,
19+
model::{InconsistentPersonalSession, PersonalSession},
20+
params::UlidPathParam,
21+
response::{ErrorResponse, SingleResponse},
22+
},
23+
impl_from_error_for_route,
24+
};
25+
26+
#[derive(Debug, thiserror::Error, OperationIo)]
27+
#[aide(output_with = "Json<ErrorResponse>")]
28+
pub enum RouteError {
29+
#[error(transparent)]
30+
Internal(Box<dyn std::error::Error + Send + Sync + 'static>),
31+
32+
#[error("User not found")]
33+
UserNotFound,
34+
35+
#[error("Session not found")]
36+
SessionNotFound,
37+
38+
#[error("Session not valid")]
39+
SessionNotValid,
40+
41+
#[error("Session does not belong to you")]
42+
SessionNotYours,
43+
}
44+
45+
impl_from_error_for_route!(mas_storage::RepositoryError);
46+
impl_from_error_for_route!(InconsistentPersonalSession);
47+
48+
impl IntoResponse for RouteError {
49+
fn into_response(self) -> axum::response::Response {
50+
let error = ErrorResponse::from_error(&self);
51+
let sentry_event_id = record_error!(self, Self::Internal(_));
52+
let status = match self {
53+
Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR,
54+
Self::UserNotFound | Self::SessionNotFound => StatusCode::NOT_FOUND,
55+
Self::SessionNotValid => StatusCode::UNPROCESSABLE_ENTITY,
56+
Self::SessionNotYours => StatusCode::FORBIDDEN,
57+
};
58+
(status, sentry_event_id, Json(error)).into_response()
59+
}
60+
}
61+
62+
/// # JSON payload for the `POST /api/admin/v1/personal-sessions/{id}/regenerate` endpoint
63+
#[derive(Deserialize, JsonSchema)]
64+
#[serde(rename = "RegeneratePersonalSessionRequest")]
65+
pub struct Request {
66+
/// Token expiry time in seconds.
67+
/// If not set, the token will default to the same lifetime as when
68+
/// originally issued.
69+
expires_in: Option<u64>,
70+
}
71+
72+
pub fn doc(operation: TransformOperation) -> TransformOperation {
73+
operation
74+
.id("regeneratePersonalSession")
75+
.summary("Regenerate a personal session by replacing its personal access token")
76+
.tag("personal-session")
77+
.response_with::<201, Json<SingleResponse<PersonalSession>>, _>(|t| {
78+
t.description(
79+
"Personal session was regenerated and a personal access token was created",
80+
)
81+
})
82+
.response_with::<404, RouteError, _>(|t| {
83+
let response = ErrorResponse::from_error(&RouteError::UserNotFound);
84+
t.description("User was not found").example(response)
85+
})
86+
}
87+
88+
#[tracing::instrument(name = "handler.admin.v1.personal_sessions.add", skip_all)]
89+
pub async fn handler(
90+
CallContext {
91+
mut repo,
92+
clock,
93+
session: caller_session,
94+
..
95+
}: CallContext,
96+
NoApi(mut rng): NoApi<BoxRng>,
97+
id: UlidPathParam,
98+
Json(params): Json<Request>,
99+
) -> Result<(StatusCode, Json<SingleResponse<PersonalSession>>), RouteError> {
100+
let session_id = *id;
101+
102+
let session = repo
103+
.personal_session()
104+
.lookup(session_id)
105+
.await?
106+
.ok_or(RouteError::SessionNotFound)?;
107+
108+
if !session.is_valid() {
109+
// We don't revive revoked sessions through regeneration
110+
return Err(RouteError::SessionNotValid);
111+
}
112+
113+
// If the owner is not the current caller, then currently we reject the
114+
// regeneration.
115+
let caller = if let Some(user_id) = caller_session.user_id {
116+
PersonalSessionOwner::User(user_id)
117+
} else {
118+
PersonalSessionOwner::OAuth2Client(caller_session.client_id)
119+
};
120+
if session.owner != caller {
121+
return Err(RouteError::SessionNotYours);
122+
}
123+
124+
// Revoke the existing active token for the session.
125+
let old_token_opt = repo
126+
.personal_access_token()
127+
.find_active_for_session(session_id)
128+
.await?;
129+
let Some(old_token) = old_token_opt else {
130+
// This shouldn't happen
131+
error!("session is supposedly valid but had no access token");
132+
return Err(RouteError::SessionNotValid);
133+
};
134+
135+
// Calculate the expiry time of the new token, defaulting
136+
// to the token's previous lifetime
137+
let expires_in = params
138+
.expires_in
139+
.map(|exp_in| Duration::seconds(i64::try_from(exp_in).unwrap_or(i64::MAX)))
140+
.or_else(|| {
141+
old_token
142+
.expires_at
143+
.map(|exp_at| exp_at - old_token.created_at)
144+
});
145+
146+
repo.personal_access_token()
147+
.revoke(&clock, old_token)
148+
.await?;
149+
150+
// Create the regenerated token for the session
151+
let access_token_string = TokenType::PersonalAccessToken.generate(&mut rng);
152+
let access_token = repo
153+
.personal_access_token()
154+
.add(&mut rng, &clock, &session, &access_token_string, expires_in)
155+
.await?;
156+
157+
repo.save().await?;
158+
159+
Ok((
160+
StatusCode::CREATED,
161+
Json(SingleResponse::new_canonical(
162+
PersonalSession::try_from((session, Some(access_token)))?
163+
.with_token(access_token_string),
164+
)),
165+
))
166+
}
167+
168+
#[cfg(test)]
169+
mod tests {
170+
use chrono::Duration;
171+
use hyper::{Request, StatusCode};
172+
use insta::assert_json_snapshot;
173+
use serde_json::{Value, json};
174+
use sqlx::PgPool;
175+
176+
use crate::test_utils::{RequestBuilderExt, ResponseExt, TestState, setup};
177+
178+
#[sqlx::test(migrator = "mas_storage_pg::MIGRATOR")]
179+
async fn test_regenerate_personal_session(pool: PgPool) {
180+
setup();
181+
let mut state = TestState::from_pool(pool).await.unwrap();
182+
let token = state.token_with_scope("urn:mas:admin").await;
183+
184+
// Create a user for testing
185+
let mut repo = state.repository().await.unwrap();
186+
let mut rng = state.rng();
187+
let user = repo
188+
.user()
189+
.add(&mut rng, &state.clock, "alice".to_owned())
190+
.await
191+
.unwrap();
192+
193+
repo.save().await.unwrap();
194+
195+
let request = Request::post("/api/admin/v1/personal-sessions")
196+
.bearer(&token)
197+
.json(json!({
198+
"actor_user_id": user.id,
199+
"human_name": "SuperDuperAdminCLITool Token",
200+
"scope": "openid urn:mas:admin",
201+
"expires_in": 3600
202+
}));
203+
204+
let response = state.request(request).await;
205+
response.assert_status(StatusCode::CREATED);
206+
let created: Value = response.json();
207+
208+
let session_id = created["data"]["id"].as_str().unwrap();
209+
210+
state.clock.advance(Duration::minutes(3));
211+
212+
let request = Request::post(format!(
213+
"/api/admin/v1/personal-sessions/{session_id}/regenerate"
214+
))
215+
.bearer(&token)
216+
.json(json!({
217+
"expires_in": 86400
218+
}));
219+
220+
let response = state.request(request).await;
221+
response.assert_status(StatusCode::CREATED);
222+
223+
let body: Value = response.json();
224+
225+
assert_json_snapshot!(body, @r#"
226+
{
227+
"data": {
228+
"type": "personal-session",
229+
"id": "01FSHN9AG07HNEZXNQM2KNBNF6",
230+
"attributes": {
231+
"created_at": "2022-01-16T14:40:00Z",
232+
"revoked_at": null,
233+
"owner_user_id": null,
234+
"owner_client_id": "01FSHN9AG0FAQ50MT1E9FFRPZR",
235+
"actor_user_id": "01FSHN9AG0MZAA6S4AF7CTV32E",
236+
"human_name": "SuperDuperAdminCLITool Token",
237+
"scope": "openid urn:mas:admin",
238+
"last_active_at": null,
239+
"last_active_ip": null,
240+
"expires_at": "2022-01-17T14:43:00Z",
241+
"access_token": "mpt_6cq7FqNSYoosbXl3bbpfh9yNy9NzuR_0vOV2O"
242+
},
243+
"links": {
244+
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
245+
}
246+
},
247+
"links": {
248+
"self": "/api/admin/v1/personal-sessions/01FSHN9AG07HNEZXNQM2KNBNF6"
249+
}
250+
}
251+
"#);
252+
}
253+
}

0 commit comments

Comments
 (0)