@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
@@ -83,10 +83,13 @@ export { registrableApex } from './utils/registrableApex';
83
83
  export { CENTRAL_IDP_APEX } from './utils/authWebUrl';
84
84
  export { runColdBoot } from './utils/coldBoot';
85
85
  export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
86
- export { buildOAuthAuthorizeUrl, computeCodeChallenge, generateOAuthState, generatePkcePair, DEFAULT_OAUTH_SCOPE, OXY_AUTHORIZE_URL, } from './utils/oauthPkce';
86
+ 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';
87
87
  export type { PkcePair, BuildOAuthAuthorizeUrlParams } from './utils/oauthPkce';
88
+ export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin, isAllowedDeviceJoinOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from './utils/officialOrigins';
89
+ export { syncHubAfterSignIn, redeemHubTicketOnHub, } from './session/hubSync';
90
+ export type { SyncHubAfterSignInOptions } from './session/hubSync';
88
91
  export { SessionClient } from './session/SessionClient';
89
- export type { TokenTransport, SessionClientHost, SessionClientOptions } from './session/SessionClient';
92
+ export type { TokenTransport, SessionClientHost, SessionClientOptions, DeviceCredential } from './session/SessionClient';
90
93
  export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
91
94
  export { createSessionClientHost } from './session/sessionClientHost';
92
95
  export { createSessionClient } from './session/createSessionClient';
