Skip to content

Add WorkOS web auth callback#32

Merged
isuttell merged 5 commits into
agents/web-member-schemafrom
agents/workos-callback-api
May 23, 2026
Merged

Add WorkOS web auth callback#32
isuttell merged 5 commits into
agents/web-member-schemafrom
agents/workos-callback-api

Conversation

@isuttell

@isuttell isuttell commented May 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements POST /v1/auth/web/callback for the WorkOS/AuthKit web sign-in flow.

Changes

  • Added API callback handling that accepts the web server's AuthKit access token, verifies it against WorkOS JWKS with a required client_id claim, and resolves canonical WorkOS user email server-side.
  • Uses jose.createRemoteJWKSet with process-lifetime JWKS cache and extracts sid/jti into the API identity model for deterministic callback idempotency.
  • Upserts/resolves workspace members via DB repository methods, auto-provisioning a Personal Workspace and default API key on first sign-in through command execution.
  • Registered the callback contract/OpenAPI route and added focused API/contracts/DB coverage.
  • Updated ops status docs for the completed endpoint and idempotency behavior.

Risk

High: this touches auth/session admission and first-login workspace provisioning. The implementation is fail-closed for missing deterministic token/session IDs and keeps WorkOS user lookup server-side.

Test plan

  • pnpm --filter @agent-paste/api test
  • pnpm --filter @agent-paste/api typecheck
  • pnpm --filter @agent-paste/contracts test
  • pnpm --filter @agent-paste/contracts typecheck
  • pnpm --filter @agent-paste/contracts openapi:check
  • pnpm --filter @agent-paste/db test
  • pnpm --filter @agent-paste/db typecheck
  • pnpm --filter @agent-paste/db db:check
  • pnpm verify
  • Pre-push hook: turbo run test for the configured package set

CodeRabbit notes

  • Initial coderabbit review --agent --base agents/web-member-schema found 5 issues; addressed unused lookup email input, deterministic callback idempotency, provisioning idempotency documentation/commentary, and docs for sid/jti extraction.
  • Rerun coderabbit review --agent --base agents/web-member-schema completed with 0 findings.

Summary by CodeRabbit

  • New Features

    • Web callback now requires a WorkOS token or session identifier and derives stable idempotency keys to prevent duplicate provisioning.
    • JWKS-backed WorkOS token verification and deterministic first-login workspace/member provisioning with default API key.
  • Documentation

    • Ops docs updated to mark the web callback flow implemented and describe idempotency and provisioning behavior.
  • Tests

    • Expanded tests for identity extraction, idempotent callbacks, and replay/lookup behavior.

Review Change Stack

@isuttell isuttell temporarily deployed to pr-preview-32 May 23, 2026 20:32 — with GitHub Actions Inactive
@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: c244e477-4af7-4b59-bcb1-32a413b41d72

📥 Commits

Reviewing files that changed from the base of the PR and between 9bc4d38 and 1ff4a59.

📒 Files selected for processing (4)
  • apps/api/src/index.test.ts
  • apps/api/src/index.ts
  • docs/ops/web-app-todo.md
  • packages/db/src/postgres/repository.ts

Walkthrough

This PR implements POST /v1/auth/web/callback: verifies WorkOS JWTs via remote JWKS, extracts sid/jti into a WebCallbackIdentity (token_id or session_id), derives a stable idempotency key, rejects if neither id is present, and invokes db.resolveWebMember inside runIdempotent to perform idempotent provisioning or "seen" updates. DB lookups were narrowed to read-only getWebMemberByWorkOsUserId and tests/docs were updated accordingly.

Sequence Diagram(s)

sequenceDiagram
  participant Browser
  participant ApiServer
  participant AuthService
  participant RemoteJWKS
  participant DB
  Browser->>ApiServer: POST /v1/auth/web/callback (access_token)
  ApiServer->>AuthService: verifyWebToken(token)
  AuthService->>RemoteJWKS: obtain JWK set
  RemoteJWKS-->>AuthService: keys
  AuthService-->>ApiServer: WebCallbackIdentity (workos_user_id, email, token_id?|session_id?)
  ApiServer->>ApiServer: webCallbackIdempotencyKey(identity)
  ApiServer->>DB: runIdempotent(idempotencyKey, resolveWebMember(...))
  DB-->>ApiServer: webAuthResponse
  ApiServer-->>Browser: 200 / 401 not_authenticated if missing id
Loading

Possibly related PRs

  • zaks-io/agent-paste#24: Modifies WorkOS web auth/callback foundations that this PR extends for token_id/session_id handling and idempotency.

Poem

🐰 I sniffed the token, found a jti or sid,
I hop, derive a key so work won't be tried,
First-time I build a workspace with care,
Seen again, I update — no duplicates to spare,
Hooray — idempotent provisioning handled with flair!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add WorkOS web auth callback' directly and clearly summarizes the main change: implementation of the POST /v1/auth/web/callback endpoint for WorkOS AuthKit integration.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agents/workos-callback-api

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint skipped: no ESLint configuration detected in root package.json. To enable, add eslint to devDependencies.


Comment @coderabbitai help to get the list of available commands and usage tips.

@isuttell

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review please

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown

@isuttell Sure, I'll review the changes now!

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/db/src/local-repository.ts (1)

172-227: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the new idempotency key in resolveWebMember to make callback replays deterministic.

Line 172 introduces idempotencyKey, but the method never uses it. Replays can still mutate last_seen_at/email and return different payloads for the same callback, which breaks idempotent behavior in the local repository path.

Proposed fix
-  async resolveWebMember(input: { workosUserId: string; email: string; idempotencyKey?: string; now?: string }) {
+  async resolveWebMember(input: { workosUserId: string; email: string; idempotencyKey: string; now?: string }) {
     const now = input.now ?? new Date().toISOString();
     const existing = [...this.workspaceMembers.values()].find((member) => member.workos_user_id === input.workosUserId);
     if (existing) {
-      existing.email = input.email;
-      existing.last_seen_at = now;
-      return this.webAuthResponse(existing, null);
+      return this.runIdempotent(
+        `web.member.seen:${existing.workspace_id}:${input.idempotencyKey}`,
+        () => {
+          existing.email = input.email;
+          existing.last_seen_at = now;
+          return this.webAuthResponse(existing, null);
+        },
+      );
     }

-    const workspace: Workspace = {
-      id: crypto.randomUUID(),
-      name: `${input.email.split("@")[0] ?? "user"}'s Workspace`,
-      contact_email: input.email,
-      created_at: now,
-      updated_at: now,
-    };
-    this.workspaces.set(workspace.id, workspace);
-
-    const member: WorkspaceMember = {
-      id: createId("mem"),
-      workspace_id: workspace.id,
-      workos_user_id: input.workosUserId,
-      email: input.email,
-      scopes: [...DEFAULT_MEMBER_SCOPES],
-      created_at: now,
-      last_seen_at: now,
-    };
-    this.workspaceMembers.set(member.id, member);
-
-    const generated = await generateApiKey(this.options.apiKeyEnv ?? "preview", this.options.apiKeyPepper);
-    const apiKey: ApiKey = {
-      id: createId("key"),
-      workspace_id: workspace.id,
-      public_id: generated.publicId,
-      name: "Default",
-      secret_hmac: generated.secretHmac,
-      pepper_kid: 1,
-      scopes: ["publish", "read"],
-      revoked_at: null,
-      last_used_at: null,
-      created_at: now,
-    };
-    this.apiKeys.set(apiKey.id, apiKey);
-    this.addEvent("system", "web-auth", "workspace.created", "workspace", workspace.id, workspace.id, {}, now);
-    this.addEvent(
-      "system",
-      "web-auth",
-      "api_key.created",
-      "api_key",
-      apiKey.id,
-      workspace.id,
-      { name: apiKey.name, public_id: apiKey.public_id },
-      now,
-    );
-    return this.webAuthResponse(member, { api_key: toApiKeySummary(apiKey), secret: generated.secret });
+    return this.runIdempotent(`web.member.provision:workos-user:${input.workosUserId}`, async () => {
+      const workspace: Workspace = {
+        id: crypto.randomUUID(),
+        name: `${input.email.split("@")[0] ?? "user"}'s Workspace`,
+        contact_email: input.email,
+        created_at: now,
+        updated_at: now,
+      };
+      this.workspaces.set(workspace.id, workspace);
+
+      const member: WorkspaceMember = {
+        id: createId("mem"),
+        workspace_id: workspace.id,
+        workos_user_id: input.workosUserId,
+        email: input.email,
+        scopes: [...DEFAULT_MEMBER_SCOPES],
+        created_at: now,
+        last_seen_at: now,
+      };
+      this.workspaceMembers.set(member.id, member);
+
+      const generated = await generateApiKey(this.options.apiKeyEnv ?? "preview", this.options.apiKeyPepper);
+      const apiKey: ApiKey = {
+        id: createId("key"),
+        workspace_id: workspace.id,
+        public_id: generated.publicId,
+        name: "Default",
+        secret_hmac: generated.secretHmac,
+        pepper_kid: 1,
+        scopes: ["publish", "read"],
+        revoked_at: null,
+        last_used_at: null,
+        created_at: now,
+      };
+      this.apiKeys.set(apiKey.id, apiKey);
+      this.addEvent("system", "web-auth", "workspace.created", "workspace", workspace.id, workspace.id, {}, now);
+      this.addEvent(
+        "system",
+        "web-auth",
+        "api_key.created",
+        "api_key",
+        apiKey.id,
+        workspace.id,
+        { name: apiKey.name, public_id: apiKey.public_id },
+        now,
+      );
+      return this.webAuthResponse(member, { api_key: toApiKeySummary(apiKey), secret: generated.secret });
+    });
   }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/local-repository.ts` around lines 172 - 227, resolveWebMember
accepts idempotencyKey but never uses it, so replayed callbacks can mutate
member fields and produce different payloads; add deterministic idempotency
handling by introducing a local map (e.g. this.webAuthIdempotency or
this.idempotencyMap) keyed by idempotencyKey that stores the canonical response
(member id, api key id/public_id, and secret or the full webAuthResponse
payload). In resolveWebMember, if input.idempotencyKey is provided then: 1)
check the map and if an entry exists return that stored webAuthResponse without
updating last_seen_at/email; 2) if no entry exists proceed to create/find
member/workspace/apiKey as before but do not rely on transient timestamps being
mutated on replays, then store the exact webAuthResponse you will return into
the idempotency map under that key before returning. Ensure you reference
resolveWebMember, this.workspaceMembers, this.apiKeys, addEvent, and
webAuthResponse when implementing the storage and lookup so subsequent calls
with the same idempotencyKey return the identical payload.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/index.ts`:
- Around line 328-330: The callback path currently calls
webCallbackIdempotencyKey(identity) but AuthService.verifyWebToken() returns
WorkOsIdentity with optional token_id/session_id, allowing custom verifiers to
compile but always cause 401; fix by tightening the verifier contract or
enforcing the requirement at verify boundary: introduce a narrower type (e.g.,
WebCallbackIdentity) that requires token_id or session_id and update
AuthService.verifyWebToken to return that type (or make a new method
verifyWebCallbackToken), or add an immediate runtime check after verify that
throws or returns an authentication error with a clear message if both token_id
and session_id are missing; update all AuthService implementations to satisfy
the new return type and adjust the callback usage of webCallbackIdempotencyKey
to expect WebCallbackIdentity.
- Around line 332-339: You detached the instance method db.resolveWebMember into
a local variable which loses its receiver (this) used inside resolveWebMember
(it calls this.runIdempotent, this.workspaceMembers, this.webAuthResponse);
instead call the method on the db instance directly (e.g., return
runIdempotent(context, () => db.resolveWebMember({...})) or explicitly bind the
receiver (db.resolveWebMember.bind(db)) so resolveWebMember retains access to
this and its instance fields/methods.

In `@apps/api/src/workos.ts`:
- Around line 122-124: The code currently sets cacheMaxAge: Infinity when
creating the JWKS resolver via createRemoteJWKSet and also memoizes resolvers in
remoteJwksCache (remoteWorkOsJwks), which prevents timely key rotation; change
cacheMaxAge from Infinity to a finite, reasonable TTL (for example 1 hour or a
configurable value) and ensure remoteWorkOsJwks / remoteJwksCache uses that TTL
so the resolver will refetch keys after expiry; update createRemoteJWKSet
invocation and any configuration around remoteWorkOsJwks/remoteJwksCache to
accept and respect a finite cacheMaxAge instead of Infinity.

---

Outside diff comments:
In `@packages/db/src/local-repository.ts`:
- Around line 172-227: resolveWebMember accepts idempotencyKey but never uses
it, so replayed callbacks can mutate member fields and produce different
payloads; add deterministic idempotency handling by introducing a local map
(e.g. this.webAuthIdempotency or this.idempotencyMap) keyed by idempotencyKey
that stores the canonical response (member id, api key id/public_id, and secret
or the full webAuthResponse payload). In resolveWebMember, if
input.idempotencyKey is provided then: 1) check the map and if an entry exists
return that stored webAuthResponse without updating last_seen_at/email; 2) if no
entry exists proceed to create/find member/workspace/apiKey as before but do not
rely on transient timestamps being mutated on replays, then store the exact
webAuthResponse you will return into the idempotency map under that key before
returning. Ensure you reference resolveWebMember, this.workspaceMembers,
this.apiKeys, addEvent, and webAuthResponse when implementing the storage and
lookup so subsequent calls with the same idempotencyKey return the identical
payload.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 83632757-6899-47ed-8fe5-42eb69975d79

📥 Commits

Reviewing files that changed from the base of the PR and between 083f571 and 32786ab.

📒 Files selected for processing (10)
  • apps/api/src/index.test.ts
  • apps/api/src/index.ts
  • apps/api/src/workos.test.ts
  • apps/api/src/workos.ts
  • docs/ops/project-status.md
  • docs/ops/web-app-todo.md
  • packages/contracts/src/routes.ts
  • packages/db/src/index.test.ts
  • packages/db/src/local-repository.ts
  • packages/db/src/postgres/repository.ts

Comment thread apps/api/src/index.ts Outdated
Comment thread apps/api/src/index.ts Outdated
Comment thread apps/api/src/workos.ts
@isuttell isuttell force-pushed the agents/workos-callback-api branch from 32786ab to 9bc4d38 Compare May 23, 2026 21:10
@isuttell isuttell temporarily deployed to pr-preview-32 May 23, 2026 21:10 — with GitHub Actions Inactive
@isuttell

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
packages/db/src/local-repository.ts (1)

209-263: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Rollback the staged workspace/member if default-key generation fails.

Workspace and WorkspaceMember are persisted before the first await. If generateApiKey(...) throws, the cache entry is removed but the half-provisioned member remains, so every retry falls into the existing-member path and never gets a default_api_key.

💡 Localized fix
     this.workspaceMembers.set(member.id, member);

