//! Personal access tokens for git over HTTPS (Basic auth). //! //! The plaintext token is shown to the user once at creation and never stored; //! only its SHA-256 hex hash lives in the DB, so a database read can't recover //! a usable credential. Lookups hash the presented token and match the column. use chrono::{DateTime, Utc}; use sqlx::PgPool; use crate::db::{GitAccessTokenId, UserId}; use crate::error::Result; /// A git access token row, minus the hash (never returned to handlers/UI). #[derive(Debug, sqlx::FromRow)] pub struct DbGitAccessToken { pub id: GitAccessTokenId, pub user_id: UserId, pub name: String, pub can_push: bool, pub created_at: DateTime, pub last_used_at: Option>, pub expires_at: Option>, } /// The user + push scope resolved from a presented token (active tokens only). pub struct ResolvedToken { pub user_id: UserId, pub can_push: bool, } /// Create a token. `token_hash` is the SHA-256 hex of the plaintext (the caller /// generates the plaintext via `crypto::generate_git_token` and shows it once). #[tracing::instrument(skip_all)] pub async fn create( pool: &PgPool, user_id: UserId, name: &str, token_hash: &str, can_push: bool, expires_at: Option>, ) -> Result { let id = sqlx::query_scalar::<_, GitAccessTokenId>( r"INSERT INTO git_access_tokens (user_id, name, token_hash, can_push, expires_at) VALUES ($1, $2, $3, $4, $5) RETURNING id", ) .bind(user_id) .bind(name) .bind(token_hash) .bind(can_push) .bind(expires_at) .fetch_one(pool) .await?; Ok(id) } /// List a user's tokens (no hashes), newest first. #[tracing::instrument(skip_all)] pub async fn list_by_user(pool: &PgPool, user_id: UserId) -> Result> { let rows = sqlx::query_as::<_, DbGitAccessToken>( r"SELECT id, user_id, name, can_push, created_at, last_used_at, expires_at FROM git_access_tokens WHERE user_id = $1 ORDER BY created_at DESC", ) .bind(user_id) .fetch_all(pool) .await?; Ok(rows) } /// Revoke (delete) a token the user owns. Returns true if a row was removed. #[tracing::instrument(skip_all)] pub async fn revoke(pool: &PgPool, id: GitAccessTokenId, user_id: UserId) -> Result { let result = sqlx::query("DELETE FROM git_access_tokens WHERE id = $1 AND user_id = $2") .bind(id) .bind(user_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) } /// Resolve a presented token's hash to its (user, push-scope), if the token /// exists and has not expired. Stamps `last_used_at` on a hit. Returns `None` /// for unknown or expired tokens, callers must treat that as unauthenticated. /// /// No constant-time compare here, deliberately: `token_hash` is already the /// SHA-256 of the secret, so the lookup is an indexed equality on a value an /// attacker cannot steer toward a stored secret via timing (a hash preimage is /// infeasible). Constant-time matters where the *secret itself* is byte-compared /// (e.g. CSRF/PKCE); a hash-indexed credential lookup is the correct asymmetry. #[tracing::instrument(skip_all)] pub async fn resolve_active(pool: &PgPool, token_hash: &str) -> Result> { // Authenticate with a read (expiry filter; an expired token matches no row, // so it resolves to None). The last_used_at stamp is then throttled to at // most ~hourly with a separate best-effort write, so a hot CI fleet hammering // one token doesn't turn every clone/fetch into a row write (ultra-fuzz M4). let row: Option<(UserId, bool, Option>)> = sqlx::query_as( r"SELECT user_id, can_push, last_used_at FROM git_access_tokens WHERE token_hash = $1 AND (expires_at IS NULL OR expires_at > NOW())", ) .bind(token_hash) .fetch_optional(pool) .await?; let Some((user_id, can_push, last_used_at)) = row else { return Ok(None); }; let stale = last_used_at.is_none_or(|t| Utc::now() - t > chrono::Duration::hours(1)); if stale { // Best-effort: a stamp failure must never fail authentication. let _ = sqlx::query("UPDATE git_access_tokens SET last_used_at = NOW() WHERE token_hash = $1") .bind(token_hash) .execute(pool) .await; } Ok(Some(ResolvedToken { user_id, can_push })) }