@volter/twin-xidentity 0.1.0 → 0.1.2

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.
@@ -14,6 +14,7 @@
14
14
  // authorization request, a real consent, a real 302 carrying a real code+state, that code redeemed
15
15
  // (PKCE verified) for a real token, and that token answering GET /2/users/me with the consenting
16
16
  // persona. A router that dispatches every route while folding nothing cannot pass that.
17
+ import { createHmac } from 'node:crypto';
17
18
  import { mkdtempSync, rmSync } from 'node:fs';
18
19
  import { tmpdir } from 'node:os';
19
20
  import { join } from 'node:path';
@@ -41,6 +42,68 @@ const authQuery = `response_type=code&client_id=${encodeURIComponent(DEFAULT_CLI
41
42
  + `&state=probe-state&code_challenge=${pkceS256(VERIFIER)}&code_challenge_method=S256`;
42
43
  const form = (params) => new URLSearchParams(params).toString();
43
44
  const basic = `Basic ${Buffer.from(`${DEFAULT_CLIENT_ID}:${DEFAULT_CLIENT_SECRET}`).toString('base64')}`;
45
+ // ── an OAuth 1.0a CLIENT, written here from docs.x.com's creating-a-signature guide ─────────────
46
+ // Deliberately NOT the handler's verifier (xidentity-oauth1.ts): a check that signs with the same
47
+ // code it verifies with would agree with itself. The consumer library (twitter-api-v2) is the
48
+ // third, independent party in xidentity-oauth1.integration.test.ts.
49
+ const enc = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
50
+ let nonceCounter = 0;
51
+ /** The Authorization header for `method url` with these extra signed params (query + form body). */
52
+ export function oauth1Header(method, url, consumer, token, signed = {}) {
53
+ const [baseUrl, query = ''] = url.split('?');
54
+ const oauth = {
55
+ oauth_consumer_key: consumer.key,
56
+ oauth_nonce: `probe${(nonceCounter += 1)}`,
57
+ oauth_signature_method: 'HMAC-SHA1',
58
+ oauth_timestamp: '1769904000',
59
+ oauth_version: '1.0',
60
+ ...(token ? { oauth_token: token.key } : {}),
61
+ };
62
+ const params = [...Object.entries(oauth), ...Object.entries(signed), ...new URLSearchParams(query).entries()]
63
+ .map(([k, v]) => `${enc(k)}=${enc(v)}`)
64
+ .sort();
65
+ const base = `${method.toUpperCase()}&${enc(baseUrl)}&${enc(params.join('&'))}`;
66
+ oauth['oauth_signature'] = createHmac('sha1', `${enc(consumer.secret)}&${enc(token?.secret ?? '')}`).update(base).digest('base64');
67
+ return 'OAuth ' + Object.entries(oauth).map(([k, v]) => `${enc(k)}="${enc(v)}"`).join(', ');
68
+ }
69
+ /** The App the OAuth 1.0a probes run under, registered through the twin door. */
70
+ export const OAUTH1_PROBE_APP = { key: 'ProbeConsumerKey000000001', secret: 'ProbeConsumerSecret00000000000000000000000000001' };
71
+ export const OAUTH1_PROBE_CALLBACK = 'https://probe-app.local/x/callback';
72
+ const FORM_TYPE = { 'content-type': 'application/x-www-form-urlencoded' };
73
+ /** This check reaches the twin at the World's origin, so its client signs that URL (a client the
74
+ * injector re-aims signs https://api.x.com and the twin reads the host it named — proven by the
75
+ * twitter-api-v2 test). */
76
+ const API = DEMO_ORIGIN;
77
+ export function oauth1Fixtures(call) {
78
+ const requestToken = async () => {
79
+ const body = new URLSearchParams({ oauth_callback: OAUTH1_PROBE_CALLBACK }).toString();
80
+ const r = await call('POST', '/oauth/request_token', body, { ...FORM_TYPE, authorization: oauth1Header('POST', `${API}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: OAUTH1_PROBE_CALLBACK }) });
81
+ const got = new URLSearchParams(String(r.body));
82
+ return { key: got.get('oauth_token') ?? '', secret: got.get('oauth_token_secret') ?? '' };
83
+ };
84
+ const presented = async () => {
85
+ const t = await requestToken();
86
+ await call('GET', `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}`);
87
+ return t;
88
+ };
89
+ const approved = async () => {
90
+ const t = await requestToken();
91
+ const screen = await call('GET', `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}`);
92
+ const handle = /name="auth_request" value="([^"]+)"/.exec(String(screen.body))?.[1] ?? '';
93
+ const d = await call('POST', '/oauth/authorize', new URLSearchParams({ auth_request: handle, decision: 'allow' }).toString(), FORM_TYPE);
94
+ const verifier = new URL(d.headers?.location ?? 'http://invalid.test/').searchParams.get('oauth_verifier') ?? '';
95
+ return { ...t, verifier };
96
+ };
97
+ const accessToken = async () => {
98
+ const t = await approved();
99
+ const body = new URLSearchParams({ oauth_verifier: t.verifier }).toString();
100
+ const r = await call('POST', '/oauth/access_token', body, { ...FORM_TYPE, authorization: oauth1Header('POST', `${API}/oauth/access_token`, OAUTH1_PROBE_APP, t, { oauth_verifier: t.verifier }) });
101
+ const got = new URLSearchParams(String(r.body));
102
+ return { key: got.get('oauth_token') ?? '', secret: got.get('oauth_token_secret') ?? '' };
103
+ };
104
+ return { requestToken, presented, approved, accessToken };
105
+ }
106
+ const formOf = (b) => new URLSearchParams(typeof b === 'string' ? b : '');
44
107
  /** A representative request per declared endpoint, with the outcome a LIVE handler produces. */
45
108
  const PROBES = {
46
109
  'GET /i/oauth2/authorize': {
@@ -89,6 +152,82 @@ const PROBES = {
89
152
  && b.data['username'] === ADA.username
90
153
  && b.data['name'] === ADA.name,
91
154
  },
155
+ 'POST /oauth/request_token': {
156
+ method: 'POST',
157
+ path: '/oauth/request_token',
158
+ build: async () => ({
159
+ path: '/oauth/request_token',
160
+ body: new URLSearchParams({ oauth_callback: OAUTH1_PROBE_CALLBACK }).toString(),
161
+ headers: { ...FORM_TYPE, authorization: oauth1Header('POST', `${API}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: OAUTH1_PROBE_CALLBACK }) },
162
+ }),
163
+ status: [200],
164
+ // The documented response: oauth_token, oauth_token_secret and oauth_callback_confirmed=true.
165
+ expect: (b) => formOf(b).get('oauth_callback_confirmed') === 'true' && (formOf(b).get('oauth_token') ?? '') !== '' && (formOf(b).get('oauth_token_secret') ?? '') !== '',
166
+ },
167
+ 'GET /oauth/authorize': {
168
+ method: 'GET',
169
+ path: '/oauth/authorize',
170
+ build: async (fx) => ({ path: `/oauth/authorize?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}`, headers: {} }),
171
+ status: [200],
172
+ // The screen names the registered App, the signed-in persona, and what Read-and-write allows.
173
+ expect: htmlContaining('Authorize app', 'Cancel', '>Probe App<', `@${ADA.username}`, 'Post and delete Posts for you', 'probe-app.local'),
174
+ },
175
+ 'POST /oauth/authorize': {
176
+ method: 'POST',
177
+ path: '/oauth/authorize',
178
+ // The screen's Authorize app button: its form posts here, on the host that served the screen.
179
+ build: async (fx) => ({
180
+ path: '/oauth/authorize',
181
+ body: new URLSearchParams({ auth_request: (await fx.presented()).key, decision: 'allow' }).toString(),
182
+ headers: FORM_TYPE,
183
+ }),
184
+ status: [302],
185
+ },
186
+ 'GET /oauth/authenticate': {
187
+ method: 'GET',
188
+ path: '/oauth/authenticate',
189
+ // After the round trip below the persona has approved Probe App, so Sign in with X skips the
190
+ // screen: the answer is the callback itself, carrying a verifier (checked after the call) —
191
+ // only a projected grant can say so.
192
+ build: async (fx) => ({ path: `/oauth/authenticate?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}`, headers: {} }),
193
+ status: [302],
194
+ },
195
+ 'POST /oauth/access_token': {
196
+ method: 'POST',
197
+ path: '/oauth/access_token',
198
+ build: async (fx) => {
199
+ const t = await fx.approved();
200
+ return {
201
+ path: '/oauth/access_token',
202
+ body: new URLSearchParams({ oauth_verifier: t.verifier }).toString(),
203
+ headers: { ...FORM_TYPE, authorization: oauth1Header('POST', `${API}/oauth/access_token`, OAUTH1_PROBE_APP, t, { oauth_verifier: t.verifier }) },
204
+ };
205
+ },
206
+ status: [200],
207
+ // The documented example's shape: `<user id>-<40 alnum>`, a secret, user_id and screen_name.
208
+ expect: (b) => new RegExp(`^${ADA.id}-[A-Za-z0-9]{40}$`).test(formOf(b).get('oauth_token') ?? '')
209
+ && (formOf(b).get('oauth_token_secret') ?? '') !== '' && formOf(b).get('user_id') === ADA.id && formOf(b).get('screen_name') === ADA.username,
210
+ },
211
+ 'POST /1.1/oauth/invalidate_token': {
212
+ method: 'POST',
213
+ path: '/1.1/oauth/invalidate_token',
214
+ build: async (fx) => {
215
+ const t = await fx.accessToken();
216
+ return { path: '/1.1/oauth/invalidate_token', headers: { authorization: oauth1Header('POST', `${API}/1.1/oauth/invalidate_token`, OAUTH1_PROBE_APP, t) } };
217
+ },
218
+ status: [200],
219
+ expect: (b) => isObject(b) && new RegExp(`^${ADA.id}-[A-Za-z0-9]{40}$`).test(String(b['access_token'])) && Object.keys(b).length === 1,
220
+ },
221
+ 'POST /1.1/oauth/invalidate_token.json': {
222
+ method: 'POST',
223
+ path: '/1.1/oauth/invalidate_token.json',
224
+ build: async (fx) => {
225
+ const t = await fx.accessToken();
226
+ return { path: '/1.1/oauth/invalidate_token.json', headers: { authorization: oauth1Header('POST', `${API}/1.1/oauth/invalidate_token.json`, OAUTH1_PROBE_APP, t) } };
227
+ },
228
+ status: [200],
229
+ expect: (b) => isObject(b) && new RegExp(`^${ADA.id}-[A-Za-z0-9]{40}$`).test(String(b['access_token'])) && Object.keys(b).length === 1,
230
+ },
92
231
  };
93
232
  /**
94
233
  * Every VENDOR method/path pair a reader of `routeXIdentityTwinRequest` can see the router branch
@@ -112,6 +251,18 @@ const ROUTER_SURFACE = [
112
251
  ['GET', '/2/oauth2/authorize'],
113
252
  ['GET', '/2/users/by'],
114
253
  ['GET', '/oauth2/token'],
254
+ ['POST', '/oauth/request_token'],
255
+ ['GET', '/oauth/request_token'],
256
+ ['GET', '/oauth/authorize'],
257
+ ['POST', '/oauth/authorize'],
258
+ ['GET', '/oauth/authenticate'],
259
+ ['POST', '/oauth/authenticate'],
260
+ ['POST', '/oauth/access_token'],
261
+ ['GET', '/oauth/access_token'],
262
+ ['POST', '/1.1/oauth/invalidate_token'],
263
+ ['POST', '/1.1/oauth/invalidate_token.json'],
264
+ ['GET', '/1.1/oauth/invalidate_token'],
265
+ ['POST', '/oauth/invalidate_token'],
115
266
  ];
116
267
  const isNotFoundEnvelope = (res) => res.status === 404
117
268
  && isObject(res.body)
@@ -190,6 +341,32 @@ export async function checkXIdentityConformance(opts = {}) {
190
341
  const spent = await call('POST', '/2/oauth2/token', form({ grant_type: 'refresh_token', refresh_token: String(tokens?.refresh_token), client_id: DEFAULT_CLIENT_ID }), { authorization: basic });
191
342
  if (spent.status !== 400)
192
343
  violations.push(`a spent refresh token was accepted a second time: ${spent.status}`);
344
+ // ── the OAuth 1.0a round trip: request_token → screen → callback → access_token → users/me ──
345
+ await call('POST', '/_twin/oauth1_apps', JSON.stringify({ consumer_key: OAUTH1_PROBE_APP.key, consumer_secret: OAUTH1_PROBE_APP.secret, name: 'Probe App', callback_urls: [OAUTH1_PROBE_CALLBACK] }));
346
+ const fx = oauth1Fixtures(call);
347
+ const rt = await fx.requestToken();
348
+ if (!rt.key || !rt.secret)
349
+ violations.push('OAuth 1.0a: request_token did not issue a request token for a registered App');
350
+ const screen = await call('GET', `/oauth/authorize?oauth_token=${encodeURIComponent(rt.key)}`);
351
+ const rtHandle = /name="auth_request" value="([^"]+)"/.exec(String(screen.body))?.[1] ?? '';
352
+ if (rtHandle !== rt.key)
353
+ violations.push('OAuth 1.0a: the authorize screen did not carry the request token as its handle');
354
+ const approval = await call('POST', '/oauth/authorize', form({ auth_request: rtHandle, decision: 'allow' }), FORM_TYPE);
355
+ const cb = new URL(approval.headers?.location ?? 'http://invalid.test/');
356
+ if (approval.status !== 302 || `${cb.origin}${cb.pathname}` !== OAUTH1_PROBE_CALLBACK || cb.searchParams.get('oauth_token') !== rt.key
357
+ || [...cb.searchParams.keys()].sort().join(',') !== 'oauth_token,oauth_verifier') {
358
+ violations.push(`OAuth 1.0a: approval did not 302 to the callback with exactly oauth_token + oauth_verifier: ${approval.status} ${cb}`);
359
+ }
360
+ const verifierParam = cb.searchParams.get('oauth_verifier') ?? '';
361
+ const exchanged = await call('POST', '/oauth/access_token', form({ oauth_verifier: verifierParam }), { ...FORM_TYPE, authorization: oauth1Header('POST', `${API}/oauth/access_token`, OAUTH1_PROBE_APP, rt, { oauth_verifier: verifierParam }) });
362
+ const at1 = { key: formOf(exchanged.body).get('oauth_token') ?? '', secret: formOf(exchanged.body).get('oauth_token_secret') ?? '' };
363
+ const me1 = await call('GET', '/2/users/me', undefined, { authorization: oauth1Header('GET', `${API}/2/users/me`, OAUTH1_PROBE_APP, at1) });
364
+ if (me1.status !== 200 || me1.body?.data?.username !== ADA.username) {
365
+ violations.push(`OAuth 1.0a: the access token did not answer /2/users/me as the consenting persona: ${me1.status} ${JSON.stringify(me1.body).slice(0, 160)}`);
366
+ }
367
+ const forged = await call('GET', '/2/users/me', undefined, { authorization: oauth1Header('GET', `${API}/2/users/me`, OAUTH1_PROBE_APP, { key: at1.key, secret: 'forged' }) });
368
+ if (forged.status !== 401)
369
+ violations.push(`OAuth 1.0a: a signature made with the wrong token secret was accepted at /2/users/me: ${forged.status}`);
193
370
  // ── the endpoint census, THREE ways ──
194
371
  const claimed = new Set(snapshot.implementedEndpoints);
195
372
  for (const key of Object.keys(PROBES)) {
@@ -231,12 +408,19 @@ export async function checkXIdentityConformance(opts = {}) {
231
408
  violations.push(`probe '${endpoint}' drives ${probe.path}, which is not the claimed path ${claimedPath}`);
232
409
  continue;
233
410
  }
234
- const path = substitute(probe.path);
235
- const body = endpoint === 'POST /2/oauth2/revoke'
411
+ const built = probe.build ? await probe.build(fx) : undefined;
412
+ const path = built ? built.path : substitute(probe.path);
413
+ const body = built ? built.body : endpoint === 'POST /2/oauth2/revoke'
236
414
  ? form({ token: String(revokeFixture?.access_token ?? ''), client_id: DEFAULT_CLIENT_ID, token_type_hint: 'access_token' })
237
415
  : probe.body === undefined ? undefined : substitute(probe.body);
238
- const headers = probe.headers ? Object.fromEntries(Object.entries(probe.headers).map(([k, v]) => [k, substitute(v)])) : undefined;
416
+ const headers = built ? built.headers : probe.headers ? Object.fromEntries(Object.entries(probe.headers).map(([k, v]) => [k, substitute(v)])) : undefined;
239
417
  const res = await call(probe.method, path, body, headers);
418
+ if (endpoint === 'GET /oauth/authenticate' && res.status === 302) {
419
+ const back = new URL(res.headers?.location ?? 'http://invalid.test/');
420
+ if (`${back.origin}${back.pathname}` !== OAUTH1_PROBE_CALLBACK || !/^[A-Za-z0-9]{32}$/.test(back.searchParams.get('oauth_verifier') ?? '')) {
421
+ violations.push(`endpoint '${endpoint}' did not skip the approved screen to the callback with a verifier: ${res.headers?.location}`);
422
+ }
423
+ }
240
424
  probed += 1;
241
425
  if (!probe.status.includes(res.status)) {
242
426
  violations.push(`endpoint '${endpoint}' answered ${res.status} (expected ${probe.status.join('/')}): ${JSON.stringify(res.body).slice(0, 160)}`);
@@ -309,6 +493,29 @@ export async function checkXIdentityConformance(opts = {}) {
309
493
  return r.status === 429 && Array.isArray(errs) && errs[0]?.code === 88;
310
494
  },
311
495
  };
496
+ // OAuth 1.0a: each row type is witnessed by a value only its projection can produce.
497
+ witness['oauth1_app'] = async () => {
498
+ // the registered App's NAME is on the screen only if its row projected
499
+ const t = await fx.requestToken();
500
+ return String((await call('GET', `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}`)).body).includes('>Probe App<');
501
+ };
502
+ witness['oauth1_request_token'] = async () => {
503
+ // an access_token exchange succeeds only for a request token whose secret projected
504
+ const t = await fx.approved();
505
+ const r = await call('POST', '/oauth/access_token', form({ oauth_verifier: t.verifier }), { ...FORM_TYPE, authorization: oauth1Header('POST', `${API}/oauth/access_token`, OAUTH1_PROBE_APP, t, { oauth_verifier: t.verifier }) });
506
+ return r.status === 200 && formOf(r.body).get('user_id') === ADA.id;
507
+ };
508
+ witness['oauth1_token'] = async () => {
509
+ const t = await fx.accessToken();
510
+ const r = await call('GET', '/2/users/me', undefined, { authorization: oauth1Header('GET', `${API}/2/users/me`, OAUTH1_PROBE_APP, t) });
511
+ return r.status === 200 && r.body?.data?.id === ADA.id;
512
+ };
513
+ witness['oauth1_grant'] = async () => {
514
+ // Sign in with X skips the screen only when a projected grant says the persona approved
515
+ const t = await fx.requestToken();
516
+ const r = await call('GET', `/oauth/authenticate?oauth_token=${encodeURIComponent(t.key)}`);
517
+ return r.status === 302 && (r.headers?.location ?? '').startsWith(OAUTH1_PROBE_CALLBACK);
518
+ };
312
519
  for (const type of snapshot.resourceTypes) {
313
520
  const probe = witness[type];
314
521
  if (!probe) {