Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# W0.4 Confidential OAuth Custody in workerd

Outcome: proved for `@atcute/oauth-node-client` 2.0.0 with one documented key-removal limitation.

## Verified contract

- `new OAuthClient({ metadata, keyset, actorResolver, stores, requestLock, fetch })` builds a confidential client. `metadata.token_endpoint_auth_method` is `private_key_jwt`, `token_endpoint_auth_signing_alg` is `ES256`, and `dpop_bound_access_tokens` is `true`.
- `generateClientAssertionKey(kid)` creates the ES256 assertion key. `client.jwks` exposes only its public JWK. Supplying `jwks_uri` leaves the metadata pointed at the public endpoint while `client.jwks` supplies the response body.
- `authorize()` generates a different per-flow DPoP private key. Captured client-assertion JWTs use the assertion-key `kid`; captured DPoP JWTs contain a different public JWK.
- The exact scope used in metadata, PAR, token responses, stored sessions, and every fixture is `atproto repo:com.emdashcms.experimental.package.release?action=create`.
- Atcute directly handles an authorization-server DPoP nonce challenge. A `400 { "error": "use_dpop_nonce" }` plus `DPoP-Nonce` causes one PAR retry whose DPoP JWT contains the nonce. A reconstructed client then reads the nonce cache from D1 and sends that nonce at the token endpoint.
- D1 stores survive reconstruction between `authorize()`, `callback()`, and `restore()`. The test uses the real D1 binding supplied by `@cloudflare/vitest-pool-workers`, not Miniflare's Node storage API or a database mock.
- `requestLock` encloses atcute's load, refresh, and session-store update. The test lease uses D1 conditional updates, expiry, an owner token, and owner-checked session persistence. Two independent clients racing `restore(did)` against an expired token produce exactly one refresh request and persist the first rotating refresh token. The authorization-server fixture consumes that refresh token once and rejects reuse with `invalid_grant`.
- Session persistence fails closed if lease ownership expires before the write. A real D1 test expires the acquired lease, calls the same guarded `sessions.set()` path used by atcute, observes the ownership error, and verifies the previous parsed session remains semantically unchanged.
- Session deletion inside a session lock uses the same owner-and-expiry guard. A real atcute `restore(..., { refresh: true })` test delays `invalid_grant`, expires owner A, lets owner B persist a successor session, and proves A's subsequent atcute error cleanup cannot delete B's value.
- Lease ownership is operation-local, not shared mutable instance state. The workerd harness uses native `AsyncLocalStorage` from `node:async_hooks` under the narrow `nodejs_als` compatibility flag. A same-persistence overlap test expires A, runs B to completion, then proves resumed A can neither overwrite nor delete B's session.
- Ownerless session mutation is deliberately asymmetric. `sessions.set()` may insert an absent callback-created session but cannot overwrite an existing row. `sessions.delete()` is a no-op for an absent row and rejects if a row exists; `sessions.clear()` likewise rejects while any session exists. This restriction is session-only; authorization-state deletion and DPoP nonce replacement retain normal Store behavior.
- The ownerless deletion policy is exercised through real atcute `OAuthSession.signOut()`: atcute performs its token operation and then calls `deleteStored()` outside `requestLock`, where the harness rejects deletion and preserves the stored session. Direct Store coverage proves ownerless overwrite also fails. The harness does not claim atcute coordinates these paths itself.
- A live owner renews its lease periodically with a conditional update on the same owner and an unexpired lease. Configurable test timings hold an operation beyond its initial lease duration, observe renewal, persist the successor, and verify owner-checked release. Renewal stops in `finally`. A separate ownership-theft test observes renewal loss and proves later persistence still fails closed.
- `StoredSession.authMethod` retains `{ method: "private_key_jwt", kid }`. A keyset ordered `[new, old]` chooses `new` for a new authorization while restoring the old session by its recorded `kid`.
- Removing the old key from published JWKS does not revoke an already issued access token. An existing atcute `OAuthSession` continues a DPoP-authenticated resource request without a token-endpoint call. Authorization-server revocation or access-token expiry remains a separate operation.

## Persisted atcute values

`StoredState`, keyed by OAuth state ID:

- `dpopKey`: private DPoP JWK.
- `authMethod`: `private_key_jwt` plus assertion-key `kid`.
- `pkceVerifier`.
- `issuer`, `redirectUri`.
- Optional expected `sub` and caller `userState`.
- `expiresAt`.

`StoredSession`, keyed by publisher DID:

- `dpopKey`: private DPoP JWK.
- `authMethod`: `private_key_jwt` plus the originating assertion-key `kid`.
- `tokenSet.iss`, `sub`, `aud`, and exact `scope`.
- `tokenSet.access_token`, rotating `refresh_token`, `token_type`, and optional `expires_at`.

The DPoP nonce store is keyed by origin and contains the latest nonce string. Future production storage must application-encrypt state, sessions, DPoP keys/nonces, and tokens. The spike intentionally does not select the final schema or encryption envelope.

## Exact library APIs

- `generateClientAssertionKey(kid, "ES256")`
- `new OAuthClient(...)`
- `OAuthClient.jwks`
- `OAuthClient.authorize(...)`
- `OAuthClient.callback(params)`
- `OAuthClient.restore(did, { refresh })`
- `OAuthSession.handle(path, init)`
- `Store<K, V>` for sessions, states, and DPoP nonces
- `LockFunction` through `OAuthClientOptions.requestLock`
- `StoredState`, `StoredSession`, and `TokenSet`

