Skip to main content

miryad_core/rest/
tokens.rs

1//! Endpoint self-service pour gérer ses propres tokens API (issue #5) — page "mon compte", pas
2//! admin : chaque utilisateur ne voit et ne révoque que ses propres tokens. `issue_token`/
3//! `revoke_token`/`validate_token` (`auth::token`) existaient déjà comme fonctions Rust mais
4//! n'étaient montées derrière aucune route HTTP.
5
6use 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
19/// `issue_token`/`revoke_token` ne produisent en pratique que `AuthError::Database` — les autres
20/// variantes appartiennent au flow OIDC/session, jamais atteintes ici. Conversion explicite
21/// plutôt qu'un `From<AuthError>` générique qui laisserait croire à une correspondance 1:1.
22fn 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/// Jamais la valeur en clair — seul `token::IssuedToken` (retourné une fois, à l'émission) la
30/// porte.
31#[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
70/// Monte `GET/POST /api/v1/tokens` et `DELETE /api/v1/tokens/{id}` — n'importe quel principal
71/// authentifié (dual-auth), toujours restreint au `subject` courant, jamais les tokens d'un autre
72/// utilisateur. Réutilise `MiryadAuthState` comme les autres routeurs. Préfixe `/api/v1` figé,
73/// cohérent avec `resource_router` (feature 6).
74pub 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        // toujours vivant, jamais révoqué par l'appelante non-propriétaire.
316        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}