From 3cba9ecf95347f1d791e4d191bd1be5b00d8f64c Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 1 Jul 2026 07:22:57 +0200 Subject: [PATCH] feat(auth): support optional expiry for bot API tokens Co-Authored-By: Claude Opus 4.8 --- backend/Cargo.lock | 2 +- backend/Cargo.toml | 2 +- .../migrations/0034_api_tokens_expires_at.sql | 9 ++ backend/src/api/auth.rs | 24 ++++- backend/src/domain/api_token.rs | 2 + backend/src/repo/api_token.rs | 12 ++- backend/tests/api_auth.rs | 94 +++++++++++++++++++ frontend/package.json | 2 +- 8 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 backend/migrations/0034_api_tokens_expires_at.sql diff --git a/backend/Cargo.lock b/backend/Cargo.lock index c55b943..c4db5a6 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] name = "mangalord" -version = "0.92.0" +version = "0.93.0" dependencies = [ "anyhow", "argon2", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 25e2222..fc141e7 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mangalord" -version = "0.92.0" +version = "0.93.0" edition = "2021" default-run = "mangalord" diff --git a/backend/migrations/0034_api_tokens_expires_at.sql b/backend/migrations/0034_api_tokens_expires_at.sql new file mode 100644 index 0000000..c5f48f6 --- /dev/null +++ b/backend/migrations/0034_api_tokens_expires_at.sql @@ -0,0 +1,9 @@ +-- Optional expiry for bot API tokens. NULL = never expires (the prior +-- behaviour, preserved for all existing rows). When set, `find_active` +-- rejects the token past this instant, mirroring the sessions table's +-- `expires_at > now()` gate. +ALTER TABLE api_tokens ADD COLUMN expires_at TIMESTAMPTZ; + +-- Partial index to keep the active-token lookup cheap once expiries exist. +CREATE INDEX api_tokens_expires_at_idx ON api_tokens (expires_at) + WHERE expires_at IS NOT NULL; diff --git a/backend/src/api/auth.rs b/backend/src/api/auth.rs index 3573b1b..e4cf745 100644 --- a/backend/src/api/auth.rs +++ b/backend/src/api/auth.rs @@ -75,8 +75,16 @@ pub struct AuthResponse { #[derive(Debug, Deserialize)] pub struct CreateTokenInput { pub name: String, + /// Optional lifetime in days. Omit (or `null`) for a non-expiring + /// token (the historical behaviour). When set, must be 1..=3650. + #[serde(default)] + pub expires_in_days: Option, } +/// Upper bound on a requested token lifetime (~10 years). A token that needs +/// to outlive this should be rotated, not minted once forever. +const MAX_TOKEN_EXPIRY_DAYS: i64 = 3650; + #[derive(Debug, Deserialize)] pub struct ChangePassword { pub current_password: String, @@ -312,8 +320,22 @@ async fn create_token( details: serde_json::json!({ "name": "max 64 characters" }), }); } + let expires_at = match input.expires_in_days { + None => None, + Some(days) if (1..=MAX_TOKEN_EXPIRY_DAYS).contains(&days) => { + Some(Utc::now() + Duration::days(days)) + } + Some(_) => { + return Err(AppError::ValidationFailed { + message: "token expiry out of range".into(), + details: serde_json::json!({ + "expires_in_days": format!("must be between 1 and {MAX_TOKEN_EXPIRY_DAYS}") + }), + }); + } + }; let (raw, hash) = generate_token(); - let token = repo::api_token::create(&state.db, user.id, name, &hash).await?; + let token = repo::api_token::create(&state.db, user.id, name, &hash, expires_at).await?; Ok(( StatusCode::CREATED, Json(CreatedTokenResponse { token, bearer: raw }), diff --git a/backend/src/domain/api_token.rs b/backend/src/domain/api_token.rs index 8972df9..f5efb8c 100644 --- a/backend/src/domain/api_token.rs +++ b/backend/src/domain/api_token.rs @@ -12,4 +12,6 @@ pub struct ApiToken { pub token_hash: Vec, pub created_at: DateTime, pub last_used_at: Option>, + /// When the token stops authenticating. `None` = never expires. + pub expires_at: Option>, } diff --git a/backend/src/repo/api_token.rs b/backend/src/repo/api_token.rs index 87f9121..d86e018 100644 --- a/backend/src/repo/api_token.rs +++ b/backend/src/repo/api_token.rs @@ -2,6 +2,7 @@ //! token; the raw value is shown to the user once at creation and never //! stored. +use chrono::{DateTime, Utc}; use sqlx::PgPool; use uuid::Uuid; @@ -13,17 +14,19 @@ pub async fn create( user_id: Uuid, name: &str, token_hash: &[u8], + expires_at: Option>, ) -> AppResult { let row = sqlx::query_as::<_, ApiToken>( r#" - INSERT INTO api_tokens (user_id, name, token_hash) - VALUES ($1, $2, $3) - RETURNING id, user_id, name, token_hash, created_at, last_used_at + INSERT INTO api_tokens (user_id, name, token_hash, expires_at) + VALUES ($1, $2, $3, $4) + RETURNING id, user_id, name, token_hash, created_at, last_used_at, expires_at "#, ) .bind(user_id) .bind(name) .bind(token_hash) + .bind(expires_at) .fetch_one(pool) .await?; Ok(row) @@ -32,9 +35,10 @@ pub async fn create( pub async fn find_active(pool: &PgPool, token_hash: &[u8]) -> AppResult> { let row = sqlx::query_as::<_, ApiToken>( r#" - SELECT id, user_id, name, token_hash, created_at, last_used_at + SELECT id, user_id, name, token_hash, created_at, last_used_at, expires_at FROM api_tokens WHERE token_hash = $1 + AND (expires_at IS NULL OR expires_at > now()) "#, ) .bind(token_hash) diff --git a/backend/tests/api_auth.rs b/backend/tests/api_auth.rs index da51c58..05e927e 100644 --- a/backend/tests/api_auth.rs +++ b/backend/tests/api_auth.rs @@ -541,6 +541,100 @@ async fn create_and_use_bot_token(pool: PgPool) { assert_eq!(resp.status(), StatusCode::OK); } +#[sqlx::test(migrations = "./migrations")] +async fn bot_token_with_future_expiry_authenticates(pool: PgPool) { + // A token minted with expires_in_days is still active before its + // expiry, and the response echoes a non-null expires_at. + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + let resp = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + "/api/v1/auth/tokens", + json!({ "name": "ci-bot", "expires_in_days": 30 }), + &cookie, + )) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED); + let body = common::body_json(resp).await; + assert!( + body["expires_at"].is_string(), + "expires_at should be set, got {}", + body["expires_at"] + ); + let bearer = body["bearer"].as_str().unwrap().to_string(); + + let resp = h + .app + .oneshot(common::get_with_bearer("/api/v1/auth/me", &bearer)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); +} + +#[sqlx::test(migrations = "./migrations")] +async fn expired_bot_token_is_rejected(pool: PgPool) { + use chrono::{Duration, Utc}; + use mangalord::auth::token::generate_token; + + let h = common::harness(pool.clone()); + common::register_user(&h.app).await; + let user_id: uuid::Uuid = sqlx::query_scalar("SELECT id FROM users LIMIT 1") + .fetch_one(&pool) + .await + .unwrap(); + + // Hand-craft a token that expired an hour ago. + let (raw, hash) = generate_token(); + let expires_at = Utc::now() - Duration::hours(1); + sqlx::query( + "INSERT INTO api_tokens (user_id, name, token_hash, expires_at) \ + VALUES ($1, 'stale', $2, $3)", + ) + .bind(user_id) + .bind(&hash[..]) + .bind(expires_at) + .execute(&pool) + .await + .unwrap(); + + let resp = h + .app + .oneshot(common::get_with_bearer("/api/v1/auth/me", &raw)) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::UNAUTHORIZED); + let body = common::body_json(resp).await; + assert_eq!(body["error"]["code"], "unauthenticated"); +} + +#[sqlx::test(migrations = "./migrations")] +async fn create_token_rejects_out_of_range_expiry(pool: PgPool) { + let h = common::harness(pool); + let (_, cookie) = common::register_user(&h.app).await; + + for days in [0, -5, 100_000] { + let resp = h + .app + .clone() + .oneshot(common::post_json_with_cookie( + "/api/v1/auth/tokens", + json!({ "name": "bad", "expires_in_days": days }), + &cookie, + )) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::UNPROCESSABLE_ENTITY, + "expires_in_days={days} should be rejected" + ); + } +} + #[sqlx::test(migrations = "./migrations")] async fn user_a_cannot_delete_user_b_token(pool: PgPool) { let h = common::harness(pool); diff --git a/frontend/package.json b/frontend/package.json index ef74bae..fedf32d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,6 +1,6 @@ { "name": "mangalord-frontend", - "version": "0.92.0", + "version": "0.93.0", "private": true, "type": "module", "scripts": {