Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions .changeset/auth-device-session-workspace-pin.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion languages/typescript/packages/auth/native.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
4 changes: 3 additions & 1 deletion languages/typescript/packages/auth/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
60 changes: 53 additions & 7 deletions packages/stack-auth/src/auto_strategy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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, AuthError> {
Self::builder().detect()
Expand Down Expand Up @@ -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()),
Comment thread
cipherstash-agent marked this conversation as resolved.
Resolved
None => store.current_workspace_store(),
};
let has_token = ws_store
.map(|ws| ws.exists_profile::<Token>())
.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));
}
}
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading