Skip to content
Open
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
24 changes: 24 additions & 0 deletions .changeset/rename-strategy-to-auth-strategy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
"@cipherstash/stack": minor
---

Rename the encryption client's auth strategy config field from `config.strategy` to **`config.authStrategy`** to make its purpose clear, and expand the `Encryption()` TypeDoc with a full authentication and keysets walkthrough.

**`config.authStrategy`** is the new, documented field for supplying an auth strategy (`OidcFederationStrategy`, `AccessKeyStrategy`, or any `{ getToken() }` object). **`config.strategy` is retained as a deprecated alias** — passing it still works and forwards to the client, but logs a one-time runtime deprecation warning. When both are set, `authStrategy` wins (and the deprecation warning still fires so the leftover field gets cleaned up).

```ts
import { Encryption, OidcFederationStrategy } from "@cipherstash/stack"

const client = await Encryption({
schemas: [users],
config: {
authStrategy: OidcFederationStrategy.create(workspaceCrn, () => getUserJwt()),
},
})
```

**Migration:** rename `config.strategy` → `config.authStrategy`. No behavioural change beyond the deprecation warning; the field is forwarded to protect-ffi's `strategy` option exactly as before.

The `Encryption()` TypeDoc now documents the default `auto` strategy (env vars → local dev profile via `npx stash auth login`), the four `CS_*` production/CI variables, custom strategies (`AccessKeyStrategy`, `OidcFederationStrategy`), lock context, and keysets for multi-tenant isolation.

The `@cipherstash/stack/wasm-inline` entry (Deno / Edge / Workers / Bun) gets the same rename so the Node and WASM interfaces stay in sync: `WasmClientConfig.authStrategy` is the documented field, `strategy` is a deprecated alias that still works and warns at runtime.
100 changes: 84 additions & 16 deletions packages/stack/__tests__/init-strategy.test.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
/**
* Tests for the optional `config.strategy` auth strategy.
* Tests for the optional `config.authStrategy` auth strategy (and its
* deprecated `config.strategy` alias).
*
* protect-ffi (0.25+) lets `newClient` take an `AuthStrategy` (any
* `{ getToken(): Promise<{ token }> }` object — the shape every
* `@cipherstash/auth` strategy satisfies, including
* `OidcFederationStrategy` for per-user identity-bound encryption).
* `Encryption` exposes it via `config.strategy`; when provided it must
* reach `newClient` as `opts.strategy`, and when omitted the option must
* be absent so the default credentials-derived strategy is used.
* `Encryption` exposes it via `config.authStrategy`; when provided it must
* reach `newClient` as `opts.strategy` (the FFI's option name), and when
* omitted the option must be absent so the default `auto` strategy is used.
* The legacy `config.strategy` field is still honoured (with a runtime
* deprecation warning) until it is removed.
*/

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { __resetStrategyDeprecationWarningForTests } from '@/encryption'
import { EncryptionV3 } from '@/encryption/v3'
import { Encryption } from '@/index'
import { encryptedColumn, encryptedTable } from '@/schema'
Expand All @@ -26,32 +30,43 @@ const users = encryptedTable('users', {
email: encryptedColumn('email'),
})

// Silence + capture the deprecation warning, and reset its once-per-process
// latch, so each test asserts warning behaviour deterministically regardless
// of order. `afterEach` restores the spy even if an assertion throws mid-test.
let warnSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
vi.clearAllMocks()
__resetStrategyDeprecationWarningForTests()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})

