@oxyhq/core 9.1.0 → 9.2.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 (68) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/boot/{coldBootV2.js → sessionColdBoot.js} +2 -2
  4. package/dist/cjs/index.js +23 -4
  5. package/dist/cjs/mixins/OxyServices.auth.js +61 -0
  6. package/dist/cjs/mixins/OxyServices.deviceBoot.js +29 -1
  7. package/dist/cjs/server/index.js +8 -1
  8. package/dist/cjs/session/SessionClient.js +57 -25
  9. package/dist/cjs/session/accountDialogController.js +20 -9
  10. package/dist/cjs/session/authStateStore.js +13 -7
  11. package/dist/cjs/session/hubSync.js +55 -0
  12. package/dist/cjs/session/sessionClientHost.js +5 -0
  13. package/dist/cjs/utils/coldBoot.js +1 -1
  14. package/dist/cjs/utils/oauthPkce.js +65 -1
  15. package/dist/cjs/utils/officialOrigins.js +128 -0
  16. package/dist/esm/.tsbuildinfo +1 -1
  17. package/dist/esm/boot/{coldBootV2.js → sessionColdBoot.js} +2 -2
  18. package/dist/esm/index.js +4 -2
  19. package/dist/esm/mixins/OxyServices.auth.js +61 -0
  20. package/dist/esm/mixins/OxyServices.deviceBoot.js +30 -2
  21. package/dist/esm/server/index.js +1 -0
  22. package/dist/esm/session/SessionClient.js +57 -25
  23. package/dist/esm/session/accountDialogController.js +20 -9
  24. package/dist/esm/session/authStateStore.js +13 -7
  25. package/dist/esm/session/hubSync.js +51 -0
  26. package/dist/esm/session/sessionClientHost.js +5 -0
  27. package/dist/esm/utils/coldBoot.js +1 -1
  28. package/dist/esm/utils/oauthPkce.js +60 -0
  29. package/dist/esm/utils/officialOrigins.js +119 -0
  30. package/dist/types/.tsbuildinfo +1 -1
  31. package/dist/types/boot/{coldBootV2.d.ts → sessionColdBoot.d.ts} +1 -1
  32. package/dist/types/index.d.ts +7 -4
  33. package/dist/types/mixins/OxyServices.auth.d.ts +14 -0
  34. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +6 -2
  35. package/dist/types/server/index.d.ts +1 -0
  36. package/dist/types/session/SessionClient.d.ts +6 -0
  37. package/dist/types/session/accountDialogController.d.ts +9 -1
  38. package/dist/types/session/authStateStore.d.ts +1 -1
  39. package/dist/types/session/hubSync.d.ts +20 -0
  40. package/dist/types/session/sessionClientHost.d.ts +2 -1
  41. package/dist/types/utils/coldBoot.d.ts +2 -2
  42. package/dist/types/utils/oauthPkce.d.ts +27 -0
  43. package/dist/types/utils/officialOrigins.d.ts +17 -0
  44. package/package.json +2 -2
  45. package/src/boot/__tests__/{coldBootV2.test.ts → sessionColdBoot.test.ts} +1 -1
  46. package/src/boot/{coldBootV2.ts → sessionColdBoot.ts} +2 -2
  47. package/src/index.ts +27 -3
  48. package/src/mixins/OxyServices.auth.ts +70 -1
  49. package/src/mixins/OxyServices.deviceBoot.ts +46 -1
  50. package/src/server/index.ts +8 -0
  51. package/src/session/SessionClient.ts +68 -26
  52. package/src/session/__tests__/SessionClient.additive.test.ts +1 -0
  53. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +1 -0
  54. package/src/session/__tests__/SessionClient.diagnostics.test.ts +1 -0
  55. package/src/session/__tests__/SessionClient.rest.test.ts +1 -0
  56. package/src/session/__tests__/SessionClient.socket.test.ts +1 -0
  57. package/src/session/__tests__/SessionClient.socketFactory.test.ts +1 -0
  58. package/src/session/__tests__/SessionClient.state.test.ts +1 -0
  59. package/src/session/__tests__/accountDialogController.test.ts +38 -6
  60. package/src/session/__tests__/sessionIntegration.test.ts +7 -0
  61. package/src/session/accountDialogController.ts +36 -10
  62. package/src/session/authStateStore.ts +18 -9
  63. package/src/session/hubSync.ts +79 -0
  64. package/src/session/sessionClientHost.ts +10 -2
  65. package/src/utils/__tests__/officialOrigins.test.ts +74 -0
  66. package/src/utils/coldBoot.ts +2 -2
  67. package/src/utils/oauthPkce.ts +71 -0
  68. package/src/utils/officialOrigins.ts +124 -0
