@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.
@@ -41,6 +41,13 @@ export const RESOURCE_TYPES = [
41
41
  'refresh_token',
42
42
  'grant',
43
43
  'rate_window',
44
+ // OAuth 1.0a (xidentity-oauth1.ts): an App registered through the twin door, a request token
45
+ // in flight between the three legs, an issued access token, and the (app, user) grant that lets
46
+ // GET oauth/authenticate skip a screen already approved.
47
+ 'oauth1_app',
48
+ 'oauth1_request_token',
49
+ 'oauth1_token',
50
+ 'oauth1_grant',
44
51
  ] as const;
45
52
  export type ResourceType = (typeof RESOURCE_TYPES)[number];
46
53
 
@@ -174,7 +181,7 @@ export const mintRefreshToken = (root: string | undefined, at: number, subject:
174
181
  // ── the authorize screen's handle: DETERMINISTIC, because it is SERVED (R9) ─────────────────────
175
182
  /** FNV-1a over the seed, widened by re-hashing with a round counter until `n` hex chars exist
176
183
  * (the resend exemplar's `stableHex`). Pure: no clock, no entropy, no host state. */
177
- function stableHex(seed: string, n: number): string {
184
+ export function stableHex(seed: string, n: number): string {
178
185
  let out = '';
179
186
  for (let round = 0; out.length < n; round += 1) {
180
187
  let h = 0x811c9dc5;
@@ -59,6 +59,7 @@ import { applyTwinWriteAtomic, type TwinResource } from '@volter/world-core';
59
59
  import { consentPageHtml, errorPageHtml, xIdentityConsentState } from './xidentity-consent-ui.ts';
60
60
  import { formatScopeParam, parseScopeParam } from './xidentity-scopes.ts';
61
61
  import { normalizeChallengeMethod, pkceVerifies } from './xidentity-pkce.ts';
62
+ import { OAUTH1_ENDPOINTS, OAUTH1_WRITE_PATHS, routeOAuth1, scopesOfAccessLevel, verifyUserContext } from './xidentity-oauth1.ts';
62
63
  import {
63
64
  authRequestIdFor,
64
65
  ensureSeed,
@@ -688,8 +689,11 @@ function enumMessage(param: string, bad: string[], allowed: readonly string[]):
688
689
  async function usersMe(req: XIdentityRequest, query: URLSearchParams): Promise<XResponse> {
689
690
  const root = req.root;
690
691
  const header = req.headers?.authorization ?? '';
691
- const bearer = /^bearer\s+(.+)$/i.exec(header)?.[1]?.trim();
692
- if (!bearer) return unauthorizedProblem();
692
+ // Two schemes, as the OpenAPI's security for this operation lists them: an OAuth 2.0 user bearer
693
+ // (OAuth2UserToken) or OAuth 1.0a User Context (UserToken), verified in xidentity-oauth1.ts.
694
+ const oauth1 = /^oauth\s/i.test(header);
695
+ const bearer = oauth1 ? undefined : /^bearer\s+(.+)$/i.exec(header)?.[1]?.trim();
696
+ if (!bearer && !oauth1) return unauthorizedProblem();
693
697
 
694
698
  // Parameter validation happens BEFORE rate accounting: a 400 is not a spent request.
695
699
  // EXTRAPOLATION on the ordering (`xidentity.rate.what_counts`, todo).
@@ -708,17 +712,29 @@ async function usersMe(req: XIdentityRequest, query: URLSearchParams): Promise<X
708
712
  // `xidentity.users_me.expansion_hydration` (todo) owns the honest gap.
709
713
 
710
714
  const evaluate = (resources: readonly TwinResource[], spend: boolean) => {
711
- const tokenRow = resources.find((row) => row.type === 'access_token' && row.id === bearer) as Row | undefined;
712
- if (!tokenRow || tokenRow.revoked === true
713
- || (typeof tokenRow.expiresAt === 'number' && nowSeconds(req.occurredAt) >= tokenRow.expiresAt)) {
714
- return { response: unauthorizedProblem() };
715
+ let sub: string;
716
+ let held: string[];
717
+ if (oauth1) {
718
+ // A signed request with an access token this pack issued and has not revoked; anything else
719
+ // (unknown consumer, bad signature, revoked token) is the same about:blank 401 a bad bearer gets.
720
+ const who = verifyUserContext(req, resources);
721
+ if (!who) return { response: unauthorizedProblem() };
722
+ sub = String(who.token.userId);
723
+ held = scopesOfAccessLevel(who.token.accessLevel);
724
+ } else {
725
+ const tokenRow = resources.find((row) => row.type === 'access_token' && row.id === bearer) as Row | undefined;
726
+ if (!tokenRow || tokenRow.revoked === true
727
+ || (typeof tokenRow.expiresAt === 'number' && nowSeconds(req.occurredAt) >= tokenRow.expiresAt)) {
728
+ return { response: unauthorizedProblem() };
729
+ }
730
+ sub = String(tokenRow.sub);
731
+ held = parseScopeParam(String(tokenRow.scope));
715
732
  }
716
- const held = parseScopeParam(String(tokenRow.scope));
717
733
  const missing = USERS_ME_REQUIRED_SCOPES.filter((scope) => !held.includes(scope));
718
734
  if (missing.length > 0) {
719
735
  return { response: forbiddenProblem(`The access token does not carry the scope(s) required by this endpoint: ${missing.join(', ')}.`) };
720
736
  }
721
- const account = resources.find((row) => row.type === 'account' && row.id === String(tokenRow.sub)) as Row | undefined;
737
+ const account = resources.find((row) => row.type === 'account' && row.id === sub) as Row | undefined;
722
738
  if (!account) return { response: unauthorizedProblem() };
723
739
  const now = nowSeconds(req.occurredAt);
724
740
  const windowId = `users_me:${account.id}`;
@@ -779,6 +795,7 @@ export function xIdentityTwinSnapshot(): { implementedEndpoints: string[]; resou
779
795
  'POST /2/oauth2/token',
780
796
  'POST /2/oauth2/revoke',
781
797
  'GET /2/users/me',
798
+ ...OAUTH1_ENDPOINTS,
782
799
  ],
783
800
  resourceTypes: STORE_RESOURCE_TYPES,
784
801
  grantTypes: ['authorization_code', 'refresh_token'],
@@ -921,7 +938,8 @@ async function routeXIdentityTwinRequest(req: XIdentityRequest): Promise<XRespon
921
938
  // D3: a read-only twin cannot mint credentials or settle consent. GET /2/users/me stays served
922
939
  // (a read), with rate accounting suspended. The refusal is an OAuth error body, so a client sees
923
940
  // a protocol error rather than an HTML surprise.
924
- const writes = path.startsWith('/_twin/') || path === '/2/oauth2/token' || path === '/2/oauth2/revoke';
941
+ const writes = path.startsWith('/_twin/') || path === '/2/oauth2/token' || path === '/2/oauth2/revoke' || OAUTH1_WRITE_PATHS.has(path)
942
+ || (method === 'POST' && path === '/oauth/authorize');
925
943
  if (req.readOnly && writes) {
926
944
  return oauthError('temporarily_unavailable', 'this twin was started read-only; omit readOnly to accept writes', 405);
927
945
  }
@@ -930,6 +948,9 @@ async function routeXIdentityTwinRequest(req: XIdentityRequest): Promise<XRespon
930
948
  // a world with clients, personas and a signed-in session — exactly what a browser at x.com has.
931
949
  if (!req.readOnly) await ensureSeed({ root: req.root, ...(req.occurredAt ? { occurredAt: req.occurredAt } : {}), ...((req.callbackOrigin ?? req.origin) ? { origin: req.callbackOrigin ?? req.origin } : {}) });
932
950
 
951
+ const oauth1 = await routeOAuth1(req, method, path);
952
+ if (oauth1) return oauth1;
953
+
933
954
  const control = await twinControl(req, path, form);
934
955
  if (control) return control;
935
956