@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.
- package/README.md +1 -1
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/boot/{coldBootV2.js → sessionColdBoot.js} +15 -2
- package/dist/cjs/index.js +27 -6
- package/dist/cjs/mixins/OxyServices.accounts.js +3 -0
- package/dist/cjs/mixins/OxyServices.auth.js +61 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +29 -1
- package/dist/cjs/server/index.js +8 -1
- package/dist/cjs/session/SessionClient.js +57 -25
- package/dist/cjs/session/accountDialogController.js +21 -9
- package/dist/cjs/session/authStateStore.js +13 -7
- package/dist/cjs/session/hubSync.js +55 -0
- package/dist/cjs/session/sessionClientHost.js +5 -0
- package/dist/cjs/utils/coldBoot.js +1 -1
- package/dist/cjs/utils/oauthPkce.js +65 -1
- package/dist/cjs/utils/officialOrigins.js +128 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/boot/{coldBootV2.js → sessionColdBoot.js} +15 -2
- package/dist/esm/index.js +5 -2
- package/dist/esm/mixins/OxyServices.accounts.js +1 -0
- package/dist/esm/mixins/OxyServices.auth.js +61 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +30 -2
- package/dist/esm/server/index.js +1 -0
- package/dist/esm/session/SessionClient.js +57 -25
- package/dist/esm/session/accountDialogController.js +21 -9
- package/dist/esm/session/authStateStore.js +13 -7
- package/dist/esm/session/hubSync.js +51 -0
- package/dist/esm/session/sessionClientHost.js +5 -0
- package/dist/esm/utils/coldBoot.js +1 -1
- package/dist/esm/utils/oauthPkce.js +60 -0
- package/dist/esm/utils/officialOrigins.js +119 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/boot/{coldBootV2.d.ts → sessionColdBoot.d.ts} +1 -1
- package/dist/types/index.d.ts +9 -5
- package/dist/types/mixins/OxyServices.accounts.d.ts +8 -0
- package/dist/types/mixins/OxyServices.auth.d.ts +15 -0
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +6 -2
- package/dist/types/models/interfaces.d.ts +3 -1
- package/dist/types/models/session.d.ts +6 -0
- package/dist/types/server/index.d.ts +1 -0
- package/dist/types/session/SessionClient.d.ts +6 -0
- package/dist/types/session/accountDialogController.d.ts +9 -1
- package/dist/types/session/authStateStore.d.ts +1 -1
- package/dist/types/session/hubSync.d.ts +20 -0
- package/dist/types/session/sessionClientHost.d.ts +2 -1
- package/dist/types/utils/coldBoot.d.ts +2 -2
- package/dist/types/utils/oauthPkce.d.ts +27 -0
- package/dist/types/utils/officialOrigins.d.ts +17 -0
- package/package.json +2 -2
- package/src/boot/__tests__/{coldBootV2.test.ts → sessionColdBoot.test.ts} +21 -1
- package/src/boot/{coldBootV2.ts → sessionColdBoot.ts} +15 -2
- package/src/index.ts +30 -3
- package/src/mixins/OxyServices.accounts.ts +9 -0
- package/src/mixins/OxyServices.auth.ts +72 -1
- package/src/mixins/OxyServices.deviceBoot.ts +46 -1
- package/src/models/interfaces.ts +3 -1
- package/src/models/session.ts +6 -0
- package/src/server/index.ts +8 -0
- package/src/session/SessionClient.ts +68 -26
- package/src/session/__tests__/SessionClient.additive.test.ts +1 -0
- package/src/session/__tests__/SessionClient.broadcastChannel.test.ts +1 -0
- package/src/session/__tests__/SessionClient.diagnostics.test.ts +1 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +1 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +1 -0
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +1 -0
- package/src/session/__tests__/SessionClient.state.test.ts +1 -0
- package/src/session/__tests__/accountDialogController.test.ts +46 -7
- package/src/session/__tests__/sessionIntegration.test.ts +7 -0
- package/src/session/accountDialogController.ts +45 -11
- package/src/session/authStateStore.ts +18 -9
- package/src/session/hubSync.ts +79 -0
- package/src/session/sessionClientHost.ts +10 -2
- package/src/utils/__tests__/officialOrigins.test.ts +74 -0
- package/src/utils/coldBoot.ts +2 -2
- package/src/utils/oauthPkce.ts +71 -0
- 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 (`
|
|
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
|
}
|
package/src/models/interfaces.ts
CHANGED
|
@@ -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;
|
package/src/models/session.ts
CHANGED
|
@@ -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
|
}
|
package/src/server/index.ts
CHANGED
|
@@ -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.
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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)
|
|
226
|
-
//
|
|
227
|
-
if (!
|
|
228
|
-
if (
|
|
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
|
-
//
|
|
234
|
-
// signed-out
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
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
|
-
|
|
286
|
-
|
|
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(
|
|
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
|
-
|
|
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/'
|
|
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')).
|
|
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
|
|
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(
|
|
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
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
562
|
-
|
|
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: {
|
|
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
|
|
752
|
-
function
|
|
753
|
-
const location = (globalThis as { location?: {
|
|
754
|
-
return typeof location?.
|
|
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
|
}
|