@oxyhq/core 9.1.0 → 9.2.1

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 (76) hide show
  1. package/README.md +1 -1
  2. package/dist/cjs/.tsbuildinfo +1 -1
  3. package/dist/cjs/boot/{coldBootV2.js → sessionColdBoot.js} +15 -2
  4. package/dist/cjs/index.js +27 -6
  5. package/dist/cjs/mixins/OxyServices.accounts.js +3 -0
  6. package/dist/cjs/mixins/OxyServices.auth.js +61 -0
  7. package/dist/cjs/mixins/OxyServices.deviceBoot.js +29 -1
  8. package/dist/cjs/server/index.js +8 -1
  9. package/dist/cjs/session/SessionClient.js +57 -25
  10. package/dist/cjs/session/accountDialogController.js +21 -9
  11. package/dist/cjs/session/authStateStore.js +13 -7
  12. package/dist/cjs/session/hubSync.js +55 -0
  13. package/dist/cjs/session/sessionClientHost.js +5 -0
  14. package/dist/cjs/utils/coldBoot.js +1 -1
  15. package/dist/cjs/utils/oauthPkce.js +65 -1
  16. package/dist/cjs/utils/officialOrigins.js +128 -0
  17. package/dist/esm/.tsbuildinfo +1 -1
  18. package/dist/esm/boot/{coldBootV2.js → sessionColdBoot.js} +15 -2
  19. package/dist/esm/index.js +5 -2
  20. package/dist/esm/mixins/OxyServices.accounts.js +1 -0
  21. package/dist/esm/mixins/OxyServices.auth.js +61 -0
  22. package/dist/esm/mixins/OxyServices.deviceBoot.js +30 -2
  23. package/dist/esm/server/index.js +1 -0
  24. package/dist/esm/session/SessionClient.js +57 -25
  25. package/dist/esm/session/accountDialogController.js +21 -9
  26. package/dist/esm/session/authStateStore.js +13 -7
  27. package/dist/esm/session/hubSync.js +51 -0
  28. package/dist/esm/session/sessionClientHost.js +5 -0
  29. package/dist/esm/utils/coldBoot.js +1 -1
  30. package/dist/esm/utils/oauthPkce.js +60 -0
  31. package/dist/esm/utils/officialOrigins.js +119 -0
  32. package/dist/types/.tsbuildinfo +1 -1
  33. package/dist/types/boot/{coldBootV2.d.ts → sessionColdBoot.d.ts} +1 -1
  34. package/dist/types/index.d.ts +9 -5
  35. package/dist/types/mixins/OxyServices.accounts.d.ts +8 -0
  36. package/dist/types/mixins/OxyServices.auth.d.ts +15 -0
  37. package/dist/types/mixins/OxyServices.deviceBoot.d.ts +6 -2
  38. package/dist/types/models/interfaces.d.ts +3 -1
  39. package/dist/types/models/session.d.ts +6 -0
  40. package/dist/types/server/index.d.ts +1 -0
  41. package/dist/types/session/SessionClient.d.ts +6 -0
  42. package/dist/types/session/accountDialogController.d.ts +9 -1
  43. package/dist/types/session/authStateStore.d.ts +1 -1
  44. package/dist/types/session/hubSync.d.ts +20 -0
  45. package/dist/types/session/sessionClientHost.d.ts +2 -1
  46. package/dist/types/utils/coldBoot.d.ts +2 -2
  47. package/dist/types/utils/oauthPkce.d.ts +27 -0
  48. package/dist/types/utils/officialOrigins.d.ts +17 -0
  49. package/package.json +2 -2
  50. package/src/boot/__tests__/{coldBootV2.test.ts → sessionColdBoot.test.ts} +21 -1
  51. package/src/boot/{coldBootV2.ts → sessionColdBoot.ts} +15 -2
  52. package/src/index.ts +30 -3
  53. package/src/mixins/OxyServices.accounts.ts +9 -0
  54. package/src/mixins/OxyServices.auth.ts +72 -1
  55. package/src/mixins/OxyServices.deviceBoot.ts +46 -1
  56. package/src/models/interfaces.ts +3 -1
  57. package/src/models/session.ts +6 -0
  58. package/src/server/index.ts +8 -0
  59. package/src/session/SessionClient.ts +68 -26
  60. package/src/session/__tests__/SessionClient.additive.test.ts +1 -0
  61. package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +1 -0
  62. package/src/session/__tests__/SessionClient.diagnostics.test.ts +1 -0
  63. package/src/session/__tests__/SessionClient.rest.test.ts +1 -0
  64. package/src/session/__tests__/SessionClient.socket.test.ts +1 -0
  65. package/src/session/__tests__/SessionClient.socketFactory.test.ts +1 -0
  66. package/src/session/__tests__/SessionClient.state.test.ts +1 -0
  67. package/src/session/__tests__/accountDialogController.test.ts +46 -7
  68. package/src/session/__tests__/sessionIntegration.test.ts +7 -0
  69. package/src/session/accountDialogController.ts +45 -11
  70. package/src/session/authStateStore.ts +18 -9
  71. package/src/session/hubSync.ts +79 -0
  72. package/src/session/sessionClientHost.ts +10 -2
  73. package/src/utils/__tests__/officialOrigins.test.ts +74 -0
  74. package/src/utils/coldBoot.ts +2 -2
  75. package/src/utils/oauthPkce.ts +71 -0
  76. package/src/utils/officialOrigins.ts +124 -0
