@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
|
@@ -22,13 +22,29 @@ export type ConsentView = {
|
|
|
22
22
|
/** The x.com session this browser is signed in as — the account that will consent. */
|
|
23
23
|
account: ConsentAccount;
|
|
24
24
|
scopes: ConsentScopeRow[];
|
|
25
|
-
/** Host of the validated redirect_uri, shown in the "you'll be redirected to" notice.
|
|
25
|
+
/** Host of the validated redirect_uri, shown in the "you'll be redirected to" notice. Empty for
|
|
26
|
+
* an OAuth 1.0a out-of-band (PIN) request, which redirects nowhere. */
|
|
26
27
|
redirectHost: string;
|
|
28
|
+
/** Where the decision posts, under `origin`: `/_twin/consent` for an OAuth 2.0 authorize screen,
|
|
29
|
+
* `/oauth/authorize` for an OAuth 1.0a one, as X's own screen posts (the request token is the handle). */
|
|
30
|
+
decisionPath?: string;
|
|
27
31
|
};
|
|
28
32
|
/** The authorize screen — X's consent page for the app named by the pending auth request. */
|
|
29
33
|
export declare function ConsentPage({ view }: {
|
|
30
34
|
view: ConsentView;
|
|
31
35
|
}): import("react").JSX.Element;
|
|
36
|
+
export type PinPageProps = {
|
|
37
|
+
appName: string;
|
|
38
|
+
/** The oauth_verifier, shown as the PIN the person types into the app (out-of-band flow). */
|
|
39
|
+
pin: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* The OAuth 1.0a out-of-band ending: an app that registered `oauth_callback=oob` gets no redirect,
|
|
43
|
+
* so X shows the verifier as a PIN for the person to type into the app (docs.x.com PIN-based
|
|
44
|
+
* authorization). The layout follows the classic screen as widely screenshotted; its live DOM was
|
|
45
|
+
* not captured (`xidentity.oauth1.consent_wording`, todo).
|
|
46
|
+
*/
|
|
47
|
+
export declare function PinPage({ appName, pin }: PinPageProps): import("react").JSX.Element;
|
|
32
48
|
export type ErrorPageProps = {
|
|
33
49
|
status: number;
|
|
34
50
|
/** The OAuth error code the failure classifies as (e.g. `invalid_request`). */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
// X's AUTHORIZE SCREEN, as React — the vendor's own product UI, not a dashboard mirror.
|
|
3
3
|
//
|
|
4
4
|
// These components are the ONE renderer for the screen: the twin server-renders them with
|
|
@@ -29,7 +29,16 @@ function ScopeSection({ heading, rows }) {
|
|
|
29
29
|
/** The authorize screen — X's consent page for the app named by the pending auth request. */
|
|
30
30
|
export function ConsentPage({ view }) {
|
|
31
31
|
const appName = view.app.name;
|
|
32
|
-
return (_jsxs("div", { className: "card", children: [_jsx("header", { className: "x-header", children: _jsx(XMark, {}) }), _jsx("div", { className: "signed-in", children: _jsxs("span", { className: "account-text", children: [_jsx("span", { className: "account-name", children: view.account.name || view.account.username || view.account.id }), view.account.username ? _jsxs("span", { className: "account-username", children: ["@", view.account.username] }) : null] }) }), _jsxs("h1", { className: "title", children: [_jsx("strong", { className: "app-name", children: appName }), " wants to access your X account"] }), _jsx(ScopeSection, { heading: `Things ${appName} can view`, rows: view.scopes.filter((s) => s.group === 'view') }), _jsx(ScopeSection, { heading: `Things ${appName} can do`, rows: view.scopes.filter((s) => s.group === 'do') }), _jsx(ScopeSection, { heading: "Until you revoke access", rows: view.scopes.filter((s) => s.group === 'session') }), _jsxs("form", { method: "POST", action: `${view.origin}/_twin/consent`, className: "consent-form", children: [_jsx("input", { type: "hidden", name: "auth_request", value: view.requestId }), _jsxs("div", { className: "actions", children: [_jsx("button", { className: "btn btn-primary", type: "submit", name: "decision", value: "allow", children: "Authorize app" }), _jsx("button", { className: "btn btn-secondary", type: "submit", name: "decision", value: "deny", children: "Cancel" })] })] }), _jsxs("p", { className: "legal", children: ["You'll be redirected to ", _jsx("strong", { className: "redirect-host", children: view.redirectHost }), ". You can revoke access to any app at any time from the Apps and sessions section of your X settings."] })] }));
|
|
32
|
+
return (_jsxs("div", { className: "card", children: [_jsx("header", { className: "x-header", children: _jsx(XMark, {}) }), _jsx("div", { className: "signed-in", children: _jsxs("span", { className: "account-text", children: [_jsx("span", { className: "account-name", children: view.account.name || view.account.username || view.account.id }), view.account.username ? _jsxs("span", { className: "account-username", children: ["@", view.account.username] }) : null] }) }), _jsxs("h1", { className: "title", children: [_jsx("strong", { className: "app-name", children: appName }), " wants to access your X account"] }), _jsx(ScopeSection, { heading: `Things ${appName} can view`, rows: view.scopes.filter((s) => s.group === 'view') }), _jsx(ScopeSection, { heading: `Things ${appName} can do`, rows: view.scopes.filter((s) => s.group === 'do') }), _jsx(ScopeSection, { heading: "Until you revoke access", rows: view.scopes.filter((s) => s.group === 'session') }), _jsxs("form", { method: "POST", action: `${view.origin}${view.decisionPath ?? '/_twin/consent'}`, className: "consent-form", children: [_jsx("input", { type: "hidden", name: "auth_request", value: view.requestId }), _jsxs("div", { className: "actions", children: [_jsx("button", { className: "btn btn-primary", type: "submit", name: "decision", value: "allow", children: "Authorize app" }), _jsx("button", { className: "btn btn-secondary", type: "submit", name: "decision", value: "deny", children: "Cancel" })] })] }), _jsxs("p", { className: "legal", children: [view.redirectHost ? (_jsxs(_Fragment, { children: ["You'll be redirected to ", _jsx("strong", { className: "redirect-host", children: view.redirectHost }), ".", ' '] })) : null, "You can revoke access to any app at any time from the Apps and sessions section of your X settings."] })] }));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The OAuth 1.0a out-of-band ending: an app that registered `oauth_callback=oob` gets no redirect,
|
|
36
|
+
* so X shows the verifier as a PIN for the person to type into the app (docs.x.com PIN-based
|
|
37
|
+
* authorization). The layout follows the classic screen as widely screenshotted; its live DOM was
|
|
38
|
+
* not captured (`xidentity.oauth1.consent_wording`, todo).
|
|
39
|
+
*/
|
|
40
|
+
export function PinPage({ appName, pin }) {
|
|
41
|
+
return (_jsxs("div", { className: "card", children: [_jsx("header", { className: "x-header", children: _jsx(XMark, {}) }), _jsxs("h1", { className: "title", children: ["You've granted access to ", _jsx("strong", { className: "app-name", children: appName }), "!"] }), _jsxs("p", { className: "legal", children: ["Next, return to ", appName, " and enter this PIN to complete the authorization process:"] }), _jsx("p", { className: "title", children: _jsx("code", { className: "oauth-pin", children: pin }) })] }));
|
|
33
42
|
}
|
|
34
43
|
/**
|
|
35
44
|
* The authorize-endpoint error PAGE — what a browser sees when the twin cannot trust the
|
|
@@ -44,8 +44,12 @@ export type ConsentView = {
|
|
|
44
44
|
/** The x.com session this browser is signed in as — the account that will consent. */
|
|
45
45
|
account: ConsentAccount;
|
|
46
46
|
scopes: ConsentScopeRow[];
|
|
47
|
-
/** Host of the validated redirect_uri, shown in the "you'll be redirected to" notice.
|
|
47
|
+
/** Host of the validated redirect_uri, shown in the "you'll be redirected to" notice. Empty for
|
|
48
|
+
* an OAuth 1.0a out-of-band (PIN) request, which redirects nowhere. */
|
|
48
49
|
redirectHost: string;
|
|
50
|
+
/** Where the decision posts, under `origin`: `/_twin/consent` for an OAuth 2.0 authorize screen,
|
|
51
|
+
* `/oauth/authorize` for an OAuth 1.0a one, as X's own screen posts (the request token is the handle). */
|
|
52
|
+
decisionPath?: string;
|
|
49
53
|
};
|
|
50
54
|
|
|
51
55
|
/** The X mark, drawn rather than fetched — a twin never reaches out to a vendor CDN. */
|
|
@@ -96,7 +100,7 @@ export function ConsentPage({ view }: { view: ConsentView }) {
|
|
|
96
100
|
<ScopeSection heading={`Things ${appName} can view`} rows={view.scopes.filter((s) => s.group === 'view')} />
|
|
97
101
|
<ScopeSection heading={`Things ${appName} can do`} rows={view.scopes.filter((s) => s.group === 'do')} />
|
|
98
102
|
<ScopeSection heading="Until you revoke access" rows={view.scopes.filter((s) => s.group === 'session')} />
|
|
99
|
-
<form method="POST" action={`${view.origin}/_twin/consent`} className="consent-form">
|
|
103
|
+
<form method="POST" action={`${view.origin}${view.decisionPath ?? '/_twin/consent'}`} className="consent-form">
|
|
100
104
|
<input type="hidden" name="auth_request" value={view.requestId} />
|
|
101
105
|
<div className="actions">
|
|
102
106
|
<button className="btn btn-primary" type="submit" name="decision" value="allow">
|
|
@@ -108,8 +112,41 @@ export function ConsentPage({ view }: { view: ConsentView }) {
|
|
|
108
112
|
</div>
|
|
109
113
|
</form>
|
|
110
114
|
<p className="legal">
|
|
111
|
-
|
|
112
|
-
|
|
115
|
+
{view.redirectHost ? (
|
|
116
|
+
<>
|
|
117
|
+
You'll be redirected to <strong className="redirect-host">{view.redirectHost}</strong>.{' '}
|
|
118
|
+
</>
|
|
119
|
+
) : null}
|
|
120
|
+
You can revoke access to any app at any time from the Apps and sessions section of your X settings.
|
|
121
|
+
</p>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export type PinPageProps = {
|
|
127
|
+
appName: string;
|
|
128
|
+
/** The oauth_verifier, shown as the PIN the person types into the app (out-of-band flow). */
|
|
129
|
+
pin: string;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* The OAuth 1.0a out-of-band ending: an app that registered `oauth_callback=oob` gets no redirect,
|
|
134
|
+
* so X shows the verifier as a PIN for the person to type into the app (docs.x.com PIN-based
|
|
135
|
+
* authorization). The layout follows the classic screen as widely screenshotted; its live DOM was
|
|
136
|
+
* not captured (`xidentity.oauth1.consent_wording`, todo).
|
|
137
|
+
*/
|
|
138
|
+
export function PinPage({ appName, pin }: PinPageProps) {
|
|
139
|
+
return (
|
|
140
|
+
<div className="card">
|
|
141
|
+
<header className="x-header">
|
|
142
|
+
<XMark />
|
|
143
|
+
</header>
|
|
144
|
+
<h1 className="title">
|
|
145
|
+
You've granted access to <strong className="app-name">{appName}</strong>!
|
|
146
|
+
</h1>
|
|
147
|
+
<p className="legal">Next, return to {appName} and enter this PIN to complete the authorization process:</p>
|
|
148
|
+
<p className="title">
|
|
149
|
+
<code className="oauth-pin">{pin}</code>
|
|
113
150
|
</p>
|
|
114
151
|
</div>
|
|
115
152
|
);
|
package/dist/src/index.d.ts
CHANGED
|
@@ -11,5 +11,7 @@ export { XIDENTITY_BUDGET_CEILING, XIDENTITY_BUDGET_MAX_RETRY_AFTER_S, XIDENTITY
|
|
|
11
11
|
export type { XIdentityBudgetErrorKind, XIdentityBudgetOptions, XIdentityBudgetReservation, XIdentityBudgetSnapshot, } from './xidentity-budget.js';
|
|
12
12
|
export { consentPageHtml, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH, errorPageHtml, xIdentityConsentState, } from './xidentity-consent-ui.js';
|
|
13
13
|
export type { ConsentAccount, ConsentScopeRow, ConsentView, ErrorPageProps } from './xidentity-consent-ui.js';
|
|
14
|
+
export { OAUTH1_ENDPOINTS, oauth1AppOf, oauth1Params, parseOAuthHeader, pct, scopesOfAccessLevel, signatureMatches, signedBaseUrl, signedRequestOf, verifyUserContext, WORLD_APP_ENV, worldOAuth1App, } from './xidentity-oauth1.js';
|
|
15
|
+
export type { OAuth1App, OAuth1Principal, SignedRequest } from './xidentity-oauth1.js';
|
|
14
16
|
import type { TwinPack } from '@volter/world-core';
|
|
15
17
|
export declare const pack: TwinPack;
|
package/dist/src/index.js
CHANGED
|
@@ -20,6 +20,7 @@ export { liveXIdentityExecute, mapUsersMeAccount, PULL_USER_FIELDS, pullXIdentit
|
|
|
20
20
|
// `XIdentityBudgetError` by type; there is deliberately no export that disables the guard.
|
|
21
21
|
export { XIDENTITY_BUDGET_CEILING, XIDENTITY_BUDGET_MAX_RETRY_AFTER_S, XIDENTITY_BUDGET_WINDOW_MS, XIDENTITY_CALL_WEIGHTS, XIDENTITY_RATE_BUDGET, XIdentityBudget, XIdentityBudgetError, xIdentityBudgetPath, xIdentityCallWeight, } from "./xidentity-budget.js";
|
|
22
22
|
export { consentPageHtml, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH, errorPageHtml, xIdentityConsentState, } from "./xidentity-consent-ui.js";
|
|
23
|
+
export { OAUTH1_ENDPOINTS, oauth1AppOf, oauth1Params, parseOAuthHeader, pct, scopesOfAccessLevel, signatureMatches, signedBaseUrl, signedRequestOf, verifyUserContext, WORLD_APP_ENV, worldOAuth1App, } from "./xidentity-oauth1.js";
|
|
23
24
|
import { XIDENTITY_RATE_BUDGET as RATE_BUDGET } from "./xidentity-budget.js";
|
|
24
25
|
/**
|
|
25
26
|
* The x.com / twitter.com paths THIS pack serves — the consent leg — as a RegExp SOURCE for the
|
|
@@ -32,13 +33,18 @@ import { XIDENTITY_RATE_BUDGET as RATE_BUDGET } from "./xidentity-budget.js";
|
|
|
32
33
|
*/
|
|
33
34
|
const XIDENTITY_CONSENT_PATHS = '^/i/oauth2/authorize/?$|^/_twin/';
|
|
34
35
|
/**
|
|
35
|
-
* The api.x.com / api.twitter.com paths THIS pack serves: the OAuth 2.0 token + revoke endpoints
|
|
36
|
-
*
|
|
36
|
+
* The api.x.com / api.twitter.com paths THIS pack serves: the OAuth 2.0 token + revoke endpoints,
|
|
37
|
+
* the authenticated-user read, and OAuth 1.0a's three legs (request_token, the authorize and
|
|
38
|
+
* authenticate screens — served on api.x.com itself, where X serves them — and access_token) plus
|
|
39
|
+
* 1.1/oauth/invalidate_token, and the page assets that screen loads from the host it is on
|
|
40
|
+
* (`/_twin/assets/consent.{js,css}`; the `x` pack's `/_twin/` claim leaves exactly these two out, so
|
|
41
|
+
* the claims stay disjoint). The screen's Authorize app form posts to `POST oauth/authorize`, which
|
|
42
|
+
* the same rule already covers. api.x.com is the ENTIRE X API v2 host — posts, DMs, spaces — and
|
|
37
43
|
* this pack models the identity slice only, so nothing else is claimed: an unmodelled X call must
|
|
38
|
-
* refuse loudly rather than land in a twin that cannot serve it.
|
|
39
|
-
* inject.cjs's former `isXIdentityApiPath` predicate.
|
|
44
|
+
* refuse loudly rather than land in a twin that cannot serve it. First transcribed from
|
|
45
|
+
* inject.cjs's former `isXIdentityApiPath` predicate; the asset paths were added 2026-09-28.
|
|
40
46
|
*/
|
|
41
|
-
const XIDENTITY_API_PATHS = '^/2/(?:oauth2/(?:token|revoke)|users/me)
|
|
47
|
+
const XIDENTITY_API_PATHS = '^/2/(?:oauth2/(?:token|revoke)|users/me)/?$|^/oauth/(?:request_token|authorize|authenticate|access_token)/?$|^/1\\.1/oauth/invalidate_token(?:\\.json)?/?$|^/_twin/assets/consent\\.(?:js|css)$';
|
|
42
48
|
export const pack = {
|
|
43
49
|
// STILL PROTOCOL 1, and the reason is the gate rather than the pack (ROADMAP.md,
|
|
44
50
|
// shape-parity-refusals: a refusal the harness cannot express). Everything else is ready: the connector
|
|
@@ -54,13 +60,15 @@ export const pack = {
|
|
|
54
60
|
transport: 'rest',
|
|
55
61
|
archetype: 'crud',
|
|
56
62
|
bin: 'world-xidentity',
|
|
57
|
-
resources: ['oauth_client', 'account', 'session', 'auth_request', 'authorization_code', 'access_token', 'refresh_token', 'grant', 'rate_window'],
|
|
63
|
+
resources: ['oauth_client', 'account', 'session', 'auth_request', 'authorization_code', 'access_token', 'refresh_token', 'grant', 'rate_window', 'oauth1_app', 'oauth1_request_token', 'oauth1_token', 'oauth1_grant'],
|
|
58
64
|
specSource: 'api.x.com/2/openapi.json (X API v2 OpenAPI 2.167: /2/users/me, the Problem family, the '
|
|
59
65
|
+ '24-scope OAuth2UserToken catalog) + docs.x.com OAuth 2.0 authorization-code/user-access-token '
|
|
60
66
|
+ 'guides + the official SDK sources (@xdevplatform/xdk, twitter-api-typescript-sdk), all '
|
|
61
|
-
+ 'fetched 2026-08-21'
|
|
67
|
+
+ 'fetched 2026-08-21; OAuth 1.0a from docs.x.com authentication/api-reference, obtaining-user-access-tokens, '
|
|
68
|
+
+ 'creating-a-signature and developer-apps, fetched 2026-09-27',
|
|
62
69
|
description: 'X (Twitter) identity twin — the real x.com authorize screen, the full OAuth 2.0 '
|
|
63
|
-
+ 'authorization-code + PKCE round trip (token, refresh, revoke),
|
|
70
|
+
+ 'authorization-code + PKCE round trip (token, refresh, revoke), the OAuth 1.0a three-legged flow '
|
|
71
|
+
+ '(request_token, the authorize/authenticate screen, access_token, invalidate_token), and GET /2/users/me with '
|
|
64
72
|
+ "X's real field/expansion/problem envelopes. Non-OIDC, exactly as the vendor is.",
|
|
65
73
|
// Adoption + interception, moved off the central maps unchanged (descriptor-first back-migration, adding-a-twin.md §3,
|
|
66
74
|
// 2026-08-31).
|
|
@@ -47,7 +47,7 @@ import { renderToStaticMarkup } from 'react-dom/server';
|
|
|
47
47
|
import { checkCapabilities, uiDataCoupled, verifyBoundary } from '@volter/world-tooling';
|
|
48
48
|
import { ConsentPage, ErrorPage } from "../client/xidentity-consent.js";
|
|
49
49
|
import { CONSENT_CLIENT_JS, xIdentityConsentState } from "./xidentity-consent-ui.js";
|
|
50
|
-
import { checkXIdentityConformance } from "./xidentity-conformance.js";
|
|
50
|
+
import { checkXIdentityConformance, oauth1Fixtures, oauth1Header, OAUTH1_PROBE_APP, OAUTH1_PROBE_CALLBACK } from "./xidentity-conformance.js";
|
|
51
51
|
import { XIdentityBudgetError, XIDENTITY_BUDGET_BURST_CEILING } from "./xidentity-budget.js";
|
|
52
52
|
import { liveXIdentityExecute, mapUsersMeAccount, pullXIdentity, pushPendingXIdentityActions, syncXIdentityFromReal, } from "./xidentity-connector.js";
|
|
53
53
|
import { pkceS256 } from "./xidentity-pkce.js";
|
|
@@ -99,6 +99,14 @@ const qp = (r, key) => {
|
|
|
99
99
|
};
|
|
100
100
|
const form = (params) => new URLSearchParams(params).toString();
|
|
101
101
|
const decoded = (token) => Buffer.from(String(token ?? ''), 'base64url').toString('utf8');
|
|
102
|
+
// ── OAuth 1.0a helpers: the probe App and its independent signer live in xidentity-conformance.ts ──
|
|
103
|
+
const FORM_H = { 'content-type': 'application/x-www-form-urlencoded' };
|
|
104
|
+
/** Register the probe App through the twin door (X's developer console has no API). */
|
|
105
|
+
const oauth1App = (h) => h({ m: 'POST', p: '/_twin/oauth1_apps', b: JSON.stringify({ consumer_key: OAUTH1_PROBE_APP.key, consumer_secret: OAUTH1_PROBE_APP.secret, name: 'Probe App', callback_urls: [OAUTH1_PROBE_CALLBACK] }) });
|
|
106
|
+
/** The conformance fixtures' call shape over this verify's handler. */
|
|
107
|
+
const oauthCall = (h) => (m, p, b, headers) => h({ m, p, ...(b === undefined ? {} : { b }), ...(headers ? { h: headers } : {}) });
|
|
108
|
+
/** The legacy v1.1 error code of a refusal. */
|
|
109
|
+
const v1Code = (r) => r.body?.errors?.[0]?.code;
|
|
102
110
|
/** Build an authorize URL from parameters, so a verify can vary exactly one of them. `undefined`
|
|
103
111
|
* REMOVES a base parameter. */
|
|
104
112
|
function authUrl(params = {}) {
|
|
@@ -500,6 +508,11 @@ export const XIDENTITY_CAPABILITIES = [
|
|
|
500
508
|
&& d['verified'] === true
|
|
501
509
|
&& d['verified_type'] === 'blue';
|
|
502
510
|
})),
|
|
511
|
+
// The x pack answers user.fields=profile_image_url with X's default avatar for an account seeded
|
|
512
|
+
// without a photo (every X account has one); this pack omits it, because a PARTIALLY-pulled persona
|
|
513
|
+
// cannot tell "no photo" from "not pulled" (renderUser). Serving the default for a seeded persona
|
|
514
|
+
// needs that distinction recorded on the row — until then the two packs answer differently (§9).
|
|
515
|
+
todo('xidentity.users_me.default_profile_image', 'users_me', "user.fields=profile_image_url for a persona seeded without a photo: X's default avatar URL (abs.twimg.com default_profile_normal.png), as the x pack serves it, rather than omission", 'api', 'common'),
|
|
503
516
|
done('xidentity.users_me.public_metrics', 'users_me', 'user.fields=public_metrics returns the metrics object with the vendor\'s six counters', 'api', 'common', () => withRoot(async (h) => {
|
|
504
517
|
const { tokens } = await fullFlow(h);
|
|
505
518
|
const r = await h({ m: 'GET', p: '/2/users/me?user.fields=public_metrics', h: { authorization: `Bearer ${tokens['access_token']}` } });
|
|
@@ -560,7 +573,17 @@ export const XIDENTITY_CAPABILITIES = [
|
|
|
560
573
|
todo('xidentity.users_me.invalid_request_envelope_pin', 'users_me', 'PIN the live invalid-parameter body against the OpenAPI\'s singular InvalidRequestProblem (the twin serves the reported errors[] wire shape)', 'api', 'common'),
|
|
561
574
|
todo('xidentity.users_me.confirmed_email_scope_behavior', 'users_me', 'PIN what the live endpoint does when confirmed_email is requested WITHOUT users.email (the twin withholds the field; a 403 is the other candidate)', 'api', 'niche'),
|
|
562
575
|
todo('xidentity.users_me.app_only_forbidden', 'users_me', 'Model the 403 Unsupported Authentication envelope an app-only bearer gets on this user-context endpoint', 'api', 'common'),
|
|
563
|
-
|
|
576
|
+
done('xidentity.users_me.oauth1_user_context', 'users_me', 'GET /2/users/me honours OAuth 1.0a User Context, the alternate scheme the OpenAPI lists: a signed request answers the token\'s user, a wrong secret or unknown token is the 401', 'api', 'common', () => withRoot(async (h) => {
|
|
577
|
+
await oauth1App(h);
|
|
578
|
+
const at = await oauth1Fixtures(oauthCall(h)).accessToken();
|
|
579
|
+
const sign = (t, q = '') => oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me${q}`, OAUTH1_PROBE_APP, t);
|
|
580
|
+
const r = await h({ m: 'GET', p: '/2/users/me?user.fields=username,verified', h: { authorization: sign(at, '?user.fields=username,verified') } });
|
|
581
|
+
// the query is signed: the same header over a different query does not verify
|
|
582
|
+
const unsignedQuery = await h({ m: 'GET', p: '/2/users/me?user.fields=location', h: { authorization: sign(at, '?user.fields=username,verified') } });
|
|
583
|
+
const unknown = await h({ m: 'GET', p: '/2/users/me', h: { authorization: sign({ key: `${ADA.id}-NotIssued`, secret: at.secret }) } });
|
|
584
|
+
return r.status === 200 && body(r).data.id === ADA.id && body(r).data.username === ADA.username && body(r).data.verified === ADA.verified
|
|
585
|
+
&& unsignedQuery.status === 401 && unknown.status === 401;
|
|
586
|
+
})),
|
|
564
587
|
todo('xidentity.users_me.partial_errors', 'users_me', 'Model the errors[] array alongside data (per-field Problem entries on a 200, per GetUsersMeResponse)', 'api', 'niche'),
|
|
565
588
|
// ═══ rate envelope ════════════════════════════════════════════════════════════════════════
|
|
566
589
|
done('xidentity.rate.headers', 'rate', 'Every /2/users/me response carries x-rate-limit-limit/remaining/reset (docs.x.com fundamentals): limit 75, remaining decrements per call, reset a unix timestamp', 'api', 'core', () => withRoot(async (h) => {
|
|
@@ -633,12 +656,14 @@ export const XIDENTITY_CAPABILITIES = [
|
|
|
633
656
|
todo('xidentity.scopes.unknown_scope_live', 'scopes', 'PIN what the live authorize endpoint does with an unknown scope value (the twin renders it flagged on the screen rather than inventing a rejection)', 'api', 'niche'),
|
|
634
657
|
todo('xidentity.scopes.users_email_wording', 'scopes', 'PIN the consent screen\'s live wording for users.email (the label is twin prose; the scope itself is announcement-grounded)', 'ui', 'niche'),
|
|
635
658
|
// ═══ read-only twin ═══════════════════════════════════════════════════════════════════════
|
|
636
|
-
done('xidentity.readonly.writes_refused', 'readonly', 'A read-only twin refuses every credential-minting leg (405, protocol-shaped error body) — D3', 'api', 'common', () => withRoot(async (h) => {
|
|
659
|
+
done('xidentity.readonly.writes_refused', 'readonly', 'A read-only twin refuses every credential-minting leg and the OAuth 1.0a screen\'s decision (405, protocol-shaped error body) — D3', 'api', 'common', () => withRoot(async (h) => {
|
|
637
660
|
await fullFlow(h); // seed a world first, through the ordinary write path
|
|
638
661
|
const ro = (s) => handleXIdentityTwinRequest({ method: s.m, path: s.p, ...(s.b !== undefined ? { body: s.b } : {}), root: h.root, origin: DEMO_ORIGIN, occurredAt: AT, readOnly: true });
|
|
639
662
|
const tok = await ro({ m: 'POST', p: '/2/oauth2/token', b: form({ grant_type: 'authorization_code', code: 'x', client_id: DEFAULT_CLIENT_ID, redirect_uri: REDIRECT, code_verifier: VERIFIER }) });
|
|
640
663
|
const rev = await ro({ m: 'POST', p: '/2/oauth2/revoke', b: form({ token: 'x', client_id: DEFAULT_CLIENT_ID }) });
|
|
641
|
-
|
|
664
|
+
// the OAuth 1.0a screen's decision settles consent: a write, though its path is also the GET screen's
|
|
665
|
+
const decide = await ro({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: 'x', decision: 'allow' }) });
|
|
666
|
+
return tok.status === 405 && body(tok)['error'] === 'temporarily_unavailable' && rev.status === 405 && decide.status === 405;
|
|
642
667
|
})),
|
|
643
668
|
done('xidentity.readonly.reads_served', 'readonly', 'A read-only twin still serves GET /2/users/me over existing state (rate accounting suspended, headers intact)', 'api', 'niche', () => withRoot(async (h) => {
|
|
644
669
|
const { tokens } = await fullFlow(h);
|
|
@@ -648,7 +673,7 @@ export const XIDENTITY_CAPABILITIES = [
|
|
|
648
673
|
// ═══ conformance (the endpoint census with teeth) ═════════════════════════════════════════
|
|
649
674
|
done('xidentity.conformance.census', 'conformance', 'The conformance check passes: one real probe per claimed endpoint, the ROUTER_SURFACE census, the live round trip, and a reachability witness per resource type', 'api', 'core', async () => {
|
|
650
675
|
const report = await checkXIdentityConformance();
|
|
651
|
-
return report.ok && report.endpointsProbed ===
|
|
676
|
+
return report.ok && report.endpointsProbed === 11 && report.resourceTypesChecked === 13;
|
|
652
677
|
}),
|
|
653
678
|
// ═══ the authorize SCREEN (the vendor's own UI — data-coupled, per ADDING_A_TWIN.md §6) ═══
|
|
654
679
|
done('xidentity.ui.authorize_screen', 'ui', 'The authorize screen names the REGISTERED app and the SIGNED-IN account from the projection (data-coupled: a seeded client + persona appear on screen)', 'ui', 'core', uiDataCoupled({
|
|
@@ -896,9 +921,219 @@ export const XIDENTITY_CAPABILITIES = [
|
|
|
896
921
|
// ═══ adjacent X auth surface the identity twin does not model yet (honest denominator) ═════
|
|
897
922
|
todo('xidentity.app_only.bearer_token', 'app_only', 'Model the app-only bearer flow (POST /oauth2/token, grant_type=client_credentials, the v1.1 path)', 'api', 'common'),
|
|
898
923
|
todo('xidentity.app_only.invalidate_token', 'app_only', 'Model POST /oauth2/invalidate_token for app-only bearers', 'api', 'niche'),
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
924
|
+
// ═══ OAuth 1.0a (xidentity-oauth1.ts; docs.x.com api-reference + obtaining-user-access-tokens) ══
|
|
925
|
+
done('xidentity.oauth1.request_token', 'oauth1', 'POST oauth/request_token signed by a registered App answers oauth_token, oauth_token_secret and oauth_callback_confirmed=true, and the token opens that App\'s screen', 'api', 'common', () => withRoot(async (h) => {
|
|
926
|
+
await oauth1App(h);
|
|
927
|
+
const r = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: OAUTH1_PROBE_CALLBACK }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: OAUTH1_PROBE_CALLBACK }) } });
|
|
928
|
+
const got = new URLSearchParams(String(r.body));
|
|
929
|
+
const token = got.get('oauth_token') ?? '';
|
|
930
|
+
const screen = await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(token)}` });
|
|
931
|
+
return r.status === 200 && got.get('oauth_callback_confirmed') === 'true' && /^[A-Za-z0-9]{27}$/.test(token)
|
|
932
|
+
&& (got.get('oauth_token_secret') ?? '').length > 0 && html(screen).includes('>Probe App<');
|
|
933
|
+
})),
|
|
934
|
+
done('xidentity.oauth1.authorize', 'oauth1', 'GET oauth/authorize shows X\'s screen naming the App and the signed-in account; Authorize app sends the person to the callback with exactly oauth_token and oauth_verifier', 'api', 'common', () => withRoot(async (h) => {
|
|
935
|
+
await oauth1App(h);
|
|
936
|
+
const fx = oauth1Fixtures(oauthCall(h));
|
|
937
|
+
const t = await fx.requestToken();
|
|
938
|
+
const screen = await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
|
|
939
|
+
const handle = AUTH_REQUEST_RE.exec(html(screen))?.[1] ?? '';
|
|
940
|
+
const d = await h({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: handle, decision: 'allow' }), h: FORM_H });
|
|
941
|
+
const back = new URL(loc(d) || 'http://invalid.test/');
|
|
942
|
+
return screen.status === 200 && html(screen).includes('<strong class="app-name">Probe App</strong> wants to access your X account')
|
|
943
|
+
&& html(screen).includes(`@${ADA.username}`) && html(screen).includes('action="http://localhost:3000/oauth/authorize"')
|
|
944
|
+
&& d.status === 302 && `${back.origin}${back.pathname}` === OAUTH1_PROBE_CALLBACK
|
|
945
|
+
&& back.searchParams.get('oauth_token') === t.key && /^[A-Za-z0-9]{32}$/.test(back.searchParams.get('oauth_verifier') ?? '')
|
|
946
|
+
&& [...back.searchParams.keys()].length === 2;
|
|
947
|
+
})),
|
|
948
|
+
// api.x.com is shared with the `x` pack, which claims that host's `/_twin/*` and comes first in
|
|
949
|
+
// the World's first-match routing. A decision door there reached the x pack in a World (the
|
|
950
|
+
// Postiz walk, 2026-09-28: "Authorize app" answered the x pack's JSON 400 and X never connected),
|
|
951
|
+
// while every in-process test posted straight to this twin's port. So the verify asks the
|
|
952
|
+
// World's own router which pack the screen's form lands on, for both API hosts, and then drives it.
|
|
953
|
+
done('xidentity.oauth1.authorize_form_routes_here', 'oauth1', 'Authorize app posts to POST oauth/authorize on the host that served the screen, as X\'s own screen does, and the screen\'s stylesheet and script load from that host — paths a World routes to this pack on api.x.com and api.twitter.com, not to the x pack beside it', 'ui', 'common', () => withRoot(async (h) => {
|
|
954
|
+
// the injector's own first-match table (compiled from pack-facts.json), untyped CommonJS
|
|
955
|
+
const { createRequire } = await import('node:module');
|
|
956
|
+
const { VENDOR_HOSTS } = createRequire(import.meta.url)('@volter/world-core/vendor-hosts');
|
|
957
|
+
const routedTo = (host, path) => Object.keys(VENDOR_HOSTS).find((k) => VENDOR_HOSTS[k](host, path));
|
|
958
|
+
await oauth1App(h);
|
|
959
|
+
const t = await oauth1Fixtures(oauthCall(h)).requestToken();
|
|
960
|
+
const screen = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent(t.key)}` });
|
|
961
|
+
const action = /<form [^>]*method="POST"[^>]*action="([^"]+)"|<form [^>]*action="([^"]+)"[^>]*method="POST"/.exec(html(screen));
|
|
962
|
+
const actionPath = new URL(action?.[1] ?? action?.[2] ?? 'http://invalid.test/nowhere').pathname;
|
|
963
|
+
const d = await h({ m: 'POST', p: actionPath, b: form({ auth_request: t.key, decision: 'allow' }), h: FORM_H });
|
|
964
|
+
// …and the page's own stylesheet and script, loaded from the same host (else it renders unstyled)
|
|
965
|
+
const assets = [...html(screen).matchAll(/(?:href|src)="([^"]+\/_twin\/assets\/[^"]+)"/g)].map((m) => new URL(m[1]).pathname);
|
|
966
|
+
const { createXIdentityTwinFetch } = await import("./xidentity-server.js");
|
|
967
|
+
const fetchTwin = createXIdentityTwinFetch({ root: h.root });
|
|
968
|
+
const served = await Promise.all(assets.map((p) => fetchTwin(new Request(`https://api.x.com${p}`))));
|
|
969
|
+
return actionPath === '/oauth/authorize' && routedTo('api.x.com', actionPath) === 'xidentity' && routedTo('api.twitter.com', actionPath) === 'xidentity'
|
|
970
|
+
&& assets.length === 2 && assets.every((p) => routedTo('api.x.com', p) === 'xidentity' && routedTo('api.twitter.com', p) === 'xidentity')
|
|
971
|
+
&& served.every((r) => r.status === 200)
|
|
972
|
+
// the x pack keeps the rest of the host's control prefix
|
|
973
|
+
&& routedTo('api.x.com', '/_twin/accounts') === 'x'
|
|
974
|
+
&& d.status === 302 && qp(d, 'oauth_token') === t.key && (qp(d, 'oauth_verifier') ?? '').length === 32;
|
|
975
|
+
})),
|
|
976
|
+
done('xidentity.oauth1.authorize_read_access', 'oauth1', 'x_auth_access_type=read narrows a Read-and-write App: the screen lists only what reading allows', 'ui', 'niche', () => withRoot(async (h) => {
|
|
977
|
+
await oauth1App(h);
|
|
978
|
+
const read = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: OAUTH1_PROBE_CALLBACK, x_auth_access_type: 'read' }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: OAUTH1_PROBE_CALLBACK, x_auth_access_type: 'read' }) } });
|
|
979
|
+
const token = new URLSearchParams(String(read.body)).get('oauth_token') ?? '';
|
|
980
|
+
const screen = html(await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(token)}` }));
|
|
981
|
+
const write = html(await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent((await oauth1Fixtures(oauthCall(h)).requestToken()).key)}` }));
|
|
982
|
+
return screen.includes('Accounts you follow, mute, and block.') && !screen.includes('Post and delete Posts for you')
|
|
983
|
+
&& write.includes('Post and delete Posts for you');
|
|
984
|
+
})),
|
|
985
|
+
done('xidentity.oauth1.authenticate_skips_approved', 'oauth1', 'GET oauth/authenticate (Sign in with X) redirects without a screen once the person has approved the App, and shows it the first time or with force_login=true', 'api', 'common', () => withRoot(async (h) => {
|
|
986
|
+
await oauth1App(h);
|
|
987
|
+
const fx = oauth1Fixtures(oauthCall(h));
|
|
988
|
+
const first = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}` });
|
|
989
|
+
await fx.approved();
|
|
990
|
+
const again = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}` });
|
|
991
|
+
const forced = await h({ m: 'GET', p: `/oauth/authenticate?oauth_token=${encodeURIComponent((await fx.requestToken()).key)}&force_login=true` });
|
|
992
|
+
const back = new URL(loc(again) || 'http://invalid.test/');
|
|
993
|
+
return first.status === 200 && html(first).includes('Authorize app') && again.status === 302
|
|
994
|
+
&& `${back.origin}${back.pathname}` === OAUTH1_PROBE_CALLBACK && (back.searchParams.get('oauth_verifier') ?? '').length === 32
|
|
995
|
+
&& forced.status === 200 && html(forced).includes('Authorize app');
|
|
996
|
+
})),
|
|
997
|
+
done('xidentity.oauth1.cancel_denied', 'oauth1', 'Cancel sends the person back with denied=<request token>, and that token can no longer be exchanged', 'api', 'common', () => withRoot(async (h) => {
|
|
998
|
+
await oauth1App(h);
|
|
999
|
+
const t = await oauth1Fixtures(oauthCall(h)).requestToken();
|
|
1000
|
+
await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
|
|
1001
|
+
const d = await h({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: t.key, decision: 'deny' }), h: FORM_H });
|
|
1002
|
+
const x = await h({ m: 'POST', p: '/oauth/access_token', b: form({ oauth_verifier: 'x' }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/access_token`, OAUTH1_PROBE_APP, t, { oauth_verifier: 'x' }) } });
|
|
1003
|
+
return d.status === 302 && qp(d, 'denied') === t.key && qp(d, 'oauth_verifier') === null && x.status === 401 && v1Code(x) === 89;
|
|
1004
|
+
})),
|
|
1005
|
+
done('xidentity.oauth1.oob_pin', 'oauth1', 'oauth_callback=oob (PIN-based): approval shows the verifier as a 7-digit PIN, which access_token accepts', 'api', 'niche', () => withRoot(async (h) => {
|
|
1006
|
+
await oauth1App(h);
|
|
1007
|
+
const r = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: 'oob' }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: 'oob' }) } });
|
|
1008
|
+
const t = { key: new URLSearchParams(String(r.body)).get('oauth_token') ?? '', secret: new URLSearchParams(String(r.body)).get('oauth_token_secret') ?? '' };
|
|
1009
|
+
await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
|
|
1010
|
+
const d = await h({ m: 'POST', p: '/oauth/authorize', b: form({ auth_request: t.key, decision: 'allow' }), h: FORM_H });
|
|
1011
|
+
const pin = /<code class="oauth-pin">([^<]+)<\/code>/.exec(html(d))?.[1] ?? '';
|
|
1012
|
+
const x = await h({ m: 'POST', p: '/oauth/access_token', b: form({ oauth_verifier: pin }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/access_token`, OAUTH1_PROBE_APP, t, { oauth_verifier: pin }) } });
|
|
1013
|
+
return d.status === 200 && /^\d{7}$/.test(pin) && x.status === 200 && new URLSearchParams(String(x.body)).get('user_id') === ADA.id;
|
|
1014
|
+
})),
|
|
1015
|
+
done('xidentity.oauth1.access_token', 'oauth1', 'POST oauth/access_token trades an approved request token + verifier for `<user id>-<token>`, its secret, user_id and screen_name — once', 'api', 'common', () => withRoot(async (h) => {
|
|
1016
|
+
await oauth1App(h);
|
|
1017
|
+
const t = await oauth1Fixtures(oauthCall(h)).approved();
|
|
1018
|
+
// twitter-api-v2's shape (Postiz's login): oauth_token in the header AND the body, signed once
|
|
1019
|
+
const exchange = (verifier) => h({ m: 'POST', p: '/oauth/access_token', b: form({ oauth_token: t.key, oauth_verifier: verifier }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/access_token`, OAUTH1_PROBE_APP, t, { oauth_verifier: verifier }) } });
|
|
1020
|
+
const wrong = await exchange('not-the-verifier');
|
|
1021
|
+
const r = await exchange(t.verifier);
|
|
1022
|
+
const got = new URLSearchParams(String(r.body));
|
|
1023
|
+
const replay = await exchange(t.verifier);
|
|
1024
|
+
return wrong.status === 401 && r.status === 200 && new RegExp(`^${ADA.id}-[A-Za-z0-9]{40}$`).test(got.get('oauth_token') ?? '')
|
|
1025
|
+
&& (got.get('oauth_token_secret') ?? '').length === 40 && got.get('user_id') === ADA.id && got.get('screen_name') === ADA.username
|
|
1026
|
+
&& replay.status === 401 && v1Code(replay) === 89;
|
|
1027
|
+
})),
|
|
1028
|
+
done('xidentity.oauth1.reauthorize_same_token', 'oauth1', 'A person who approves the same App again gets the access token they already hold (X\'s tokens "do not expire"); a revoked one is replaced', 'api', 'niche', () => withRoot(async (h) => {
|
|
1029
|
+
await oauth1App(h);
|
|
1030
|
+
const fx = oauth1Fixtures(oauthCall(h));
|
|
1031
|
+
const a = await fx.accessToken();
|
|
1032
|
+
const b = await fx.accessToken();
|
|
1033
|
+
await h({ m: 'POST', p: '/1.1/oauth/invalidate_token', h: { authorization: oauth1Header('POST', `${DEMO_ORIGIN}/1.1/oauth/invalidate_token`, OAUTH1_PROBE_APP, a) } });
|
|
1034
|
+
const c = await fx.accessToken();
|
|
1035
|
+
return a.key !== '' && a.key === b.key && a.secret === b.secret && c.key !== a.key && c.key !== '';
|
|
1036
|
+
})),
|
|
1037
|
+
done('xidentity.oauth1.unknown_consumer_refused', 'oauth1', 'A consumer key X does not hold is 401 with code 32 "Could not authenticate you." and issues nothing', 'api', 'core', () => withRoot(async (h) => {
|
|
1038
|
+
await oauth1App(h);
|
|
1039
|
+
const r = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: OAUTH1_PROBE_CALLBACK }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, { key: 'UnknownConsumer', secret: OAUTH1_PROBE_APP.secret }, null, { oauth_callback: OAUTH1_PROBE_CALLBACK }) } });
|
|
1040
|
+
const none = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: OAUTH1_PROBE_CALLBACK }), h: FORM_H });
|
|
1041
|
+
return r.status === 401 && v1Code(r) === 32 && body(r).errors[0].message === 'Could not authenticate you.'
|
|
1042
|
+
&& none.status === 400 && v1Code(none) === 215;
|
|
1043
|
+
})),
|
|
1044
|
+
done('xidentity.oauth1.bad_signature_refused', 'oauth1', 'A signature made with the wrong consumer secret, the wrong token secret, or over different parameters is refused at every leg and at /2/users/me', 'api', 'core', () => withRoot(async (h) => {
|
|
1045
|
+
await oauth1App(h);
|
|
1046
|
+
const wrongApp = { key: OAUTH1_PROBE_APP.key, secret: 'wrong-consumer-secret' };
|
|
1047
|
+
const leg1 = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: OAUTH1_PROBE_CALLBACK }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, wrongApp, null, { oauth_callback: OAUTH1_PROBE_CALLBACK }) } });
|
|
1048
|
+
// the body says one callback, the signature covered another
|
|
1049
|
+
const tampered = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: 'https://evil.local/cb' }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: OAUTH1_PROBE_CALLBACK }) } });
|
|
1050
|
+
const fx = oauth1Fixtures(oauthCall(h));
|
|
1051
|
+
const t = await fx.approved();
|
|
1052
|
+
const leg3 = await h({ m: 'POST', p: '/oauth/access_token', b: form({ oauth_verifier: t.verifier }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/access_token`, OAUTH1_PROBE_APP, { key: t.key, secret: 'wrong' }, { oauth_verifier: t.verifier }) } });
|
|
1053
|
+
const at = await fx.accessToken();
|
|
1054
|
+
const me = (secret, url = `${DEMO_ORIGIN}/2/users/me`) => h({ m: 'GET', p: '/2/users/me', h: { authorization: oauth1Header('GET', url, OAUTH1_PROBE_APP, { key: at.key, secret }) } });
|
|
1055
|
+
const good = await me(at.secret);
|
|
1056
|
+
const badToken = await me('wrong');
|
|
1057
|
+
const otherUrl = await me(at.secret, `${DEMO_ORIGIN}/2/users/me/other`);
|
|
1058
|
+
// a query realm is an ordinary parameter (only the header's is unsigned), and a repeated key is refused
|
|
1059
|
+
const signedPlain = oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me?user.fields=username`, OAUTH1_PROBE_APP, at);
|
|
1060
|
+
const realmInjected = await h({ m: 'GET', p: '/2/users/me?user.fields=username&realm=injected', h: { authorization: signedPlain } });
|
|
1061
|
+
// even signed over it: X accepts no duplicate keys
|
|
1062
|
+
const dup = '/2/users/me?user.fields=username&user.fields=username';
|
|
1063
|
+
const repeated = await h({ m: 'GET', p: dup, h: { authorization: oauth1Header('GET', `${DEMO_ORIGIN}${dup}`, OAUTH1_PROBE_APP, at) } });
|
|
1064
|
+
const plain = await h({ m: 'GET', p: '/2/users/me?user.fields=username', h: { authorization: signedPlain } });
|
|
1065
|
+
return leg1.status === 401 && v1Code(leg1) === 32 && tampered.status === 401 && v1Code(tampered) === 32
|
|
1066
|
+
&& leg3.status === 401 && v1Code(leg3) === 32 && good.status === 200 && plain.status === 200
|
|
1067
|
+
&& badToken.status === 401 && body(badToken).type === 'about:blank' && otherUrl.status === 401
|
|
1068
|
+
&& realmInjected.status === 401 && repeated.status === 401;
|
|
1069
|
+
})),
|
|
1070
|
+
done('xidentity.oauth1.callback_not_approved', 'oauth1', 'A callback the App\'s settings do not list is refused with code 415 "Callback URL not approved for this client application."', 'api', 'common', () => withRoot(async (h) => {
|
|
1071
|
+
await oauth1App(h);
|
|
1072
|
+
const cb = 'https://probe-app.local/x/callback/';
|
|
1073
|
+
const r = await h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: cb }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, OAUTH1_PROBE_APP, null, { oauth_callback: cb }) } });
|
|
1074
|
+
return r.status === 403 && v1Code(r) === 415 && body(r).errors[0].message === 'Callback URL not approved for this client application.';
|
|
1075
|
+
})),
|
|
1076
|
+
done('xidentity.oauth1.invalid_request_token_page', 'oauth1', 'An authorize link whose request token X does not hold (unknown or already used) shows the invalid-token page, not a screen', 'api', 'niche', () => withRoot(async (h) => {
|
|
1077
|
+
await oauth1App(h);
|
|
1078
|
+
const fx = oauth1Fixtures(oauthCall(h));
|
|
1079
|
+
const t = await fx.approved();
|
|
1080
|
+
const used = await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(t.key)}` });
|
|
1081
|
+
const unknown = await h({ m: 'GET', p: '/oauth/authorize?oauth_token=NotARequestToken' });
|
|
1082
|
+
return used.status === 400 && html(used).includes('The request token for this page is invalid') && !html(used).includes('Authorize app')
|
|
1083
|
+
&& unknown.status === 400 && html(unknown).includes('The request token for this page is invalid');
|
|
1084
|
+
})),
|
|
1085
|
+
done('xidentity.oauth1.invalidate_token', 'oauth1', 'POST 1.1/oauth/invalidate_token answers {"access_token": …} and the token is then refused (401) at /2/users/me and again at invalidate (code 89)', 'api', 'niche', () => withRoot(async (h) => {
|
|
1086
|
+
await oauth1App(h);
|
|
1087
|
+
const at = await oauth1Fixtures(oauthCall(h)).accessToken();
|
|
1088
|
+
const inv = () => h({ m: 'POST', p: '/1.1/oauth/invalidate_token', h: { authorization: oauth1Header('POST', `${DEMO_ORIGIN}/1.1/oauth/invalidate_token`, OAUTH1_PROBE_APP, at) } });
|
|
1089
|
+
const before = await h({ m: 'GET', p: '/2/users/me', h: { authorization: oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me`, OAUTH1_PROBE_APP, at) } });
|
|
1090
|
+
const r = await inv();
|
|
1091
|
+
const after = await h({ m: 'GET', p: '/2/users/me', h: { authorization: oauth1Header('GET', `${DEMO_ORIGIN}/2/users/me`, OAUTH1_PROBE_APP, at) } });
|
|
1092
|
+
const again = await inv();
|
|
1093
|
+
return before.status === 200 && r.status === 200 && body(r).access_token === at.key && after.status === 401 && again.status === 401 && v1Code(again) === 89;
|
|
1094
|
+
})),
|
|
1095
|
+
done('xidentity.oauth1.world_app', 'oauth1', 'The World\'s own App (X_API_KEY / X_API_SECRET the World sets) signs request_token and is named on the screen for the World; the same values from the caller\'s shell alone are no App', 'api', 'common', () => withRoot(async (h) => {
|
|
1096
|
+
const names = ['X_API_KEY', 'X_API_SECRET', 'VOLTER_WORLD_ENV_NAMES', 'VOLTER_WORLD_NAME'];
|
|
1097
|
+
const saved = Object.fromEntries(names.map((n) => [n, process.env[n]]));
|
|
1098
|
+
const app = { key: 'WorldConsumerKey000000001', secret: 'WorldConsumerSecret0000000000000000000000000001' };
|
|
1099
|
+
const ask = () => h({ m: 'POST', p: '/oauth/request_token', b: form({ oauth_callback: 'https://postiz.local/integrations/social/x' }), h: { ...FORM_H, authorization: oauth1Header('POST', `${DEMO_ORIGIN}/oauth/request_token`, app, null, { oauth_callback: 'https://postiz.local/integrations/social/x' }) } });
|
|
1100
|
+
try {
|
|
1101
|
+
Object.assign(process.env, { X_API_KEY: app.key, X_API_SECRET: app.secret, VOLTER_WORLD_NAME: 'acme' });
|
|
1102
|
+
delete process.env.VOLTER_WORLD_ENV_NAMES;
|
|
1103
|
+
const shellOnly = await ask();
|
|
1104
|
+
if (shellOnly.status !== 401 || v1Code(shellOnly) !== 32)
|
|
1105
|
+
return false;
|
|
1106
|
+
process.env.VOLTER_WORLD_ENV_NAMES = JSON.stringify(['X_API_KEY', 'X_API_SECRET']);
|
|
1107
|
+
const r = await ask();
|
|
1108
|
+
const token = new URLSearchParams(String(r.body)).get('oauth_token') ?? '';
|
|
1109
|
+
const screen = html(await h({ m: 'GET', p: `/oauth/authorize?oauth_token=${encodeURIComponent(token)}` }));
|
|
1110
|
+
return r.status === 200 && screen.includes('<strong class="app-name">acme</strong>');
|
|
1111
|
+
}
|
|
1112
|
+
finally {
|
|
1113
|
+
for (const [n, v] of Object.entries(saved)) {
|
|
1114
|
+
if (v === undefined)
|
|
1115
|
+
delete process.env[n];
|
|
1116
|
+
else
|
|
1117
|
+
process.env[n] = v;
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1120
|
+
})),
|
|
1121
|
+
todo('xidentity.oauth1.sign_in_with_x_setting', 'oauth1', 'The App\'s "Use Sign in with X" setting, which oauth/authenticate\'s skip depends on (the twin treats every App as having it on)', 'api', 'niche'),
|
|
1122
|
+
todo('xidentity.oauth1.reauthorize_token_reuse_pin', 'oauth1', 'PIN that re-authorizing an App returns the access token it already holds (the twin reads it from the docs\' "do not expire" and invalidate_token sentences)', 'api', 'niche'),
|
|
1123
|
+
todo('xidentity.oauth1.timestamp_window', 'oauth1', 'Refuse an oauth_timestamp outside X\'s window (code 135) — needs the app\'s clock and the World\'s to be one', 'api', 'niche'),
|
|
1124
|
+
todo('xidentity.oauth1.nonce_replay', 'oauth1', 'Refuse a repeated oauth_nonce within the timestamp window', 'api', 'niche'),
|
|
1125
|
+
todo('xidentity.oauth1.request_token_ttl', 'oauth1', 'Expire an unused request token (X\'s lifetime is undocumented)', 'api', 'niche'),
|
|
1126
|
+
todo('xidentity.oauth1.login_screen', 'oauth1', 'force_login / screen_name: the x.com login step before the screen (the twin authenticates nobody; the session is the seeded persona)', 'ui', 'niche'),
|
|
1127
|
+
todo('xidentity.oauth1.dm_access_level', 'oauth1', 'The "Read, write, and Direct Messages" App permission and x_auth_access_type\'s interaction with it', 'api', 'niche'),
|
|
1128
|
+
todo('xidentity.oauth1.consent_wording', 'oauth1', 'PIN the OAuth 1.0a screen\'s live lines and the PIN page (the twin shows the widely-screenshotted wording)', 'ui', 'niche'),
|
|
1129
|
+
todo('xidentity.oauth1.callback_refusal_status', 'oauth1', 'PIN the HTTP status of the 415 callback refusal (the docs show the body; the twin answers 403)', 'api', 'niche'),
|
|
1130
|
+
todo('xidentity.oauth1.request_token_error_wording', 'oauth1', 'PIN the live answer to a request_token without oauth_callback (the twin answers 400 code 215)', 'api', 'niche'),
|
|
1131
|
+
todo('xidentity.oauth1.access_token_error_wording', 'oauth1', 'PIN the live answers at access_token for a wrong verifier (the twin answers the widely-reported plain-text 401)', 'api', 'niche'),
|
|
1132
|
+
todo('xidentity.oauth1.access_type_unknown_value', 'oauth1', 'PIN what X does with an x_auth_access_type other than read/write (the twin ignores it)', 'api', 'niche'),
|
|
1133
|
+
todo('xidentity.oauth1.invalid_token_page', 'oauth1', 'PIN the live page and status for an unknown or used request token (the twin serves the "Whoa there!" wording at 400)', 'ui', 'niche'),
|
|
1134
|
+
todo('xidentity.oauth1.denied_callback', 'oauth1', 'PIN the live callback for Cancel (the twin sends denied=<request token>, as widely reported)', 'api', 'niche'),
|
|
1135
|
+
todo('xidentity.oauth1.response_content_type', 'oauth1', 'PIN the content type X serves the form-encoded leg bodies with (the twin serves text/html)', 'api', 'niche'),
|
|
1136
|
+
todo('xidentity.oauth1.verify_credentials', 'oauth1', 'GET 1.1/account/verify_credentials, the identity read the OAuth 1.0a guide names (v1.1 is not modelled)', 'api', 'niche'),
|
|
902
1137
|
];
|
|
903
1138
|
export function xidentityCapabilities() {
|
|
904
1139
|
return checkCapabilities('xidentity', XIDENTITY_CAPABILITIES);
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type XResponse } from './xidentity-twin.js';
|
|
1
2
|
export type XIdentityConformanceReport = {
|
|
2
3
|
ok: boolean;
|
|
3
4
|
endpointsChecked: number;
|
|
@@ -5,6 +6,46 @@ export type XIdentityConformanceReport = {
|
|
|
5
6
|
resourceTypesChecked: number;
|
|
6
7
|
violations: string[];
|
|
7
8
|
};
|
|
9
|
+
/** The Authorization header for `method url` with these extra signed params (query + form body). */
|
|
10
|
+
export declare function oauth1Header(method: string, url: string, consumer: {
|
|
11
|
+
key: string;
|
|
12
|
+
secret: string;
|
|
13
|
+
}, token: {
|
|
14
|
+
key: string;
|
|
15
|
+
secret: string;
|
|
16
|
+
} | null, signed?: Record<string, string>): string;
|
|
17
|
+
/** The App the OAuth 1.0a probes run under, registered through the twin door. */
|
|
18
|
+
export declare const OAUTH1_PROBE_APP: {
|
|
19
|
+
key: string;
|
|
20
|
+
secret: string;
|
|
21
|
+
};
|
|
22
|
+
export declare const OAUTH1_PROBE_CALLBACK = "https://probe-app.local/x/callback";
|
|
23
|
+
type Call = (method: string, path: string, body?: string, headers?: Record<string, string>) => Promise<XResponse>;
|
|
24
|
+
export type OAuth1Fixtures = {
|
|
25
|
+
/** A fresh request token (and its secret) from step 1. */
|
|
26
|
+
requestToken(): Promise<{
|
|
27
|
+
key: string;
|
|
28
|
+
secret: string;
|
|
29
|
+
}>;
|
|
30
|
+
/** A fresh request token whose screen the person has open (the decision is still to make). */
|
|
31
|
+
presented(): Promise<{
|
|
32
|
+
key: string;
|
|
33
|
+
secret: string;
|
|
34
|
+
}>;
|
|
35
|
+
/** A request token taken through the screen and approved: its verifier. */
|
|
36
|
+
approved(): Promise<{
|
|
37
|
+
key: string;
|
|
38
|
+
secret: string;
|
|
39
|
+
verifier: string;
|
|
40
|
+
}>;
|
|
41
|
+
/** A usable access token for the signed-in persona. */
|
|
42
|
+
accessToken(): Promise<{
|
|
43
|
+
key: string;
|
|
44
|
+
secret: string;
|
|
45
|
+
}>;
|
|
46
|
+
};
|
|
47
|
+
export declare function oauth1Fixtures(call: Call): OAuth1Fixtures;
|
|
8
48
|
export declare function checkXIdentityConformance(opts?: {
|
|
9
49
|
root?: string;
|
|
10
50
|
}): Promise<XIdentityConformanceReport>;
|
|
51
|
+
export {};
|