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