feat(auth): support optional expiry for bot API tokens

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-07-01 07:22:57 +02:00
parent ed18d95bb0
commit 3cba9ecf95
8 changed files with 139 additions and 8 deletions

View File

@@ -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 }),

View File

@@ -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>>,
}

View File

@@ -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)