describe('Encryption config.strategy', () => {
it('forwards a supplied strategy to newClient', async () => {
const strategy: AuthStrategy = {
afterEach(() => {
warnSpy.mockRestore()
})

describe('Encryption config.authStrategy', () => {
it('forwards a supplied authStrategy to newClient', async () => {
const authStrategy: AuthStrategy = {
getToken: vi.fn(async () => ({ token: 'service-token' })),
}

await Encryption({ schemas: [users], config: { strategy } })
await Encryption({ schemas: [users], config: { authStrategy } })

// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args
const opts = vi.mocked(ffi.newClient).mock.calls.at(-1)![0] as any
expect(opts.strategy).toBe(strategy)
expect(opts.strategy).toBe(authStrategy)
})

it('passes the strategy alongside the credential clientOpts', async () => {
const strategy: AuthStrategy = {
it('passes the authStrategy alongside the credential clientOpts', async () => {
const authStrategy: AuthStrategy = {
getToken: vi.fn(async () => ({ token: 'service-token' })),
}

await Encryption({
schemas: [users],
config: {
strategy,
authStrategy,
workspaceCrn: 'crn:ap-southeast-2.aws:test-workspace',
clientId: 'client-id',
clientKey: 'client-key',
Expand All @@ -60,8 +75,8 @@ describe('Encryption config.strategy', () => {

// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args
const opts = vi.mocked(ffi.newClient).mock.calls.at(-1)![0] as any
expect(opts.strategy).toBe(strategy)
// clientKey is still required even when a strategy is supplied.
expect(opts.strategy).toBe(authStrategy)
// clientKey is still required even when an authStrategy is supplied.
expect(opts.clientOpts.clientKey).toBe('client-key')
expect(opts.clientOpts.workspaceCrn).toBe(
'crn:ap-southeast-2.aws:test-workspace',
Expand All @@ -83,7 +98,10 @@ describe('Encryption config.strategy', () => {
})),
}

await Encryption({ schemas: [users], config: { strategy: oidcStrategy } })
await Encryption({
schemas: [users],
config: { authStrategy: oidcStrategy },
})

// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args
const opts = vi.mocked(ffi.newClient).mock.calls.at(-1)![0] as any
Expand All @@ -99,6 +117,56 @@ describe('Encryption config.strategy', () => {
})
})

describe('Encryption config.strategy (deprecated alias)', () => {
it('still forwards a deprecated strategy to newClient and warns', async () => {
const strategy: AuthStrategy = {
getToken: vi.fn(async () => ({ token: 'service-token' })),
}

await Encryption({ schemas: [users], config: { strategy } })

// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args
const opts = vi.mocked(ffi.newClient).mock.calls.at(-1)![0] as any
expect(opts.strategy).toBe(strategy)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('`config.strategy` is deprecated'),
)
})

it('prefers authStrategy over the deprecated strategy when both are set, and still warns', async () => {
const strategy: AuthStrategy = {
getToken: vi.fn(async () => ({ token: 'legacy-token' })),
}
const authStrategy: AuthStrategy = {
getToken: vi.fn(async () => ({ token: 'new-token' })),
}

await Encryption({
schemas: [users],
config: { strategy, authStrategy },
})

// biome-ignore lint/suspicious/noExplicitAny: reading recorded mock args
const opts = vi.mocked(ffi.newClient).mock.calls.at(-1)![0] as any
expect(opts.strategy).toBe(authStrategy)
// The deprecated field is still present, so the nudge to remove it fires
// even though `authStrategy` takes precedence.
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('`config.strategy` is deprecated'),
)
})

it('does not warn when only authStrategy is supplied', async () => {
const authStrategy: AuthStrategy = {
getToken: vi.fn(async () => ({ token: 'new-token' })),
}

await Encryption({ schemas: [users], config: { authStrategy } })

expect(warnSpy).not.toHaveBeenCalled()
})
})

// A minimal structural EQL v3 table: what marks a table as v3 for wire-format
// detection is its `buildColumnKeyMap()` method (v2 tables have none). Built
// by hand rather than via `@cipherstash/stack/v3` so this suite pins the
Expand Down
2 changes: 1 addition & 1 deletion packages/stack/__tests__/lock-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ async function userClient(userJwt: string) {
return Encryption({
schemas: [users],
config: {
strategy: OidcFederationStrategy.create(crn, () =>
authStrategy: OidcFederationStrategy.create(crn, () =>
Promise.resolve(userJwt),
),
},
Expand Down
10 changes: 7 additions & 3 deletions packages/stack/__tests__/wasm-inline-new-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -100,12 +100,16 @@ describe('wasm-inline Encryption → newClient (protect-ffi 0.25 single-object f
expect(arg.encryptConfig.tables.users.email.cast_as).toBe('text')
})

it('uses an explicit config.strategy verbatim on the strategy path', async () => {
it('uses an explicit config.authStrategy verbatim on the strategy path', async () => {
const explicit = { getToken: vi.fn() }
await Encryption({
schemas: [users],
// biome-ignore lint/suspicious/noExplicitAny: exercise the strategy arm of the config union
config: { strategy: explicit, clientId: 'cid', clientKey: 'ckey' } as any,
config: {
authStrategy: explicit,
clientId: 'cid',
clientKey: 'ckey',
// biome-ignore lint/suspicious/noExplicitAny: exercise the authStrategy arm of the config union
} as any,
})

// biome-ignore lint/suspicious/noExplicitAny: reading the recorded single options object
Expand Down
79 changes: 70 additions & 9 deletions packages/stack/__tests__/wasm-inline-strategy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@
* Deno e2e (`e2e/wasm/roundtrip.test.ts`), which skips without real `CS_*`
* secrets, so the wiring goes unchecked in the normal suite. These tests mock
* `@cipherstash/auth/wasm-inline` and assert that the CRN reaches
* `AccessKeyStrategy.create`, that an explicit `config.strategy` is used
* verbatim, and that the `strategy` + `accessKey` mutual-exclusion guard fires.
* `AccessKeyStrategy.create`, that an explicit `config.authStrategy` is used
* verbatim, that the deprecated `config.strategy` alias still works (and warns),
* and that the auth-strategy + `accessKey` mutual-exclusion guard fires. This
* mirrors the Node entry's `config.authStrategy` contract.
*/

import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@cipherstash/auth/wasm-inline', () => ({
AccessKeyStrategy: {
Expand All @@ -28,12 +30,25 @@ vi.mock('@cipherstash/protect-ffi/wasm-inline', () => ({
}))

import { AccessKeyStrategy } from '@cipherstash/auth/wasm-inline'
import { resolveStrategy } from '../src/wasm-inline'
import {
__resetStrategyDeprecationWarningForTests,
resolveStrategy,
} from '../src/wasm-inline'

const CRN = 'crn:ap-southeast-2.aws:test-workspace'

// Silence + capture the deprecation warning and reset its once-per-process
// latch so each test asserts warning behaviour deterministically.
let warnSpy: ReturnType<typeof vi.spyOn>

beforeEach(() => {
vi.clearAllMocks()
__resetStrategyDeprecationWarningForTests()
warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
})

afterEach(() => {
warnSpy.mockRestore()
})

describe('wasm-inline resolveStrategy', () => {
Expand All @@ -47,15 +62,43 @@ describe('wasm-inline resolveStrategy', () => {
'CSAK.test',
)
expect(strategy).toEqual({ __mock: 'access-key-strategy' })
expect(warnSpy).not.toHaveBeenCalled()
})

it('uses an explicit config.authStrategy verbatim and never builds an access key', () => {
const explicit = { getToken: vi.fn() }
// biome-ignore lint/suspicious/noExplicitAny: exercise the authStrategy arm of the discriminated union directly
const strategy = resolveStrategy({ authStrategy: explicit } as any)

expect(strategy).toBe(explicit)
expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled()
expect(warnSpy).not.toHaveBeenCalled()
})

it('uses an explicit config.strategy verbatim and never builds an access key', () => {
it('honours the deprecated config.strategy alias and warns', () => {
const explicit = { getToken: vi.fn() }
// biome-ignore lint/suspicious/noExplicitAny: exercise the strategy arm of the discriminated union directly
// biome-ignore lint/suspicious/noExplicitAny: exercise the deprecated strategy arm directly
const strategy = resolveStrategy({ strategy: explicit } as any)

expect(strategy).toBe(explicit)
expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled()
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('`config.strategy` is deprecated'),
)
})

it('prefers authStrategy over the deprecated strategy when both are set, and still warns', () => {
const authStrategy = { getToken: vi.fn() }
const strategy = { getToken: vi.fn() }
const resolved = resolveStrategy(
// biome-ignore lint/suspicious/noExplicitAny: both fields set — JS callers bypass the compile-time union
{ authStrategy, strategy } as any,
)

expect(resolved).toBe(authStrategy)
expect(warnSpy).toHaveBeenCalledWith(
expect.stringContaining('`config.strategy` is deprecated'),
)
})

it('throws when the access-key arm is missing workspaceCrn or accessKey', () => {
Expand All @@ -78,17 +121,35 @@ describe('wasm-inline resolveStrategy', () => {
expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled()
})

it('throws when both strategy and accessKey are supplied', () => {
it('throws when both an auth strategy and accessKey are supplied', () => {
const both = {
workspaceCrn: CRN,
accessKey: 'CSAK.test',
strategy: { getToken: vi.fn() },
authStrategy: { getToken: vi.fn() },
}
expect(() =>
// biome-ignore lint/suspicious/noExplicitAny: deliberately invalid — JS callers bypass the compile-time union
resolveStrategy(both as any),
).toThrowError(/mutually exclusive/)
).toThrowError(
/`config\.authStrategy` and `config\.accessKey` are mutually exclusive/,
)
// The guard must short-circuit *before* building a strategy.
expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled()
})

it('throws when the deprecated strategy and accessKey are both supplied, naming `strategy`', () => {
const both = {
workspaceCrn: CRN,
accessKey: 'CSAK.test',
strategy: { getToken: vi.fn() },
}
expect(() =>
// biome-ignore lint/suspicious/noExplicitAny: deliberately invalid — JS callers bypass the compile-time union
resolveStrategy(both as any),
).toThrowError(
// Names the field the caller actually set, not the resolved `authStrategy`.
/`config\.strategy` and `config\.accessKey` are mutually exclusive/,
)
expect(vi.mocked(AccessKeyStrategy.create)).not.toHaveBeenCalled()
})
})
Loading
Loading