@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';
|
|
@@ -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
|
|
235
|
-
const
|
|
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) {
|