1use axum::extract::{FromRef, Path, Query, State};
7use axum::http::StatusCode;
8use axum::routing::get;
9use axum::{Json, Router};
10use sea_orm::entity::prelude::*;
11use sea_orm::{ColumnTrait, PaginatorTrait, QueryFilter};
12use serde::{Deserialize, Serialize};
13
14use crate::auth::token::{ApiToken, Column};
15use crate::auth::{AuthError, AuthPrincipal, MiryadAuthState, issue_token, revoke_token};
16use crate::query::{PagedResult, Pagination};
17use crate::rest::error::RestError;
18
19fn to_rest_error(err: AuthError) -> RestError {
23 match err {
24 AuthError::Database(db_err) => RestError::Database(db_err),
25 other => RestError::Internal(other.to_string()),
26 }
27}
28
29#[derive(Debug, Clone, PartialEq, Serialize)]
32pub struct TokenSummary {
33 pub id: i32,
34 pub name: String,
35 pub created_at: DateTimeUtc,
36 pub expires_at: Option<DateTimeUtc>,
37 pub last_used_at: Option<DateTimeUtc>,
38}
39
40impl From<crate::auth::token::Model> for TokenSummary {
41 fn from(model: crate::auth::token::Model) -> Self {
42 Self {
43 id: model.id,
44 name: model.name,
45 created_at: model.created_at,
46 expires_at: model.expires_at,
47 last_used_at: model.last_used_at,
48 }
49 }
50}
51
52#[derive(Deserialize)]
53struct ListParams {
54 page: Option<u64>,
55 per_page: Option<u64>,
56}
57
58#[derive(Deserialize)]
59struct CreateTokenBody {
60 name: String,
61 expires_at: Option<DateTimeUtc>,
62}
63
64#[derive(Serialize)]
65struct CreatedToken {
66 id: i32,
67 token: String,
68}
69
70pub fn tokens_router<S>() -> Router<S>
75where
76 S: Clone + Send + Sync + 'static,
77 MiryadAuthState: FromRef<S>,
78{
79 Router::new().nest(
80 "/api/v1",
81 Router::new()
82 .route("/tokens", get(list_tokens_handler).post(create_token_handler))
83 .route("/tokens/{id}", axum::routing::delete(delete_token_handler)),
84 )
85}
86
87async fn list_tokens_handler(
88 State(auth): State<MiryadAuthState>,
89 principal: AuthPrincipal,
90 Query(params): Query<ListParams>,
91) -> Result<Json<PagedResult<TokenSummary>>, RestError> {
92 let pagination = Pagination::from_raw(params.page, params.per_page);
93 let paginator = ApiToken::find()
94 .filter(Column::Subject.eq(&principal.subject))
95 .paginate(&auth.db, pagination.per_page);
96 let totals = paginator.num_items_and_pages().await?;
97 let items = paginator
98 .fetch_page(pagination.page - 1)
99 .await?
100 .into_iter()
101 .map(TokenSummary::from)
102 .collect();
103
104 Ok(Json(PagedResult {
105 items,
106 page: pagination.page,
107 per_page: pagination.per_page,
108 total_items: totals.number_of_items,
109 total_pages: totals.number_of_pages,
110 }))
111}
112
113async fn create_token_handler(
114 State(auth): State<MiryadAuthState>,
115 principal: AuthPrincipal,
116 Json(body): Json<CreateTokenBody>,
117) -> Result<Json<CreatedToken>, RestError> {
118 let issued = issue_token(&auth.db, &principal.subject, &body.name, body.expires_at)
119 .await
120 .map_err(to_rest_error)?;
121 Ok(Json(CreatedToken {
122 id: issued.id,
123 token: issued.token,
124 }))
125}
126
127async fn delete_token_handler(
128 State(auth): State<MiryadAuthState>,
129 principal: AuthPrincipal,
130 Path(id): Path<i32>,
131) -> Result<StatusCode, RestError> {
132 let record = ApiToken::find_by_id(id)
133 .one(&auth.db)
134 .await?
135 .ok_or(RestError::NotFound)?;
136 if record.subject != principal.subject {
137 return Err(RestError::Forbidden);
138 }
139 revoke_token(&auth.db, id).await.map_err(to_rest_error)?;
140 Ok(StatusCode::NO_CONTENT)
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use crate::auth::oidc::MockOidcClient;
147 use crate::migration::Migrator;
148 use axum::body::Body;
149 use axum::http::Request;
150 use sea_orm::{Database, DatabaseConnection};
151 use sea_orm_migration::MigratorTrait;
152 use tower::ServiceExt;
153
154 async fn test_db() -> DatabaseConnection {
155 let db = Database::connect("sqlite::memory:")
156 .await
157 .expect("in-memory sqlite connects");
158 Migrator::up(&db, None).await.expect("migrations apply cleanly");
159 db
160 }
161
162 fn test_state(db: DatabaseConnection) -> MiryadAuthState {
163 MiryadAuthState {
164 oidc_client: std::sync::Arc::new(MockOidcClient),
165 cookie_key: ::cookie::Key::from(&[0u8; 64]),
166 post_login_redirect: "/".to_string(),
167 post_logout_redirect: "/".to_string(),
168 db,
169 }
170 }
171
172 fn app(state: MiryadAuthState) -> Router {
173 Router::new()
174 .merge(tokens_router::<MiryadAuthState>())
175 .with_state(state)
176 }
177
178 async fn json_body(resp: axum::response::Response) -> serde_json::Value {
179 let bytes = axum::body::to_bytes(resp.into_body(), usize::MAX)
180 .await
181 .expect("readable body");
182 serde_json::from_slice(&bytes).expect("valid JSON body")
183 }
184
185 fn request(method: &str, uri: &str, token: &str, body: Option<serde_json::Value>) -> Request<Body> {
186 let builder = Request::builder()
187 .method(method)
188 .uri(uri)
189 .header("Authorization", format!("Bearer {token}"))
190 .header("Content-Type", "application/json");
191 match body {
192 Some(value) => builder
193 .body(Body::from(value.to_string()))
194 .expect("valid request"),
195 None => builder.body(Body::empty()).expect("valid request"),
196 }
197 }
198
199 #[tokio::test]
200 async fn create_then_list_returns_the_token_without_the_cleartext_value() {
201 let db = test_db().await;
202 let bootstrap = issue_token(&db, "alice", "bootstrap", None)
203 .await
204 .expect("issuing succeeds")
205 .token;
206 let app = app(test_state(db));
207
208 let create_body = serde_json::json!({ "name": "cli laptop" });
209 let created = app
210 .clone()
211 .oneshot(request("POST", "/api/v1/tokens", &bootstrap, Some(create_body)))
212 .await
213 .expect("router does not fail");
214 assert_eq!(created.status(), StatusCode::OK);
215 let created_body = json_body(created).await;
216 let cleartext = created_body["token"].as_str().expect("token present").to_string();
217 assert!(cleartext.starts_with("mrd_"));
218
219 let listed = app
220 .oneshot(request("GET", "/api/v1/tokens", &bootstrap, None))
221 .await
222 .expect("router does not fail");
223 assert_eq!(listed.status(), StatusCode::OK);
224 let listed_body = json_body(listed).await;
225 assert_eq!(listed_body["total_items"], 2);
226 let items = listed_body["items"].as_array().expect("items array");
227 let new_entry = items
228 .iter()
229 .find(|t| t["name"] == "cli laptop")
230 .expect("new token present in the list");
231 assert!(
232 new_entry.get("token").is_none(),
233 "cleartext value must never be listed"
234 );
235 assert_eq!(new_entry["id"], created_body["id"]);
236 }
237
238 #[tokio::test]
239 async fn list_only_returns_the_caller_own_tokens() {
240 let db = test_db().await;
241 let alice_token = issue_token(&db, "alice", "alice's token", None)
242 .await
243 .expect("issuing succeeds")
244 .token;
245 issue_token(&db, "bob", "bob's token", None)
246 .await
247 .expect("issuing succeeds");
248 let app = app(test_state(db));
249
250 let resp = app
251 .oneshot(request("GET", "/api/v1/tokens", &alice_token, None))
252 .await
253 .expect("router does not fail");
254 let body = json_body(resp).await;
255 assert_eq!(body["total_items"], 1);
256 assert_eq!(body["items"][0]["name"], "alice's token");
257 }
258
259 #[tokio::test]
260 async fn owner_can_revoke_their_own_token() {
261 let db = test_db().await;
262 let alice_token = issue_token(&db, "alice", "alice's token", None)
263 .await
264 .expect("issuing succeeds")
265 .token;
266 let to_revoke = issue_token(&db, "alice", "to revoke", None)
267 .await
268 .expect("issuing succeeds");
269 let app = app(test_state(db));
270
271 let resp = app
272 .clone()
273 .oneshot(request(
274 "DELETE",
275 &format!("/api/v1/tokens/{}", to_revoke.id),
276 &alice_token,
277 None,
278 ))
279 .await
280 .expect("router does not fail");
281 assert_eq!(resp.status(), StatusCode::NO_CONTENT);
282
283 let listed = app
284 .oneshot(request("GET", "/api/v1/tokens", &alice_token, None))
285 .await
286 .expect("router does not fail");
287 let body = json_body(listed).await;
288 assert_eq!(body["total_items"], 1);
289 }
290
291 #[tokio::test]
292 async fn cannot_revoke_someone_else_token() {
293 let db = test_db().await;
294 let alice_token = issue_token(&db, "alice", "alice's token", None)
295 .await
296 .expect("issuing succeeds")
297 .token;
298 let bobs_token = issue_token(&db, "bob", "bob's token", None)
299 .await
300 .expect("issuing succeeds");
301 let db_check = db.clone();
302 let app = app(test_state(db));
303
304 let resp = app
305 .oneshot(request(
306 "DELETE",
307 &format!("/api/v1/tokens/{}", bobs_token.id),
308 &alice_token,
309 None,
310 ))
311 .await
312 .expect("router does not fail");
313 assert_eq!(resp.status(), StatusCode::FORBIDDEN);
314
315 let principal = crate::auth::validate_token(&db_check, &bobs_token.token)
317 .await
318 .expect("bob's token remains valid");
319 assert_eq!(principal.subject, "bob");
320 }
321
322 #[tokio::test]
323 async fn deleting_an_unknown_token_returns_not_found() {
324 let db = test_db().await;
325 let alice_token = issue_token(&db, "alice", "alice's token", None)
326 .await
327 .expect("issuing succeeds")
328 .token;
329 let app = app(test_state(db));
330
331 let resp = app
332 .oneshot(request("DELETE", "/api/v1/tokens/999999", &alice_token, None))
333 .await
334 .expect("router does not fail");
335 assert_eq!(resp.status(), StatusCode::NOT_FOUND);
336 }
337}