// This Source Code Form is subject to the terms of the Mozilla Public // License, v. 2.0. If a copy of the MPL was not distributed with this // file, You can obtain one at https://mozilla.org/MPL/2.0/. //! `fabrica token` — API tokens (HS256 JWTs, revocable via their `jti` row). use std::path::Path; use std::process::ExitCode; use std::time::{SystemTime, UNIX_EPOCH}; use auth::Claims; use clap::Subcommand; use model::ApiToken; use store::{NewToken, Store}; use crate::context::{block_on, ensure_secret, open_store}; use crate::prompt::confirm; /// Actions under `fabrica token`. #[derive(Debug, Subcommand)] pub(crate) enum TokenCommand { /// Mint an API token. The signed JWT is printed **once** — it is not stored and /// cannot be retrieved again. Add { /// The owning user. user: String, /// A label for the token. name: String, /// Comma-separated scopes (default `repo:read`). One of `repo:read`, /// `repo:write`, `repo:admin`, `user:read`, `user:write`, `admin`. #[arg(long)] scopes: Option, /// Lifetime as a duration (`90d`, `24h`, `30m`); defaults to the configured /// `auth.token_ttl_days`. #[arg(long)] expires: Option, }, /// Revoke and delete a token by its 1-based list index. Del { /// The owning user. user: String, /// The 1-based index from `token list`. index: usize, /// Skip the confirmation prompt (required when stdin is not a terminal). #[arg(long)] yes: bool, }, /// List a user's tokens (never the token values — those are shown only once). List { /// The user whose tokens to list. user: String, }, } /// Dispatch a `token` subcommand. pub(crate) fn run( command: &TokenCommand, config_path: Option<&Path>, json: bool, ) -> anyhow::Result { match command { TokenCommand::Add { user, name, scopes, expires, } => block_on(add( config_path, user, name, scopes.clone(), expires.clone(), )), TokenCommand::Del { user, index, yes } => block_on(del(config_path, user, *index, *yes)), TokenCommand::List { user } => block_on(list(config_path, user, json)), } } /// Look up a user's id by name, or error cleanly. async fn require_user_id(store: &Store, name: &str) -> anyhow::Result { store .user_by_username(name) .await? .map(|u| u.id) .ok_or_else(|| anyhow::anyhow!("no such user {name:?}")) } /// The current time in Unix seconds. fn now_secs() -> i64 { SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) } /// Parse a duration like `90d`, `24h`, `30m`, `45s`, or `2w` into seconds. fn parse_duration(s: &str) -> anyhow::Result { let s = s.trim(); let (digits, unit) = s.split_at(s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len())); let value: i64 = digits .parse() .map_err(|_| anyhow::anyhow!("invalid duration {s:?} (e.g. 90d, 24h, 30m)"))?; let multiplier = match unit { "s" | "" => 1, "m" => 60, "h" => 3_600, "d" => 86_400, "w" => 604_800, other => anyhow::bail!("unknown duration unit {other:?} (use s, m, h, d, or w)"), }; Ok(value.saturating_mul(multiplier)) } async fn add( config_path: Option<&Path>, user: &str, name: &str, scopes: Option, expires: Option, ) -> anyhow::Result { let (config, store) = open_store(config_path).await?; let user_id = require_user_id(&store, user).await?; // Validate and canonicalize the scope list. let scope_list = scopes.unwrap_or_else(|| "repo:read".to_string()); let parsed = auth::parse_scopes(&scope_list)?; let scopes = auth::format_scopes(&parsed); let ttl = match expires { Some(d) => parse_duration(&d)?, None => i64::from(config.auth.token_ttl_days).saturating_mul(86_400), }; let iat = now_secs(); let exp = iat.saturating_add(ttl); let token = store .create_token(NewToken { user_id: user_id.clone(), name: name.to_string(), scopes: scopes.clone(), expires_at: Some(exp.saturating_mul(1000)), }) .await?; let secret = ensure_secret(&config)?; let jwt = auth::mint_jwt( &secret, &Claims { sub: user_id, jti: token.id, scopes, iat, exp, }, )?; eprintln!("token {name:?} created for {user}; copy it now — it is not stored:"); println!("{jwt}"); Ok(ExitCode::SUCCESS) } async fn del( config_path: Option<&Path>, user: &str, index: usize, yes: bool, ) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let user_id = require_user_id(&store, user).await?; let tokens = store.tokens_by_user(&user_id).await?; let token = index .checked_sub(1) .and_then(|i| tokens.get(i)) .ok_or_else(|| anyhow::anyhow!("{user} has no token #{index}"))?; if !confirm(&format!("Revoke token #{index} ({:?})?", token.name), yes)? { println!("aborted"); return Ok(ExitCode::SUCCESS); } store.delete_token(&token.id).await?; println!("revoked token #{index} for {user}"); Ok(ExitCode::SUCCESS) } async fn list(config_path: Option<&Path>, user: &str, json: bool) -> anyhow::Result { let (_config, store) = open_store(config_path).await?; let user_id = require_user_id(&store, user).await?; let tokens = store.tokens_by_user(&user_id).await?; if json { let view: Vec<_> = tokens .iter() .enumerate() .map(|(i, t)| token_json(i + 1, t)) .collect(); println!("{}", serde_json::to_string_pretty(&view)?); return Ok(ExitCode::SUCCESS); } if tokens.is_empty() { println!("{user} has no tokens"); return Ok(ExitCode::SUCCESS); } println!("{:>3} {:<16} {:<28} STATE", "IDX", "NAME", "SCOPES"); for (i, token) in tokens.iter().enumerate() { let state = if token.revoked_at.is_some() { "revoked" } else { "active" }; println!( "{:>3} {:<16} {:<28} {state}", i + 1, token.name, token.scopes ); } Ok(ExitCode::SUCCESS) } /// Render a token for `--json` (metadata only — never the JWT). fn token_json(index: usize, token: &ApiToken) -> serde_json::Value { serde_json::json!({ "index": index, "name": token.name, "scopes": token.scopes, "expires_at": token.expires_at, "revoked": token.revoked_at.is_some(), "last_used_at": token.last_used_at, "created_at": token.created_at, }) } #[cfg(test)] mod tests { #![allow(clippy::unwrap_used)] use super::*; use crate::testutil::env; #[test] fn parse_duration_units() { assert_eq!(parse_duration("45s").unwrap(), 45); assert_eq!(parse_duration("30m").unwrap(), 1_800); assert_eq!(parse_duration("24h").unwrap(), 86_400); assert_eq!(parse_duration("90d").unwrap(), 7_776_000); assert_eq!(parse_duration("2w").unwrap(), 1_209_600); assert!(parse_duration("10y").is_err()); } #[test] fn add_mints_a_verifiable_token_and_del_removes_it() { let env = env(); let user_id = block_on(async { anyhow::Ok(env.make_user("ada").await) }).unwrap(); // Minting stores a row whose id is the jti; the printed JWT verifies against // the persisted secret and carries the right claims. block_on(add( env.path(), "ada", "ci", Some("repo:read,repo:write".to_string()), Some("1d".to_string()), )) .unwrap(); let (row, jwt_ok) = block_on(async { let store = env.store().await; let tokens = store.tokens_by_user(&user_id).await?; assert_eq!(tokens.len(), 1); let row = tokens[0].clone(); // Re-mint the same claims with the resolved secret to prove the secret // round-trips (the actual JWT is only printed to stdout). let cfg = config::load(env.path())?.config; let secret = ensure_secret(&cfg)?; let jwt = auth::mint_jwt( &secret, &Claims { sub: user_id.clone(), jti: row.id.clone(), scopes: row.scopes.clone(), iat: 0, exp: now_secs() + 1000, }, )?; let verified = auth::verify_jwt(&secret, &jwt, now_secs()).is_ok(); anyhow::Ok((row, verified)) }) .unwrap(); assert_eq!(row.name, "ci"); assert_eq!(row.scopes, "repo:read,repo:write"); assert!(row.expires_at.is_some()); assert!(jwt_ok, "the token should verify against the stored secret"); block_on(del(env.path(), "ada", 1, true)).unwrap(); let after = block_on(async { anyhow::Ok(env.store().await.tokens_by_user(&user_id).await?) }) .unwrap(); assert!(after.is_empty()); } }