@oxyhq/core 5.5.0 → 7.0.0

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 (196) hide show
  1. package/dist/cjs/.tsbuildinfo +1 -1
  2. package/dist/cjs/HttpService.js +7 -4
  3. package/dist/cjs/OxyServices.base.js +7 -102
  4. package/dist/cjs/boot/coldBootV2.js +350 -0
  5. package/dist/cjs/boot/deviceBootReturn.js +152 -0
  6. package/dist/cjs/crypto/keyManager.js +95 -0
  7. package/dist/cjs/i18n/locales/en-US.json +13 -1
  8. package/dist/cjs/i18n/locales/es-ES.json +13 -1
  9. package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
  10. package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
  11. package/dist/cjs/index.js +54 -45
  12. package/dist/cjs/mixins/OxyServices.accounts.js +13 -26
  13. package/dist/cjs/mixins/OxyServices.auth.js +66 -201
  14. package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
  15. package/dist/cjs/mixins/index.js +8 -17
  16. package/dist/cjs/server/index.js +2 -2
  17. package/dist/cjs/session/SessionClient.js +40 -1
  18. package/dist/cjs/session/authStateStore.js +284 -0
  19. package/dist/cjs/session/refresh.js +264 -0
  20. package/dist/cjs/shared/utils/debugUtils.js +3 -3
  21. package/dist/cjs/utils/accountUtils.js +1 -55
  22. package/dist/cjs/utils/authWebUrl.js +10 -39
  23. package/dist/cjs/utils/coldBoot.js +10 -8
  24. package/dist/cjs/utils/platform.js +19 -0
  25. package/dist/cjs/utils/registrableApex.js +49 -0
  26. package/dist/cjs/utils/ssoBounce.js +15 -362
  27. package/dist/cjs/utils/validationUtils.js +57 -0
  28. package/dist/esm/.tsbuildinfo +1 -1
  29. package/dist/esm/HttpService.js +7 -4
  30. package/dist/esm/OxyServices.base.js +7 -102
  31. package/dist/esm/boot/coldBootV2.js +344 -0
  32. package/dist/esm/boot/deviceBootReturn.js +146 -0
  33. package/dist/esm/crypto/keyManager.js +95 -0
  34. package/dist/esm/i18n/locales/en-US.json +13 -1
  35. package/dist/esm/i18n/locales/es-ES.json +13 -1
  36. package/dist/esm/i18n/locales/locales/en-US.json +13 -1
  37. package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
  38. package/dist/esm/index.js +34 -17
  39. package/dist/esm/mixins/OxyServices.accounts.js +13 -26
  40. package/dist/esm/mixins/OxyServices.auth.js +66 -201
  41. package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
  42. package/dist/esm/mixins/index.js +8 -17
  43. package/dist/esm/server/index.js +1 -1
  44. package/dist/esm/session/SessionClient.js +40 -1
  45. package/dist/esm/session/authStateStore.js +278 -0
  46. package/dist/esm/session/refresh.js +257 -0
  47. package/dist/esm/shared/utils/debugUtils.js +3 -3
  48. package/dist/esm/utils/accountUtils.js +0 -53
  49. package/dist/esm/utils/authWebUrl.js +9 -37
  50. package/dist/esm/utils/coldBoot.js +10 -8
  51. package/dist/esm/utils/platform.js +18 -0
  52. package/dist/esm/utils/registrableApex.js +46 -0
  53. package/dist/esm/utils/ssoBounce.js +14 -345
  54. package/dist/esm/utils/validationUtils.js +56 -0
  55. package/dist/types/.tsbuildinfo +1 -1
  56. package/dist/types/HttpService.d.ts +14 -1
  57. package/dist/types/OxyServices.base.d.ts +0 -52
  58. package/dist/types/OxyServices.d.ts +0 -25
  59. package/dist/types/boot/coldBootV2.d.ts +76 -0
  60. package/dist/types/boot/deviceBootReturn.d.ts +83 -0
  61. package/dist/types/crypto/keyManager.d.ts +21 -0
  62. package/dist/types/index.d.ts +14 -21
  63. package/dist/types/mixins/OxyServices.accounts.d.ts +24 -29
  64. package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
  65. package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
  66. package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
  67. package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
  68. package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
  69. package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
  70. package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
  71. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
  72. package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
  73. package/dist/types/mixins/OxyServices.features.d.ts +0 -2
  74. package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
  75. package/dist/types/mixins/OxyServices.language.d.ts +0 -2
  76. package/dist/types/mixins/OxyServices.links.d.ts +0 -2
  77. package/dist/types/mixins/OxyServices.location.d.ts +0 -2
  78. package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
  79. package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
  80. package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
  81. package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
  82. package/dist/types/mixins/OxyServices.security.d.ts +0 -2
  83. package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
  84. package/dist/types/mixins/OxyServices.user.d.ts +0 -2
  85. package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
  86. package/dist/types/mixins/index.d.ts +5 -9
  87. package/dist/types/models/interfaces.d.ts +0 -67
  88. package/dist/types/models/session.d.ts +4 -5
  89. package/dist/types/server/index.d.ts +1 -1
  90. package/dist/types/session/SessionClient.d.ts +26 -1
  91. package/dist/types/session/authStateStore.d.ts +119 -0
  92. package/dist/types/session/refresh.d.ts +93 -0
  93. package/dist/types/shared/utils/debugUtils.d.ts +3 -3
  94. package/dist/types/utils/accountUtils.d.ts +2 -17
  95. package/dist/types/utils/authWebUrl.d.ts +9 -35
  96. package/dist/types/utils/coldBoot.d.ts +17 -14
  97. package/dist/types/utils/platform.d.ts +14 -0
  98. package/dist/types/utils/registrableApex.d.ts +31 -0
  99. package/dist/types/utils/ssoBounce.d.ts +14 -280
  100. package/dist/types/utils/validationUtils.d.ts +15 -0
  101. package/package.json +2 -2
  102. package/src/HttpService.ts +20 -4
  103. package/src/OxyServices.base.ts +7 -112
  104. package/src/OxyServices.ts +0 -38
  105. package/src/boot/__tests__/coldBootV2.test.ts +317 -0
  106. package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
  107. package/src/boot/coldBootV2.ts +426 -0
  108. package/src/boot/deviceBootReturn.ts +195 -0
  109. package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
  110. package/src/crypto/keyManager.ts +101 -0
  111. package/src/i18n/locales/en-US.json +13 -1
  112. package/src/i18n/locales/es-ES.json +13 -1
  113. package/src/index.ts +75 -65
  114. package/src/mixins/OxyServices.accounts.ts +27 -39
  115. package/src/mixins/OxyServices.auth.ts +78 -253
  116. package/src/mixins/OxyServices.deviceBoot.ts +146 -0
  117. package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
  118. package/src/mixins/__tests__/accounts.test.ts +17 -44
  119. package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
  120. package/src/mixins/index.ts +10 -23
  121. package/src/models/interfaces.ts +0 -79
  122. package/src/models/session.ts +4 -5
  123. package/src/server/index.ts +1 -1
  124. package/src/session/SessionClient.ts +53 -2
  125. package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
  126. package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
  127. package/src/session/__tests__/SessionClient.state.test.ts +18 -5
  128. package/src/session/__tests__/authStateStore.test.ts +209 -0
  129. package/src/session/__tests__/refresh.test.ts +256 -0
  130. package/src/session/authStateStore.ts +335 -0
  131. package/src/session/refresh.ts +334 -0
  132. package/src/shared/utils/debugUtils.ts +3 -3
  133. package/src/utils/__tests__/authWebUrl.test.ts +5 -40
  134. package/src/utils/__tests__/registrableApex.test.ts +62 -0
  135. package/src/utils/__tests__/validationUtils.test.ts +30 -0
  136. package/src/utils/accountUtils.ts +2 -65
  137. package/src/utils/authWebUrl.ts +9 -39
  138. package/src/utils/coldBoot.ts +17 -14
  139. package/src/utils/platform.ts +21 -0
  140. package/src/utils/registrableApex.ts +45 -0
  141. package/src/utils/ssoBounce.ts +14 -393
  142. package/src/utils/validationUtils.ts +62 -0
  143. package/dist/cjs/AuthManager.js +0 -1110
  144. package/dist/cjs/AuthManagerTypes.js +0 -13
  145. package/dist/cjs/CrossDomainAuth.js +0 -206
  146. package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
  147. package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
  148. package/dist/cjs/mixins/OxyServices.silent.js +0 -204
  149. package/dist/cjs/mixins/OxyServices.sso.js +0 -208
  150. package/dist/cjs/utils/fapiAutoDetect.js +0 -99
  151. package/dist/cjs/utils/ssoEstablish.js +0 -110
  152. package/dist/cjs/utils/ssoReturn.js +0 -275
  153. package/dist/esm/AuthManager.js +0 -1105
  154. package/dist/esm/AuthManagerTypes.js +0 -12
  155. package/dist/esm/CrossDomainAuth.js +0 -201
  156. package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
  157. package/dist/esm/mixins/OxyServices.redirect.js +0 -92
  158. package/dist/esm/mixins/OxyServices.silent.js +0 -202
  159. package/dist/esm/mixins/OxyServices.sso.js +0 -204
  160. package/dist/esm/utils/fapiAutoDetect.js +0 -95
  161. package/dist/esm/utils/ssoEstablish.js +0 -107
  162. package/dist/esm/utils/ssoReturn.js +0 -271
  163. package/dist/types/AuthManager.d.ts +0 -380
  164. package/dist/types/AuthManagerTypes.d.ts +0 -81
  165. package/dist/types/CrossDomainAuth.d.ts +0 -164
  166. package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
  167. package/dist/types/mixins/OxyServices.redirect.d.ts +0 -92
  168. package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
  169. package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
  170. package/dist/types/utils/fapiAutoDetect.d.ts +0 -56
  171. package/dist/types/utils/ssoEstablish.d.ts +0 -85
  172. package/dist/types/utils/ssoReturn.d.ts +0 -156
  173. package/src/AuthManager.ts +0 -1269
  174. package/src/AuthManagerTypes.ts +0 -86
  175. package/src/CrossDomainAuth.ts +0 -243
  176. package/src/__tests__/authManager.cookiePath.test.ts +0 -390
  177. package/src/__tests__/authManager.security.test.ts +0 -377
  178. package/src/__tests__/crossDomainAuth.test.ts +0 -116
  179. package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
  180. package/src/mixins/OxyServices.fedcm.ts +0 -1026
  181. package/src/mixins/OxyServices.redirect.ts +0 -122
  182. package/src/mixins/OxyServices.silent.ts +0 -272
  183. package/src/mixins/OxyServices.sso.ts +0 -261
  184. package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
  185. package/src/mixins/__tests__/fedcm.test.ts +0 -667
  186. package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
  187. package/src/mixins/__tests__/silent.test.ts +0 -102
  188. package/src/mixins/__tests__/sso.test.ts +0 -228
  189. package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
  190. package/src/utils/__tests__/fapiAutoDetect.test.ts +0 -183
  191. package/src/utils/__tests__/ssoBounce.test.ts +0 -219
  192. package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
  193. package/src/utils/__tests__/ssoReturn.test.ts +0 -276
  194. package/src/utils/fapiAutoDetect.ts +0 -91
  195. package/src/utils/ssoEstablish.ts +0 -174
  196. package/src/utils/ssoReturn.ts +0 -389