## Direct proof versus simulation

Directly proved in workerd: WebCrypto key generation and JWT signing, public-JWKS derivation, confidential metadata defaults, private-key JWT fields, distinct DPoP keys, nonce challenge/retry, D1 state/session/nonce round trips, callback deletion of one-time state, ownerless create-only session insertion, real-atcute ownerless sign-out rejection, D1 lease serialization and renewal, operation-local ownership across overlapping operations, owner-checked fail-closed persistence and deletion, rotating-token persistence, key selection by recorded `kid`, and DPoP resource requests after JWKS rotation.

Simulated: OAuth discovery, PAR, token, and PDS HTTP responses are supplied by an injected in-isolate authorization-server fixture. The fixture enforces rotating refresh-token consumption and captures signed requests, but does not verify their cryptographic signatures or contact an external PDS. Cross-vendor create-only scope acceptance and revocation endpoint behavior belong to W0.3.

## Limitation

With only the new private key loaded, atcute 2.0.0 rejects `restore(oldDid, { refresh: false })` before making a resource request: `key "assertion-old" no longer available or compatible`. `OAuthServerAgent` eagerly constructs the client-assertion factory even when a valid access token needs no token-endpoint authentication. Therefore routine rotation must retain old private assertion keys, not only old public JWKS entries, for every restorable session using that `kid`. This is stricter than OAuth token validity: removing a key does not itself revoke an already issued access token, as the existing-session resource request proves.

The operation-local lock adapter requires Workers' native `AsyncLocalStorage`. A production Worker using this coordination pattern must enable `nodejs_als` (or the broader `nodejs_compat`) at a compatible date. The feasibility harness proves the narrower flag in workerd; this does not decide the production coordinator or schema.

Atcute's callback creation legitimately writes outside `requestLock`, while `OAuthClient.revoke()`, `OAuthSession.signOut()`, and second-invalid-resource cleanup can delete outside it. Under this harness policy, explicit revocation/sign-out cannot rely on raw atcute `Store.delete`; production service logic must acquire the publisher session lease, perform revocation, and delete conditionally under that owner. The spike directly tests `signOut()`, not every ownerless atcute deletion route.

## Command evidence

- Baseline: `pnpm lint:json | jq '.diagnostics | length'` -> `0`.
- Default package test: `pnpm --filter @emdash-cms/auth-atproto test` -> 27 Node tests and 9 workerd tests passed. The package-local Node config excludes the workerd-only file, and the normal package script then runs `test:workerd` explicitly.
- CI filter selection: root `test:unit` includes the exact `--filter @emdash-cms/auth-atproto`; its scoped equivalent `pnpm run --filter @emdash-cms/auth-atproto test` -> the same 27 Node and 9 workerd tests passed.
- Focused test: `pnpm --filter @emdash-cms/auth-atproto exec vitest run --config vitest.workerd.config.ts` -> 1 file, 9 tests passed.
- Harness types: `pnpm --filter @emdash-cms/auth-atproto exec tsgo --noEmit -p tests/support/confidential-oauth/tsconfig.json` -> passed.
- Repository build: `pnpm build` -> passed with existing virtual-import and direct-eval warnings.
- Package types: `pnpm --filter @emdash-cms/auth-atproto typecheck` -> passed after the full build.
- Full lint: `pnpm lint` -> 0 warnings and 0 errors.
- Diff integrity: `git diff --check` -> passed.

The lockfile was regenerated only through `pnpm add`, `pnpm remove`, and `pnpm install`. The final direct additions are `@cloudflare/vitest-pool-workers`, `@cloudflare/workers-types`, and `@types/node`. Pnpm also reconciled pre-existing stale peer snapshots in this importer from TypeScript 6.0.0-beta to the workspace's catalog TypeScript 6.0.3 and deduplicated stale transitive snapshots; no root catalog or workspace file changed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"typecheck:templates": "pnpm run --workspace-concurrency=1 --filter {./templates/*} typecheck",
"check": "pnpm run typecheck && pnpm run --filter {./packages/*} check",
"test": "pnpm run --filter {./packages/*} test",
"test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-cli --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons test",
"test:unit": "pnpm run --filter emdash --filter @emdash-cms/auth --filter @emdash-cms/auth-atproto --filter @emdash-cms/blocks --filter @emdash-cms/gutenberg-to-portable-text --filter @emdash-cms/marketplace --filter @emdash-cms/plugin-cli --filter @emdash-cms/plugin-forms --filter @emdash-cms/plugin-types --filter @emdash-cms/registry-client --filter @emdash-cms/registry-lexicons test",
"test:browser": "pnpm run --filter @emdash-cms/admin test",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
Expand Down
7 changes: 6 additions & 1 deletion packages/auth-atproto/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,16 @@
"devDependencies": {
"@atcute/lexicons": "catalog:",
"@cloudflare/kumo": "catalog:",
"@cloudflare/vitest-pool-workers": "catalog:",
"@cloudflare/workers-types": "catalog:",
"@types/node": "catalog:",
"@types/react": "^19.0.0",
"vitest": "catalog:"
},
"scripts": {
"test": "vitest run",
"test": "pnpm test:node && pnpm test:workerd",
"test:node": "vitest run --config vitest.config.ts",
"test:workerd": "vitest run --config vitest.workerd.config.ts",
"typecheck": "tsgo --noEmit"
},
"repository": {
Expand Down
Loading
Loading