Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
78 changes: 63 additions & 15 deletions packages/stack/__tests__/init-strategy.test.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,16 @@
/**
* 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'
Expand All @@ -30,28 +33,28 @@ beforeEach(() => {
vi.clearAllMocks()
})

describe('Encryption config.strategy', () => {
it('forwards a supplied strategy to newClient', async () => {
const strategy: AuthStrategy = {
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 +63,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 +86,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 +105,48 @@ describe('Encryption config.strategy', () => {
})
})

describe('Encryption config.strategy (deprecated alias)', () => {
it('still forwards a deprecated strategy to newClient and warns once', async () => {
// The rename warning is deduped per process, so this must be the first
// test in the file that passes `config.strategy`; the other suites use
// `authStrategy` and never trip the flag.
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})

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(warn).toHaveBeenCalledWith(
expect.stringContaining('`config.strategy` is deprecated'),
)

warn.mockRestore()
})
Comment thread
coderdan marked this conversation as resolved.
Outdated

it('prefers authStrategy over the deprecated strategy when both are set', 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)
})
})

// 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
151 changes: 126 additions & 25 deletions packages/stack/src/encryption/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export class EncryptionClient {
clientId?: string
clientKey?: string
keyset?: KeysetIdentifier
strategy?: AuthStrategy
authStrategy?: AuthStrategy
eqlVersion?: 2 | 3
}): Promise<Result<EncryptionClient, EncryptionError>> {
return await withResult(
Expand All @@ -145,7 +145,7 @@ export class EncryptionClient {

// newClient handles env var fallback internally via withEnvCredentials,
// so we pass config values through without manual fallback here.
// When `strategy` is supplied, protect-ffi invokes its getToken()
// When `authStrategy` is supplied, protect-ffi invokes its getToken()
// on every ZeroKMS request instead of building an AutoStrategy
// from the credentials in clientOpts (the clientKey is still used
// for encryption). Passing `strategy: undefined` is equivalent to
Expand All @@ -163,7 +163,7 @@ export class EncryptionClient {
clientKey: config.clientKey,
keyset: toFfiKeysetIdentifier(config.keyset),
},
strategy: config.strategy,
strategy: config.authStrategy,
eqlVersion: config.eqlVersion,
})

Expand Down Expand Up @@ -673,58 +673,151 @@ export class EncryptionClient {
}
}

// Emit the `config.strategy` → `config.authStrategy` rename warning at most
// once per process so repeated `Encryption()` calls don't spam the console.
let warnedStrategyDeprecated = false
function warnStrategyDeprecated(): void {
if (warnedStrategyDeprecated) return
Comment thread
coderdan marked this conversation as resolved.
warnedStrategyDeprecated = true
console.warn(
'[encryption]: `config.strategy` is deprecated and will be removed in a future release — use `config.authStrategy` instead.',
)
}

/**
* Creates and initializes an Encryption client for encrypting and decrypting data with CipherStash.
*
* Provide at least one schema (from {@link encryptedTable}) so the client knows which tables and
* columns to use. Credentials are read from the optional `config` or from the environment
* (`CS_WORKSPACE_CRN`, `CS_CLIENT_ID`, `CS_CLIENT_KEY`, `CS_CLIENT_ACCESS_KEY`).
* columns to use:
*
* Pass a `config.strategy` to control how ZeroKMS requests are authenticated; its `getToken()`
* is then used for every request in place of the credentials-derived default. Use
* `OidcFederationStrategy` for per-user, identity-bound encryption (federates an end user's OIDC
* JWT into a CTS service token) or `AccessKeyStrategy` for service-to-service / CI. Both are
* re-exported from `@cipherstash/stack`. See {@link ClientConfig.strategy}.
* ```typescript
* import { Encryption, encryptedTable, encryptedColumn } from "@cipherstash/stack"
*
* @param config - Initialization options. Must include `schemas`; optionally include `config` for
* workspace/keys. Logging is configured via the `STASH_STACK_LOG` environment variable
* (`debug | info | error`, default: `error`).
* @returns A Promise that resolves to an initialized {@link EncryptionClient} ready for
* {@link EncryptionClient.encrypt}, {@link EncryptionClient.decrypt}, and related operations.
* const users = encryptedTable("users", { email: encryptedColumn("email") })
* const client = await Encryption({ schemas: [users] })
* const result = await client.encrypt("alice@example.com", { column: users.email, table: users })
* ```
*
* @throws Throws if `schemas` is empty, or if a keyset `id` is supplied but is not a valid UUID.
* Also throws if the client fails to initialize (e.g. invalid credentials or config).
* ## Authentication
*
* By default the client uses the `auto` auth strategy. `auto` first looks for the `CS_*`
* environment variables (see below) and, if they are not set, falls back to the local **dev
* profile** on your machine. The dev profile also supplies the client key, so during local
* development you generally don't need to set any environment variables at all.
*
* ### Local development — create a dev profile
*
* Log in once to create the dev profile that `auto` picks up automatically:
*
* ```bash
* npx stash auth login
* ```
*
* ### Production / CI — environment variables
*
* In production and CI you typically authenticate with the four `CS_*` environment variables
* instead of a dev profile. Developers can obtain these values from the
* [CipherStash dashboard](https://dashboard.cipherstash.com):
*
* | Environment variable | Description |
* | ---------------------- | ------------------------------------------------------------------------------ |
* | `CS_WORKSPACE_CRN` | The workspace Cloud Resource Name (CRN) that identifies your workspace. |
* | `CS_CLIENT_ID` | The client identifier issued when you create an access key. |
* | `CS_CLIENT_KEY` | The client key material combined with ZeroKMS to perform encryption. |
* | `CS_CLIENT_ACCESS_KEY` | The API access key used to authenticate requests to CipherStash. |
*
* When these are set, `auto` uses them in preference to the local dev profile.
*
* ### Custom auth strategies — `config.authStrategy`
*
* For finer control, pass an explicit strategy via `config.authStrategy` (from `@cipherstash/auth`,
* re-exported by `@cipherstash/stack`). See the `@cipherstash/auth` package for the full list. Two
* common choices:
Comment thread
coderdan marked this conversation as resolved.
*
* `AccessKeyStrategy` — like `auto`, but only ever uses an access key; it never falls back to the
* local dev profile. Ideal for services and CI:
*
* @example
* ```typescript
* import { Encryption, encryptedTable, encryptedColumn } from "@cipherstash/stack"
* import { Encryption, AccessKeyStrategy } from "@cipherstash/stack"
Comment thread
coderdan marked this conversation as resolved.
*
* const users = encryptedTable("users", {
* email: encryptedColumn("email"),
* const client = await Encryption({
* schemas: [users],
* config: {
* authStrategy: AccessKeyStrategy.create(workspaceCrn, accessKey),
* },
* })
* const client = await Encryption({ schemas: [users] })
* const result = await client.encrypt("alice@example.com", { column: users.email, table: users })
* ```
*
* @example Per-user, identity-bound encryption
* `OidcFederationStrategy` — authenticate end users through your own identity provider (Supabase,
* Clerk, Auth0 or Okta) by federating their OIDC JWT into a CipherStash token. Add the provider to
* your workspace first at
* [dashboard.cipherstash.com/workspaces/_/oidc-providers](https://dashboard.cipherstash.com/workspaces/_/oidc-providers)
* (the `_` in the URL resolves to whichever workspace you select):
*
* ```typescript
* import { Encryption, OidcFederationStrategy } from "@cipherstash/stack"
*
* // Authenticate every ZeroKMS request as the signed-in user.
* const client = await Encryption({
* schemas: [users],
* config: {
* strategy: OidcFederationStrategy.create(workspaceCrn, () => getUserJwt()),
* authStrategy: OidcFederationStrategy.create(workspaceCrn, () => getUserJwt()),
* },
* })
* ```
*
* ### Lock context (identity-bound encryption)
*
* Lock context is an **additional** capability layered on top of `OidcFederationStrategy`: it
* requires that strategy, but `OidcFederationStrategy` does not require lock context. It binds a
* value to a claim from the user's JWT (typically `sub`) so that only the user who encrypted a
* value can decrypt it:
*
* ```typescript
* // Bind the data key to the user's `sub` claim.
* const result = await client
* .encrypt("alice@example.com", { column: users.email, table: users })
* .withLockContext({ identityClaim: ["sub"] })
* ```
*
* Because the lock is tied to a specific end user's identity, `AccessKeyStrategy` (which
* authenticates a service, not a user) is not valid for lock context — there is no user `sub`
* claim to bind to.
*
* ## Keysets (multi-tenant isolation)
*
* Pass `config.keyset` to encrypt under a specific **keyset** — a named or UUID-identified keyspace
* that gives each tenant its own cryptographic isolation, so data encrypted under one keyset cannot
* be decrypted under another. Create and manage keysets in the
* [dashboard](https://dashboard.cipherstash.com/workspaces/_/keysets) (the `_` in the URL resolves
* to whichever workspace you select); omit `config.keyset` to use the workspace's default keyset.
*
Comment thread
coderdan marked this conversation as resolved.
* ```typescript
* const client = await Encryption({
* schemas: [users],
* config: {
* keyset: { name: "tenant-a" }, // or { id: "<uuid>" }
* },
* })
* ```
*
* A client is bound to a single keyset for its lifetime, so multi-tenant applications use **one
* `Encryption()` client per tenant**. Keysets are orthogonal to `authStrategy` and lock context —
* they isolate a whole tenant's *keyspace* (coarse, fixed per client), whereas lock context binds
* an individual value to a user's identity claim (fine-grained, per operation) — and can be
* combined with both.
*
* @param config - Initialization options. Must include `schemas`; optionally include `config` for
* credentials and authentication. Logging is configured via the `STASH_STACK_LOG` environment
* variable (`debug | info | error`, default: `error`).
* @returns A Promise that resolves to an initialized {@link EncryptionClient} ready for
* {@link EncryptionClient.encrypt}, {@link EncryptionClient.decrypt}, and related operations.
*
* @throws Throws if `schemas` is empty, or if a keyset `id` is supplied but is not a valid UUID.
* Also throws if the client fails to initialize (e.g. invalid credentials or config).
*
* @see {@link EncryptionClientConfig} for full config options.
* @see {@link ClientConfig.authStrategy} for the auth strategy field.
* @see {@link EncryptionClient} for available methods after initialization.
*/
export const Encryption = async (
Expand All @@ -748,6 +841,13 @@ export const Encryption = async (
)
}