@@ -1,122 +0,0 @@
1
- import type { OxyServicesBase } from '../OxyServices.base';
2
- import { OxyAuthenticationError } from '../OxyServices.errors';
3
- import type { SessionLoginResponse } from '../models/session';
4
- import {
5
- buildSsoBounceUrl,
6
- ssoAttemptedKey,
7
- ssoDestKey,
8
- ssoGuardKey,
9
- ssoStateKey,
10
- } from '../utils/ssoBounce';
11
-
12
- export interface RedirectAuthOptions {
13
- redirectUri?: string;
14
- mode?: 'login' | 'signup';
15
- preserveUrl?: boolean;
16
- }
17
-
18
- /**
19
- * Redirect-based authentication without bearer tokens in URLs.
20
- *
21
- * The redirect fallback now uses the same central SSO code-return contract as
22
- * cold boot: the RP stores CSRF/destination state in sessionStorage, navigates
23
- * to the central IdP, receives only an opaque one-time code in the URL fragment,
24
- * and the provider's `sso-return` step exchanges that code for the real session.
25
- */
26
- export function OxyServicesRedirectAuthMixin<T extends typeof OxyServicesBase>(Base: T) {
27
- return class extends Base {
28
- constructor(...args: any[]) {
29
- super(...(args as [any]));
30
- }
31
-
32
- /**
33
- * Start a full-page redirect through the central SSO flow.
34
- *
35
- * No access token, refresh token, or session id is ever put in the URL or
36
- * localStorage. The caller's provider must run `consumeSsoReturn` on startup
37
- * to complete the code exchange and commit the session.
38
- */
39
- signInWithRedirect(options: RedirectAuthOptions = {}): void {
40
- if (typeof window === 'undefined' || typeof window.sessionStorage === 'undefined') {
41
- throw new OxyAuthenticationError('Redirect authentication requires browser sessionStorage');
42
- }
43
-
44
- const origin = window.location.origin;
45
- const state = this.generateState();
46
- const destination = options.preserveUrl === false
47
- ? (options.redirectUri || origin)
48
- : (options.redirectUri || window.location.href);
49
-
50
- window.sessionStorage.setItem(ssoStateKey(origin), state);
51
- window.sessionStorage.setItem(ssoGuardKey(origin), String(Date.now()));
52
- window.sessionStorage.setItem(ssoDestKey(origin), destination);
53
- window.sessionStorage.setItem(ssoAttemptedKey(origin), '1');
54
-
55
- window.location.assign(buildSsoBounceUrl(origin, state, this.config.authWebUrl));
56
- }
57
-
58
- signUpWithRedirect(options: RedirectAuthOptions = {}): void {
59
- this.signInWithRedirect({ ...options, mode: 'signup' });
60
- }
61
-
62
- /**
63
- * Legacy token-query callbacks are intentionally rejected. Modern providers
64
- * complete redirect auth through `consumeSsoReturn`, which accepts only
65
- * `#oxy_sso=ok&code=...`.
66
- */
67
- handleAuthCallback(): SessionLoginResponse | null {
68
- if (typeof window === 'undefined') {
69
- return null;
70
- }
71
-
72
- const url = new URL(window.location.href);
73
- if (url.searchParams.has('access_token') || url.searchParams.has('session_id')) {
74
- this.cleanAuthCallbackUrl(url);
75
- throw new OxyAuthenticationError('Legacy access-token redirect callbacks are no longer accepted.');
76
- }
77
-
78
- return null;
79
- }
80
-
81
- restoreSession(): boolean {
82
- return false;
83
- }
84
-
85
- clearStoredSession(): void {
86
- this.httpService.clearTokens();
87
- }
88
-
89
- getStoredSessionId(): string | null {
90
- return null;
91
- }
92
-
93
- public generateState(): string {
94
- if (typeof crypto !== 'undefined' && crypto.randomUUID) {
95
- return crypto.randomUUID();
96
- }
97
- if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
98
- const bytes = new Uint8Array(16);
99
- crypto.getRandomValues(bytes);
100
- return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
101
- }
102
- throw new Error('No secure random source available for state generation');
103
- }
104
-
105
- public generateNonce(): string {
106
- return this.generateState();
107
- }
108
-
109
- public cleanAuthCallbackUrl(url: URL): void {
110
- url.searchParams.delete('access_token');
111
- url.searchParams.delete('session_id');
112
- url.searchParams.delete('expires_at');
113
- url.searchParams.delete('state');
114
- url.searchParams.delete('nonce');
115
- url.searchParams.delete('error');
116
- url.searchParams.delete('error_description');
117
- window.history.replaceState({}, '', url.toString());
118
- }
119
- };
120
- }
121
-
122
- export { OxyServicesRedirectAuthMixin as RedirectAuthMixin };
@@ -1,272 +0,0 @@
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 };
@@ -1,261 +0,0 @@
1
- /**
2
- * Central Cross-Domain SSO (opaque-code) Mixin
3
- *
4
- * Implements the Relying-Party half of TRUE central cross-domain SSO
5
- * (Google/Meta/Clerk style). The central IdP at `auth.oxy.so` owns the session;
6
- * an RP bounces a top-level redirect (prompt=none) to `auth.oxy.so/sso`, which
7
- * returns an OPAQUE single-use code in the redirect fragment. The RP then
8
- * exchanges that code here for the real session.
9
- *
10
- * Security properties:
11
- * - NO token/JWT ever travels in a URL — only the opaque code does. The real
12
- * `accessToken` is delivered exclusively in this exchange response body.
13
- * - The exchange is a CORS POST with NO credentials/cookies — the opaque code
14
- * is the only bearer of authority, and the central store burns it atomically
15
- * (single-use). Sending no cookies keeps the request a clean, ambient-
16
- * authority-free bearer exchange that the central `POST /sso/exchange`
17
- * endpoint validates by `Origin` against the code's bound `clientOrigin`.
18
- * - The code is minted server-side bound to the RP origin and expires in
19
- * seconds, so a leaked code is useless cross-origin and short-lived.
20
- *
21
- * On success the mixin plants the returned access token via
22
- * `httpService.setTokens(...)` — mirroring `exchangeIdTokenForSession` /
23
- * `verifyChallenge` — so callers do NOT need to plant tokens manually.
24
- */
25
-
26
- import type { OxyServicesBase } from '../OxyServices.base';
27
- import type { SessionLoginResponse, MinimalUserData } from '../models/session';
28
- import type { UserNameResponse } from '@oxyhq/contracts';
29
- import { createDebugLogger } from '../shared/utils/debugUtils';
30
- import { ssoStateKey } from '../utils/ssoBounce';
31
-
32
- const debug = createDebugLogger('SSO');
33
-
34
- /**
35
- * Wire shape of `POST /sso/exchange`. `expiresAt` and `authuser` are optional
36
- * because the central SSO store may omit them.
37
- */
38
- interface SsoExchangeWireResponse {
39
- accessToken: string;
40
- sessionId: string;
41
- user: {
42
- id?: string;
43
- _id?: string;
44
- username?: string;
45
- name?: UserNameResponse;
46
- avatar?: string;
47
- };
48
- expiresAt?: string;
49
- authuser?: number;
50
- }
51
-
52
- /** Wire shape of `POST /sso/establish-token`. */
53
- interface SsoEstablishTokenWireResponse {
54
- establishUrl: string;
55
- }
56
-
57
- /**
58
- * Generate a cryptographically secure state value for the SSO bounce.
59
- *
60
- * Exposed as a module-level helper (in addition to the instance method below)
61
- * so consumers that do not yet hold an `OxyServices` instance can still mint a
62
- * bounce state. Uses `crypto.randomUUID` with a `getRandomValues` fallback.
63
- */
64
- export function generateSsoState(): string {
65
- if (typeof crypto !== 'undefined' && crypto.randomUUID) {
66
- return crypto.randomUUID();
67
- }
68
- if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
69
- const bytes = new Uint8Array(16);
70
- crypto.getRandomValues(bytes);
71
- return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
72
- }
73
- throw new Error('No secure random source available for SSO state generation');
74
- }
75
-
76
- /**
77
- * Read the SSO bounce state stored for the current browser origin, if any.
78
- *
79
- * Returns `null` outside a browser (no `window`/`sessionStorage`) or when no
80
- * state is stored — in which case the caller cannot (and must not) enforce a
81
- * state match, e.g. native flows or pre-hydration callbacks that already
82
- * validated the state before this exchange.
83
- */
84
- function getStoredSsoStateForCurrentOrigin(): string | null {
85
- if (typeof window === 'undefined' || !window.location || !window.sessionStorage) {
86
- return null;
87
- }
88
-
89
- try {
90
- return window.sessionStorage.getItem(ssoStateKey(window.location.origin));
91
- } catch {
92
- return null;
93
- }
94
- }
95
-
96
- export function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Base: T) {
97
- return class extends Base {
98
- constructor(...args: any[]) {
99
- super(...(args as [any]));
100
- }
101
-
102
- /**
103
- * Generate cryptographically secure state for the SSO bounce (CSRF
104
- * protection). Delegates to the module-level {@link generateSsoState}
105
- * helper, which uses `crypto.randomUUID` when available and falls back to
106
- * `crypto.getRandomValues`.
107
- */
108
- public generateSsoState(): string {
109
- return generateSsoState();
110
- }
111
-
112
- /**
113
- * Exchange an opaque single-use SSO code for the real Oxy session.
114
- *
115
- * POSTs `{ code }` to `${getSessionBaseUrl()}/sso/exchange` as a CORS
116
- * request with NO credentials/cookies. On success the returned access token
117
- * is planted via `httpService.setTokens(...)` (matching
118
- * `exchangeIdTokenForSession` / `verifyChallenge`), so callers do not need
119
- * to plant tokens manually.
120
- *
121
- * @param code - The opaque single-use code delivered in the SSO return
122
- * fragment (see {@link parseSsoReturnFragment}). The central store burns
123
- * it atomically on exchange.
124
- * @param state - The state value returned alongside the code. In browsers,
125
- * when an SSO bounce state is still stored for the current origin, this
126
- * must match before any token-committing exchange is attempted.
127
- * @returns The resolved {@link SessionLoginResponse}.
128
- */
129
- public async exchangeSsoCode(code: string, state?: string): Promise<SessionLoginResponse> {
130
- if (typeof code !== 'string' || code.length === 0) {
131
- throw this.handleError(new Error('exchangeSsoCode requires a non-empty code'));
132
- }
133
-
134
- const expectedState = getStoredSsoStateForCurrentOrigin();
135
- if (expectedState !== null && (typeof state !== 'string' || state.length === 0 || state !== expectedState)) {
136
- throw this.handleError(new Error('SSO exchange state mismatch'));
137
- }
138
-
139
- const url = `${this.getSessionBaseUrl().replace(/\/$/, '')}/sso/exchange`;
140
- debug.log('Exchanging SSO code for session...');
141
-
142
- let response: Response;
143
- try {
144
- response = await fetch(url, {
145
- method: 'POST',
146
- // No cookies: the opaque code is the sole bearer of authority and the
147
- // server validates by Origin against the code's bound clientOrigin.
148
- credentials: 'omit',
149
- headers: {
150
- 'Content-Type': 'application/json',
151
- Accept: 'application/json',
152
- },
153
- body: JSON.stringify({ code }),
154
- });
155
- } catch (error) {
156
- debug.error('SSO exchange request failed:', error instanceof Error ? error.message : String(error));
157
- throw this.handleError(error);
158
- }
159
-
160
- if (!response.ok) {
161
- throw this.handleError(new Error(`SSO exchange failed with HTTP ${response.status}`));
162
- }
163
-
164
- let payload: SsoExchangeWireResponse;
165
- try {
166
- payload = (await response.json()) as SsoExchangeWireResponse;
167
- } catch (error) {
168
- throw this.handleError(error);
169
- }
170
-
171
- if (!payload || typeof payload.accessToken !== 'string' || payload.accessToken.length === 0) {
172
- throw this.handleError(new Error('SSO exchange returned no access token'));
173
- }
174
- if (typeof payload.sessionId !== 'string' || payload.sessionId.length === 0) {
175
- throw this.handleError(new Error('SSO exchange returned no sessionId'));
176
- }
177
-
178
- const userId = payload.user?.id ?? payload.user?._id;
179
- if (!userId || typeof payload.user?.username !== 'string') {
180
- throw this.handleError(new Error('SSO exchange returned an invalid user'));
181
- }
182
-
183
- const user: MinimalUserData = {
184
- id: userId,
185
- username: payload.user.username,
186
- // `name.displayName` is optional on the contract. A no-name user is
187
- // valid; carry whatever structured name the server sent (possibly an
188
- // empty object) and let consumers fall back to a handle.
189
- name: payload.user.name ?? {},
190
- avatar: payload.user.avatar,
191
- };
192
-
193
- // Plant the access token exactly like exchangeIdTokenForSession does.
194
- // The SSO exchange does not return a refresh token (the central store
195
- // holds the refresh credential), so default it to an empty string.
196
- this.httpService.setTokens(payload.accessToken);
197
-
198
- debug.log('SSO exchange complete:', { hasSession: !!payload.sessionId });
199
-
200
- const session: SessionLoginResponse = {
201
- sessionId: payload.sessionId,
202
- deviceId: '',
203
- expiresAt: payload.expiresAt ?? '',
204
- user,
205
- accessToken: payload.accessToken,
206
- };
207
-
208
- return session;
209
- }
210
-
211
- /**
212
- * Mint a server-formed `/sso/establish` URL for the caller's OWN session,
213
- * bound to an approved RP `origin`.
214
- *
215
- * Bearer-authenticated (the session id is taken from the caller's own
216
- * bearer, server-side — never from any argument). The server validates that
217
- * `origin` is an approved client origin (and matches the request `Origin`),
218
- * derives the per-apex IdP host (`auth.<apex>`), mints a short-lived HS256
219
- * establish-token, and returns a fully-formed
220
- * `https://<auth-host>/sso/establish?et=…&return_to=<origin>/__oxy/sso-callback&state=<state>`.
221
- *
222
- * Used AFTER a web device-flow claim to plant the durable first-party
223
- * `fedcm_session` cookie so a reload can re-mint a token (see
224
- * {@link establishIdpSessionAfterClaim}). Cache-free (a POST is never
225
- * cached, but `cache: false` is explicit).
226
- *
227
- * @param origin - The RP origin (`window.location.origin`) to establish for.
228
- * @param state - The CSRF state echoed back in the callback fragment; the
229
- * caller persists the SAME value under `ssoStateKey(origin)` so the
230
- * post-bounce `sso-return` step validates it.
231
- */
232
- public async requestSsoEstablishUrl(
233
- origin: string,
234
- state: string,
235
- ): Promise<{ establishUrl: string }> {
236
- if (typeof origin !== 'string' || origin.length === 0) {
237
- throw this.handleError(new Error('requestSsoEstablishUrl requires a non-empty origin'));
238
- }
239
- if (typeof state !== 'string' || state.length === 0) {
240
- throw this.handleError(new Error('requestSsoEstablishUrl requires a non-empty state'));
241
- }
242
-
243
- const response = await this.makeRequest<SsoEstablishTokenWireResponse>(
244
- 'POST',
245
- '/sso/establish-token',
246
- { origin, state },
247
- { cache: false },
248
- );
249
-
250
- if (
251
- !response ||
252
- typeof response.establishUrl !== 'string' ||
253
- response.establishUrl.length === 0
254
- ) {
255
- throw this.handleError(new Error('SSO establish-token returned no establishUrl'));
256
- }
257
-
258
- return { establishUrl: response.establishUrl };
259
- }
260
- };
261
- }