@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
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
// X's OAuth 1.0a — the three-legged flow and the user-context signature that authorizes the API.
|
|
2
|
+
//
|
|
3
|
+
// api.x.com POST /oauth/request_token → step 1: a request token for a registered App
|
|
4
|
+
// GET /oauth/authorize → step 2: the authorize screen, always shown
|
|
5
|
+
// GET /oauth/authenticate → step 2 (Sign in with X): skipped once approved
|
|
6
|
+
// POST /oauth/access_token → step 3: the request token + verifier → access token
|
|
7
|
+
// POST /1.1/oauth/invalidate_token → an App revokes an access token it holds
|
|
8
|
+
//
|
|
9
|
+
// GROUNDING (docs.x.com, fetched 2026-09-27): resources/fundamentals/authentication/api-reference
|
|
10
|
+
// (the five endpoints, their parameters and the request/response examples quoted at each site),
|
|
11
|
+
// .../oauth-1-0a/obtaining-user-access-tokens (the walkthrough: oauth_callback_confirmed, the
|
|
12
|
+
// callback carrying oauth_token + oauth_verifier, tokens that "do not expire but can be revoked"),
|
|
13
|
+
// .../oauth-1-0a/creating-a-signature (the HMAC-SHA1 base string: method, base URL, the sorted
|
|
14
|
+
// percent-encoded parameter string; the signing key `consumer_secret&token_secret`), and
|
|
15
|
+
// fundamentals/developer-apps (the Read / Read and write permission levels; the 415 "Callback URL
|
|
16
|
+
// not approved" body verbatim). Legacy error codes 32/89/135/215 are X's v1.1 error table
|
|
17
|
+
// (developer.x.com error-troubleshooting, whose codes the consumer library also enumerates:
|
|
18
|
+
// twitter-api-v2@1.29.1 EApiV1ErrorCode); where a wording or status is not in a fetched page the
|
|
19
|
+
// site says EXTRAPOLATION and a manifest todo pins it.
|
|
20
|
+
//
|
|
21
|
+
// THE CONSUMER this was built for is Postiz (gitroomhq/postiz-app@374fb202334a,
|
|
22
|
+
// libraries/nestjs-libraries/src/integrations/social/x.provider.ts): `generateAuthLink(callback,
|
|
23
|
+
// {authAccessType:'write', linkMode:'authenticate'})`, `login(oauth_verifier)`, then `v2.me()`,
|
|
24
|
+
// v2 media upload and `POST /2/tweets`, every one signed with OAuth 1.0a HMAC-SHA1 by
|
|
25
|
+
// twitter-api-v2 or by Postiz's own `signOAuth1`.
|
|
26
|
+
//
|
|
27
|
+
// ONE VENDOR, TWO PACKS. The access tokens minted here authorize X's posting surface too, which
|
|
28
|
+
// the `x` pack serves. X has one credential store; this pack is it. The `x` pack READS the
|
|
29
|
+
// `oauth1_token` and `oauth1_app` rows below through the kernel projection (never writes them) and
|
|
30
|
+
// verifies the signature with its own transcription of `oauth1Params`/`signatureMatches` (A3
|
|
31
|
+
// forbids the import). The row shapes are that contract:
|
|
32
|
+
// oauth1_app id=consumer key { secret, name, callbackUrls: string[], accessLevel: 'read'|'read-write', deleted? }
|
|
33
|
+
// oauth1_token id=access token { consumerKey, secret, userId, screenName, accessLevel, revoked }
|
|
34
|
+
// and the World's own App is the pair its env names (worldOAuth1App below, transcribed there too).
|
|
35
|
+
//
|
|
36
|
+
// NOT MODELLED (manifest todos): the ±5-minute timestamp window and nonce replay (code 135) — the
|
|
37
|
+
// twin has no clock but the World's, and an app signs with its host's; request-token expiry; the
|
|
38
|
+
// login screen force_login/screen_name would show (the twin authenticates nobody: the signed-in
|
|
39
|
+
// session is the seeded persona, as for OAuth 2.0); the v1.1 API the tokens also authorize.
|
|
40
|
+
import { createHmac, timingSafeEqual } from 'node:crypto';
|
|
41
|
+
import { applyTwinWriteAtomic, worldEnvValue } from '@volter/world-core';
|
|
42
|
+
import { errorPageHtml, pinPageHtml, consentPageHtml, xIdentityOAuth1ConsentState } from "./xidentity-consent-ui.js";
|
|
43
|
+
import { oauthError } from "./xidentity-problems.js";
|
|
44
|
+
import { nextRevIn, readAll, readType, sessionAccount, stableHex, writeAtomic } from "./xidentity-store.js";
|
|
45
|
+
const HTML = { 'content-type': 'text/html; charset=utf-8' };
|
|
46
|
+
const JSON_TYPE = { 'content-type': 'application/json; charset=utf-8' };
|
|
47
|
+
const NOSTORE = { 'cache-control': 'no-cache, no-store, max-age=0' };
|
|
48
|
+
// ── RFC 5849 mechanics, as X's creating-a-signature guide states them ───────────────────────────
|
|
49
|
+
/** RFC 3986 percent-encoding, the guide's "Percent encoding parameters" (unreserved: A-Z a-z 0-9 - . _ ~). */
|
|
50
|
+
export const pct = (s) => encodeURIComponent(s).replace(/[!'()*]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
|
|
51
|
+
/** The `Authorization: OAuth k="v", …` header as decoded pairs; null when it is not an OAuth header. */
|
|
52
|
+
export function parseOAuthHeader(value) {
|
|
53
|
+
if (!value)
|
|
54
|
+
return null;
|
|
55
|
+
const m = /^OAuth\s+(.*)$/is.exec(value.trim());
|
|
56
|
+
if (!m)
|
|
57
|
+
return null;
|
|
58
|
+
const out = [];
|
|
59
|
+
for (const part of m[1].split(',')) {
|
|
60
|
+
const kv = /^\s*([^=\s]+)\s*=\s*"([^"]*)"\s*$/.exec(part);
|
|
61
|
+
if (!kv) {
|
|
62
|
+
if (part.trim() === '')
|
|
63
|
+
continue;
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
out.push([decodeURIComponent(kv[1]), decodeURIComponent(kv[2])]);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return out;
|
|
74
|
+
}
|
|
75
|
+
/** Every oauth_* protocol parameter the request carries, header first (RFC 5849 §3.5 order). */
|
|
76
|
+
export function oauth1Params(req) {
|
|
77
|
+
const out = new Map();
|
|
78
|
+
for (const list of [req.header, req.body, req.query]) {
|
|
79
|
+
for (const [k, v] of list)
|
|
80
|
+
if (k.startsWith('oauth_') && !out.has(k))
|
|
81
|
+
out.set(k, v);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
function baseString(method, baseUrl, pairs) {
|
|
86
|
+
const encoded = pairs.map(([k, v]) => [pct(k), pct(v)]).sort((a, b) => (a[0] === b[0] ? (a[1] < b[1] ? -1 : a[1] > b[1] ? 1 : 0) : a[0] < b[0] ? -1 : 1));
|
|
87
|
+
return `${method.toUpperCase()}&${pct(baseUrl)}&${pct(encoded.map(([k, v]) => `${k}=${v}`).join('&'))}`;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* The base strings a correct signer may have signed. The guide's rule collects every parameter
|
|
91
|
+
* once per occurrence (header, query, body) except `oauth_signature`, and RFC 5849 §3.4.1.3.1
|
|
92
|
+
* excludes the Authorization header's `realm` — only that one: a `realm` in the query or body is
|
|
93
|
+
* an ordinary, signed parameter. ONE leniency, and only it: twitter-api-v2 — the library Postiz
|
|
94
|
+
* logs in with — sends `oauth_token` in BOTH the header and the body of `POST oauth/access_token`
|
|
95
|
+
* and signs it ONCE (its OAuth1Helper merges the two into one object); X completes that login in
|
|
96
|
+
* production (Postiz's x.provider.ts `authenticate`), so the twin also accepts an `oauth_*`
|
|
97
|
+
* parameter the HEADER carries and the body or query repeats with the same value, counted once.
|
|
98
|
+
* Any other repetition is signed per occurrence (X "does not accept duplicate keys in API
|
|
99
|
+
* requests", creating-a-signature footnote 2 — a request that repeats a key is refused below).
|
|
100
|
+
*/
|
|
101
|
+
function candidateBaseStrings(req) {
|
|
102
|
+
const header = req.header.filter(([k]) => k !== 'oauth_signature' && k !== 'realm');
|
|
103
|
+
const rest = [...req.query, ...req.body].filter(([k]) => k !== 'oauth_signature');
|
|
104
|
+
const strict = baseString(req.method, req.baseUrl, [...header, ...rest]);
|
|
105
|
+
const inHeader = new Set(header.map(([k, v]) => `${k}\u0000${v}`));
|
|
106
|
+
const echoed = rest.filter(([k, v]) => !(k.startsWith('oauth_') && inHeader.has(`${k}\u0000${v}`)));
|
|
107
|
+
if (echoed.length === rest.length)
|
|
108
|
+
return [strict];
|
|
109
|
+
return [strict, baseString(req.method, req.baseUrl, [...header, ...echoed])];
|
|
110
|
+
}
|
|
111
|
+
/** A key repeated within the query or within the body (X accepts no duplicate keys — the guide). */
|
|
112
|
+
function repeatsAKey(req) {
|
|
113
|
+
for (const list of [req.query, req.body]) {
|
|
114
|
+
const keys = list.map(([k]) => k);
|
|
115
|
+
if (new Set(keys).size !== keys.length)
|
|
116
|
+
return true;
|
|
117
|
+
}
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
/** HMAC-SHA1 over the base string with `pct(consumer_secret)&pct(token_secret)` — the guide's key. */
|
|
121
|
+
export function signatureMatches(req, consumerSecret, tokenSecret) {
|
|
122
|
+
const presented = oauth1Params(req).get('oauth_signature');
|
|
123
|
+
if (!presented || repeatsAKey(req))
|
|
124
|
+
return false;
|
|
125
|
+
const key = `${pct(consumerSecret)}&${pct(tokenSecret)}`;
|
|
126
|
+
const given = Buffer.from(presented);
|
|
127
|
+
return candidateBaseStrings(req).some((base) => {
|
|
128
|
+
const expected = Buffer.from(createHmac('sha1', key).update(base).digest('base64'));
|
|
129
|
+
return expected.length === given.length && timingSafeEqual(expected, given);
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
/** The protocol parameters every signed X request carries. X signs with HMAC-SHA1 only (the guide). */
|
|
133
|
+
function wellFormed(params) {
|
|
134
|
+
return ['oauth_consumer_key', 'oauth_nonce', 'oauth_signature', 'oauth_timestamp'].every((k) => (params.get(k) ?? '') !== '')
|
|
135
|
+
&& params.get('oauth_signature_method') === 'HMAC-SHA1'
|
|
136
|
+
&& (!params.has('oauth_version') || params.get('oauth_version') === '1.0');
|
|
137
|
+
}
|
|
138
|
+
const X_HOSTS = new Set(['api.x.com', 'api.twitter.com', 'x.com', 'twitter.com']);
|
|
139
|
+
/**
|
|
140
|
+
* The URL the client signed. Reached through the injector the request still names X's host (the
|
|
141
|
+
* Node path keeps Host; the fetch path names it in `x-volter-twin-original-host`) and the client
|
|
142
|
+
* signed `https://<that host><path>`. Reached directly, it signed where it sent: `origin` (the
|
|
143
|
+
* twin's public base, a World mount included). A direct in-process call with neither is X's own
|
|
144
|
+
* API origin.
|
|
145
|
+
*/
|
|
146
|
+
export function signedBaseUrl(headers, origin, path) {
|
|
147
|
+
const named = (headers?.['x-volter-twin-original-host'] ?? headers?.['host'] ?? '').toLowerCase().replace(/:443$/, '');
|
|
148
|
+
if (X_HOSTS.has(named.replace(/:\d+$/, '')))
|
|
149
|
+
return `https://${named.replace(/:\d+$/, '')}${path}`;
|
|
150
|
+
const base = origin ?? 'https://api.x.com';
|
|
151
|
+
let u;
|
|
152
|
+
try {
|
|
153
|
+
u = new URL(base);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
return `https://api.x.com${path}`;
|
|
157
|
+
}
|
|
158
|
+
const port = u.port && !((u.protocol === 'https:' && u.port === '443') || (u.protocol === 'http:' && u.port === '80')) ? `:${u.port}` : '';
|
|
159
|
+
return `${u.protocol}//${u.hostname.toLowerCase()}${port}${u.pathname.replace(/\/+$/, '')}${path}`;
|
|
160
|
+
}
|
|
161
|
+
/** Split a request into the signed pieces. Body pairs count only for a form-encoded body (the guide). */
|
|
162
|
+
export function signedRequestOf(req) {
|
|
163
|
+
const [p, q = ''] = req.path.split('?');
|
|
164
|
+
const pairs = (s) => [...new URLSearchParams(s).entries()];
|
|
165
|
+
const type = (req.headers?.['content-type'] ?? '').toLowerCase();
|
|
166
|
+
const formBody = type.startsWith('application/x-www-form-urlencoded') && req.method.toUpperCase() !== 'GET';
|
|
167
|
+
return {
|
|
168
|
+
method: req.method,
|
|
169
|
+
baseUrl: signedBaseUrl(req.headers, req.origin, p ?? '/'),
|
|
170
|
+
header: parseOAuthHeader(req.headers?.['authorization']) ?? [],
|
|
171
|
+
query: pairs(q),
|
|
172
|
+
body: formBody ? pairs(req.body ?? '') : [],
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
// ── X's v1.1 error family for the OAuth endpoints ───────────────────────────────────────────────
|
|
176
|
+
function v1Error(status, code, message) {
|
|
177
|
+
return { status, body: { errors: [{ code, message }] }, headers: { ...JSON_TYPE, ...NOSTORE } };
|
|
178
|
+
}
|
|
179
|
+
/** 215 "Bad Authentication data." — no usable OAuth header at all; X's table pairs it with HTTP 400. */
|
|
180
|
+
export const badAuthenticationData = () => v1Error(400, 215, 'Bad Authentication data.');
|
|
181
|
+
/** 32 "Could not authenticate you." — an unknown consumer key or a signature that does not verify (401). */
|
|
182
|
+
export const couldNotAuthenticate = () => v1Error(401, 32, 'Could not authenticate you.');
|
|
183
|
+
/** 89 "Invalid or expired token." — verbatim from the invalidate_token example (api-reference). */
|
|
184
|
+
export const invalidOrExpiredToken = () => v1Error(401, 89, 'Invalid or expired token.');
|
|
185
|
+
/** 415, the body verbatim from developer-apps' troubleshooting. HTTP 403 is EXTRAPOLATION
|
|
186
|
+
* (the page shows the body only) — `xidentity.oauth1.callback_refusal_status` pins it. */
|
|
187
|
+
export const callbackNotApproved = () => v1Error(403, 415, 'Callback URL not approved for this client application.');
|
|
188
|
+
/** The env pairs a World names its X App with: Postiz's X_API_KEY / X_API_SECRET (x.provider.ts),
|
|
189
|
+
* and the TWITTER_ spelling of the same "API Key and Secret" (docs.x.com's own name for the pair). */
|
|
190
|
+
export const WORLD_APP_ENV = [['X_API_KEY', 'X_API_SECRET'], ['TWITTER_API_KEY', 'TWITTER_API_SECRET']];
|
|
191
|
+
/**
|
|
192
|
+
* The World's own App: the one its env describes. An app holding consumer keys is an App its
|
|
193
|
+
* developer made in the X developer console, so the twin takes the World's values as that App's —
|
|
194
|
+
* the slack pack's worldSlackApp precedent. Only a value the World sets counts (worldEnvValue),
|
|
195
|
+
* never one the caller's shell passes. Where the documentation stops and the twin decides: the App
|
|
196
|
+
* is named for the World (VOLTER_WORLD_NAME, else "World app"); a key with no secret is no App;
|
|
197
|
+
* its console lists no callback URLs, since the env names none, so it accepts the callback each
|
|
198
|
+
* request names; its permission is Read and write, which an `x_auth_access_type=read` narrows.
|
|
199
|
+
*/
|
|
200
|
+
export function worldOAuth1App() {
|
|
201
|
+
for (const [keyName, secretName] of WORLD_APP_ENV) {
|
|
202
|
+
const key = worldEnvValue(keyName);
|
|
203
|
+
const secret = worldEnvValue(secretName);
|
|
204
|
+
if (key && secret) {
|
|
205
|
+
const name = (typeof process === 'undefined' ? undefined : process.env.VOLTER_WORLD_NAME) || 'World app';
|
|
206
|
+
return { consumerKey: key, secret, name, callbackUrls: [], accessLevel: 'read-write', world: true };
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return undefined;
|
|
210
|
+
}
|
|
211
|
+
/** The App a consumer key names: one registered through the twin door, else the World's own. */
|
|
212
|
+
export function oauth1AppOf(resources, consumerKey) {
|
|
213
|
+
if (!consumerKey)
|
|
214
|
+
return undefined;
|
|
215
|
+
const row = resources.find((r) => r.type === 'oauth1_app' && r.id === consumerKey);
|
|
216
|
+
if (row && row.deleted !== true) {
|
|
217
|
+
return {
|
|
218
|
+
consumerKey,
|
|
219
|
+
secret: String(row.secret),
|
|
220
|
+
name: String(row.name ?? consumerKey),
|
|
221
|
+
callbackUrls: Array.isArray(row.callbackUrls) ? row.callbackUrls.map(String) : [],
|
|
222
|
+
accessLevel: row.accessLevel === 'read' ? 'read' : 'read-write',
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
const world = worldOAuth1App();
|
|
226
|
+
return world?.consumerKey === consumerKey ? world : undefined;
|
|
227
|
+
}
|
|
228
|
+
// ── minting (the store's R9 rule: a stable hash of instant + rows held + subject) ───────────────
|
|
229
|
+
const ALNUM = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
|
|
230
|
+
/** `n` characters of X's mixed-case alphanumeric token alphabet, from the store's stable hash. */
|
|
231
|
+
function alnum(seed, n) {
|
|
232
|
+
const hex = stableHex(seed, n * 2);
|
|
233
|
+
let out = '';
|
|
234
|
+
for (let i = 0; i < n; i += 1)
|
|
235
|
+
out += ALNUM[parseInt(hex.slice(i * 2, i * 2 + 2), 16) % ALNUM.length];
|
|
236
|
+
return out;
|
|
237
|
+
}
|
|
238
|
+
function formBody(req) {
|
|
239
|
+
return new URLSearchParams(req.method.toUpperCase() === 'GET' ? '' : (req.body ?? ''));
|
|
240
|
+
}
|
|
241
|
+
/** A consumer-signed request, checked: the App it names and the signature it carries. */
|
|
242
|
+
function verifyConsumer(req, resources, tokenSecret) {
|
|
243
|
+
const signed = signedRequestOf(req);
|
|
244
|
+
const params = oauth1Params(signed);
|
|
245
|
+
// No credentials at all (no consumer key, no signature, wherever they might travel): 215.
|
|
246
|
+
if (!params.has('oauth_consumer_key') && !params.has('oauth_signature'))
|
|
247
|
+
return { fail: badAuthenticationData() };
|
|
248
|
+
if (!wellFormed(params))
|
|
249
|
+
return { fail: couldNotAuthenticate() };
|
|
250
|
+
const app = oauth1AppOf(resources, params.get('oauth_consumer_key'));
|
|
251
|
+
if (!app)
|
|
252
|
+
return { fail: couldNotAuthenticate() };
|
|
253
|
+
const secret = tokenSecret(params);
|
|
254
|
+
if (secret === null)
|
|
255
|
+
return { fail: invalidOrExpiredToken() };
|
|
256
|
+
if (!signatureMatches(signed, app.secret, secret))
|
|
257
|
+
return { fail: couldNotAuthenticate() };
|
|
258
|
+
return { app, params };
|
|
259
|
+
}
|
|
260
|
+
const formReply = (pairs) => ({
|
|
261
|
+
status: 200,
|
|
262
|
+
body: new URLSearchParams(pairs).toString(),
|
|
263
|
+
// X answers these legs with a form-encoded body served as text/html (the api-reference examples
|
|
264
|
+
// show the body; the type is the widely-reported wire one — EXTRAPOLATION, and twitter-api-v2
|
|
265
|
+
// parses the OAuth endpoints as form-encoded whatever the type says).
|
|
266
|
+
headers: { ...HTML, ...NOSTORE },
|
|
267
|
+
});
|
|
268
|
+
// ── step 1: POST oauth/request_token ────────────────────────────────────────────────────────────
|
|
269
|
+
async function requestToken(req) {
|
|
270
|
+
const at = req.occurredAt;
|
|
271
|
+
return (await applyTwinWriteAtomic('xidentity', (resources) => {
|
|
272
|
+
const checked = verifyConsumer(req, resources, () => '');
|
|
273
|
+
if ('fail' in checked)
|
|
274
|
+
return { kind: 'skip', value: checked.fail };
|
|
275
|
+
const { app, params } = checked;
|
|
276
|
+
// "For OAuth 1.0a compliance this parameter is required" (api-reference). A missing one is a
|
|
277
|
+
// malformed request: RFC 5849 §3.2 answers 400; the body is EXTRAPOLATION (215, pinned by
|
|
278
|
+
// `xidentity.oauth1.request_token_error_wording`).
|
|
279
|
+
const callback = params.get('oauth_callback');
|
|
280
|
+
if (!callback)
|
|
281
|
+
return { kind: 'skip', value: badAuthenticationData() };
|
|
282
|
+
// Any callback other than `oob` "will have to be configured within the App's settings", matched
|
|
283
|
+
// exactly (developer-apps troubleshooting). The World's App lists none and takes any.
|
|
284
|
+
if (callback !== 'oob') {
|
|
285
|
+
try {
|
|
286
|
+
new URL(callback);
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return { kind: 'skip', value: callbackNotApproved() };
|
|
290
|
+
}
|
|
291
|
+
if (!app.world && !app.callbackUrls.includes(callback))
|
|
292
|
+
return { kind: 'skip', value: callbackNotApproved() };
|
|
293
|
+
}
|
|
294
|
+
// x_auth_access_type "overrides the access level an application requests … supported values are
|
|
295
|
+
// read or write … to register a read/write application but also request read only access": it
|
|
296
|
+
// can only narrow. A value outside the two is ignored (EXTRAPOLATION, pinned by
|
|
297
|
+
// `xidentity.oauth1.access_type_unknown_value`).
|
|
298
|
+
const asked = new URLSearchParams(req.path.split('?')[1] ?? '').get('x_auth_access_type') ?? formBody(req).get('x_auth_access_type');
|
|
299
|
+
const accessLevel = app.accessLevel === 'read' || asked === 'read' ? 'read' : 'read-write';
|
|
300
|
+
const held = resources.filter((r) => r.type === 'oauth1_request_token').length;
|
|
301
|
+
const token = alnum(`oauth1_request_token:${at}:${held}:${app.consumerKey}`, 27);
|
|
302
|
+
const secret = alnum(`oauth1_request_token_secret:${at}:${held}:${token}`, 32);
|
|
303
|
+
return {
|
|
304
|
+
kind: 'write',
|
|
305
|
+
value: formReply({ oauth_token: token, oauth_token_secret: secret, oauth_callback_confirmed: 'true' }),
|
|
306
|
+
write: {
|
|
307
|
+
operation: 'oauth1_request_token.create',
|
|
308
|
+
subjectType: 'oauth1_request_token',
|
|
309
|
+
subjectId: token,
|
|
310
|
+
fields: { consumerKey: app.consumerKey, secret, callback, accessLevel, state: 'issued', rev: 1 },
|
|
311
|
+
occurredAt: at,
|
|
312
|
+
actor: { kind: 'agent' },
|
|
313
|
+
},
|
|
314
|
+
};
|
|
315
|
+
}, req.root)).value;
|
|
316
|
+
}
|
|
317
|
+
// ── step 2: GET oauth/authorize | oauth/authenticate ────────────────────────────────────────────
|
|
318
|
+
/** The page X shows for a request token it does not hold. The "Whoa there!" wording is X's classic
|
|
319
|
+
* page as widely reported; status and DOM are EXTRAPOLATION (`xidentity.oauth1.invalid_token_page`). */
|
|
320
|
+
function invalidRequestTokenPage(req) {
|
|
321
|
+
return {
|
|
322
|
+
status: 400,
|
|
323
|
+
body: errorPageHtml({ status: 400, code: 'invalid_request_token', detail: 'Whoa there! The request token for this page is invalid. It may have already been used, or expired because it is too old. Please go back to the site or application that sent you here and try again; it was probably just a mistake.' }, req.origin ?? ''),
|
|
324
|
+
headers: { ...HTML, ...NOSTORE },
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
function withParams(callback, params) {
|
|
328
|
+
const url = new URL(callback);
|
|
329
|
+
for (const [k, v] of Object.entries(params))
|
|
330
|
+
url.searchParams.set(k, v);
|
|
331
|
+
return url.toString();
|
|
332
|
+
}
|
|
333
|
+
async function authorizeScreen(req, mode) {
|
|
334
|
+
const root = req.root;
|
|
335
|
+
const query = new URLSearchParams(req.path.split('?')[1] ?? '');
|
|
336
|
+
const token = query.get('oauth_token') ?? '';
|
|
337
|
+
const at = req.occurredAt;
|
|
338
|
+
const all = readAll(root);
|
|
339
|
+
const row = all.find((r) => r.type === 'oauth1_request_token' && r.id === token);
|
|
340
|
+
if (!row || (row.state !== 'issued' && row.state !== 'pending'))
|
|
341
|
+
return invalidRequestTokenPage(req);
|
|
342
|
+
const app = oauth1AppOf(all, String(row.consumerKey));
|
|
343
|
+
if (!app)
|
|
344
|
+
return invalidRequestTokenPage(req);
|
|
345
|
+
if (req.readOnly)
|
|
346
|
+
return oauthError('temporarily_unavailable', 'this twin was started read-only; omit readOnly to accept writes', 405);
|
|
347
|
+
const account = sessionAccount(root);
|
|
348
|
+
if (!account) {
|
|
349
|
+
return { status: 400, body: errorPageHtml({ status: 400, code: 'invalid_request', detail: 'No x.com session is signed in on this twin. Seed an account and set /_twin/session.' }, req.origin ?? ''), headers: { ...HTML, ...NOSTORE } };
|
|
350
|
+
}
|
|
351
|
+
// Sign in with X: "if the user has already granted the application permission, the redirect will
|
|
352
|
+
// occur without the user having to re-approve" — unless force_login=true (api-reference). A
|
|
353
|
+
// grant at a narrower level than this request asks for is not that permission. X does this only
|
|
354
|
+
// for an App with the "Use Sign in with X" setting enabled; the twin treats every App as having it
|
|
355
|
+
// (EXTRAPOLATION — `xidentity.oauth1.sign_in_with_x_setting`, todo).
|
|
356
|
+
const grant = all.find((r) => r.type === 'oauth1_grant' && r.id === `${app.consumerKey}:${account.id}`);
|
|
357
|
+
const covered = grant && grant.revoked !== true && (grant.accessLevel === 'read-write' || row.accessLevel === 'read');
|
|
358
|
+
if (mode === 'authenticate' && covered && query.get('force_login') !== 'true' && row.callback !== 'oob') {
|
|
359
|
+
return settle(req, token, 'allow', String(account.id));
|
|
360
|
+
}
|
|
361
|
+
// Bind the screen to the account it shows, so a later session switch cannot mint for another.
|
|
362
|
+
const presented = await applyTwinWriteAtomic('xidentity', (resources) => {
|
|
363
|
+
const now = resources.find((r) => r.type === 'oauth1_request_token' && r.id === token);
|
|
364
|
+
// decided (or exchanged) since the read above: never reopen it
|
|
365
|
+
if (!now || (now.state !== 'issued' && now.state !== 'pending'))
|
|
366
|
+
return { kind: 'skip', value: false };
|
|
367
|
+
return {
|
|
368
|
+
kind: 'write',
|
|
369
|
+
value: true,
|
|
370
|
+
write: { operation: 'oauth1_request_token.present', subjectType: 'oauth1_request_token', subjectId: token, fields: { state: 'pending', accountId: account.id, rev: nextRevIn(resources, 'oauth1_request_token', token) }, occurredAt: at, actor: { kind: 'agent' } },
|
|
371
|
+
};
|
|
372
|
+
}, root);
|
|
373
|
+
if (!presented.value)
|
|
374
|
+
return invalidRequestTokenPage(req);
|
|
375
|
+
const view = xIdentityOAuth1ConsentState({ root, requestToken: token, appName: app.name, origin: req.origin ?? 'https://api.x.com' });
|
|
376
|
+
if (!view)
|
|
377
|
+
return invalidRequestTokenPage(req);
|
|
378
|
+
return { status: 200, body: consentPageHtml(view, req.origin ?? ''), headers: { ...HTML, ...NOSTORE } };
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* The decision: the screen's form posts back to `POST oauth/authorize` on the host that served it,
|
|
382
|
+
* as X's own authorize screen does (its form's action is `https://api.x.com/oauth/authorize`; the
|
|
383
|
+
* form fields are the twin's). It must be a path this pack claims on api.x.com: that host is
|
|
384
|
+
* shared with the `x` pack, which owns its `/_twin/*`, so a `/_twin/` door there never reached this
|
|
385
|
+
* pack in a World (the Postiz walk, 2026-09-28: "Authorize app" answered the x pack's JSON 400). Allow → the callback with `oauth_token` and
|
|
386
|
+
* `oauth_verifier` (obtaining-user-access-tokens), or for `oob` the PIN page; Cancel → the callback
|
|
387
|
+
* with `denied=<request token>` (the flowchart on the same page; the parameter name is the widely
|
|
388
|
+
* reported one — EXTRAPOLATION, `xidentity.oauth1.denied_callback`).
|
|
389
|
+
*/
|
|
390
|
+
async function settle(req, token, decision, accountId) {
|
|
391
|
+
const at = req.occurredAt;
|
|
392
|
+
const decided = await applyTwinWriteAtomic('xidentity', (resources) => {
|
|
393
|
+
const row = resources.find((r) => r.type === 'oauth1_request_token' && r.id === token);
|
|
394
|
+
const who = accountId ?? (row ? String(row.accountId ?? '') : '');
|
|
395
|
+
if (!row || (accountId ? row.state !== 'issued' && row.state !== 'pending' : row.state !== 'pending') || !who) {
|
|
396
|
+
return { kind: 'skip', value: { page: 'invalid' } };
|
|
397
|
+
}
|
|
398
|
+
const app = oauth1AppOf(resources, String(row.consumerKey));
|
|
399
|
+
const base = { operation: 'oauth1_request_token.settle', subjectType: 'oauth1_request_token', subjectId: token, occurredAt: at, actor: { kind: 'human' } };
|
|
400
|
+
if (decision !== 'allow' || !app) {
|
|
401
|
+
return { kind: 'write', value: { denied: String(row.callback) }, write: { ...base, fields: { state: 'denied', rev: nextRevIn(resources, 'oauth1_request_token', token) } } };
|
|
402
|
+
}
|
|
403
|
+
const held = resources.filter((r) => r.type === 'oauth1_request_token' && typeof r.verifier === 'string').length;
|
|
404
|
+
// The web flow's verifier is an opaque string (obtaining-user-access-tokens' example: 42
|
|
405
|
+
// alphanumerics; the twin mints 32). Out-of-band it is the PIN the person types: "a X
|
|
406
|
+
// generated ~7 digit PIN" (pin-based-oauth), `oauth_verifier=4868795` in the api-reference.
|
|
407
|
+
const verifier = row.callback === 'oob'
|
|
408
|
+
? String(parseInt(stableHex(`oauth1_pin:${at}:${held}:${token}`, 8), 16) % 9_000_000 + 1_000_000)
|
|
409
|
+
: alnum(`oauth1_verifier:${at}:${held}:${token}`, 32);
|
|
410
|
+
const grantId = `${app.consumerKey}:${who}`;
|
|
411
|
+
const grantExists = resources.some((r) => r.type === 'oauth1_grant' && r.id === grantId);
|
|
412
|
+
// A narrower approval does not take back a wider one the person already gave this App.
|
|
413
|
+
const prior = resources.find((r) => r.type === 'oauth1_grant' && r.id === grantId);
|
|
414
|
+
const level = prior && prior.revoked !== true && prior.accessLevel === 'read-write' ? 'read-write' : row.accessLevel;
|
|
415
|
+
const grantFields = { consumerKey: app.consumerKey, userId: who, accessLevel: level, revoked: false, rev: nextRevIn(resources, 'oauth1_grant', grantId) };
|
|
416
|
+
return {
|
|
417
|
+
kind: 'write',
|
|
418
|
+
value: { allowed: String(row.callback), verifier, appName: app.name },
|
|
419
|
+
write: {
|
|
420
|
+
...base,
|
|
421
|
+
fields: { state: 'authorized', verifier, accountId: who, rev: nextRevIn(resources, 'oauth1_request_token', token) },
|
|
422
|
+
projection: grantExists
|
|
423
|
+
? { updates: [{ type: 'oauth1_grant', id: grantId, fields: grantFields }] }
|
|
424
|
+
: { creates: [{ type: 'oauth1_grant', id: grantId, fields: grantFields }] },
|
|
425
|
+
},
|
|
426
|
+
};
|
|
427
|
+
}, req.root);
|
|
428
|
+
const v = decided.value;
|
|
429
|
+
if (v.page === 'invalid')
|
|
430
|
+
return invalidRequestTokenPage(req);
|
|
431
|
+
if (v.denied !== undefined) {
|
|
432
|
+
if (v.denied === 'oob')
|
|
433
|
+
return { status: 200, body: errorPageHtml({ status: 200, code: 'access_denied', detail: 'You did not authorize the app.' }, req.origin ?? ''), headers: { ...HTML, ...NOSTORE } };
|
|
434
|
+
return { status: 302, body: '', headers: { location: withParams(v.denied, { denied: token }), ...NOSTORE } };
|
|
435
|
+
}
|
|
436
|
+
if (v.allowed === 'oob')
|
|
437
|
+
return { status: 200, body: pinPageHtml(v.appName ?? '', v.verifier ?? '', req.origin ?? ''), headers: { ...HTML, ...NOSTORE } };
|
|
438
|
+
return { status: 302, body: '', headers: { location: withParams(v.allowed, { oauth_token: token, oauth_verifier: v.verifier }), ...NOSTORE } };
|
|
439
|
+
}
|
|
440
|
+
// ── step 3: POST oauth/access_token ─────────────────────────────────────────────────────────────
|
|
441
|
+
async function accessToken(req) {
|
|
442
|
+
const at = req.occurredAt;
|
|
443
|
+
return (await applyTwinWriteAtomic('xidentity', (resources) => {
|
|
444
|
+
const requestRow = (params) => resources.find((r) => r.type === 'oauth1_request_token' && r.id === params.get('oauth_token'));
|
|
445
|
+
const checked = verifyConsumer(req, resources, (params) => {
|
|
446
|
+
const row = requestRow(params);
|
|
447
|
+
// Unknown, already exchanged, denied or never approved: the token is not one X will trade.
|
|
448
|
+
if (!row || row.state !== 'authorized' || row.consumerKey !== params.get('oauth_consumer_key'))
|
|
449
|
+
return null;
|
|
450
|
+
return String(row.secret);
|
|
451
|
+
});
|
|
452
|
+
if ('fail' in checked)
|
|
453
|
+
return { kind: 'skip', value: checked.fail };
|
|
454
|
+
const row = requestRow(checked.params);
|
|
455
|
+
// "applications not using the oauth_verifier will fail to complete the OAuth flow"
|
|
456
|
+
// (api-reference). The refusal's body is EXTRAPOLATION (X answers this leg with a plain-text
|
|
457
|
+
// error, widely reported as below — `xidentity.oauth1.access_token_error_wording`).
|
|
458
|
+
const verifier = checked.params.get('oauth_verifier') ?? formBody(req).get('oauth_verifier') ?? new URLSearchParams(req.path.split('?')[1] ?? '').get('oauth_verifier');
|
|
459
|
+
if (!verifier || verifier !== row.verifier) {
|
|
460
|
+
return { kind: 'skip', value: { status: 401, body: 'Error processing your OAuth request: Invalid oauth_verifier parameter', headers: { 'content-type': 'text/plain; charset=utf-8', ...NOSTORE } } };
|
|
461
|
+
}
|
|
462
|
+
const userId = String(row.accountId);
|
|
463
|
+
const account = resources.find((r) => r.type === 'account' && r.id === userId);
|
|
464
|
+
if (!account)
|
|
465
|
+
return { kind: 'skip', value: invalidOrExpiredToken() };
|
|
466
|
+
// X's user access tokens "do not expire but can be revoked" (obtaining-user-access-tokens), and
|
|
467
|
+
// "once an access_token has been invalidated, new creation attempts will yield a different
|
|
468
|
+
// Access Token" (api-reference, invalidate_token) — so until then a creation attempt yields the
|
|
469
|
+
// token the App already holds for that user and level. That reading is EXTRAPOLATION from the
|
|
470
|
+
// two sentences, pinned by `xidentity.oauth1.reauthorize_token_reuse_pin` (todo).
|
|
471
|
+
const existing = resources.find((r) => r.type === 'oauth1_token' && r.consumerKey === row.consumerKey
|
|
472
|
+
&& r.userId === userId && r.accessLevel === row.accessLevel && r.revoked !== true);
|
|
473
|
+
const held = resources.filter((r) => r.type === 'oauth1_token').length;
|
|
474
|
+
// The docs' example: `oauth_token=6253282-eWudHldSbIaelX7swmsiHImEL4KinwaGloHANdrY` — the user
|
|
475
|
+
// id, a dash, 40 alphanumerics; a 40-character secret.
|
|
476
|
+
const token = existing ? String(existing.id) : `${userId}-${alnum(`oauth1_token:${at}:${held}:${row.id}`, 40)}`;
|
|
477
|
+
const secret = existing ? String(existing.secret) : alnum(`oauth1_token_secret:${at}:${held}:${token}`, 40);
|
|
478
|
+
const screenName = String(account.username ?? '');
|
|
479
|
+
const consume = { operation: 'oauth1_request_token.exchange', subjectType: 'oauth1_request_token', subjectId: String(row.id), occurredAt: at, actor: { kind: 'agent' } };
|
|
480
|
+
return {
|
|
481
|
+
kind: 'write',
|
|
482
|
+
value: formReply({ oauth_token: token, oauth_token_secret: secret, user_id: userId, screen_name: screenName }),
|
|
483
|
+
write: {
|
|
484
|
+
...consume,
|
|
485
|
+
fields: { state: 'exchanged', rev: nextRevIn(resources, 'oauth1_request_token', String(row.id)) },
|
|
486
|
+
...(existing ? {} : {
|
|
487
|
+
projection: { creates: [{ type: 'oauth1_token', id: token, fields: { consumerKey: row.consumerKey, secret, userId, screenName, accessLevel: row.accessLevel, revoked: false, rev: 1 } }] },
|
|
488
|
+
}),
|
|
489
|
+
},
|
|
490
|
+
};
|
|
491
|
+
}, req.root)).value;
|
|
492
|
+
}
|
|
493
|
+
/** A request signed with an access token this pack issued and has not revoked; null otherwise. */
|
|
494
|
+
export function verifyUserContext(req, resources) {
|
|
495
|
+
const signed = signedRequestOf(req);
|
|
496
|
+
if (signed.header.length === 0)
|
|
497
|
+
return null;
|
|
498
|
+
const params = oauth1Params(signed);
|
|
499
|
+
if (!wellFormed(params))
|
|
500
|
+
return null;
|
|
501
|
+
const token = resources.find((r) => r.type === 'oauth1_token' && r.id === params.get('oauth_token'));
|
|
502
|
+
if (!token || token.revoked === true || token.consumerKey !== params.get('oauth_consumer_key'))
|
|
503
|
+
return null;
|
|
504
|
+
const app = oauth1AppOf(resources, String(token.consumerKey));
|
|
505
|
+
if (!app)
|
|
506
|
+
return null;
|
|
507
|
+
return signatureMatches(signed, app.secret, String(token.secret)) ? { token, app } : null;
|
|
508
|
+
}
|
|
509
|
+
/** The OAuth 2.0 scopes an OAuth 1.0a token's permission level stands for on a v2 endpoint that
|
|
510
|
+
* declares both schemes (the OpenAPI's `security` lists OAuth2UserToken scopes OR UserToken). */
|
|
511
|
+
export function scopesOfAccessLevel(level) {
|
|
512
|
+
return level === 'read' ? ['tweet.read', 'users.read'] : ['tweet.read', 'users.read', 'tweet.write', 'media.write'];
|
|
513
|
+
}
|
|
514
|
+
// ── POST 1.1/oauth/invalidate_token ─────────────────────────────────────────────────────────────
|
|
515
|
+
async function invalidateToken(req) {
|
|
516
|
+
const at = req.occurredAt;
|
|
517
|
+
return (await applyTwinWriteAtomic('xidentity', (resources) => {
|
|
518
|
+
const who = verifyUserContext(req, resources);
|
|
519
|
+
if (!who)
|
|
520
|
+
return { kind: 'skip', value: invalidOrExpiredToken() };
|
|
521
|
+
const id = String(who.token.id);
|
|
522
|
+
const grantId = `${who.token.consumerKey}:${who.token.userId}`;
|
|
523
|
+
const grant = resources.some((r) => r.type === 'oauth1_grant' && r.id === grantId);
|
|
524
|
+
return {
|
|
525
|
+
kind: 'write',
|
|
526
|
+
// The api-reference example response, verbatim in shape: {"access_token":"ACCESS_TOKEN"}.
|
|
527
|
+
value: { status: 200, body: { access_token: id }, headers: { ...JSON_TYPE, ...NOSTORE } },
|
|
528
|
+
write: {
|
|
529
|
+
operation: 'oauth1_token.invalidate',
|
|
530
|
+
subjectType: 'oauth1_token',
|
|
531
|
+
subjectId: id,
|
|
532
|
+
fields: { revoked: true, rev: nextRevIn(resources, 'oauth1_token', id) },
|
|
533
|
+
...(grant ? { projection: { updates: [{ type: 'oauth1_grant', id: grantId, fields: { revoked: true, rev: nextRevIn(resources, 'oauth1_grant', grantId) } }] } } : {}),
|
|
534
|
+
occurredAt: at,
|
|
535
|
+
actor: { kind: 'agent' },
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}, req.root)).value;
|
|
539
|
+
}
|
|
540
|
+
// ── the twin door: register an App (the developer console has no API) ─────────────────────────
|
|
541
|
+
async function registerApp(req) {
|
|
542
|
+
let b;
|
|
543
|
+
try {
|
|
544
|
+
b = JSON.parse(req.body ?? '{}');
|
|
545
|
+
}
|
|
546
|
+
catch {
|
|
547
|
+
return oauthError('invalid_request', 'A JSON body is required.', 400);
|
|
548
|
+
}
|
|
549
|
+
const at = req.occurredAt;
|
|
550
|
+
const held = readType(req.root, 'oauth1_app').length;
|
|
551
|
+
const key = typeof b['consumer_key'] === 'string' && b['consumer_key'] ? String(b['consumer_key']) : alnum(`oauth1_app:${at}:${held}`, 25);
|
|
552
|
+
if (key.includes(':'))
|
|
553
|
+
return oauthError('invalid_request', 'consumer_key must not contain ":".', 400);
|
|
554
|
+
const level = b['access_level'] === 'read' ? 'read' : 'read-write';
|
|
555
|
+
const row = await writeAtomic('oauth1_app', key, 'oauth1_app.create', () => ({
|
|
556
|
+
name: typeof b['name'] === 'string' ? b['name'] : 'Twin App',
|
|
557
|
+
secret: typeof b['consumer_secret'] === 'string' && b['consumer_secret'] ? b['consumer_secret'] : alnum(`oauth1_app_secret:${at}:${held}:${key}`, 50),
|
|
558
|
+
callbackUrls: Array.isArray(b['callback_urls']) ? b['callback_urls'].map(String) : [],
|
|
559
|
+
accessLevel: level,
|
|
560
|
+
}), { root: req.root, occurredAt: at });
|
|
561
|
+
return { status: 200, body: { consumer_key: key, consumer_secret: row.secret, name: row.name, callback_urls: row.callbackUrls, access_level: row.accessLevel } };
|
|
562
|
+
}
|
|
563
|
+
// ── router slice ────────────────────────────────────────────────────────────────────────────────
|
|
564
|
+
export const OAUTH1_ENDPOINTS = [
|
|
565
|
+
'POST /oauth/request_token',
|
|
566
|
+
'GET /oauth/authorize',
|
|
567
|
+
'POST /oauth/authorize',
|
|
568
|
+
'GET /oauth/authenticate',
|
|
569
|
+
'POST /oauth/access_token',
|
|
570
|
+
'POST /1.1/oauth/invalidate_token',
|
|
571
|
+
'POST /1.1/oauth/invalidate_token.json',
|
|
572
|
+
];
|
|
573
|
+
/** Paths this slice writes on (read-only twins refuse them). */
|
|
574
|
+
export const OAUTH1_WRITE_PATHS = new Set(['/oauth/request_token', '/oauth/access_token', '/1.1/oauth/invalidate_token', '/1.1/oauth/invalidate_token.json']);
|
|
575
|
+
/** Route an OAuth 1.0a request; null when the path is not this slice's. */
|
|
576
|
+
export async function routeOAuth1(req, method, path) {
|
|
577
|
+
if (method === 'POST' && path === '/oauth/request_token')
|
|
578
|
+
return requestToken(req);
|
|
579
|
+
if (method === 'GET' && path === '/oauth/authorize')
|
|
580
|
+
return authorizeScreen(req, 'authorize');
|
|
581
|
+
if (method === 'GET' && path === '/oauth/authenticate')
|
|
582
|
+
return authorizeScreen(req, 'authenticate');
|
|
583
|
+
if (method === 'POST' && path === '/oauth/access_token')
|
|
584
|
+
return accessToken(req);
|
|
585
|
+
if (method === 'POST' && (path === '/1.1/oauth/invalidate_token' || path === '/1.1/oauth/invalidate_token.json'))
|
|
586
|
+
return invalidateToken(req);
|
|
587
|
+
if (method === 'POST' && path === '/oauth/authorize') {
|
|
588
|
+
const form = formBody(req);
|
|
589
|
+
return settle(req, form.get('auth_request') ?? '', form.get('decision') ?? '');
|
|
590
|
+
}
|
|
591
|
+
if (method === 'POST' && path === '/_twin/oauth1_apps')
|
|
592
|
+
return registerApp(req);
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
// • it serves the authorize page's own assets under the twin-namespaced `/_twin/assets/*`.
|
|
11
11
|
//
|
|
12
12
|
// It also tells the handler its OWN origin, so the consent form's action points BACK AT THE TWIN.
|
|
13
|
-
import { serveHttp } from '@volter/world-core';
|
|
13
|
+
import { isReadOnlyRequest, serveHttp, withRequestScopes } from '@volter/world-core';
|
|
14
14
|
import { CONSENT_CLIENT_CSS, CONSENT_CLIENT_JS, CONSENT_SCRIPT_PATH, CONSENT_STYLE_PATH } from "./xidentity-consent-ui.js";
|
|
15
|
+
import { oauthError } from "./xidentity-problems.js";
|
|
15
16
|
import { worldNow, statefulTwinManifest, twinPublicBase } from '@volter/world-core';
|
|
16
17
|
import { handleXIdentityTwinRequest } from "./xidentity-twin.js";
|
|
17
18
|
/**
|
|
@@ -28,8 +29,12 @@ import { handleXIdentityTwinRequest } from "./xidentity-twin.js";
|
|
|
28
29
|
* plane by `scripts/consent-clients.ts`) — no build and no disk read on the fetch path.
|
|
29
30
|
*/
|
|
30
31
|
export function createXIdentityTwinFetch(options = {}) {
|
|
31
|
-
|
|
32
|
-
|
|
32
|
+
// a read-only request (x-volter-read-only, a World's read token) is a request to a read-only twin: the
|
|
33
|
+
// authorize screen never signs anyone in or mints a code, no token is minted or revoked, GET
|
|
34
|
+
// /2/users/me is served without spending the app's rate window (D3), and the kernel refuses any write
|
|
35
|
+
// that slips past as X's own flat OAuth error
|
|
36
|
+
return withRequestScopes(async function xIdentityTwinFetch(request) {
|
|
37
|
+
const readOnly = (options.readOnly ?? false) || isReadOnlyRequest(request);
|
|
33
38
|
const url = new URL(request.url);
|
|
34
39
|
// GET /twin — the discovery manifest (education inside the twin).
|
|
35
40
|
if (request.method === 'GET' && url.pathname.replace(/\/+$/, '') === '/twin') {
|
|
@@ -67,7 +72,12 @@ export function createXIdentityTwinFetch(options = {}) {
|
|
|
67
72
|
}
|
|
68
73
|
out['content-type'] = out['content-type'] ?? 'application/json; charset=utf-8';
|
|
69
74
|
return new Response(JSON.stringify(res.body), { status: res.status, headers: out });
|
|
70
|
-
}
|
|
75
|
+
}, {
|
|
76
|
+
refuse: () => {
|
|
77
|
+
const refused = oauthError('temporarily_unavailable', 'this request is read-only', 405);
|
|
78
|
+
return Response.json(refused.body, { status: refused.status, headers: refused.headers });
|
|
79
|
+
},
|
|
80
|
+
});
|
|
71
81
|
}
|
|
72
82
|
export async function createXIdentityTwinServer(options) {
|
|
73
83
|
const server = await serveHttp({
|