@@ -99,8 +102,8 @@ export { createWebAuthStateStore, createNativeAuthStateStore, createMemoryAuthSt
99
102
  export type { PersistedAuthState, AuthStateStore, NativeKeyValueStorage, } from './session/authStateStore';
100
103
  export { refreshPersistedSession, createAuthRefreshHandler, installAuthRefreshHandler, startTokenRefreshScheduler, TOKEN_REFRESH_LEAD_MS, } from './session/refresh';
101
104
  export type { RefreshDeps, TokenRefreshSchedulerHandle } from './session/refresh';
102
- export { runSessionColdBoot } from './boot/coldBootV2';
103
- export type { RunSessionColdBootOptions, SignedOutReason, DeviceBootSession, } from './boot/coldBootV2';
105
+ export { runSessionColdBoot } from './boot/sessionColdBoot';
106
+ export type { RunSessionColdBootOptions, SignedOutReason, DeviceBootSession, } from './boot/sessionColdBoot';
104
107
  export { packageInfo } from './constants/version';
105
108
  import { OxyServices } from './OxyServices';
106
109
  export default OxyServices;
@@ -477,6 +477,7 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
477
477
  passwordSignIn(identifier: string, password: string, options?: {
478
478
  deviceName?: string;
479
479
  deviceFingerprint?: string;
480
+ deviceId?: string;
480
481
  }): Promise<LoginResult>;
481
482
  /**
482
483
  * Complete a 2FA-gated sign-in started by {@link passwordSignIn}. Presents
@@ -490,6 +491,19 @@ export declare function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(B
490
491
  token?: string;
491
492
  backupCode?: string;
492
493
  deviceName?: string;
494
+ deviceId?: string;
495
+ }): Promise<LoginSessionResult>;
496
+ /**
497
+ * Exchange an OAuth authorization code (returned to the RP redirect URI
498
+ * after password sign-in at auth.oxy.so) for a device-first session.
499
+ * Public first-party clients use PKCE (`codeVerifier`); the access token is
500
+ * planted immediately on success.
501
+ */
502
+ exchangeOAuthCode(params: {
503
+ code: string;
504
+ clientId: string;
505
+ redirectUri: string;
506
+ codeVerifier: string;
493
507
  }): Promise<LoginSessionResult>;
494
508
  httpService: import("../HttpService").HttpService;
495
509
  cloudURL: string;
@@ -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 { type DeviceTokenMintResponse } from '@oxyhq/contracts';
16
+ import { type DeviceTokenMintResponse, type DeviceHubTicketIssueResponse, type DeviceHubTicketRedeemResponse } from '@oxyhq/contracts';
17
17
  import type { OxyServicesBase } from '../OxyServices.base';
18
18
  export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Base: T): {
19
19
  new (...args: any[]): {
@@ -33,6 +33,10 @@ export declare function OxyServicesDeviceBootMixin<T extends typeof OxyServicesB
33
33
  * @throws if the response does not match {@link deviceTokenMintResponseSchema}.
34
34
  */
35
35
  mintFromDeviceSecret(deviceId: string, deviceSecret: string): Promise<DeviceTokenMintResponse>;
36
+ /** Mint a one-time hub sync ticket (bearer required). */
37
+ issueHubTicket(returnOrigin: string): Promise<DeviceHubTicketIssueResponse>;
38
+ /** Redeem a hub sync ticket for a fresh device secret (public). */
39
+ redeemHubTicket(ticket: string, returnOrigin: string): Promise<DeviceHubTicketRedeemResponse>;
36
40
  httpService: import("../HttpService").HttpService;
37
41
  cloudURL: string;
38
42
  config: import("../OxyServices.base").OxyConfig;
@@ -24,3 +24,4 @@ export { createOxyCors } from './cors';
24
24
  export type { OxyCorsOptions } from './cors';
25
25
  export { verifySecret } from './verifySecret';
26
26
  export { registrableApex } from '../utils/registrableApex';
27
+ export { buildIdpHubOrigin, buildHubSyncUrl, isIdpHubOrigin, isOfficialWebOrigin, normalizeOfficialReturnOrigin, parseHubSyncReturnUrl, } from '../utils/officialOrigins';
@@ -4,12 +4,18 @@ export interface TokenTransport {
4
4
  /** Ensure this app holds a per-domain access token for state.activeAccountId (mint via the persisted refresh family / shared keychain). Best-effort. */
5
5
  ensureActiveToken(state: DeviceSessionState): Promise<void>;
6
6
  }
7
+ export interface DeviceCredential {
8
+ deviceId: string;
9
+ deviceSecret: string;
10
+ }
7
11
  export interface SessionClientHost {
8
12
  makeRequest<T>(method: 'GET' | 'POST', url: string, data?: unknown, options?: {
9
13
  cache?: boolean;
10
14
  }): Promise<T>;
11
15
  getBaseURL(): string;
12
16
  getAccessToken(): string | null;
17
+ /** Zero-cookie device credential for socket handshake when no bearer is planted yet. */
18
+ getDeviceCredential(): DeviceCredential | null;
13
19
  onTokensChanged(listener: (token: string | null) => void): () => void;
14
20
  setTokens(accessToken: string): void;
15
21
  getCurrentAccountId(): string | null;
@@ -97,6 +97,12 @@ export interface AccountDialogControllerOptions {
97
97
  onSignedIn?: (user: MinimalUserData) => void;
98
98
  /** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
99
99
  idpApex?: string;
100
+ /**
101
+ * Registered OAuth redirect URI for this RP (exact match against
102
+ * `Application.redirectUris`). When set, wins over `returnUrl` /
103
+ * `location.origin` normalization in {@link openPasswordAtOxyAuth}.
104
+ */
105
+ authRedirectUri?: string | null;
100
106
  /** QR device-flow poll interval in ms (default 3000). */
101
107
  pollIntervalMs?: number;
102
108
  /**
@@ -115,6 +121,7 @@ export declare class AccountDialogController {
115
121
  private readonly commitSession?;
116
122
  private readonly onSignedIn?;
117
123
  private readonly idpApex;
124
+ private readonly authRedirectUri;
118
125
  private readonly pollIntervalMs;
119
126
  private readonly openUrl?;
120
127
  private readonly listeners;
@@ -244,7 +251,8 @@ export declare class AccountDialogController {
244
251
  openPasswordAtOxyAuth(params?: {
245
252
  returnUrl?: string;
246
253
  state?: string;
247
- }): string;
254
+ redirectUri?: string;
255
+ }): Promise<string>;
248
256
  private scheduleNextPoll;
249
257
  private pollOnce;
250
258
  private claimAndComplete;
@@ -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
@@ -0,0 +1,20 @@
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 type { OxyServices } from '../OxyServices';
6
+ import type { AuthStateStore } from './authStateStore';
7
+ export interface SyncHubAfterSignInOptions {
8
+ /** Skip sync when false (OxyProvider hubSync prop). @default true */
9
+ enabled?: boolean;
10
+ }
11
+ /**
12
+ * After a successful sign-in on an official web app, mint a hub ticket and
13
+ * redirect to auth.oxy.so/sync so the IdP hub can redeem it and persist the
14
+ * shared device credential for silent OAuth restore on other origins.
15
+ *
16
+ * No-op on native, non-official origins, and when already on the IdP hub.
17
+ */
18
+ export declare function syncHubAfterSignIn(oxy: Pick<OxyServices, 'issueHubTicket'>, opts?: SyncHubAfterSignInOptions): Promise<boolean>;
19
+ /** Redeem a hub ticket on auth.oxy.so and persist credentials locally. */
20
+ export declare function redeemHubTicketOnHub(oxy: Pick<OxyServices, 'redeemHubTicket'>, store: AuthStateStore, ticket: string): Promise<boolean>;
@@ -1,5 +1,5 @@
1
1
  import type { OxyServices } from '../OxyServices';
2
- import type { SessionClientHost } from './SessionClient';
2
+ import type { DeviceCredential, SessionClientHost } from './SessionClient';
3
3
  /**
4
4
  * Thin `SessionClientHost` adapter over an `OxyServices` instance.
5
5
  *
@@ -14,4 +14,5 @@ import type { SessionClientHost } from './SessionClient';
14
14
  */
15
15
  export declare function createSessionClientHost(oxyServices: OxyServices): SessionClientHost & {
16
16
  setCurrentAccountId(id: string | null): void;
17
+ setDeviceCredential(credential: DeviceCredential | null): void;
17
18
  };
@@ -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
@@ -94,7 +94,7 @@ export interface RunColdBootOptions<S> {
94
94
  * fails to settle before the deadline, the runner abandons the await for that
95
95
  * step (reporting it via `onStepDeadline`) and CONTINUES to the next step,
96
96
  * each now racing against an already-expired deadline. This is deliberate:
97
- * the runner keeps iterating so the TERMINAL step (e.g. `coldBootV2`'s
97
+ * the runner keeps iterating so the TERMINAL step (e.g. `sessionColdBoot`'s
98
98
  * `bootstrap-hop`, whose `run()` performs its navigation side effect
99
99
  * synchronously before its first `await`) still gets to fire. A step that
100
100
  * has nothing to contribute after the deadline simply doesn't settle and is
@@ -42,6 +42,11 @@ export interface BuildOAuthAuthorizeUrlParams {
42
42
  state: string;
43
43
  /** The PKCE `codeChallenge` from {@link generatePkcePair}. */
44
44
  codeChallenge: string;
45
+ /**
46
+ * OAuth `prompt` parameter. Use `none` for silent cross-origin session restore
47
+ * (no UI when the IdP hub already has a session + grant).
48
+ */
49
+ prompt?: 'none' | 'login' | 'consent';
45
50
  }
46
51
  /**
47
52
  * Compute the PKCE S256 `code_challenge` for a given verifier:
@@ -72,3 +77,25 @@ export declare function generateOAuthState(): Promise<string>;
72
77
  * All values are percent-encoded by `URL.searchParams`.
73
78
  */
74
79
  export declare function buildOAuthAuthorizeUrl(params: BuildOAuthAuthorizeUrlParams): string;
80
+ /** `sessionStorage` key for the OAuth CSRF `state` across an authorize redirect. */
81
+ export declare const OXY_OAUTH_STATE_STORAGE_KEY = "oxy_oauth_state";
82
+ /** `sessionStorage` key for the PKCE `code_verifier` across an authorize redirect. */
83
+ export declare const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = "oxy_oauth_code_verifier";
84
+ /** `sessionStorage` key — at most one silent OAuth attempt per navigation. */
85
+ export declare const OXY_SILENT_OAUTH_ATTEMPTED_KEY = "oxy.silent_oauth_attempted";
86
+ /** `sessionStorage` key — blocks further cross-origin auto-restore in this tab. */
87
+ export declare const OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = "oxy.cross_origin_restore_attempted";
88
+ /**
89
+ * Normalize a redirect URI to its origin. Official Oxy apps register apex
90
+ * origins (`https://inbox.oxy.so`) — never path-qualified URLs.
91
+ */
92
+ export declare function normalizeOAuthRedirectUri(input: string): string;
93
+ /** Persist the OAuth handshake for a full-page redirect return (web only). */
94
+ export declare function persistOAuthHandshake(state: string, codeVerifier: string): boolean;
95
+ /** Read the persisted OAuth handshake, or `null` when absent. */
96
+ export declare function readOAuthHandshake(): {
97
+ state: string;
98
+ codeVerifier: string;
99
+ } | null;
100
+ /** Drop persisted OAuth handshake keys after a successful or aborted return. */
101
+ export declare function clearOAuthHandshake(): void;
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Official first-party web origin allowlist — shared by hub-ticket issuance,
3
+ * OAuth redirect validation, and cross-origin session restore.
4
+ */
5
+ export declare function buildIdpHubOrigin(): string;
6
+ /** Whether the current web origin is the central IdP hub (`auth.oxy.so`). */
7
+ export declare function isIdpHubOrigin(): boolean;
8
+ /** Whether an origin belongs to the official Oxy web ecosystem. */
9
+ export declare function isOfficialWebOrigin(origin: string): boolean;
10
+ /** Normalize and validate a return URL against official origins. Returns origin only. */
11
+ export declare function normalizeOfficialReturnOrigin(raw: string): string | null;
12
+ /** Validate a hub-sync return URL; returns the full normalized URL string. */
13
+ export declare function parseHubSyncReturnUrl(raw: string | null): string | null;
14
+ /** Build auth.oxy.so/sync redirect URL with a one-time hub ticket. */
15
+ export declare function buildHubSyncUrl(ticket: string, returnUrl?: string): string;
16
+ /** @deprecated Use {@link isOfficialWebOrigin}. */
17
+ export declare const isAllowedDeviceJoinOrigin: typeof isOfficialWebOrigin;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oxyhq/core",
3
- "version": "9.1.0",
3
+ "version": "9.2.0",
4
4
  "description": "OxyHQ SDK Foundation — API client, authentication, cryptographic identity, and shared utilities",
5
5
  "main": "dist/cjs/index.js",
6
6
  "module": "dist/esm/index.js",
@@ -94,7 +94,7 @@
94
94
  }
95
95
  },
96
96
  "dependencies": {
97
- "@oxyhq/contracts": "^0.13.0",
97
+ "@oxyhq/contracts": "^0.13.1",
98
98
  "@oxyhq/protocol": "^0.1.3",
99
99
  "bip39": "^3.1.0",
100
100
  "buffer": "^6.0.3",
@@ -9,7 +9,7 @@
9
9
  import type { OxyServices } from '../../OxyServices';
10
10
  import type { DeviceTokenMintResponse } from '@oxyhq/contracts';
11
11
  import type { SessionLoginResponse } from '../../models/session';
12
- import { runSessionColdBoot } from '../coldBootV2';
12
+ import { runSessionColdBoot } from '../sessionColdBoot';
13
13
  import { createMemoryAuthStateStore, type PersistedAuthState } from '../../session/authStateStore';
14
14
 
15
15
  interface OxyOverrides {
@@ -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
@@ -139,7 +139,7 @@ export async function runSessionColdBoot(
139
139
  // Transient (network / 5xx): keep the secret; a later attempt can succeed.
140
140
  logger.debug(
141
141
  'device-secret mint failed (transient) — keeping secret',
142
- { component: 'coldBootV2', method: 'device-secret-mint' },
142
+ { component: 'sessionColdBoot', method: 'device-secret-mint' },
143
143
  error,
144
144
  );
145
145
  return { kind: 'skip' };
package/src/index.ts CHANGED
@@ -523,14 +523,38 @@ export {
523
523
  generatePkcePair,
524
524
  DEFAULT_OAUTH_SCOPE,
525
525
  OXY_AUTHORIZE_URL,
526
+ OXY_OAUTH_STATE_STORAGE_KEY,
527
+ OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY,
528
+ OXY_SILENT_OAUTH_ATTEMPTED_KEY,
529
+ OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY,
530
+ normalizeOAuthRedirectUri,
531
+ persistOAuthHandshake,
532
+ readOAuthHandshake,
533
+ clearOAuthHandshake,
526
534
  } from './utils/oauthPkce';
527
535
  export type { PkcePair, BuildOAuthAuthorizeUrlParams } from './utils/oauthPkce';
528
536
 
537
+ export {
538
+ buildIdpHubOrigin,
539
+ buildHubSyncUrl,
540
+ isIdpHubOrigin,
541
+ isOfficialWebOrigin,
542
+ isAllowedDeviceJoinOrigin,
543
+ normalizeOfficialReturnOrigin,
544
+ parseHubSyncReturnUrl,
545
+ } from './utils/officialOrigins';
546
+
547
+ export {
548
+ syncHubAfterSignIn,
549
+ redeemHubTicketOnHub,
550
+ } from './session/hubSync';
551
+ export type { SyncHubAfterSignInOptions } from './session/hubSync';
552
+
529
553
  // ---------------------------------------------------------------------------
530
554
  // Session sync (device-scoped multi-account session client)
531
555
  // ---------------------------------------------------------------------------
532
556
  export { SessionClient } from './session/SessionClient';
533
- export type { TokenTransport, SessionClientHost, SessionClientOptions } from './session/SessionClient';
557
+ export type { TokenTransport, SessionClientHost, SessionClientOptions, DeviceCredential } from './session/SessionClient';
534
558
  // The injectable socket factory type: consumers that bundle socket.io-client
535
559
  // (services/auth-sdk) pass its `io` export as `socketFactory` so realtime sync
536
560
  // never relies on core's lazy dynamic import of a bare specifier.
@@ -609,12 +633,12 @@ export {
609
633
  } from './session/refresh';
610
634
  export type { RefreshDeps, TokenRefreshSchedulerHandle } from './session/refresh';
611
635
 
612
- export { runSessionColdBoot } from './boot/coldBootV2';
636
+ export { runSessionColdBoot } from './boot/sessionColdBoot';
613
637
  export type {
614
638
  RunSessionColdBootOptions,
615
639
  SignedOutReason,
616
640
  DeviceBootSession,
617
- } from './boot/coldBootV2';
641
+ } from './boot/sessionColdBoot';
618
642
 
619
643
  // API response contracts (request/response Zod schemas + inferred types) live in
620
644
  // `@oxyhq/contracts` — the single source of truth shared by the backend and every
@@ -1136,7 +1136,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1136
1136
  async passwordSignIn(
1137
1137
  identifier: string,
1138
1138
  password: string,
1139
- options: { deviceName?: string; deviceFingerprint?: string } = {},
1139
+ options: { deviceName?: string; deviceFingerprint?: string; deviceId?: string } = {},
1140
1140
  ): Promise<LoginResult> {
1141
1141
  try {
1142
1142
  const res = await this.makeRequest<unknown>('POST', '/auth/login', {
@@ -1144,6 +1144,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1144
1144
  password,
1145
1145
  deviceName: options.deviceName,
1146
1146
  deviceFingerprint: options.deviceFingerprint,
1147
+ ...(options.deviceId ? { deviceId: options.deviceId } : {}),
1147
1148
  }, { cache: false });
1148
1149
  const parsed = safeParseContract(loginResultSchema, res);
1149
1150
  if (!parsed) {
@@ -1170,6 +1171,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1170
1171
  token?: string;
1171
1172
  backupCode?: string;
1172
1173
  deviceName?: string;
1174
+ deviceId?: string;
1173
1175
  }): Promise<LoginSessionResult> {
1174
1176
  try {
1175
1177
  const res = await this.makeRequest<unknown>('POST', '/security/2fa/verify-login', {
@@ -1177,6 +1179,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1177
1179
  token: params.token,
1178
1180
  backupCode: params.backupCode,
1179
1181
  deviceName: params.deviceName,
1182
+ ...(params.deviceId ? { deviceId: params.deviceId } : {}),
1180
1183
  }, { cache: false });
1181
1184
  const parsed = safeParseContract(loginResultSchema, res);
1182
1185
  if (!parsed || 'twoFactorRequired' in parsed) {
@@ -1190,5 +1193,71 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1190
1193
  throw this.handleError(error);
1191
1194
  }
1192
1195
  }
1196
+
1197
+ /**
1198
+ * Exchange an OAuth authorization code (returned to the RP redirect URI
1199
+ * after password sign-in at auth.oxy.so) for a device-first session.
1200
+ * Public first-party clients use PKCE (`codeVerifier`); the access token is
1201
+ * planted immediately on success.
1202
+ */
1203
+ async exchangeOAuthCode(params: {
1204
+ code: string;
1205
+ clientId: string;
1206
+ redirectUri: string;
1207
+ codeVerifier: string;
1208
+ }): Promise<LoginSessionResult> {
1209
+ try {
1210
+ const res = await this.makeRequest<unknown>('POST', '/auth/oauth/token', {
1211
+ code: params.code,
1212
+ clientId: params.clientId,
1213
+ redirectUri: params.redirectUri,
1214
+ codeVerifier: params.codeVerifier,
1215
+ }, { cache: false });
1216
+ const payload =
1217
+ (res as { data?: Record<string, unknown> }).data ??
1218
+ (res as Record<string, unknown>);
1219
+ if (!payload || typeof payload !== 'object') {
1220
+ throw new Error('auth/oauth/token returned an unexpected response shape');
1221
+ }
1222
+ const record = payload as Record<string, unknown>;
1223
+ const accessToken = (record.access_token ?? record.accessToken) as string | undefined;
1224
+ const sessionId = (record.session_id ?? record.sessionId) as string | undefined;
1225
+ const deviceId = (record.deviceId ?? record.device_id) as string | undefined;
1226
+ const deviceSecret = (record.deviceSecret ?? record.device_secret) as string | undefined;
1227
+ const userRaw = record.user;
1228
+ if (!sessionId || !deviceId || !userRaw || typeof userRaw !== 'object') {
1229
+ throw new Error('auth/oauth/token returned an incomplete session payload');
1230
+ }
1231
+ const userObj = userRaw as Record<string, unknown>;
1232
+ const userId = userObj.id as string | undefined;
1233
+ if (!userId) {
1234
+ throw new Error('auth/oauth/token returned a session without user.id');
1235
+ }
1236
+ const expiresInSec =
1237
+ typeof record.expires_in === 'number'
1238
+ ? record.expires_in
1239
+ : typeof record.expiresIn === 'number'
1240
+ ? record.expiresIn
1241
+ : 15 * 60;
1242
+ const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
1243
+ if (accessToken) {
1244
+ this.setTokens(accessToken);
1245
+ }
1246
+ return {
1247
+ sessionId,
1248
+ deviceId,
1249
+ expiresAt,
1250
+ accessToken,
1251
+ deviceSecret,
1252
+ user: {
1253
+ id: userId,
1254
+ username: typeof userObj.username === 'string' ? userObj.username : undefined,
1255
+ avatar: typeof userObj.avatar === 'string' ? userObj.avatar : undefined,
1256
+ },
1257
+ };
1258
+ } catch (error) {
1259
+ throw this.handleError(error);
1260
+ }
1261
+ }
1193
1262
  };
1194
1263
  }
@@ -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
@@ -15,8 +15,12 @@
15
15
  */
16
16
  import {
17
17
  deviceTokenMintResponseSchema,
18
+ deviceHubTicketIssueResponseSchema,
19
+ deviceHubTicketRedeemResponseSchema,
18
20
  safeParseContract,
19
21
  type DeviceTokenMintResponse,
22
+ type DeviceHubTicketIssueResponse,
23
+ type DeviceHubTicketRedeemResponse,
20
24
  } from '@oxyhq/contracts';
21
25
  import type { OxyServicesBase } from '../OxyServices.base';
22
26
 
@@ -57,5 +61,46 @@ export function OxyServicesDeviceBootMixin<T extends typeof OxyServicesBase>(Bas
57
61
  throw this.handleError(error);
58
62
  }
59
63
  }
64
+
65
+ /** Mint a one-time hub sync ticket (bearer required). */
66
+ async issueHubTicket(returnOrigin: string): Promise<DeviceHubTicketIssueResponse> {
67
+ try {
68
+ const res = await this.makeRequest<unknown>(
69
+ 'POST',
70
+ '/session/device/hub-ticket',
71
+ { returnOrigin },
72
+ { cache: false },
73
+ );
74
+ const parsed = safeParseContract(deviceHubTicketIssueResponseSchema, res);
75
+ if (!parsed) {
76
+ throw new Error('session/device/hub-ticket returned an unexpected response shape');
77
+ }
78
+ return parsed;
79
+ } catch (error) {
80
+ throw this.handleError(error);
81
+ }
82
+ }
83
+
84
+ /** Redeem a hub sync ticket for a fresh device secret (public). */
85
+ async redeemHubTicket(
86
+ ticket: string,
87
+ returnOrigin: string,
88
+ ): Promise<DeviceHubTicketRedeemResponse> {
89
+ try {
90
+ const res = await this.makeRequest<unknown>(
91
+ 'POST',
92
+ '/session/device/redeem-ticket',
93
+ { ticket, returnOrigin },
94
+ { cache: false, skipAuth: true },
95
+ );
96
+ const parsed = safeParseContract(deviceHubTicketRedeemResponseSchema, res);
97
+ if (!parsed) {
98
+ throw new Error('session/device/redeem-ticket returned an unexpected response shape');
99
+ }
100
+ return parsed;
101
+ } catch (error) {
102
+ throw this.handleError(error);
103
+ }
104
+ }
60
105
  };
61
106
  }
@@ -69,3 +69,11 @@ export { verifySecret } from './verifySecret';
69
69
  // Pure host handling (no browser deps), so it is safe on the server subpath and
70
70
  // lets `@oxyhq/api` derive `auth.<apex>` without duplicating PSL logic.
71
71
  export { registrableApex } from '../utils/registrableApex';
72
+ export {
73
+ buildIdpHubOrigin,
74
+ buildHubSyncUrl,
75
+ isIdpHubOrigin,
76
+ isOfficialWebOrigin,
77
+ normalizeOfficialReturnOrigin,
78
+ parseHubSyncReturnUrl,
79
+ } from '../utils/officialOrigins';