@oxyhq/core 3.4.1 → 3.4.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 (174) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/AuthManager.js +91 -319
  3. package/dist/cjs/CrossDomainAuth.js +19 -106
  4. package/dist/cjs/HttpService.js +49 -73
  5. package/dist/cjs/OxyServices.base.js +2 -2
  6. package/dist/cjs/i18n/index.js +7 -1
  7. package/dist/cjs/i18n/locales/ar-SA.json +18 -2
  8. package/dist/cjs/i18n/locales/ca-ES.json +18 -2
  9. package/dist/cjs/i18n/locales/de-DE.json +18 -2
  10. package/dist/cjs/i18n/locales/en-US.json +16 -2
  11. package/dist/cjs/i18n/locales/es-ES.json +16 -2
  12. package/dist/cjs/i18n/locales/fr-FR.json +18 -2
  13. package/dist/cjs/i18n/locales/it-IT.json +18 -2
  14. package/dist/cjs/i18n/locales/ja-JP.json +18 -2
  15. package/dist/cjs/i18n/locales/ko-KR.json +18 -2
  16. package/dist/cjs/i18n/locales/locales/ar-SA.json +18 -2
  17. package/dist/cjs/i18n/locales/locales/ca-ES.json +18 -2
  18. package/dist/cjs/i18n/locales/locales/de-DE.json +18 -2
  19. package/dist/cjs/i18n/locales/locales/en-US.json +17 -3
  20. package/dist/cjs/i18n/locales/locales/es-ES.json +16 -2
  21. package/dist/cjs/i18n/locales/locales/fr-FR.json +18 -2
  22. package/dist/cjs/i18n/locales/locales/it-IT.json +18 -2
  23. package/dist/cjs/i18n/locales/locales/ja-JP.json +18 -2
  24. package/dist/cjs/i18n/locales/locales/ko-KR.json +18 -2
  25. package/dist/cjs/i18n/locales/locales/pt-PT.json +18 -2
  26. package/dist/cjs/i18n/locales/locales/zh-CN.json +18 -2
  27. package/dist/cjs/i18n/locales/pt-PT.json +18 -2
  28. package/dist/cjs/i18n/locales/zh-CN.json +18 -2
  29. package/dist/cjs/mixins/OxyServices.auth.js +20 -63
  30. package/dist/cjs/mixins/OxyServices.fedcm.js +10 -12
  31. package/dist/cjs/mixins/OxyServices.popup.js +50 -299
  32. package/dist/cjs/mixins/OxyServices.redirect.js +84 -348
  33. package/dist/cjs/mixins/OxyServices.silent.js +204 -0
  34. package/dist/cjs/mixins/OxyServices.sso.js +4 -5
  35. package/dist/cjs/mixins/OxyServices.utility.js +6 -15
  36. package/dist/cjs/mixins/index.js +5 -6
  37. package/dist/cjs/server/index.js +21 -0
  38. package/dist/cjs/server/rateLimit.js +77 -0
  39. package/dist/cjs/shared/utils/debugUtils.js +1 -1
  40. package/dist/cjs/utils/accountUtils.js +4 -4
  41. package/dist/cjs/utils/authHelpers.js +21 -15
  42. package/dist/cjs/utils/coldBoot.js +3 -3
  43. package/dist/cjs/utils/fapiAutoDetect.js +1 -1
  44. package/dist/esm/.tsbuildinfo +1 -1
  45. package/dist/esm/AuthManager.js +91 -319
  46. package/dist/esm/CrossDomainAuth.js +19 -106
  47. package/dist/esm/HttpService.js +49 -73
  48. package/dist/esm/OxyServices.base.js +2 -2
  49. package/dist/esm/i18n/index.js +7 -1
  50. package/dist/esm/i18n/locales/ar-SA.json +18 -2
  51. package/dist/esm/i18n/locales/ca-ES.json +18 -2
  52. package/dist/esm/i18n/locales/de-DE.json +18 -2
  53. package/dist/esm/i18n/locales/en-US.json +16 -2
  54. package/dist/esm/i18n/locales/es-ES.json +16 -2
  55. package/dist/esm/i18n/locales/fr-FR.json +18 -2
  56. package/dist/esm/i18n/locales/it-IT.json +18 -2
  57. package/dist/esm/i18n/locales/ja-JP.json +18 -2
  58. package/dist/esm/i18n/locales/ko-KR.json +18 -2
  59. package/dist/esm/i18n/locales/locales/ar-SA.json +18 -2
  60. package/dist/esm/i18n/locales/locales/ca-ES.json +18 -2
  61. package/dist/esm/i18n/locales/locales/de-DE.json +18 -2
  62. package/dist/esm/i18n/locales/locales/en-US.json +17 -3
  63. package/dist/esm/i18n/locales/locales/es-ES.json +16 -2
  64. package/dist/esm/i18n/locales/locales/fr-FR.json +18 -2
  65. package/dist/esm/i18n/locales/locales/it-IT.json +18 -2
  66. package/dist/esm/i18n/locales/locales/ja-JP.json +18 -2
  67. package/dist/esm/i18n/locales/locales/ko-KR.json +18 -2
  68. package/dist/esm/i18n/locales/locales/pt-PT.json +18 -2
  69. package/dist/esm/i18n/locales/locales/zh-CN.json +18 -2
  70. package/dist/esm/i18n/locales/pt-PT.json +18 -2
  71. package/dist/esm/i18n/locales/zh-CN.json +18 -2
  72. package/dist/esm/mixins/OxyServices.auth.js +20 -63
  73. package/dist/esm/mixins/OxyServices.fedcm.js +10 -12
  74. package/dist/esm/mixins/OxyServices.popup.js +52 -301
  75. package/dist/esm/mixins/OxyServices.redirect.js +84 -349
  76. package/dist/esm/mixins/OxyServices.silent.js +202 -0
  77. package/dist/esm/mixins/OxyServices.sso.js +4 -5
  78. package/dist/esm/mixins/OxyServices.utility.js +6 -15
  79. package/dist/esm/mixins/index.js +5 -6
  80. package/dist/esm/server/index.js +17 -0
  81. package/dist/esm/server/rateLimit.js +71 -0
  82. package/dist/esm/shared/utils/debugUtils.js +1 -1
  83. package/dist/esm/utils/accountUtils.js +4 -4
  84. package/dist/esm/utils/authHelpers.js +21 -15
  85. package/dist/esm/utils/coldBoot.js +3 -3
  86. package/dist/esm/utils/fapiAutoDetect.js +1 -1
  87. package/dist/types/.tsbuildinfo +1 -1
  88. package/dist/types/AuthManager.d.ts +26 -53
  89. package/dist/types/AuthManagerTypes.d.ts +5 -9
  90. package/dist/types/CrossDomainAuth.d.ts +13 -52
  91. package/dist/types/HttpService.d.ts +9 -8
  92. package/dist/types/OxyServices.base.d.ts +1 -1
  93. package/dist/types/OxyServices.d.ts +4 -10
  94. package/dist/types/index.d.ts +1 -1
  95. package/dist/types/mixins/OxyServices.analytics.d.ts +1 -1
  96. package/dist/types/mixins/OxyServices.appData.d.ts +1 -1
  97. package/dist/types/mixins/OxyServices.applications.d.ts +1 -1
  98. package/dist/types/mixins/OxyServices.assets.d.ts +1 -1
  99. package/dist/types/mixins/OxyServices.auth.d.ts +10 -31
  100. package/dist/types/mixins/OxyServices.contacts.d.ts +1 -1
  101. package/dist/types/mixins/OxyServices.devices.d.ts +1 -1
  102. package/dist/types/mixins/OxyServices.features.d.ts +1 -1
  103. package/dist/types/mixins/OxyServices.fedcm.d.ts +5 -5
  104. package/dist/types/mixins/OxyServices.language.d.ts +1 -1
  105. package/dist/types/mixins/OxyServices.location.d.ts +1 -1
  106. package/dist/types/mixins/OxyServices.managedAccounts.d.ts +1 -1
  107. package/dist/types/mixins/OxyServices.payment.d.ts +1 -1
  108. package/dist/types/mixins/OxyServices.popup.d.ts +18 -120
  109. package/dist/types/mixins/OxyServices.privacy.d.ts +1 -1
  110. package/dist/types/mixins/OxyServices.redirect.d.ts +13 -174
  111. package/dist/types/mixins/OxyServices.reputation.d.ts +1 -1
  112. package/dist/types/mixins/OxyServices.security.d.ts +1 -1
  113. package/dist/types/mixins/OxyServices.silent.d.ts +131 -0
  114. package/dist/types/mixins/OxyServices.sso.d.ts +4 -5
  115. package/dist/types/mixins/OxyServices.topics.d.ts +1 -1
  116. package/dist/types/mixins/OxyServices.user.d.ts +1 -1
  117. package/dist/types/mixins/OxyServices.utility.d.ts +3 -8
  118. package/dist/types/mixins/OxyServices.workspaces.d.ts +1 -1
  119. package/dist/types/mixins/index.d.ts +3 -3
  120. package/dist/types/models/interfaces.d.ts +5 -16
  121. package/dist/types/models/session.d.ts +0 -2
  122. package/dist/types/server/index.d.ts +18 -0
  123. package/dist/types/server/rateLimit.d.ts +40 -0
  124. package/dist/types/shared/utils/debugUtils.d.ts +1 -1
  125. package/dist/types/utils/authHelpers.d.ts +4 -3
  126. package/dist/types/utils/coldBoot.d.ts +2 -2
  127. package/dist/types/utils/fapiAutoDetect.d.ts +1 -1
  128. package/package.json +24 -2
  129. package/src/AuthManager.ts +100 -370
  130. package/src/AuthManagerTypes.ts +5 -9
  131. package/src/CrossDomainAuth.ts +22 -129
  132. package/src/HttpService.ts +55 -73
  133. package/src/OxyServices.base.ts +2 -3
  134. package/src/OxyServices.ts +9 -11
  135. package/src/__tests__/authManager.cookiePath.test.ts +19 -17
  136. package/src/__tests__/authManager.security.test.ts +7 -3
  137. package/src/__tests__/crossDomainAuth.test.ts +26 -118
  138. package/src/i18n/index.ts +7 -1
  139. package/src/i18n/locales/ar-SA.json +18 -2
  140. package/src/i18n/locales/ca-ES.json +18 -2
  141. package/src/i18n/locales/de-DE.json +18 -2
  142. package/src/i18n/locales/en-US.json +17 -3
  143. package/src/i18n/locales/es-ES.json +16 -2
  144. package/src/i18n/locales/fr-FR.json +18 -2
  145. package/src/i18n/locales/it-IT.json +18 -2
  146. package/src/i18n/locales/ja-JP.json +18 -2
  147. package/src/i18n/locales/ko-KR.json +18 -2
  148. package/src/i18n/locales/pt-PT.json +18 -2
  149. package/src/i18n/locales/zh-CN.json +18 -2
  150. package/src/index.ts +1 -1
  151. package/src/mixins/OxyServices.auth.ts +23 -75
  152. package/src/mixins/OxyServices.fedcm.ts +10 -12
  153. package/src/mixins/OxyServices.redirect.ts +82 -371
  154. package/src/mixins/OxyServices.silent.ts +272 -0
  155. package/src/mixins/OxyServices.sso.ts +5 -6
  156. package/src/mixins/OxyServices.utility.ts +9 -22
  157. package/src/mixins/__tests__/appData.test.ts +1 -1
  158. package/src/mixins/__tests__/onTokensChanged.test.ts +1 -1
  159. package/src/mixins/__tests__/reputation.test.ts +1 -1
  160. package/src/mixins/__tests__/serviceAuth.test.ts +7 -5
  161. package/src/mixins/__tests__/silent.test.ts +102 -0
  162. package/src/mixins/__tests__/verifyChallenge.test.ts +9 -14
  163. package/src/mixins/index.ts +6 -8
  164. package/src/models/interfaces.ts +5 -16
  165. package/src/models/session.ts +1 -3
  166. package/src/server/index.ts +19 -0
  167. package/src/server/rateLimit.ts +170 -0
  168. package/src/shared/utils/debugUtils.ts +1 -1
  169. package/src/utils/accountUtils.ts +4 -4
  170. package/src/utils/authHelpers.ts +23 -15
  171. package/src/utils/coldBoot.ts +4 -4
  172. package/src/utils/fapiAutoDetect.ts +1 -1
  173. package/src/mixins/OxyServices.popup.ts +0 -631
  174. package/src/mixins/__tests__/popup.test.ts +0 -374
