@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.
- package/README.md +37 -4
- package/client/xidentity-consent.tsx +41 -4
- package/dist/client/xidentity-consent.bundle.js +85 -85
- package/dist/client/xidentity-consent.d.ts +17 -1
- package/dist/client/xidentity-consent.js +11 -2
- package/dist/client/xidentity-consent.tsx +41 -4
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +16 -8
- package/dist/src/xidentity-capabilities.js +243 -8
- package/dist/src/xidentity-conformance.d.ts +41 -0
- package/dist/src/xidentity-conformance.js +210 -3
- package/dist/src/xidentity-consent-client.gen.d.ts +1 -1
- package/dist/src/xidentity-consent-client.gen.js +1 -1
- package/dist/src/xidentity-consent-ui.d.ts +25 -1
- package/dist/src/xidentity-consent-ui.js +62 -1
- package/dist/src/xidentity-oauth1.d.ts +93 -0
- package/dist/src/xidentity-oauth1.js +594 -0
- package/dist/src/xidentity-server.js +14 -4
- package/dist/src/xidentity-store.d.ts +4 -1
- package/dist/src/xidentity-store.js +8 -1
- package/dist/src/xidentity-twin.js +32 -9
- package/package.json +5 -4
- package/src/index.ts +30 -8
- package/src/xidentity-capabilities.ts +254 -8
- package/src/xidentity-conformance.ts +232 -3
- package/src/xidentity-consent-client.gen.ts +1 -1
- package/src/xidentity-consent-ui.ts +67 -0
- package/src/xidentity-oauth1.ts +598 -0
- package/src/xidentity-server.ts +14 -4
- package/src/xidentity-store.ts +8 -1
- package/src/xidentity-twin.ts +30 -9
|
@@ -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';
|
|
@@ -34,6 +35,8 @@ type Probe = {
|
|
|
34
35
|
path: string;
|
|
35
36
|
body?: string;
|
|
36
37
|
headers?: Record<string, string>;
|
|
38
|
+
/** An OAuth 1.0a probe is signed per request against a fresh fixture, so it builds its request. */
|
|
39
|
+
build?: (fx: OAuth1Fixtures) => Promise<{ path: string; body?: string; headers: Record<string, string> }>;
|
|
37
40
|
/** The status(es) a WORKING handler answers with. */
|
|
38
41
|
status: number[];
|
|
39
42
|
/** What a working handler's body must look like. */
|
|
@@ -66,6 +69,90 @@ const authQuery = `response_type=code&client_id=${encodeURIComponent(DEFAULT_CLI
|
|
|
66
69
|
const form = (params: Record<string, string>) => new URLSearchParams(params).toString();
|
|
67
70
|
const basic = `Basic ${Buffer.from(`${DEFAULT_CLIENT_ID}:${DEFAULT_CLIENT_SECRET}`).toString('base64')}`;
|
|
68
71
|
|
|
72
|
+
// ── an OAuth 1.0a CLIENT, written here from docs.x.com's creating-a-signature guide ─────────────
|
|
73
|
+
// Deliberately NOT the handler's verifier (xidentity-oauth1.ts): a check that signs with the same
|
|
74
|
+
// code it verifies with would agree with itself. The consumer library (twitter-api-v2) is the
|
|
75
|
+
// third, independent party in xidentity-oauth1.integration.test.ts.
|
|
76
|
+
const enc = (s: string) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => '%' + c.charCodeAt(0).toString(16).toUpperCase());
|
|
77
|
+
let nonceCounter = 0;
|
|
78
|
+
/** The Authorization header for `method url` with these extra signed params (query + form body). */
|
|
79
|
+
export function oauth1Header(
|
|
80
|
+
method: string,
|
|
81
|
+
url: string,
|
|
82
|
+
consumer: { key: string; secret: string },
|
|
83
|
+
token: { key: string; secret: string } | null,
|
|
84
|
+
signed: Record<string, string> = {},
|
|
85
|
+
): string {
|
|
86
|
+
const [baseUrl, query = ''] = url.split('?');
|
|
87
|
+
const oauth: Record<string, string> = {
|
|
88
|
+
oauth_consumer_key: consumer.key,
|
|
89
|
+
oauth_nonce: `probe${(nonceCounter += 1)}`,
|
|
90
|
+
oauth_signature_method: 'HMAC-SHA1',
|
|
91
|
+
oauth_timestamp: '1769904000',
|
|
92
|
+
oauth_version: '1.0',
|
|
93
|
+
...(token ? { oauth_token: token.key } : {}),
|
|
94
|
+
};
|
|
95
|
+
const params = [...Object.entries(oauth), ...Object.entries(signed), ...new URLSearchParams(query).entries()]
|
|
96
|
+
.map(([k, v]) => `${enc(k)}=${enc(v)}`)
|
|
97
|
+
.sort();
|
|
98
|
+
const base = `${method.toUpperCase()}&${enc(baseUrl!)}&${enc(params.join('&'))}`;
|
|
99
|
+
oauth['oauth_signature'] = createHmac('sha1', `${enc(consumer.secret)}&${enc(token?.secret ?? '')}`).update(base).digest('base64');
|
|
100
|
+
return 'OAuth ' + Object.entries(oauth).map(([k, v]) => `${enc(k)}="${enc(v)}"`).join(', ');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** The App the OAuth 1.0a probes run under, registered through the twin door. */
|
|
104
|
+
export const OAUTH1_PROBE_APP = { key: 'ProbeConsumerKey000000001', secret: 'ProbeConsumerSecret00000000000000000000000000001' };
|
|
105
|
+
export const OAUTH1_PROBE_CALLBACK = 'https://probe-app.local/x/callback';
|
|
106
|
+
const FORM_TYPE = { 'content-type': 'application/x-www-form-urlencoded' };
|
|
107
|
+
/** This check reaches the twin at the World's origin, so its client signs that URL (a client the
|
|
108
|
+
* injector re-aims signs https://api.x.com and the twin reads the host it named — proven by the
|
|
109
|
+
* twitter-api-v2 test). */
|
|
110
|
+
const API = DEMO_ORIGIN;
|
|
111
|
+
|
|
112
|
+
type Call = (method: string, path: string, body?: string, headers?: Record<string, string>) => Promise<XResponse>;
|
|
113
|
+
export type OAuth1Fixtures = {
|
|
114
|
+
/** A fresh request token (and its secret) from step 1. */
|
|
115
|
+
requestToken(): Promise<{ key: string; secret: string }>;
|
|
116
|
+
/** A fresh request token whose screen the person has open (the decision is still to make). */
|
|
117
|
+
presented(): Promise<{ key: string; secret: string }>;
|
|
118
|
+
/** A request token taken through the screen and approved: its verifier. */
|
|
119
|
+
approved(): Promise<{ key: string; secret: string; verifier: string }>;
|
|
120
|
+
/** A usable access token for the signed-in persona. */
|
|
121
|
+
accessToken(): Promise<{ key: string; secret: string }>;
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
export function oauth1Fixtures(call: Call): OAuth1Fixtures {
|
|
125
|
+
const requestToken = async () => {
|
|
126
|
+
const body = new URLSearchParams({ oauth_callback: OAUTH1_PROBE_CALLBACK }).toString();
|
|
127
|
+
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 }) });
|
|
128
|
+
const got = new URLSearchParams(String(r.body));
|
|
129
|
+
return { key: got.get('oauth_token') ?? '', secret: got.get('oauth_token_secret') ?? '' };
|
|
130
|
+
};
|
|
131
|
+
const presented = async () => {
|
|
132
|
+
const t = await requestToken();
|
|
133
|
+
await call('GET', `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}`);
|
|
134
|
+
return t;
|
|
135
|
+
};
|
|
136
|
+
const approved = async () => {
|
|
137
|
+
const t = await requestToken();
|
|
138
|
+
const screen = await call('GET', `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}`);
|
|
139
|
+
const handle = /name="auth_request" value="([^"]+)"/.exec(String(screen.body))?.[1] ?? '';
|
|
140
|
+
const d = await call('POST', '/oauth/authorize', new URLSearchParams({ auth_request: handle, decision: 'allow' }).toString(), FORM_TYPE);
|
|
141
|
+
const verifier = new URL(d.headers?.location ?? 'http://invalid.test/').searchParams.get('oauth_verifier') ?? '';
|
|
142
|
+
return { ...t, verifier };
|
|
143
|
+
};
|
|
144
|
+
const accessToken = async () => {
|
|
145
|
+
const t = await approved();
|
|
146
|
+
const body = new URLSearchParams({ oauth_verifier: t.verifier }).toString();
|
|
147
|
+
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 }) });
|
|
148
|
+
const got = new URLSearchParams(String(r.body));
|
|
149
|
+
return { key: got.get('oauth_token') ?? '', secret: got.get('oauth_token_secret') ?? '' };
|
|
150
|
+
};
|
|
151
|
+
return { requestToken, presented, approved, accessToken };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const formOf = (b: unknown) => new URLSearchParams(typeof b === 'string' ? b : '');
|
|
155
|
+
|
|
69
156
|
/** A representative request per declared endpoint, with the outcome a LIVE handler produces. */
|
|
70
157
|
const PROBES: Record<string, Probe> = {
|
|
71
158
|
'GET /i/oauth2/authorize': {
|
|
@@ -116,6 +203,82 @@ const PROBES: Record<string, Probe> = {
|
|
|
116
203
|
&& b.data['username'] === ADA.username
|
|
117
204
|
&& b.data['name'] === ADA.name,
|
|
118
205
|
},
|
|
206
|
+
'POST /oauth/request_token': {
|
|
207
|
+
method: 'POST',
|
|
208
|
+
path: '/oauth/request_token',
|
|
209
|
+
build: async () => ({
|
|
210
|
+
path: '/oauth/request_token',
|
|
211
|
+
body: new URLSearchParams({ oauth_callback: OAUTH1_PROBE_CALLBACK }).toString(),
|
|
212
|
+
headers: { ...FORM_TYPE, authorization: oauth1Header('POST', `${API}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: OAUTH1_PROBE_CALLBACK }) },
|
|
213
|
+
}),
|
|
214
|
+
status: [200],
|
|
215
|
+
// The documented response: oauth_token, oauth_token_secret and oauth_callback_confirmed=true.
|
|
216
|
+
expect: (b) => formOf(b).get('oauth_callback_confirmed') === 'true' && (formOf(b).get('oauth_token') ?? '') !== '' && (formOf(b).get('oauth_token_secret') ?? '') !== '',
|
|
217
|
+
},
|
|
218
|
+
'GET /oauth/authorize': {
|
|
219
|
+
method: 'GET',
|
|
220
|
+
path: '/oauth/authorize',
|
|
221
|
+
build: async (fx) => ({ path: `/oauth/authorize?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}`, headers: {} }),
|
|
222
|
+
status: [200],
|
|
223
|
+
// The screen names the registered App, the signed-in persona, and what Read-and-write allows.
|
|
224
|
+
expect: htmlContaining('Authorize app', 'Cancel', '>Probe App<', `@${ADA.username}`, 'Post and delete Posts for you', 'probe-app.local'),
|
|
225
|
+
},
|
|
226
|
+
'POST /oauth/authorize': {
|
|
227
|
+
method: 'POST',
|
|
228
|
+
path: '/oauth/authorize',
|
|
229
|
+
// The screen's Authorize app button: its form posts here, on the host that served the screen.
|
|
230
|
+
build: async (fx) => ({
|
|
231
|
+
path: '/oauth/authorize',
|
|
232
|
+
body: new URLSearchParams({ auth_request: (await fx.presented()).key, decision: 'allow' }).toString(),
|
|
233
|
+
headers: FORM_TYPE,
|
|
234
|
+
}),
|
|
235
|
+
status: [302],
|
|
236
|
+
},
|
|
237
|
+
'GET /oauth/authenticate': {
|
|
238
|
+
method: 'GET',
|
|
239
|
+
path: '/oauth/authenticate',
|
|
240
|
+
// After the round trip below the persona has approved Probe App, so Sign in with X skips the
|
|
241
|
+
// screen: the answer is the callback itself, carrying a verifier (checked after the call) —
|
|
242
|
+
// only a projected grant can say so.
|
|
243
|
+
build: async (fx) => ({ path: `/oauth/authenticate?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}`, headers: {} }),
|
|
244
|
+
status: [302],
|
|
245
|
+
},
|
|
246
|
+
'POST /oauth/access_token': {
|
|
247
|
+
method: 'POST',
|
|
248
|
+
path: '/oauth/access_token',
|
|
249
|
+
build: async (fx) => {
|
|
250
|
+
const t = await fx.approved();
|
|
251
|
+
return {
|
|
252
|
+
path: '/oauth/access_token',
|
|
253
|
+
body: new URLSearchParams({ oauth_verifier: t.verifier }).toString(),
|
|
254
|
+
headers: { ...FORM_TYPE, authorization: oauth1Header('POST', `${API}/oauth/access_token`, OAUTH1_PROBE_APP, t, { oauth_verifier: t.verifier }) },
|
|
255
|
+
};
|
|
256
|
+
},
|
|
257
|
+
status: [200],
|
|
258
|
+
// The documented example's shape: `<user id>-<40 alnum>`, a secret, user_id and screen_name.
|
|
259
|
+
expect: (b) => new RegExp(`^${ADA.id}-[A-Za-z0-9]{40}$`).test(formOf(b).get('oauth_token') ?? '')
|
|
260
|
+
&& (formOf(b).get('oauth_token_secret') ?? '') !== '' && formOf(b).get('user_id') === ADA.id && formOf(b).get('screen_name') === ADA.username,
|
|
261
|
+
},
|
|
262
|
+
'POST /1.1/oauth/invalidate_token': {
|
|
263
|
+
method: 'POST',
|
|
264
|
+
path: '/1.1/oauth/invalidate_token',
|
|
265
|
+
build: async (fx) => {
|
|
266
|
+
const t = await fx.accessToken();
|
|
267
|
+
return { path: '/1.1/oauth/invalidate_token', headers: { authorization: oauth1Header('POST', `${API}/1.1/oauth/invalidate_token`, OAUTH1_PROBE_APP, t) } };
|
|
268
|
+
},
|
|
269
|
+
status: [200],
|
|
270
|
+
expect: (b) => isObject(b) && new RegExp(`^${ADA.id}-[A-Za-z0-9]{40}$`).test(String(b['access_token'])) && Object.keys(b).length === 1,
|
|
271
|
+
},
|
|
272
|
+
'POST /1.1/oauth/invalidate_token.json': {
|
|
273
|
+
method: 'POST',
|
|
274
|
+
path: '/1.1/oauth/invalidate_token.json',
|
|
275
|
+
build: async (fx) => {
|
|
276
|
+
const t = await fx.accessToken();
|
|
277
|
+
return { path: '/1.1/oauth/invalidate_token.json', headers: { authorization: oauth1Header('POST', `${API}/1.1/oauth/invalidate_token.json`, OAUTH1_PROBE_APP, t) } };
|
|
278
|
+
},
|
|
279
|
+
status: [200],
|
|
280
|
+
expect: (b) => isObject(b) && new RegExp(`^${ADA.id}-[A-Za-z0-9]{40}$`).test(String(b['access_token'])) && Object.keys(b).length === 1,
|
|
281
|
+
},
|
|
119
282
|
};
|
|
120
283
|
|
|
121
284
|
/**
|
|
@@ -140,6 +303,18 @@ const ROUTER_SURFACE: Array<[string, string]> = [
|
|
|
140
303
|
['GET', '/2/oauth2/authorize'],
|
|
141
304
|
['GET', '/2/users/by'],
|
|
142
305
|
['GET', '/oauth2/token'],
|
|
306
|
+
['POST', '/oauth/request_token'],
|
|
307
|
+
['GET', '/oauth/request_token'],
|
|
308
|
+
['GET', '/oauth/authorize'],
|
|
309
|
+
['POST', '/oauth/authorize'],
|
|
310
|
+
['GET', '/oauth/authenticate'],
|
|
311
|
+
['POST', '/oauth/authenticate'],
|
|
312
|
+
['POST', '/oauth/access_token'],
|
|
313
|
+
['GET', '/oauth/access_token'],
|
|
314
|
+
['POST', '/1.1/oauth/invalidate_token'],
|
|
315
|
+
['POST', '/1.1/oauth/invalidate_token.json'],
|
|
316
|
+
['GET', '/1.1/oauth/invalidate_token'],
|
|
317
|
+
['POST', '/oauth/invalidate_token'],
|
|
143
318
|
];
|
|
144
319
|
|
|
145
320
|
const isNotFoundEnvelope = (res: XResponse): boolean =>
|
|
@@ -225,6 +400,30 @@ export async function checkXIdentityConformance(opts: { root?: string } = {}): P
|
|
|
225
400
|
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 });
|
|
226
401
|
if (spent.status !== 400) violations.push(`a spent refresh token was accepted a second time: ${spent.status}`);
|
|
227
402
|
|
|
403
|
+
// ── the OAuth 1.0a round trip: request_token → screen → callback → access_token → users/me ──
|
|
404
|
+
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] }));
|
|
405
|
+
const fx = oauth1Fixtures(call);
|
|
406
|
+
const rt = await fx.requestToken();
|
|
407
|
+
if (!rt.key || !rt.secret) violations.push('OAuth 1.0a: request_token did not issue a request token for a registered App');
|
|
408
|
+
const screen = await call('GET', `/oauth/authorize?oauth_token=${encodeURIComponent(rt.key)}`);
|
|
409
|
+
const rtHandle = /name="auth_request" value="([^"]+)"/.exec(String(screen.body))?.[1] ?? '';
|
|
410
|
+
if (rtHandle !== rt.key) violations.push('OAuth 1.0a: the authorize screen did not carry the request token as its handle');
|
|
411
|
+
const approval = await call('POST', '/oauth/authorize', form({ auth_request: rtHandle, decision: 'allow' }), FORM_TYPE);
|
|
412
|
+
const cb = new URL(approval.headers?.location ?? 'http://invalid.test/');
|
|
413
|
+
if (approval.status !== 302 || `${cb.origin}${cb.pathname}` !== OAUTH1_PROBE_CALLBACK || cb.searchParams.get('oauth_token') !== rt.key
|
|
414
|
+
|| [...cb.searchParams.keys()].sort().join(',') !== 'oauth_token,oauth_verifier') {
|
|
415
|
+
violations.push(`OAuth 1.0a: approval did not 302 to the callback with exactly oauth_token + oauth_verifier: ${approval.status} ${cb}`);
|
|
416
|
+
}
|
|
417
|
+
const verifierParam = cb.searchParams.get('oauth_verifier') ?? '';
|
|
418
|
+
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 }) });
|
|
419
|
+
const at1 = { key: formOf(exchanged.body).get('oauth_token') ?? '', secret: formOf(exchanged.body).get('oauth_token_secret') ?? '' };
|
|
420
|
+
const me1 = await call('GET', '/2/users/me', undefined, { authorization: oauth1Header('GET', `${API}/2/users/me`, OAUTH1_PROBE_APP, at1) });
|
|
421
|
+
if (me1.status !== 200 || (me1.body as Record<string, any>)?.data?.username !== ADA.username) {
|
|
422
|
+
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)}`);
|
|
423
|
+
}
|
|
424
|
+
const forged = await call('GET', '/2/users/me', undefined, { authorization: oauth1Header('GET', `${API}/2/users/me`, OAUTH1_PROBE_APP, { key: at1.key, secret: 'forged' }) });
|
|
425
|
+
if (forged.status !== 401) violations.push(`OAuth 1.0a: a signature made with the wrong token secret was accepted at /2/users/me: ${forged.status}`);
|
|
426
|
+
|
|
228
427
|
// ── the endpoint census, THREE ways ──
|
|
229
428
|
const claimed = new Set(snapshot.implementedEndpoints);
|
|
230
429
|
for (const key of Object.keys(PROBES)) {
|
|
@@ -269,12 +468,19 @@ export async function checkXIdentityConformance(opts: { root?: string } = {}): P
|
|
|
269
468
|
violations.push(`probe '${endpoint}' drives ${probe.path}, which is not the claimed path ${claimedPath}`);
|
|
270
469
|
continue;
|
|
271
470
|
}
|
|
272
|
-
const
|
|
273
|
-
const
|
|
471
|
+
const built = probe.build ? await probe.build(fx) : undefined;
|
|
472
|
+
const path = built ? built.path : substitute(probe.path);
|
|
473
|
+
const body = built ? built.body : endpoint === 'POST /2/oauth2/revoke'
|
|
274
474
|
? form({ token: String(revokeFixture?.access_token ?? ''), client_id: DEFAULT_CLIENT_ID, token_type_hint: 'access_token' })
|
|
275
475
|
: probe.body === undefined ? undefined : substitute(probe.body);
|
|
276
|
-
const headers = probe.headers ? Object.fromEntries(Object.entries(probe.headers).map(([k, v]) => [k, substitute(v)])) : undefined;
|
|
476
|
+
const headers = built ? built.headers : probe.headers ? Object.fromEntries(Object.entries(probe.headers).map(([k, v]) => [k, substitute(v)])) : undefined;
|
|
277
477
|
const res = await call(probe.method, path, body, headers);
|
|
478
|
+
if (endpoint === 'GET /oauth/authenticate' && res.status === 302) {
|
|
479
|
+
const back = new URL(res.headers?.location ?? 'http://invalid.test/');
|
|
480
|
+
if (`${back.origin}${back.pathname}` !== OAUTH1_PROBE_CALLBACK || !/^[A-Za-z0-9]{32}$/.test(back.searchParams.get('oauth_verifier') ?? '')) {
|
|
481
|
+
violations.push(`endpoint '${endpoint}' did not skip the approved screen to the callback with a verifier: ${res.headers?.location}`);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
278
484
|
probed += 1;
|
|
279
485
|
if (!probe.status.includes(res.status)) {
|
|
280
486
|
violations.push(`endpoint '${endpoint}' answered ${res.status} (expected ${probe.status.join('/')}): ${JSON.stringify(res.body).slice(0, 160)}`);
|
|
@@ -348,6 +554,29 @@ export async function checkXIdentityConformance(opts: { root?: string } = {}): P
|
|
|
348
554
|
return r.status === 429 && Array.isArray(errs) && errs[0]?.code === 88;
|
|
349
555
|
},
|
|
350
556
|
};
|
|
557
|
+
// OAuth 1.0a: each row type is witnessed by a value only its projection can produce.
|
|
558
|
+
witness['oauth1_app'] = async () => {
|
|
559
|
+
// the registered App's NAME is on the screen only if its row projected
|
|
560
|
+
const t = await fx.requestToken();
|
|
561
|
+
return String((await call('GET', `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}`)).body).includes('>Probe App<');
|
|
562
|
+
};
|
|
563
|
+
witness['oauth1_request_token'] = async () => {
|
|
564
|
+
// an access_token exchange succeeds only for a request token whose secret projected
|
|
565
|
+
const t = await fx.approved();
|
|
566
|
+
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 }) });
|
|
567
|
+
return r.status === 200 && formOf(r.body).get('user_id') === ADA.id;
|
|
568
|
+
};
|
|
569
|
+
witness['oauth1_token'] = async () => {
|
|
570
|
+
const t = await fx.accessToken();
|
|
571
|
+
const r = await call('GET', '/2/users/me', undefined, { authorization: oauth1Header('GET', `${API}/2/users/me`, OAUTH1_PROBE_APP, t) });
|
|
572
|
+
return r.status === 200 && (r.body as Record<string, any>)?.data?.id === ADA.id;
|
|
573
|
+
};
|
|
574
|
+
witness['oauth1_grant'] = async () => {
|
|
575
|
+
// Sign in with X skips the screen only when a projected grant says the persona approved
|
|
576
|
+
const t = await fx.requestToken();
|
|
577
|
+
const r = await call('GET', `/oauth/authenticate?oauth_token=${encodeURIComponent(t.key)}`);
|
|
578
|
+
return r.status === 302 && (r.headers?.location ?? '').startsWith(OAUTH1_PROBE_CALLBACK);
|
|
579
|
+
};
|
|
351
580
|
for (const type of snapshot.resourceTypes) {
|
|
352
581
|
const probe = witness[type];
|
|
353
582
|
if (!probe) {
|