// Resolve the auth strategy, honouring the deprecated `strategy` alias.
// `authStrategy` wins when both are set.
if (clientConfig?.strategy && !clientConfig.authStrategy) {
warnStrategyDeprecated()
}
const authStrategy = clientConfig?.authStrategy ?? clientConfig?.strategy

Comment thread
coderabbitai[bot] marked this conversation as resolved.
const client = new EncryptionClient()
const encryptConfig = buildEncryptConfig(...schemas)

Expand All @@ -760,6 +860,7 @@ export const Encryption = async (
const result = await client.init({
encryptConfig,
...clientConfig,
authStrategy,
eqlVersion,
})

Expand Down
4 changes: 2 additions & 2 deletions packages/stack/src/identity/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ export function resolveLockContext(input: LockContextInput): Context {
* const client = await Encryption({
* schemas,
* config: {
* strategy: OidcFederationStrategy.create(workspaceCrn, () => getJwt()),
* authStrategy: OidcFederationStrategy.create(workspaceCrn, () => getJwt()),
* },
* })
*
Expand Down Expand Up @@ -118,7 +118,7 @@ export class LockContext {
*
* @deprecated Per-operation CTS tokens were removed in protect-ffi 0.25.
* Authenticate the client as the user with an `OidcFederationStrategy`
* (`config.strategy`) instead, and pass the claim to `.withLockContext()`.
* (`config.authStrategy`) instead, and pass the claim to `.withLockContext()`.
* The token fetched here is no longer used by encryption operations. This
* method is kept for backwards compatibility and will be removed in a
* future major release.
Expand Down
4 changes: 2 additions & 2 deletions packages/stack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ export {
} from '@/encryption/helpers'
export { encryptedColumn, encryptedField, encryptedTable } from '@/schema'

// Re-export auth strategies for convenience. Pass one as `config.strategy` to
// `Encryption()` to control how ZeroKMS requests are authenticated — notably
// Re-export auth strategies for convenience. Pass one as `config.authStrategy`
// to `Encryption()` to control how ZeroKMS requests are authenticated — notably
// `OidcFederationStrategy` for per-user, identity-bound encryption (pair with
// `.withLockContext({ identityClaim })`). Re-exported so integrators don't need
// a separate `@cipherstash/auth` install.
Expand Down
Loading
Loading