Skip to main content

max / makenotwork

4.4 KB · 124 lines History Blame Raw
1 //! Personal access tokens for git over HTTPS (Basic auth).
2 //!
3 //! The plaintext token is shown to the user once at creation and never stored;
4 //! only its SHA-256 hex hash lives in the DB, so a database read can't recover
5 //! a usable credential. Lookups hash the presented token and match the column.
6
7 use chrono::{DateTime, Utc};
8 use sqlx::PgPool;
9
10 use crate::db::{GitAccessTokenId, UserId};
11 use crate::error::Result;
12
13 /// A git access token row, minus the hash (never returned to handlers/UI).
14 #[derive(Debug, sqlx::FromRow)]
15 pub struct DbGitAccessToken {
16 pub id: GitAccessTokenId,
17 pub user_id: UserId,
18 pub name: String,
19 pub can_push: bool,
20 pub created_at: DateTime<Utc>,
21 pub last_used_at: Option<DateTime<Utc>>,
22 pub expires_at: Option<DateTime<Utc>>,
23 }
24
25 /// The user + push scope resolved from a presented token (active tokens only).
26 pub struct ResolvedToken {
27 pub user_id: UserId,
28 pub can_push: bool,
29 }
30
31 /// Create a token. `token_hash` is the SHA-256 hex of the plaintext (the caller
32 /// generates the plaintext via `crypto::generate_git_token` and shows it once).
33 #[tracing::instrument(skip_all)]
34 pub async fn create(
35 pool: &PgPool,
36 user_id: UserId,
37 name: &str,
38 token_hash: &str,
39 can_push: bool,
40 expires_at: Option<DateTime<Utc>>,
41 ) -> Result<GitAccessTokenId> {
42 let id = sqlx::query_scalar::<_, GitAccessTokenId>(
43 r"INSERT INTO git_access_tokens (user_id, name, token_hash, can_push, expires_at)
44 VALUES ($1, $2, $3, $4, $5)
45 RETURNING id",
46 )
47 .bind(user_id)
48 .bind(name)
49 .bind(token_hash)
50 .bind(can_push)
51 .bind(expires_at)
52 .fetch_one(pool)
53 .await?;
54
55 Ok(id)
56 }
57
58 /// List a user's tokens (no hashes), newest first.
59 #[tracing::instrument(skip_all)]
60 pub async fn list_by_user(pool: &PgPool, user_id: UserId) -> Result<Vec<DbGitAccessToken>> {
61 let rows = sqlx::query_as::<_, DbGitAccessToken>(
62 r"SELECT id, user_id, name, can_push, created_at, last_used_at, expires_at
63 FROM git_access_tokens WHERE user_id = $1 ORDER BY created_at DESC",
64 )
65 .bind(user_id)
66 .fetch_all(pool)
67 .await?;
68
69 Ok(rows)
70 }
71
72 /// Revoke (delete) a token the user owns. Returns true if a row was removed.
73 #[tracing::instrument(skip_all)]
74 pub async fn revoke(pool: &PgPool, id: GitAccessTokenId, user_id: UserId) -> Result<bool> {
75 let result = sqlx::query("DELETE FROM git_access_tokens WHERE id = $1 AND user_id = $2")
76 .bind(id)
77 .bind(user_id)
78 .execute(pool)
79 .await?;
80
81 Ok(result.rows_affected() > 0)
82 }
83
84 /// Resolve a presented token's hash to its (user, push-scope), if the token
85 /// exists and has not expired. Stamps `last_used_at` on a hit. Returns `None`
86 /// for unknown or expired tokens, callers must treat that as unauthenticated.
87 ///
88 /// No constant-time compare here, deliberately: `token_hash` is already the
89 /// SHA-256 of the secret, so the lookup is an indexed equality on a value an
90 /// attacker cannot steer toward a stored secret via timing (a hash preimage is
91 /// infeasible). Constant-time matters where the *secret itself* is byte-compared
92 /// (e.g. CSRF/PKCE); a hash-indexed credential lookup is the correct asymmetry.
93 #[tracing::instrument(skip_all)]
94 pub async fn resolve_active(pool: &PgPool, token_hash: &str) -> Result<Option<ResolvedToken>> {
95 // Authenticate with a read (expiry filter; an expired token matches no row,
96 // so it resolves to None). The last_used_at stamp is then throttled to at
97 // most ~hourly with a separate best-effort write, so a hot CI fleet hammering
98 // one token doesn't turn every clone/fetch into a row write (ultra-fuzz M4).
99 let row: Option<(UserId, bool, Option<DateTime<Utc>>)> = sqlx::query_as(
100 r"SELECT user_id, can_push, last_used_at
101 FROM git_access_tokens
102 WHERE token_hash = $1 AND (expires_at IS NULL OR expires_at > NOW())",
103 )
104 .bind(token_hash)
105 .fetch_optional(pool)
106 .await?;
107
108 let Some((user_id, can_push, last_used_at)) = row else {
109 return Ok(None);
110 };
111
112 let stale = last_used_at.is_none_or(|t| Utc::now() - t > chrono::Duration::hours(1));
113 if stale {
114 // Best-effort: a stamp failure must never fail authentication.
115 let _ =
116 sqlx::query("UPDATE git_access_tokens SET last_used_at = NOW() WHERE token_hash = $1")
117 .bind(token_hash)
118 .execute(pool)
119 .await;
120 }
121
122 Ok(Some(ResolvedToken { user_id, can_push }))
123 }
124