@@ -1,5 +1,5 @@
1
1
  /**
2
- * coldBootV2 — one device-first cold boot for every consumer.
2
+ * Device-first session cold boot for every consumer.
3
3
  *
4
4
  * On a fresh page load / app launch this resolves the device's session in a
5
5
  * deterministic order, built on the pure `runColdBoot` primitive. It NEVER
@@ -92,7 +92,7 @@ export async function runSessionColdBoot(opts) {
92
92
  return { kind: 'skip' };
93
93
  }
94
94
  // Transient (network / 5xx): keep the secret; a later attempt can succeed.
95
- logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'coldBootV2', method: 'device-secret-mint' }, error);
95
+ logger.debug('device-secret mint failed (transient) — keeping secret', { component: 'sessionColdBoot', method: 'device-secret-mint' }, error);
96
96
  return { kind: 'skip' };
97
97
  }
98
98
  },
package/dist/esm/index.js CHANGED
@@ -131,7 +131,9 @@ export { runColdBoot } from './utils/coldBoot.js';
131
131
  // OAuth 2.0 Authorization Code + PKCE helpers ("Sign in with Oxy" third party).
132
132
  // Standard OAuth against auth.oxy.so/authorize — no FedCM/cookies/SSO bounce.
133
133
  // ---------------------------------------------------------------------------
134
- export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, } from './utils/oauthPkce.js';
134
+ export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, OXY_OAUTH_STATE_STORAGE_KEY, OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, OXY_SILENT_OAUTH_ATTEMPTED_KEY, OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY, normalizeOAuthRedirectUri, persistOAuthHandshake, readOAuthHandshake, clearOAuthHandshake, } from './utils/oauthPkce.js';
135
+ export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins.js';
136
+ export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync.js';
135
137
  // ---------------------------------------------------------------------------
136
138
  // Session sync (device-scoped multi-account session client)
137
139
  // ---------------------------------------------------------------------------
@@ -164,7 +166,7 @@ export { AccountDialogController, createAccountDialogController, } from './sessi
164
166
  // ---------------------------------------------------------------------------
165
167
  export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthStateStore, AUTH_STATE_STORAGE_KEY, } from './session/authStateStore.js';
166
168
  export { refreshPersistedSession, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh.js';
167
- export { runSessionColdBoot } from './boot/coldBootV2.js';
169
+ export { runSessionColdBoot } from './boot/sessionColdBoot.js';
168
170
  // API response contracts (request/response Zod schemas + inferred types) live in
169
171
  // `@oxyhq/contracts` — the single source of truth shared by the backend and every
170
172
  // client SDK. Import them directly from `@oxyhq/contracts`; `@oxyhq/core` does NOT
@@ -803,6 +803,7 @@ export function OxyServicesAuthMixin(Base) {
803
803
  password,
804
804
  deviceName: options.deviceName,
805
805
  deviceFingerprint: options.deviceFingerprint,
806
+ ...(options.deviceId ? { deviceId: options.deviceId } : {}),
806
807
  }, { cache: false });
807
808
  const parsed = safeParseContract(loginResultSchema, res);
808
809
  if (!parsed) {
@@ -831,6 +832,7 @@ export function OxyServicesAuthMixin(Base) {
831
832
  token: params.token,
832
833
  backupCode: params.backupCode,
833
834
  deviceName: params.deviceName,
835
+ ...(params.deviceId ? { deviceId: params.deviceId } : {}),
834
836
  }, { cache: false });
835
837
  const parsed = safeParseContract(loginResultSchema, res);
836
838
  if (!parsed || 'twoFactorRequired' in parsed) {
@@ -845,5 +847,64 @@ export function OxyServicesAuthMixin(Base) {
845
847
  throw this.handleError(error);
846
848
  }
847
849
  }
850
+ /**
851
+ * Exchange an OAuth authorization code (returned to the RP redirect URI
852
+ * after password sign-in at auth.oxy.so) for a device-first session.
853
+ * Public first-party clients use PKCE (`codeVerifier`); the access token is
854
+ * planted immediately on success.
855
+ */
856
+ async exchangeOAuthCode(params) {
857
+ try {
858
+ const res = await this.makeRequest('POST', '/auth/oauth/token', {
859
+ code: params.code,
860
+ clientId: params.clientId,
861
+ redirectUri: params.redirectUri,
862
+ codeVerifier: params.codeVerifier,
863
+ }, { cache: false });
864
+ const payload = res.data ??
865
+ res;
866
+ if (!payload || typeof payload !== 'object') {
867
+ throw new Error('auth/oauth/token returned an unexpected response shape');
868
+ }
869
+ const record = payload;
870
+ const accessToken = (record.access_token ?? record.accessToken);
871
+ const sessionId = (record.session_id ?? record.sessionId);
872
+ const deviceId = (record.deviceId ?? record.device_id);
873
+ const deviceSecret = (record.deviceSecret ?? record.device_secret);
874
+ const userRaw = record.user;
875
+ if (!sessionId || !deviceId || !userRaw || typeof userRaw !== 'object') {
876
+ throw new Error('auth/oauth/token returned an incomplete session payload');
877
+ }
878
+ const userObj = userRaw;
879
+ const userId = userObj.id;
880
+ if (!userId) {
881
+ throw new Error('auth/oauth/token returned a session without user.id');
882
+ }
883
+ const expiresInSec = typeof record.expires_in === 'number'
884
+ ? record.expires_in
885
+ : typeof record.expiresIn === 'number'
886
+ ? record.expiresIn
887
+ : 15 * 60;
888
+ const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
889
+ if (accessToken) {
890
+ this.setTokens(accessToken);
891
+ }
892
+ return {
893
+ sessionId,
894
+ deviceId,
895
+ expiresAt,
896
+ accessToken,
897
+ deviceSecret,
898
+ user: {
899
+ id: userId,
900
+ username: typeof userObj.username === 'string' ? userObj.username : undefined,
901
+ avatar: typeof userObj.avatar === 'string' ? userObj.avatar : undefined,
902
+ },
903
+ };
904
+ }
905
+ catch (error) {
906
+ throw this.handleError(error);
907
+ }
908
+ }
848
909
  };
