#2053·InsForge

[Bug]: X shared-key OAuth cannot complete after #2051, since x.provider.ts still uses the pre-hardening init query

Author: ayaangazaliCreated Sep 8, 2026Updated Sep 15, 2026

What

The shared-OAuth hardening in #2051 updated seven of the eight providers that use /api/auth/oauth/shared/callback/{state}. x.provider.ts was not updated, so X shared-key login now cannot complete.

This fails closed, so it is an availability regression rather than a hole. Flagging it that way deliberately: nothing here weakens the fix.

Proof

Eight providers post to the hardened callback, seven call the new init helper:

$ git grep -l "shared/callback" -- backend/src/providers/oauth/ | wc -l
8
$ git grep -l "buildSharedOAuthInitQuery" -- backend/src/providers/oauth/ | wc -l
7

The one in the first list and not the second is x.provider.ts, which still sends the pre-#2051 query:

typescript
// backend/src/providers/oauth/x.provider.ts
const redirectUri = `${selfBaseUrl}/api/auth/oauth/shared/callback/${state}`;
const response = await axios.get(
  `${cloudBasedUrl}/oauth/twitter?redirect_uri=${encodeURIComponent(redirectUri)}`,

against what the other seven now send:

typescript
// shared-oauth.service.ts
return new URLSearchParams({
  redirect_uri: redirectUri,
  project_id: appConfig.cloud.projectId as string,
  sign,
  flow_id: sharedOAuthFlowId(state),
}).toString();

Why the callback then rejects it

oauth.routes.ts runs verifyIdentityToken for every provider whose config has useSharedKey; there is no per-provider bypass. Two of its checks cannot be satisfied for X:

  • payload.projectId !== appConfig.cloud.projectId rejects, and project_id is never sent. The init request is an unauthenticated GET with no headers, so the cloud has no other way to learn which project is asking.
  • payload.sid !== sharedOAuthFlowId(context.state) rejects, and flow_id is never sent.

The sid check is arguably recoverable, since state is embedded in redirect_uri and the cloud could hash it itself. The projectId check is not: nothing in the X init request identifies the project.

X also goes to a different cloud endpoint (/oauth/twitter) than the seven (/auth/v1/shared/<provider>), so it may be on an older cloud path that was never in scope for #2051.

Root cause

The provider list was updated per file rather than swept. apple, discord, facebook, github, google, linkedin and microsoft were changed in the same commit; x uses the same callback and the same config?.useSharedKey branch but was missed.

backend/tests/unit/shared-oauth-identity.test.ts has no X case, so nothing failed.

What I could not verify

Whether the cloud's /oauth/twitter endpoint already emits a signed assertion. It does not change the conclusion, because the projectId binding cannot be satisfied without project_id in the init request, but I want to be clear about the boundary: I can read this repo and the published CLI, not the cloud backend.

If X shared keys are not actually offered to projects, this is moot and I would rather be told that than guess. oauth-config.service.ts does carry a case 'x': with scopes, and x.provider.ts has a full useSharedKey branch, which is why I read it as reachable.

Proposed fix

Route X through the same helper as the rest:

typescript
const response = await axios.get(
  `${cloudBasedUrl}/oauth/twitter?${buildSharedOAuthInitQuery(redirectUri, state)}`,

That needs the cloud's /oauth/twitter to accept the extra params and mint the bound assertion, which is a cloud-side change I cannot make or test. If the intent was instead to drop X from shared keys, the fix is to remove its useSharedKey branch so it fails at config time with a clear message rather than at callback time.

Acceptance criteria

  • Every provider that posts to /api/auth/oauth/shared/callback/{state} either uses buildSharedOAuthInitQuery or no longer offers useSharedKey.
  • A test covers the X path, so a future provider addition cannot silently repeat this.
  • git grep -l "shared/callback" -- backend/src/providers/oauth/ and git grep -l "buildSharedOAuthInitQuery" -- backend/src/providers/oauth/ return the same set.

Note on process

I am at the 3-issue assignment limit (#1918, #2020, #2029), so I have not claimed this. Filing it now anyway because it is a regression in a security fix that landed hours ago and seemed worth surfacing promptly rather than sitting on until a slot frees.