From db76cf4f451b465628e8bc1bf58db6527a479a53 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:39:09 +0000 Subject: [PATCH 01/12] fix(stack-auth): honour CS_WORKSPACE_CRN on the device-session path AutoStrategy dropped the CRN unless an access key was present, so a CLI user's current workspace decided which workspace a process used. Select the CRN's workspace store and verify every token against it. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- .../auth-device-session-workspace-pin.md | 5 ++ packages/stack-auth/src/auto_strategy.rs | 48 +++++++++-- .../stack-auth/src/device_session_strategy.rs | 84 ++++++++++++++++++- 3 files changed, 127 insertions(+), 10 deletions(-) create mode 100644 .changeset/auth-device-session-workspace-pin.md diff --git a/.changeset/auth-device-session-workspace-pin.md b/.changeset/auth-device-session-workspace-pin.md new file mode 100644 index 0000000000..216e3e44bc --- /dev/null +++ b/.changeset/auth-device-session-workspace-pin.md @@ -0,0 +1,5 @@ +--- +"@cipherstash/auth": patch +--- + +`CS_WORKSPACE_CRN` is now honoured when you are signed in through the CLI (no access key). 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. diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 8a28f618f7..a900aad217 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,31 @@ 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] + fn crn_without_a_session_for_that_workspace_is_not_authenticated() { + let dir = tempfile::tempdir().unwrap(); + let store = ProfileStore::new(dir.path()); + store.init_workspace("AAAAAAAAAAAAAAAA").unwrap(); + + // Current workspace has a token, but the CRN names another. + let result = AutoStrategy::detect_inner(None, Some(valid_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_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs index 8596da179c..92418b7163 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -1,4 +1,4 @@ -use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery}; +use cts_common::{Crn, CtsServiceDiscovery, Region, ServiceDiscovery, WorkspaceId}; use tracing::warn; use stack_profile::ProfileStore; @@ -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,24 @@ 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`. + /// + /// For a strategy built from a profile store, the token is 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. Every token returned by `get_token` is + /// also checked against the workspace, failing with + /// [`AuthError::WorkspaceMismatch`] if it belongs to another one — as + /// [`AccessKeyStrategy`](crate::AccessKeyStrategy) does. + 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 +192,7 @@ impl DeviceSessionStrategyBuilder { source, base_url_override, transport, + expected_workspace, } = self; let transport = transport::resolve(transport)?; match source { @@ -177,13 +202,20 @@ impl DeviceSessionStrategyBuilder { token, } => Self::build_from_token(region, client_id, token, base_url_override, transport), OAuthTokenSource::Store(store) => { - let ws_store = store.current_workspace_store()?; + 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) } OAuthTokenSource::WorkspaceStore(store) => { Self::build_from_workspace_store(store, base_url_override, transport) } } + .map(|mut strategy| { + strategy.expected_workspace = expected_workspace; + strategy + }) } /// Build from a token supplied directly (in-memory only, no store). @@ -225,6 +257,7 @@ impl DeviceSessionStrategyBuilder { ); Ok(DeviceSessionStrategy { crn, + expected_workspace: None, inner: AutoRefresh::with_token(refresher, token), }) } @@ -269,6 +302,7 @@ impl DeviceSessionStrategyBuilder { ); Ok(DeviceSessionStrategy { crn, + expected_workspace: None, inner: AutoRefresh::with_token(refresher, token), }) } @@ -324,4 +358,48 @@ mod tests { "CRN should be None when the token has no decodable workspace claim" ); } + + #[tokio::test] + async fn get_token_rejects_a_token_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"); + + let strategy = DeviceSessionStrategy::with_token(region, "my-client", token) + .base_url(base_url()) + .workspace_crn(&other) + .build() + .expect("build should succeed"); + + let err = (&strategy) + .get_token() + .await + .expect_err("a token for another workspace must be rejected"); + assert!( + matches!(err, AuthError::WorkspaceMismatch(_)), + "expected WorkspaceMismatch, got {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"); + } } From 4024cbc3b422526a237d8c0a4031ab763f7c7b0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:40:27 +0000 Subject: [PATCH 02/12] fix(stack-auth): address review of CRN pinning; update stash-auth skill Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- .../auth-device-session-workspace-pin.md | 1 + packages/stack-auth/src/auto_strategy.rs | 19 +++++++++++++++++++ .../stack-auth/src/device_session_strategy.rs | 17 ++++++++++------- skills/stash-auth/SKILL.md | 2 +- 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/.changeset/auth-device-session-workspace-pin.md b/.changeset/auth-device-session-workspace-pin.md index 216e3e44bc..99cd2d4913 100644 --- a/.changeset/auth-device-session-workspace-pin.md +++ b/.changeset/auth-device-session-workspace-pin.md @@ -1,5 +1,6 @@ --- "@cipherstash/auth": patch +"stash": patch --- `CS_WORKSPACE_CRN` is now honoured when you are signed in through the CLI (no access key). 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. diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index a900aad217..7a44d3ca22 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -136,6 +136,14 @@ impl AutoStrategy { let has_token = ws_store .map(|ws| ws.exists_profile::()) .unwrap_or(false); + if !has_token { + if let Some(crn) = &crn { + tracing::info!( + workspace = %crn.workspace_id, + "no saved session for the workspace named by the CRN" + ); + } + } if has_token { let mut builder = DeviceSessionStrategy::with_profile(store); if let Some(crn) = &crn { @@ -387,6 +395,17 @@ mod tests { 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] fn crn_without_a_session_for_that_workspace_is_not_authenticated() { let dir = tempfile::tempdir().unwrap(); diff --git a/packages/stack-auth/src/device_session_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs index 92418b7163..d612aed305 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -135,13 +135,16 @@ pub struct DeviceSessionStrategyBuilder { impl DeviceSessionStrategyBuilder { /// Pin the strategy to the workspace named by `crn`. /// - /// For a strategy built from a profile store, the token is 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. Every token returned by `get_token` is - /// also checked against the workspace, failing with - /// [`AuthError::WorkspaceMismatch`] if it belongs to another one — as - /// [`AccessKeyStrategy`](crate::AccessKeyStrategy) does. + /// 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 diff --git a/skills/stash-auth/SKILL.md b/skills/stash-auth/SKILL.md index 0fb7f2edf9..0de08178b5 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 (never the CLI's current workspace; tokens are checked against it, `WORKSPACE_MISMATCH` otherwise), else the current workspace's — else fails `NOT_AUTHENTICATED` | | `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 | From 3fd7766ba085df36ca5c52dc2338259786302a7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 10:43:09 +0000 Subject: [PATCH 03/12] refactor(stack-auth): drop untestable log branch flagged by mutation testing Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- packages/stack-auth/src/auto_strategy.rs | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index 7a44d3ca22..12330d19b6 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -136,14 +136,6 @@ impl AutoStrategy { let has_token = ws_store .map(|ws| ws.exists_profile::()) .unwrap_or(false); - if !has_token { - if let Some(crn) = &crn { - tracing::info!( - workspace = %crn.workspace_id, - "no saved session for the workspace named by the CRN" - ); - } - } if has_token { let mut builder = DeviceSessionStrategy::with_profile(store); if let Some(crn) = &crn { From 77f9fefeb1ed2cf08e66eb3cdc60ec51aeb9830c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 01:02:39 +0000 Subject: [PATCH 04/12] fix(stack-encrypt): read the client key from the CRN's workspace too The token now follows CS_WORKSPACE_CRN, so the profile client key must as well or ZeroKMS gets one workspace's token with another's key. Also fix the no-session test, and update skill and napi docs. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- .../auth-device-session-workspace-pin.md | 2 + .../typescript/packages/auth/native.d.ts | 4 +- languages/typescript/packages/auth/src/lib.rs | 4 +- packages/stack-auth/src/auto_strategy.rs | 9 +- packages/stack-encrypt/src/cipher.rs | 105 ++++++++++++++++-- skills/stash-auth/SKILL.md | 11 +- 6 files changed, 115 insertions(+), 20 deletions(-) diff --git a/.changeset/auth-device-session-workspace-pin.md b/.changeset/auth-device-session-workspace-pin.md index 99cd2d4913..55e5a9b0a8 100644 --- a/.changeset/auth-device-session-workspace-pin.md +++ b/.changeset/auth-device-session-workspace-pin.md @@ -4,3 +4,5 @@ --- `CS_WORKSPACE_CRN` is now honoured when you are signed in through the CLI (no access key). 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 12330d19b6..f7c006e962 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -399,13 +399,16 @@ mod tests { } #[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(); let store = ProfileStore::new(dir.path()); - store.init_workspace("AAAAAAAAAAAAAAAA").unwrap(); + // The current workspace has a token; the CRN names another. + let current = write_token_store(dir.path()); + drop(current); + let crn: Crn = "crn:ap-southeast-2.aws:AAAAAAAAAAAAAAAA".parse().unwrap(); - // Current workspace has a token, but the CRN names another. - let result = AutoStrategy::detect_inner(None, Some(valid_crn()), Some(store), None); + let result = AutoStrategy::detect_inner(None, Some(crn), Some(store), None); assert!(matches!(result, Err(AuthError::NotAuthenticated(_)))); } diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 46103500c4..6d085e9587 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,62 @@ 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 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() -> ProfileStore { + let dir = std::env::temp_dir().join(format!("stack-encrypt-{}", Uuid::new_v4())); + let store = ProfileStore::new(&dir); + 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(); + store + } + + #[tokio::test] + async fn a_pinned_workspace_never_borrows_the_current_workspaces_client_key() { + let provider = ProfileClientKey { + store: Some(store_with_key_in_current_workspace()), + 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 provider = ProfileClientKey { + store: Some(store_with_key_in_current_workspace()), + 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 0de08178b5..7954961ddb 100644 --- a/skills/stash-auth/SKILL.md +++ b/skills/stash-auth/SKILL.md @@ -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. If `CS_WORKSPACE_CRN` is 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` | | `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,13 @@ 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. If `CS_WORKSPACE_CRN` is set, log in + to that workspace, or unset it. 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. From 9aa98963fd3d77e5d21e88e595ad96759db8d58a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 01:12:50 +0000 Subject: [PATCH 05/12] docs(stash-auth): scope the CRN pin to AutoStrategy; clean up test profile dir The default client resolves credentials in protect-ffi, which still pins stack-auth 0.42.3 and follows the CLI's current workspace. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- packages/stack-encrypt/src/cipher.rs | 23 +++++++++++++++++------ skills/stash-auth/SKILL.md | 4 ++-- 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 6d085e9587..2ca9ce0b88 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -2089,11 +2089,20 @@ mod profile_client_key_tests { 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() -> ProfileStore { - let dir = std::env::temp_dir().join(format!("stack-encrypt-{}", Uuid::new_v4())); - let store = ProfileStore::new(&dir); + 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 = @@ -2103,13 +2112,14 @@ mod profile_client_key_tests { .unwrap() .save_profile(&key) .unwrap(); - store + (dir, store) } #[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_with_key_in_current_workspace()), + store: Some(store), workspace: Some(PINNED.to_string()), }; @@ -2123,8 +2133,9 @@ mod profile_client_key_tests { #[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_with_key_in_current_workspace()), + store: Some(store), workspace: None, }; diff --git a/skills/stash-auth/SKILL.md b/skills/stash-auth/SKILL.md index 7954961ddb..6c4647b4a3 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`) — the session of the workspace named by `CS_WORKSPACE_CRN` if set (never the CLI's current workspace; tokens are checked against it, `WORKSPACE_MISMATCH` otherwise), else the current workspace's — 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 until it picks the pin up** | | `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,7 +217,7 @@ 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. If `CS_WORKSPACE_CRN` is 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` | +| `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 (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 | From aac605770232f2327914c50365684c848b2e964b Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 01:14:57 +0000 Subject: [PATCH 06/12] test(stack-encrypt): cover the temp profile dir cleanup Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- packages/stack-encrypt/src/cipher.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/stack-encrypt/src/cipher.rs b/packages/stack-encrypt/src/cipher.rs index 2ca9ce0b88..e83b9031cf 100644 --- a/packages/stack-encrypt/src/cipher.rs +++ b/packages/stack-encrypt/src/cipher.rs @@ -2115,6 +2115,17 @@ mod profile_client_key_tests { (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(); From 558ebf808f289bd0a2588077f631cbfb1e83010a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 01:22:41 +0000 Subject: [PATCH 07/12] docs: scope the changeset to AutoStrategy; tidy a test Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- .changeset/auth-device-session-workspace-pin.md | 2 +- packages/stack-auth/src/auto_strategy.rs | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.changeset/auth-device-session-workspace-pin.md b/.changeset/auth-device-session-workspace-pin.md index 55e5a9b0a8..c627b02168 100644 --- a/.changeset/auth-device-session-workspace-pin.md +++ b/.changeset/auth-device-session-workspace-pin.md @@ -3,6 +3,6 @@ "stash": patch --- -`CS_WORKSPACE_CRN` is now honoured when you are signed in through the CLI (no access key). 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. +`@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/packages/stack-auth/src/auto_strategy.rs b/packages/stack-auth/src/auto_strategy.rs index f7c006e962..5e264ef1df 100644 --- a/packages/stack-auth/src/auto_strategy.rs +++ b/packages/stack-auth/src/auto_strategy.rs @@ -402,10 +402,8 @@ mod tests { #[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(); - let store = ProfileStore::new(dir.path()); // The current workspace has a token; the CRN names another. - let current = write_token_store(dir.path()); - drop(current); + 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); From 5da06f161782bc7b866a2a62dc119962bfaf934f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 01:58:49 +0000 Subject: [PATCH 08/12] docs(stash-auth): scope the quick-diagnosis CRN advice to AutoStrategy Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- skills/stash-auth/SKILL.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/skills/stash-auth/SKILL.md b/skills/stash-auth/SKILL.md index 6c4647b4a3..15936f5631 100644 --- a/skills/stash-auth/SKILL.md +++ b/skills/stash-auth/SKILL.md @@ -399,8 +399,9 @@ 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. If `CS_WORKSPACE_CRN` is set, log in - to that workspace, or unset it. + 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`. From 34bd725eff421a79b8a4c02e0df6eaab77226a28 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 02:05:56 +0000 Subject: [PATCH 09/12] fix(stack-auth): refuse a refreshed token for another workspace before saving it The device-session refresher now carries the pinned workspace and checks a rotated or refreshed token against it before it is cached or written to the profile, so a rejected token leaves the saved session intact. Also note in the stash-auth skill that protect-ffi's client key still follows the CLI's current workspace. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- .../src/device_session_refresher.rs | 81 +++++++++++++++++++ .../stack-auth/src/device_session_strategy.rs | 41 +++++++--- skills/stash-auth/SKILL.md | 2 +- 3 files changed, 110 insertions(+), 14 deletions(-) diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index 225de57359..a82e77ec69 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,9 @@ 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, } impl DeviceSessionRefresher { @@ -38,8 +42,33 @@ impl DeviceSessionRefresher { region: region.into(), device_instance_id, transport, + expected_workspace: None, } } + + /// 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> { + let Some(expected) = self.expected_workspace 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 { @@ -88,6 +117,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 +135,10 @@ 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. + self.check_workspace(&token)?; + // 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 +380,53 @@ 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. + #[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": "other-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 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 d612aed305..9b40f8a568 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -203,22 +203,33 @@ 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 = 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) - } - OAuthTokenSource::WorkspaceStore(store) => { - Self::build_from_workspace_store(store, base_url_override, transport) + 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, + ), } - .map(|mut strategy| { - strategy.expected_workspace = expected_workspace; - strategy - }) } /// Build from a token supplied directly (in-memory only, no store). @@ -228,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, @@ -257,10 +269,11 @@ impl DeviceSessionStrategyBuilder { ®ion_id, device_instance_id, transport, - ); + ) + .pinned_to(expected_workspace); Ok(DeviceSessionStrategy { crn, - expected_workspace: None, + expected_workspace, inner: AutoRefresh::with_token(refresher, token), }) } @@ -270,6 +283,7 @@ impl DeviceSessionStrategyBuilder { ws_store: ProfileStore, base_url_override: Option, transport: SharedTransport, + expected_workspace: Option, ) -> Result { let token: Token = ws_store.load_profile()?; @@ -302,10 +316,11 @@ impl DeviceSessionStrategyBuilder { ®ion_str, device_instance_id, transport, - ); + ) + .pinned_to(expected_workspace); Ok(DeviceSessionStrategy { crn, - expected_workspace: None, + expected_workspace, inner: AutoRefresh::with_token(refresher, token), }) } diff --git a/skills/stash-auth/SKILL.md b/skills/stash-auth/SKILL.md index 15936f5631..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`) — 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 until it picks the pin up** | +| `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 | From 71f760b4e16c2b6c827642b0d13925a11b8b27f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 03:13:19 +0000 Subject: [PATCH 10/12] fix(stack-auth): refuse another workspace's session before AutoRefresh sees it With a workspace pinned, a near-expiry session for a different workspace reached AutoRefresh and was refreshed on first use, consuming a credential that belongs to that workspace. The strategy now checks the starting session at build time and fails with WorkspaceMismatch before any request. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- .../src/device_session_refresher.rs | 37 ++++++++++++------- .../stack-auth/src/device_session_strategy.rs | 29 +++++++++------ 2 files changed, 41 insertions(+), 25 deletions(-) diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index a82e77ec69..820a4ac468 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -55,20 +55,31 @@ impl DeviceSessionRefresher { } fn check_workspace(&self, token: &Token) -> Result<(), AuthError> { - let Some(expected) = self.expected_workspace 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(()) + 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 { diff --git a/packages/stack-auth/src/device_session_strategy.rs b/packages/stack-auth/src/device_session_strategy.rs index 9b40f8a568..eee2704a7e 100644 --- a/packages/stack-auth/src/device_session_strategy.rs +++ b/packages/stack-auth/src/device_session_strategy.rs @@ -4,7 +4,7 @@ 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}; @@ -258,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); @@ -286,6 +287,9 @@ impl DeviceSessionStrategyBuilder { 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() @@ -377,27 +381,28 @@ mod tests { ); } - #[tokio::test] - async fn get_token_rejects_a_token_from_another_workspace() { + /// 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"); - let strategy = DeviceSessionStrategy::with_token(region, "my-client", token) + // 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() - .expect("build should succeed"); + .build(); - let err = (&strategy) - .get_token() - .await - .expect_err("a token for another workspace must be rejected"); assert!( - matches!(err, AuthError::WorkspaceMismatch(_)), - "expected WorkspaceMismatch, got {err:?}" + matches!(result, Err(AuthError::WorkspaceMismatch(_))), + "a session for another workspace must be refused at build, got {:?}", + result.err() ); } From 1fcd7609060072c2bf27ee36097cc3d328ae651a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 03:14:25 +0000 Subject: [PATCH 11/12] fix(stack-auth): do not replay a refresh credential the issuer already rotated When a refresh rotates the credential but returns a token for another workspace, the saved credential is spent. Drop the saved session and make later refreshes on the same refresher ask for a new sign-in, so a retry or a reopened profile cannot replay it and get the chain revoked. A response that did not rotate the credential leaves the saved session untouched. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- .../src/device_session_refresher.rs | 106 +++++++++++++++++- 1 file changed, 103 insertions(+), 3 deletions(-) diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index 820a4ac468..5e2f6e6c26 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -24,6 +24,11 @@ pub(crate) struct DeviceSessionRefresher { /// 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 { @@ -43,6 +48,7 @@ impl DeviceSessionRefresher { device_instance_id, transport, expected_workspace: None, + credential_consumed: std::sync::atomic::AtomicBool::new(false), } } @@ -103,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 @@ -148,7 +158,25 @@ impl Refresher for DeviceSessionRefresher { // Before anything is saved or cached: a rejected token must leave // this profile's saved session alone. - self.check_workspace(&token)?; + 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 { + if let Err(clear_err) = store.clear_profile::() { + tracing::error!(%clear_err, "failed to clear a consumed session"); + } + } + } + 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 @@ -392,7 +420,8 @@ 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. + /// 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(); @@ -402,7 +431,7 @@ mod tests { "access_token": crate::test_support::jwt_with_workspace("AAAAAAAAAAAAAAAA"), "token_type": "Bearer", "expires_in": 3600, - "refresh_token": "other-refresh" + "refresh_token": "matching-refresh" })); }); let server = start_server(mocks).await; @@ -438,6 +467,77 @@ mod tests { ); } + /// 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" + ); + } + /// 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. From 8612fc9b277221be4eaf6574eb4e893d38fd4008 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 03:21:55 +0000 Subject: [PATCH 12/12] fix(stack-auth): report a failure to clear a spent session If the issuer rotated the credential and the saved copy cannot be removed, return that error instead of the mismatch: the credential is still on disk and the next process could replay it. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_012Jmn7o5FdWpXMFP7MSoBnt --- .../src/device_session_refresher.rs | 60 ++++++++++++++++++- 1 file changed, 58 insertions(+), 2 deletions(-) diff --git a/packages/stack-auth/src/device_session_refresher.rs b/packages/stack-auth/src/device_session_refresher.rs index 5e2f6e6c26..b699bcc5e0 100644 --- a/packages/stack-auth/src/device_session_refresher.rs +++ b/packages/stack-auth/src/device_session_refresher.rs @@ -170,9 +170,13 @@ impl Refresher for DeviceSessionRefresher { self.credential_consumed .store(true, std::sync::atomic::Ordering::SeqCst); if let Some(store) = &self.store { - if let Err(clear_err) = store.clear_profile::() { + // 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); @@ -538,6 +542,58 @@ mod tests { ); } + /// 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.