849
910
  }
@@ -2,7 +2,7 @@
2
2
  * Device-first token mint mixin.
3
3
  *
4
4
  * The client half of the zero-cookie device transport: the single network call
5
- * the cold boot (`coldBootV2`) and the unified re-mint handler (`refresh.ts`)
5
+ * the cold boot (`sessionColdBoot`) and the unified re-mint handler (`refresh.ts`)
6
6
  * make to turn a first-party `deviceId` + `deviceSecret` into a fresh access
7
7
  * token. The response is validated against the `@oxyhq/contracts`
8
8
  * `deviceTokenMintResponseSchema`, so producer (oxy-api) and consumer cannot
@@ -13,7 +13,7 @@
13
13
  * the cold boot / re-mint handler own persistence and `setTokens`, so the same
14
14
  * primitive can be reused from either without double-planting.
15
15
  */
16
- import { deviceTokenMintResponseSchema, safeParseContract, } from '@oxyhq/contracts';
16
+ import { deviceTokenMintResponseSchema, deviceHubTicketIssueResponseSchema, deviceHubTicketRedeemResponseSchema, safeParseContract, } from '@oxyhq/contracts';
17
17
  export function OxyServicesDeviceBootMixin(Base) {
18
18
  return class extends Base {
19
19
  /**
@@ -44,5 +44,33 @@ export function OxyServicesDeviceBootMixin(Base) {
44
44
  throw this.handleError(error);
45
45
  }
46
46
  }
47
+ /** Mint a one-time hub sync ticket (bearer required). */
48
+ async issueHubTicket(returnOrigin) {
49
+ try {
50
+ const res = await this.makeRequest('POST', '/session/device/hub-ticket', { returnOrigin }, { cache: false });
51
+ const parsed = safeParseContract(deviceHubTicketIssueResponseSchema, res);
52
+ if (!parsed) {
53
+ throw new Error('session/device/hub-ticket returned an unexpected response shape');
54
+ }
55
+ return parsed;
56
+ }
57
+ catch (error) {
58
+ throw this.handleError(error);
59
+ }
60
+ }
61
+ /** Redeem a hub sync ticket for a fresh device secret (public). */
62
+ async redeemHubTicket(ticket, returnOrigin) {
63
+ try {
64
+ const res = await this.makeRequest('POST', '/session/device/redeem-ticket', { ticket, returnOrigin }, { cache: false, skipAuth: true });
65
+ const parsed = safeParseContract(deviceHubTicketRedeemResponseSchema, res);
66
+ if (!parsed) {
67
+ throw new Error('session/device/redeem-ticket returned an unexpected response shape');
68
+ }
69
+ return parsed;
70
+ }
71
+ catch (error) {
72
+ throw this.handleError(error);
73
+ }
74
+ }
47
75
  };
48
76
  }
@@ -27,3 +27,4 @@ export { verifySecret } from './verifySecret.js';
27
27
  // Pure host handling (no browser deps), so it is safe on the server subpath and
28
28
  // lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
29
29
  export { registrableApex } from '../utils/registrableApex.js';
30
+ export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from '../utils/officialOrigins.js';
@@ -60,21 +60,32 @@ export class SessionClient {
60
60
  return false;
61
61
  }
62
62
  this.state = next;
63
- this.notify();
64
- if (this.options.transport) {
65
- void this.options.transport.ensureActiveToken(next).catch((error) => {
63
+ const transport = this.options.transport;
64
+ const needsMintBeforeNotify = transport != null && next.accounts.length > 0 && !this.host.getAccessToken();
65
+ const finishApply = () => {
66
+ this.notify();
67
+ if (next.accounts.length === 0 && this.options.onUnauthenticated) {
68
+ try {
69
+ this.options.onUnauthenticated();
70
+ }
71
+ catch (error) {
72
+ logger.error('[SessionClient] onUnauthenticated threw', error);
73
+ }
74
+ }
75
+ };
76
+ if (needsMintBeforeNotify) {
77
+ void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
66
78
  logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
79
+ finishApply();
67
80
  });
68
81
  }
69
- // A device signout-all leaves zero accounts — tell the provider to clear
70
- // the persisted store so a reload does not try to restore a dead session.
71
- if (next.accounts.length === 0 && this.options.onUnauthenticated) {
72
- try {
73
- this.options.onUnauthenticated();
74
- }
75
- catch (error) {
76
- logger.error('[SessionClient] onUnauthenticated threw', error);
82
+ else {
83
+ if (transport) {
84
+ void transport.ensureActiveToken(next).catch((error) => {
85
+ logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
86
+ });
77
87
  }
88
+ finishApply();
78
89
  }
79
90
  return true;
80
91
  }
@@ -152,19 +163,24 @@ export class SessionClient {
152
163
  this.started = true;
153
164
  this.tokenUnsub = this.host.onTokensChanged((token) => {
154
165
  // A rotated/fresh bearer landed — reconnect a dropped socket so its
155
- // handshake re-runs with the current token. Sign-out (null token) is
156
- // handled by the consumer calling `stop()`.
157
- if (!token || !this.socket)
166
+ // handshake re-runs with the current token. Sign-out (null token) keeps
167
+ // the device-scoped socket when a device credential is available.
168
+ if (!this.socket)
158
169
  return;
159
- if (!this.socket.connected) {
170
+ if (token) {
171
+ if (!this.socket.connected) {
172
+ this.socket.connect();
173
+ }
174
+ return;
175
+ }
176
+ const cred = this.host.getDeviceCredential();
177
+ if (cred && !this.socket.connected) {
160
178
  this.socket.connect();
161
179
  }
162
180
  });
163
181
  this.openBroadcastChannel();
164
- // `bootstrap` (`GET /session/device/state`) is bearer-authenticated. A
165
- // signed-out client opens no socket and runs no bootstrap; a bootstrap
166
- // failure is non-fatal — the socket still connects so realtime sync
167
- // survives a transient state-fetch error.
182
+ // Device-scoped socket: bearer when authenticated, else deviceId+deviceSecret so
183
+ // signed-out tabs still receive `session_state` and can mint on change.
168
184
  if (this.host.getAccessToken()) {
169
185
  try {
170
186
  await this.bootstrap();
@@ -207,15 +223,29 @@ export class SessionClient {
207
223
  }
208
224
  if (!this.started)
209
225
  return; // stopped while the dynamic import was in flight
210
- // Sockets are BEARER-ONLY: the server rejects any handshake without a valid
211
- // bearer, so a signed-out client never opens a socket.
212
- if (!this.host.getAccessToken())
226
+ const token = this.host.getAccessToken();
227
+ const deviceCredential = this.host.getDeviceCredential();
228
+ if (!token && !deviceCredential)
213
229
  return;
214
230
  const socket = io(this.host.getBaseURL(), {
215
231
  transports: ['websocket'],
216
232
  autoConnect: true,
233
+ reconnection: true,
234
+ reconnectionAttempts: Infinity,
235
+ reconnectionDelay: 1000,
236
+ reconnectionDelayMax: 10000,
217
237
  auth: (cb) => {
218
- cb({ token: this.host.getAccessToken() ?? '' });
238
+ const bearer = this.host.getAccessToken();
239
+ if (bearer) {
240
+ cb({ token: bearer });
241
+ return;
242
+ }
243
+ const cred = this.host.getDeviceCredential();
244
+ if (cred) {
245
+ cb({ deviceId: cred.deviceId, deviceSecret: cred.deviceSecret });
246
+ return;
247
+ }
248
+ cb({ token: '' });
219
249
  },
220
250
  });
221
251
  socket.on('session_state', (payload) => {
@@ -223,9 +253,11 @@ export class SessionClient {
223
253
  if (!applied)
224
254
  return;
225
255
  // A push changed the active account on another device/tab — re-fetch state
226
- // to plant the access token for the newly-active account.
256
+ // to plant the access token for the newly-active account. When this tab is
257
+ // still signed out, applyState mints via ensureActiveToken first; bootstrap
258
+ // requires a bearer and must not run until then.
227
259
  const active = this.state?.activeAccountId ?? null;
228
- if (active && active !== this.host.getCurrentAccountId()) {
260
+ if (active && active !== this.host.getCurrentAccountId() && this.host.getAccessToken()) {
229
261
  void this.bootstrap().catch((error) => {
230
262
  logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
231
263
  });
@@ -27,6 +27,7 @@
27
27
  */
28
28
  import { logger } from '../utils/loggerUtils.js';
29
29
  import { CENTRAL_IDP_APEX } from '../utils/authWebUrl.js';
30
+ import { generateOAuthState, generatePkcePair, normalizeOAuthRedirectUri, persistOAuthHandshake, } from '../utils/oauthPkce.js';
30
31
  import { projectSwitchableAccounts, switchableAccountIds, } from './accountProjection.js';
31
32
  const DEFAULT_POLL_INTERVAL_MS = 3000;
32
33
  const IDLE_SIGN_IN = {
@@ -68,6 +69,7 @@ export class AccountDialogController {
68
69
  this.commitSession = options.commitSession;
69
70
  this.onSignedIn = options.onSignedIn;
70
71
  this.idpApex = options.idpApex ?? CENTRAL_IDP_APEX;
72
+ this.authRedirectUri = options.authRedirectUri ?? null;
71
73
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
72
74
  this.openUrl = options.openUrl;
73
75
  this.snapshot = this.computeSnapshot();
@@ -421,18 +423,27 @@ export class AccountDialogController {
421
423
  * @param params.state - Optional opaque state echoed back on return.
422
424
  * @returns The absolute auth.oxy.so sign-in URL.
423
425
  */
424
- openPasswordAtOxyAuth(params = {}) {
426
+ async openPasswordAtOxyAuth(params = {}) {
425
427
  const base = `https://auth.${this.idpApex}`;
426
428
  const url = new URL('/login', base);
427
- const returnUrl = params.returnUrl ?? currentLocationHref();
428
- if (returnUrl) {
429
- url.searchParams.set('redirect_uri', returnUrl);
429
+ const rawRedirect = params.redirectUri ??
430
+ this.authRedirectUri ??
431
+ params.returnUrl ??
432
+ currentLocationOrigin();
433
+ const redirectUri = rawRedirect ? normalizeOAuthRedirectUri(rawRedirect) : '';
434
+ if (redirectUri) {
435
+ url.searchParams.set('redirect_uri', redirectUri);
430
436
  }
431
437
  if (this.clientId) {
432
438
  url.searchParams.set('client_id', this.clientId);
433
439
  }
434
- if (params.state) {
435
- url.searchParams.set('state', params.state);
440
+ const state = params.state ?? (await generateOAuthState());
441
+ url.searchParams.set('state', state);
442
+ const { codeChallenge, codeVerifier } = await generatePkcePair();
443
+ url.searchParams.set('code_challenge', codeChallenge);
444
+ url.searchParams.set('code_challenge_method', 'S256');
445
+ if (!persistOAuthHandshake(state, codeVerifier)) {
446
+ throw new Error('Could not persist OAuth handshake for password sign-in');
436
447
  }
437
448
  const href = url.toString();
438
449
  this.openUrl?.(href);
@@ -601,8 +612,8 @@ export function createAccountDialogController(options) {
601
612
  // ---------------------------------------------------------------------------
602
613
  // Local helpers
603
614
  // ---------------------------------------------------------------------------
604
- /** Current document URL on web; empty string where `location` is absent (native/SSR). */
605
- function currentLocationHref() {
615
+ /** Current document origin on web; empty string where `location` is absent (native/SSR). */
616
+ function currentLocationOrigin() {
606
617
  const location = globalThis.location;
607
- return typeof location?.href === 'string' ? location.href : '';
618
+ return typeof location?.origin === 'string' ? location.origin : '';
608
619
  }
@@ -5,7 +5,7 @@
5
5
  * a reload restores the session locally without a redirect: `deviceId` +
6
6
  * `deviceSecret` mint a fresh access token via `POST /session/device/token`.
7
7
  * This module is the storage seam: a tiny `load / save / clear` interface plus
8
- * platform factories, so the cold boot (`coldBootV2`) and the unified re-mint
8
+ * platform factories, so the cold boot (`sessionColdBoot`) and the unified re-mint
9
9
  * handler (`refresh.ts`) never touch a platform storage API directly.
10
10
  *
11
11
  * Platform-agnostic — the native factory takes an INJECTED key/value store
@@ -42,15 +42,21 @@ function deserialize(raw) {
42
42
  return null;
43
43
  }
44
44
  const candidate = parsed;
45
- if (typeof candidate.sessionId !== 'string' ||
46
- typeof candidate.userId !== 'string' ||
47
- candidate.sessionId.length === 0 ||
48
- candidate.userId.length === 0) {
45
+ const hasDeviceCredential = typeof candidate.deviceId === 'string' &&
46
+ candidate.deviceId.length > 0 &&
47
+ typeof candidate.deviceSecret === 'string' &&
48
+ candidate.deviceSecret.length > 0;
49
+ const hasSessionIdentity = typeof candidate.sessionId === 'string' &&
50
+ typeof candidate.userId === 'string' &&
51
+ candidate.sessionId.length > 0 &&
52
+ candidate.userId.length > 0;
53
+ // Device-only bootstrap (post join, pre-sign-in): durable credential without session.
54
+ if (!hasSessionIdentity && !hasDeviceCredential) {
49
55
  return null;
50
56
  }
51
57
  const state = {
52
- sessionId: candidate.sessionId,
53
- userId: candidate.userId,
58
+ sessionId: hasSessionIdentity ? candidate.sessionId : '',
59
+ userId: hasSessionIdentity ? candidate.userId : '',
54
60
  };
55
61
  if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
56
62
  state.deviceId = candidate.deviceId;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Post-sign-in hub sync — plant device credentials on auth.oxy.so via a
3
+ * one-time server ticket (no secrets in URL fragments).
4
+ */
5
+ import { buildHubSyncUrl, buildIdpHubOrigin, isIdpHubOrigin, isOfficialWebOrigin, } from '../utils/officialOrigins.js';
6
+ /**
7
+ * After a successful sign-in on an official web app, mint a hub ticket and
8
+ * redirect to auth.oxy.so/sync so the IdP hub can redeem it and persist the
9
+ * shared device credential for silent OAuth restore on other origins.
10
+ *
11
+ * No-op on native, non-official origins, and when already on the IdP hub.
12
+ */
13
+ export async function syncHubAfterSignIn(oxy, opts) {
14
+ if (opts?.enabled === false) {
15
+ return false;
16
+ }
17
+ if (typeof globalThis === 'undefined') {
18
+ return false;
19
+ }
20
+ const location = globalThis.location;
21
+ if (!location) {
22
+ return false;
23
+ }
24
+ if (isIdpHubOrigin()) {
25
+ return false;
26
+ }
27
+ if (!isOfficialWebOrigin(location.origin)) {
28
+ return false;
29
+ }
30
+ const hubOrigin = buildIdpHubOrigin();
31
+ const issued = await oxy.issueHubTicket(hubOrigin);
32
+ const returnUrl = `${location.origin}${location.pathname}${location.search}`;
33
+ const syncUrl = buildHubSyncUrl(issued.ticket, returnUrl);
34
+ window.location.assign(syncUrl);
35
+ return true;
36
+ }
37
+ /** Redeem a hub ticket on auth.oxy.so and persist credentials locally. */
38
+ export async function redeemHubTicketOnHub(oxy, store, ticket) {
39
+ const hubOrigin = buildIdpHubOrigin();
40
+ const creds = await oxy.redeemHubTicket(ticket, hubOrigin);
41
+ const prior = await store.load();
42
+ await store.save({
43
+ sessionId: prior?.sessionId ?? '',
44
+ userId: prior?.userId ?? '',
45
+ deviceId: creds.deviceId,
46
+ deviceSecret: creds.deviceSecret,
47
+ ...(prior?.accessToken ? { accessToken: prior.accessToken } : {}),
48
+ ...(prior?.expiresAt ? { expiresAt: prior.expiresAt } : {}),
49
+ });
50
+ return true;
51
+ }
@@ -12,15 +12,20 @@
12
12
  */
13
13
  export function createSessionClientHost(oxyServices) {
14
14
  let currentAccountId = null;
15
+ let deviceCredential = null;
15
16
  return {
16
17
  makeRequest: (method, url, data, options) => oxyServices.makeRequest(method, url, data, options),
17
18
  getBaseURL: () => oxyServices.getBaseURL(),
18
19
  getAccessToken: () => oxyServices.getAccessToken(),
20
+ getDeviceCredential: () => deviceCredential,
19
21
  onTokensChanged: (listener) => oxyServices.onTokensChanged(listener),
20
22
  setTokens: (accessToken) => oxyServices.setTokens(accessToken),
21
23
  getCurrentAccountId: () => currentAccountId,
22
24
  setCurrentAccountId: (id) => {
23
25
  currentAccountId = id;
24
26
  },
27
+ setDeviceCredential: (credential) => {
28
+ deviceCredential = credential;
29
+ },
25
30
  };
26
31
  }
@@ -16,7 +16,7 @@
16
16
  * web bundle — the reason any run-once guard for a step must live in the
17
17
  * calling consumer, never in a core module-level singleton).
18
18
  * - Architecture-agnostic: it knows nothing about HOW a step resolves a
19
- * session; `runSessionColdBoot` (`boot/coldBootV2.ts`) is the current
19
+ * session; `runSessionColdBoot` (`boot/sessionColdBoot.ts`) is the current
20
20
  * device-first consumer.
21
21
  *
22
22
  * A step is skipped (without running) when its `enabled` predicate returns
@@ -131,5 +131,65 @@ export function buildOAuthAuthorizeUrl(params) {
131
131
  url.searchParams.set('scope', scope);
132
132
  url.searchParams.set('code_challenge', codeChallenge);
133
133
  url.searchParams.set('code_challenge_method', 'S256');
134
+ if (params.prompt) {
135
+ url.searchParams.set('prompt', params.prompt);
136
+ }
134
137
  return url.toString();
135
138
  }
139
+ /** `sessionStorage` key for the OAuth CSRF `state` across an authorize redirect. */
140
+ export const OXY_OAUTH_STATE_STORAGE_KEY = 'oxy_oauth_state';
141
+ /** `sessionStorage` key for the PKCE `code_verifier` across an authorize redirect. */
142
+ export const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = 'oxy_oauth_code_verifier';
143
+ /** `sessionStorage` key — at most one silent OAuth attempt per navigation. */
144
+ export const OXY_SILENT_OAUTH_ATTEMPTED_KEY = 'oxy.silent_oauth_attempted';
145
+ /** `sessionStorage` key — blocks further cross-origin auto-restore in this tab. */
146
+ export const OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = 'oxy.cross_origin_restore_attempted';
147
+ /**
148
+ * Normalize a redirect URI to its origin. Official Oxy apps register apex
149
+ * origins (`https://inbox.oxy.so`) — never path-qualified URLs.
150
+ */
151
+ export function normalizeOAuthRedirectUri(input) {
152
+ try {
153
+ return new URL(input).origin;
154
+ }
155
+ catch {
156
+ return input;
157
+ }
158
+ }
159
+ /** Persist the OAuth handshake for a full-page redirect return (web only). */
160
+ export function persistOAuthHandshake(state, codeVerifier) {
161
+ const store = globalThis.sessionStorage;
162
+ try {
163
+ if (!store)
164
+ throw new Error('sessionStorage is unavailable');
165
+ store.setItem(OXY_OAUTH_STATE_STORAGE_KEY, state);
166
+ store.setItem(OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, codeVerifier);
167
+ return true;
168
+ }
169
+ catch (error) {
170
+ logger.warn('Could not persist OAuth handshake to sessionStorage', { component: 'oauthPkce' }, error);
171
+ return false;
172
+ }
173
+ }
174
+ /** Read the persisted OAuth handshake, or `null` when absent. */
175
+ export function readOAuthHandshake() {
176
+ const store = globalThis.sessionStorage;
177
+ if (!store)
178
+ return null;
179
+ const state = store.getItem(OXY_OAUTH_STATE_STORAGE_KEY);
180
+ const codeVerifier = store.getItem(OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY);
181
+ if (!state || !codeVerifier)
182
+ return null;
183
+ return { state, codeVerifier };
184
+ }
185
+ /** Drop persisted OAuth handshake keys after a successful or aborted return. */
186
+ export function clearOAuthHandshake() {
187
+ const store = globalThis.sessionStorage;
188
+ try {
189
+ store?.removeItem(OXY_OAUTH_STATE_STORAGE_KEY);
190
+ store?.removeItem(OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY);
191
+ }
192
+ catch {
193
+ // Best-effort cleanup only.
194
+ }
195
+ }