@@ -664,6 +664,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
664
664
  deviceId: string;
665
665
  expiresAt: string;
666
666
  user: User;
667
+ deviceSecret?: string;
667
668
  }> {
668
669
  try {
669
670
  const res = await this.makeRequest<{
@@ -672,6 +673,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
672
673
  deviceId: string;
673
674
  expiresAt: string;
674
675
  user: User;
676
+ deviceSecret?: string;
675
677
  }>(
676
678
  'POST',
677
679
  '/auth/session/claim',
@@ -1136,7 +1138,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1136
1138
  async passwordSignIn(
1137
1139
  identifier: string,
1138
1140
  password: string,
1139
- options: { deviceName?: string; deviceFingerprint?: string } = {},
1141
+ options: { deviceName?: string; deviceFingerprint?: string; deviceId?: string } = {},
1140
1142
  ): Promise<LoginResult> {
1141
1143
  try {
1142
1144
  const res = await this.makeRequest<unknown>('POST', '/auth/login', {
@@ -1144,6 +1146,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1144
1146
  password,
1145
1147
  deviceName: options.deviceName,
1146
1148
  deviceFingerprint: options.deviceFingerprint,
1149
+ ...(options.deviceId ? { deviceId: options.deviceId } : {}),
1147
1150
  }, { cache: false });
1148
1151
  const parsed = safeParseContract(loginResultSchema, res);
1149
1152
  if (!parsed) {
@@ -1170,6 +1173,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1170
1173
  token?: string;
1171
1174
  backupCode?: string;
1172
1175
  deviceName?: string;
1176
+ deviceId?: string;
1173
1177
  }): Promise<LoginSessionResult> {
1174
1178
  try {
1175
1179
  const res = await this.makeRequest<unknown>('POST', '/security/2fa/verify-login', {
@@ -1177,6 +1181,7 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1177
1181
  token: params.token,
1178
1182
  backupCode: params.backupCode,
1179
1183
  deviceName: params.deviceName,
1184
+ ...(params.deviceId ? { deviceId: params.deviceId } : {}),
1180
1185
  }, { cache: false });
1181
1186
  const parsed = safeParseContract(loginResultSchema, res);
1182
1187
  if (!parsed || 'twoFactorRequired' in parsed) {
@@ -1190,5 +1195,71 @@ export function OxyServicesAuthMixin<T extends typeof OxyServicesBase>(Base: T)
1190
1195
  throw this.handleError(error);
1191
1196
  }
1192
1197
  }
1198
+
1199
+ /**
1200
+ * Exchange an OAuth authorization code (returned to the RP redirect URI
1201
+ * after password sign-in at auth.oxy.so) for a device-first session.
1202
+ * Public first-party clients use PKCE (`codeVerifier`); the access token is
1203
+ * planted immediately on success.
1204
+ */
1205
+ async exchangeOAuthCode(params: {
1206
+ code: string;
1207
+ clientId: string;
1208
+ redirectUri: string;
1209
+ codeVerifier: string;
1210
+ }): Promise<LoginSessionResult> {
1211
+ try {
1212
+ const res = await this.makeRequest<unknown>('POST', '/auth/oauth/token', {
1213
+ code: params.code,
1214
+ clientId: params.clientId,
1215
+ redirectUri: params.redirectUri,
1216
+ codeVerifier: params.codeVerifier,
1217
+ }, { cache: false });
1218
+ const payload =
1219
+ (res as { data?: Record<string, unknown> }).data ??
1220
+ (res as Record<string, unknown>);
1221
+ if (!payload || typeof payload !== 'object') {
1222
+ throw new Error('auth/oauth/token returned an unexpected response shape');
1223
+ }
1224
+ const record = payload as Record<string, unknown>;
1225
+ const accessToken = (record.access_token ?? record.accessToken) as string | undefined;
1226
+ const sessionId = (record.session_id ?? record.sessionId) as string | undefined;
1227
+ const deviceId = (record.deviceId ?? record.device_id) as string | undefined;
1228
+ const deviceSecret = (record.deviceSecret ?? record.device_secret) as string | undefined;
1229
+ const userRaw = record.user;
1230
+ if (!sessionId || !deviceId || !userRaw || typeof userRaw !== 'object') {
1231
+ throw new Error('auth/oauth/token returned an incomplete session payload');
1232
+ }
1233
+ const userObj = userRaw as Record<string, unknown>;
1234
+ const userId = userObj.id as string | undefined;
1235
+ if (!userId) {
1236
+ throw new Error('auth/oauth/token returned a session without user.id');
1237
+ }
1238
+ const expiresInSec =
1239
+ typeof record.expires_in === 'number'
1240
+ ? record.expires_in
1241
+ : typeof record.expiresIn === 'number'
1242
+ ? record.expiresIn
1243
+ : 15 * 60;
1244
+ const expiresAt = new Date(Date.now() + expiresInSec * 1000).toISOString();
1245
+ if (accessToken) {
1246
+ this.setTokens(accessToken);
1247
+ }
1248
+ return {
1249
+ sessionId,
1250
+ deviceId,
1251
+ expiresAt,
1252
+ accessToken,
1253
+ deviceSecret,
1254
+ user: {
1255
+ id: userId,
1256
+ username: typeof userObj.username === 'string' ? userObj.username : undefined,
1257
+ avatar: typeof userObj.avatar === 'string' ? userObj.avatar : undefined,
1258
+ },
1259
+ };
1260
+ } catch (error) {
1261
+ throw this.handleError(error);
1262
+ }
1263
+ }
1193
1264
  };
1194
1265
  }
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import type { UserNameResponse } from '@oxyhq/contracts';
1
+ import type { OrganizationCategory, UserNameResponse } from '@oxyhq/contracts';
2
2
 
3
3
  export interface OxyConfig {
4
4
  baseURL: string;
@@ -142,6 +142,8 @@ export interface User {
142
142
  // Managed account fields
143
143
  isManagedAccount?: boolean;
144
144
  managedBy?: string;
145
+ /** Real-estate taxonomy when this user is a `kind: 'organization'` account. */
146
+ organizationCategory?: OrganizationCategory;
145
147
  // User-controlled notification preferences. All channels default to on; users
146
148
  // opt out per-channel. Updated via `PUT /users/me`.
147
149
  notificationPreferences?: NotificationPreferences;
@@ -35,4 +35,10 @@ export interface SessionLoginResponse {
35
35
  user: MinimalUserData;
36
36
  /** JWT access token for API authentication */
37
37
  accessToken?: string;
38
+ /**
39
+ * Rotating zero-cookie device credential minted on sign-in / claim. Persisted
40
+ * first-party alongside `deviceId` so cold boot can re-mint via
41
+ * `POST /session/device/token`.
42
+ */
43
+ deviceSecret?: string;
38
44
  }
@@ -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';
@@ -13,10 +13,17 @@ export interface TokenTransport {
13
13
  ensureActiveToken(state: DeviceSessionState): Promise<void>;
14
14
  }
15
15
 
16
+ export interface DeviceCredential {
17
+ deviceId: string;
18
+ deviceSecret: string;
19
+ }
20
+
16
21
  export interface SessionClientHost {
17
22
  makeRequest<T>(method: 'GET' | 'POST', url: string, data?: unknown, options?: { cache?: boolean }): Promise<T>;
18
23
  getBaseURL(): string;
19
24
  getAccessToken(): string | null;
25
+ /** Zero-cookie device credential for socket handshake when no bearer is planted yet. */
26
+ getDeviceCredential(): DeviceCredential | null;
20
27
  onTokensChanged(listener: (token: string | null) => void): () => void;
21
28
  setTokens(accessToken: string): void;
22
29
  getCurrentAccountId(): string | null;
@@ -123,20 +130,33 @@ export class SessionClient {
123
130
  return false;
124
131
  }
125
132
  this.state = next;
126
- this.notify();
127
- if (this.options.transport) {
128
- void this.options.transport.ensureActiveToken(next).catch((error) => {
133
+ const transport = this.options.transport;
134
+ const needsMintBeforeNotify =
135
+ transport != null && next.accounts.length > 0 && !this.host.getAccessToken();
136
+
137
+ const finishApply = (): void => {
138
+ this.notify();
139
+ if (next.accounts.length === 0 && this.options.onUnauthenticated) {
140
+ try {
141
+ this.options.onUnauthenticated();
142
+ } catch (error) {
143
+ logger.error('[SessionClient] onUnauthenticated threw', error);
144
+ }
145
+ }
146
+ };
147
+
148
+ if (needsMintBeforeNotify) {
149
+ void transport.ensureActiveToken(next).then(finishApply).catch((error) => {
129
150
  logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
151
+ finishApply();
130
152
  });
131
- }
132
- // A device signout-all leaves zero accounts — tell the provider to clear
133
- // the persisted store so a reload does not try to restore a dead session.
134
- if (next.accounts.length === 0 && this.options.onUnauthenticated) {
135
- try {
136
- this.options.onUnauthenticated();
137
- } catch (error) {
138
- logger.error('[SessionClient] onUnauthenticated threw', error);
153
+ } else {
154
+ if (transport) {
155
+ void transport.ensureActiveToken(next).catch((error) => {
156
+ logger.warn('[SessionClient] ensureActiveToken failed', { component: 'SessionClient' }, error);
157
+ });
139
158
  }
159
+ finishApply();
140
160
  }
141
161
  return true;
142
162
  }
@@ -222,18 +242,23 @@ export class SessionClient {
222
242
  this.started = true;
223
243
  this.tokenUnsub = this.host.onTokensChanged((token) => {
224
244
  // A rotated/fresh bearer landed — reconnect a dropped socket so its
225
- // handshake re-runs with the current token. Sign-out (null token) is
226
- // handled by the consumer calling `stop()`.
227
- if (!token || !this.socket) return;
228
- if (!this.socket.connected) {
245
+ // handshake re-runs with the current token. Sign-out (null token) keeps
246
+ // the device-scoped socket when a device credential is available.
247
+ if (!this.socket) return;
248
+ if (token) {
249
+ if (!this.socket.connected) {
250
+ this.socket.connect();
251
+ }
252
+ return;
253
+ }
254
+ const cred = this.host.getDeviceCredential();
255
+ if (cred && !this.socket.connected) {
229
256
  this.socket.connect();
230
257
  }
231
258
  });
232
259
  this.openBroadcastChannel();
233
- // `bootstrap` (`GET /session/device/state`) is bearer-authenticated. A
234
- // signed-out client opens no socket and runs no bootstrap; a bootstrap
235
- // failure is non-fatal — the socket still connects so realtime sync
236
- // survives a transient state-fetch error.
260
+ // Device-scoped socket: bearer when authenticated, else deviceId+deviceSecret so
261
+ // signed-out tabs still receive `session_state` and can mint on change.
237
262
  if (this.host.getAccessToken()) {
238
263
  try {
239
264
  await this.bootstrap();
@@ -275,24 +300,41 @@ export class SessionClient {
275
300
  return;
276
301
  }
277
302
  if (!this.started) return; // stopped while the dynamic import was in flight
278
- // Sockets are BEARER-ONLY: the server rejects any handshake without a valid
279
- // bearer, so a signed-out client never opens a socket.
280
- if (!this.host.getAccessToken()) return;
303
+
304
+ const token = this.host.getAccessToken();
305
+ const deviceCredential = this.host.getDeviceCredential();
306
+ if (!token && !deviceCredential) return;
281
307
 
282
308
  const socket = io(this.host.getBaseURL(), {
283
309
  transports: ['websocket'],
284
310
  autoConnect: true,
285
- auth: (cb: (data: { token: string }) => void) => {
286
- cb({ token: this.host.getAccessToken() ?? '' });
311
+ reconnection: true,
312
+ reconnectionAttempts: Infinity,
313
+ reconnectionDelay: 1000,
314
+ reconnectionDelayMax: 10000,
315
+ auth: (cb: (data: Record<string, string>) => void) => {
316
+ const bearer = this.host.getAccessToken();
317
+ if (bearer) {
318
+ cb({ token: bearer });
319
+ return;
320
+ }
321
+ const cred = this.host.getDeviceCredential();
322
+ if (cred) {
323
+ cb({ deviceId: cred.deviceId, deviceSecret: cred.deviceSecret });
324
+ return;
325
+ }
326
+ cb({ token: '' });
287
327
  },
288
328
  });
289
329
  socket.on('session_state', (payload: unknown) => {
290
330
  const applied = this.applyState(payload);
291
331
  if (!applied) return;
292
332
  // A push changed the active account on another device/tab — re-fetch state
293
- // to plant the access token for the newly-active account.
333
+ // to plant the access token for the newly-active account. When this tab is
334
+ // still signed out, applyState mints via ensureActiveToken first; bootstrap
335
+ // requires a bearer and must not run until then.
294
336
  const active = this.state?.activeAccountId ?? null;
295
- if (active && active !== this.host.getCurrentAccountId()) {
337
+ if (active && active !== this.host.getCurrentAccountId() && this.host.getAccessToken()) {
296
338
  void this.bootstrap().catch((error) => {
297
339
  logger.warn('[SessionClient] post-push token fetch failed', { component: 'SessionClient' }, error);
298
340
  });
@@ -16,6 +16,7 @@ function makeHost(makeRequest: jest.Mock, currentAccountId: string | null = null
16
16
  makeRequest,
17
17
  getBaseURL: () => 'http://test.invalid',
18
18
  getAccessToken: () => 't',
19
+ getDeviceCredential: () => null,
19
20
  onTokensChanged: () => () => undefined,
20
21
  setTokens: jest.fn(),
21
22
  getCurrentAccountId: () => currentAccountId,
@@ -55,6 +55,7 @@ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
55
55
  makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
56
56
  getBaseURL: () => 'http://test.invalid',
57
57
  getAccessToken: () => 'tok',
58
+ getDeviceCredential: () => null,
58
59
  onTokensChanged: () => () => undefined,
59
60
  setTokens: jest.fn(),
60
61
  getCurrentAccountId: () => 'a1',
@@ -11,6 +11,7 @@ function makeHost(makeRequest: jest.Mock): SessionClientHost {
11
11
  makeRequest,
12
12
  getBaseURL: () => 'http://test.invalid',
13
13
  getAccessToken: () => 't',
14
+ getDeviceCredential: () => null,
14
15
  onTokensChanged: () => () => undefined,
15
16
  setTokens: jest.fn(),
16
17
  getCurrentAccountId: () => null,
@@ -10,6 +10,7 @@ function makeHost(makeRequest: jest.Mock): SessionClientHost {
10
10
  makeRequest,
11
11
  getBaseURL: () => 'http://test.invalid',
12
12
  getAccessToken: () => 't',
13
+ getDeviceCredential: () => null,
13
14
  onTokensChanged: () => () => undefined,
14
15
  setTokens: jest.fn(),
15
16
  getCurrentAccountId: () => null,
@@ -30,6 +30,7 @@ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
30
30
  makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
31
31
  getBaseURL: () => 'http://test.invalid',
32
32
  getAccessToken: () => 'tok',
33
+ getDeviceCredential: () => null,
33
34
  onTokensChanged: () => () => undefined,
34
35
  setTokens: jest.fn(),
35
36
  getCurrentAccountId: () => 'a1',
@@ -32,6 +32,7 @@ function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
32
32
  makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
33
33
  getBaseURL: () => 'http://test.invalid',
34
34
  getAccessToken: () => 'tok',
35
+ getDeviceCredential: () => null,
35
36
  onTokensChanged: () => () => undefined,
36
37
  setTokens: jest.fn(),
37
38
  getCurrentAccountId: () => 'a1',
@@ -6,6 +6,7 @@ function makeHost(): SessionClientHost {
6
6
  makeRequest: jest.fn(),
7
7
  getBaseURL: () => 'http://test.invalid',
8
8
  getAccessToken: () => 't',
9
+ getDeviceCredential: () => null,
9
10
  onTokensChanged: () => () => undefined,
10
11
  setTokens: jest.fn(),
11
12
  getCurrentAccountId: () => null,
@@ -22,6 +22,7 @@ function host(): SessionClientHost {
22
22
  makeRequest: jest.fn(),
23
23
  getBaseURL: () => 'http://test.invalid',
24
24
  getAccessToken: () => 'token',
25
+ getDeviceCredential: () => null,
25
26
  onTokensChanged: () => () => undefined,
26
27
  setTokens: jest.fn(),
27
28
  getCurrentAccountId: () => null,
@@ -460,6 +461,7 @@ describe('AccountDialogController — sign in with Oxy', () => {
460
461
  accessToken: 'access-1',
461
462
  sessionId: 'sess-1',
462
463
  deviceId: 'device-1',
464
+ deviceSecret: 'claimed-secret',
463
465
  expiresAt: '2030-01-01T00:00:00Z',
464
466
  user: user('a1'),
465
467
  });
@@ -472,7 +474,13 @@ describe('AccountDialogController — sign in with Oxy', () => {
472
474
 
473
475
  await jest.advanceTimersByTimeAsync(1000); // second poll → authorized → claim
474
476
  expect(oxy.claimSessionByToken).toHaveBeenCalledWith('secret-tok');
475
- expect(commitSession).toHaveBeenCalledWith(expect.objectContaining({ sessionId: 'sess-1', accessToken: 'access-1' }));
477
+ expect(commitSession).toHaveBeenCalledWith(
478
+ expect.objectContaining({
479
+ sessionId: 'sess-1',
480
+ accessToken: 'access-1',
481
+ deviceSecret: 'claimed-secret',
482
+ }),
483
+ );
476
484
  expect(onSignedIn).toHaveBeenCalledWith(expect.objectContaining({ id: 'a1' }));
477
485
  expect(controller.getSnapshot().view).toBe('accounts');
478
486
  } finally {
@@ -533,7 +541,23 @@ describe('AccountDialogController — sign in with Oxy', () => {
533
541
  });
534
542
 
535
543
  describe('AccountDialogController — openPasswordAtOxyAuth', () => {
536
- it('builds the IdP sign-in URL with redirect_uri + client_id and invokes openUrl', () => {
544
+ beforeEach(() => {
545
+ const store = new Map<string, string>();
546
+ Object.defineProperty(globalThis, 'sessionStorage', {
547
+ value: {
548
+ getItem: (key: string) => store.get(key) ?? null,
549
+ setItem: (key: string, value: string) => {
550
+ store.set(key, value);
551
+ },
552
+ removeItem: (key: string) => {
553
+ store.delete(key);
554
+ },
555
+ },
556
+ configurable: true,
557
+ });
558
+ });
559
+
560
+ it('builds the IdP sign-in URL with redirect_uri + client_id and invokes openUrl', async () => {
537
561
  const oxy = makeOxy();
538
562
  const sc = new TestSessionClient(host());
539
563
  const openUrl = jest.fn();
@@ -544,17 +568,32 @@ describe('AccountDialogController — openPasswordAtOxyAuth', () => {
544
568
  openUrl,
545
569
  });
546
570
 
547
- const url = controller.openPasswordAtOxyAuth({ returnUrl: 'https://mention.earth/', state: 'xyz' });
571
+ const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://mention.earth/dashboard' });
548
572
  const parsed = new URL(url);
549
573
  expect(parsed.origin).toBe('https://auth.oxy.so');
550
574
  expect(parsed.pathname).toBe('/login');
551
- expect(parsed.searchParams.get('redirect_uri')).toBe('https://mention.earth/');
575
+ expect(parsed.searchParams.get('redirect_uri')).toBe('https://mention.earth');
552
576
  expect(parsed.searchParams.get('client_id')).toBe('oxy_dk_test');
553
- expect(parsed.searchParams.get('state')).toBe('xyz');
577
+ expect(parsed.searchParams.get('state')).toBeTruthy();
578
+ expect(parsed.searchParams.get('code_challenge')).toBeTruthy();
579
+ expect(parsed.searchParams.get('code_challenge_method')).toBe('S256');
554
580
  expect(openUrl).toHaveBeenCalledWith(url);
555
581
  });
556
582
 
557
- it('honors an idpApex override', () => {
583
+ it('honors authRedirectUri over returnUrl', async () => {
584
+ const oxy = makeOxy();
585
+ const sc = new TestSessionClient(host());
586
+ const controller = new AccountDialogController({
587
+ oxyServices: oxy as unknown as OxyServices,
588
+ sessionClient: sc,
589
+ authRedirectUri: 'https://inbox.oxy.so',
590
+ });
591
+
592
+ const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://inbox.oxy.so/mail' });
593
+ expect(new URL(url).searchParams.get('redirect_uri')).toBe('https://inbox.oxy.so');
594
+ });
595
+
596
+ it('honors an idpApex override', async () => {
558
597
  const oxy = makeOxy();
559
598
  const sc = new TestSessionClient(host());
560
599
  const controller = new AccountDialogController({
@@ -562,7 +601,7 @@ describe('AccountDialogController — openPasswordAtOxyAuth', () => {
562
601
  sessionClient: sc,
563
602
  idpApex: 'alia.onl',
564
603
  });
565
- const url = controller.openPasswordAtOxyAuth({ returnUrl: 'https://alia.onl/' });
604
+ const url = await controller.openPasswordAtOxyAuth({ returnUrl: 'https://alia.onl/' });
566
605
  expect(new URL(url).origin).toBe('https://auth.alia.onl');
567
606
  });
568
607
  });
@@ -44,6 +44,13 @@ describe('createSessionClientHost', () => {
44
44
  expect(host.getCurrentAccountId()).toBe('u1');
45
45
  });
46
46
 
47
+ test('setDeviceCredential reflects on getDeviceCredential', () => {
48
+ const host = createSessionClientHost(fakeOxy() as never);
49
+ expect(host.getDeviceCredential()).toBeNull();
50
+ host.setDeviceCredential({ deviceId: 'd1', deviceSecret: 's1' });
51
+ expect(host.getDeviceCredential()).toEqual({ deviceId: 'd1', deviceSecret: 's1' });
52
+ });
53
+
47
54
  test('onTokensChanged forwards to oxyServices and unsubscribes', () => {
48
55
  const oxy = fakeOxy();
49
56
  const host = createSessionClientHost(oxy as never);
@@ -31,6 +31,12 @@ import type { SessionLoginResponse, MinimalUserData } from '../models/session';
31
31
  import type { User } from '../models/interfaces';
32
32
  import { logger } from '../utils/loggerUtils';
33
33
  import { CENTRAL_IDP_APEX } from '../utils/authWebUrl';
34
+ import {
35
+ generateOAuthState,
36
+ generatePkcePair,
37
+ normalizeOAuthRedirectUri,
38
+ persistOAuthHandshake,
39
+ } from '../utils/oauthPkce';
34
40
  import { SessionClient } from './SessionClient';
35
41
  import {
36
42
  projectSwitchableAccounts,
@@ -111,6 +117,12 @@ export interface AccountDialogControllerOptions {
111
117
  onSignedIn?: (user: MinimalUserData) => void;
112
118
  /** Central IdP apex for `openPasswordAtOxyAuth` (defaults to `CENTRAL_IDP_APEX`). */
113
119
  idpApex?: string;
120
+ /**
121
+ * Registered OAuth redirect URI for this RP (exact match against
122
+ * `Application.redirectUris`). When set, wins over `returnUrl` /
123
+ * `location.origin` normalization in {@link openPasswordAtOxyAuth}.
124
+ */
125
+ authRedirectUri?: string | null;
114
126
  /** QR device-flow poll interval in ms (default 3000). */
115
127
  pollIntervalMs?: number;
116
128
  /**
@@ -145,6 +157,7 @@ export class AccountDialogController {
145
157
  private readonly commitSession?: (session: SessionLoginResponse) => Promise<void>;
146
158
  private readonly onSignedIn?: (user: MinimalUserData) => void;
147
159
  private readonly idpApex: string;
160
+ private readonly authRedirectUri: string | null;
148
161
  private readonly pollIntervalMs: number;
149
162
  private readonly openUrl?: (url: string) => void;
150
163
 
@@ -181,6 +194,7 @@ export class AccountDialogController {
181
194
  this.commitSession = options.commitSession;
182
195
  this.onSignedIn = options.onSignedIn;
183
196
  this.idpApex = options.idpApex ?? CENTRAL_IDP_APEX;
197
+ this.authRedirectUri = options.authRedirectUri ?? null;
184
198
  this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
185
199
  this.openUrl = options.openUrl;
186
200
  this.snapshot = this.computeSnapshot();
@@ -548,18 +562,30 @@ export class AccountDialogController {
548
562
  * @param params.state - Optional opaque state echoed back on return.
549
563
  * @returns The absolute auth.oxy.so sign-in URL.
550
564
  */
551
- openPasswordAtOxyAuth(params: { returnUrl?: string; state?: string } = {}): string {
565
+ async openPasswordAtOxyAuth(
566
+ params: { returnUrl?: string; state?: string; redirectUri?: string } = {},
567
+ ): Promise<string> {
552
568
  const base = `https://auth.${this.idpApex}`;
553
569
  const url = new URL('/login', base);
554
- const returnUrl = params.returnUrl ?? currentLocationHref();
555
- if (returnUrl) {
556
- url.searchParams.set('redirect_uri', returnUrl);
570
+ const rawRedirect =
571
+ params.redirectUri ??
572
+ this.authRedirectUri ??
573
+ params.returnUrl ??
574
+ currentLocationOrigin();
575
+ const redirectUri = rawRedirect ? normalizeOAuthRedirectUri(rawRedirect) : '';
576
+ if (redirectUri) {
577
+ url.searchParams.set('redirect_uri', redirectUri);
557
578
  }
558
579
  if (this.clientId) {
559
580
  url.searchParams.set('client_id', this.clientId);
560
581
  }
561
- if (params.state) {
562
- url.searchParams.set('state', params.state);
582
+ const state = params.state ?? (await generateOAuthState());
583
+ url.searchParams.set('state', state);
584
+ const { codeChallenge, codeVerifier } = await generatePkcePair();
585
+ url.searchParams.set('code_challenge', codeChallenge);
586
+ url.searchParams.set('code_challenge_method', 'S256');
587
+ if (!persistOAuthHandshake(state, codeVerifier)) {
588
+ throw new Error('Could not persist OAuth handshake for password sign-in');
563
589
  }
564
590
  const href = url.toString();
565
591
  this.openUrl?.(href);
@@ -612,7 +638,14 @@ export class AccountDialogController {
612
638
 
613
639
  private async claimAndComplete(sessionId: string, sessionToken: string): Promise<void> {
614
640
  this.setSignIn({ ...this.signIn, phase: 'authorized' });
615
- let claimed: { accessToken: string; sessionId: string; deviceId: string; expiresAt: string; user: User };
641
+ let claimed: {
642
+ accessToken: string;
643
+ sessionId: string;
644
+ deviceId: string;
645
+ expiresAt: string;
646
+ user: User;
647
+ deviceSecret?: string;
648
+ };
616
649
  try {
617
650
  claimed = await this.oxyServices.claimSessionByToken(sessionToken);
618
651
  } catch (error) {
@@ -640,6 +673,7 @@ export class AccountDialogController {
640
673
  expiresAt: claimed.expiresAt ?? '',
641
674
  user: minimalUser,
642
675
  accessToken: claimed.accessToken,
676
+ ...(claimed.deviceSecret ? { deviceSecret: claimed.deviceSecret } : {}),
643
677
  },
644
678
  minimalUser,
645
679
  );
@@ -748,8 +782,8 @@ export function createAccountDialogController(
748
782
  // Local helpers
749
783
  // ---------------------------------------------------------------------------
750
784
 
751
- /** Current document URL on web; empty string where `location` is absent (native/SSR). */
752
- function currentLocationHref(): string {
753
- const location = (globalThis as { location?: { href?: string } }).location;
754
- return typeof location?.href === 'string' ? location.href : '';
785
+ /** Current document origin on web; empty string where `location` is absent (native/SSR). */
786
+ function currentLocationOrigin(): string {
787
+ const location = (globalThis as { location?: { origin?: string } }).location;
788
+ return typeof location?.origin === 'string' ? location.origin : '';
755
789
  }