mirror of https://github.com/astral-sh/uv
Add pyx support to `uv auth` commands (#15636)
## Summary This PR adds support for pyx to `uv auth login`, `uv auth logout`, and `uv auth token`. These are generic uv commands that can be used to store credentials for arbitrary indexes and other URLs, but we include a fast-path for pyx that initiates the appropriate login or logout flow.
This commit is contained in:
parent
f9e98d1fb6
commit
f88aaa8740
|
|
@ -811,7 +811,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
checksum = "117725a109d387c937a1533ce01b450cbde6b88abceea8473c4d7a85853cda3c"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"lazy_static",
|
"lazy_static",
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1855,7 +1855,7 @@ dependencies = [
|
||||||
"libc",
|
"libc",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"socket2 0.5.10",
|
"socket2 0.6.0",
|
||||||
"system-configuration",
|
"system-configuration",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tower-service",
|
"tower-service",
|
||||||
|
|
@ -2085,6 +2085,15 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is-docker"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is-terminal"
|
name = "is-terminal"
|
||||||
version = "0.4.16"
|
version = "0.4.16"
|
||||||
|
|
@ -2093,7 +2102,17 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"hermit-abi",
|
"hermit-abi",
|
||||||
"libc",
|
"libc",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is-wsl"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5"
|
||||||
|
dependencies = [
|
||||||
|
"is-docker",
|
||||||
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2153,7 +2172,7 @@ dependencies = [
|
||||||
"portable-atomic",
|
"portable-atomic",
|
||||||
"portable-atomic-util",
|
"portable-atomic-util",
|
||||||
"serde",
|
"serde",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -2673,6 +2692,17 @@ version = "11.1.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "open"
|
||||||
|
version = "5.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e2483562e62ea94312f3576a7aca397306df7990b8d89033e18766744377ef95"
|
||||||
|
dependencies = [
|
||||||
|
"is-wsl",
|
||||||
|
"libc",
|
||||||
|
"pathdiff",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "openssl-probe"
|
name = "openssl-probe"
|
||||||
version = "0.1.6"
|
version = "0.1.6"
|
||||||
|
|
@ -3101,7 +3131,7 @@ dependencies = [
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"socket2 0.5.10",
|
"socket2 0.5.10",
|
||||||
"tracing",
|
"tracing",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -3553,7 +3583,7 @@ dependencies = [
|
||||||
"errno",
|
"errno",
|
||||||
"libc",
|
"libc",
|
||||||
"linux-raw-sys 0.4.15",
|
"linux-raw-sys 0.4.15",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -4214,7 +4244,7 @@ dependencies = [
|
||||||
"getrandom 0.3.3",
|
"getrandom 0.3.3",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
"rustix 1.0.8",
|
"rustix 1.0.8",
|
||||||
"windows-sys 0.52.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -4985,6 +5015,7 @@ dependencies = [
|
||||||
"itertools 0.14.0",
|
"itertools 0.14.0",
|
||||||
"miette",
|
"miette",
|
||||||
"nix 0.30.1",
|
"nix 0.30.1",
|
||||||
|
"open",
|
||||||
"owo-colors",
|
"owo-colors",
|
||||||
"petgraph",
|
"petgraph",
|
||||||
"predicates",
|
"predicates",
|
||||||
|
|
@ -5012,6 +5043,7 @@ dependencies = [
|
||||||
"tracing-tree",
|
"tracing-tree",
|
||||||
"unicode-width 0.2.1",
|
"unicode-width 0.2.1",
|
||||||
"url",
|
"url",
|
||||||
|
"uuid",
|
||||||
"uv-auth",
|
"uv-auth",
|
||||||
"uv-bin-install",
|
"uv-bin-install",
|
||||||
"uv-build-backend",
|
"uv-build-backend",
|
||||||
|
|
@ -5075,12 +5107,15 @@ name = "uv-auth"
|
||||||
version = "0.0.1"
|
version = "0.0.1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"anyhow",
|
"anyhow",
|
||||||
|
"arcstr",
|
||||||
"async-trait",
|
"async-trait",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
|
"etcetera",
|
||||||
"fs-err",
|
"fs-err",
|
||||||
"futures",
|
"futures",
|
||||||
"http",
|
"http",
|
||||||
"insta",
|
"insta",
|
||||||
|
"jiff",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"reqwest-middleware",
|
"reqwest-middleware",
|
||||||
|
|
@ -5088,6 +5123,7 @@ dependencies = [
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"schemars",
|
"schemars",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"test-log",
|
"test-log",
|
||||||
"thiserror 2.0.16",
|
"thiserror 2.0.16",
|
||||||
|
|
@ -5095,6 +5131,7 @@ dependencies = [
|
||||||
"toml",
|
"toml",
|
||||||
"tracing",
|
"tracing",
|
||||||
"url",
|
"url",
|
||||||
|
"uv-cache-key",
|
||||||
"uv-fs",
|
"uv-fs",
|
||||||
"uv-keyring",
|
"uv-keyring",
|
||||||
"uv-once-map",
|
"uv-once-map",
|
||||||
|
|
@ -6757,7 +6794,7 @@ version = "0.1.9"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"windows-sys 0.48.0",
|
"windows-sys 0.59.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -135,6 +135,7 @@ miette = { version = "7.2.0", features = ["fancy-no-backtrace"] }
|
||||||
nanoid = { version = "0.4.0" }
|
nanoid = { version = "0.4.0" }
|
||||||
nix = { version = "0.30.0", features = ["signal"] }
|
nix = { version = "0.30.0", features = ["signal"] }
|
||||||
once_cell = { version = "1.20.2" }
|
once_cell = { version = "1.20.2" }
|
||||||
|
open = { version = "5.3.2" }
|
||||||
owo-colors = { version = "4.1.0" }
|
owo-colors = { version = "4.1.0" }
|
||||||
path-slash = { version = "0.2.1" }
|
path-slash = { version = "0.2.1" }
|
||||||
pathdiff = { version = "0.2.1" }
|
pathdiff = { version = "0.2.1" }
|
||||||
|
|
@ -188,6 +189,7 @@ tracing-tree = { version = "0.4.0" }
|
||||||
unicode-width = { version = "0.2.0" }
|
unicode-width = { version = "0.2.0" }
|
||||||
unscanny = { version = "0.1.0" }
|
unscanny = { version = "0.1.0" }
|
||||||
url = { version = "2.5.2", features = ["serde"] }
|
url = { version = "2.5.2", features = ["serde"] }
|
||||||
|
uuid = { version = "1.16.0" }
|
||||||
version-ranges = { git = "https://github.com/astral-sh/pubgrub", rev = "06ec5a5f59ffaeb6cf5079c6cb184467da06c9db" }
|
version-ranges = { git = "https://github.com/astral-sh/pubgrub", rev = "06ec5a5f59ffaeb6cf5079c6cb184467da06c9db" }
|
||||||
walkdir = { version = "2.5.0" }
|
walkdir = { version = "2.5.0" }
|
||||||
which = { version = "8.0.0", features = ["regex"] }
|
which = { version = "8.0.0", features = ["regex"] }
|
||||||
|
|
|
||||||
|
|
@ -10,22 +10,26 @@ doctest = false
|
||||||
workspace = true
|
workspace = true
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
|
uv-cache-key = { workspace = true }
|
||||||
uv-fs = { workspace = true }
|
uv-fs = { workspace = true }
|
||||||
uv-keyring = { workspace = true, features = ["apple-native", "secret-service", "windows-native"] }
|
uv-keyring = { workspace = true, features = ["apple-native", "secret-service", "windows-native"] }
|
||||||
uv-once-map = { workspace = true }
|
uv-once-map = { workspace = true }
|
||||||
uv-preview = { workspace = true }
|
uv-preview = { workspace = true }
|
||||||
uv-redacted = { workspace = true }
|
uv-redacted = { workspace = true }
|
||||||
uv-small-str = { workspace = true }
|
uv-small-str = { workspace = true }
|
||||||
uv-static = { workspace = true }
|
|
||||||
uv-state = { workspace = true }
|
uv-state = { workspace = true }
|
||||||
|
uv-static = { workspace = true }
|
||||||
uv-warnings = { workspace = true }
|
uv-warnings = { workspace = true }
|
||||||
|
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
|
arcstr = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
base64 = { workspace = true }
|
base64 = { workspace = true }
|
||||||
fs-err = { workspace = true }
|
etcetera = { workspace = true }
|
||||||
|
fs-err = { workspace = true, features = ["tokio"] }
|
||||||
futures = { workspace = true }
|
futures = { workspace = true }
|
||||||
http = { workspace = true }
|
http = { workspace = true }
|
||||||
|
jiff = { workspace = true }
|
||||||
percent-encoding = { workspace = true }
|
percent-encoding = { workspace = true }
|
||||||
reqwest = { workspace = true }
|
reqwest = { workspace = true }
|
||||||
reqwest-middleware = { workspace = true }
|
reqwest-middleware = { workspace = true }
|
||||||
|
|
@ -33,6 +37,7 @@ rust-netrc = { workspace = true }
|
||||||
rustc-hash = { workspace = true }
|
rustc-hash = { workspace = true }
|
||||||
schemars = { workspace = true, optional = true }
|
schemars = { workspace = true, optional = true }
|
||||||
serde = { workspace = true, features = ["derive"] }
|
serde = { workspace = true, features = ["derive"] }
|
||||||
|
serde_json = { workspace = true }
|
||||||
thiserror = { workspace = true }
|
thiserror = { workspace = true }
|
||||||
tokio = { workspace = true }
|
tokio = { workspace = true }
|
||||||
toml = { workspace = true }
|
toml = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,34 @@
|
||||||
|
/// An encoded JWT access token.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||||
|
#[serde(transparent)]
|
||||||
|
pub struct AccessToken(String);
|
||||||
|
|
||||||
|
impl AccessToken {
|
||||||
|
/// Return the [`AccessToken`] as a vector of bytes.
|
||||||
|
pub fn into_bytes(self) -> Vec<u8> {
|
||||||
|
self.0.into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the [`AccessToken`] as a string slice.
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<String> for AccessToken {
|
||||||
|
fn from(value: String) -> Self {
|
||||||
|
Self(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AsRef<[u8]> for AccessToken {
|
||||||
|
fn as_ref(&self) -> &[u8] {
|
||||||
|
self.0.as_bytes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for AccessToken {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "{}", self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -2,25 +2,30 @@ use std::sync::{Arc, LazyLock};
|
||||||
|
|
||||||
use tracing::trace;
|
use tracing::trace;
|
||||||
|
|
||||||
|
use uv_redacted::DisplaySafeUrl;
|
||||||
|
|
||||||
|
pub use access_token::AccessToken;
|
||||||
use cache::CredentialsCache;
|
use cache::CredentialsCache;
|
||||||
pub use credentials::{Credentials, Username};
|
pub use credentials::{Credentials, Username};
|
||||||
pub use index::{AuthPolicy, Index, Indexes};
|
pub use index::{AuthPolicy, Index, Indexes};
|
||||||
pub use keyring::KeyringProvider;
|
pub use keyring::KeyringProvider;
|
||||||
pub use middleware::AuthMiddleware;
|
pub use middleware::AuthMiddleware;
|
||||||
use realm::Realm;
|
pub use pyx::{DEFAULT_TOLERANCE_SECS, PyxOAuthTokens, PyxTokenStore, PyxTokens, TokenStoreError};
|
||||||
|
pub use realm::Realm;
|
||||||
pub use service::{Service, ServiceParseError};
|
pub use service::{Service, ServiceParseError};
|
||||||
pub use store::{AuthScheme, TextCredentialStore, TomlCredentialError};
|
pub use store::{AuthBackend, AuthScheme, TextCredentialStore, TomlCredentialError};
|
||||||
use uv_redacted::DisplaySafeUrl;
|
|
||||||
|
|
||||||
|
mod access_token;
|
||||||
mod cache;
|
mod cache;
|
||||||
mod credentials;
|
mod credentials;
|
||||||
mod index;
|
mod index;
|
||||||
mod keyring;
|
mod keyring;
|
||||||
mod middleware;
|
mod middleware;
|
||||||
mod providers;
|
mod providers;
|
||||||
|
mod pyx;
|
||||||
mod realm;
|
mod realm;
|
||||||
mod service;
|
mod service;
|
||||||
pub mod store;
|
mod store;
|
||||||
|
|
||||||
// TODO(zanieb): Consider passing a cache explicitly throughout
|
// TODO(zanieb): Consider passing a cache explicitly throughout
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,20 +4,24 @@ use anyhow::{anyhow, format_err};
|
||||||
use http::{Extensions, StatusCode};
|
use http::{Extensions, StatusCode};
|
||||||
use netrc::Netrc;
|
use netrc::Netrc;
|
||||||
use reqwest::{Request, Response};
|
use reqwest::{Request, Response};
|
||||||
use reqwest_middleware::{Error, Middleware, Next};
|
use reqwest_middleware::{ClientWithMiddleware, Error, Middleware, Next};
|
||||||
|
use tokio::sync::Mutex;
|
||||||
use tracing::{debug, trace, warn};
|
use tracing::{debug, trace, warn};
|
||||||
|
|
||||||
use uv_preview::{Preview, PreviewFeatures};
|
use uv_preview::{Preview, PreviewFeatures};
|
||||||
use uv_redacted::DisplaySafeUrl;
|
use uv_redacted::DisplaySafeUrl;
|
||||||
|
use uv_warnings::owo_colors::OwoColorize;
|
||||||
|
|
||||||
use crate::providers::HuggingFaceProvider;
|
use crate::providers::HuggingFaceProvider;
|
||||||
|
use crate::pyx::{DEFAULT_TOLERANCE_SECS, PyxTokenStore};
|
||||||
use crate::{
|
use crate::{
|
||||||
CREDENTIALS_CACHE, CredentialsCache, KeyringProvider,
|
AccessToken, CREDENTIALS_CACHE, CredentialsCache, KeyringProvider,
|
||||||
cache::FetchUrl,
|
cache::FetchUrl,
|
||||||
credentials::{Credentials, Username},
|
credentials::{Credentials, Username},
|
||||||
index::{AuthPolicy, Indexes},
|
index::{AuthPolicy, Indexes},
|
||||||
realm::Realm,
|
realm::Realm,
|
||||||
};
|
};
|
||||||
|
|
||||||
use crate::{TextCredentialStore, TomlCredentialError};
|
use crate::{TextCredentialStore, TomlCredentialError};
|
||||||
|
|
||||||
/// Strategy for loading netrc files.
|
/// Strategy for loading netrc files.
|
||||||
|
|
@ -105,6 +109,15 @@ impl TextStoreMode {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum TokenState {
|
||||||
|
/// The token state has not yet been initialized from the store.
|
||||||
|
Uninitialized,
|
||||||
|
/// The token state has been initialized, and the store either returned tokens or `None` if
|
||||||
|
/// the user has not yet authenticated.
|
||||||
|
Initialized(Option<AccessToken>),
|
||||||
|
}
|
||||||
|
|
||||||
/// A middleware that adds basic authentication to requests.
|
/// A middleware that adds basic authentication to requests.
|
||||||
///
|
///
|
||||||
/// Uses a cache to propagate credentials from previously seen requests and
|
/// Uses a cache to propagate credentials from previously seen requests and
|
||||||
|
|
@ -119,6 +132,12 @@ pub struct AuthMiddleware {
|
||||||
/// Set all endpoints as needing authentication. We never try to send an
|
/// Set all endpoints as needing authentication. We never try to send an
|
||||||
/// unauthenticated request, avoiding cloning an uncloneable request.
|
/// unauthenticated request, avoiding cloning an uncloneable request.
|
||||||
only_authenticated: bool,
|
only_authenticated: bool,
|
||||||
|
/// The base client to use for requests within the middleware.
|
||||||
|
base_client: Option<ClientWithMiddleware>,
|
||||||
|
/// The pyx token store to use for persistent credentials.
|
||||||
|
pyx_token_store: Option<PyxTokenStore>,
|
||||||
|
/// Tokens to use for persistent credentials.
|
||||||
|
pyx_token_state: Mutex<TokenState>,
|
||||||
preview: Preview,
|
preview: Preview,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -131,6 +150,9 @@ impl AuthMiddleware {
|
||||||
cache: None,
|
cache: None,
|
||||||
indexes: Indexes::new(),
|
indexes: Indexes::new(),
|
||||||
only_authenticated: false,
|
only_authenticated: false,
|
||||||
|
base_client: None,
|
||||||
|
pyx_token_store: None,
|
||||||
|
pyx_token_state: Mutex::new(TokenState::Uninitialized),
|
||||||
preview: Preview::default(),
|
preview: Preview::default(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -197,6 +219,20 @@ impl AuthMiddleware {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Configure the [`ClientWithMiddleware`] to use for requests within the middleware.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_base_client(mut self, client: ClientWithMiddleware) -> Self {
|
||||||
|
self.base_client = Some(client);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Configure the [`PyxTokenStore`] to use for persistent credentials.
|
||||||
|
#[must_use]
|
||||||
|
pub fn with_pyx_token_store(mut self, token_store: PyxTokenStore) -> Self {
|
||||||
|
self.pyx_token_store = Some(token_store);
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Get the configured authentication store.
|
/// Get the configured authentication store.
|
||||||
///
|
///
|
||||||
/// If not set, the global store is used.
|
/// If not set, the global store is used.
|
||||||
|
|
@ -309,9 +345,20 @@ impl Middleware for AuthMiddleware {
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.is_some_and(|credentials| credentials.username().is_some());
|
.is_some_and(|credentials| credentials.username().is_some());
|
||||||
|
|
||||||
let retry_unauthenticated =
|
// Determine whether this is a "known" URL.
|
||||||
!self.only_authenticated && !matches!(auth_policy, AuthPolicy::Always);
|
let is_known_url = self
|
||||||
let (mut retry_request, response) = if retry_unauthenticated {
|
.pyx_token_store
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|token_store| token_store.is_known_url(request.url()));
|
||||||
|
|
||||||
|
let must_authenticate = self.only_authenticated
|
||||||
|
|| match auth_policy {
|
||||||
|
AuthPolicy::Auto => is_known_url,
|
||||||
|
AuthPolicy::Always => true,
|
||||||
|
AuthPolicy::Never => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
let (mut retry_request, response) = if !must_authenticate {
|
||||||
let url = tracing_url(&request, credentials.as_deref());
|
let url = tracing_url(&request, credentials.as_deref());
|
||||||
if credentials.is_none() {
|
if credentials.is_none() {
|
||||||
trace!("Attempting unauthenticated request for {url}");
|
trace!("Attempting unauthenticated request for {url}");
|
||||||
|
|
@ -419,9 +466,16 @@ impl Middleware for AuthMiddleware {
|
||||||
if let Some(response) = response {
|
if let Some(response) = response {
|
||||||
Ok(response)
|
Ok(response)
|
||||||
} else {
|
} else {
|
||||||
Err(Error::Middleware(format_err!(
|
if is_known_url {
|
||||||
"Missing credentials for {url}"
|
Err(Error::Middleware(format_err!(
|
||||||
)))
|
"Run `{}` to authenticate the uv CLI",
|
||||||
|
"uv auth login pyx.dev".green()
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
Err(Error::Middleware(format_err!(
|
||||||
|
"Missing credentials for {url}"
|
||||||
|
)))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -589,6 +643,46 @@ impl AuthMiddleware {
|
||||||
return Some(credentials);
|
return Some(credentials);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If this is a known URL, authenticate it via the token store.
|
||||||
|
if let Some(base_client) = self.base_client.as_ref() {
|
||||||
|
if let Some(token_store) = self.pyx_token_store.as_ref() {
|
||||||
|
if token_store.is_known_url(url) {
|
||||||
|
let mut token_state = self.pyx_token_state.lock().await;
|
||||||
|
|
||||||
|
// If the token store is uninitialized, initialize it.
|
||||||
|
let token = match *token_state {
|
||||||
|
TokenState::Uninitialized => {
|
||||||
|
trace!("Initializing token store for {url}");
|
||||||
|
let generated = match token_store
|
||||||
|
.access_token(base_client, DEFAULT_TOLERANCE_SECS)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(Some(token)) => Some(token),
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(err) => {
|
||||||
|
warn!("Failed to generate access tokens: {err}");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
};
|
||||||
|
*token_state = TokenState::Initialized(generated.clone());
|
||||||
|
generated
|
||||||
|
}
|
||||||
|
TokenState::Initialized(ref tokens) => tokens.clone(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let credentials = token.map(|token| {
|
||||||
|
trace!("Using credentials from token store for {url}");
|
||||||
|
Arc::new(Credentials::from(token))
|
||||||
|
});
|
||||||
|
|
||||||
|
// Register the fetch for this key
|
||||||
|
self.cache().fetches.done(key.clone(), credentials.clone());
|
||||||
|
|
||||||
|
return credentials;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Netrc support based on: <https://github.com/gribouille/netrc>.
|
// Netrc support based on: <https://github.com/gribouille/netrc>.
|
||||||
let credentials = if let Some(credentials) = self.netrc.get().and_then(|netrc| {
|
let credentials = if let Some(credentials) = self.netrc.get().and_then(|netrc| {
|
||||||
debug!("Checking netrc for credentials for {url}");
|
debug!("Checking netrc for credentials for {url}");
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,682 @@
|
||||||
|
use std::io;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use base64::Engine;
|
||||||
|
use base64::prelude::BASE64_URL_SAFE_NO_PAD;
|
||||||
|
use etcetera::BaseStrategy;
|
||||||
|
use reqwest_middleware::ClientWithMiddleware;
|
||||||
|
use tracing::debug;
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use uv_cache_key::CanonicalUrl;
|
||||||
|
use uv_redacted::DisplaySafeUrl;
|
||||||
|
use uv_small_str::SmallString;
|
||||||
|
use uv_state::{StateBucket, StateStore};
|
||||||
|
use uv_static::EnvVars;
|
||||||
|
|
||||||
|
use crate::{AccessToken, Credentials, Realm};
|
||||||
|
|
||||||
|
/// Retrieve the pyx API key from the environment variable, or return `None`.
|
||||||
|
fn read_pyx_api_key() -> Option<String> {
|
||||||
|
std::env::var(EnvVars::PYX_API_KEY)
|
||||||
|
.ok()
|
||||||
|
.or_else(|| std::env::var(EnvVars::UV_API_KEY).ok())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Retrieve the pyx authentication token (JWT) from the environment variable, or return `None`.
|
||||||
|
fn read_pyx_auth_token() -> Option<AccessToken> {
|
||||||
|
std::env::var(EnvVars::PYX_AUTH_TOKEN)
|
||||||
|
.ok()
|
||||||
|
.or_else(|| std::env::var(EnvVars::UV_AUTH_TOKEN).ok())
|
||||||
|
.map(AccessToken::from)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An access token with an accompanying refresh token.
|
||||||
|
///
|
||||||
|
/// Refresh tokens are single-use tokens that can be exchanged for a renewed access token
|
||||||
|
/// and a new refresh token.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct PyxOAuthTokens {
|
||||||
|
pub access_token: AccessToken,
|
||||||
|
pub refresh_token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An access token with an accompanying API key.
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub struct PyxApiKeyTokens {
|
||||||
|
pub access_token: AccessToken,
|
||||||
|
pub api_key: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||||
|
pub enum PyxTokens {
|
||||||
|
/// An access token with an accompanying refresh token.
|
||||||
|
///
|
||||||
|
/// Refresh tokens are single-use tokens that can be exchanged for a renewed access token
|
||||||
|
/// and a new refresh token.
|
||||||
|
OAuth(PyxOAuthTokens),
|
||||||
|
/// An access token with an accompanying API key.
|
||||||
|
///
|
||||||
|
/// API keys are long-lived tokens that can be exchanged for an access token.
|
||||||
|
ApiKey(PyxApiKeyTokens),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PyxTokens> for AccessToken {
|
||||||
|
fn from(tokens: PyxTokens) -> Self {
|
||||||
|
match tokens {
|
||||||
|
PyxTokens::OAuth(PyxOAuthTokens { access_token, .. }) => access_token,
|
||||||
|
PyxTokens::ApiKey(PyxApiKeyTokens { access_token, .. }) => access_token,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<PyxTokens> for Credentials {
|
||||||
|
fn from(tokens: PyxTokens) -> Self {
|
||||||
|
let access_token = match tokens {
|
||||||
|
PyxTokens::OAuth(PyxOAuthTokens { access_token, .. }) => access_token,
|
||||||
|
PyxTokens::ApiKey(PyxApiKeyTokens { access_token, .. }) => access_token,
|
||||||
|
};
|
||||||
|
Self::from(access_token)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<AccessToken> for Credentials {
|
||||||
|
fn from(access_token: AccessToken) -> Self {
|
||||||
|
Self::Bearer {
|
||||||
|
token: access_token.into_bytes(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The default tolerance for the access token expiration.
|
||||||
|
pub const DEFAULT_TOLERANCE_SECS: u64 = 60 * 5;
|
||||||
|
|
||||||
|
/// The root directory for the pyx token store.
|
||||||
|
fn root_dir(api: &DisplaySafeUrl) -> Result<PathBuf, io::Error> {
|
||||||
|
// Store credentials in a subdirectory based on the API URL.
|
||||||
|
let digest = uv_cache_key::cache_digest(&CanonicalUrl::new(api));
|
||||||
|
|
||||||
|
// If the user explicitly set `PYX_CREDENTIALS_DIR`, use that.
|
||||||
|
if let Some(tool_dir) = std::env::var_os(EnvVars::PYX_CREDENTIALS_DIR) {
|
||||||
|
return std::path::absolute(tool_dir).map(|dir| dir.join(&digest));
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the user has pyx credentials in their uv credentials directory, read them for
|
||||||
|
// backwards compatibility.
|
||||||
|
let credentials_dir = if let Some(tool_dir) = std::env::var_os(EnvVars::UV_CREDENTIALS_DIR) {
|
||||||
|
std::path::absolute(tool_dir)?
|
||||||
|
} else {
|
||||||
|
StateStore::from_settings(None)?.bucket(StateBucket::Credentials)
|
||||||
|
};
|
||||||
|
let credentials_dir = credentials_dir.join(&digest);
|
||||||
|
if credentials_dir.exists() {
|
||||||
|
return Ok(credentials_dir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Otherwise, use (e.g.) `~/.local/share/pyx`.
|
||||||
|
let Ok(xdg) = etcetera::base_strategy::choose_base_strategy() else {
|
||||||
|
return Err(io::Error::new(
|
||||||
|
io::ErrorKind::NotFound,
|
||||||
|
"Could not determine user data directory",
|
||||||
|
));
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(xdg.data_dir().join("pyx").join("credentials").join(&digest))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PyxTokenStore {
|
||||||
|
/// The root directory for the token store (e.g., `/Users/ferris/.local/share/pyx/credentials/3859a629b26fda96`).
|
||||||
|
root: PathBuf,
|
||||||
|
/// The API URL for the token store (e.g., `https://api.pyx.dev`).
|
||||||
|
api: DisplaySafeUrl,
|
||||||
|
/// The CDN domain for the token store (e.g., `astralhosted.com`).
|
||||||
|
cdn: SmallString,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PyxTokenStore {
|
||||||
|
/// Create a new [`PyxTokenStore`] from settings.
|
||||||
|
pub fn from_settings() -> Result<Self, TokenStoreError> {
|
||||||
|
// Read the API URL and CDN domain from the environment variables, or fallback to the
|
||||||
|
// defaults.
|
||||||
|
let api = if let Ok(api_url) = std::env::var(EnvVars::PYX_API_URL) {
|
||||||
|
DisplaySafeUrl::parse(&api_url)
|
||||||
|
} else {
|
||||||
|
DisplaySafeUrl::parse("https://api.pyx.dev")
|
||||||
|
}?;
|
||||||
|
let cdn = std::env::var(EnvVars::PYX_CDN_DOMAIN)
|
||||||
|
.ok()
|
||||||
|
.map(SmallString::from)
|
||||||
|
.unwrap_or_else(|| SmallString::from(arcstr::literal!("astralhosted.com")));
|
||||||
|
|
||||||
|
// Determine the root directory for the token store.
|
||||||
|
let root = root_dir(&api)?;
|
||||||
|
|
||||||
|
Ok(Self { root, api, cdn })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Return the API URL for the token store.
|
||||||
|
pub fn api(&self) -> &DisplaySafeUrl {
|
||||||
|
&self.api
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get or initialize an [`AccessToken`] from the store.
|
||||||
|
///
|
||||||
|
/// If an access token is set in the environment, it will be returned as-is.
|
||||||
|
///
|
||||||
|
/// If an access token is present on-disk, it will be returned (and refreshed, if necessary).
|
||||||
|
///
|
||||||
|
/// If no access token is found, but an API key is present, the API key will be used to
|
||||||
|
/// bootstrap an access token.
|
||||||
|
pub async fn access_token(
|
||||||
|
&self,
|
||||||
|
client: &ClientWithMiddleware,
|
||||||
|
tolerance_secs: u64,
|
||||||
|
) -> Result<Option<AccessToken>, TokenStoreError> {
|
||||||
|
// If the access token is already set in the environment, return it.
|
||||||
|
if let Some(access_token) = read_pyx_auth_token() {
|
||||||
|
return Ok(Some(access_token));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the tokens from the store.
|
||||||
|
let tokens = self.init(client, tolerance_secs).await?;
|
||||||
|
|
||||||
|
// Extract the access token from the OAuth tokens or API key.
|
||||||
|
Ok(tokens.map(AccessToken::from))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Initialize the [`PyxTokens`] from the store.
|
||||||
|
///
|
||||||
|
/// If an access token is already present, it will be returned (and refreshed, if necessary).
|
||||||
|
///
|
||||||
|
/// If no access token is found, but an API key is present, the API key will be used to
|
||||||
|
/// bootstrap an access token.
|
||||||
|
pub async fn init(
|
||||||
|
&self,
|
||||||
|
client: &ClientWithMiddleware,
|
||||||
|
tolerance_secs: u64,
|
||||||
|
) -> Result<Option<PyxTokens>, TokenStoreError> {
|
||||||
|
match self.read().await? {
|
||||||
|
Some(tokens) => {
|
||||||
|
// Refresh the tokens if they are expired.
|
||||||
|
let tokens = self.refresh(tokens, client, tolerance_secs).await?;
|
||||||
|
Ok(Some(tokens))
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
// If no tokens are present, bootstrap them from an API key.
|
||||||
|
self.bootstrap(client).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Write the tokens to the store.
|
||||||
|
pub async fn write(&self, tokens: &PyxTokens) -> Result<(), TokenStoreError> {
|
||||||
|
fs_err::tokio::create_dir_all(&self.root).await?;
|
||||||
|
match tokens {
|
||||||
|
PyxTokens::OAuth(tokens) => {
|
||||||
|
// Write OAuth tokens to a generic `tokens.json` file.
|
||||||
|
fs_err::tokio::write(self.root.join("tokens.json"), serde_json::to_vec(tokens)?)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
PyxTokens::ApiKey(tokens) => {
|
||||||
|
// Write API key tokens to a file based on the API key.
|
||||||
|
let digest = uv_cache_key::cache_digest(&tokens.api_key);
|
||||||
|
fs_err::tokio::write(
|
||||||
|
self.root.join(format!("{digest}.json")),
|
||||||
|
&tokens.access_token,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the user appears to have credentials (which may be invalid).
|
||||||
|
pub fn has_credentials(&self) -> bool {
|
||||||
|
read_pyx_auth_token().is_some()
|
||||||
|
|| read_pyx_api_key().is_some()
|
||||||
|
|| self.root.join("tokens.json").is_file()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the tokens from the store.
|
||||||
|
pub async fn read(&self) -> Result<Option<PyxTokens>, TokenStoreError> {
|
||||||
|
// Retrieve the API URL from the environment variable, or error if unset.
|
||||||
|
if let Some(api_key) = read_pyx_api_key() {
|
||||||
|
// Read the API key tokens from a file based on the API key.
|
||||||
|
let digest = uv_cache_key::cache_digest(&api_key);
|
||||||
|
match fs_err::tokio::read(self.root.join(format!("{digest}.json"))).await {
|
||||||
|
Ok(data) => {
|
||||||
|
let access_token =
|
||||||
|
AccessToken::from(String::from_utf8(data).expect("Invalid UTF-8"));
|
||||||
|
Ok(Some(PyxTokens::ApiKey(PyxApiKeyTokens {
|
||||||
|
access_token,
|
||||||
|
api_key,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
match fs_err::tokio::read(self.root.join("tokens.json")).await {
|
||||||
|
Ok(data) => {
|
||||||
|
let tokens: PyxOAuthTokens = serde_json::from_slice(&data)?;
|
||||||
|
Ok(Some(PyxTokens::OAuth(tokens)))
|
||||||
|
}
|
||||||
|
Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(None),
|
||||||
|
Err(err) => Err(err.into()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove the tokens from the store.
|
||||||
|
pub async fn delete(&self) -> Result<(), io::Error> {
|
||||||
|
fs_err::tokio::remove_dir_all(&self.root).await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bootstrap the tokens from the store.
|
||||||
|
async fn bootstrap(
|
||||||
|
&self,
|
||||||
|
client: &ClientWithMiddleware,
|
||||||
|
) -> Result<Option<PyxTokens>, TokenStoreError> {
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||||
|
struct Payload {
|
||||||
|
access_token: AccessToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Retrieve the API key from the environment variable, if set.
|
||||||
|
let Some(api_key) = read_pyx_api_key() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
|
||||||
|
debug!("Bootstrapping access token from an API key");
|
||||||
|
|
||||||
|
// Parse the API URL.
|
||||||
|
let mut url = self.api.clone();
|
||||||
|
url.set_path("auth/cli/access-token");
|
||||||
|
|
||||||
|
let mut request = reqwest::Request::new(reqwest::Method::POST, Url::from(url));
|
||||||
|
request.headers_mut().insert(
|
||||||
|
"Authorization",
|
||||||
|
reqwest::header::HeaderValue::from_str(&format!("Bearer {api_key}"))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client.execute(request).await?;
|
||||||
|
let Payload { access_token } = response.error_for_status()?.json::<Payload>().await?;
|
||||||
|
let tokens = PyxTokens::ApiKey(PyxApiKeyTokens {
|
||||||
|
access_token,
|
||||||
|
api_key,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Write the tokens to disk.
|
||||||
|
self.write(&tokens).await?;
|
||||||
|
|
||||||
|
Ok(Some(tokens))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refresh the tokens in the store, if they are expired.
|
||||||
|
///
|
||||||
|
/// In theory, we should _also_ refresh if we hit a 401; but for now, we only refresh ahead of
|
||||||
|
/// time.
|
||||||
|
async fn refresh(
|
||||||
|
&self,
|
||||||
|
tokens: PyxTokens,
|
||||||
|
client: &ClientWithMiddleware,
|
||||||
|
tolerance_secs: u64,
|
||||||
|
) -> Result<PyxTokens, TokenStoreError> {
|
||||||
|
// Decode the access token.
|
||||||
|
let jwt = Jwt::decode(match &tokens {
|
||||||
|
PyxTokens::OAuth(PyxOAuthTokens { access_token, .. }) => access_token.as_str(),
|
||||||
|
PyxTokens::ApiKey(PyxApiKeyTokens { access_token, .. }) => access_token.as_str(),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// If the access token is expired, refresh it.
|
||||||
|
let is_up_to_date = match jwt.exp {
|
||||||
|
None => {
|
||||||
|
debug!("Access token has no expiration; refreshing...");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Some(..) if tolerance_secs == 0 => {
|
||||||
|
debug!("Refreshing access token due to zero tolerance...");
|
||||||
|
false
|
||||||
|
}
|
||||||
|
Some(jwt) => {
|
||||||
|
let exp = jiff::Timestamp::from_second(jwt)?;
|
||||||
|
let now = jiff::Timestamp::now();
|
||||||
|
if exp < now {
|
||||||
|
debug!("Access token is expired (`{exp}`); refreshing...");
|
||||||
|
false
|
||||||
|
} else if exp < now + Duration::from_secs(tolerance_secs) {
|
||||||
|
debug!(
|
||||||
|
"Access token will expire within the tolerance (`{exp}`); refreshing..."
|
||||||
|
);
|
||||||
|
false
|
||||||
|
} else {
|
||||||
|
debug!("Access token is up-to-date (`{exp}`)");
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if is_up_to_date {
|
||||||
|
return Ok(tokens);
|
||||||
|
}
|
||||||
|
|
||||||
|
let tokens = match tokens {
|
||||||
|
PyxTokens::OAuth(PyxOAuthTokens { refresh_token, .. }) => {
|
||||||
|
// Parse the API URL.
|
||||||
|
let mut url = self.api.clone();
|
||||||
|
url.set_path("auth/cli/refresh");
|
||||||
|
|
||||||
|
let mut request = reqwest::Request::new(reqwest::Method::POST, Url::from(url));
|
||||||
|
let body = serde_json::json!({
|
||||||
|
"refresh_token": refresh_token
|
||||||
|
});
|
||||||
|
*request.body_mut() = Some(body.to_string().into());
|
||||||
|
|
||||||
|
let response = client.execute(request).await?;
|
||||||
|
let tokens = response
|
||||||
|
.error_for_status()?
|
||||||
|
.json::<PyxOAuthTokens>()
|
||||||
|
.await?;
|
||||||
|
PyxTokens::OAuth(tokens)
|
||||||
|
}
|
||||||
|
PyxTokens::ApiKey(PyxApiKeyTokens { api_key, .. }) => {
|
||||||
|
#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
|
||||||
|
struct Payload {
|
||||||
|
access_token: AccessToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the API URL.
|
||||||
|
let mut url = self.api.clone();
|
||||||
|
url.set_path("auth/cli/access-token");
|
||||||
|
|
||||||
|
let mut request = reqwest::Request::new(reqwest::Method::POST, Url::from(url));
|
||||||
|
request.headers_mut().insert(
|
||||||
|
"Authorization",
|
||||||
|
reqwest::header::HeaderValue::from_str(&format!("Bearer {api_key}"))?,
|
||||||
|
);
|
||||||
|
|
||||||
|
let response = client.execute(request).await?;
|
||||||
|
let Payload { access_token } =
|
||||||
|
response.error_for_status()?.json::<Payload>().await?;
|
||||||
|
PyxTokens::ApiKey(PyxApiKeyTokens {
|
||||||
|
access_token,
|
||||||
|
api_key,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Write the new tokens to disk.
|
||||||
|
self.write(&tokens).await?;
|
||||||
|
Ok(tokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the given URL is "known" to this token store (i.e., should be
|
||||||
|
/// authenticated using the store's tokens).
|
||||||
|
pub fn is_known_url(&self, url: &Url) -> bool {
|
||||||
|
is_known_url(url, &self.api, &self.cdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the URL is on a "known" domain (i.e., the same domain as the API or CDN).
|
||||||
|
///
|
||||||
|
/// Like [`is_known_url`](Self::is_known_url), but also returns `true` if the API is on the
|
||||||
|
/// subdomain of the URL (e.g., if the API is `api.pyx.dev` and the URL is `pyx.dev`).
|
||||||
|
pub fn is_known_domain(&self, url: &Url) -> bool {
|
||||||
|
is_known_domain(url, &self.api, &self.cdn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum TokenStoreError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Url(#[from] url::ParseError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Io(#[from] io::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Serialization(#[from] serde_json::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Reqwest(#[from] reqwest::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
ReqwestMiddleware(#[from] reqwest_middleware::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
InvalidHeaderValue(#[from] reqwest::header::InvalidHeaderValue),
|
||||||
|
#[error(transparent)]
|
||||||
|
Jiff(#[from] jiff::Error),
|
||||||
|
#[error(transparent)]
|
||||||
|
Jwt(#[from] JwtError),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TokenStoreError {
|
||||||
|
/// Returns `true` if the error is a 401 (Unauthorized) error.
|
||||||
|
pub fn is_unauthorized(&self) -> bool {
|
||||||
|
match self {
|
||||||
|
Self::Reqwest(err) => err.status() == Some(reqwest::StatusCode::UNAUTHORIZED),
|
||||||
|
Self::ReqwestMiddleware(err) => err.status() == Some(reqwest::StatusCode::UNAUTHORIZED),
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The payload of the JWT.
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct Jwt {
|
||||||
|
exp: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Jwt {
|
||||||
|
/// Decode the JWT from the access token.
|
||||||
|
fn decode(access_token: &str) -> Result<Self, JwtError> {
|
||||||
|
let mut token_segments = access_token.splitn(3, '.');
|
||||||
|
|
||||||
|
let _header = token_segments.next().ok_or(JwtError::MissingHeader)?;
|
||||||
|
let payload = token_segments.next().ok_or(JwtError::MissingPayload)?;
|
||||||
|
let _signature = token_segments.next().ok_or(JwtError::MissingSignature)?;
|
||||||
|
if token_segments.next().is_some() {
|
||||||
|
return Err(JwtError::TooManySegments);
|
||||||
|
}
|
||||||
|
|
||||||
|
let decoded = BASE64_URL_SAFE_NO_PAD.decode(payload)?;
|
||||||
|
|
||||||
|
let jwt = serde_json::from_slice::<Self>(&decoded)?;
|
||||||
|
Ok(jwt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(thiserror::Error, Debug)]
|
||||||
|
pub enum JwtError {
|
||||||
|
#[error("JWT is missing a header")]
|
||||||
|
MissingHeader,
|
||||||
|
#[error("JWT is missing a payload")]
|
||||||
|
MissingPayload,
|
||||||
|
#[error("JWT is missing a signature")]
|
||||||
|
MissingSignature,
|
||||||
|
#[error("JWT has too many segments")]
|
||||||
|
TooManySegments,
|
||||||
|
#[error(transparent)]
|
||||||
|
Base64(#[from] base64::DecodeError),
|
||||||
|
#[error(transparent)]
|
||||||
|
Serde(#[from] serde_json::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_known_url(url: &Url, api: &DisplaySafeUrl, cdn: &str) -> bool {
|
||||||
|
// Determine whether the URL matches the API realm.
|
||||||
|
if Realm::from(url) == Realm::from(&**api) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine whether the URL matches the CDN domain (or a subdomain of it).
|
||||||
|
//
|
||||||
|
// For example, if URL is on `files.astralhosted.com` and the CDN domain is
|
||||||
|
// `astralhosted.com`, consider it known.
|
||||||
|
if matches_domain(url, cdn) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_known_domain(url: &Url, api: &DisplaySafeUrl, cdn: &str) -> bool {
|
||||||
|
// Determine whether the URL matches the API domain.
|
||||||
|
if let Some(domain) = url.domain() {
|
||||||
|
if matches_domain(api, domain) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
is_known_url(url, api, cdn)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns `true` if the target URL is on the given domain.
|
||||||
|
fn matches_domain(url: &Url, domain: &str) -> bool {
|
||||||
|
url.domain().is_some_and(|subdomain| {
|
||||||
|
subdomain == domain
|
||||||
|
|| subdomain
|
||||||
|
.strip_suffix(domain)
|
||||||
|
.is_some_and(|prefix| prefix.ends_with('.'))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_known_url() {
|
||||||
|
let api_url = DisplaySafeUrl::from(Url::parse("https://api.pyx.dev").unwrap());
|
||||||
|
let cdn_domain = "astralhosted.com";
|
||||||
|
|
||||||
|
// Same realm as API.
|
||||||
|
assert!(is_known_url(
|
||||||
|
&Url::parse("https://api.pyx.dev/simple/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// Different path on same API domain
|
||||||
|
assert!(is_known_url(
|
||||||
|
&Url::parse("https://api.pyx.dev/v1/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// CDN domain.
|
||||||
|
assert!(is_known_url(
|
||||||
|
&Url::parse("https://astralhosted.com/packages/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// CDN subdomain.
|
||||||
|
assert!(is_known_url(
|
||||||
|
&Url::parse("https://files.astralhosted.com/packages/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// Unknown domain.
|
||||||
|
assert!(!is_known_url(
|
||||||
|
&Url::parse("https://pypi.org/simple/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// Similar but not matching domain.
|
||||||
|
assert!(!is_known_url(
|
||||||
|
&Url::parse("https://badastralhosted.com/packages/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_is_known_domain() {
|
||||||
|
let api_url = DisplaySafeUrl::from(Url::parse("https://api.pyx.dev").unwrap());
|
||||||
|
let cdn_domain = "astralhosted.com";
|
||||||
|
|
||||||
|
// Same realm as API.
|
||||||
|
assert!(is_known_domain(
|
||||||
|
&Url::parse("https://api.pyx.dev/simple/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// API super-domain.
|
||||||
|
assert!(is_known_domain(
|
||||||
|
&Url::parse("https://pyx.dev").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// API subdomain.
|
||||||
|
assert!(!is_known_domain(
|
||||||
|
&Url::parse("https://foo.api.pyx.dev").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// Different subdomain.
|
||||||
|
assert!(!is_known_domain(
|
||||||
|
&Url::parse("https://beta.pyx.dev/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// CDN domain.
|
||||||
|
assert!(is_known_domain(
|
||||||
|
&Url::parse("https://astralhosted.com/packages/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// CDN subdomain.
|
||||||
|
assert!(is_known_domain(
|
||||||
|
&Url::parse("https://files.astralhosted.com/packages/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// Unknown domain.
|
||||||
|
assert!(!is_known_domain(
|
||||||
|
&Url::parse("https://pypi.org/simple/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
|
||||||
|
// Different TLD.
|
||||||
|
assert!(!is_known_domain(
|
||||||
|
&Url::parse("https://pyx.com/").unwrap(),
|
||||||
|
&api_url,
|
||||||
|
cdn_domain
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_matches_domain() {
|
||||||
|
assert!(matches_domain(
|
||||||
|
&Url::parse("https://example.com").unwrap(),
|
||||||
|
"example.com"
|
||||||
|
));
|
||||||
|
assert!(matches_domain(
|
||||||
|
&Url::parse("https://foo.example.com").unwrap(),
|
||||||
|
"example.com"
|
||||||
|
));
|
||||||
|
assert!(matches_domain(
|
||||||
|
&Url::parse("https://bar.foo.example.com").unwrap(),
|
||||||
|
"example.com"
|
||||||
|
));
|
||||||
|
|
||||||
|
assert!(!matches_domain(
|
||||||
|
&Url::parse("https://example.com").unwrap(),
|
||||||
|
"other.com"
|
||||||
|
));
|
||||||
|
assert!(!matches_domain(
|
||||||
|
&Url::parse("https://example.org").unwrap(),
|
||||||
|
"example.com"
|
||||||
|
));
|
||||||
|
assert!(!matches_domain(
|
||||||
|
&Url::parse("https://badexample.com").unwrap(),
|
||||||
|
"example.com"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,7 +23,7 @@ use uv_small_str::SmallString;
|
||||||
// However, `url` (and therefore `reqwest`) sets the `port` to `None` if it matches the default port
|
// However, `url` (and therefore `reqwest`) sets the `port` to `None` if it matches the default port
|
||||||
// so we do not need any special handling here.
|
// so we do not need any special handling here.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub(crate) struct Realm {
|
pub struct Realm {
|
||||||
scheme: SmallString,
|
scheme: SmallString,
|
||||||
host: Option<SmallString>,
|
host: Option<SmallString>,
|
||||||
port: Option<u16>,
|
port: Option<u16>,
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ use crate::service::Service;
|
||||||
use crate::{Credentials, KeyringProvider};
|
use crate::{Credentials, KeyringProvider};
|
||||||
|
|
||||||
/// The storage backend to use in `uv auth` commands.
|
/// The storage backend to use in `uv auth` commands.
|
||||||
|
#[derive(Debug)]
|
||||||
pub enum AuthBackend {
|
pub enum AuthBackend {
|
||||||
// TODO(zanieb): Right now, we're using a keyring provider for the system store but that's just
|
// TODO(zanieb): Right now, we're using a keyring provider for the system store but that's just
|
||||||
// where the native implementation is living at the moment. We should consider refactoring these
|
// where the native implementation is living at the moment. We should consider refactoring these
|
||||||
|
|
@ -104,11 +105,11 @@ pub enum BearerAuthError {
|
||||||
/// A single credential entry in a TOML credentials file.
|
/// A single credential entry in a TOML credentials file.
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(try_from = "TomlCredentialWire", into = "TomlCredentialWire")]
|
#[serde(try_from = "TomlCredentialWire", into = "TomlCredentialWire")]
|
||||||
pub struct TomlCredential {
|
struct TomlCredential {
|
||||||
/// The service URL for this credential.
|
/// The service URL for this credential.
|
||||||
pub service: Service,
|
service: Service,
|
||||||
/// The credentials for this entry.
|
/// The credentials for this entry.
|
||||||
pub credentials: Credentials,
|
credentials: Credentials,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|
@ -380,11 +381,13 @@ impl TextCredentialStore {
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
|
||||||
use std::io::Write;
|
use std::io::Write;
|
||||||
use std::str::FromStr;
|
use std::str::FromStr;
|
||||||
|
|
||||||
use tempfile::NamedTempFile;
|
use tempfile::NamedTempFile;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_toml_serialization() {
|
fn test_toml_serialization() {
|
||||||
let credentials = TomlCredentials {
|
let credentials = TomlCredentials {
|
||||||
|
|
|
||||||
|
|
@ -1164,6 +1164,11 @@ impl PythonRunner {
|
||||||
// tools, which might mess with wrappers trying to parse their
|
// tools, which might mess with wrappers trying to parse their
|
||||||
// output.
|
// output.
|
||||||
.env(EnvVars::PYTHONIOENCODING, "utf-8:backslashreplace")
|
.env(EnvVars::PYTHONIOENCODING, "utf-8:backslashreplace")
|
||||||
|
// Remove potentially-sensitive environment variables.
|
||||||
|
.env_remove(EnvVars::PYX_API_KEY)
|
||||||
|
.env_remove(EnvVars::UV_API_KEY)
|
||||||
|
.env_remove(EnvVars::PYX_AUTH_TOKEN)
|
||||||
|
.env_remove(EnvVars::UV_AUTH_TOKEN)
|
||||||
.stdout(std::process::Stdio::piped())
|
.stdout(std::process::Stdio::piped())
|
||||||
.stderr(std::process::Stdio::piped())
|
.stderr(std::process::Stdio::piped())
|
||||||
.spawn()
|
.spawn()
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ use tracing::{debug, trace};
|
||||||
use url::ParseError;
|
use url::ParseError;
|
||||||
use url::Url;
|
use url::Url;
|
||||||
|
|
||||||
use uv_auth::{AuthMiddleware, Credentials, Indexes};
|
use uv_auth::{AuthMiddleware, Credentials, Indexes, PyxTokenStore};
|
||||||
use uv_configuration::{KeyringProviderType, TrustedHost};
|
use uv_configuration::{KeyringProviderType, TrustedHost};
|
||||||
use uv_fs::Simplified;
|
use uv_fs::Simplified;
|
||||||
use uv_pep508::MarkerEnvironment;
|
use uv_pep508::MarkerEnvironment;
|
||||||
|
|
@ -472,6 +472,30 @@ impl<'a> BaseClientBuilder<'a> {
|
||||||
fn apply_middleware(&self, client: Client) -> ClientWithMiddleware {
|
fn apply_middleware(&self, client: Client) -> ClientWithMiddleware {
|
||||||
match self.connectivity {
|
match self.connectivity {
|
||||||
Connectivity::Online => {
|
Connectivity::Online => {
|
||||||
|
// Create a base client to using in the authentication middleware.
|
||||||
|
let base_client = {
|
||||||
|
let mut client = reqwest_middleware::ClientBuilder::new(client.clone());
|
||||||
|
|
||||||
|
// Avoid uncloneable errors with a streaming body during publish.
|
||||||
|
if self.retries > 0 {
|
||||||
|
// Initialize the retry strategy.
|
||||||
|
let retry_strategy = RetryTransientMiddleware::new_with_policy_and_strategy(
|
||||||
|
self.retry_policy(),
|
||||||
|
UvRetryableStrategy,
|
||||||
|
);
|
||||||
|
client = client.with(retry_strategy);
|
||||||
|
}
|
||||||
|
|
||||||
|
// When supplied, add the extra middleware.
|
||||||
|
if let Some(extra_middleware) = &self.extra_middleware {
|
||||||
|
for middleware in &extra_middleware.0 {
|
||||||
|
client = client.with_arc(middleware.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.build()
|
||||||
|
};
|
||||||
|
|
||||||
let mut client = reqwest_middleware::ClientBuilder::new(client);
|
let mut client = reqwest_middleware::ClientBuilder::new(client);
|
||||||
|
|
||||||
// Avoid uncloneable errors with a streaming body during publish.
|
// Avoid uncloneable errors with a streaming body during publish.
|
||||||
|
|
@ -484,22 +508,36 @@ impl<'a> BaseClientBuilder<'a> {
|
||||||
client = client.with(retry_strategy);
|
client = client.with(retry_strategy);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When supplied, add the extra middleware.
|
||||||
|
if let Some(extra_middleware) = &self.extra_middleware {
|
||||||
|
for middleware in &extra_middleware.0 {
|
||||||
|
client = client.with_arc(middleware.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize the authentication middleware to set headers.
|
// Initialize the authentication middleware to set headers.
|
||||||
match self.auth_integration {
|
match self.auth_integration {
|
||||||
AuthIntegration::Default => {
|
AuthIntegration::Default => {
|
||||||
let auth_middleware = AuthMiddleware::new()
|
let mut auth_middleware = AuthMiddleware::new()
|
||||||
|
.with_base_client(base_client)
|
||||||
.with_indexes(self.indexes.clone())
|
.with_indexes(self.indexes.clone())
|
||||||
.with_keyring(self.keyring.to_provider())
|
.with_keyring(self.keyring.to_provider())
|
||||||
.with_preview(self.preview);
|
.with_preview(self.preview);
|
||||||
|
if let Ok(token_store) = PyxTokenStore::from_settings() {
|
||||||
|
auth_middleware = auth_middleware.with_pyx_token_store(token_store);
|
||||||
|
}
|
||||||
client = client.with(auth_middleware);
|
client = client.with(auth_middleware);
|
||||||
}
|
}
|
||||||
AuthIntegration::OnlyAuthenticated => {
|
AuthIntegration::OnlyAuthenticated => {
|
||||||
let auth_middleware = AuthMiddleware::new()
|
let mut auth_middleware = AuthMiddleware::new()
|
||||||
|
.with_base_client(base_client)
|
||||||
.with_indexes(self.indexes.clone())
|
.with_indexes(self.indexes.clone())
|
||||||
.with_keyring(self.keyring.to_provider())
|
.with_keyring(self.keyring.to_provider())
|
||||||
.with_preview(self.preview)
|
.with_preview(self.preview)
|
||||||
.with_only_authenticated(true);
|
.with_only_authenticated(true);
|
||||||
|
if let Ok(token_store) = PyxTokenStore::from_settings() {
|
||||||
|
auth_middleware = auth_middleware.with_pyx_token_store(token_store);
|
||||||
|
}
|
||||||
client = client.with(auth_middleware);
|
client = client.with(auth_middleware);
|
||||||
}
|
}
|
||||||
AuthIntegration::NoAuthMiddleware => {
|
AuthIntegration::NoAuthMiddleware => {
|
||||||
|
|
@ -507,13 +545,6 @@ impl<'a> BaseClientBuilder<'a> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// When supplied add the extra middleware
|
|
||||||
if let Some(extra_middleware) = &self.extra_middleware {
|
|
||||||
for middleware in &extra_middleware.0 {
|
|
||||||
client = client.with_arc(middleware.clone());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
client.build()
|
client.build()
|
||||||
}
|
}
|
||||||
Connectivity::Offline => reqwest_middleware::ClientBuilder::new(client)
|
Connectivity::Offline => reqwest_middleware::ClientBuilder::new(client)
|
||||||
|
|
|
||||||
|
|
@ -105,7 +105,7 @@ pub enum StateBucket {
|
||||||
ManagedPython,
|
ManagedPython,
|
||||||
/// Installed tools.
|
/// Installed tools.
|
||||||
Tools,
|
Tools,
|
||||||
/// Stored authentication credentials.
|
/// Credentials.
|
||||||
Credentials,
|
Credentials,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -856,4 +856,27 @@ impl EnvVars {
|
||||||
|
|
||||||
/// Disable Hugging Face authentication, even if `HF_TOKEN` is set.
|
/// Disable Hugging Face authentication, even if `HF_TOKEN` is set.
|
||||||
pub const UV_NO_HF_TOKEN: &'static str = "UV_NO_HF_TOKEN";
|
pub const UV_NO_HF_TOKEN: &'static str = "UV_NO_HF_TOKEN";
|
||||||
|
|
||||||
|
/// The URL of the pyx Simple API server.
|
||||||
|
pub const PYX_API_URL: &'static str = "PYX_API_URL";
|
||||||
|
|
||||||
|
/// The domain of the pyx CDN.
|
||||||
|
pub const PYX_CDN_DOMAIN: &'static str = "PYX_CDN_DOMAIN";
|
||||||
|
|
||||||
|
/// The pyx API key (e.g., `sk-pyx-...`).
|
||||||
|
pub const PYX_API_KEY: &'static str = "PYX_API_KEY";
|
||||||
|
|
||||||
|
/// The pyx API key, for backwards compatibility.
|
||||||
|
#[attr_hidden]
|
||||||
|
pub const UV_API_KEY: &'static str = "UV_API_KEY";
|
||||||
|
|
||||||
|
/// The pyx authentication token (e.g., `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...`), as output by `uv auth token`.
|
||||||
|
pub const PYX_AUTH_TOKEN: &'static str = "PYX_AUTH_TOKEN";
|
||||||
|
|
||||||
|
/// The pyx authentication token, for backwards compatibility.
|
||||||
|
#[attr_hidden]
|
||||||
|
pub const UV_AUTH_TOKEN: &'static str = "UV_AUTH_TOKEN";
|
||||||
|
|
||||||
|
/// Specifies the directory where uv stores pyx credentials.
|
||||||
|
pub const PYX_CREDENTIALS_DIR: &'static str = "PYX_CREDENTIALS_DIR";
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,7 @@ axoupdater = { workspace = true, features = [
|
||||||
"github_releases",
|
"github_releases",
|
||||||
"tokio",
|
"tokio",
|
||||||
], optional = true }
|
], optional = true }
|
||||||
|
base64 = { workspace = true }
|
||||||
clap = { workspace = true, features = ["derive", "string", "wrap_help"] }
|
clap = { workspace = true, features = ["derive", "string", "wrap_help"] }
|
||||||
console = { workspace = true }
|
console = { workspace = true }
|
||||||
ctrlc = { workspace = true }
|
ctrlc = { workspace = true }
|
||||||
|
|
@ -84,6 +85,7 @@ indoc = { workspace = true }
|
||||||
itertools = { workspace = true }
|
itertools = { workspace = true }
|
||||||
h2 = { workspace = true }
|
h2 = { workspace = true }
|
||||||
miette = { workspace = true, features = ["fancy-no-backtrace"] }
|
miette = { workspace = true, features = ["fancy-no-backtrace"] }
|
||||||
|
open = { workspace = true }
|
||||||
owo-colors = { workspace = true }
|
owo-colors = { workspace = true }
|
||||||
petgraph = { workspace = true }
|
petgraph = { workspace = true }
|
||||||
regex = { workspace = true }
|
regex = { workspace = true }
|
||||||
|
|
@ -107,6 +109,7 @@ tracing-subscriber = { workspace = true, features = ["env-filter", "json", "regi
|
||||||
tracing-tree = { workspace = true }
|
tracing-tree = { workspace = true }
|
||||||
unicode-width = { workspace = true }
|
unicode-width = { workspace = true }
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
|
uuid = { workspace = true, features = ["v4"] }
|
||||||
version-ranges = { workspace = true }
|
version-ranges = { workspace = true }
|
||||||
walkdir = { workspace = true }
|
walkdir = { workspace = true }
|
||||||
which = { workspace = true }
|
which = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -3,15 +3,21 @@ use std::fmt::Write;
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
use console::Term;
|
use console::Term;
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
|
use url::Url;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
use uv_auth::Service;
|
use uv_auth::{
|
||||||
use uv_auth::store::AuthBackend;
|
AccessToken, AuthBackend, Credentials, PyxOAuthTokens, PyxTokenStore, PyxTokens, Service,
|
||||||
use uv_auth::{Credentials, TextCredentialStore};
|
TextCredentialStore,
|
||||||
|
};
|
||||||
|
use uv_client::{AuthIntegration, BaseClient, BaseClientBuilder};
|
||||||
use uv_distribution_types::IndexUrl;
|
use uv_distribution_types::IndexUrl;
|
||||||
use uv_pep508::VerbatimUrl;
|
use uv_pep508::VerbatimUrl;
|
||||||
use uv_preview::Preview;
|
use uv_preview::Preview;
|
||||||
|
|
||||||
use crate::{commands::ExitStatus, printer::Printer};
|
use crate::commands::ExitStatus;
|
||||||
|
use crate::printer::Printer;
|
||||||
|
use crate::settings::NetworkSettings;
|
||||||
|
|
||||||
/// Login to a service.
|
/// Login to a service.
|
||||||
pub(crate) async fn login(
|
pub(crate) async fn login(
|
||||||
|
|
@ -19,9 +25,35 @@ pub(crate) async fn login(
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
password: Option<String>,
|
password: Option<String>,
|
||||||
token: Option<String>,
|
token: Option<String>,
|
||||||
|
network_settings: &NetworkSettings,
|
||||||
printer: Printer,
|
printer: Printer,
|
||||||
preview: Preview,
|
preview: Preview,
|
||||||
) -> Result<ExitStatus> {
|
) -> Result<ExitStatus> {
|
||||||
|
let pyx_store = PyxTokenStore::from_settings()?;
|
||||||
|
if pyx_store.is_known_domain(service.url()) {
|
||||||
|
if username.is_some() {
|
||||||
|
bail!("Cannot specify a username when logging in to pyx");
|
||||||
|
}
|
||||||
|
if password.is_some() {
|
||||||
|
bail!("Cannot specify a password when logging in to pyx");
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = BaseClientBuilder::default()
|
||||||
|
.connectivity(network_settings.connectivity)
|
||||||
|
.native_tls(network_settings.native_tls)
|
||||||
|
.allow_insecure_host(network_settings.allow_insecure_host.clone())
|
||||||
|
.auth_integration(AuthIntegration::NoAuthMiddleware)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
pyx_login_with_browser(&pyx_store, &client, &printer).await?;
|
||||||
|
writeln!(
|
||||||
|
printer.stderr(),
|
||||||
|
"Logged in to {}",
|
||||||
|
pyx_store.api().bold().cyan()
|
||||||
|
)?;
|
||||||
|
return Ok(ExitStatus::Success);
|
||||||
|
}
|
||||||
|
|
||||||
let backend = AuthBackend::from_settings(preview)?;
|
let backend = AuthBackend::from_settings(preview)?;
|
||||||
|
|
||||||
// If the URL includes a known index URL suffix, strip it
|
// If the URL includes a known index URL suffix, strip it
|
||||||
|
|
@ -131,3 +163,64 @@ pub(crate) async fn login(
|
||||||
)?;
|
)?;
|
||||||
Ok(ExitStatus::Success)
|
Ok(ExitStatus::Success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Log in via the [`PyxTokenStore`].
|
||||||
|
pub(crate) async fn pyx_login_with_browser(
|
||||||
|
store: &PyxTokenStore,
|
||||||
|
client: &BaseClient,
|
||||||
|
printer: &Printer,
|
||||||
|
) -> Result<AccessToken> {
|
||||||
|
// Generate a login code, like `67e55044-10b1-426f-9247-bb680e5fe0c8`.
|
||||||
|
let cli_token = Uuid::new_v4();
|
||||||
|
let url = {
|
||||||
|
let mut url = store.api().clone();
|
||||||
|
url.set_path(&format!("auth/cli/login/{cli_token}"));
|
||||||
|
url
|
||||||
|
};
|
||||||
|
match open::that(url.as_ref()) {
|
||||||
|
Ok(()) => {
|
||||||
|
writeln!(printer.stderr(), "Logging in with {}", url.cyan().bold())?;
|
||||||
|
}
|
||||||
|
Err(..) => {
|
||||||
|
writeln!(
|
||||||
|
printer.stderr(),
|
||||||
|
"Open the following URL in your browser: {}",
|
||||||
|
url.cyan().bold()
|
||||||
|
)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Poll the server for the login code.
|
||||||
|
let url = {
|
||||||
|
let mut url = store.api().clone();
|
||||||
|
url.set_path(&format!("auth/cli/status/{cli_token}"));
|
||||||
|
url
|
||||||
|
};
|
||||||
|
|
||||||
|
let credentials = loop {
|
||||||
|
let response = client
|
||||||
|
.for_host(store.api())
|
||||||
|
.get(Url::from(url.clone()))
|
||||||
|
.send()
|
||||||
|
.await?;
|
||||||
|
match response.status() {
|
||||||
|
// Retry on 404.
|
||||||
|
reqwest::StatusCode::NOT_FOUND => {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
}
|
||||||
|
// Parse the credentials on success.
|
||||||
|
_ if response.status().is_success() => {
|
||||||
|
let credentials = response.json::<PyxOAuthTokens>().await?;
|
||||||
|
break Ok::<PyxTokens, anyhow::Error>(PyxTokens::OAuth(credentials));
|
||||||
|
}
|
||||||
|
// Fail on any other status code (like a 500).
|
||||||
|
status => {
|
||||||
|
break Err(anyhow::anyhow!("Failed to login with code `{status}`"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}?;
|
||||||
|
|
||||||
|
store.write(&credentials).await?;
|
||||||
|
|
||||||
|
Ok(AccessToken::from(credentials))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,12 +3,13 @@ use std::fmt::Write;
|
||||||
use anyhow::{Context, Result, bail};
|
use anyhow::{Context, Result, bail};
|
||||||
use owo_colors::OwoColorize;
|
use owo_colors::OwoColorize;
|
||||||
|
|
||||||
use uv_auth::store::AuthBackend;
|
use uv_auth::{AuthBackend, Credentials, PyxTokenStore, Service, TextCredentialStore, Username};
|
||||||
use uv_auth::{Credentials, Service, TextCredentialStore, Username};
|
use uv_client::BaseClientBuilder;
|
||||||
use uv_distribution_types::IndexUrl;
|
use uv_distribution_types::IndexUrl;
|
||||||
use uv_pep508::VerbatimUrl;
|
use uv_pep508::VerbatimUrl;
|
||||||
use uv_preview::Preview;
|
use uv_preview::Preview;
|
||||||
|
|
||||||
|
use crate::settings::NetworkSettings;
|
||||||
use crate::{commands::ExitStatus, printer::Printer};
|
use crate::{commands::ExitStatus, printer::Printer};
|
||||||
|
|
||||||
/// Logout from a service.
|
/// Logout from a service.
|
||||||
|
|
@ -17,9 +18,15 @@ use crate::{commands::ExitStatus, printer::Printer};
|
||||||
pub(crate) async fn logout(
|
pub(crate) async fn logout(
|
||||||
service: Service,
|
service: Service,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
|
network_settings: &NetworkSettings,
|
||||||
printer: Printer,
|
printer: Printer,
|
||||||
preview: Preview,
|
preview: Preview,
|
||||||
) -> Result<ExitStatus> {
|
) -> Result<ExitStatus> {
|
||||||
|
let pyx_store = PyxTokenStore::from_settings()?;
|
||||||
|
if pyx_store.is_known_domain(service.url()) {
|
||||||
|
return pyx_logout(&pyx_store, network_settings, printer).await;
|
||||||
|
}
|
||||||
|
|
||||||
let backend = AuthBackend::from_settings(preview)?;
|
let backend = AuthBackend::from_settings(preview)?;
|
||||||
|
|
||||||
// TODO(zanieb): Use a shared abstraction across `login` and `logout`?
|
// TODO(zanieb): Use a shared abstraction across `login` and `logout`?
|
||||||
|
|
@ -79,3 +86,67 @@ pub(crate) async fn logout(
|
||||||
|
|
||||||
Ok(ExitStatus::Success)
|
Ok(ExitStatus::Success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Log out via the [`PyxTokenStore`], invalidating the existing tokens.
|
||||||
|
async fn pyx_logout(
|
||||||
|
store: &PyxTokenStore,
|
||||||
|
network_settings: &NetworkSettings,
|
||||||
|
printer: Printer,
|
||||||
|
) -> Result<ExitStatus> {
|
||||||
|
// Initialize the client.
|
||||||
|
let client = BaseClientBuilder::default()
|
||||||
|
.connectivity(network_settings.connectivity)
|
||||||
|
.native_tls(network_settings.native_tls)
|
||||||
|
.allow_insecure_host(network_settings.allow_insecure_host.clone())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
// Retrieve the token store.
|
||||||
|
let Some(tokens) = store.read().await? else {
|
||||||
|
writeln!(
|
||||||
|
printer.stderr(),
|
||||||
|
"{}",
|
||||||
|
format_args!("No credentials found for {}", store.api().bold().cyan())
|
||||||
|
)?;
|
||||||
|
return Ok(ExitStatus::Success);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add the token to the request.
|
||||||
|
let url = {
|
||||||
|
let mut url = store.api().clone();
|
||||||
|
url.set_path("auth/cli/logout");
|
||||||
|
url
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build a basic request first, then authenticate it
|
||||||
|
let request = reqwest::Request::new(reqwest::Method::GET, url.into());
|
||||||
|
let request = Credentials::from(tokens).authenticate(request);
|
||||||
|
|
||||||
|
// Hit the logout endpoint using the client's execute method
|
||||||
|
let response = client.execute(request).await?;
|
||||||
|
match response.error_for_status_ref() {
|
||||||
|
Ok(..) => {}
|
||||||
|
Err(err) if matches!(err.status(), Some(reqwest::StatusCode::UNAUTHORIZED)) => {
|
||||||
|
tracing::debug!(
|
||||||
|
"Received 401 (Unauthorized) response from logout endpoint; removing tokens..."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove the tokens from the store.
|
||||||
|
match store.delete().await {
|
||||||
|
Ok(..) => {}
|
||||||
|
Err(err) if matches!(err.kind(), std::io::ErrorKind::NotFound) => {}
|
||||||
|
Err(err) => return Err(err.into()),
|
||||||
|
}
|
||||||
|
|
||||||
|
writeln!(
|
||||||
|
printer.stderr(),
|
||||||
|
"{}",
|
||||||
|
format_args!("Logged out from {}", store.api().bold().cyan())
|
||||||
|
)?;
|
||||||
|
|
||||||
|
Ok(ExitStatus::Success)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,44 @@
|
||||||
use std::fmt::Write;
|
use std::fmt::Write;
|
||||||
|
|
||||||
use anyhow::{Result, bail};
|
use anyhow::{Result, bail};
|
||||||
|
use owo_colors::OwoColorize;
|
||||||
|
use tracing::debug;
|
||||||
|
|
||||||
use uv_auth::Credentials;
|
use uv_auth::{AuthBackend, Service};
|
||||||
use uv_auth::Service;
|
use uv_auth::{Credentials, PyxTokenStore};
|
||||||
use uv_auth::store::AuthBackend;
|
use uv_client::{AuthIntegration, BaseClient, BaseClientBuilder};
|
||||||
use uv_preview::Preview;
|
use uv_preview::Preview;
|
||||||
|
|
||||||
use crate::{commands::ExitStatus, printer::Printer};
|
use crate::commands::ExitStatus;
|
||||||
|
use crate::commands::auth::login;
|
||||||
|
use crate::printer::Printer;
|
||||||
|
use crate::settings::NetworkSettings;
|
||||||
|
|
||||||
/// Show the token that will be used for a service.
|
/// Show the token that will be used for a service.
|
||||||
pub(crate) async fn token(
|
pub(crate) async fn token(
|
||||||
service: Service,
|
service: Service,
|
||||||
username: Option<String>,
|
username: Option<String>,
|
||||||
|
network_settings: &NetworkSettings,
|
||||||
printer: Printer,
|
printer: Printer,
|
||||||
preview: Preview,
|
preview: Preview,
|
||||||
) -> Result<ExitStatus> {
|
) -> Result<ExitStatus> {
|
||||||
|
let pyx_store = PyxTokenStore::from_settings()?;
|
||||||
|
if pyx_store.is_known_domain(service.url()) {
|
||||||
|
if username.is_some() {
|
||||||
|
bail!("Cannot specify a username when logging in to pyx");
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = BaseClientBuilder::default()
|
||||||
|
.connectivity(network_settings.connectivity)
|
||||||
|
.native_tls(network_settings.native_tls)
|
||||||
|
.allow_insecure_host(network_settings.allow_insecure_host.clone())
|
||||||
|
.auth_integration(AuthIntegration::NoAuthMiddleware)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
pyx_refresh(&pyx_store, &client, printer).await?;
|
||||||
|
return Ok(ExitStatus::Success);
|
||||||
|
}
|
||||||
|
|
||||||
let backend = AuthBackend::from_settings(preview)?;
|
let backend = AuthBackend::from_settings(preview)?;
|
||||||
let url = service.url();
|
let url = service.url();
|
||||||
|
|
||||||
|
|
@ -65,3 +88,36 @@ pub(crate) async fn token(
|
||||||
writeln!(printer.stdout(), "{password}")?;
|
writeln!(printer.stdout(), "{password}")?;
|
||||||
Ok(ExitStatus::Success)
|
Ok(ExitStatus::Success)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refresh the authentication tokens in the [`PyxTokenStore`], prompting for login if necessary.
|
||||||
|
async fn pyx_refresh(store: &PyxTokenStore, client: &BaseClient, printer: Printer) -> Result<()> {
|
||||||
|
// Retrieve the token store.
|
||||||
|
let token = match store
|
||||||
|
.access_token(client.for_host(store.api()).raw_client(), 0)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
// If the tokens were successfully refreshed, return them.
|
||||||
|
Ok(Some(token)) => token,
|
||||||
|
|
||||||
|
// If the token store is empty, prompt for login.
|
||||||
|
Ok(None) => {
|
||||||
|
debug!("Token store is empty; prompting for login...");
|
||||||
|
login::pyx_login_with_browser(store, client, &printer).await?
|
||||||
|
}
|
||||||
|
|
||||||
|
// Similarly, if the refresh token expired, prompt for login.
|
||||||
|
Err(err) if err.is_unauthorized() => {
|
||||||
|
debug!(
|
||||||
|
"Received 401 (Unauthorized) response from refresh endpoint; prompting for login..."
|
||||||
|
);
|
||||||
|
login::pyx_login_with_browser(store, client, &printer).await?
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(err) => {
|
||||||
|
return Err(err.into());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
writeln!(printer.stdout(), "{}", token.cyan())?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -443,7 +443,11 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
|
||||||
command: AuthCommand::Login(args),
|
command: AuthCommand::Login(args),
|
||||||
}) => {
|
}) => {
|
||||||
// Resolve the settings from the command-line arguments and workspace configuration.
|
// Resolve the settings from the command-line arguments and workspace configuration.
|
||||||
let args = settings::AuthLoginSettings::resolve(args, filesystem);
|
let args = settings::AuthLoginSettings::resolve(
|
||||||
|
args,
|
||||||
|
&cli.top_level.global_args,
|
||||||
|
filesystem.as_ref(),
|
||||||
|
);
|
||||||
show_settings!(args);
|
show_settings!(args);
|
||||||
|
|
||||||
commands::auth_login(
|
commands::auth_login(
|
||||||
|
|
@ -451,6 +455,7 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
|
||||||
args.username,
|
args.username,
|
||||||
args.password,
|
args.password,
|
||||||
args.token,
|
args.token,
|
||||||
|
&args.network_settings,
|
||||||
printer,
|
printer,
|
||||||
globals.preview,
|
globals.preview,
|
||||||
)
|
)
|
||||||
|
|
@ -460,19 +465,41 @@ async fn run(mut cli: Cli) -> Result<ExitStatus> {
|
||||||
command: AuthCommand::Logout(args),
|
command: AuthCommand::Logout(args),
|
||||||
}) => {
|
}) => {
|
||||||
// Resolve the settings from the command-line arguments and workspace configuration.
|
// Resolve the settings from the command-line arguments and workspace configuration.
|
||||||
let args = settings::AuthLogoutSettings::resolve(args, filesystem);
|
let args = settings::AuthLogoutSettings::resolve(
|
||||||
|
args,
|
||||||
|
&cli.top_level.global_args,
|
||||||
|
filesystem.as_ref(),
|
||||||
|
);
|
||||||
show_settings!(args);
|
show_settings!(args);
|
||||||
|
|
||||||
commands::auth_logout(args.service, args.username, printer, globals.preview).await
|
commands::auth_logout(
|
||||||
|
args.service,
|
||||||
|
args.username,
|
||||||
|
&args.network_settings,
|
||||||
|
printer,
|
||||||
|
globals.preview,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
Commands::Auth(AuthNamespace {
|
Commands::Auth(AuthNamespace {
|
||||||
command: AuthCommand::Token(args),
|
command: AuthCommand::Token(args),
|
||||||
}) => {
|
}) => {
|
||||||
// Resolve the settings from the command-line arguments and workspace configuration.
|
// Resolve the settings from the command-line arguments and workspace configuration.
|
||||||
let args = settings::AuthTokenSettings::resolve(args, filesystem);
|
let args = settings::AuthTokenSettings::resolve(
|
||||||
|
args,
|
||||||
|
&cli.top_level.global_args,
|
||||||
|
filesystem.as_ref(),
|
||||||
|
);
|
||||||
show_settings!(args);
|
show_settings!(args);
|
||||||
|
|
||||||
commands::auth_token(args.service, args.username, printer, globals.preview).await
|
commands::auth_token(
|
||||||
|
args.service,
|
||||||
|
args.username,
|
||||||
|
&args.network_settings,
|
||||||
|
printer,
|
||||||
|
globals.preview,
|
||||||
|
)
|
||||||
|
.await
|
||||||
}
|
}
|
||||||
Commands::Auth(AuthNamespace {
|
Commands::Auth(AuthNamespace {
|
||||||
command: AuthCommand::Dir,
|
command: AuthCommand::Dir,
|
||||||
|
|
|
||||||
|
|
@ -3490,14 +3490,22 @@ impl PublishSettings {
|
||||||
pub(crate) struct AuthLogoutSettings {
|
pub(crate) struct AuthLogoutSettings {
|
||||||
pub(crate) service: Service,
|
pub(crate) service: Service,
|
||||||
pub(crate) username: Option<String>,
|
pub(crate) username: Option<String>,
|
||||||
|
|
||||||
|
// Both CLI and configuration.
|
||||||
|
pub(crate) network_settings: NetworkSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthLogoutSettings {
|
impl AuthLogoutSettings {
|
||||||
/// Resolve the [`AuthLogoutSettings`] from the CLI and filesystem configuration.
|
/// Resolve the [`AuthLogoutSettings`] from the CLI and filesystem configuration.
|
||||||
pub(crate) fn resolve(args: AuthLogoutArgs, _filesystem: Option<FilesystemOptions>) -> Self {
|
pub(crate) fn resolve(
|
||||||
|
args: AuthLogoutArgs,
|
||||||
|
global_args: &GlobalArgs,
|
||||||
|
filesystem: Option<&FilesystemOptions>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
service: args.service,
|
service: args.service,
|
||||||
username: args.username,
|
username: args.username,
|
||||||
|
network_settings: NetworkSettings::resolve(global_args, filesystem),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3507,14 +3515,22 @@ impl AuthLogoutSettings {
|
||||||
pub(crate) struct AuthTokenSettings {
|
pub(crate) struct AuthTokenSettings {
|
||||||
pub(crate) service: Service,
|
pub(crate) service: Service,
|
||||||
pub(crate) username: Option<String>,
|
pub(crate) username: Option<String>,
|
||||||
|
|
||||||
|
// Both CLI and configuration.
|
||||||
|
pub(crate) network_settings: NetworkSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthTokenSettings {
|
impl AuthTokenSettings {
|
||||||
/// Resolve the [`AuthTokenSettings`] from the CLI and filesystem configuration.
|
/// Resolve the [`AuthTokenSettings`] from the CLI and filesystem configuration.
|
||||||
pub(crate) fn resolve(args: AuthTokenArgs, _filesystem: Option<FilesystemOptions>) -> Self {
|
pub(crate) fn resolve(
|
||||||
|
args: AuthTokenArgs,
|
||||||
|
global_args: &GlobalArgs,
|
||||||
|
filesystem: Option<&FilesystemOptions>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
service: args.service,
|
service: args.service,
|
||||||
username: args.username,
|
username: args.username,
|
||||||
|
network_settings: NetworkSettings::resolve(global_args, filesystem),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3526,16 +3542,24 @@ pub(crate) struct AuthLoginSettings {
|
||||||
pub(crate) username: Option<String>,
|
pub(crate) username: Option<String>,
|
||||||
pub(crate) password: Option<String>,
|
pub(crate) password: Option<String>,
|
||||||
pub(crate) token: Option<String>,
|
pub(crate) token: Option<String>,
|
||||||
|
|
||||||
|
// Both CLI and configuration.
|
||||||
|
pub(crate) network_settings: NetworkSettings,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AuthLoginSettings {
|
impl AuthLoginSettings {
|
||||||
/// Resolve the [`AuthLoginSettings`] from the CLI and filesystem configuration.
|
/// Resolve the [`AuthLoginSettings`] from the CLI and filesystem configuration.
|
||||||
pub(crate) fn resolve(args: AuthLoginArgs, _filesystem: Option<FilesystemOptions>) -> Self {
|
pub(crate) fn resolve(
|
||||||
|
args: AuthLoginArgs,
|
||||||
|
global_args: &GlobalArgs,
|
||||||
|
filesystem: Option<&FilesystemOptions>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
service: args.service,
|
service: args.service,
|
||||||
username: args.username,
|
username: args.username,
|
||||||
password: args.password,
|
password: args.password,
|
||||||
token: args.token,
|
token: args.token,
|
||||||
|
network_settings: NetworkSettings::resolve(global_args, filesystem),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -685,6 +685,26 @@ See [`PycInvalidationMode`](https://docs.python.org/3/library/py_compile.html#py
|
||||||
|
|
||||||
Adds directories to Python module search path (e.g., `PYTHONPATH=/path/to/modules`).
|
Adds directories to Python module search path (e.g., `PYTHONPATH=/path/to/modules`).
|
||||||
|
|
||||||
|
### `PYX_API_KEY`
|
||||||
|
|
||||||
|
The pyx API key (e.g., `sk-pyx-...`).
|
||||||
|
|
||||||
|
### `PYX_API_URL`
|
||||||
|
|
||||||
|
The URL of the pyx Simple API server.
|
||||||
|
|
||||||
|
### `PYX_AUTH_TOKEN`
|
||||||
|
|
||||||
|
The pyx authentication token (e.g., `eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...`), as output by `uv auth token`.
|
||||||
|
|
||||||
|
### `PYX_CDN_DOMAIN`
|
||||||
|
|
||||||
|
The domain of the pyx CDN.
|
||||||
|
|
||||||
|
### `PYX_CREDENTIALS_DIR`
|
||||||
|
|
||||||
|
Specifies the directory where uv stores pyx credentials.
|
||||||
|
|
||||||
### `RUST_BACKTRACE`
|
### `RUST_BACKTRACE`
|
||||||
|
|
||||||
If set, it can be used to display more stack trace details when a panic occurs.
|
If set, it can be used to display more stack trace details when a panic occurs.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue