@oxyhq/core 2.2.1 → 2.2.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.
Files changed (37) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/index.js +17 -1
  3. package/dist/cjs/mixins/OxyServices.auth.js +45 -0
  4. package/dist/cjs/mixins/OxyServices.user.js +15 -5
  5. package/dist/cjs/utils/authWebUrl.js +14 -3
  6. package/dist/cjs/utils/fapiAutoDetect.js +47 -6
  7. package/dist/cjs/utils/ssoBounce.js +192 -0
  8. package/dist/cjs/utils/ssoReturn.js +111 -0
  9. package/dist/esm/.tsbuildinfo +1 -1
  10. package/dist/esm/index.js +5 -3
  11. package/dist/esm/mixins/OxyServices.auth.js +45 -0
  12. package/dist/esm/mixins/OxyServices.user.js +15 -5
  13. package/dist/esm/utils/authWebUrl.js +13 -2
  14. package/dist/esm/utils/fapiAutoDetect.js +45 -6
  15. package/dist/esm/utils/ssoBounce.js +181 -0
  16. package/dist/esm/utils/ssoReturn.js +110 -0
  17. package/dist/types/.tsbuildinfo +1 -1
  18. package/dist/types/index.d.ts +5 -4
  19. package/dist/types/mixins/OxyServices.auth.d.ts +35 -0
  20. package/dist/types/mixins/OxyServices.user.d.ts +7 -0
  21. package/dist/types/utils/authWebUrl.d.ts +12 -1
  22. package/dist/types/utils/fapiAutoDetect.d.ts +36 -0
  23. package/dist/types/utils/ssoBounce.d.ts +124 -0
  24. package/dist/types/utils/ssoReturn.d.ts +65 -0
  25. package/package.json +1 -1
  26. package/src/index.ts +18 -4
  27. package/src/mixins/OxyServices.auth.ts +54 -0
  28. package/src/mixins/OxyServices.user.ts +14 -5
  29. package/src/mixins/__tests__/serviceAuth.test.ts +92 -0
  30. package/src/utils/__tests__/authWebUrl.test.ts +11 -1
  31. package/src/utils/__tests__/consumeSsoReturn.test.ts +401 -0
  32. package/src/utils/__tests__/fapiAutoDetect.test.ts +62 -1
  33. package/src/utils/__tests__/ssoBounce.test.ts +148 -0
  34. package/src/utils/authWebUrl.ts +14 -2
  35. package/src/utils/fapiAutoDetect.ts +41 -5
  36. package/src/utils/ssoBounce.ts +198 -0
  37. package/src/utils/ssoReturn.ts +168 -0
@@ -72,11 +72,12 @@ export type { LogContext } from './utils/loggerUtils';
72
72
  export { updateAvatarVisibility } from './utils/avatarUtils';
73
73
  export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccountFallbackHandle, formatPublicKeyHandle, mergeAccountsFromRefreshAll, getAccountColor, } from './utils/accountUtils';
74
74
  export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
75
- export { autoDetectAuthWebUrl } from './utils/fapiAutoDetect';
76
- export { CENTRAL_AUTH_URL, resolveCentralAuthUrl } from './utils/authWebUrl';
77
- export { parseSsoReturnFragment } from './utils/ssoReturn';
78
- export type { SsoReturnKind, SsoReturnResult } from './utils/ssoReturn';
75
+ export { autoDetectAuthWebUrl, registrableApex, MULTIPART_TLDS } from './utils/fapiAutoDetect';
76
+ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl';
77
+ export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
78
+ export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
79
79
  export { generateSsoState } from './mixins/OxyServices.sso';
80
+ export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoNavigate, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, } from './utils/ssoBounce';
80
81
  export { runColdBoot } from './utils/coldBoot';
81
82
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
82
83
  export { packageInfo } from './constants/version';
@@ -51,6 +51,12 @@ interface ServiceTokenCacheEntry {
51
51
  secretBuf: Buffer;
52
52
  /** In-flight refresh promise (deduplicates concurrent callers) */
53
53
  pending: Promise<string> | null;
54
+ /**
55
+ * The raw apiKey that produced this entry. Retained so a targeted, fully
56
+ * synchronous `invalidateServiceToken(apiKey)` can locate the entry without
57
+ * re-deriving the async `SHA-256(apiKey)` Map key. Never logged or returned.
58
+ */
59
+ apiKey: string;
54
60
  }
55
61
  /**
56
62
  * Sentinel error raised when getServiceToken() is called with a known apiKey
@@ -132,6 +138,35 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
132
138
  * @internal
133
139
  */
134
140
  _doFetchServiceToken(key: string, secret: string, cacheKey: string, secretBuf: Buffer): Promise<string>;
141
+ /**
142
+ * Invalidate cached service token(s), forcing the next `getServiceToken()`
143
+ * call to mint a fresh token from `/auth/service-token`.
144
+ *
145
+ * `getServiceToken()` only refreshes on expiry (with a 60s clock-drift
146
+ * buffer), so a credential that is revoked or rotated mid-run — surfaced as
147
+ * a 401 on a downstream service request — cannot otherwise be recovered
148
+ * within the same process: the still-unexpired cached token keeps being
149
+ * returned. Call this after such a 401 to clear the stale entry; the very
150
+ * next `getServiceToken()` for that credential re-mints.
151
+ *
152
+ * Fully synchronous and deterministic: the call completes before it
153
+ * returns, so a `getServiceToken()` issued immediately afterwards is
154
+ * guaranteed to see the cleared cache and mint anew.
155
+ *
156
+ * @param apiKey - When provided, clears only the cache entry for that
157
+ * specific apiKey. When omitted, clears the entry for the credential set
158
+ * via `configureServiceAuth()`; if neither is available (no key to
159
+ * target), clears the entire cache. Passing no argument is the common
160
+ * case for hosts that configured a single service credential at startup.
161
+ *
162
+ * The cache Map is keyed by an asynchronously-computed `SHA-256(apiKey)`
163
+ * that cannot be reproduced synchronously, so a targeted clear scans the
164
+ * entries and removes the one whose stored raw `apiKey` matches — keeping
165
+ * this method synchronous. The fully-untargeted call (no argument and no
166
+ * configured key) clears every entry, which is safe because each credential
167
+ * pair is independently re-minted on its next request.
168
+ */
169
+ invalidateServiceToken(apiKey?: string): void;
135
170
  /**
136
171
  * Make an authenticated request on behalf of a user using a service token.
137
172
  * Automatically obtains/refreshes the service token.
@@ -49,6 +49,13 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
49
49
  }): Promise<User>;
50
50
  /**
51
51
  * Get profile recommendations, optionally filtering out specific user types.
52
+ *
53
+ * Public discovery read — works WITHOUT authentication. The SDK attaches the
54
+ * access token automatically when one is available (personalized via
55
+ * mutual-connection overlap), and falls back to popular public profiles when
56
+ * the caller is logged out. This deliberately does NOT use `withAuthRetry`,
57
+ * which would throw an authentication timeout for logged-out callers before
58
+ * the request is ever sent.
52
59
  */
