| 1 | |
| 2 | |
| 3 | |
| 4 | |
| 5 | |
| 6 | |
| 7 | use std::path::Path; |
| 8 | use std::process::ExitCode; |
| 9 | use std::time::{SystemTime, UNIX_EPOCH}; |
| 10 | |
| 11 | use auth::Claims; |
| 12 | use clap::Subcommand; |
| 13 | use model::ApiToken; |
| 14 | use store::{NewToken, Store}; |
| 15 | |
| 16 | use crate::context::{block_on, ensure_secret, open_store}; |
| 17 | use crate::prompt::confirm; |
| 18 | |
| 19 | |
| 20 | #[derive(Debug, Subcommand)] |
| 21 | pub(crate) enum TokenCommand { |
| 22 | |
| 23 | |
| 24 | Add { |
| 25 | |
| 26 | user: String, |
| 27 | |
| 28 | name: String, |
| 29 | |
| 30 | |
| 31 | #[arg(long)] |
| 32 | scopes: Option<String>, |
| 33 | |
| 34 | |
| 35 | #[arg(long)] |
| 36 | expires: Option<String>, |
| 37 | }, |
| 38 | |
| 39 | Del { |
| 40 | |
| 41 | user: String, |
| 42 | |
| 43 | index: usize, |
| 44 | |
| 45 | #[arg(long)] |
| 46 | yes: bool, |
| 47 | }, |
| 48 | |
| 49 | List { |
| 50 | |
| 51 | user: String, |
| 52 | }, |
| 53 | } |
| 54 | |
| 55 | |
| 56 | pub(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 | |
| 80 | async 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 | |
| 89 | fn 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 | |
| 96 | fn 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 | |
| 113 | async 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 | |
| 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 | |
| 161 | async 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 | |
| 184 | async 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 | |
| 220 | fn 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)] |
| 233 | mod 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 | |
| 255 | |
| 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 | |
| 272 | |
| 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 | } |