@volter/twin-xidentity 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -0
- package/client/xidentity-consent.css +204 -0
- package/client/xidentity-consent.tsx +162 -0
- package/dist/client/xidentity-consent.bundle.js +235 -0
- package/dist/client/xidentity-consent.css +204 -0
- package/dist/client/xidentity-consent.d.ts +53 -0
- package/dist/client/xidentity-consent.js +57 -0
- package/dist/client/xidentity-consent.tsx +162 -0
- package/dist/src/cli.d.ts +2 -0
- package/dist/src/cli.js +44 -0
- package/dist/src/index.d.ts +15 -0
- package/dist/src/index.js +105 -0
- package/dist/src/xidentity-budget.d.ts +50 -0
- package/dist/src/xidentity-budget.js +108 -0
- package/dist/src/xidentity-capabilities.d.ts +3 -0
- package/dist/src/xidentity-capabilities.js +905 -0
- package/dist/src/xidentity-conformance.d.ts +10 -0
- package/dist/src/xidentity-conformance.js +332 -0
- package/dist/src/xidentity-connector.d.ts +84 -0
- package/dist/src/xidentity-connector.js +239 -0
- package/dist/src/xidentity-consent-client.gen.d.ts +2 -0
- package/dist/src/xidentity-consent-client.gen.js +10 -0
- package/dist/src/xidentity-consent-ui.d.ts +21 -0
- package/dist/src/xidentity-consent-ui.js +94 -0
- package/dist/src/xidentity-pkce.d.ts +7 -0
- package/dist/src/xidentity-pkce.js +27 -0
- package/dist/src/xidentity-problems.d.ts +38 -0
- package/dist/src/xidentity-problems.js +108 -0
- package/dist/src/xidentity-scopes.d.ts +23 -0
- package/dist/src/xidentity-scopes.js +81 -0
- package/dist/src/xidentity-server.d.ts +33 -0
- package/dist/src/xidentity-server.js +85 -0
- package/dist/src/xidentity-store.d.ts +97 -0
- package/dist/src/xidentity-store.js +358 -0
- package/dist/src/xidentity-twin.d.ts +54 -0
- package/dist/src/xidentity-twin.js +851 -0
- package/package.json +74 -0
- package/src/cli.ts +43 -0
- package/src/index.ts +177 -0
- package/src/xidentity-budget.ts +135 -0
- package/src/xidentity-capabilities.ts +1012 -0
- package/src/xidentity-conformance.ts +370 -0
- package/src/xidentity-connector.ts +269 -0
- package/src/xidentity-consent-client.gen.ts +10 -0
- package/src/xidentity-consent-ui.ts +113 -0
- package/src/xidentity-journey.uitest.ts +277 -0
- package/src/xidentity-pkce.ts +29 -0
- package/src/xidentity-problems.ts +128 -0
- package/src/xidentity-scopes.ts +96 -0
- package/src/xidentity-server.ts +97 -0
- package/src/xidentity-store.ts +419 -0
- package/src/xidentity-twin.ts +944 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
// X identity twin — the SERVER HALF of the authorize screen.
|
|
2
|
+
//
|
|
3
|
+
// Named `-consent-ui` rather than `-mirror-ui` on purpose (the googleoauth class doctrine): a
|
|
4
|
+
// dashboard mirror is a SECOND renderer this repo writes over a vendor's data, whereas this is the
|
|
5
|
+
// vendor's OWN page, on the vendor's OWN path, in the middle of the vendor's OWN protocol. The
|
|
6
|
+
// mirror DISCIPLINE still applies in full — one renderer, data-coupled to the projection, driven
|
|
7
|
+
// by a real browser journey.
|
|
8
|
+
//
|
|
9
|
+
// The pieces:
|
|
10
|
+
// • `xIdentityConsentState` — THE STATE BUILDER. Everything on screen is derived here, from the
|
|
11
|
+
// kernel projection, and nowhere else. It is the seam `scripts/mutation-test.ts` sabotages in
|
|
12
|
+
// the mirror phase (TWIN-20/B9): with it dead the screen has no app name, no account and no
|
|
13
|
+
// scopes, so every UI capability must go red while the write/handler path stays real.
|
|
14
|
+
// • `consentPageHtml` / `errorPageHtml` — server-render the REAL exported React components with
|
|
15
|
+
// `renderToStaticMarkup`. There is no template string of vendor markup anywhere.
|
|
16
|
+
// • `CONSENT_CLIENT_JS` / `CONSENT_CLIENT_CSS` — the browser bundle and stylesheet as COMMITTED TEXT
|
|
17
|
+
// (`xidentity-consent-client.gen.ts`, written by `scripts/consent-clients.ts` and drift-gated):
|
|
18
|
+
// progressive enhancement only, and the serve path never builds (runtime contract R12b, R9).
|
|
19
|
+
import { createElement } from 'react';
|
|
20
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
21
|
+
import {
|
|
22
|
+
ConsentPage,
|
|
23
|
+
ErrorPage,
|
|
24
|
+
type ConsentAccount,
|
|
25
|
+
type ConsentScopeRow,
|
|
26
|
+
type ConsentView,
|
|
27
|
+
type ErrorPageProps,
|
|
28
|
+
} from '../client/xidentity-consent.tsx';
|
|
29
|
+
import { describeScopes, parseScopeParam, sortScopesForConsent } from './xidentity-scopes.ts';
|
|
30
|
+
import { readOne, type Row } from './xidentity-store.ts';
|
|
31
|
+
|
|
32
|
+
export type { ConsentAccount, ConsentScopeRow, ConsentView, ErrorPageProps } from '../client/xidentity-consent.tsx';
|
|
33
|
+
export { XIDENTITY_CONSENT_CLIENT_CSS as CONSENT_CLIENT_CSS, XIDENTITY_CONSENT_CLIENT_JS as CONSENT_CLIENT_JS } from './xidentity-consent-client.gen.ts';
|
|
34
|
+
|
|
35
|
+
/** Twin-only asset paths, clearly namespaced so they can never be mistaken for vendor surface. */
|
|
36
|
+
export const CONSENT_SCRIPT_PATH = '/_twin/assets/consent.js';
|
|
37
|
+
export const CONSENT_STYLE_PATH = '/_twin/assets/consent.css';
|
|
38
|
+
|
|
39
|
+
function toConsentAccount(row: Row): ConsentAccount {
|
|
40
|
+
return {
|
|
41
|
+
id: row.id,
|
|
42
|
+
username: String(row.username ?? ''),
|
|
43
|
+
name: String(row.name ?? ''),
|
|
44
|
+
profileImageUrl: String(row.profileImageUrl ?? ''),
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* THE STATE BUILDER — the authorize screen's entire view model, folded out of the kernel
|
|
50
|
+
* projection. Returns `null` when the authorization request is unknown or already settled, or when
|
|
51
|
+
* no x.com session exists to consent as (the caller renders the error page); it never invents an
|
|
52
|
+
* app name, an account or a scope row.
|
|
53
|
+
*/
|
|
54
|
+
export function xIdentityConsentState(opts: {
|
|
55
|
+
root?: string;
|
|
56
|
+
requestId: string;
|
|
57
|
+
origin: string;
|
|
58
|
+
}): ConsentView | null {
|
|
59
|
+
const authRequest = readOne(opts.root, 'auth_request', opts.requestId);
|
|
60
|
+
if (!authRequest || authRequest.settled === true) return null;
|
|
61
|
+
const client = readOne(opts.root, 'oauth_client', String(authRequest.clientId));
|
|
62
|
+
if (!client) return null;
|
|
63
|
+
const account = readOne(opts.root, 'account', String(authRequest.accountId ?? ''));
|
|
64
|
+
if (!account) return null;
|
|
65
|
+
|
|
66
|
+
const requested = sortScopesForConsent(parseScopeParam(String(authRequest.scope ?? '')));
|
|
67
|
+
const scopes: ConsentScopeRow[] = describeScopes(requested).map((s) => ({
|
|
68
|
+
scope: s.scope,
|
|
69
|
+
label: s.label,
|
|
70
|
+
group: s.group,
|
|
71
|
+
known: s.known,
|
|
72
|
+
}));
|
|
73
|
+
|
|
74
|
+
let redirectHost = '';
|
|
75
|
+
try {
|
|
76
|
+
redirectHost = new URL(String(authRequest.redirectUri)).host;
|
|
77
|
+
} catch {
|
|
78
|
+
redirectHost = String(authRequest.redirectUri);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
requestId: opts.requestId,
|
|
83
|
+
origin: opts.origin,
|
|
84
|
+
app: { clientId: client.id, name: String(client.name ?? '') },
|
|
85
|
+
account: toConsentAccount(account),
|
|
86
|
+
scopes,
|
|
87
|
+
redirectHost,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** `base` is where the twin is reached (`twinPublicBase`: origin plus any served-World mount path),
|
|
92
|
+
* so the page's assets resolve inside the World's mount; empty for an in-process render. */
|
|
93
|
+
function page(title: string, bodyMarkup: string, base: string): string {
|
|
94
|
+
return `<!doctype html>
|
|
95
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
96
|
+
<title>${escapeHtml(title)}</title><link rel="stylesheet" href="${base}${CONSENT_STYLE_PATH}"></head>
|
|
97
|
+
<body>${bodyMarkup}<div id="consent-enhanced"></div><script type="module" src="${base}${CONSENT_SCRIPT_PATH}"></script></body></html>`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function escapeHtml(s: string): string {
|
|
101
|
+
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Render the authorize page from a view model. The tab title mirrors X's "Authorize <app>". */
|
|
105
|
+
export function consentPageHtml(view: ConsentView, base = ''): string {
|
|
106
|
+
const markup = renderToStaticMarkup(createElement(ConsentPage, { view }));
|
|
107
|
+
return page(`Authorize ${view.app.name} to access your account? / X`, markup, base);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Render the authorize-endpoint error page (an un-redirectable failure). */
|
|
111
|
+
export function errorPageHtml(props: ErrorPageProps, base = ''): string {
|
|
112
|
+
return page(`Error ${props.status}: ${props.code} / X`, renderToStaticMarkup(createElement(ErrorPage, props)), base);
|
|
113
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
// X IDENTITY UI JOURNEY — the authorize leg, driven in a real headless chromium.
|
|
2
|
+
//
|
|
3
|
+
// The browser is the protocol participant: the app redirects, a human reads what the App is asking
|
|
4
|
+
// for, clicks "Authorize app" (or Cancel), and the browser is bounced back to the app's callback
|
|
5
|
+
// carrying a code — which the test then REDEEMS at the twin's token endpoint and USES at
|
|
6
|
+
// /2/users/me. A journey that stopped at "the screen rendered" would prove the page exists; this
|
|
7
|
+
// one proves the round trip closes.
|
|
8
|
+
//
|
|
9
|
+
// Seeds through the pack's OWN write path (the twin-only client/session controls and the real
|
|
10
|
+
// authorize endpoint — never a hand-written events.jsonl), boots the real twin handler on an
|
|
11
|
+
// ephemeral port with the app's callback page alongside it (see `startJourneyServer`), and drives
|
|
12
|
+
// it with role/text locators only. Every step writes a filmstrip frame (the harness helpers do
|
|
13
|
+
// it), so a reviewer can LOOK at the screens rather than trusting a green tick.
|
|
14
|
+
import { describe, expect, test } from 'bun:test';
|
|
15
|
+
import { atPath, clickByName, requireBrowser, runUiJourney, visible } from '@volter/world-tooling';
|
|
16
|
+
import { CONSENT_CLIENT_CSS, CONSENT_CLIENT_JS, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH } from './xidentity-consent-ui.ts';
|
|
17
|
+
import { pkceS256 } from './xidentity-pkce.ts';
|
|
18
|
+
import { DEFAULT_ACCOUNTS } from './xidentity-store.ts';
|
|
19
|
+
import { handleXIdentityTwinRequest } from './xidentity-twin.ts';
|
|
20
|
+
|
|
21
|
+
const JOURNEY_LABEL = 'xidentity UI journey';
|
|
22
|
+
|
|
23
|
+
const CLIENT_ID = 'Sm91cm5leVNjaGVkdWxlcjoxOmNp';
|
|
24
|
+
const CLIENT_SECRET = 'journey-secret-0000000000000000000000000000000000';
|
|
25
|
+
const APP_NAME = 'Twin Journey Scheduler';
|
|
26
|
+
const SCOPE = 'tweet.read users.read offline.access';
|
|
27
|
+
const VERIFIER = 'journey-verifier-0123456789-0123456789-01234567';
|
|
28
|
+
const ADA = DEFAULT_ACCOUNTS[0]!;
|
|
29
|
+
/** The integrating app's callback, served on the same loopback origin (see `startJourneyServer`). */
|
|
30
|
+
const APP_CALLBACK_PATH = '/oauth/callback';
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The journey's server: the REAL twin handler, plus a tiny `/oauth/callback` page standing in for
|
|
34
|
+
* the integrating app.
|
|
35
|
+
*
|
|
36
|
+
* WHY THEY SHARE ONE ORIGIN. The journey harness is loopback-only and installs a route guard that
|
|
37
|
+
* ABORTS any request leaving the server origin it was given (`uiJourney.ts`) — so a callback
|
|
38
|
+
* server on its own port is unreachable from the piloted page. Sharing the loopback origin is a
|
|
39
|
+
* harness accommodation, not a fidelity claim; the SDK fidelity test exercises the ordinary
|
|
40
|
+
* cross-origin configuration. What the journey proves is the leg only a browser can: a human's
|
|
41
|
+
* click turning into a code on the app's callback URL.
|
|
42
|
+
*/
|
|
43
|
+
function startJourneyServer(root: string): { url: string; stop: () => void; last: () => URL | null } {
|
|
44
|
+
let last: URL | null = null;
|
|
45
|
+
const server = Bun.serve({
|
|
46
|
+
hostname: '127.0.0.1',
|
|
47
|
+
port: 0,
|
|
48
|
+
idleTimeout: 30,
|
|
49
|
+
async fetch(request) {
|
|
50
|
+
const url = new URL(request.url);
|
|
51
|
+
|
|
52
|
+
// ── the harness's landing page ──
|
|
53
|
+
// `runUiJourney` navigates to the server URL and waits for `#root` to have children before
|
|
54
|
+
// it hands the page over. A vendor twin has no app shell at `/` (x.com's root is the
|
|
55
|
+
// timeline, which this pack does not model), so the JOURNEY supplies one. Test scaffolding
|
|
56
|
+
// in this file — never pack surface.
|
|
57
|
+
if (url.pathname === '/') {
|
|
58
|
+
return new Response(
|
|
59
|
+
`<!doctype html><html><body><div id="root"><h1>Twin X world</h1>`
|
|
60
|
+
+ `<p>the ${APP_NAME} integration starts by redirecting to the x.com authorize page</p></div></body></html>`,
|
|
61
|
+
{ headers: { 'content-type': 'text/html; charset=utf-8' } },
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── the app's callback ──
|
|
66
|
+
if (url.pathname === APP_CALLBACK_PATH) {
|
|
67
|
+
last = url;
|
|
68
|
+
const code = url.searchParams.get('code');
|
|
69
|
+
const error = url.searchParams.get('error');
|
|
70
|
+
const body = code
|
|
71
|
+
? `<h1>${APP_NAME}</h1><p>authorization code received</p><pre>${code}</pre>`
|
|
72
|
+
: `<h1>${APP_NAME}</h1><p>authorization failed</p><pre>${error ?? 'no error parameter'}</pre>`;
|
|
73
|
+
return new Response(`<!doctype html><html><body>${body}</body></html>`, {
|
|
74
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── everything else: the real twin ──
|
|
79
|
+
if (request.method === 'GET' && url.pathname === CONSENT_SCRIPT_PATH) {
|
|
80
|
+
return new Response(CONSENT_CLIENT_JS, { headers: { 'content-type': 'text/javascript; charset=utf-8' } });
|
|
81
|
+
}
|
|
82
|
+
if (request.method === 'GET' && url.pathname === CONSENT_STYLE_PATH) {
|
|
83
|
+
return new Response(CONSENT_CLIENT_CSS, { headers: { 'content-type': 'text/css; charset=utf-8' } });
|
|
84
|
+
}
|
|
85
|
+
const body = request.method === 'GET' || request.method === 'HEAD' ? '' : await request.text();
|
|
86
|
+
const headers: Record<string, string> = {};
|
|
87
|
+
request.headers.forEach((value, key) => { headers[key.toLowerCase()] = value; });
|
|
88
|
+
const res = await handleXIdentityTwinRequest({
|
|
89
|
+
method: request.method,
|
|
90
|
+
path: url.pathname + (url.search || ''),
|
|
91
|
+
body,
|
|
92
|
+
headers,
|
|
93
|
+
root,
|
|
94
|
+
occurredAt: new Date().toISOString(),
|
|
95
|
+
origin: url.origin,
|
|
96
|
+
});
|
|
97
|
+
const out = { ...(res.headers ?? {}) };
|
|
98
|
+
if (typeof res.body === 'string') {
|
|
99
|
+
if (!out['content-type'] && res.body) out['content-type'] = 'text/html; charset=utf-8';
|
|
100
|
+
return new Response(res.body, { status: res.status, headers: out });
|
|
101
|
+
}
|
|
102
|
+
out['content-type'] = out['content-type'] ?? 'application/json; charset=utf-8';
|
|
103
|
+
return new Response(JSON.stringify(res.body), { status: res.status, headers: out });
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
return { url: `http://127.0.0.1:${server.port}`, stop: () => server.stop(true), last: () => last };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Register the journey's OAuth client through the twin's own write path. */
|
|
110
|
+
async function seedClient(twinOrigin: string, redirectUri: string) {
|
|
111
|
+
const res = await fetch(`${twinOrigin}/_twin/clients`, {
|
|
112
|
+
method: 'POST',
|
|
113
|
+
headers: { 'content-type': 'application/json' },
|
|
114
|
+
body: JSON.stringify({
|
|
115
|
+
client_id: CLIENT_ID,
|
|
116
|
+
client_secret: CLIENT_SECRET,
|
|
117
|
+
name: APP_NAME,
|
|
118
|
+
client_type: 'confidential',
|
|
119
|
+
redirect_uris: [redirectUri],
|
|
120
|
+
}),
|
|
121
|
+
});
|
|
122
|
+
expect(res.status).toBe(200);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const authUrl = (twinOrigin: string, redirectUri: string, extra: Record<string, string> = {}) =>
|
|
126
|
+
`${twinOrigin}/i/oauth2/authorize?${new URLSearchParams({
|
|
127
|
+
response_type: 'code',
|
|
128
|
+
client_id: CLIENT_ID,
|
|
129
|
+
redirect_uri: redirectUri,
|
|
130
|
+
scope: SCOPE,
|
|
131
|
+
state: 'journey-state-1',
|
|
132
|
+
code_challenge: pkceS256(VERIFIER),
|
|
133
|
+
code_challenge_method: 'S256',
|
|
134
|
+
...extra,
|
|
135
|
+
})}`;
|
|
136
|
+
|
|
137
|
+
describe('xidentity UI journey', () => {
|
|
138
|
+
test('a human reads the authorize screen, clicks Authorize app — and the code redeems into a usable identity', async () => {
|
|
139
|
+
if (!(await requireBrowser(JOURNEY_LABEL))) return;
|
|
140
|
+
|
|
141
|
+
// Captured inside the journey while the server is still up; asserted after teardown, so a
|
|
142
|
+
// torn-down fixture can never read as a product failure.
|
|
143
|
+
let redeemed: Record<string, any> | null = null;
|
|
144
|
+
let identity: Record<string, any> | null = null;
|
|
145
|
+
let callback: URL | null = null;
|
|
146
|
+
|
|
147
|
+
await runUiJourney({
|
|
148
|
+
label: 'xidentity authorize round trip',
|
|
149
|
+
// The pack's write path IS its HTTP server, and the harness calls `seed` BEFORE `serve` —
|
|
150
|
+
// so the client registration happens as the journey's first step instead of here.
|
|
151
|
+
seed: async () => {},
|
|
152
|
+
serve: (root) => startJourneyServer(root),
|
|
153
|
+
journey: async (page) => {
|
|
154
|
+
const origin = new URL(page.url()).origin;
|
|
155
|
+
const redirectUri = `${origin}${APP_CALLBACK_PATH}`;
|
|
156
|
+
await seedClient(origin, redirectUri);
|
|
157
|
+
|
|
158
|
+
// 1. The app redirects the browser to the vendor. What lands is X's AUTHORIZE screen,
|
|
159
|
+
// naming the registered app, the signed-in account, and the scopes in X's own wording.
|
|
160
|
+
await page.goto(authUrl(origin, redirectUri));
|
|
161
|
+
await atPath(page, '/i/oauth2/authorize');
|
|
162
|
+
await visible(page, `${APP_NAME} wants to access your X account`);
|
|
163
|
+
await visible(page, ADA.name);
|
|
164
|
+
await visible(page, `@${ADA.username}`);
|
|
165
|
+
await visible(page, `Things ${APP_NAME} can view`);
|
|
166
|
+
await visible(page, 'View all posts you can see, including those from protected accounts.');
|
|
167
|
+
await visible(page, 'View any account you can see, including protected accounts.');
|
|
168
|
+
await visible(page, 'Stay connected to your account until you revoke access.');
|
|
169
|
+
|
|
170
|
+
// 2. Authorize. The browser leaves the vendor's path and lands on the APP's callback.
|
|
171
|
+
await clickByName(page, 'Authorize app');
|
|
172
|
+
await atPath(page, APP_CALLBACK_PATH);
|
|
173
|
+
await visible(page, 'authorization code received');
|
|
174
|
+
|
|
175
|
+
callback = new URL(page.url());
|
|
176
|
+
const code = callback.searchParams.get('code')!;
|
|
177
|
+
|
|
178
|
+
// 3. THE POINT OF THE WHOLE FLOW: the code a browser CLICK produced is redeemable at the
|
|
179
|
+
// token endpoint, and the token it yields answers /2/users/me with the account that
|
|
180
|
+
// was signed in on the screen.
|
|
181
|
+
const tokenRes = await fetch(`${origin}/2/oauth2/token`, {
|
|
182
|
+
method: 'POST',
|
|
183
|
+
headers: {
|
|
184
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
185
|
+
authorization: `Basic ${Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString('base64')}`,
|
|
186
|
+
},
|
|
187
|
+
body: new URLSearchParams({
|
|
188
|
+
grant_type: 'authorization_code',
|
|
189
|
+
code,
|
|
190
|
+
client_id: CLIENT_ID,
|
|
191
|
+
redirect_uri: redirectUri,
|
|
192
|
+
code_verifier: VERIFIER,
|
|
193
|
+
}).toString(),
|
|
194
|
+
});
|
|
195
|
+
redeemed = (await tokenRes.json()) as Record<string, any>;
|
|
196
|
+
|
|
197
|
+
const me = await fetch(`${origin}/2/users/me`, {
|
|
198
|
+
headers: { authorization: `Bearer ${redeemed.access_token}` },
|
|
199
|
+
});
|
|
200
|
+
identity = (await me.json()) as Record<string, any>;
|
|
201
|
+
},
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
expect(callback!.searchParams.get('state')).toBe('journey-state-1');
|
|
205
|
+
expect(Buffer.from(callback!.searchParams.get('code')!, 'base64url').toString('utf8')).toContain(':ac:');
|
|
206
|
+
expect(redeemed!.token_type).toBe('bearer');
|
|
207
|
+
expect(redeemed!.scope).toBe(SCOPE);
|
|
208
|
+
expect(Buffer.from(String(redeemed!.refresh_token), 'base64url').toString('utf8')).toContain(':rt:');
|
|
209
|
+
// The account that was on the screen is the identity the token yields — the click really
|
|
210
|
+
// consented as that person.
|
|
211
|
+
expect(identity!.data.id).toBe(ADA.id);
|
|
212
|
+
expect(identity!.data.username).toBe(ADA.username);
|
|
213
|
+
}, 30_000);
|
|
214
|
+
|
|
215
|
+
test('Cancel bounces back with error=access_denied and no code', async () => {
|
|
216
|
+
if (!(await requireBrowser(JOURNEY_LABEL))) return;
|
|
217
|
+
|
|
218
|
+
let callback: URL | null = null;
|
|
219
|
+
await runUiJourney({
|
|
220
|
+
label: 'xidentity authorize denial',
|
|
221
|
+
seed: async () => {},
|
|
222
|
+
serve: (root) => startJourneyServer(root),
|
|
223
|
+
journey: async (page) => {
|
|
224
|
+
const origin = new URL(page.url()).origin;
|
|
225
|
+
const redirectUri = `${origin}${APP_CALLBACK_PATH}`;
|
|
226
|
+
await seedClient(origin, redirectUri);
|
|
227
|
+
|
|
228
|
+
await page.goto(authUrl(origin, redirectUri));
|
|
229
|
+
await visible(page, `${APP_NAME} wants to access your X account`);
|
|
230
|
+
await clickByName(page, 'Cancel');
|
|
231
|
+
await atPath(page, APP_CALLBACK_PATH);
|
|
232
|
+
await visible(page, 'authorization failed');
|
|
233
|
+
await visible(page, 'access_denied');
|
|
234
|
+
callback = new URL(page.url());
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
expect(callback!.searchParams.get('error')).toBe('access_denied');
|
|
239
|
+
expect(callback!.searchParams.get('state')).toBe('journey-state-1');
|
|
240
|
+
expect(callback!.searchParams.get('code')).toBeNull();
|
|
241
|
+
}, 30_000);
|
|
242
|
+
|
|
243
|
+
test('a misconfigured redirect_uri stays on the vendor error page, and the app is NEVER reached', async () => {
|
|
244
|
+
if (!(await requireBrowser(JOURNEY_LABEL))) return;
|
|
245
|
+
|
|
246
|
+
let finalUrl = '';
|
|
247
|
+
// The HANDLE, kept so the assertion can read it AFTER the journey (the googleoauth §9 lesson:
|
|
248
|
+
// reading `last()` inside `serve` stores a value that is null by construction, and the
|
|
249
|
+
// security assertion could then never fail).
|
|
250
|
+
let app: { last: () => URL | null } | null = null;
|
|
251
|
+
await runUiJourney({
|
|
252
|
+
label: 'xidentity redirect_uri mismatch error page',
|
|
253
|
+
seed: async () => {},
|
|
254
|
+
serve: (root) => {
|
|
255
|
+
const server = startJourneyServer(root);
|
|
256
|
+
app = server;
|
|
257
|
+
return server;
|
|
258
|
+
},
|
|
259
|
+
journey: async (page) => {
|
|
260
|
+
const origin = new URL(page.url()).origin;
|
|
261
|
+
await seedClient(origin, `${origin}${APP_CALLBACK_PATH}`);
|
|
262
|
+
|
|
263
|
+
// The single most common integration bug, seen the way a developer sees it: the browser
|
|
264
|
+
// STAYS on the vendor showing the error, and the app is never reached.
|
|
265
|
+
await page.goto(authUrl(origin, `${origin}/oauth/WRONG`));
|
|
266
|
+
await atPath(page, '/i/oauth2/authorize');
|
|
267
|
+
await visible(page, 'Something went wrong');
|
|
268
|
+
await visible(page, 'Error 400: invalid_request');
|
|
269
|
+
finalUrl = page.url();
|
|
270
|
+
},
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
expect(new URL(finalUrl).pathname, 'the browser must end on the vendor authorize path showing the error').toBe('/i/oauth2/authorize');
|
|
274
|
+
// Read AFTER the journey: the app must never have been reached at all.
|
|
275
|
+
expect(app!.last(), 'the app callback must never have been called').toBeNull();
|
|
276
|
+
}, 30_000);
|
|
277
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// PKCE (RFC 7636) — real SHA-256, no stubs. X's documented code_challenge_method values are
|
|
2
|
+
// `S256` and `plain` (docs.x.com authorization-code guide, fetched 2026-08-21).
|
|
3
|
+
//
|
|
4
|
+
// CASE: the docs write `S256`; X's LEGACY official SDK (twitter-api-typescript-sdk, OAuth2User.
|
|
5
|
+
// generateAuthURL) emits the lowercase `s256` in its authorize URLs, so a twin that refused the
|
|
6
|
+
// lowercase form would refuse the vendor's own client. Both spellings are accepted and normalised
|
|
7
|
+
// here; the exact live tolerance is pinned as a manifest todo rather than asserted
|
|
8
|
+
// (`xidentity.authorize.challenge_method_case`).
|
|
9
|
+
import { createHash } from 'node:crypto';
|
|
10
|
+
|
|
11
|
+
/** base64url(SHA-256(verifier)) — the S256 code_challenge for a code_verifier. */
|
|
12
|
+
export function pkceS256(verifier: string): string {
|
|
13
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export type ChallengeMethod = 'S256' | 'plain';
|
|
17
|
+
|
|
18
|
+
/** Normalise a caller-supplied code_challenge_method, or null when it isn't a documented value. */
|
|
19
|
+
export function normalizeChallengeMethod(raw: string | null): ChallengeMethod | null {
|
|
20
|
+
if (raw === null) return null;
|
|
21
|
+
if (raw === 'plain') return 'plain';
|
|
22
|
+
if (raw.toUpperCase() === 'S256') return 'S256';
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Does `verifier` satisfy the challenge the code was minted against? */
|
|
27
|
+
export function pkceVerifies(method: ChallengeMethod, challenge: string, verifier: string): boolean {
|
|
28
|
+
return method === 'S256' ? pkceS256(verifier) === challenge : verifier === challenge;
|
|
29
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// X API v2 error envelopes — the vendor's THREE distinct error families, kept distinct because an
|
|
2
|
+
// integration can (and X's own SDKs do) branch on them:
|
|
3
|
+
//
|
|
4
|
+
// 1. OAuth token-endpoint errors: the RFC 6749 §5.2 flat body `{ error, error_description }`.
|
|
5
|
+
// The docs show no error examples (user-access-token guide, fetched 2026-08-21), so the
|
|
6
|
+
// `error` codes here are RFC 6749's and the descriptions are the widely-reported X wire
|
|
7
|
+
// strings — the EVIDENCE BOUNDARY is marked at each call site and the exact texts are filed
|
|
8
|
+
// as `xidentity.token.error_wording` (todo) rather than claimed pinned.
|
|
9
|
+
//
|
|
10
|
+
// 2. v2 problem envelopes (`application/problem+json` shapes): the OpenAPI 2.167 `Problem`
|
|
11
|
+
// family, discriminated by a `type` URL (`https://api.x.com/2/problems/…`). The generic
|
|
12
|
+
// auth failure is the `about:blank` form (`{title, type: "about:blank", status, detail}`) —
|
|
13
|
+
// observed wire behaviour for a bad bearer, marked extrapolated where the doc trail stops.
|
|
14
|
+
//
|
|
15
|
+
// 3. The request-level invalid-parameter envelope: `{errors: [{parameters, message}], title:
|
|
16
|
+
// "Invalid Request", detail: "One or more parameters to your request was invalid.", type:
|
|
17
|
+
// …/invalid-request}` — the OpenAPI's InvalidRequestProblem, with the per-parameter `errors`
|
|
18
|
+
// array the wire carries.
|
|
19
|
+
//
|
|
20
|
+
// 4. The 15-minute-window rate refusal: HTTP 429 with legacy error code 88 — docs.x.com
|
|
21
|
+
// fundamentals/rate-limits (fetched 2026-08-21) documents "a 429 error with error code 88:
|
|
22
|
+
// Rate limit exceeded" verbatim, hence the v1.1-style `{errors: [{code, message}]}` body.
|
|
23
|
+
|
|
24
|
+
export type XResponse = {
|
|
25
|
+
status: number;
|
|
26
|
+
body: unknown;
|
|
27
|
+
headers?: Record<string, string>;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const NOSTORE = { 'cache-control': 'no-cache, no-store, max-age=0' };
|
|
31
|
+
|
|
32
|
+
// ── family 1: OAuth token endpoint (RFC 6749 flat body) ─────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
export function oauthError(error: string, description: string, status: number): XResponse {
|
|
35
|
+
return { status, body: { error, error_description: description }, headers: { ...NOSTORE } };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The ONE answer an unusable authorization code gets — unknown, already-redeemed, expired, minted
|
|
40
|
+
* for another client, wrong redirect_uri pairing or failed PKCE are deliberately indistinguishable
|
|
41
|
+
* (telling them apart would leak which codes exist). The description is the widely-reported X wire
|
|
42
|
+
* string for this case; the `error` code X pairs with it is reported as `invalid_request` (where
|
|
43
|
+
* RFC 6749 would say `invalid_grant`) and the twin follows the REPORTED WIRE, marked extrapolated.
|
|
44
|
+
*/
|
|
45
|
+
export function badAuthorizationCode(): XResponse {
|
|
46
|
+
return oauthError('invalid_request', 'Value passed for the authorization code was invalid.', 400);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The refresh-token twin of the above — same reported-wire caveat. */
|
|
50
|
+
export function badRefreshToken(): XResponse {
|
|
51
|
+
return oauthError('invalid_request', 'Value passed for the token was invalid.', 400);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Client authentication failed at the token/revoke endpoint (RFC 6749 §5.2: 401 invalid_client). */
|
|
55
|
+
export function invalidClient(): XResponse {
|
|
56
|
+
return oauthError('invalid_client', 'Client authentication failed.', 401);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── family 2: v2 problem envelopes ──────────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
/** The generic auth problem X answers a bad/absent/expired bearer with on /2 resources. */
|
|
62
|
+
export function unauthorizedProblem(): XResponse {
|
|
63
|
+
return {
|
|
64
|
+
status: 401,
|
|
65
|
+
body: { title: 'Unauthorized', type: 'about:blank', status: 401, detail: 'Unauthorized' },
|
|
66
|
+
headers: { ...NOSTORE, 'content-type': 'application/problem+json' },
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* A user token whose scope set does not cover the endpoint. The 403 + problem SHAPE follows the
|
|
72
|
+
* OpenAPI's default-error contract; X's exact wording for the insufficient-scope case was not
|
|
73
|
+
* captured from an official artefact, so the twin states the refusal plainly rather than inventing
|
|
74
|
+
* vendor prose — pinning the live body is `xidentity.users_me.insufficient_scope_wording` (todo).
|
|
75
|
+
*/
|
|
76
|
+
export function forbiddenProblem(detail: string): XResponse {
|
|
77
|
+
return {
|
|
78
|
+
status: 403,
|
|
79
|
+
body: { title: 'Forbidden', type: 'about:blank', status: 403, detail },
|
|
80
|
+
headers: { ...NOSTORE, 'content-type': 'application/problem+json' },
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** An unmodelled /2 route fails like the vendor: 404 problem, never a fake success. */
|
|
85
|
+
export function notFoundProblem(): XResponse {
|
|
86
|
+
return {
|
|
87
|
+
status: 404,
|
|
88
|
+
body: { title: 'Not Found Error', type: 'about:blank', status: 404, detail: 'Not Found' },
|
|
89
|
+
headers: { ...NOSTORE, 'content-type': 'application/problem+json' },
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A contained unexpected failure on the v2 resource surface. Never exposes local exception text. */
|
|
94
|
+
export function internalServerProblem(): XResponse {
|
|
95
|
+
return {
|
|
96
|
+
status: 500,
|
|
97
|
+
body: { title: 'Internal Server Error', type: 'about:blank', status: 500, detail: 'Internal Server Error' },
|
|
98
|
+
headers: { ...NOSTORE, 'content-type': 'application/problem+json' },
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The invalid-parameter envelope (OpenAPI InvalidRequestProblem + the wire's per-parameter
|
|
104
|
+
* `errors` array). `parameters` maps the offending query parameter to the values it arrived with.
|
|
105
|
+
*/
|
|
106
|
+
export function invalidRequestProblem(parameters: Record<string, string[]>, message: string): XResponse {
|
|
107
|
+
return {
|
|
108
|
+
status: 400,
|
|
109
|
+
body: {
|
|
110
|
+
errors: [{ parameters, message }],
|
|
111
|
+
title: 'Invalid Request',
|
|
112
|
+
detail: 'One or more parameters to your request was invalid.',
|
|
113
|
+
type: 'https://api.x.com/2/problems/invalid-request',
|
|
114
|
+
},
|
|
115
|
+
headers: { ...NOSTORE, 'content-type': 'application/json' },
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── family 4: the rate refusal ──────────────────────────────────────────────────────────────────
|
|
120
|
+
|
|
121
|
+
/** HTTP 429, legacy code 88 — the documented pair (docs.x.com fundamentals/rate-limits). */
|
|
122
|
+
export function rateLimitExceeded(headers: Record<string, string>): XResponse {
|
|
123
|
+
return {
|
|
124
|
+
status: 429,
|
|
125
|
+
body: { errors: [{ code: 88, message: 'Rate limit exceeded' }] },
|
|
126
|
+
headers: { ...NOSTORE, ...headers, 'content-type': 'application/json' },
|
|
127
|
+
};
|
|
128
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// X OAuth 2.0 scopes — the vendor's own catalog, transcribed VERBATIM from the X API v2 OpenAPI
|
|
2
|
+
// document's `OAuth2UserToken` security scheme (https://api.x.com/2/openapi.json, version 2.167,
|
|
3
|
+
// fetched 2026-08-21). The description strings are X's own consent-screen wording, so the authorize
|
|
4
|
+
// page the twin serves shows exactly what the vendor's shows for each scope.
|
|
5
|
+
//
|
|
6
|
+
// The screen model: X's authorize page is ALL-OR-NOTHING. It lists what the app will be able to
|
|
7
|
+
// view and do, with exactly two buttons — "Authorize app" and "Cancel" — and no per-scope
|
|
8
|
+
// checkboxes (unlike Google's granular consent). The grant is the full requested scope set or
|
|
9
|
+
// nothing.
|
|
10
|
+
|
|
11
|
+
export type ScopeInfo = {
|
|
12
|
+
scope: string;
|
|
13
|
+
/** X's own consent wording for the scope (OpenAPI securityScheme description). */
|
|
14
|
+
label: string;
|
|
15
|
+
/** How the authorize screen groups it: things the app can VIEW vs DO vs the session promise. */
|
|
16
|
+
group: 'view' | 'do' | 'session';
|
|
17
|
+
/** In the vendor catalog? An unknown scope is still displayed, flagged, so a typo is visible. */
|
|
18
|
+
known: boolean;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The 24 scopes X's OpenAPI declares, plus the announcement-grounded `users.email` (see its
|
|
22
|
+
* in-line note), keyed by scope id. */
|
|
23
|
+
export const SCOPE_CATALOG: Record<string, { label: string; group: 'view' | 'do' | 'session' }> = {
|
|
24
|
+
'block.read': { label: 'View accounts you have blocked.', group: 'view' },
|
|
25
|
+
'block.write': { label: 'Block and unblock accounts on your behalf.', group: 'do' },
|
|
26
|
+
'bookmark.read': { label: 'Read your bookmarked Posts.', group: 'view' },
|
|
27
|
+
'bookmark.write': { label: 'Create and delete your bookmarks.', group: 'do' },
|
|
28
|
+
'broadcast.read': { label: 'View your live broadcasts and their chat.', group: 'view' },
|
|
29
|
+
'broadcast.write': { label: 'Manage your live broadcasts and send chat messages on your behalf.', group: 'do' },
|
|
30
|
+
'dm.read': { label: 'Read all your Direct Messages.', group: 'view' },
|
|
31
|
+
'dm.write': { label: 'Send and manage your Direct Messages.', group: 'do' },
|
|
32
|
+
'follows.read': { label: 'View accounts you follow and accounts following you.', group: 'view' },
|
|
33
|
+
'follows.write': { label: 'Follow and unfollow accounts on your behalf.', group: 'do' },
|
|
34
|
+
'like.read': { label: 'View Posts you have liked and likes you can see.', group: 'view' },
|
|
35
|
+
'like.write': { label: 'Like and unlike Posts on your behalf.', group: 'do' },
|
|
36
|
+
'list.read': { label: 'View Lists, members, and followers of Lists you created or are a member of, including private Lists.', group: 'view' },
|
|
37
|
+
'list.write': { label: 'Create and manage Lists on your behalf.', group: 'do' },
|
|
38
|
+
'media.write': { label: 'Upload media, such as photos and videos, on your behalf.', group: 'do' },
|
|
39
|
+
'mute.read': { label: 'View accounts you have muted.', group: 'view' },
|
|
40
|
+
'mute.write': { label: 'Mute and unmute accounts on your behalf.', group: 'do' },
|
|
41
|
+
'offline.access': { label: 'Stay connected to your account until you revoke access.', group: 'session' },
|
|
42
|
+
'space.read': { label: 'View all Spaces you have access to.', group: 'view' },
|
|
43
|
+
'timeline.read': { label: 'View all Custom Timelines you can see.', group: 'view' },
|
|
44
|
+
'tweet.moderate.write': { label: 'Hide and unhide replies to your posts.', group: 'do' },
|
|
45
|
+
'tweet.read': { label: 'View all posts you can see, including those from protected accounts.', group: 'view' },
|
|
46
|
+
'tweet.write': { label: 'Create and repost on your behalf.', group: 'do' },
|
|
47
|
+
'users.read': { label: 'View any account you can see, including protected accounts.', group: 'view' },
|
|
48
|
+
// NOT in the 2.167 OpenAPI securityScheme catalog (a recorded intra-vendor discrepancy): X's own
|
|
49
|
+
// April-2025 announcement ("Announcing support for email address retrieval with OAuth 2.0 in the
|
|
50
|
+
// X API v2", devcommunity.x.com/t/240555, re-fetched 2026-08-21) adds `users.email` as the scope
|
|
51
|
+
// gating the `confirmed_email` field on /2/users/me. The label is NOT vendor wording —
|
|
52
|
+
// EXTRAPOLATION, pinned by `xidentity.scopes.users_email_wording` (todo).
|
|
53
|
+
'users.email': { label: 'Access your email address.', group: 'view' },
|
|
54
|
+
};
|
|
55
|
+
// EVIDENCE BOUNDARY: every label above is the OpenAPI description verbatim EXCEPT `offline.access`,
|
|
56
|
+
// whose OpenAPI description ("Request a refresh token for the app.") is developer-facing, not the
|
|
57
|
+
// consent-screen line. The consent-screen wording used here is the widely-reported "Stay connected
|
|
58
|
+
// …" line; it is not from a fetched official artefact and is marked as such in the manifest
|
|
59
|
+
// (`xidentity.consent.offline_access_wording`, todo).
|
|
60
|
+
|
|
61
|
+
export const KNOWN_SCOPES: readonly string[] = Object.keys(SCOPE_CATALOG);
|
|
62
|
+
|
|
63
|
+
/** Space-separated scope param → ordered unique scope list (X formats scope space-separated). */
|
|
64
|
+
export function parseScopeParam(raw: string | null): string[] {
|
|
65
|
+
if (!raw) return [];
|
|
66
|
+
const seen = new Set<string>();
|
|
67
|
+
const out: string[] = [];
|
|
68
|
+
for (const s of raw.split(/[\s+]+/)) {
|
|
69
|
+
// `+` appears when a caller form-encodes spaces as plus signs and a layer decodes only %XX.
|
|
70
|
+
if (!s || seen.has(s)) continue;
|
|
71
|
+
seen.add(s);
|
|
72
|
+
out.push(s);
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function formatScopeParam(scopes: readonly string[]): string {
|
|
78
|
+
return scopes.join(' ');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function describeScope(scope: string): ScopeInfo {
|
|
82
|
+
const known = SCOPE_CATALOG[scope];
|
|
83
|
+
return known
|
|
84
|
+
? { scope, label: known.label, group: known.group, known: true }
|
|
85
|
+
: { scope, label: scope, group: 'view', known: false };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function describeScopes(scopes: readonly string[]): ScopeInfo[] {
|
|
89
|
+
return scopes.map(describeScope);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Screen order: what the app can view, then what it can do, then the offline promise. */
|
|
93
|
+
export function sortScopesForConsent(scopes: readonly string[]): string[] {
|
|
94
|
+
const order: Record<'view' | 'do' | 'session', number> = { view: 0, do: 1, session: 2 };
|
|
95
|
+
return [...scopes].sort((a, b) => order[describeScope(a).group] - order[describeScope(b).group]);
|
|
96
|
+
}
|