-    const generated = await generateApiKey(this.options.apiKeyEnv ?? "preview", this.options.apiKeyPepper);
+    let generated: Awaited<ReturnType<typeof generateApiKey>>;
+    try {
+      generated = await generateApiKey(this.options.apiKeyEnv ?? "preview", this.options.apiKeyPepper);
+    } catch (error) {
+      this.workspaceMembers.delete(member.id);
+      this.workspaces.delete(workspace.id);
+      throw error;
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/db/src/local-repository.ts` around lines 209 - 263, In
resolveWebMemberOnce, workspace and member are inserted into this.workspaces and
this.workspaceMembers before awaiting generateApiKey, so if generateApiKey
throws we leave a half-provisioned member; either move creation of
workspace/member until after generateApiKey succeeds, or wrap the generateApiKey
call in try/catch and on error remove the staged entries
(this.workspaces.delete(workspace.id) and
this.workspaceMembers.delete(member.id)) before rethrowing; ensure any events
and this.apiKeys insertion happen only after generateApiKey returns and
reference symbols: resolveWebMemberOnce, generateApiKey, this.workspaces,
this.workspaceMembers, this.apiKeys, addEvent, toApiKeySummary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/api/src/index.test.ts`:
- Around line 199-239: The test doesn't assert that the session-only callback
maps session_id -> the idempotency key sent to resolveWebMember; update the db
mock's resolveWebMember (the async resolveWebMember(this: { marker: string },
input: { email: string }) stub) to accept and return the idempotencyKey from its
input, then add an assertion on the response JSON that idempotencyKey equals the
expected fallback key (e.g. "workos-sid:sess_1") so handleRequest (driven by
AUTH.verifyWebToken returning session_id) is verified to pass the correct
idempotencyKey to resolveWebMember.

In `@apps/api/src/index.ts`:
- Around line 707-715: The current hasWebCallbackId incorrectly treats empty
strings as present, causing webCallbackIdempotencyKey to sometimes return
"workos-session:undefined"; update hasWebCallbackId to only accept non-empty
strings for token_id and session_id (e.g., typeof === "string" &&
identity.token_id.length>0) and make webCallbackIdempotencyKey defensively
handle missing values (throw or return a deterministic error/invalid key) so
empty identifiers cannot collapse unrelated callbacks—apply these checks around
WorkOsIdentity/WebCallbackIdentity and ensure AuthService.verifyWebToken callers
will fail closed rather than produce a shared idempotency key.

In `@docs/ops/web-app-todo.md`:
- Around line 42-43: Update the POST /v1/auth/web/callback implementation
(agents/workos-callback-api / apps/api code that uses jose.createRemoteJWKSet
and runCommand) to extract session_id from JWT sid and token_id from JWT jti,
derive and use idempotency keys such as workos-jti:{jti} or workos-session:{sid}
when performing upserts, and ensure first-time provisioning is gated by a
deterministic key like workos-user:{workos_user_id} to avoid duplicate Personal
Workspace creation; wire these keys into the existing upsert/provision logic
(workspace_members upsert, Personal Workspace + Workspace Member + default API
Key via runCommand) so the callback is idempotent and concurrency-safe.

In `@packages/db/src/postgres/repository.ts`:
- Around line 233-328: The handler must replay a prior callback by
input.idempotencyKey before branching on existing member; add an initial
top-level call to runAdminCommand (actor { type: "system", id: "web-auth" },
action like "web.member.replay") using input.idempotencyKey and null workspace
to check for and return any previously stored result, and only if that returns
nothing continue the current logic that checks
workspaceMemberQueries.findByWorkOsUserId and then uses the per-user
provisioning key (`workos-user:${input.workosUserId}`) for the provisioning
branch; update resolveWebMember to perform this idempotency-key replay-first
step (referencing runAdminCommand, resolveWebMember, input.idempotencyKey,
workspaceMemberQueries.findByWorkOsUserId, and the existing
"web.member.seen"/"web.member.provision" branches).

---

