@volter/twin-xidentity
v0.1.37
Published
Local X (Twitter) identity twin — the real x.com authorize screen, the full OAuth 2.0 authorization-code + PKCE round trip (token, refresh, revoke), and GET /2/users/me with X's real field/expansion/problem envelopes, so an unmodified X client completes s
Readme
@volter/twin-xidentity
A local, stateful, vendor-faithful twin of X (Twitter) identity — the x.com authorize screen,
the full OAuth 2.0 authorization-code + PKCE round trip (token, refresh, revoke), and
GET /2/users/me with X's real field/expansion/problem envelopes. Point an unmodified X client at
it and complete a whole sign-in-with-X flow offline.
bun run packages/twin/xidentity/src/cli.ts mirror
# xidentity twin (X OAuth 2.0 + /2/users/me) at http://127.0.0.1:54321
# authorize screen: http://127.0.0.1:54321/i/oauth2/authorize?response_type=code&client_id=…Deliberately NON-OIDC — because the vendor is
X's OAuth 2.0 issues opaque bearer tokens: no id_token, no JWKS, no discovery document.
Identity is fetched from GET /2/users/me with the access token. That non-OIDC shape is the whole
reason this vendor needs its own pack next to googleoauth — an integration built against an OIDC
provider (id_token claims, openid email profile scopes) breaks against X in exactly the ways this
twin reproduces: the scope model is X's own catalog (the OpenAPI's 26 scopes plus the announcement-added
users.email, which gates confirmed_email — a recorded intra-vendor discrepancy), the refresh
token only arrives with offline.access, PKCE is required, and the only identity read is the
API call.
It is a browser-facing protocol pack (the googleoauth class): the authorize screen is served
as HTML at the vendor's real path (/i/oauth2/authorize), server-rendered from the twin's own
kernel projection by the same React components the browser bundle ships — one renderer, so
API↔UI parity cannot drift. The decision is two plain <form> submits ("Authorize app" /
"Cancel"), so no JavaScript is required to complete an OAuth round trip against this twin. The
bundle and stylesheet the twin serves are committed text (src/xidentity-consent-client.gen.ts,
written from client/ by bun scripts/consent-clients.ts and drift-gated by
scripts/consent-clients.test.ts): the serve path reads constants and never runs a bundler.
A derived pack (Protocol 3)
The pack is derived (architecture) from
X's own OpenAPI document, X API v2 version 2.168 (spec/openapi.json.gz, the same bytes the x pack vendors;
provenance in spec/SOURCE.md), corrected by spec/patches.json: the x pack's corrections to the wire's names
(tweet.fields, pinned_tweet_id, most_recent_tweet_id), and X's OAuth endpoints the document does not list,
patched in from docs.x.com's Authorization Code Flow page and OAuth API reference. bun scripts/derive-pack.ts
xidentity writes the wire (src/generated/). GET /2/users/me, POST /2/oauth2/token, POST /2/oauth2/revoke,
POST /oauth/request_token, POST /oauth/access_token and POST /1.1/oauth/invalidate_token (and its .json
form) are semantics handlers (src/semantics/); every other operation of the document is the gap, X's 404
problem — in a World the x pack claims those paths on api.x.com and api.twitter.com. The authorize pages are
screens (src/screens/), and the World's doors are src/xidentity-doors.ts. src/manifest.ts declares the
state machines the handlers and screens ask on every move (a screen's settling, a code's redemption, a refresh
token's rotation and revocation, an access token's revocation, an OAuth 1.0a request token's life, an OAuth 1.0a
access token's invalidation).
A credential is bookkeeping, kept by its SHA-256 — an authorization code, an access token, a refresh token, an
OAuth 1.0a request token and an OAuth 2.0 App's client secret live under _ types, never as a subject id or a field
of an entry that could be deployed or read in a changeset. A World that ran the protocol 1 pack keeps its
credentials under the types they were written as; they still resolve, and the first move on one writes it as
bookkeeping. Two rows keep a secret by contract: the OAuth 1.0a App (oauth1_app) and access token
(oauth1_token), which the x pack reads by the token itself through the kernel's owner read
(xidentity-oauth1.ts states the row shapes); moving them is that contract's change, on both packs at once.
Not done by the Protocol 3 procedure yet: the customer life, the vendor's published examples and the report
(journeys/); the capability manifest stays the pack's T0.
Coverage
Partial and honest. The manifest (src/xidentity-capabilities.ts) is the enumerated identity
service-area denominator, not a claim to enumerate the whole X vendor surface. It is authored
top-down from X's own artefacts (first fetched 2026-08-21): the X API
v2 OpenAPI document (2.167 then; 2.168 vendored now) for /2/users/me, the Problem error family and the scope
catalog with X's consent descriptions; the docs.x.com OAuth 2.0 guides for the authorize/token/refresh/
revoke requests; the docs.x.com rate-limit pages for the x-rate-limit-* headers and the
75/15-minute per-user figure; and the official SDK sources (@xdevplatform/xdk,
twitter-api-typescript-sdk) for the token/revoke response shapes the docs never show as JSON.
It currently reads 103 done / 162 covered (59 todo, and the login leg is not an API surface: it is the seeded
session, so it is not in that denominator). The todos are real X identity
surface this twin does not model — most notably the v1.1 app-only bearer flow, OAuth 1.0a's
timestamp window and nonce replay, the unmodelled user.fields (entities, withheld, subscription, the
relational fields), expansion hydration — plus a family of wire-pinning todos: behaviours the
twin models from RFC 6749/7009 or widely-reported captures because no fetched official artefact
states them (exact token error wording, the authorize error channel, refresh-token rotation, the
code TTL). Each such done names its evidence boundary in the manifest and its pinning todo.
What is real here
- The complete round trip. Authorization request → authorize screen → 302 to
redirect_uriwith exactlystate+code(the documented callback shape) → the code is redeemable exactly once, PKCE-verified with real SHA-256 →refresh_tokengrant (rotating) → revoke kills the whole grant. One pack against one state root, so the code minted at the screen is honoured at the token endpoint by construction. - PKCE required,
S256andplain, exactly as X requires it; the legacy official SDK's lowercases256spelling is accepted because the vendor's own client emits it. - Exact callback matching — X documents exact-match validation, so a trailing slash is a
different URI and
redirect_uri_mismatch-class bugs reproduce. - Confidential vs public clients — HTTP Basic for confidential (the docs' rule), body
client_idfor public; a confidential client without its Basic header is refused. - Vendor-shaped opaque credentials — the twin's codes/tokens are base64url blobs whose decoded
structure (
<opaque>:<unix-ms>:1:1:ac|at|rt:1) matches what the docs' own examples decode to. - The identity read —
GET /2/users/mewith the OpenAPI's scope demands (tweet.read+users.read), the spec'suser.fields,expansionsandtweet.fields/post.fieldsenums with the wire's invalid-parameter envelope (a query parameter the operation does not take is refused the same way), the documentedx-rate-limit-*headers, 75/15min per-user accounting, and the documented 429 + legacy code 88 refusal.user.fields=profile_image_urlis the seeded photo, or X's default avatar URL (abs.twimg.com/…/default_profile_normal.png) for a persona seeded without one — the same answer thexpack gives for the same person; a pulled persona whose reply withheld the field is not given one. - SDK fidelity — the unmodified current official SDK (
@xdevplatform/xdk) drivesusers.getMe()against the twin through its own publicbaseUrlconfig (xidentity-sdk.integration.test.ts); the OAuth legs its hardcoded hosts cannot re-aim are proven over real transport in the docs' own curl shapes.
OAuth 1.0a (src/semantics/oauth1.ts, src/screens/oauth1.ts, src/xidentity-oauth1.ts)
The three-legged flow as docs.x.com's API reference and "Obtaining access tokens using 3-legged
OAuth flow" state it, which is what Postiz (gitroomhq/postiz-app) connects an X account with
through twitter-api-v2:
POST oauth/request_token, signed by a registered App with HMAC-SHA1, answersoauth_token,oauth_token_secretandoauth_callback_confirmed=true; a callback the App does not list is code 415,oobis the PIN flow, andx_auth_access_type=readnarrows a Read-and-write App.GET oauth/authorizeandGET oauth/authenticate(served onapi.x.com, where X serves them) show the screen naming the App and the signed-in account, listing what its permission allows; its form posts back toPOST oauth/authorizeon the same host, as X's screen does (on api.x.com, which thexpack shares, that path and the screen's/_twin/assets/consent.{js,css}are this pack's). Authorize app returns to the callback with exactlyoauth_token+oauth_verifier, Cancel withdenied=<request token>.authenticateskips the screen once the person approved the App (unlessforce_login=true).POST oauth/access_tokentrades the approved request token and verifier, once, for<user id>-<token>, its secret,user_idandscreen_name; approving the same App again returns the token already held.POST 1.1/oauth/invalidate_tokenrevokes it.- The access token signs
GET /2/users/mehere and the posting surface in thexpack (which reads this pack'soauth1_tokenrows). An unknown consumer key or a signature that does not verify is code 32 at the legs and theabout:blank401 on/2. - The World's own App is the pair the World's env names (
X_API_KEY/X_API_SECRET, orTWITTER_API_KEY/TWITTER_API_SECRET), read withworldEnvValue, named for the World and accepting any callback — the slack pack's World-app rule. Other Apps are registered throughPOST /_twin/oauth1_apps.
Not modelled (manifest todos): the timestamp window and nonce replay (the twin's clock is the
World's, an app signs with its host's), request-token expiry, the login step force_login and
screen_name would show, and the live wording of several refusals, each pinned by name.
What is not
The twin authenticates nobody: no password, no 2FA, no risk engine. The
x.com "signed in" session is a seeded persona row — POST /_twin/session switches it, which is the
honest local equivalent of "the browser is already signed in". This is X's identity surface; posts,
timelines and media are the x pack's, which shares X's hosts with this one by path.
Twin-only scaffolding (not vendor surface, not counted)
GET /_twin/consent (re-render a pending authorize screen by request handle) and
POST /_twin/consent (the Authorize/Cancel form post — X's real form posts to an undocumented
internal endpoint; the OAuth 1.0a screen's posts to POST oauth/authorize, above),
POST /_twin/oauth1_apps (register an OAuth 1.0a App: consumer key and secret, callback URLs,
permission), POST /_twin/clients, POST /_twin/accounts, POST /_twin/session,
POST /_twin/rate_limit (arm a deterministic 429), and the /_twin/assets/* page assets. This
list is the audit trail the conformance census deliberately excludes — keep it in lockstep with
the doors in xidentity-doors.ts.
The three operations
- pull —
syncXIdentityFromReal(execute)observes the real account behind the operator's own user token (GET /2/users/me, every modelled field) over an injected executor; live runs useliveXIdentityExecute(accessToken), the ONE place a real X request may be issued, guarded by the fail-closed rate budget (xidentity-budget.ts: X's own 75/15min scheme, live-fetched 2026-08-21). The client registry and the grant's scope set are not observable (no API reads X Apps; X has no token introspection) — reasoned gaps, not fakes. The same read is the protocol 2 refresh adapter,syncXIdentityFromRemote, over the kernel's executor. - write — the default: local consent flows mint local credentials, no real calls.
- push — structurally impossible (X exposes no write API for this surface) and reported as
such, never faked:
performXIdentityActionanswers every entryperformed: false.
Serving
world-xidentity serve starts one server for the WHOLE surface; point x.com, twitter.com,
api.x.com and api.twitter.com at it (the injector's xidentity VENDOR_HOSTS entry does
exactly that). mirror is the same server plus a ready-to-open authorize URL; conformance runs
the endpoint census (one real probe per claimed endpoint + the hand-enumerated router surface +
a reachability witness per resource type).
