feat(auth): support optional expiry for bot API tokens
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2
backend/Cargo.lock
generated
2
backend/Cargo.lock
generated
@@ -1558,7 +1558,7 @@ checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
||||
|
||||
[[package]]
|
||||
name = "mangalord"
|
||||
version = "0.92.0"
|
||||
version = "0.93.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "mangalord"
|
||||
version = "0.92.0"
|
||||
version = "0.93.0"
|
||||
edition = "2021"
|
||||
default-run = "mangalord"
|
||||
|
||||
|
||||
9
backend/migrations/0034_api_tokens_expires_at.sql
Normal file
9
backend/migrations/0034_api_tokens_expires_at.sql
Normal file
@@ -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;
|
||||
@@ -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<i64>,
|
||||
}
|
||||
|
||||
/// 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 }),
|
||||
|
||||
@@ -12,4 +12,6 @@ pub struct ApiToken {
|
||||
pub token_hash: Vec<u8>,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_used_at: Option<DateTime<Utc>>,
|
||||
/// When the token stops authenticating. `None` = never expires.
|
||||
pub expires_at: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
@@ -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<DateTime<Utc>>,
|
||||
) -> AppResult<ApiToken> {
|
||||
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<Option<ApiToken>> {
|
||||
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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "mangalord-frontend",
|
||||
"version": "0.92.0",
|
||||
"version": "0.93.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
Reference in New Issue
Block a user