fabrica

hanna/fabrica

9592 bytes
Raw
1// This Source Code Form is subject to the terms of the Mozilla Public
2// License, v. 2.0. If a copy of the MPL was not distributed with this
3// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
5//! `fabrica token` — API tokens (HS256 JWTs, revocable via their `jti` row).
6
7use std::path::Path;
8use std::process::ExitCode;
9use std::time::{SystemTime, UNIX_EPOCH};
10
11use auth::Claims;
12use clap::Subcommand;
13use model::ApiToken;
14use store::{NewToken, Store};
15
16use crate::context::{block_on, ensure_secret, open_store};
17use crate::prompt::confirm;
18
19/// Actions under `fabrica token`.
20#[derive(Debug, Subcommand)]
21pub(crate) enum TokenCommand {
22 /// Mint an API token. The signed JWT is printed **once** — it is not stored and
23 /// cannot be retrieved again.
24 Add {
25 /// The owning user.
26 user: String,
27 /// A label for the token.
28 name: String,
29 /// Comma-separated scopes (default `repo:read`). One of `repo:read`,
30 /// `repo:write`, `repo:admin`, `user:read`, `user:write`, `admin`.
31 #[arg(long)]
32 scopes: Option<String>,
33 /// Lifetime as a duration (`90d`, `24h`, `30m`); defaults to the configured
34 /// `auth.token_ttl_days`.
35 #[arg(long)]
36 expires: Option<String>,
37 },
38 /// Revoke and delete a token by its 1-based list index.
39 Del {
40 /// The owning user.
41 user: String,
42 /// The 1-based index from `token list`.
43 index: usize,
44 /// Skip the confirmation prompt (required when stdin is not a terminal).
45 #[arg(long)]
46 yes: bool,
47 },
48 /// List a user's tokens (never the token values — those are shown only once).
49 List {
50 /// The user whose tokens to list.
51 user: String,
52 },
53}
54
55/// Dispatch a `token` subcommand.
56pub(crate) fn run(
57 command: &TokenCommand,
58 config_path: Option<&Path>,
59 json: bool,
60) -> anyhow::Result<ExitCode> {
61 match command {
62 TokenCommand::Add {
63 user,
64 name,
65 scopes,
66 expires,
67 } => block_on(add(
68 config_path,
69 user,
70 name,
71 scopes.clone(),
72 expires.clone(),
73 )),
74 TokenCommand::Del { user, index, yes } => block_on(del(config_path, user, *index, *yes)),
75 TokenCommand::List { user } => block_on(list(config_path, user, json)),
76 }
77}
78
79/// Look up a user's id by name, or error cleanly.
80async fn require_user_id(store: &Store, name: &str) -> anyhow::Result<String> {
81 store
82 .user_by_username(name)
83 .await?
84 .map(|u| u.id)
85 .ok_or_else(|| anyhow::anyhow!("no such user {name:?}"))
86}
87
88/// The current time in Unix seconds.
89fn now_secs() -> i64 {
90 SystemTime::now()
91 .duration_since(UNIX_EPOCH)
92 .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
93}
94
95/// Parse a duration like `90d`, `24h`, `30m`, `45s`, or `2w` into seconds.
96fn parse_duration(s: &str) -> anyhow::Result<i64> {
97 let s = s.trim();
98 let (digits, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len()));
99 let value: i64 = digits
100 .parse()
101 .map_err(|_| anyhow::anyhow!("invalid duration {s:?} (e.g. 90d, 24h, 30m)"))?;
102 let multiplier = match unit {
103 "s" | "" => 1,
104 "m" => 60,
105 "h" => 3_600,
106 "d" => 86_400,
107 "w" => 604_800,
108 other => anyhow::bail!("unknown duration unit {other:?} (use s, m, h, d, or w)"),
109 };
110 Ok(value.saturating_mul(multiplier))
111}
112
113async fn add(
114 config_path: Option<&Path>,
115 user: &str,
116 name: &str,
117 scopes: Option<String>,
118 expires: Option<String>,
119) -> anyhow::Result<ExitCode> {
120 let (config, store) = open_store(config_path).await?;
121 let user_id = require_user_id(&store, user).await?;
122
123 // Validate and canonicalize the scope list.
124 let scope_list = scopes.unwrap_or_else(|| "repo:read".to_string());
125 let parsed = auth::parse_scopes(&scope_list)?;
126 let scopes = auth::format_scopes(&parsed);
127
128 let ttl = match expires {
129 Some(d) => parse_duration(&d)?,
130 None => i64::from(config.auth.token_ttl_days).saturating_mul(86_400),
131 };
132 let iat = now_secs();
133 let exp = iat.saturating_add(ttl);
134
135 let token = store
136 .create_token(NewToken {
137 user_id: user_id.clone(),
138 name: name.to_string(),
139 scopes: scopes.clone(),
140 expires_at: Some(exp.saturating_mul(1000)),
141 })
142 .await?;
143
144 let secret = ensure_secret(&config)?;
145 let jwt = auth::mint_jwt(
146 &secret,
147 &Claims {
148 sub: user_id,
149 jti: token.id,
150 scopes,
151 iat,
152 exp,
153 },
154 )?;
155
156 eprintln!("token {name:?} created for {user}; copy it now — it is not stored:");
157 println!("{jwt}");
158 Ok(ExitCode::SUCCESS)
159}
160
161async fn del(
162 config_path: Option<&Path>,
163 user: &str,
164 index: usize,
165 yes: bool,
166) -> anyhow::Result<ExitCode> {
167 let (_config, store) = open_store(config_path).await?;
168 let user_id = require_user_id(&store, user).await?;
169 let tokens = store.tokens_by_user(&user_id).await?;
170 let token = index
171 .checked_sub(1)
172 .and_then(|i| tokens.get(i))
173 .ok_or_else(|| anyhow::anyhow!("{user} has no token #{index}"))?;
174
175 if !confirm(&format!("Revoke token #{index} ({:?})?", token.name), yes)? {
176 println!("aborted");
177 return Ok(ExitCode::SUCCESS);
178 }
179 store.delete_token(&token.id).await?;
180 println!("revoked token #{index} for {user}");
181 Ok(ExitCode::SUCCESS)
182}
183
184async fn list(config_path: Option<&Path>, user: &str, json: bool) -> anyhow::Result<ExitCode> {
185 let (_config, store) = open_store(config_path).await?;
186 let user_id = require_user_id(&store, user).await?;
187 let tokens = store.tokens_by_user(&user_id).await?;
188
189 if json {
190 let view: Vec<_> = tokens
191 .iter()
192 .enumerate()
193 .map(|(i, t)| token_json(i + 1, t))
194 .collect();
195 println!("{}", serde_json::to_string_pretty(&view)?);
196 return Ok(ExitCode::SUCCESS);
197 }
198 if tokens.is_empty() {
199 println!("{user} has no tokens");
200 return Ok(ExitCode::SUCCESS);
201 }
202 println!("{:>3} {:<16} {:<28} STATE", "IDX", "NAME", "SCOPES");
203 for (i, token) in tokens.iter().enumerate() {
204 let state = if token.revoked_at.is_some() {
205 "revoked"
206 } else {
207 "active"
208 };
209 println!(
210 "{:>3} {:<16} {:<28} {state}",
211 i + 1,
212 token.name,
213 token.scopes
214 );
215 }
216 Ok(ExitCode::SUCCESS)
217}
218
219/// Render a token for `--json` (metadata only — never the JWT).
220fn token_json(index: usize, token: &ApiToken) -> serde_json::Value {
221 serde_json::json!({
222 "index": index,
223 "name": token.name,
224 "scopes": token.scopes,
225 "expires_at": token.expires_at,
226 "revoked": token.revoked_at.is_some(),
227 "last_used_at": token.last_used_at,
228 "created_at": token.created_at,
229 })
230}
231
232#[cfg(test)]
233mod tests {
234 #![allow(clippy::unwrap_used)]
235
236 use super::*;
237 use crate::testutil::env;
238
239 #[test]
240 fn parse_duration_units() {
241 assert_eq!(parse_duration("45s").unwrap(), 45);
242 assert_eq!(parse_duration("30m").unwrap(), 1_800);
243 assert_eq!(parse_duration("24h").unwrap(), 86_400);
244 assert_eq!(parse_duration("90d").unwrap(), 7_776_000);
245 assert_eq!(parse_duration("2w").unwrap(), 1_209_600);
246 assert!(parse_duration("10y").is_err());
247 }
248
249 #[test]
250 fn add_mints_a_verifiable_token_and_del_removes_it() {
251 let env = env();
252 let user_id = block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap();
253
254 // Minting stores a row whose id is the jti; the printed JWT verifies against
255 // the persisted secret and carries the right claims.
256 block_on(add(
257 env.path(),
258 "ada",
259 "ci",
260 Some("repo:read,repo:write".to_string()),
261 Some("1d".to_string()),
262 ))
263 .unwrap();
264
265 let (row, jwt_ok) = block_on(async {
266 let store = env.store().await;
267 let tokens = store.tokens_by_user(&user_id).await?;
268 assert_eq!(tokens.len(), 1);
269 let row = tokens[0].clone();
270
271 // Re-mint the same claims with the resolved secret to prove the secret
272 // round-trips (the actual JWT is only printed to stdout).
273 let cfg = config::load(env.path())?.config;
274 let secret = ensure_secret(&cfg)?;
275 let jwt = auth::mint_jwt(
276 &secret,
277 &Claims {
278 sub: user_id.clone(),
279 jti: row.id.clone(),
280 scopes: row.scopes.clone(),
281 iat: 0,
282 exp: now_secs() + 1000,
283 },
284 )?;
285 let verified = auth::verify_jwt(&secret, &jwt, now_secs()).is_ok();
286 anyhow::Ok((row, verified))
287 })
288 .unwrap();
289 assert_eq!(row.name, "ci");
290 assert_eq!(row.scopes, "repo:read,repo:write");
291 assert!(row.expires_at.is_some());
292 assert!(jwt_ok, "the token should verify against the stored secret");
293
294 block_on(del(env.path(), "ada", 1, true)).unwrap();
295 let after =
296 block_on(async { anyhow::Ok(env.store().await.tokens_by_user(&user_id).await?) })
297 .unwrap();
298 assert!(after.is_empty());
299 }
300}