Outside diff comments:
In `@packages/db/src/local-repository.ts`:
- Around line 209-263: In resolveWebMemberOnce, workspace and member are
inserted into this.workspaces and this.workspaceMembers before awaiting
generateApiKey, so if generateApiKey throws we leave a half-provisioned member;
either move creation of workspace/member until after generateApiKey succeeds, or
wrap the generateApiKey call in try/catch and on error remove the staged entries
(this.workspaces.delete(workspace.id) and
this.workspaceMembers.delete(member.id)) before rethrowing; ensure any events
and this.apiKeys insertion happen only after generateApiKey returns and
reference symbols: resolveWebMemberOnce, generateApiKey, this.workspaces,
this.workspaceMembers, this.apiKeys, addEvent, toApiKeySummary.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 7408f5f5-d5dd-4ab5-9bb7-8d0fed1cbc10

📥 Commits

Reviewing files that changed from the base of the PR and between 32786ab and 9bc4d38.

📒 Files selected for processing (10)
  • apps/api/src/index.test.ts
  • apps/api/src/index.ts
  • apps/api/src/workos.test.ts
  • apps/api/src/workos.ts
  • docs/ops/project-status.md
  • docs/ops/web-app-todo.md
  • packages/contracts/src/routes.ts
  • packages/db/src/index.test.ts
  • packages/db/src/local-repository.ts
  • packages/db/src/postgres/repository.ts

