feat: improve auth system for registry changes

This commit is contained in:
daimond113 2024-10-14 19:40:02 +02:00
parent 66a885b4e6
commit c7c1daab36
No known key found for this signature in database
GPG key ID: 3A8ECE51328B513C
15 changed files with 183 additions and 270 deletions

View file

@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- Support full version requirements in workspace version field by @daimond113
- Improved authentication system for registry changes by @daimond113
### Fixed
- Correct `pesde.toml` inclusion message in `publish` command by @daimond113

View file

@ -1,23 +1,51 @@
use crate::cli::config::{read_config, write_config};
use anyhow::Context;
use gix::bstr::BStr;
use keyring::Entry;
use serde::Deserialize;
use reqwest::header::AUTHORIZATION;
use serde::{ser::SerializeMap, Deserialize, Serialize};
use std::collections::BTreeMap;
pub fn get_token() -> anyhow::Result<Option<String>> {
match std::env::var("PESDE_TOKEN") {
Ok(token) => return Ok(Some(token)),
Err(std::env::VarError::NotPresent) => {}
Err(e) => return Err(e.into()),
#[derive(Debug, Clone)]
pub struct Tokens(pub BTreeMap<gix::Url, String>);
impl Serialize for Tokens {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::ser::Serializer,
{
let mut map = serializer.serialize_map(Some(self.0.len()))?;
for (k, v) in &self.0 {
map.serialize_entry(&k.to_bstring().to_string(), v)?;
}
map.end()
}
}
impl<'de> Deserialize<'de> for Tokens {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::de::Deserializer<'de>,
{
Ok(Tokens(
BTreeMap::<String, String>::deserialize(deserializer)?
.into_iter()
.map(|(k, v)| gix::Url::from_bytes(BStr::new(&k)).map(|k| (k, v)))
.collect::<Result<_, _>>()
.map_err(serde::de::Error::custom)?,
))
}
}
pub fn get_tokens() -> anyhow::Result<Tokens> {
let config = read_config()?;
if let Some(token) = config.token {
return Ok(Some(token));
if !config.tokens.0.is_empty() {
return Ok(config.tokens);
}
match Entry::new("token", env!("CARGO_PKG_NAME")) {
match Entry::new("tokens", env!("CARGO_PKG_NAME")) {
Ok(entry) => match entry.get_password() {
Ok(token) => return Ok(Some(token)),
Ok(token) => return serde_json::from_str(&token).context("failed to parse tokens"),
Err(keyring::Error::PlatformFailure(_) | keyring::Error::NoEntry) => {}
Err(e) => return Err(e.into()),
},
@ -25,32 +53,32 @@ pub fn get_token() -> anyhow::Result<Option<String>> {
Err(e) => return Err(e.into()),
}
Ok(None)
Ok(Tokens(BTreeMap::new()))
}
pub fn set_token(token: Option<&str>) -> anyhow::Result<()> {
let entry = match Entry::new("token", env!("CARGO_PKG_NAME")) {
Ok(entry) => entry,
Err(e) => return Err(e.into()),
};
pub fn set_tokens(tokens: Tokens) -> anyhow::Result<()> {
let entry = Entry::new("tokens", env!("CARGO_PKG_NAME"))?;
let json = serde_json::to_string(&tokens).context("failed to serialize tokens")?;
let result = if let Some(token) = token {
entry.set_password(token)
} else {
entry.delete_credential()
};
match result {
match entry.set_password(&json) {
Ok(()) => return Ok(()),
Err(keyring::Error::PlatformFailure(_) | keyring::Error::NoEntry) => {}
Err(e) => return Err(e.into()),
}
let mut config = read_config()?;
config.token = token.map(|s| s.to_string());
write_config(&config)?;
config.tokens = tokens;
write_config(&config).map_err(Into::into)
}
Ok(())
pub fn set_token(repo: &gix::Url, token: Option<&str>) -> anyhow::Result<()> {
let mut tokens = get_tokens()?;
if let Some(token) = token {
tokens.0.insert(repo.clone(), token.to_string());
} else {
tokens.0.remove(repo);
}
set_tokens(tokens)
}
#[derive(Debug, Deserialize)]
@ -64,7 +92,7 @@ pub fn get_token_login(
) -> anyhow::Result<String> {
let response = reqwest
.get("https://api.github.com/user")
.header("Authorization", access_token)
.header(AUTHORIZATION, access_token)
.send()
.context("failed to send user request")?
.error_for_status()

View file

@ -5,24 +5,16 @@ use serde::Deserialize;
use url::Url;
use pesde::{
errors::ManifestReadError,
source::{pesde::PesdePackageSource, traits::PackageSource},
Project,
};
use crate::cli::{
auth::{get_token_login, set_token},
config::read_config,
};
use crate::cli::auth::{get_token_login, set_token};
#[derive(Debug, Args)]
pub struct LoginCommand {
/// The index to use. Defaults to `default`, or the configured default index if current directory doesn't have a manifest
#[arg(short, long)]
index: Option<String>,
/// The token to use for authentication, skipping login
#[arg(short, long, conflicts_with = "index")]
#[arg(short, long)]
token: Option<String>,
}
@ -55,41 +47,13 @@ enum AccessTokenResponse {
impl LoginCommand {
pub fn authenticate_device_flow(
&self,
index_url: &gix::Url,
project: &Project,
reqwest: &reqwest::blocking::Client,
) -> anyhow::Result<String> {
let manifest = match project.deser_manifest() {
Ok(manifest) => Some(manifest),
Err(e) => match e {
ManifestReadError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => None,
e => return Err(e.into()),
},
};
println!("logging in into {index_url}");
let index_url = match self.index.as_deref() {
Some(index) => match index.try_into() {
Ok(url) => Some(url),
Err(_) => None,
},
None => match manifest {
Some(_) => None,
None => Some(read_config()?.default_index),
},
};
let index_url = match index_url {
Some(url) => url,
None => {
let index_name = self.index.as_deref().unwrap_or("default");
match manifest.unwrap().indices.get(index_name) {
Some(index) => index.clone(),
None => anyhow::bail!("Index {index_name} not found"),
}
}
};
let source = PesdePackageSource::new(index_url);
let source = PesdePackageSource::new(index_url.clone());
source.refresh(project).context("failed to refresh index")?;
let config = source
@ -182,24 +146,32 @@ impl LoginCommand {
anyhow::bail!("code expired, please re-run the login command");
}
pub fn run(self, project: Project, reqwest: reqwest::blocking::Client) -> anyhow::Result<()> {
pub fn run(
self,
index_url: gix::Url,
project: Project,
reqwest: reqwest::blocking::Client,
) -> anyhow::Result<()> {
let token_given = self.token.is_some();
let token = match self.token {
Some(token) => token,
None => self.authenticate_device_flow(&project, &reqwest)?,
None => self.authenticate_device_flow(&index_url, &project, &reqwest)?,
};
let token = if token_given {
println!("set token");
println!("set token for {index_url}");
token
} else {
let token = format!("Bearer {token}");
println!("logged in as {}", get_token_login(&reqwest, &token)?.bold());
println!(
"logged in as {} for {index_url}",
get_token_login(&reqwest, &token)?.bold()
);
token
};
set_token(Some(&token))?;
set_token(&index_url, Some(&token))?;
Ok(())
}

View file

@ -5,10 +5,10 @@ use clap::Args;
pub struct LogoutCommand {}
impl LogoutCommand {
pub fn run(self) -> anyhow::Result<()> {
set_token(None)?;
pub fn run(self, index_url: gix::Url) -> anyhow::Result<()> {
set_token(&index_url, None)?;
println!("logged out");
println!("logged out of {index_url}");
Ok(())
}

View file

@ -1,31 +1,69 @@
use clap::Subcommand;
use pesde::Project;
use crate::cli::config::read_config;
use clap::{Args, Subcommand};
use pesde::{errors::ManifestReadError, Project};
mod login;
mod logout;
mod set_token_override;
mod whoami;
#[derive(Debug, Args)]
pub struct AuthSubcommand {
/// The index to use. Defaults to `default`, or the configured default index if current directory doesn't have a manifest
#[arg(short, long)]
pub index: Option<String>,
#[clap(subcommand)]
pub command: AuthCommands,
}
#[derive(Debug, Subcommand)]
pub enum AuthCommands {
/// Logs in into GitHub, and stores the token
/// Sets a token for an index. Optionally gets it from GitHub
Login(login::LoginCommand),
/// Removes the stored token
Logout(logout::LogoutCommand),
/// Prints the username of the currently logged-in user
#[clap(name = "whoami")]
WhoAmI(whoami::WhoAmICommand),
/// Sets a token override for a specific repository
SetTokenOverride(set_token_override::SetTokenOverrideCommand),
}
impl AuthCommands {
impl AuthSubcommand {
pub fn run(self, project: Project, reqwest: reqwest::blocking::Client) -> anyhow::Result<()> {
match self {
AuthCommands::Login(login) => login.run(project, reqwest),
AuthCommands::Logout(logout) => logout.run(),
AuthCommands::WhoAmI(whoami) => whoami.run(reqwest),
AuthCommands::SetTokenOverride(set_token_override) => set_token_override.run(),
let manifest = match project.deser_manifest() {
Ok(manifest) => Some(manifest),
Err(e) => match e {
ManifestReadError::Io(e) if e.kind() == std::io::ErrorKind::NotFound => None,
e => return Err(e.into()),
},
};
let index_url = match self.index.as_deref() {
Some(index) => match index.try_into() {
Ok(url) => Some(url),
Err(_) => None,
},
None => match manifest {
Some(_) => None,
None => Some(read_config()?.default_index),
},
};
let index_url = match index_url {
Some(url) => url,
None => {
let index_name = self.index.as_deref().unwrap_or("default");
match manifest.unwrap().indices.get(index_name) {
Some(index) => index.clone(),
None => anyhow::bail!("index {index_name} not found"),
}
}
};
match self.command {
AuthCommands::Login(login) => login.run(index_url, project, reqwest),
AuthCommands::Logout(logout) => logout.run(index_url),
AuthCommands::WhoAmI(whoami) => whoami.run(index_url, reqwest),
}
}
}

View file

@ -1,31 +0,0 @@
use crate::cli::config::{read_config, write_config};
use clap::Args;
#[derive(Debug, Args)]
pub struct SetTokenOverrideCommand {
/// The repository to add the token to
#[arg(index = 1, value_parser = crate::cli::parse_gix_url)]
repository: gix::Url,
/// The token to set
#[arg(index = 2)]
token: Option<String>,
}
impl SetTokenOverrideCommand {
pub fn run(self) -> anyhow::Result<()> {
let mut config = read_config()?;
if let Some(token) = self.token {
println!("set token for {}", self.repository);
config.token_overrides.insert(self.repository, token);
} else {
println!("removed token for {}", self.repository);
config.token_overrides.remove(&self.repository);
}
write_config(&config)?;
Ok(())
}
}

View file

@ -1,4 +1,4 @@
use crate::cli::{auth::get_token_login, get_token};
use crate::cli::auth::{get_token_login, get_tokens};
use clap::Args;
use colored::Colorize;
@ -6,16 +6,24 @@ use colored::Colorize;
pub struct WhoAmICommand {}
impl WhoAmICommand {
pub fn run(self, reqwest: reqwest::blocking::Client) -> anyhow::Result<()> {
let token = match get_token()? {
pub fn run(
self,
index_url: gix::Url,
reqwest: reqwest::blocking::Client,
) -> anyhow::Result<()> {
let tokens = get_tokens()?;
let token = match tokens.0.get(&index_url) {
Some(token) => token,
None => {
println!("not logged in");
println!("not logged in into {index_url}");
return Ok(());
}
};
println!("logged in as {}", get_token_login(&reqwest, &token)?.bold());
println!(
"logged in as {} into {index_url}",
get_token_login(&reqwest, token)?.bold()
);
Ok(())
}

View file

@ -23,8 +23,7 @@ mod update;
#[derive(Debug, clap::Subcommand)]
pub enum Subcommand {
/// Authentication-related commands
#[command(subcommand)]
Auth(auth::AuthCommands),
Auth(auth::AuthSubcommand),
/// Configuration-related commands
#[command(subcommand)]

View file

@ -1,7 +1,7 @@
use anyhow::Context;
use clap::Args;
use colored::Colorize;
use reqwest::StatusCode;
use reqwest::{header::AUTHORIZATION, StatusCode};
use semver::VersionReq;
use std::{
io::{Seek, Write},
@ -457,13 +457,11 @@ impl PublishCommand {
.finish()
.context("failed to get archive bytes")?;
let source = PesdePackageSource::new(
manifest
.indices
.get(DEFAULT_INDEX_NAME)
.context("missing default index")?
.clone(),
);
let index_url = manifest
.indices
.get(DEFAULT_INDEX_NAME)
.context("missing default index")?;
let source = PesdePackageSource::new(index_url.clone());
source
.refresh(project)
.context("failed to refresh source")?;
@ -501,14 +499,19 @@ impl PublishCommand {
return Ok(());
}
let response = reqwest
let mut request = reqwest
.post(format!("{}/v0/packages", config.api()))
.multipart(reqwest::blocking::multipart::Form::new().part(
"tarball",
reqwest::blocking::multipart::Part::bytes(archive).file_name("package.tar.gz"),
))
.send()
.context("failed to send request")?;
));
if let Some(token) = project.auth_config().tokens().get(index_url) {
log::debug!("using token for {index_url}");
request = request.header(AUTHORIZATION, token);
}
let response = request.send().context("failed to send request")?;
let status = response.status();
let text = response.text().context("failed to get response text")?;

View file

@ -1,10 +1,7 @@
use std::collections::BTreeMap;
use crate::cli::{auth::Tokens, home_dir};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::cli::home_dir;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CliConfig {
#[serde(
@ -18,15 +15,7 @@ pub struct CliConfig {
)]
pub scripts_repo: gix::Url,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub token: Option<String>,
#[serde(
default,
skip_serializing_if = "BTreeMap::is_empty",
serialize_with = "crate::cli::serialize_string_url_map",
deserialize_with = "crate::cli::deserialize_string_url_map"
)]
pub token_overrides: BTreeMap<gix::Url, String>,
pub tokens: Tokens,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_checked_updates: Option<(chrono::DateTime<chrono::Utc>, semver::Version)>,
@ -42,8 +31,7 @@ impl Default for CliConfig {
.try_into()
.unwrap(),
token: None,
token_overrides: Default::default(),
tokens: Tokens(Default::default()),
last_checked_updates: None,
}

View file

@ -1,6 +1,4 @@
use crate::cli::auth::get_token;
use anyhow::Context;
use gix::bstr::BStr;
use indicatif::MultiProgress;
use pesde::{
lockfile::{DependencyGraph, DownloadedGraph, Lockfile},
@ -10,7 +8,6 @@ use pesde::{
Project,
};
use relative_path::RelativePathBuf;
use serde::{ser::SerializeMap, Deserialize, Deserializer, Serializer};
use std::{
collections::{BTreeMap, HashSet},
fs::create_dir_all,
@ -188,30 +185,6 @@ pub fn parse_gix_url(s: &str) -> Result<gix::Url, gix::url::parse::Error> {
s.try_into()
}
pub fn serialize_string_url_map<S: Serializer>(
url: &BTreeMap<gix::Url, String>,
serializer: S,
) -> Result<S::Ok, S::Error> {
let mut map = serializer.serialize_map(Some(url.len()))?;
for (k, v) in url {
map.serialize_entry(&k.to_bstring().to_string(), v)?;
}
map.end()
}
pub fn deserialize_string_url_map<'de, D: Deserializer<'de>>(
deserializer: D,
) -> Result<BTreeMap<gix::Url, String>, D::Error> {
BTreeMap::<String, String>::deserialize(deserializer)?
.into_iter()
.map(|(k, v)| {
gix::Url::from_bytes(BStr::new(&k))
.map(|k| (k, v))
.map_err(serde::de::Error::custom)
})
.collect()
}
#[allow(clippy::too_many_arguments)]
pub fn download_graph(
project: &Project,

View file

@ -44,8 +44,7 @@ pub(crate) const LINK_LIB_NO_FILE_FOUND: &str = "____pesde_no_export_file_found"
/// Struct containing the authentication configuration
#[derive(Debug, Default, Clone)]
pub struct AuthConfig {
default_token: Option<String>,
token_overrides: HashMap<gix::Url, String>,
tokens: HashMap<gix::Url, String>,
git_credentials: Option<Account>,
}
@ -55,18 +54,12 @@ impl AuthConfig {
AuthConfig::default()
}
/// Sets the default token
pub fn with_default_token<S: AsRef<str>>(mut self, token: Option<S>) -> Self {
self.default_token = token.map(|s| s.as_ref().to_string());
self
}
/// Set the token overrides
pub fn with_token_overrides<I: IntoIterator<Item = (gix::Url, S)>, S: AsRef<str>>(
/// Set the tokens
pub fn with_tokens<I: IntoIterator<Item = (gix::Url, S)>, S: AsRef<str>>(
mut self,
tokens: I,
) -> Self {
self.token_overrides = tokens
self.tokens = tokens
.into_iter()
.map(|(url, s)| (url, s.as_ref().to_string()))
.collect();
@ -79,27 +72,15 @@ impl AuthConfig {
self
}
/// Get the default token
pub fn default_token(&self) -> Option<&str> {
self.default_token.as_deref()
}
/// Get the token overrides
pub fn token_overrides(&self) -> &HashMap<gix::Url, String> {
&self.token_overrides
/// Get the tokens
pub fn tokens(&self) -> &HashMap<gix::Url, String> {
&self.tokens
}
/// Get the git credentials
pub fn git_credentials(&self) -> Option<&Account> {
self.git_credentials.as_ref()
}
pub(crate) fn get_token(&self, url: &gix::Url) -> Option<&str> {
self.token_overrides
.get(url)
.map(|s| s.as_str())
.or(self.default_token.as_deref())
}
}
/// The main struct of the pesde library, representing a project

View file

@ -14,9 +14,7 @@ use std::{
use crate::cli::version::{
check_for_updates, current_version, get_or_download_version, max_installed_version,
};
use crate::cli::{
auth::get_token, config::read_config, home_dir, repos::update_repo_dependencies, HOME_DIR,
};
use crate::cli::{auth::get_tokens, home_dir, repos::update_repo_dependencies, HOME_DIR};
mod cli;
pub mod util;
@ -187,14 +185,14 @@ fn run() -> anyhow::Result<()> {
let data_dir = home_dir()?.join("data");
create_dir_all(&data_dir).expect("failed to create data directory");
let token = get_token()?;
let home_cas_dir = data_dir.join("cas");
create_dir_all(&home_cas_dir).expect("failed to create cas directory");
let project_root = get_root(&project_root_dir);
let cas_dir = if get_root(&home_cas_dir) == project_root {
log::debug!("using home cas dir");
home_cas_dir
} else {
log::debug!("using cas dir in {}", project_root.display());
project_root.join(HOME_DIR).join("cas")
};
@ -203,19 +201,11 @@ fn run() -> anyhow::Result<()> {
project_workspace_dir,
data_dir,
cas_dir,
AuthConfig::new()
.with_default_token(token.clone())
.with_token_overrides(read_config()?.token_overrides),
AuthConfig::new().with_tokens(get_tokens()?.0),
);
let reqwest = {
let mut headers = reqwest::header::HeaderMap::new();
if let Some(token) = token {
headers.insert(
reqwest::header::AUTHORIZATION,
token.parse().context("failed to create auth header")?,
);
}
headers.insert(
reqwest::header::ACCEPT,

View file

@ -7,7 +7,7 @@ use std::{
use gix::Url;
use relative_path::RelativePathBuf;
use reqwest::header::{HeaderMap, ACCEPT, AUTHORIZATION};
use reqwest::header::{ACCEPT, AUTHORIZATION};
use serde::{Deserialize, Serialize};
use pkg_ref::PesdePackageRef;
@ -273,29 +273,14 @@ impl PackageSource for PesdePackageSource {
.replace("{PACKAGE_VERSION}", &pkg_ref.version.to_string())
.replace("{PACKAGE_TARGET}", &pkg_ref.target.to_string());
let mut headers = HeaderMap::new();
headers.insert(
ACCEPT,
"application/octet-stream"
.parse()
.map_err(|e| errors::DownloadError::InvalidHeaderValue("Accept".to_string(), e))?,
);
let mut request = reqwest.get(&url).header(ACCEPT, "application/octet-stream");
if let Some(token) = project.auth_config.get_token(&self.repo_url) {
log::debug!("using token for pesde package download");
headers.insert(
AUTHORIZATION,
token.parse().map_err(|e| {
errors::DownloadError::InvalidHeaderValue("Authorization".to_string(), e)
})?,
);
if let Some(token) = project.auth_config.tokens().get(&self.repo_url) {
log::debug!("using token for {}", self.repo_url);
request = request.header(AUTHORIZATION, token);
}
let response = reqwest
.get(url)
.headers(headers)
.send()?
.error_for_status()?;
let response = request.send()?.error_for_status()?;
let bytes = response.bytes()?;
let mut decoder = flate2::read::GzDecoder::new(bytes.as_ref());
@ -583,9 +568,5 @@ pub mod errors {
/// Error writing index file
#[error("error reading index file")]
ReadIndex(#[source] std::io::Error),
/// A header value was invalid
#[error("invalid header {0} value")]
InvalidHeaderValue(String, #[source] reqwest::header::InvalidHeaderValue),
}
}

View file

@ -5,7 +5,7 @@ use std::{
use gix::Url;
use relative_path::RelativePathBuf;
use reqwest::header::{HeaderMap, AUTHORIZATION};
use reqwest::header::AUTHORIZATION;
use serde::Deserialize;
use tempfile::tempdir;
@ -178,33 +178,19 @@ impl PackageSource for WallyPackageSource {
pkg_ref.version
);
let mut headers = HeaderMap::new();
headers.insert(
let mut request = reqwest.get(&url).header(
"Wally-Version",
std::env::var("PESDE_WALLY_VERSION")
.as_deref()
.unwrap_or("0.3.2")
.parse()
.map_err(|e| {
errors::DownloadError::InvalidHeaderValue("Wally-Version".to_string(), e)
})?,
.unwrap_or("0.3.2"),
);
if let Some(token) = project.auth_config.get_token(&self.repo_url) {
log::debug!("using token for wally package download");
headers.insert(
AUTHORIZATION,
token.parse().map_err(|e| {
errors::DownloadError::InvalidHeaderValue("Authorization".to_string(), e)
})?,
);
if let Some(token) = project.auth_config.tokens().get(&self.repo_url) {
log::debug!("using token for {}", self.repo_url);
request = request.header(AUTHORIZATION, token);
}
let response = reqwest
.get(url)
.headers(headers)
.send()?
.error_for_status()?;
let response = request.send()?.error_for_status()?;
let bytes = response.bytes()?;
let mut archive = zip::ZipArchive::new(std::io::Cursor::new(bytes))?;
@ -355,9 +341,5 @@ pub mod errors {
/// Error writing index file
#[error("error writing index file")]
WriteIndex(#[source] std::io::Error),
/// A header value was invalid
#[error("invalid header {0} value")]
InvalidHeaderValue(String, #[source] reqwest::header::InvalidHeaderValue),
}
}