@@ -0,0 +1,272 @@
1
+ import type { OxyServicesBase } from "../OxyServices.base";
2
+ import type { SessionLoginResponse } from "../models/session";
3
+ import { createDebugLogger } from "../shared/utils/debugUtils";
4
+
5
+ const debug = createDebugLogger("SilentAuth");
6
+
7
+ export interface SilentAuthOptions {
8
+ timeout?: number;
9
+ /**
10
+ * Override the auth-web (IdP) origin used for the silent iframe, instead of
11
+ * the instance's configured `resolveAuthUrl()`.
12
+ *
13
+ * Why this exists: an instance configured with the CENTRAL IdP
14
+ * (`authWebUrl=https://auth.oxy.so`, for the opaque-code `/sso` bounce and
15
+ * FedCM) cannot read the DURABLE per-apex `fedcm_session` cookie via the
16
+ * central host — that cookie is first-party only on `auth.<rp-apex>` (e.g.
17
+ * `auth.mention.earth`). The cross-domain reload-restore path must point the
18
+ * `/auth/silent` iframe at the PER-APEX host so the cookie is same-site to
19
+ * the RP page (first-party under Safari ITP / Firefox TCP) and the restore
20
+ * is NOT a top-level navigation (no flash, works in a backgrounded tab).
21
+ *
22
+ * When provided this value is used BOTH for the iframe `src` AND for the
23
+ * `postMessage` origin validation in {@link waitForIframeAuth}, so the
24
+ * security check still matches the exact origin the iframe was loaded from.
25
+ * Must be an absolute origin (`https://auth.<apex>`); ignored if empty.
26
+ */
27
+ authWebUrlOverride?: string;
28
+ }
29
+
30
+ /**
31
+ * Cross-domain silent browser auth helpers.
32
+ *
33
+ * The clean session model supports FedCM, tokenless redirect SSO, and silent
34
+ * iframe SSO. Bearer-token callback URLs are not part of this surface.
35
+ */
36
+ export function OxyServicesSilentAuthMixin<T extends typeof OxyServicesBase>(
37
+ Base: T,
38
+ ) {
39
+ return class extends Base {
40
+ constructor(...args: any[]) {
41
+ super(...(args as [any]));
42
+ }
43
+ public static readonly DEFAULT_AUTH_URL = "https://auth.oxy.so";
44
+
45
+ /** Resolve auth URL from config or static default (method, not getter — getters break in TS mixins) */
46
+ public resolveAuthUrl(): string {
47
+ return (
48
+ this.config.authWebUrl || (this.constructor as any).DEFAULT_AUTH_URL
49
+ );
50
+ }
51
+
52
+ public static readonly SILENT_TIMEOUT = 5000; // 5 seconds
53
+
54
+ /**
55
+ * Silent sign-in using hidden iframe
56
+ *
57
+ * Attempts to automatically re-authenticate the user without any UI.
58
+ * This is what enables seamless SSO across all Oxy domains.
59
+ *
60
+ * How it works:
61
+ * 1. Creates hidden iframe pointing to auth.oxy.so/silent-auth
62
+ * 2. If user has valid session at auth.oxy.so, it exchanges an opaque SSO code
63
+ * 3. If not, iframe responds with null (no error thrown)
64
+ *
65
+ * This should be called on app startup to check for existing sessions.
66
+ *
67
+ * @param options - Silent auth options
68
+ * @returns Session if user is signed in, null otherwise
69
+ *
70
+ * @example
71
+ * ```typescript
72
+ * useEffect(() => {
73
+ * const checkAuth = async () => {
74
+ * const session = await oxyServices.silentSignIn();
75
+ * if (session) {
76
+ * setUser(session.user);
77
+ * }
78
+ * };
79
+ * checkAuth();
80
+ * }, []);
81
+ * ```
82
+ */
83
+ async silentSignIn(
84
+ options: SilentAuthOptions = {},
85
+ ): Promise<SessionLoginResponse | null> {
86
+ if (typeof window === "undefined") {
87
+ return null;
88
+ }
89
+
90
+ const timeout =
91
+ options.timeout || (this.constructor as any).SILENT_TIMEOUT;
92
+ const nonce = this.generateNonce();
93
+ const clientId = window.location.origin;
94
+
95
+ // Resolve the IdP origin for the iframe. An explicit per-apex override (the
96
+ // durable cross-domain reload path — see `SilentAuthOptions.authWebUrlOverride`)
97
+ // wins over the instance's configured central auth URL. The SAME origin is
98
+ // handed to `waitForIframeAuth` so the postMessage origin check matches the
99
+ // exact host the iframe was loaded from.
100
+ const authOrigin =
101
+ options.authWebUrlOverride && options.authWebUrlOverride.length > 0
102
+ ? options.authWebUrlOverride
103
+ : this.resolveAuthUrl();
104
+
105
+ const iframe = document.createElement("iframe");
106
+ iframe.style.display = "none";
107
+ iframe.style.position = "absolute";
108
+ iframe.style.width = "0";
109
+ iframe.style.height = "0";
110
+ iframe.style.border = "none";
111
+
112
+ const silentUrl =
113
+ `${authOrigin}/auth/silent?` +
114
+ `client_id=${encodeURIComponent(clientId)}&` +
115
+ `nonce=${nonce}`;
116
+
117
+ iframe.src = silentUrl;
118
+ document.body.appendChild(iframe);
119
+
120
+ try {
121
+ const session = await this.waitForIframeAuth(
122
+ iframe,
123
+ timeout,
124
+ authOrigin,
125
+ );
126
+
127
+ // Bail early on incomplete responses. The iframe contract requires
128
+ // both an access token and a session id; anything less is unusable.
129
+ // Returning `null` here (without installing the token) prevents a
130
+ // stale credential from being committed to HttpService when the
131
+ // user is actually signed out — that pattern caused a `getCurrentUser`
132
+ // -> 401 -> token-clear loop in consumer apps because callers gated
133
+ // on `session?.user` and never installed the user via
134
+ // `handleAuthSuccess`, while HttpService quietly held the token.
135
+ const accessToken = session
136
+ ? (session as { accessToken?: string }).accessToken
137
+ : undefined;
138
+ if (!session || !accessToken || !session.sessionId) {
139
+ return null;
140
+ }
141
+
142
+ // Snapshot the previous token so we can roll back if the user
143
+ // lookup below fails — this avoids leaving a half-committed session
144
+ // (token installed, user missing) which would let the next
145
+ // authenticated request 401 with no way to recover.
146
+ const previousAccessToken = this.httpService.getAccessToken();
147
+ this.httpService.setTokens(accessToken);
148
+
149
+ // The iframe typically returns `{ sessionId, accessToken }` without
150
+ // user data. Fetch the user explicitly so callers receive a
151
+ // fully-formed session and never need a second `/users/me` round
152
+ // trip. If this fails the session is unusable — revert the token
153
+ // and return null so the caller treats this exactly like a
154
+ // missing-session response.
155
+ if (!session.user) {
156
+ try {
157
+ const userData = await this.makeRequest<unknown>(
158
+ "GET",
159
+ `/session/user/${session.sessionId}`,
160
+ undefined,
161
+ { cache: false, retry: false },
162
+ );
163
+ if (!userData) {
164
+ throw new Error("Empty user response");
165
+ }
166
+ (session as { user?: unknown }).user = userData;
167
+ } catch (userError) {
168
+ debug.warn(
169
+ "silentSignIn: failed to fetch user data, rolling back token",
170
+ userError,
171
+ );
172
+ if (previousAccessToken) {
173
+ this.httpService.setTokens(previousAccessToken);
174
+ } else {
175
+ this.httpService.clearTokens();
176
+ }
177
+ return null;
178
+ }
179
+ }
180
+
181
+ return session;
182
+ } catch (error) {
183
+ return null;
184
+ } finally {
185
+ document.body.removeChild(iframe);
186
+ }
187
+ }
188
+
189
+ /**
190
+ * Wait for authentication response from iframe
191
+ *
192
+ * @private
193
+ */
194
+ public async waitForIframeAuth(
195
+ iframe: HTMLIFrameElement,
196
+ timeout: number,
197
+ expectedOrigin: string,
198
+ ): Promise<SessionLoginResponse | null> {
199
+ return new Promise((resolve) => {
200
+ const timeoutId = setTimeout(() => {
201
+ cleanup();
202
+ resolve(null); // Silent failure - don't throw
203
+ }, timeout);
204
+
205
+ const messageHandler = (event: MessageEvent) => {
206
+ // Verify origin against the EXACT host the iframe was loaded from
207
+ // (`expectedOrigin`). For the per-apex durable-restore path this is
208
+ // `auth.<rp-apex>`, not the instance's central `resolveAuthUrl()` — so
209
+ // we must honour the caller-supplied origin, never re-derive it here.
210
+ if (event.origin !== expectedOrigin) {
211
+ return;
212
+ }
213
+
214
+ const { type, session } = event.data;
215
+
216
+ if (type !== "oxy_silent_auth") {
217
+ return;
218
+ }
219
+
220
+ cleanup();
221
+ resolve(session || null);
222
+ };
223
+
224
+ // Fail-fast on a load failure. When the per-apex `/auth/silent` host is
225
+ // unreachable, blocked by CSP `frame-ancestors`/`X-Frame-Options`, or the
226
+ // network drops, the iframe never posts a message — without this handler
227
+ // the silent restore would block for the FULL `timeout` (dead latency in
228
+ // the cold-boot critical path). `onerror`/`onabort` fire on a failed load,
229
+ // so resolve `null` immediately and let the next cold-boot step run. The
230
+ // success path posts a message and is handled above; these only catch the
231
+ // no-message failure modes.
232
+ const failFast = () => {
233
+ cleanup();
234
+ resolve(null);
235
+ };
236
+ iframe.onerror = failFast;
237
+ iframe.onabort = failFast;
238
+
239
+ const cleanup = () => {
240
+ clearTimeout(timeoutId);
241
+ iframe.onerror = null;
242
+ iframe.onabort = null;
243
+ window.removeEventListener("message", messageHandler);
244
+ };
245
+
246
+ window.addEventListener("message", messageHandler);
247
+ });
248
+ }
249
+
250
+ /**
251
+ * Generate nonce for replay attack prevention
252
+ *
253
+ * @private
254
+ */
255
+ public generateNonce(): string {
256
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
257
+ return crypto.randomUUID();
258
+ }
259
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
260
+ const bytes = new Uint8Array(16);
261
+ crypto.getRandomValues(bytes);
262
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
263
+ "",
264
+ );
265
+ }
266
+ throw new Error("No secure random source available for nonce generation");
267
+ }
268
+ };
269
+ }
270
+
271
+ // Export the mixin function as both named and default
272
+ export { OxyServicesSilentAuthMixin as SilentAuthMixin };
@@ -31,7 +31,7 @@ const debug = createDebugLogger('SSO');
31
31
 