Comment on lines +199 to +239
it("calls resolveWebMember with the database receiver intact", async () => {
const db = {
marker: "receiver-kept",
async getWhoami() {
return {};
},
async getAgentView() {
return null;
},
async getPublicAgentView() {
return null;
},
async resolveWebMember(this: { marker: string }, input: { email: string }) {
return { receiver: this.marker, email: input.email };
},
async runCleanup() {
return {};
},
};
const env: Env = {
AUTH: {
async verifyApiKey() {
return null;
},
async verifyWebToken() {
return { workos_user_id: "user_1", email: "user@example.com", session_id: "sess_1" };
},
},
DB: db as unknown as Env["DB"],
};

const response = await handleRequest(
new Request("https://api.test/v1/auth/web/callback", {
method: "POST",
headers: { authorization: "Bearer workos-ok" },
}),
env,
);

expect(response.status).toBe(200);
await expect(response.json()).resolves.toMatchObject({ receiver: "receiver-kept", email: "user@example.com" });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Assert the session_id fallback key in this test.

This is the only session-only callback path, but it never checks what idempotencyKey reaches resolveWebMember. A regression in the session_idworkos-sid:* mapping would still pass here.

🧪 Tighten the assertion
-      async resolveWebMember(this: { marker: string }, input: { email: string }) {
+      async resolveWebMember(this: { marker: string }, input: { email: string; idempotencyKey: string }) {
+        expect(input.idempotencyKey).toBe("workos-sid:sess_1");
         return { receiver: this.marker, email: input.email };
       },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/index.test.ts` around lines 199 - 239, The test doesn't assert
that the session-only callback maps session_id -> the idempotency key sent to
resolveWebMember; update the db mock's resolveWebMember (the async
resolveWebMember(this: { marker: string }, input: { email: string }) stub) to
accept and return the idempotencyKey from its input, then add an assertion on
the response JSON that idempotencyKey equals the expected fallback key (e.g.
"workos-sid:sess_1") so handleRequest (driven by AUTH.verifyWebToken returning
session_id) is verified to pass the correct idempotencyKey to resolveWebMember.

Comment thread apps/api/src/index.ts
Comment on lines +707 to +715
function hasWebCallbackId(identity: WorkOsIdentity): identity is WebCallbackIdentity {
return typeof identity.token_id === "string" || typeof identity.session_id === "string";
}

function webCallbackIdempotencyKey(identity: WebCallbackIdentity): string {
if (identity.token_id) {
return `workos-jti:${identity.token_id}`;
}
return `workos-session:${identity.session_id}`;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject blank WorkOS callback identifiers.

hasWebCallbackId treats "" as present, but webCallbackIdempotencyKey treats it as absent and can fall through to workos-session:undefined. A custom AuthService.verifyWebToken implementation can then collapse unrelated callbacks onto the same idempotency key instead of failing closed.

Suggested fix
 function hasWebCallbackId(identity: WorkOsIdentity): identity is WebCallbackIdentity {
-  return typeof identity.token_id === "string" || typeof identity.session_id === "string";
+  return (
+    (typeof identity.token_id === "string" && identity.token_id.length > 0) ||
+    (typeof identity.session_id === "string" && identity.session_id.length > 0)
+  );
 }
 
 function webCallbackIdempotencyKey(identity: WebCallbackIdentity): string {
-  if (identity.token_id) {
+  if (typeof identity.token_id === "string" && identity.token_id.length > 0) {
     return `workos-jti:${identity.token_id}`;
   }
-  return `workos-session:${identity.session_id}`;
+  if (typeof identity.session_id === "string" && identity.session_id.length > 0) {
+    return `workos-session:${identity.session_id}`;
+  }
+  throw new Error("missing WorkOS token_id or session_id");
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
function hasWebCallbackId(identity: WorkOsIdentity): identity is WebCallbackIdentity {
return typeof identity.token_id === "string" || typeof identity.session_id === "string";
}
function webCallbackIdempotencyKey(identity: WebCallbackIdentity): string {
if (identity.token_id) {
return `workos-jti:${identity.token_id}`;
}
return `workos-session:${identity.session_id}`;
function hasWebCallbackId(identity: WorkOsIdentity): identity is WebCallbackIdentity {
return (
(typeof identity.token_id === "string" && identity.token_id.length > 0) ||
(typeof identity.session_id === "string" && identity.session_id.length > 0)
);
}
function webCallbackIdempotencyKey(identity: WebCallbackIdentity): string {
if (typeof identity.token_id === "string" && identity.token_id.length > 0) {
return `workos-jti:${identity.token_id}`;
}
if (typeof identity.session_id === "string" && identity.session_id.length > 0) {
return `workos-session:${identity.session_id}`;
}
throw new Error("missing WorkOS token_id or session_id");
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@apps/api/src/index.ts` around lines 707 - 715, The current hasWebCallbackId
incorrectly treats empty strings as present, causing webCallbackIdempotencyKey
to sometimes return "workos-session:undefined"; update hasWebCallbackId to only
accept non-empty strings for token_id and session_id (e.g., typeof === "string"
&& identity.token_id.length>0) and make webCallbackIdempotencyKey defensively
handle missing values (throw or return a deterministic error/invalid key) so
empty identifiers cannot collapse unrelated callbacks—apply these checks around
WorkOsIdentity/WebCallbackIdentity and ensure AuthService.verifyWebToken callers
will fail closed rather than produce a shared idempotency key.

Comment thread docs/ops/web-app-todo.md Outdated
Comment thread packages/db/src/postgres/repository.ts Outdated
@isuttell isuttell temporarily deployed to pr-preview-32 May 23, 2026 22:18 — with GitHub Actions Inactive
@isuttell

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented May 23, 2026

Copy link
Copy Markdown
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@isuttell isuttell merged commit f9bcf27 into agents/web-member-schema May 23, 2026
3 of 4 checks passed
@github-actions

Copy link
Copy Markdown

agent-paste PR preview resources were cleaned up. The pr-preview-${context.issue.number} environment is left in place; remove it from the GitHub UI if desired.

isuttell added a commit that referenced this pull request May 23, 2026
* feat(api): add WorkOS web auth callback

* fix(api): address WorkOS callback review

* fix(api): address WorkOS callback review follow-ups

* fix(api): address WorkOS callback review follow-ups
isuttell added a commit that referenced this pull request May 23, 2026
* Add WorkOS web auth callback (#32)

* feat(api): add WorkOS web auth callback

* fix(api): address WorkOS callback review

* fix(api): address WorkOS callback review follow-ups

* fix(api): address WorkOS callback review follow-ups

* feat(api): add dashboard workspace artifact reads
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant