|
7 | 7 | // 1. An OAuth connection's access token expires (per-client TTL of 1s via |
8 | 8 | // the emulator's DCR `access_token_ttl_seconds` extension, well inside |
9 | 9 | // the 60s refresh skew). |
10 | | -// 2. A health check resolves the connection → triggers the refresh-token |
11 | | -// grant. The grant SUCCEEDS and the authorization server ROTATES the |
12 | | -// refresh token (single use — the emulator mirrors AuthKit). |
13 | | -// 3. Persisting the new tokens does read → `PUT /vault/v1/kv/:id`. The PUT |
14 | | -// fails (in prod: 409 version conflict, an OAuth-shaped 400, or an HTML |
15 | | -// error page; here: the armed 400 with `error`/`error_description`, the |
16 | | -// exact shape the WorkOS SDK maps to OauthException — Sentry issue -53). |
17 | | -// 4. The write failure surfaces as `StorageError: WorkOS Vault secret write |
| 10 | +// 2. A health check resolves the connection → the refresh path runs. Vault |
| 11 | +// writes go through read → `PUT /vault/v1/kv/:id`; the PUT fails (in |
| 12 | +// prod: 409 version conflict, an OAuth-shaped 400, or an HTML error |
| 13 | +// page; here: the armed 400 with `error`/`error_description`, the exact |
| 14 | +// shape the WorkOS SDK maps to OauthException — Sentry issue -53). |
| 15 | +// 3. The write failure surfaces as `StorageError: WorkOS Vault secret write |
18 | 16 | // failed` → the health endpoint answers a typed InternalError (the 500 |
19 | | -// Sentry records). |
| 17 | +// Sentry records). Crucially it must fail BEFORE the refresh-token grant |
| 18 | +// (the AS rotates the single-use token; consuming it with an unwritable |
| 19 | +// store loses the rotated copy forever). |
20 | 20 | // |
21 | | -// The second scenario pins the DAMAGE, not just the surface error: the vault |
22 | | -// write failed AFTER the refresh token was consumed and rotated, so the new |
23 | | -// refresh token is lost and the stored one is revoked. Once the vault |
24 | | -// recovers, the connection must recover too — today it cannot (the next |
25 | | -// refresh gets invalid_grant → the connection reads "expired" and demands a |
26 | | -// re-auth). Red until the refresh/persist ordering is made crash-safe. |
| 21 | +// The second scenario pins the DAMAGE the surface error used to hide: when |
| 22 | +// the vault write failed AFTER the refresh token was consumed and rotated, |
| 23 | +// the rotated token was lost and the stored one already revoked — the next |
| 24 | +// refresh got invalid_grant and the connection demanded a re-auth over a |
| 25 | +// storage blip. The fix gates the grant on a proof-of-writability rewrite of |
| 26 | +// the stored refresh token, so a vault outage fails BEFORE the single-use |
| 27 | +// token is spent and the connection recovers with the vault. |
27 | 28 | import { randomBytes } from "node:crypto"; |
28 | 29 |
|
29 | 30 | import { expect } from "@effect/vitest"; |
30 | 31 | import { Effect } from "effect"; |
31 | 32 | import type { HttpApiClient } from "effect/unstable/httpapi"; |
32 | 33 | import { composePluginApi } from "@executor-js/api/server"; |
33 | | -import { connectEmulator, type EmulatorClient } from "@executor-js/emulate"; |
| 34 | +import { connectEmulator } from "@executor-js/emulate"; |
34 | 35 | import { openApiHttpPlugin } from "@executor-js/plugin-openapi/api"; |
35 | 36 | import { |
36 | 37 | AuthTemplateSlug, |
@@ -243,22 +244,24 @@ scenario( |
243 | 244 | ).toBe("InternalError"); |
244 | 245 |
|
245 | 246 | // The proof this is the production mechanism and not an incidental |
246 | | - // failure: the product performed the refresh grant and then hit the |
247 | | - // injected fault on the vault update. |
| 247 | + // failure: the product hit the injected fault on the vault update leg. |
248 | 248 | const ledger = yield* Effect.promise(() => workos.ledger.list(200)); |
249 | 249 | const faultedPut = ledger.find((entry) => entry.faulted === true && entry.method === "PUT"); |
250 | 250 | expect(faultedPut?.path, "the failure came from the injected vault-update fault").toMatch( |
251 | 251 | /\/vault\/v1\/kv\//, |
252 | 252 | ); |
| 253 | + // The crash-safety contract: the write-gate fails BEFORE the refresh |
| 254 | + // grant, so the single-use refresh token is never consumed while the |
| 255 | + // store cannot persist its rotated successor. |
253 | 256 | const refreshGrant = ledger.find( |
254 | 257 | (entry) => |
255 | 258 | entry.path.endsWith("/oauth2/token") && |
256 | 259 | JSON.stringify(entry.request.body ?? "").includes("refresh_token"), |
257 | 260 | ); |
258 | 261 | expect( |
259 | 262 | refreshGrant, |
260 | | - "the vault write that failed was persisting a completed refresh grant", |
261 | | - ).toBeDefined(); |
| 263 | + "no refresh grant is issued while the vault cannot persist the rotated token", |
| 264 | + ).toBeUndefined(); |
262 | 265 | }), |
263 | 266 | Effect.gen(function* () { |
264 | 267 | yield* Effect.promise(() => workos.faults.clear()); |
|
0 commit comments