diff --git a/.changeset/auth-device-session-workspace-pin.md b/.changeset/auth-device-session-workspace-pin.md new file mode 100644 index 0000000000..c627b02168 --- /dev/null +++ b/.changeset/auth-device-session-workspace-pin.md @@ -0,0 +1,8 @@ +--- +"@cipherstash/auth": patch +"stash": patch +--- + +`@cipherstash/auth`'s `AutoStrategy` now honours `CS_WORKSPACE_CRN` when you are signed in through the CLI (no access key). The default `@cipherstash/stack` client resolves credentials in `@cipherstash/protect-ffi` and does not pick this up until that package does. Previously the CRN was ignored and the CLI's *current* workspace was used, so switching workspaces with the CLI silently changed which workspace a process authenticated against. Now the session saved for the workspace named by the CRN is used, and every token is checked against that workspace, failing with `WORKSPACE_MISMATCH` otherwise. If no session exists for that workspace, the result is `NOT_AUTHENTICATED`. Without a CRN, behaviour is unchanged. + +The client key (`secretkey.json`) that `stack-encrypt` reads from the CLI profile follows the same workspace, so the token and the client key never come from different ones. diff --git a/languages/typescript/packages/auth/native.d.ts b/languages/typescript/packages/auth/native.d.ts index d38d002410..d798ade1f7 100644 --- a/languages/typescript/packages/auth/native.d.ts +++ b/languages/typescript/packages/auth/native.d.ts @@ -58,7 +58,9 @@ export declare function beginDeviceCodeFlow(region: string, clientId: string): P * * Detection order: * 1. `CS_CLIENT_ACCESS_KEY` env var (or explicit `accessKey` option) → access key auth - * 2. `~/.cipherstash/auth.json` → OAuth token auth + * 2. `~/.cipherstash/auth.json` → OAuth token auth, from the workspace named by + * the workspace CRN (option or `CS_WORKSPACE_CRN`) if set, otherwise the + * CLI's current workspace * 3. Error: not authenticated */ export declare class AutoStrategy { diff --git a/languages/typescript/packages/auth/src/lib.rs b/languages/typescript/packages/auth/src/lib.rs index 67bd19b9c2..3e5dff27bf 100644 --- a/languages/typescript/packages/auth/src/lib.rs +++ b/languages/typescript/packages/auth/src/lib.rs @@ -109,7 +109,9 @@ pub struct AutoStrategyOptions { /// /// Detection order: /// 1. `CS_CLIENT_ACCESS_KEY` env var (or explicit `accessKey` option) → access key auth -/// 2. `~/.cipherstash/auth.json` → OAuth token auth +/// 2. `~/.cipherstash/auth.json` → OAuth token auth, from the workspace named by +/// the workspace CRN (option or `CS_WORKSPACE_CRN`) if set, otherwise the +/// CLI's current workspace /// 3. Error: not authenticated #[napi] pub struct AutoStrategy { diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 8a28f618f7..5e264ef1df 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -24,6 +24,8 @@ use crate::{AuthError, AuthStrategy, ServiceToken}; /// to verify every issued token. /// 2. If a token store file exists at the default location /// (`~/.cipherstash/auth.json`), a [`DeviceSessionStrategy`] is created from it. +/// If a workspace CRN is supplied, the session of that workspace is used +/// (not the CLI's current one) and every token is verified against it. /// 3. Otherwise, [`AuthError::NotAuthenticated`] is returned. /// /// # Examples @@ -93,7 +95,8 @@ impl AutoStrategy { /// /// Resolution order: /// 1. `CS_CLIENT_ACCESS_KEY` env var → [`AccessKeyStrategy`] - /// 2. `~/.cipherstash/auth.json` → [`DeviceSessionStrategy`] + /// 2. `~/.cipherstash/auth.json` → [`DeviceSessionStrategy`] (the workspace + /// named by `CS_WORKSPACE_CRN` if set, otherwise the current one) /// 3. [`AuthError::NotAuthenticated`] pub fn detect() -> Result { Self::builder().detect() @@ -123,16 +126,22 @@ impl AutoStrategy { return Ok(Self::AccessKey(strategy)); } - // 2. OAuth token from disk (in the current workspace directory) + // 2. OAuth token from disk. A supplied CRN pins the workspace, so the + // CLI's current workspace is irrelevant; without one, use it. if let Some(store) = store { - let has_token = store - .current_workspace_store() + let ws_store = match &crn { + Some(crn) => store.workspace_store(crn.workspace_id.as_str()), + None => store.current_workspace_store(), + }; + let has_token = ws_store .map(|ws| ws.exists_profile::()) .unwrap_or(false); if has_token { - let strategy = DeviceSessionStrategy::with_profile(store) - .maybe_transport(transport) - .build()?; + let mut builder = DeviceSessionStrategy::with_profile(store); + if let Some(crn) = &crn { + builder = builder.workspace_crn(crn); + } + let strategy = builder.maybe_transport(transport).build()?; return Ok(Self::DeviceSession(strategy)); } } @@ -365,6 +374,43 @@ mod tests { assert!(matches!(result.unwrap(), AutoStrategy::DeviceSession(_))); } + #[test] + #[cfg(feature = "http")] // builds a strategy with no transport of its own + fn crn_selects_that_workspaces_session_over_the_current_one() { + let dir = tempfile::tempdir().unwrap(); + let store = write_token_store(dir.path()); + // The CLI user switches to another workspace with no session. + store.init_workspace("AAAAAAAAAAAAAAAA").unwrap(); + + let result = AutoStrategy::detect_inner(None, Some(valid_crn()), Some(store), None); + + assert!(matches!(result, Ok(AutoStrategy::DeviceSession(_)))); + } + + #[test] + #[cfg(feature = "http")] // builds a strategy with no transport of its own + fn crn_matching_the_current_workspace_builds_a_device_session() { + let dir = tempfile::tempdir().unwrap(); + let store = write_token_store(dir.path()); + + let result = AutoStrategy::detect_inner(None, Some(valid_crn()), Some(store), None); + + assert!(matches!(result, Ok(AutoStrategy::DeviceSession(_)))); + } + + #[test] + #[cfg(feature = "http")] // saves a token, so the fallback would build a strategy + fn crn_without_a_session_for_that_workspace_is_not_authenticated() { + let dir = tempfile::tempdir().unwrap(); + // The current workspace has a token; the CRN names another. + let store = write_token_store(dir.path()); + let crn: Crn = "crn:ap-southeast-2.aws:AAAAAAAAAAAAAAAA".parse().unwrap(); + + let result = AutoStrategy::detect_inner(None, Some(crn), Some(store), None); + + assert!(matches!(result, Err(AuthError::NotAuthenticated(_)))); + } + #[test] fn oauth_store_without_token_file_returns_not_authenticated() { let dir = tempfile::tempdir().unwrap(); diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index 225de57359..b699bcc5e0 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -1,3 +1,4 @@ +use cts_common::WorkspaceId; use url::Url; use stack_profile::ProfileStore; @@ -20,6 +21,14 @@ pub(crate) struct DeviceSessionRefresher { region: String, device_instance_id: Option, transport: SharedTransport, + /// When set, a refreshed token for any other workspace is refused before + /// it is cached or written to the store. + expected_workspace: Option, + /// Set once an exchange consumed this session's refresh credential and + /// returned a token we refused. The credential cannot be replayed (the + /// issuer would revoke the chain), so every later refresh through this + /// refresher asks for a new sign-in instead. + credential_consumed: std::sync::atomic::AtomicBool, } impl DeviceSessionRefresher { @@ -38,8 +47,45 @@ impl DeviceSessionRefresher { region: region.into(), device_instance_id, transport, + expected_workspace: None, + credential_consumed: std::sync::atomic::AtomicBool::new(false), } } + + /// Refuse, before it is cached or saved, any refreshed token that is not + /// for `workspace`. Otherwise a session for another workspace would be + /// written over this profile's, where a sibling process would pick it up. + pub(crate) fn pinned_to(mut self, workspace: Option) -> Self { + self.expected_workspace = workspace; + self + } + + fn check_workspace(&self, token: &Token) -> Result<(), AuthError> { + ensure_workspace(token, self.expected_workspace) + } +} + +/// Refuse `token` unless it is for `expected` (no pin: accept it). Used on +/// the session a strategy starts from as well as on refreshed tokens, so a +/// session for another workspace never reaches `AutoRefresh`, whose refresh +/// would consume that session's credential. +pub(crate) fn ensure_workspace( + token: &Token, + expected: Option, +) -> Result<(), AuthError> { + let Some(expected) = expected else { + return Ok(()); + }; + let token_workspace = token.workspace_id()?; + if token_workspace != expected { + return Err(AuthError::WorkspaceMismatch( + crate::error::WorkspaceMismatch { + expected_workspace: expected, + token_workspace, + }, + )); + } + Ok(()) } impl Refresher for DeviceSessionRefresher { @@ -63,6 +109,10 @@ impl Refresher for DeviceSessionRefresher { } async fn refresh(&self, credential: &Self::Credential) -> Result { + use std::sync::atomic::Ordering; + if self.credential_consumed.load(Ordering::SeqCst) { + return Err(AuthError::NotAuthenticated(crate::error::NotAuthenticated)); + } // Cross-process refresh lock: only one process should be exchanging // a refresh token with the upstream IdP at a time. Without this, // two CLI invocations sharing `~/.cipherstash` both load the same @@ -88,6 +138,7 @@ impl Refresher for DeviceSessionRefresher { tracing::debug!( "refresh skipped: another process rotated the token while we waited on the lock" ); + self.check_workspace(&disk_token)?; return Ok(disk_token); } @@ -105,6 +156,32 @@ impl Refresher for DeviceSessionRefresher { token.set_device_instance_id(id); } + // Before anything is saved or cached: a rejected token must leave + // this profile's saved session alone. + if let Err(err) = self.check_workspace(&token) { + // The saved session is left alone, unless the issuer rotated the + // credential: then the saved copy is spent, and a retry (here or + // in another process) would replay it and get the whole chain + // revoked. Drop it so the next attempt asks for a sign-in. + let rotated = token + .refresh_token() + .is_some_and(|new| new.as_str() != credential.as_str()); + if rotated { + self.credential_consumed + .store(true, std::sync::atomic::Ordering::SeqCst); + if let Some(store) = &self.store { + // A spent credential left on disk can be replayed by the + // next process, so a failure to clear it is the error the + // caller sees, not the mismatch. + store.clear_profile::().map_err(|clear_err| { + tracing::error!(%clear_err, "failed to clear a consumed session"); + AuthError::from(clear_err) + })?; + } + } + return Err(err); + } + // Persist while holding the lock — any sibling process waiting on // the lock will read the rotated token on their next attempt and // skip burning their stale credential. @@ -346,6 +423,177 @@ mod tests { ); } + /// A refresh that returns another workspace's token must be refused before + /// anything is saved: the profile's own session has to survive intact. The + /// issuer did not rotate the credential here, so the saved one is still good. + #[tokio::test] + async fn a_refreshed_token_for_another_workspace_is_refused_and_not_saved() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(serde_json::json!({ + "access_token": crate::test_support::jwt_with_workspace("AAAAAAAAAAAAAAAA"), + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "matching-refresh" + })); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + + let disk = token_on_disk("own-access", "matching-refresh"); + let refresher = refresher_with_disk_token(&dir, server.url(""), disk) + .pinned_to(Some(WorkspaceId::try_from(WORKSPACE_ID).unwrap())); + + let err = refresher + .refresh(&SecretToken::new("matching-refresh")) + .await + .unwrap_err(); + assert!( + matches!(err, AuthError::WorkspaceMismatch(_)), + "a token for another workspace must be refused, got {err:?}" + ); + + let on_disk: Token = ProfileStore::new(dir.path()) + .workspace_store(WORKSPACE_ID) + .unwrap() + .load_profile() + .unwrap(); + assert_eq!( + on_disk.access_token().as_str(), + "own-access", + "the saved session must not be overwritten by the rejected token" + ); + assert_eq!( + on_disk.refresh_token().unwrap().as_str(), + "matching-refresh", + "the saved refresh token must be untouched" + ); + } + + /// The issuer rotated the credential and returned another workspace's + /// token. The saved credential is spent, so neither a retry on this + /// refresher nor a reopened profile may replay it: the issuer must see + /// exactly one exchange and every retry must ask for a new sign-in. + #[tokio::test] + async fn a_rotated_credential_with_a_wrong_workspace_token_is_not_replayed() { + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::Arc; + + let counter = Arc::new(AtomicUsize::new(0)); + let counter_clone = Arc::clone(&counter); + let wrong_token = crate::test_support::jwt_with_workspace("AAAAAAAAAAAAAAAA"); + let app = axum::Router::new().route( + "/oauth/token", + axum::routing::post(move || { + let counter = Arc::clone(&counter_clone); + let wrong_token = wrong_token.clone(); + async move { + counter.fetch_add(1, Ordering::SeqCst); + axum::Json(serde_json::json!({ + "access_token": wrong_token, + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rotated-refresh" + })) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + }); + let base_url = Url::parse(&format!("http://{addr}")).unwrap(); + + let dir = tempfile::tempdir().unwrap(); + let refresher = + refresher_with_disk_token(&dir, base_url, token_on_disk("own-access", "spent-refresh")) + .pinned_to(Some(WorkspaceId::try_from(WORKSPACE_ID).unwrap())); + let credential = SecretToken::new("spent-refresh"); + + let first = refresher.refresh(&credential).await.unwrap_err(); + assert!( + matches!(first, AuthError::WorkspaceMismatch(_)), + "the rejected exchange reports the mismatch, got {first:?}" + ); + + // Retry on the same refresher: no second exchange. + let retry = refresher.refresh(&credential).await.unwrap_err(); + assert!( + matches!(retry, AuthError::NotAuthenticated(_)), + "a retry must ask for a new sign-in, got {retry:?}" + ); + + // Reopen the profile: the spent session is gone. + let reopened = ProfileStore::new(dir.path()) + .workspace_store(WORKSPACE_ID) + .unwrap() + .load_profile::(); + assert!( + reopened.is_err(), + "the spent session must not be loadable again" + ); + + assert_eq!( + counter.load(Ordering::SeqCst), + 1, + "the issuer must see exactly one exchange" + ); + } + + /// If the spent session cannot be removed, the caller must hear that, not + /// only the mismatch: the credential is still on disk to be replayed. + #[cfg(unix)] + #[tokio::test] + async fn a_spent_session_that_cannot_be_cleared_is_reported() { + let mut mocks = MockSet::new(); + mocks.mock(|when, then| { + when.post().path("/oauth/token"); + then.json(serde_json::json!({ + "access_token": crate::test_support::jwt_with_workspace("AAAAAAAAAAAAAAAA"), + "token_type": "Bearer", + "expires_in": 3600, + "refresh_token": "rotated-refresh" + })); + }); + let server = start_server(mocks).await; + let dir = tempfile::tempdir().unwrap(); + let refresher = refresher_with_disk_token( + &dir, + server.url(""), + token_on_disk("own-access", "spent-refresh"), + ) + .pinned_to(Some(WorkspaceId::try_from(WORKSPACE_ID).unwrap())); + + // A directory where the profile file should be: unlink fails. + fn find(dir: &std::path::Path) -> Option { + for entry in std::fs::read_dir(dir).ok()? { + let path = entry.ok()?.path(); + if path.is_dir() { + if let Some(found) = find(&path) { + return Some(found); + } + } else if path.file_name().is_some_and(|n| n == Token::FILENAME) { + return Some(path); + } + } + None + } + let profile = find(dir.path()).expect("the saved profile"); + std::fs::remove_file(&profile).unwrap(); + std::fs::create_dir(&profile).unwrap(); + + let err = refresher + .refresh(&SecretToken::new("spent-refresh")) + .await + .unwrap_err(); + assert!( + !matches!(err, AuthError::WorkspaceMismatch(_)), + "the clear failure must surface, got {err:?}" + ); + } + /// The refresh response does not echo the device instance (CIP-2793), so /// a device-bound refresher re-attaches it: the next refresh has to /// present the same instance, and it reads it from this token. diff --git a/packages/stack-auth/src/device_session_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs index 8596da179c..eee2704a7e 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -1,10 +1,10 @@ -use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; +use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery, WorkspaceId}; use tracing::warn; use stack_profile::ProfileStore; use crate::auto_refresh::AutoRefresh; -use crate::device_session_refresher::DeviceSessionRefresher; +use crate::device_session_refresher::{ensure_workspace, DeviceSessionRefresher}; use crate::transport::{self, SharedTransport}; use crate::{ensure_trailing_slash, AuthError, AuthStrategy, HttpTransport, ServiceToken, Token}; @@ -35,6 +35,8 @@ use crate::{ensure_trailing_slash, AuthError, AuthStrategy, HttpTransport, Servi /// ``` pub struct DeviceSessionStrategy { crn: Option, + /// When set, every token is checked against this workspace. + expected_workspace: Option, inner: AutoRefresh, } @@ -56,6 +58,7 @@ impl DeviceSessionStrategy { }, base_url_override: None, transport: None, + expected_workspace: None, } } @@ -72,6 +75,7 @@ impl DeviceSessionStrategy { source: OAuthTokenSource::Store(store), base_url_override: None, transport: None, + expected_workspace: None, } } @@ -85,6 +89,7 @@ impl DeviceSessionStrategy { source: OAuthTokenSource::WorkspaceStore(store), base_url_override: None, transport: None, + expected_workspace: None, } } @@ -96,7 +101,11 @@ impl DeviceSessionStrategy { impl AuthStrategy for &DeviceSessionStrategy { async fn get_token(self) -> Result { - Ok(self.inner.get_token().await?) + let token = self.inner.get_token().await?; + match self.expected_workspace { + Some(expected) => token.verify_workspace(expected), + None => Ok(token), + } } } @@ -120,9 +129,27 @@ pub struct DeviceSessionStrategyBuilder { source: OAuthTokenSource, base_url_override: Option, transport: Option, + expected_workspace: Option, } impl DeviceSessionStrategyBuilder { + /// Pin the strategy to the workspace named by `crn`. + /// + /// Every token returned by `get_token` is checked against the CRN's + /// workspace, failing with [`AuthError::WorkspaceMismatch`] if it belongs + /// to another one — as [`AccessKeyStrategy`](crate::AccessKeyStrategy) + /// does. Only the workspace ID is compared, not the region. + /// + /// For a strategy built with [`with_profile`](DeviceSessionStrategy::with_profile), + /// the token is also loaded from that workspace's directory rather than + /// from the store's *current* workspace, so a CLI user who has switched + /// workspaces does not change which one this process uses. The other + /// sources are already a specific workspace's, so the pin only verifies. + pub fn workspace_crn(mut self, crn: &Crn) -> Self { + self.expected_workspace = Some(crn.workspace_id); + self + } + /// Send this strategy's requests through `transport` instead of the /// bundled `reqwest` client. /// @@ -168,6 +195,7 @@ impl DeviceSessionStrategyBuilder { source, base_url_override, transport, + expected_workspace, } = self; let transport = transport::resolve(transport)?; match source { @@ -175,14 +203,32 @@ impl DeviceSessionStrategyBuilder { region, client_id, token, - } => Self::build_from_token(region, client_id, token, base_url_override, transport), + } => Self::build_from_token( + region, + client_id, + token, + base_url_override, + transport, + expected_workspace, + ), OAuthTokenSource::Store(store) => { - let ws_store = store.current_workspace_store()?; - Self::build_from_workspace_store(ws_store, base_url_override, transport) - } - OAuthTokenSource::WorkspaceStore(store) => { - Self::build_from_workspace_store(store, base_url_override, transport) + let ws_store = match expected_workspace { + Some(ws) => store.workspace_store(ws.as_str())?, + None => store.current_workspace_store()?, + }; + Self::build_from_workspace_store( + ws_store, + base_url_override, + transport, + expected_workspace, + ) } + OAuthTokenSource::WorkspaceStore(store) => Self::build_from_workspace_store( + store, + base_url_override, + transport, + expected_workspace, + ), } } @@ -193,6 +239,7 @@ impl DeviceSessionStrategyBuilder { mut token: Token, base_url_override: Option, transport: SharedTransport, + expected_workspace: Option, ) -> Result { let base_url = match base_url_override { Some(url) => url, @@ -211,6 +258,7 @@ impl DeviceSessionStrategyBuilder { e }) .ok(); + ensure_workspace(&token, expected_workspace)?; let region_id = region.identifier(); let device_instance_id = token.device_instance_id().map(String::from); token.set_region(®ion_id); @@ -222,9 +270,11 @@ impl DeviceSessionStrategyBuilder { ®ion_id, device_instance_id, transport, - ); + ) + .pinned_to(expected_workspace); Ok(DeviceSessionStrategy { crn, + expected_workspace, inner: AutoRefresh::with_token(refresher, token), }) } @@ -234,8 +284,12 @@ impl DeviceSessionStrategyBuilder { ws_store: ProfileStore, base_url_override: Option, transport: SharedTransport, + expected_workspace: Option, ) -> Result { let token: Token = ws_store.load_profile()?; + // Before `AutoRefresh` can see it: refreshing another workspace's + // session would consume that session's refresh credential. + ensure_workspace(&token, expected_workspace)?; let region_str = token .region() @@ -266,9 +320,11 @@ impl DeviceSessionStrategyBuilder { ®ion_str, device_instance_id, transport, - ); + ) + .pinned_to(expected_workspace); Ok(DeviceSessionStrategy { crn, + expected_workspace, inner: AutoRefresh::with_token(refresher, token), }) } @@ -324,4 +380,49 @@ mod tests { "CRN should be None when the token has no decodable workspace claim" ); } + + /// A session for another workspace is refused when the strategy is built, + /// before `AutoRefresh` exists: a near-expiry one would otherwise be + /// refreshed on the first `get_token`, consuming a credential that + /// belongs to someone else. + #[test] + fn build_rejects_a_session_from_another_workspace() { + let region = Region::aws("ap-southeast-2").expect("valid region"); + let token = jwt_token(claims_with_workspace("7366ITCXSAPCH5TN")); + let other: Crn = "crn:ap-southeast-2.aws:ZVATKW3VHMFG27DY" + .parse() + .expect("valid crn"); + + // The base URL is unreachable: nothing may be sent. + let result = DeviceSessionStrategy::with_token(region, "my-client", token) + .base_url(base_url()) + .workspace_crn(&other) + .build(); + + assert!( + matches!(result, Err(AuthError::WorkspaceMismatch(_))), + "a session for another workspace must be refused at build, got {:?}", + result.err() + ); + } + + #[tokio::test] + async fn get_token_accepts_a_token_from_the_pinned_workspace() { + let region = Region::aws("ap-southeast-2").expect("valid region"); + let token = jwt_token(claims_with_workspace("7366ITCXSAPCH5TN")); + let same: Crn = "crn:ap-southeast-2.aws:7366ITCXSAPCH5TN" + .parse() + .expect("valid crn"); + + let strategy = DeviceSessionStrategy::with_token(region, "my-client", token) + .base_url(base_url()) + .workspace_crn(&same) + .build() + .expect("build should succeed"); + + (&strategy) + .get_token() + .await + .expect("matching workspace should pass"); + } } diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 46103500c4..e83b9031cf 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -641,15 +641,25 @@ async fn load_default_keyset(kms: &K) -> Result FallbackKeyProvider { + // An unparseable CRN is left for `AutoStrategy` to report; here it just + // means no workspace pin. + let workspace = std::env::var("CS_WORKSPACE_CRN") + .ok() + .and_then(|crn| crn.parse::().ok()) + .map(|crn| crn.workspace_id.to_string()); FallbackKeyProvider::new( EnvKeyProvider, - ProfileClientKey(ProfileStore::resolve(None).ok()), + ProfileClientKey { + store: ProfileStore::resolve(None).ok(), + workspace, + }, ) } @@ -663,14 +673,33 @@ fn client_key_provider() -> EnvKeyProvider { /// directory so the "not configured" message can say what to do about it /// rather than only that `CS_CLIENT_ID` is unset. #[cfg(all(feature = "http", not(target_arch = "wasm32")))] -struct ProfileClientKey(Option); +struct ProfileClientKey { + store: Option, + /// The workspace to read `secretkey.json` from, so the client key and the + /// access token come from the same one. `None` means the CLI's current. + workspace: Option, +} #[cfg(all(feature = "http", not(target_arch = "wasm32")))] impl KeyProvider for ProfileClientKey { async fn client_key(&self) -> Result { - match &self.0 { - Some(store) => store.client_key().await, - None => Err(KeyProviderError::NotConfigured( + match (&self.store, &self.workspace) { + (Some(store), Some(workspace)) => { + let ws_store = store + .workspace_store(workspace) + .map_err(|e| KeyProviderError::LoadError(e.to_string()))?; + if !ws_store.exists_profile::() { + return Err(KeyProviderError::NotConfigured(format!( + "no client key for workspace {workspace}: set CS_CLIENT_ID / CS_CLIENT_KEY, or run `npx stash auth login` for that workspace" + ))); + } + let secret_key: stack_kms::SecretKey = ws_store + .load_profile() + .map_err(|e| KeyProviderError::LoadError(e.to_string()))?; + secret_key.client_key().await + } + (Some(store), None) => store.client_key().await, + (None, _) => Err(KeyProviderError::NotConfigured( "no client key: set CS_CLIENT_ID / CS_CLIENT_KEY, or run `npx stash auth login`" .into(), )), @@ -757,9 +786,9 @@ impl StackCipherBuilder { /// /// 1. the environment (`CS_CLIENT_ACCESS_KEY` + `CS_WORKSPACE_CRN`; /// `CS_CLIENT_ID` + `CS_CLIENT_KEY`), then - /// 2. the current workspace in the CLI's profile directory - /// (`auth.json`; `secretkey.json`), which `npx stash auth login` - /// writes. + /// 2. the CLI's profile directory (`auth.json`; `secretkey.json`), which + /// `npx stash auth login` writes: the workspace `CS_WORKSPACE_CRN` + /// names if it is set (for both files), otherwise the current one. /// /// So on a developer machine, logging in with the CLI is sufficient; in /// CI, the four variables are. @@ -2053,6 +2082,84 @@ impl<'c, 'a> MapAccess<'c> for StackMapAccess<'a> { } } +#[cfg(all(test, feature = "http", not(target_arch = "wasm32")))] +mod profile_client_key_tests { + use super::*; + + const CURRENT: &str = "ZVATKW3VHMFG27DY"; + const PINNED: &str = "AAAAAAAAAAAAAAAA"; + + /// A temporary profile directory, removed when dropped. + struct TempDir(std::path::PathBuf); + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A profile directory whose *current* workspace has a (not usable as a + /// ZeroKMS key, but present) client key and whose pinned one has none. + fn store_with_key_in_current_workspace() -> (TempDir, ProfileStore) { + let dir = TempDir(std::env::temp_dir().join(format!("stack-encrypt-{}", Uuid::new_v4()))); + let store = ProfileStore::new(&dir.0); + store.init_workspace(PINNED).unwrap(); + store.init_workspace(CURRENT).unwrap(); // now current + let key = + stack_kms::SecretKey::from_hex(Uuid::new_v4().to_string(), "00".repeat(32)).unwrap(); + store + .current_workspace_store() + .unwrap() + .save_profile(&key) + .unwrap(); + (dir, store) + } + + #[test] + fn the_temp_profile_directory_is_removed_when_dropped() { + let (dir, _store) = store_with_key_in_current_workspace(); + let path = dir.0.clone(); + assert!(path.exists()); + + drop(dir); + + assert!(!path.exists(), "temp profile directory was left behind"); + } + + #[tokio::test] + async fn a_pinned_workspace_never_borrows_the_current_workspaces_client_key() { + let (_dir, store) = store_with_key_in_current_workspace(); + let provider = ProfileClientKey { + store: Some(store), + workspace: Some(PINNED.to_string()), + }; + + let err = provider.client_key().await.map(|_| ()).unwrap_err(); + + assert!( + matches!(err, KeyProviderError::NotConfigured(_)), + "expected NotConfigured for the pinned workspace, got {err:?}" + ); + } + + #[tokio::test] + async fn without_a_pin_the_current_workspaces_client_key_is_used() { + let (_dir, store) = store_with_key_in_current_workspace(); + let provider = ProfileClientKey { + store: Some(store), + workspace: None, + }; + + // The key is found (so not `NotConfigured`); it is just not a real one. + let err = provider.client_key().await.map(|_| ()).unwrap_err(); + + assert!( + !matches!(err, KeyProviderError::NotConfigured(_)), + "the current workspace's key should have been found, got {err:?}" + ); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/skills/stash-auth/SKILL.md b/skills/stash-auth/SKILL.md index 0fb7f2edf9..76e09fe7c8 100644 --- a/skills/stash-auth/SKILL.md +++ b/skills/stash-auth/SKILL.md @@ -159,7 +159,7 @@ yourself. | Strategy | Use for | Credential source | |---|---|---| -| `AutoStrategy` (the default when `config.authStrategy` is unset) | Most apps | `CS_CLIENT_ACCESS_KEY` **and** `CS_WORKSPACE_CRN` env vars, else the dev profile (`~/.cipherstash/auth.json`), else fails `NOT_AUTHENTICATED` | +| `AutoStrategy` (the default when `config.authStrategy` is unset) | Most apps | `CS_CLIENT_ACCESS_KEY` **and** `CS_WORKSPACE_CRN` env vars, else the dev profile (`~/.cipherstash/auth.json`) — the session of the workspace named by `CS_WORKSPACE_CRN` if set (tokens are checked against it, `WORKSPACE_MISMATCH` otherwise), else the CLI's current workspace's — else fails `NOT_AUTHENTICATED`. **That workspace pin is in `@cipherstash/auth`'s `AutoStrategy` (pass it as `config.authStrategy`); the default client built without `config.authStrategy` resolves credentials inside `@cipherstash/protect-ffi`, which still follows the CLI's *current* workspace — for the token and for the client key it reads from the profile — until it picks the pin up. Pass `config.authStrategy` and keep the CLI's current workspace equal to `CS_WORKSPACE_CRN` until then** | | `AccessKeyStrategy` | Services, CI, backfill jobs | Explicit workspace CRN + access key | | `OidcFederationStrategy` | Per-user (identity-bound) encryption | Your IdP's JWT, fetched via a callback on every operation — one CTS token cached per distinct JWT (see Client lifetime) | | `DeviceSessionStrategy` | CLI-adjacent tooling | The device-code session `stash auth login` created | @@ -217,9 +217,9 @@ pass `.data`. | `failure.type` | Meaning / fix | |---|---| -| `NOT_AUTHENTICATED` | No credentials found — set the `CS_*` variables, or run `npx stash auth login` for a dev profile | +| `NOT_AUTHENTICATED` | No credentials found — set the `CS_*` variables, or run `npx stash auth login` for a dev profile. With `@cipherstash/auth`'s `AutoStrategy` and `CS_WORKSPACE_CRN` set, the dev profile must be logged in to *that* workspace (the CLI's current workspace doesn't count): log in to it, or unset `CS_WORKSPACE_CRN`. The default client (no `config.authStrategy`) uses the CLI's current workspace instead — make the intended workspace current | | `MISSING_WORKSPACE_CRN` | `CS_CLIENT_ACCESS_KEY` is set but `CS_WORKSPACE_CRN` isn't — the access-key path needs both | -| `WORKSPACE_MISMATCH` (`expected` / `actual`) | The access key belongs to a different workspace than the CRN — you've mixed environments' credentials | +| `WORKSPACE_MISMATCH` (`expected` / `actual`) | The access key (or dev-profile session) belongs to a different workspace than the CRN — you've mixed environments' credentials | | `INVALID_ACCESS_KEY` / `INVALID_CRN` | Malformed values — check for truncation/quoting in the secret store | | `EXPIRED_TOKEN` / `INVALID_GRANT` | The underlying session/JWT expired — for `OidcFederationStrategy`, check that `getJwt` returns a *fresh* token | | `ACCESS_DENIED` | CTS refused the exchange — membership, provider config, or (as an HTTP 402) billing | @@ -399,12 +399,14 @@ client-side strategy model applies to apps behind the Proxy. ## Quick diagnosis 1. **`NOT_AUTHENTICATED` locally** → `npx stash auth login` (dev profile), - or export the four `CS_*` variables. + or export the four `CS_*` variables. With `@cipherstash/auth`'s + `AutoStrategy` and `CS_WORKSPACE_CRN` set, log in to that workspace, or + unset it; the default client follows the CLI's current workspace. 2. **Works locally, 401 in CI/production** → the deployed environment lacks the `CS_*` variables, or they're from the wrong workspace — mint with `stash env`, compare `CS_WORKSPACE_CRN`. -3. **`WORKSPACE_MISMATCH`** → the access key and the CRN name different - workspaces; re-mint rather than mix-and-match. +3. **`WORKSPACE_MISMATCH`** → the access key (or dev-profile session) and + the CRN name different workspaces; re-mint rather than mix-and-match. 4. **`No OIDC provider found for issuer`** → add your IdP at dashboard.cipherstash.com → workspace → OIDC providers, and check the issuer URL matches the JWT's `iss` exactly.