//! 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. #[tracing::instrument(skip_all)] pub async fn resolve_active(pool: &PgPool, token_hash: &str) -> Result> { // UPDATE...RETURNING does the expiry filter and the last_used_at stamp in // one round-trip; an expired token matches no row, so it resolves to None. let row: Option<(UserId, bool)> = sqlx::query_as( r#"UPDATE git_access_tokens SET last_used_at = NOW() WHERE token_hash = $1 AND (expires_at IS NULL OR expires_at > NOW()) RETURNING user_id, can_push"#, ) .bind(token_hash) .fetch_optional(pool) .await?; Ok(row.map(|(user_id, can_push)| ResolvedToken { user_id, can_push })) }