32
32
  /**
33
33
  * Wire shape of `POST /sso/exchange`. `expiresAt` and `authuser` are optional
34
- * (the central store may omit them for legacy single-slot sessions).
34
+ * because the central SSO store may omit them.
35
35
  */
36
36
  interface SsoExchangeWireResponse {
37
37
  accessToken: string;
@@ -51,8 +51,7 @@ interface SsoExchangeWireResponse {
51
51
  *
52
52
  * Exposed as a module-level helper (in addition to the instance method below)
53
53
  * so consumers that do not yet hold an `OxyServices` instance can still mint a
54
- * bounce state. Reuses the same `crypto.randomUUID`-based generator the popup
55
- * mixin uses for its CSRF state, with a `getRandomValues` fallback.
54
+ * bounce state. Uses `crypto.randomUUID` with a `getRandomValues` fallback.
56
55
  */
57
56
  export function generateSsoState(): string {
58
57
  if (typeof crypto !== 'undefined' && crypto.randomUUID) {
@@ -75,8 +74,8 @@ export function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Base: T) {
75
74
  /**
76
75
  * Generate cryptographically secure state for the SSO bounce (CSRF
77
76
  * protection). Delegates to the module-level {@link generateSsoState}
78
- * helper, which uses the same `crypto.randomUUID`-based generator the popup
79
- * mixin's `generateState()` uses — one shared secure-random implementation.
77
+ * helper, which uses `crypto.randomUUID` when available and falls back to
78
+ * `crypto.getRandomValues`.
80
79
  */
81
80
  public generateSsoState(): string {
82
81
  return generateSsoState();
@@ -154,7 +153,7 @@ export function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Base: T) {
154
153
  // Plant the access token exactly like exchangeIdTokenForSession does.
155
154
  // The SSO exchange does not return a refresh token (the central store
156
155
  // holds the refresh credential), so default it to an empty string.
157
- this.httpService.setTokens(payload.accessToken, '');
156
+ this.httpService.setTokens(payload.accessToken);
158
157
 
159
158
  debug.log('SSO exchange complete:', { hasSession: !!payload.sessionId });
160
159
 
@@ -62,13 +62,8 @@ export interface ServiceApp {
62
62
  appId: string;
63
63
  appName: string;
64
64
  scopes: string[];
65
- /**
66
- * The credentialId of the specific service credential that minted this token.
67
- * Carried by newer service-token JWTs alongside `appId`; absent on tokens
68
- * issued before credential-level audit linking. Use for per-credential audit
69
- * trails and rotation alignment (GitHub #215).
70
- */
71
- credentialId?: string;
65
+ /** The credentialId of the specific service credential that minted this token. */
66
+ credentialId: string;
72
67
  }
73
68
 
74
69
  /**
@@ -409,19 +404,12 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
409
404
  };
410
405
 
411
406
  try {
412
- // Extract token from Authorization header or query params.
407
+ // Extract token from Authorization header.
413
408
  // Node/Express normalizes `Authorization` to a string; we guard
414
409
  // against the (legal but unusual) string[] case anyway.
415
410
  const rawAuthHeader = req.headers.authorization;
416
411
  const authHeader = Array.isArray(rawAuthHeader) ? rawAuthHeader[0] : rawAuthHeader;
417
- let token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
418
-
419
- // Fallback to query params (useful for WebSocket upgrades)
420
- if (!token) {
421
- const q = req.query || {};
422
- if (typeof q.token === 'string' && q.token) token = q.token;
423
- else if (typeof q.access_token === 'string' && q.access_token) token = q.access_token;
424
- }
412
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.substring(7) : null;
425
413
 
426
414
  if (debug) {
427
415
  logger.debug(`[oxy.auth] ${req.method} ${req.path} | token: ${!!token}`, {
@@ -573,13 +561,14 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
573
561
 
574
562
  // Validate required service token fields
575
563
  const appId = decoded.appId;
576
- if (!appId) {
564
+ const credentialId = decoded.credentialId;
565
+ if (!appId || typeof credentialId !== 'string' || credentialId.length === 0) {
577
566
  if (optional) {
578
567
  req.userId = null;
579
568
  req.user = null;
580
569
  return next();
581
570
  }
582
- const error = { error: 'INVALID_SERVICE_TOKEN', message: 'Invalid service token: missing appId', code: 'INVALID_SERVICE_TOKEN', status: 401 };
571
+ const error = { error: 'INVALID_SERVICE_TOKEN', message: 'Invalid service token: missing required claims', code: 'INVALID_SERVICE_TOKEN', status: 401 };
583
572
  if (onError) return onError(error);
584
573
  return res.status(401).json(error);
585
574
  }
@@ -625,10 +614,8 @@ export function OxyServicesUtilityMixin<T extends typeof OxyServicesBase>(Base:
625
614
  req.serviceApp = {
626
615
  appId,
627
616
  appName: decoded.appName || 'unknown',
617
+ credentialId,
628
618
  scopes: Array.isArray(decoded.scopes) ? decoded.scopes : [],
629
- ...(typeof decoded.credentialId === 'string' && decoded.credentialId.length > 0
630
- ? { credentialId: decoded.credentialId }
631
- : {}),
632
619
  };
633
620
 
634
621
  if (debug) {
@@ -1133,7 +1120,7 @@ interface OxyAuthInstance {
1133
1120
  [key: string]: unknown;
1134
1121
  } | null>;
1135
1122
  getAccessToken(): string | null;
1136
- setTokens(accessToken: string, refreshToken?: string): void;
1123
+ setTokens(accessToken: string): void;
1137
1124
  clearTokens(): void;
1138
1125
  getCurrentUser(): Promise<User | null>;
1139
1126
  handleError(error: unknown): Error;
@@ -19,7 +19,7 @@ const setAccessTokenForTest = (oxy: OxyServices): void => {
19
19
  // `withAuthRetry` loop polls. Reaching in via the public httpService and
20
20
  // calling setTokens with a dummy avoids us needing to expose new test
21
21
  // hooks just for this.
22
- oxy.httpService.setTokens('test-token', '');
22
+ oxy.httpService.setTokens('test-token');
23
23
  };
24
24
 
25
25
  describe('OxyServices.appData', () => {
@@ -29,7 +29,7 @@ describe('OxyServices.onTokensChanged', () => {
29
29
  const listener = jest.fn();
30
30
  oxy.onTokensChanged(listener);
31
31
 
32
- oxy.setTokens('access_1', 'refresh_1');
32
+ oxy.setTokens('access_1');
33
33
 
34
34
  expect(listener).toHaveBeenCalledTimes(1);
35
35
  expect(listener).toHaveBeenCalledWith('access_1');
@@ -20,7 +20,7 @@ import type {
20
20
  } from '../OxyServices.reputation';
21
21
 
22
22
  const setAccessTokenForTest = (oxy: OxyServices): void => {
23
- oxy.httpService.setTokens('test-token', '');
23
+ oxy.httpService.setTokens('test-token');
24
24
  };
25
25
 
26
26
  const balanceFixture: ReputationBalance = {
@@ -48,6 +48,7 @@ const signServiceToken = (claims: ServiceTokenClaims, secret: string): string =>
48
48
  type: 'service',
49
49
  aud: 'oxy-api',
50
50
  iss: 'oxy-auth',
51
+ credentialId: 'cred-1',
51
52
  ...claims,
52
53
  };
53
54
  const headerB64 = b64url(JSON.stringify(header));
@@ -180,6 +181,7 @@ describe('C3: service-token acting-as enforcement', () => {
180
181
  expect(req.serviceApp).toEqual({
181
182
  appId: 'app-1',
182
183
  appName: 'trusted-service',
184
+ credentialId: 'cred-1',
183
185
  scopes: ['user:read'],
184
186
  });
185
187
  });
@@ -202,7 +204,7 @@ describe('C3: service-token acting-as enforcement', () => {
202
204
  expect(verifySpy).not.toHaveBeenCalled();
203
205
  expect(next).toHaveBeenCalledTimes(1);
204
206
  expect(req.userId).toBeNull();
205
- expect(req.serviceApp).toMatchObject({ appId: 'app-1' });
207
+ expect(req.serviceApp).toMatchObject({ appId: 'app-1', credentialId: 'cred-1' });
206
208
  });
207
209
 
208
210
  it('caches positive grants per (appId, userId) — avoids hammering verify endpoint', async () => {
@@ -620,7 +622,7 @@ describe('H4: aud / iss / type claim verification', () => {
620
622
  await mw(req as unknown as never, res as unknown as never, next as unknown as never);
621
623
 
622
624
  expect(next).toHaveBeenCalledTimes(1);
623
- expect(req.serviceApp).toMatchObject({ appId: 'a' });
625
+ expect(req.serviceApp).toMatchObject({ appId: 'a', credentialId: 'cred-1' });
624
626
  });
625
627
 
626
628
  it('honors expectedAudience and expectedIssuer overrides', async () => {
@@ -660,7 +662,7 @@ describe('requireScope() middleware', () => {
660
662
  // Simulate a fully-authenticated service request — auth() has already
661
663
  // attached `serviceApp`. requireScope() only reads from that field.
662
664
  });
663
- req.serviceApp = { appId: 'a', appName: 'svc', scopes: ['files:write'] };
665
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['files:write'] };
664
666
  const res = makeRes();
665
667
  const next = jest.fn();
666
668
 
@@ -672,7 +674,7 @@ describe('requireScope() middleware', () => {
672
674
 
673
675
  it('allows requests where the delegation grant carries the required scope', () => {
674
676
  const req = makeReq();
675
- req.serviceApp = { appId: 'a', appName: 'svc', scopes: [] };
677
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: [] };
676
678
  req.serviceActingAs = { userId: 'u-1', scopes: ['user:read'] };
677
679
  const res = makeRes();
678
680
  const next = jest.fn();
@@ -684,7 +686,7 @@ describe('requireScope() middleware', () => {
684
686
 
685
687
  it('rejects requests missing the required scope with 403', () => {
686
688
  const req = makeReq();
687
- req.serviceApp = { appId: 'a', appName: 'svc', scopes: ['user:read'] };
689
+ req.serviceApp = { appId: 'a', appName: 'svc', credentialId: 'cred-1', scopes: ['user:read'] };
688
690
  const res = makeRes();
689
691
  const next = jest.fn();
690
692
 
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Silent iframe auth tests.
3
+ *
4
+ * The cross-domain durable-restore iframe (`/auth/silent` at the per-apex host)
5
+ * posts a message on success. On a failed load, it never posts, so
6
+ * `waitForIframeAuth` must resolve `null` immediately instead of waiting for
7
+ * the full timeout.
8
+ */
9
+
10
+ import { OxyServices } from '../../OxyServices';
11
+
12
+ const ORIGIN = 'https://mention.earth';
13
+
14
+ function installBrowserGlobals(options: {
15
+ postMessageDispatcher?: { current: ((event: { origin: string; data: unknown }) => void) | null };
16
+ } = {}): void {
17
+ const store = new Map<string, string>();
18
+ const sessionStorageStub = {
19
+ getItem: (k: string) => (store.has(k) ? (store.get(k) as string) : null),
20
+ setItem: (k: string, v: string) => { store.set(k, v); },
21
+ removeItem: (k: string) => { store.delete(k); },
22
+ };
23
+ const messageHandlers: Array<(event: { origin: string; data: unknown }) => void> = [];
24
+ const win = {
25
+ location: { origin: ORIGIN, hostname: 'mention.earth' },
26
+ sessionStorage: sessionStorageStub,
27
+ addEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
28
+ if (event === 'message') {
29
+ messageHandlers.push(handler);
30
+ if (options.postMessageDispatcher) {
31
+ options.postMessageDispatcher.current = handler;
32
+ }
33
+ }
34
+ },
35
+ removeEventListener: (event: string, handler: (e: { origin: string; data: unknown }) => void) => {
36
+ if (event === 'message') {
37
+ const idx = messageHandlers.indexOf(handler);
38
+ if (idx >= 0) messageHandlers.splice(idx, 1);
39
+ }
40
+ },
41
+ };
42
+ (globalThis as unknown as { window: unknown }).window = win;
43
+ (globalThis as unknown as { sessionStorage: unknown }).sessionStorage = sessionStorageStub;
44
+ }
45
+
46
+ function clearBrowserGlobals(): void {
47
+ for (const key of ['window', 'sessionStorage'] as const) {
48
+ delete (globalThis as Record<string, unknown>)[key];
49
+ }
50
+ }
51
+
52
+ interface FakeIframe {
53
+ onerror: ((this: unknown, ...args: unknown[]) => unknown) | null;
54
+ onabort: ((this: unknown, ...args: unknown[]) => unknown) | null;
55
+ }
56
+
57
+ describe('OxyServices waitForIframeAuth fail-fast on iframe load error', () => {
58
+ afterEach(() => {
59
+ clearBrowserGlobals();
60
+ jest.restoreAllMocks();
61
+ });
62
+
63
+ it('resolves null immediately when the iframe fires onerror', async () => {
64
+ installBrowserGlobals();
65
+
66
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
67
+ const iframe: FakeIframe = { onerror: null, onabort: null };
68
+
69
+ const settled = oxy.waitForIframeAuth(
70
+ iframe as unknown as HTMLIFrameElement,
71
+ 100000,
72
+ 'https://auth.mention.earth',
73
+ );
74
+
75
+ await Promise.resolve();
76
+ expect(typeof iframe.onerror).toBe('function');
77
+ iframe.onerror?.call(iframe);
78
+
79
+ await expect(settled).resolves.toBeNull();
80
+ expect(iframe.onerror).toBeNull();
81
+ expect(iframe.onabort).toBeNull();
82
+ });
83
+
84
+ it('resolves null immediately when the iframe fires onabort', async () => {
85
+ installBrowserGlobals();
86
+
87
+ const oxy = new OxyServices({ baseURL: 'https://api.oxy.so' });
88
+ const iframe: FakeIframe = { onerror: null, onabort: null };
89
+
90
+ const settled = oxy.waitForIframeAuth(
91
+ iframe as unknown as HTMLIFrameElement,
92
+ 100000,
93
+ 'https://auth.mention.earth',
94
+ );
95
+
96
+ await Promise.resolve();
97
+ expect(typeof iframe.onabort).toBe('function');
98
+ iframe.onabort?.call(iframe);
99
+
100
+ await expect(settled).resolves.toBeNull();
101
+ });
102
+ });
@@ -2,13 +2,11 @@
2
2
  * `verifyChallenge` token-planting regression tests.
3
3
  *
4
4
  * `OxyServices.verifyChallenge()` returns a `SessionLoginResponse` carrying the
5
- * first `accessToken`/`refreshToken` minted by `POST /auth/verify`. It must
6
- * PLANT those tokens internally — mirroring its sibling `claimSessionByToken` —
5
+ * first `accessToken` minted by `POST /auth/verify`. It must
6
+ * plant that token internally — mirroring its sibling `claimSessionByToken` —
7
7
  * so callers (e.g. @oxyhq/services' `useAuthOperations.performSignIn`) end up
8
- * with an authenticated client WITHOUT falling back to the bearer-protected
9
- * `GET /session/token/:sessionId`. That fallback 401s for a brand-new identity
10
- * that has no bearer yet and previously broke the entire new-identity
11
- * onboarding flow.
8
+ * with an authenticated client. Session IDs are not public token-minting
9
+ * credentials, so the initial bearer must come from the verify response body.
12
10
  *
13
11
  * These tests stub `makeRequest` so the planting logic is exercised end-to-end
14
12
  * against a real OxyServices instance, with token state observed via the public
@@ -22,7 +20,6 @@ interface VerifyResponse {
22
20
  deviceId: string;
23
21
  expiresAt: string;
24
22
  accessToken?: string;
25
- refreshToken?: string;
26
23
  user: { id: string; username: string };
27
24
  }
28
25
 
@@ -35,7 +32,7 @@ describe('OxyServices.verifyChallenge token planting', () => {
35
32
  jest.restoreAllMocks();
36
33
  });
37
34
 
38
- it('plants the access + refresh token from the /auth/verify response body', async () => {
35
+ it('plants the access token from the /auth/verify response body', async () => {
39
36
  const oxy = makeOxy();
40
37
  expect(oxy.hasValidToken()).toBe(false);
41
38
 
@@ -46,7 +43,6 @@ describe('OxyServices.verifyChallenge token planting', () => {
46
43
  deviceId: 'dev_1',
47
44
  expiresAt: new Date(Date.now() + 60_000).toISOString(),
48
45
  accessToken: 'access_verify',
49
- refreshToken: 'refresh_verify',
50
46
  user: { id: 'user_1', username: 'tester' },
51
47
  } as never;
52
48
  }
@@ -55,15 +51,15 @@ describe('OxyServices.verifyChallenge token planting', () => {
55
51
 
56
52
  const session = await oxy.verifyChallenge('pubkey', 'challenge', 'sig', 123, 'Device', 'fp');
57
53
 
58
- // Response still carries the tokens for callers that want them.
54
+ // Response still carries the access token for callers that want it.
59
55
  expect(session.accessToken).toBe('access_verify');
60
- // ...and they are now planted on the client so subsequent requests are
56
+ // ...and it is now planted on the client so subsequent requests are
61
57
  // authenticated without a second round-trip.
62
58
  expect(oxy.hasValidToken()).toBe(true);
63
59
  expect(oxy.getAccessToken()).toBe('access_verify');
64
60
  });
65
61
 
66
- it('defaults the refresh token to an empty string when the response omits it', async () => {
62
+ it('plants the access token when no refresh token is present', async () => {
67
63
  const oxy = makeOxy();
68
64
 
69
65
  jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
@@ -110,14 +106,13 @@ describe('OxyServices.verifyChallenge token planting', () => {
110
106
  expect(oxy.hasValidToken()).toBe(false);
111
107
  });
112
108
 
113
- it('matches claimSessionByToken: both plant tokens via the same path', async () => {
109
+ it('matches claimSessionByToken: both plant access tokens via the same path', async () => {
114
110
  const oxy = makeOxy();
115
111
 
116
112
  jest.spyOn(oxy, 'makeRequest').mockImplementation(async (_method, url) => {
117
113
  if (url === '/auth/session/claim') {
118
114
  return {
119
115
  accessToken: 'access_claim',
120
- refreshToken: 'refresh_claim',
121
116
  sessionId: 'sess_claim',
122
117
  deviceId: 'dev_claim',
123
118
  expiresAt: new Date(Date.now() + 60_000).toISOString(),