53
60
  getProfileRecommendations(options?: {
54
61
  excludeTypes?: Array<"federated" | "agent" | "automated">;
@@ -16,9 +16,20 @@
16
16
  * derivation). The central-SSO path deliberately does NOT auto-detect per-apex
17
17
  * IdPs — it is central only. An explicitly-configured `authWebUrl` still wins.
18
18
  */
19
+ /**
20
+ * The registrable apex (eTLD+1) of the Oxy ecosystem's central Identity
21
+ * Provider. The central IdP is reachable at `auth.${CENTRAL_IDP_APEX}` and the
22
+ * ID-token assertion issuer is always `https://auth.${CENTRAL_IDP_APEX}`
23
+ * regardless of which per-apex `auth.<rp>` host served a given request.
24
+ *
25
+ * Kept as a standalone constant so the IdP worker and the SDK derive the same
26
+ * literal from one source of truth (the worker imports it to brand assertions).
27
+ */
28
+ export declare const CENTRAL_IDP_APEX = "oxy.so";
19
29
  /**
20
30
  * The canonical central Identity Provider origin for the Oxy ecosystem.
21
- * No trailing slash.
31
+ * No trailing slash. Derived from {@link CENTRAL_IDP_APEX} so the apex and the
32
+ * full origin never drift apart.
22
33
  */
23
34
  export declare const CENTRAL_AUTH_URL = "https://auth.oxy.so";
24
35
  /**
@@ -34,4 +34,40 @@
34
34
  * (`packages/auth/server/index.ts`), so an honest CNAME pair is all that
35
35
  * is required for end-to-end FedCM correctness — no per-RP config.
36
36
  */
37
+ /**
38
+ * Known multi-part public suffixes where the registrable domain is the LAST
39
+ * THREE labels, not two. Deriving an apex from `labels.slice(-2)` against any
40
+ * of these would yield an attacker-registrable suffix (e.g. `auth.co.uk`),
41
+ * so we bail out instead.
42
+ *
43
+ * This is intentionally a small, explicit allow-list rather than the full
44
+ * Public Suffix List — it covers the suffixes the Oxy ecosystem's RPs use.
45
+ * Any multi-part-TLD RP MUST extend this set (or wire in a proper PSL check)
46
+ * before relying on this helper, otherwise auto-detection silently bails to
47
+ * `undefined` and the consumer must pass `authWebUrl` explicitly.
48
+ */
49
+ export declare const MULTIPART_TLDS: ReadonlySet<string>;
50
+ /**
51
+ * Compute the bare registrable apex (eTLD+1) of a hostname, guarding against
52
+ * multi-part public suffixes.
53
+ *
54
+ * This is the pure host-handling kernel shared by {@link autoDetectAuthWebUrl}
55
+ * and the IdP worker — it performs NO protocol handling, NO `auth.` prefixing,
56
+ * and builds NO URL. It only answers "what is the registrable domain of this
57
+ * host, or is that undefinable?".
58
+ *
59
+ * Returns `null` (apex undefinable) for:
60
+ * - empty input;
61
+ * - IPv4 literals (`192.168.1.10`);
62
+ * - IPv6 literals or any host carrying a port (`[::1]`, anything with `:`);
63
+ * - single-label hosts (`intranet`, `localhost`);
64
+ * - hosts whose trailing two labels form a known multi-part public suffix
65
+ * (e.g. `foo.co.uk`), where `labels.slice(-2)` would yield an
66
+ * attacker-registrable suffix (`co.uk`) rather than a real registrable
67
+ * domain. Such hosts MUST configure `authWebUrl` explicitly.
68
+ *
69
+ * @param hostname - A bare hostname (no scheme), e.g. `www.mention.earth`.
70
+ * @returns The eTLD+1 (`mention.earth`), or `null` when undefinable.
71
+ */
72
+ export declare function registrableApex(hostname: string): string | null;
37
73
  export declare function autoDetectAuthWebUrl(location?: Pick<Location, 'hostname' | 'protocol'> | undefined): string | undefined;
@@ -0,0 +1,124 @@
1
+ /**
2
+ * Central cross-domain SSO bounce — per-origin sessionStorage keys, the bounce
3
+ * URL builder, and the small pure predicates shared by every consumer's
4
+ * cold-boot `sso-return` / `sso-bounce` steps and bfcache `pageshow`
5
+ * re-evaluation.
6
+ *
7
+ * This is the single source of truth for the SSO bounce wire/storage contract.
8
+ * `@oxyhq/auth` (`WebOxyProvider`) and `@oxyhq/services` (`OxyContext`) both
9
+ * consume these helpers so the two providers behave identically.
10
+ *
11
+ * TRUE central SSO (Google/Meta/Clerk style) works like this for a Relying
12
+ * Party (mention.earth, homiio.com, alia.onl, …) with no local session:
13
+ *
14
+ * 1. `sso-bounce` (terminal, once): a TOP-LEVEL navigation to
15
+ * `auth.oxy.so/sso?prompt=none&client_id=<origin>&return_to=<origin>{@link SSO_CALLBACK_PATH}&state=<s>`.
16
+ * Before navigating it records, in this origin's `sessionStorage`, the
17
+ * CSRF `state` ({@link ssoStateKey}), a guard timestamp ({@link ssoGuardKey},
18
+ * the loop breaker), and the real destination URL ({@link ssoDestKey}) to
19
+ * restore after the callback.
20
+ * 2. The central IdP worker reads its first-party `fedcm_session`, mints a
21
+ * session, stores it under an opaque single-use `code`, and 303-redirects
22
+ * back to `<origin>{@link SSO_CALLBACK_PATH}#oxy_sso=ok&code=<code>&state=<s>`
23
+ * (or `#oxy_sso=none` / `#oxy_sso=error`).
24
+ * 3. `sso-return` parses the fragment (`parseSsoReturnFragment`), validates
25
+ * `state`, exchanges the `code` via `oxyServices.exchangeSsoCode`, commits
26
+ * the session, then restores the original destination.
27
+ *
28
+ * Loop proof (logged-out): first load all steps skip → `sso-bounce` sets
29
+ * guard/state/dest and navigates; the IdP (no central session) returns
30
+ * `#oxy_sso=none`; the callback load's `sso-return` sees `none`, sets the
31
+ * NO_SESSION flag ({@link ssoNoSessionKey}), and `sso-bounce` is then disabled.
32
+ * Exactly ONE bounce, no loop. An interrupted bounce (user hit back
33
+ * mid-redirect) self-heals once the {@link SSO_GUARD_TTL_MS} guard TTL lapses.
34
+ *
35
+ * All state lives in `sessionStorage` (per tab, cleared on tab close) and is
36
+ * keyed per-origin so two RPs hosted in the same browser never collide. The
37
+ * key strings and the 30s TTL are a wire/storage contract — they MUST match
38
+ * the values the IdP and every consumer expect and must not change lightly.
39
+ */
40
+ /**
41
+ * The RP callback path the central IdP redirects back to after a bounce. The
42
+ * SSO result is delivered in the fragment of this URL; the `sso-return` step
43
+ * consumes it and then restores the user's real destination (stored under
44
+ * {@link ssoDestKey}), so the user never lingers on this internal path.
45
+ */
46
+ export declare const SSO_CALLBACK_PATH = "/__oxy/sso-callback";
47
+ /**
48
+ * Self-healing TTL (ms) for the bounce guard. An in-flight bounce sets a
49
+ * timestamp guard; if the bounce is interrupted before the callback lands
50
+ * (e.g. the user navigates back mid-redirect), the guard would otherwise pin
51
+ * the RP signed-out forever. After this window the guard is treated as stale
52
+ * and a fresh single bounce is permitted. 30s comfortably exceeds a real
53
+ * redirect round-trip while keeping a crash short-lived.
54
+ */
55
+ export declare const SSO_GUARD_TTL_MS = 30000;
56
+ /** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
57
+ export declare function ssoStateKey(origin: string): string;
58
+ /** Per-origin bounce guard key (a timestamp; loop breaker + self-heal TTL). */
59
+ export declare function ssoGuardKey(origin: string): string;
60
+ /** Per-origin destination key (the real URL to restore after the callback). */
61
+ export declare function ssoDestKey(origin: string): string;
62
+ /**
63
+ * Per-origin "the central IdP has no session for me" key. Set after a
64
+ * `none`/`error` return (or a failed/forged exchange) so `sso-bounce` does not
65
+ * fire again this tab — the definitive loop breaker.
66
+ */
67
+ export declare function ssoNoSessionKey(origin: string): string;
68
+ /**
69
+ * Perform the terminal top-level SSO bounce navigation.
70
+ *
71
+ * A thin wrapper over `window.location.assign(url)` so the single navigation
72
+ * seam lives in one place (and stays mockable in tests, where jsdom's
73
+ * `Location.assign` is a non-configurable native method). In production this is
74
+ * exactly `window.location.assign` — the document is torn down and replaced by
75
+ * the central IdP page. Off-browser (SSR / native) it is a no-op: native never
76
+ * bounces.
77
+ */
78
+ export declare function ssoNavigate(url: string): void;
79
+ /**
80
+ * Build the central IdP `/sso` bounce URL for an RP.
81
+ *
82
+ * Pure (no DOM access) so it is unit-testable and shared by every consumer's
83
+ * terminal `sso-bounce` step. The IdP reads `client_id` (the RP origin) and
84
+ * `return_to` to mint an origin-bound opaque code and 303-redirect back.
85
+ *
86
+ * The IdP base is resolved via {@link resolveCentralAuthUrl} so an explicit
87
+ * `authWebUrl` override (e.g. a staging IdP) drives the SSO bounce exactly the
88
+ * way it drives FedCM. When omitted, the central default {@link CENTRAL_AUTH_URL}
89
+ * is used.
90
+ *
91
+ * @param origin - The RP origin (`window.location.origin`).
92
+ * @param state - The CSRF state minted for this bounce.
93
+ * @param authWebUrl - Optional explicit IdP base URL override. Falls back to
94
+ * the central default when `undefined`/empty.
95
+ * @returns The absolute `<idp-origin>/sso?...` URL string.
96
+ */
97
+ export declare function buildSsoBounceUrl(origin: string, state: string, authWebUrl?: string): string;
98
+ /**
99
+ * Whether `origin` IS the central IdP origin. The RP must NEVER bounce while
100
+ * sitting on `auth.oxy.so` itself — doing so would loop the IdP against itself.
101
+ *
102
+ * Both sides are normalised via `new URL(...).origin` so a trailing-slash or
103
+ * path difference never defeats the guard. Returns `false` on any parse
104
+ * failure (an unparseable candidate is, by definition, not the central IdP).
105
+ */
106
+ export declare function isCentralIdPOrigin(origin: string): boolean;
107
+ /**
108
+ * Read the bounce guard and decide whether it is still ACTIVE.
109
+ *
110
+ * Active means: a guard value is present AND it parses to a finite timestamp
111
+ * AND less than {@link SSO_GUARD_TTL_MS} has elapsed since it was set. An active
112
+ * guard disables `sso-bounce` (a bounce is already in flight this tab). A
113
+ * missing, malformed, or expired guard is NOT active, so a fresh bounce may
114
+ * proceed (this is the 30s self-heal for an interrupted bounce).
115
+ *
116
+ * Defensive: a `getItem` that throws (e.g. a locked/disabled storage) is
117
+ * treated as "not active" so the guard never wedges the flow.
118
+ *
119
+ * @param storage - The session storage to read (injected for testability).
120
+ * @param origin - The page origin whose guard to evaluate.
121
+ * @param now - Current epoch ms (injected for deterministic tests). Defaults to
122
+ * `Date.now()`.
123
+ */
124
+ export declare function guardActive(storage: Pick<Storage, 'getItem'>, origin: string, now?: number): boolean;
@@ -20,6 +20,7 @@
20
20
  * an oxy_sso fragment at all (i.e. `oxy_sso` is absent or an unrecognised
21
21
  * value), so the caller can ignore unrelated fragments without special-casing.
22
22
  */
23
+ import type { SessionLoginResponse } from '../models/session';
23
24
  /**
24
25
  * The recognised outcomes of an SSO bounce.
25
26
  */
@@ -44,3 +45,67 @@ export interface SsoReturnResult {
44
45
  * otherwise `null`. Never throws.
45
46
  */
46
47
  export declare function parseSsoReturnFragment(hash: string | undefined | null): SsoReturnResult | null;
48
+ /**
49
+ * Injectable dependencies for {@link consumeSsoReturn}.
50
+ *
51
+ * Every web seam (storage, location, history, web-detection) is injectable so
52
+ * the function is fully unit-testable with fakes and so SSR / native callers
53
+ * can supply their own (or rely on the defaults, which resolve to `window.*`
54
+ * only when a browser is present). Defaults are evaluated lazily inside
55
+ * `consumeSsoReturn` so importing this module never touches `window`.
56
+ */
57
+ export interface ConsumeSsoReturnDeps {
58
+ /** Per-tab SSO state store. Default: `window.sessionStorage`. */
59
+ storage?: Pick<Storage, 'getItem' | 'setItem' | 'removeItem'>;
60
+ /** The current location. Default: `window.location`. */
61
+ location?: Pick<Location, 'hash' | 'origin' | 'pathname' | 'search'>;
62
+ /** History API for fragment stripping / dest restore. Default: `window.history`. */
63
+ history?: Pick<History, 'replaceState'>;
64
+ /**
65
+ * Whether the current environment is a web browser with usable
66
+ * `sessionStorage`. Default: `typeof window !== 'undefined' && typeof
67
+ * window.sessionStorage !== 'undefined'`.
68
+ */
69
+ isWeb?: () => boolean;
70
+ /**
71
+ * Optional debug hook invoked with the thrown error when the code exchange
72
+ * fails. NEVER rethrown — `consumeSsoReturn` is total. Default: no-op.
73
+ */
74
+ onExchangeError?: (error: unknown) => void;
75
+ }
76
+ /**
77
+ * Consume an SSO return: the commit-free, security-critical kernel of the
78
+ * cross-domain SSO `sso-return` cold-boot step.
79
+ *
80
+ * This performs the CSRF/fragment/exchange/dest-restore/loop-breaker sequence
81
+ * and RETURNS the exchanged session (or `null`). It deliberately does NOT
82
+ * commit any UI/auth state — each provider commits its own way AROUND this
83
+ * (e.g. `@oxyhq/services` `OxyContext` calls its `handleWebSSOSession`,
84
+ * `@oxyhq/auth` `WebOxyProvider` updates its React state). Hoisting the kernel
85
+ * here keeps the two providers byte-for-byte identical on the parts that matter
86
+ * for security (state validation, fragment stripping order, loop prevention).
87
+ *
88
+ * Security/loop invariants (preserved exactly from both former copies):
89
+ * - The fragment is stripped via `history.replaceState` FIRST — before the
90
+ * exchange — so the opaque code never lingers in the URL, browser history,
91
+ * or a `Referer` header even if a later step throws.
92
+ * - `state` must match (CSRF). A mismatch or a missing code sets the
93
+ * NO_SESSION flag so `sso-bounce` is disabled (no rebounce loop).
94
+ * - `none`/`error` outcomes set the NO_SESSION flag (the load2 half of the
95
+ * loop proof).
96
+ * - A throwing exchange is caught, reported via `onExchangeError`, and
97
+ * treated exactly like "no session" (never loops, never rethrows).
98
+ * - After a successful exchange landing on {@link SSO_CALLBACK_PATH}, the real
99
+ * destination is restored from the DEST key — same-origin only (an
100
+ * attacker-planted cross-origin or relative-evil dest is rejected). The
101
+ * DEST key is removed unconditionally.
102
+ *
103
+ * Total: this function NEVER throws. Off-web it is a no-op returning `null`.
104
+ *
105
+ * @param oxy - The exchange surface (`oxyServices.exchangeSsoCode`).
106
+ * @param deps - Injectable web seams; see {@link ConsumeSsoReturnDeps}.
107
+ * @returns The exchanged session on success, otherwise `null`.
108
+ */
109
+ export declare function consumeSsoReturn(oxy: {
110
+ exchangeSsoCode: (code: string) => Promise<SessionLoginResponse>;
111
+ }, deps?: ConsumeSsoReturnDeps): Promise<SessionLoginResponse | null>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "2.2.1",
3
+ "version": "2.2.2",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
package/src/index.ts CHANGED
@@ -368,14 +368,28 @@ export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
368
368
  // ---------------------------------------------------------------------------
369
369
  // Cross-domain SSO infrastructure
370
370
  // ---------------------------------------------------------------------------
371
- export { autoDetectAuthWebUrl } from './utils/fapiAutoDetect';
371
+ export { autoDetectAuthWebUrl, registrableApex, MULTIPART_TLDS } from './utils/fapiAutoDetect';
372
372
 
373
373
  // Central cross-domain SSO (opaque single-use code bounce via auth.oxy.so)
374
- export { CENTRAL_AUTH_URL, resolveCentralAuthUrl } from './utils/authWebUrl';
375
- export { parseSsoReturnFragment } from './utils/ssoReturn';
376
- export type { SsoReturnKind, SsoReturnResult } from './utils/ssoReturn';
374
+ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './utils/authWebUrl';
375
+ export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
376
+ export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
377
377
  export { generateSsoState } from './mixins/OxyServices.sso';
378
378
 
379
+ // SSO bounce — per-origin sessionStorage keys, bounce URL builder, predicates
380
+ export {
381
+ SSO_CALLBACK_PATH,
382
+ SSO_GUARD_TTL_MS,
383
+ ssoStateKey,
384
+ ssoGuardKey,
385
+ ssoDestKey,
386
+ ssoNoSessionKey,
387
+ ssoNavigate,
388
+ buildSsoBounceUrl,
389
+ isCentralIdPOrigin,
390
+ guardActive,
391
+ } from './utils/ssoBounce';
392
+
379
393
  export { runColdBoot } from './utils/coldBoot';
380
394
  export type {
381
395
  ColdBootStep,
@@ -65,6 +65,12 @@ interface ServiceTokenCacheEntry {
65
65
  secretBuf: Buffer;
66
66
  /** In-flight refresh promise (deduplicates concurrent callers) */
67
67
  pending: Promise<string> | null;
68
+ /**
69
+ * The raw apiKey that produced this entry. Retained so a targeted, fully
70
+ * synchronous `invalidateServiceToken(apiKey)` can locate the entry without
71
+ * re-deriving the async `SHA-256(apiKey)` Map key. Never logged or returned.
72
+ */
73
+ apiKey: string;
68
74
  }
69
75
 
70
76
  /**
@@ -210,6 +216,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
210
216
  expiresAt: 0,
211
217
  secretBuf: providedSecretBuf,
212
218
  pending: null,
219
+ apiKey: key,
213
220
  };
214
221
  this._serviceTokenCache.set(cacheKey, entry);
215
222
  }
@@ -260,12 +267,59 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
260
267
  expiresAt,
261
268
  secretBuf,
262
269
  pending: null,
270
+ apiKey: key,
263
271
  });
264
272
  }
265
273
 
266
274
  return response.token;
267
275
  }
268
276
 
277
+ /**
278
+ * Invalidate cached service token(s), forcing the next `getServiceToken()`
279
+ * call to mint a fresh token from `/auth/service-token`.
280
+ *
281
+ * `getServiceToken()` only refreshes on expiry (with a 60s clock-drift
282
+ * buffer), so a credential that is revoked or rotated mid-run — surfaced as
283
+ * a 401 on a downstream service request — cannot otherwise be recovered
284
+ * within the same process: the still-unexpired cached token keeps being
285
+ * returned. Call this after such a 401 to clear the stale entry; the very
286
+ * next `getServiceToken()` for that credential re-mints.
287
+ *
288
+ * Fully synchronous and deterministic: the call completes before it
289
+ * returns, so a `getServiceToken()` issued immediately afterwards is
290
+ * guaranteed to see the cleared cache and mint anew.
291
+ *
292
+ * @param apiKey - When provided, clears only the cache entry for that
293
+ * specific apiKey. When omitted, clears the entry for the credential set
294
+ * via `configureServiceAuth()`; if neither is available (no key to
295
+ * target), clears the entire cache. Passing no argument is the common
296
+ * case for hosts that configured a single service credential at startup.
297
+ *
298
+ * The cache Map is keyed by an asynchronously-computed `SHA-256(apiKey)`
299
+ * that cannot be reproduced synchronously, so a targeted clear scans the
300
+ * entries and removes the one whose stored raw `apiKey` matches — keeping
301
+ * this method synchronous. The fully-untargeted call (no argument and no
302
+ * configured key) clears every entry, which is safe because each credential
303
+ * pair is independently re-minted on its next request.
304
+ */
305
+ invalidateServiceToken(apiKey?: string): void {
306
+ const targetKey = apiKey ?? this._serviceApiKey;
307
+
308
+ // No specific credential to target — clear everything. The next
309
+ // getServiceToken() for any credential re-mints from scratch.
310
+ if (!targetKey) {
311
+ this._serviceTokenCache.clear();
312
+ return;
313
+ }
314
+
315
+ for (const [cacheKey, entry] of this._serviceTokenCache) {
316
+ if (entry.apiKey === targetKey) {
317
+ this._serviceTokenCache.delete(cacheKey);
318
+ return;
319
+ }
320
+ }
321
+ }
322
+
269
323
  /**
270
324
  * Make an authenticated request on behalf of a user using a service token.
271
325
  * Automatically obtains/refreshes the service token.
@@ -135,6 +135,13 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
135
135
 
136
136
  /**
137
137
  * Get profile recommendations, optionally filtering out specific user types.
138
+ *
139
+ * Public discovery read — works WITHOUT authentication. The SDK attaches the
140
+ * access token automatically when one is available (personalized via
141
+ * mutual-connection overlap), and falls back to popular public profiles when
142
+ * the caller is logged out. This deliberately does NOT use `withAuthRetry`,
143
+ * which would throw an authentication timeout for logged-out callers before
144
+ * the request is ever sent.
138
145
  */
139
146
  async getProfileRecommendations(options?: {
140
147
  excludeTypes?: Array<'federated' | 'agent' | 'automated'>;
@@ -152,12 +159,14 @@ export function OxyServicesUserMixin<T extends typeof OxyServicesBase>(Base: T)
152
159
  _count?: { followers: number; following: number };
153
160
  [key: string]: unknown;
154
161
  }>> {
155
- const params = options?.excludeTypes?.length
156
- ? { excludeTypes: options.excludeTypes.join(',') }
157
- : undefined;
158
- return this.withAuthRetry(async () => {
162
+ try {
163
+ const params = options?.excludeTypes?.length
164
+ ? { excludeTypes: options.excludeTypes.join(',') }
165
+ : undefined;
159
166
  return await this.makeRequest('GET', '/profiles/recommendations', params, { cache: true });
160
- }, 'getProfileRecommendations');
167
+ } catch (error) {
168
+ throw this.handleError(error);
169
+ }
161
170
  }
162
171
 
163
172
  /**
@@ -332,6 +332,98 @@ describe('H1: getServiceToken per-credential cache + secret verification', () =>
332
332
  });
333
333
  });
334
334
 
335
+ // ---------------------------------------------------------------------------
336
+ // invalidateServiceToken — clears the cached service token so the next
337
+ // getServiceToken() mints anew, enabling recovery from a mid-run 401 (e.g.
338
+ // credential revocation) without waiting for natural token expiry.
339
+ // ---------------------------------------------------------------------------
340
+
341
+ describe('invalidateServiceToken: forces a fresh mint after a same-run 401', () => {
342
+ let oxy: OxyServices;
343
+ let makeRequestSpy: jest.SpyInstance;
344
+
345
+ beforeEach(() => {
346
+ oxy = new OxyServices({ baseURL: 'http://test.invalid' });
347
+ makeRequestSpy = jest.spyOn(oxy as unknown as { makeRequest: jest.Mock }, 'makeRequest');
348
+ });
349
+
350
+ afterEach(() => {
351
+ makeRequestSpy.mockRestore();
352
+ });
353
+
354
+ it('re-mints on the next getServiceToken() after invalidation (configured credential)', async () => {
355
+ makeRequestSpy
356
+ .mockResolvedValueOnce({ token: 'token-first', expiresIn: 3600, appName: 'tenant-A' })
357
+ .mockResolvedValueOnce({ token: 'token-second', expiresIn: 3600, appName: 'tenant-A' });
358
+
359
+ oxy.configureServiceAuth('key-A', 'secret-A');
360
+
361
+ const first = await oxy.getServiceToken();
362
+ // Cached — would normally be returned again without re-minting.
363
+ const cached = await oxy.getServiceToken();
364
+ expect(first).toBe('token-first');
365
+ expect(cached).toBe('token-first');
366
+ expect(makeRequestSpy).toHaveBeenCalledTimes(1);
367
+
368
+ // Simulate a 401: invalidate, then the very next call must mint anew.
369
+ oxy.invalidateServiceToken();
370
+
371
+ const fresh = await oxy.getServiceToken();
372
+ expect(fresh).toBe('token-second');
373
+ expect(makeRequestSpy).toHaveBeenCalledTimes(2);
374
+ });
375
+
376
+ it('clears only the targeted apiKey entry, leaving other tenants cached', async () => {
377
+ makeRequestSpy
378
+ .mockResolvedValueOnce({ token: 'token-A1', expiresIn: 3600, appName: 'tenant-A' })
379
+ .mockResolvedValueOnce({ token: 'token-B1', expiresIn: 3600, appName: 'tenant-B' })
380
+ .mockResolvedValueOnce({ token: 'token-A2', expiresIn: 3600, appName: 'tenant-A' });
381
+
382
+ await oxy.getServiceToken('key-A', 'secret-A');
383
+ await oxy.getServiceToken('key-B', 'secret-B');
384
+ expect(makeRequestSpy).toHaveBeenCalledTimes(2);
385
+
386
+ // Invalidate only tenant A.
387
+ oxy.invalidateServiceToken('key-A');
388
+
389
+ // Tenant A re-mints...
390
+ const a2 = await oxy.getServiceToken('key-A', 'secret-A');
391
+ expect(a2).toBe('token-A2');
392
+ expect(makeRequestSpy).toHaveBeenCalledTimes(3);
393
+
394
+ // ...tenant B is still cached (no extra mint).
395
+ const b1 = await oxy.getServiceToken('key-B', 'secret-B');
396
+ expect(b1).toBe('token-B1');
397
+ expect(makeRequestSpy).toHaveBeenCalledTimes(3);
398
+ });
399
+
400
+ it('clears every entry when no key is configured and none is passed', async () => {
401
+ makeRequestSpy
402
+ .mockResolvedValueOnce({ token: 'token-A1', expiresIn: 3600, appName: 'tenant-A' })
403
+ .mockResolvedValueOnce({ token: 'token-B1', expiresIn: 3600, appName: 'tenant-B' })
404
+ .mockResolvedValueOnce({ token: 'token-A2', expiresIn: 3600, appName: 'tenant-A' })
405
+ .mockResolvedValueOnce({ token: 'token-B2', expiresIn: 3600, appName: 'tenant-B' });
406
+
407
+ await oxy.getServiceToken('key-A', 'secret-A');
408
+ await oxy.getServiceToken('key-B', 'secret-B');
409
+ expect(makeRequestSpy).toHaveBeenCalledTimes(2);
410
+
411
+ // No configureServiceAuth() and no argument → clear all.
412
+ oxy.invalidateServiceToken();
413
+
414
+ const a2 = await oxy.getServiceToken('key-A', 'secret-A');
415
+ const b2 = await oxy.getServiceToken('key-B', 'secret-B');
416
+ expect(a2).toBe('token-A2');
417
+ expect(b2).toBe('token-B2');
418
+ expect(makeRequestSpy).toHaveBeenCalledTimes(4);
419
+ });
420
+
421
+ it('is a no-op safe to call when nothing is cached', () => {
422
+ expect(() => oxy.invalidateServiceToken()).not.toThrow();
423
+ expect(() => oxy.invalidateServiceToken('key-unknown')).not.toThrow();
424
+ });
425
+ });
426
+
335
427
  // ---------------------------------------------------------------------------
336
428
  // H2 — malformed tokens must yield 401, not 500. Uses class-based error
337
429
  // detection so future failure modes can't silently fall through.
@@ -5,13 +5,23 @@
5
5
  * `auth.oxy.so` origin is returned. No DOM, no side effects.
6
6
  */
7
7
 
8
- import { CENTRAL_AUTH_URL, resolveCentralAuthUrl } from '../authWebUrl';
8
+ import { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from '../authWebUrl';
9
+
10
+ describe('CENTRAL_IDP_APEX', () => {
11
+ it('is the central IdP registrable apex', () => {
12
+ expect(CENTRAL_IDP_APEX).toBe('oxy.so');
13
+ });
14
+ });
9
15
 
10
16
  describe('CENTRAL_AUTH_URL', () => {
11
17
  it('is the central IdP origin with no trailing slash', () => {
12
18
  expect(CENTRAL_AUTH_URL).toBe('https://auth.oxy.so');
13
19
  expect(CENTRAL_AUTH_URL.endsWith('/')).toBe(false);
14
20
  });
21
+
22
+ it('is derived from CENTRAL_IDP_APEX (apex and origin never drift)', () => {
23
+ expect(CENTRAL_AUTH_URL).toBe(`https://auth.${CENTRAL_IDP_APEX}`);
24
+ });
15
25
  });
16
26
 
17
27
  describe('resolveCentralAuthUrl', () => {