Skip to content

Commit 141b10f

Browse files
committed
Experimental workspace plumbing
Signed-off-by: William Woodruff <william@astral.sh>
1 parent 91aeab8 commit 141b10f

File tree

5 files changed

+179
-27
lines changed

5 files changed

+179
-27
lines changed

crates/uv-auth/src/index.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,11 @@ impl Indexes {
107107
.unwrap_or(AuthPolicy::Auto)
108108
}
109109

110+
/// Iterate over all indexes.
111+
pub fn iter(&self) -> impl Iterator<Item = &Index> {
112+
self.0.iter()
113+
}
114+
110115
fn find_prefix_index(&self, url: &Url) -> Option<&Index> {
111116
self.0.iter().find(|&index| index.is_prefix_for(url))
112117
}

crates/uv-auth/src/middleware.rs

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ use http::{Extensions, StatusCode};
55
use netrc::Netrc;
66
use reqwest::{Request, Response};
77
use reqwest_middleware::{ClientWithMiddleware, Error, Middleware, Next};
8+
use rustc_hash::FxHashMap;
89
use tokio::sync::Mutex;
910
use tracing::{debug, trace, warn};
1011

@@ -120,15 +121,6 @@ impl TextStoreMode {
120121
}
121122
}
122123

123-
#[derive(Debug, Clone)]
124-
enum TokenState {
125-
/// The token state has not yet been initialized from the store.
126-
Uninitialized,
127-
/// The token state has been initialized, and the store either returned tokens or `None` if
128-
/// the user has not yet authenticated.
129-
Initialized(Option<AccessToken>),
130-
}
131-
132124
#[derive(Clone)]
133125
enum S3CredentialState {
134126
/// The S3 credential state has not yet been initialized.
@@ -166,8 +158,13 @@ pub struct AuthMiddleware {
166158
base_client: Option<ClientWithMiddleware>,
167159
/// The pyx token store to use for persistent credentials.
168160
pyx_token_store: Option<PyxTokenStore>,
169-
/// Tokens to use for persistent credentials.
170-
pyx_token_state: Mutex<TokenState>,
161+
/// Per-workspace token state.
162+
///
163+
/// The key is the workspace name (e.g., `"acme"`), or `None` for CDN/non-workspace URLs.
164+
/// Each workspace gets its own entry so that Trusted Access tokens — which are minted
165+
/// per-workspace — are cached and refreshed independently. A missing entry means the token
166+
/// has not yet been fetched for that workspace.
167+
pyx_token_state: Mutex<FxHashMap<Option<String>, Option<AccessToken>>>,
171168
/// Cached S3 credentials to avoid running the credential helper multiple times.
172169
s3_credential_state: Mutex<S3CredentialState>,
173170
/// Cached GCS credentials to avoid running the credential helper multiple times.
@@ -193,7 +190,7 @@ impl AuthMiddleware {
193190
only_authenticated: false,
194191
base_client: None,
195192
pyx_token_store: None,
196-
pyx_token_state: Mutex::new(TokenState::Uninitialized),
193+
pyx_token_state: Mutex::new(FxHashMap::default()),
197194
s3_credential_state: Mutex::new(S3CredentialState::Uninitialized),
198195
gcs_credential_state: Mutex::new(GcsCredentialState::Uninitialized),
199196
preview: Preview::default(),
@@ -752,14 +749,37 @@ impl AuthMiddleware {
752749
if let Some(base_client) = self.base_client.as_ref() {
753750
if let Some(token_store) = self.pyx_token_store.as_ref() {
754751
if token_store.is_known_url(url) {
755-
let mut token_state = self.pyx_token_state.lock().await;
752+
// Derive the workspace from the URL (e.g., `acme` from
753+
// `https://api.pyx.dev/simple/acme/main/`). CDN URLs have no workspace in
754+
// their path, so this returns `None` for them.
755+
let workspace = token_store.workspace_for_url(url).map(str::to_owned);
756+
757+
let mut token_state_map = self.pyx_token_state.lock().await;
756758

757-
// If the token store is uninitialized, initialize it.
758-
let token = match *token_state {
759-
TokenState::Uninitialized => {
759+
let token = if let Some(token) = token_state_map.get(&workspace) {
760+
token.clone()
761+
} else {
762+
// For CDN URLs (workspace=None), try to reuse any already-initialized
763+
// workspace token rather than bootstrapping from scratch. Any valid
764+
// pyx token should authorize CDN downloads.
765+
let existing = if workspace.is_none() {
766+
token_state_map.values().find_map(std::clone::Clone::clone)
767+
} else {
768+
None
769+
};
770+
771+
if let Some(token) = existing {
772+
// Cache under the None key for subsequent CDN requests.
773+
token_state_map.insert(None, Some(token.clone()));
774+
Some(token)
775+
} else {
760776
trace!("Initializing token store for {url}");
761777
let generated = match token_store
762-
.access_token(base_client, DEFAULT_TOLERANCE_SECS)
778+
.access_token(
779+
base_client,
780+
DEFAULT_TOLERANCE_SECS,
781+
workspace.as_deref(),
782+
)
763783
.await
764784
{
765785
Ok(Some(token)) => Some(token),
@@ -769,10 +789,9 @@ impl AuthMiddleware {
769789
None
770790
}
771791
};
772-
*token_state = TokenState::Initialized(generated.clone());
792+
token_state_map.insert(workspace.clone(), generated.clone());
773793
generated
774794
}
775-
TokenState::Initialized(ref tokens) => tokens.clone(),
776795
};
777796

778797
let credentials = token.map(|token| {

crates/uv-auth/src/pyx.rs

Lines changed: 134 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -253,6 +253,15 @@ impl PyxTokenStore {
253253
})
254254
}
255255

256+
/// Extract the workspace name from a pyx Simple API URL.
257+
///
258+
/// Pyx Simple API index URLs have the form `{api}/simple/{workspace}/{view}` (e.g.,
259+
/// `https://api.pyx.dev/simple/acme/main`). Returns `None` if the URL does not match this
260+
/// store's API URL or does not have the expected path shape.
261+
pub fn workspace_for_url<'a>(&self, url: &'a DisplaySafeUrl) -> Option<&'a str> {
262+
workspace_from_simple_url(url, &self.api)
263+
}
264+
256265
/// Return the root directory for the token store.
257266
pub fn root(&self) -> &Path {
258267
&self.root
@@ -271,18 +280,22 @@ impl PyxTokenStore {
271280
///
272281
/// If no access token is found, but an API key is present, the API key will be used to
273282
/// bootstrap an access token.
283+
///
284+
/// `workspace` is the pyx workspace name, required to bootstrap a Trusted Access token. If
285+
/// `None`, Trusted Access bootstrapping is skipped.
274286
pub async fn access_token(
275287
&self,
276288
client: &ClientWithMiddleware,
277289
tolerance_secs: u64,
290+
workspace: Option<&str>,
278291
) -> Result<Option<AccessToken>, TokenStoreError> {
279292
// If the access token is already set in the environment, return it.
280293
if let Some(access_token) = read_pyx_auth_token() {
281294
return Ok(Some(access_token));
282295
}
283296

284297
// Initialize the tokens from the store.
285-
let tokens = self.init(client, tolerance_secs).await?;
298+
let tokens = self.init(client, tolerance_secs, workspace).await?;
286299

287300
// Extract the access token from the OAuth tokens or API key.
288301
Ok(tokens.map(AccessToken::from))
@@ -294,20 +307,26 @@ impl PyxTokenStore {
294307
///
295308
/// If no access token is found, but an API key is present, the API key will be used to
296309
/// bootstrap an access token.
310+
///
311+
/// `workspace` is the pyx workspace name, required to bootstrap a Trusted Access token. If
312+
/// `None`, Trusted Access bootstrapping is skipped.
297313
pub async fn init(
298314
&self,
299315
client: &ClientWithMiddleware,
300316
tolerance_secs: u64,
317+
workspace: Option<&str>,
301318
) -> Result<Option<PyxTokens>, TokenStoreError> {
302319
match self.read().await? {
303320
Some(tokens) => {
304321
// Refresh the tokens if they are expired.
305-
let tokens = self.refresh(tokens, client, tolerance_secs).await?;
322+
let tokens = self
323+
.refresh(tokens, client, tolerance_secs, workspace)
324+
.await?;
306325
Ok(Some(tokens))
307326
}
308327
None => {
309-
// If no tokens are present, bootstrap them from an API key.
310-
self.bootstrap(client).await
328+
// If no tokens are present, bootstrap them from an API key or Trusted Access.
329+
self.bootstrap(client, workspace).await
311330
}
312331
}
313332
}
@@ -412,23 +431,31 @@ impl PyxTokenStore {
412431
}
413432

414433
/// Bootstrap the tokens from an API key or from Trusted Access.
434+
///
435+
/// `workspace` is the pyx workspace name, required to bootstrap a Trusted Access token. If
436+
/// `None`, Trusted Access bootstrapping is skipped.
415437
async fn bootstrap(
416438
&self,
417439
client: &ClientWithMiddleware,
440+
workspace: Option<&str>,
418441
) -> Result<Option<PyxTokens>, TokenStoreError> {
419442
if let Some(api_key) = read_pyx_api_key() {
420443
// If an API key is present, use it to bootstrap the tokens.
421444
self.bootstrap_from_api_key(api_key, client).await.map(Some)
422445
} else {
423446
// Otherwise, attempt to bootstrap from Trusted Access.
424-
self.bootstrap_from_trusted_access(client).await
447+
self.bootstrap_from_trusted_access(client, workspace).await
425448
}
426449
}
427450

428451
/// Bootstrap a [`PyxTokens`] from Trusted Access.
452+
///
453+
/// `workspace` is the pyx workspace name. If `None`, Trusted Access bootstrapping is skipped,
454+
/// since the workspace is required to construct the token endpoint.
429455
async fn bootstrap_from_trusted_access(
430456
&self,
431457
client: &ClientWithMiddleware,
458+
workspace: Option<&str>,
432459
) -> Result<Option<PyxTokens>, TokenStoreError> {
433460
#[derive(Debug, Clone, serde::Serialize)]
434461
struct RequestBody<'a> {
@@ -440,6 +467,12 @@ impl PyxTokenStore {
440467
token: AccessToken,
441468
}
442469

470+
// Trusted Access requires the workspace name to construct the token endpoint.
471+
let Some(workspace) = workspace else {
472+
debug!("Skipping Trusted Access: pyx workspace name is not known");
473+
return Ok(None);
474+
};
475+
443476
// Get an OIDC token from the ambient environment (e.g., GitHub Actions).
444477
let detector = ambient_id::Detector::new_with_client(client.clone());
445478
let Some(id_token) = detector.detect("pyx:trusted-access").await? else {
@@ -449,7 +482,7 @@ impl PyxTokenStore {
449482

450483
// Exchange the OIDC token for a Trusted Access token from pyx.
451484
let mut url = self.api.clone();
452-
url.set_path("v1/trusted-access/TODO/mint-token");
485+
url.set_path(&format!("v1/trusted-access/{workspace}/mint-token"));
453486

454487
let request = client
455488
.request(reqwest::Method::POST, Url::from(url))
@@ -509,6 +542,7 @@ impl PyxTokenStore {
509542
tokens: PyxTokens,
510543
client: &ClientWithMiddleware,
511544
tolerance_secs: u64,
545+
workspace: Option<&str>,
512546
) -> Result<PyxTokens, TokenStoreError> {
513547
let reason = match tokens.check_fresh(tolerance_secs) {
514548
Ok(exp) => {
@@ -589,7 +623,7 @@ impl PyxTokenStore {
589623
}
590624
PyxTokens::TrustedAccess(_) => {
591625
// Refreshing a Trusted Access token is the same as bootstrapping it.
592-
self.bootstrap_from_trusted_access(client)
626+
self.bootstrap_from_trusted_access(client, workspace)
593627
.await?
594628
.ok_or_else(|| {
595629
// This can only happen if we're in an environment that previously
@@ -729,6 +763,30 @@ fn is_known_domain(url: &Url, api: &DisplaySafeUrl, cdn: &str) -> bool {
729763
is_known_url(url, api, cdn)
730764
}
731765

766+
/// Extract the workspace name from a pyx Simple API URL.
767+
///
768+
/// Pyx Simple API URLs have the form `{api}/simple/{workspace}/{view}` (e.g.,
769+
/// `https://api.pyx.dev/simple/acme/main`). Returns the workspace segment when
770+
/// the URL matches the given `api` base URL.
771+
fn workspace_from_simple_url<'a>(url: &'a DisplaySafeUrl, api: &DisplaySafeUrl) -> Option<&'a str> {
772+
// The URL must be on the same host/port as the API.
773+
if url.scheme() != api.scheme()
774+
|| url.host_str() != api.host_str()
775+
|| url.port_or_known_default() != api.port_or_known_default()
776+
{
777+
return None;
778+
}
779+
// Path must be `/simple/{workspace}/{view}[/]`.
780+
let mut segments = url.path_segments()?;
781+
if segments.next()? != "simple" {
782+
return None;
783+
}
784+
let workspace = segments.next().filter(|s| !s.is_empty())?;
785+
// There must be at least a view segment after the workspace.
786+
segments.next().filter(|s| !s.is_empty())?;
787+
Some(workspace)
788+
}
789+
732790
/// Returns `true` if the URL is on the default pyx domain.
733791
///
734792
/// This is used in auth commands to recognize `pyx.dev` as a pyx domain even when
@@ -943,4 +1001,73 @@ mod tests {
9431001
&Url::parse("https://beta.pyx.dev").unwrap()
9441002
));
9451003
}
1004+
1005+
#[test]
1006+
fn test_workspace_from_simple_url() {
1007+
let api = DisplaySafeUrl::parse("https://api.pyx.dev").unwrap();
1008+
1009+
// Standard pyx simple index URL.
1010+
assert_eq!(
1011+
workspace_from_simple_url(
1012+
&DisplaySafeUrl::parse("https://api.pyx.dev/simple/acme/main").unwrap(),
1013+
&api
1014+
),
1015+
Some("acme")
1016+
);
1017+
1018+
// Trailing slash is fine.
1019+
assert_eq!(
1020+
workspace_from_simple_url(
1021+
&DisplaySafeUrl::parse("https://api.pyx.dev/simple/acme/main/").unwrap(),
1022+
&api
1023+
),
1024+
Some("acme")
1025+
);
1026+
1027+
// Must have a view segment after the workspace (bare /simple/acme is not a full index URL).
1028+
assert_eq!(
1029+
workspace_from_simple_url(
1030+
&DisplaySafeUrl::parse("https://api.pyx.dev/simple/acme").unwrap(),
1031+
&api
1032+
),
1033+
None
1034+
);
1035+
1036+
// Non-simple path returns None.
1037+
assert_eq!(
1038+
workspace_from_simple_url(
1039+
&DisplaySafeUrl::parse("https://api.pyx.dev/v1/upload/acme/main").unwrap(),
1040+
&api
1041+
),
1042+
None
1043+
);
1044+
1045+
// Different host returns None.
1046+
assert_eq!(
1047+
workspace_from_simple_url(
1048+
&DisplaySafeUrl::parse("https://other.pyx.dev/simple/acme/main").unwrap(),
1049+
&api
1050+
),
1051+
None
1052+
);
1053+
1054+
// Different scheme returns None.
1055+
assert_eq!(
1056+
workspace_from_simple_url(
1057+
&DisplaySafeUrl::parse("http://api.pyx.dev/simple/acme/main").unwrap(),
1058+
&api
1059+
),
1060+
None
1061+
);
1062+
1063+
// Custom API URL works the same way.
1064+
let custom_api = DisplaySafeUrl::parse("https://staging.example.com").unwrap();
1065+
assert_eq!(
1066+
workspace_from_simple_url(
1067+
&DisplaySafeUrl::parse("https://staging.example.com/simple/myorg/prod").unwrap(),
1068+
&custom_api
1069+
),
1070+
Some("myorg")
1071+
);
1072+
}
9461073
}

crates/uv/src/commands/auth/helper.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ async fn credentials_for_url(
9494
.access_token(
9595
client.for_host(pyx_store.api()).raw_client(),
9696
DEFAULT_TOLERANCE_SECS,
97+
pyx_store.workspace_for_url(url),
9798
)
9899
.await
99100
.context("Authentication failure")?

crates/uv/src/commands/auth/token.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ async fn pyx_refresh(store: &PyxTokenStore, client: &BaseClient, printer: Printe
9191
// Retrieve the token store.
9292
// Use zero tolerance to force a refresh.
9393
let token = match store
94-
.access_token(client.for_host(store.api()).raw_client(), 0)
94+
.access_token(client.for_host(store.api()).raw_client(), 0, None)
9595
.await
9696
{
9797
// If the tokens were successfully refreshed, return them.

0 commit comments

Comments
 (0)