diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cafdc2b..229a2e5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,7 +63,7 @@ jobs: run: pnpm run publint - name: Types (Are The Types Wrong?) - run: pnpm run attw -- --pack --profile esm-only + run: pnpm run attw --pack --profile esm-only # The example resolves @workos/authkit-electron from the BUILT dist # (workspace:*), so a broken public type fails this build (criterion 8). diff --git a/README.md b/README.md index 965c8f2..ca989d5 100644 --- a/README.md +++ b/README.md @@ -42,13 +42,13 @@ npm install @workos/authkit-electron @workos-inc/node The package ships five entry points that version in lockstep: -| Import | Process | Provides | -| ------------------------------------ | -------- | ----------------------------------------------------------------------------------------------- | -| `@workos/authkit-electron` | Main | `createAuthKit`, default storage, shared types | -| `@workos/authkit-electron/preload` | Preload | `exposeAuthKit`, the typed bridge contract | -| `@workos/authkit-electron/react` | Renderer | `AuthKitProvider`, `useAuth`, `useAccessToken`, `SignedIn/SignedOut` | +| Import | Process | Provides | +| ------------------------------------ | -------- | --------------------------------------------------------------------------------------------------------- | +| `@workos/authkit-electron` | Main | `createAuthKit`, default storage, shared types | +| `@workos/authkit-electron/preload` | Preload | `exposeAuthKit`, the typed bridge contract | +| `@workos/authkit-electron/react` | Renderer | `AuthKitProvider`, `useAuth`, `useAccessToken`, `SignedIn/SignedOut` | | `@workos/authkit-electron/globals` | Renderer | Types only: opt-in `Window` typing for the bridge (see [Renderer without React](#renderer-without-react)) | -| `@workos/authkit-electron/internals` | Main | Building blocks for advanced composition (see [Building blocks](#building-blocks)) | +| `@workos/authkit-electron/internals` | Main | Building blocks for advanced composition (see [Building blocks](#building-blocks)) | ## Pre-flight diff --git a/src/main/__tests__/session-manager.spec.ts b/src/main/__tests__/session-manager.spec.ts index 6957fc5..5b77a83 100644 --- a/src/main/__tests__/session-manager.spec.ts +++ b/src/main/__tests__/session-manager.spec.ts @@ -354,9 +354,7 @@ describe('signOut', () => { await manager.signOut(); expect(endSession).toHaveBeenCalledTimes(1); - expect(endSession).toHaveBeenCalledWith( - 'https://api.workos.com/logout?session_id=session_xyz', - ); + expect(endSession).toHaveBeenCalledWith('https://api.workos.com/logout?session_id=session_xyz'); expect(storage.clearSession).toHaveBeenCalledTimes(1); }); diff --git a/src/main/__tests__/storage.spec.ts b/src/main/__tests__/storage.spec.ts index 827eca1..6cfc970 100644 --- a/src/main/__tests__/storage.spec.ts +++ b/src/main/__tests__/storage.spec.ts @@ -279,6 +279,66 @@ describe('createDefaultStorage — pending verifiers', () => { }); }); +describe('createDefaultStorage — read-path plaintext policy (SEC-1592)', () => { + // Models a local, same-user attacker who can write the electron-store JSON + // file but has no OS-keychain access: they inject `plain:`-prefixed values the + // SDK would never write while encryption is available and allowPlaintext=false. + const forgedSession: Session = { + accessToken: 'eyJATTACKER', + refreshToken: 'rt_ATTACKER', + user: { id: 'user_ATTACKER', email: 'attacker@evil.example' } as unknown as User, + }; + + it('rejects an injected plain: session when encryption is available', () => { + const store = makeStore(); + store.raw.set('session', `plain:${JSON.stringify(forgedSession)}`); + const storage = createDefaultStorage({ store, safeStorage: makeSafeStorage(true) }); + expect(storage.getSession()).toBeNull(); + }); + + it('ignores an injected plain: cookiePassword and generates a fresh one', () => { + const store = makeStore(); + const attackerKnown = 'A'.repeat(32); + store.raw.set('cookiePassword', `plain:${attackerKnown}`); + const storage = createDefaultStorage({ store, safeStorage: makeSafeStorage(true) }); + expect(storage.getOrCreateCookiePassword()).not.toBe(attackerKnown); + }); + + it('rejects injected plain: pending verifiers', () => { + const store = makeStore(); + store.raw.set( + 'pendingVerifiers', + `plain:${JSON.stringify({ + 'attacker-state': { value: 'sealed', expiresAt: Date.now() + 600000 }, + })}`, + ); + const storage = createDefaultStorage({ store, safeStorage: makeSafeStorage(true) }); + expect(storage.takePendingVerifier('attacker-state')).toBeNull(); + }); + + it('still honors plain: values when allowPlaintext is enabled (encryption unavailable)', () => { + const store = makeStore(); + store.raw.set('session', `plain:${JSON.stringify(session)}`); + const storage = createDefaultStorage({ + store, + safeStorage: makeSafeStorage(false), + allowPlaintext: true, + }); + expect(storage.getSession()).toEqual(session); + }); + + it('still honors plain: values when allowPlaintext is enabled (encryption available)', () => { + const store = makeStore(); + store.raw.set('session', `plain:${JSON.stringify(session)}`); + const storage = createDefaultStorage({ + store, + safeStorage: makeSafeStorage(true), + allowPlaintext: true, + }); + expect(storage.getSession()).toEqual(session); + }); +}); + describe('createDefaultStorage — default electron bindings', () => { it('constructs without injected deps (uses mocked electron + electron-store)', () => { // Exercises the default `safeStorage` / `new ElectronStore()` branch. diff --git a/src/main/storage.ts b/src/main/storage.ts index 88842b9..ad7752f 100644 --- a/src/main/storage.ts +++ b/src/main/storage.ts @@ -136,6 +136,13 @@ export function createDefaultStorage(opts: CreateStorageOptions = {}): TokenStor return ss.decryptString(buf); } if (stored.startsWith(PLAINTEXT_PREFIX)) { + // Mirror the write-path policy: the SDK only ever writes a plaintext + // value when `allowPlaintext` is enabled. Rejecting `plain:` otherwise + // prevents a local file writer from injecting a value the SDK would + // never have produced (forged session / cookiePassword / verifiers). + if (!allowPlaintext) { + return null; + } return stored.slice(PLAINTEXT_PREFIX.length); } return null;