@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
|
@@ -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
|
|
@@ -106,17 +106,26 @@ function deserialize(raw: string | null): PersistedAuthState | null {
|
|
|
106
106
|
return null;
|
|
107
107
|
}
|
|
108
108
|
const candidate = parsed as Record<string, unknown>;
|
|
109
|
-
|
|
110
|
-
typeof candidate.
|
|
111
|
-
|
|
112
|
-
candidate.
|
|
113
|
-
candidate.
|
|
114
|
-
|
|
109
|
+
const hasDeviceCredential =
|
|
110
|
+
typeof candidate.deviceId === 'string' &&
|
|
111
|
+
candidate.deviceId.length > 0 &&
|
|
112
|
+
typeof candidate.deviceSecret === 'string' &&
|
|
113
|
+
candidate.deviceSecret.length > 0;
|
|
114
|
+
|
|
115
|
+
const hasSessionIdentity =
|
|
116
|
+
typeof candidate.sessionId === 'string' &&
|
|
117
|
+
typeof candidate.userId === 'string' &&
|
|
118
|
+
candidate.sessionId.length > 0 &&
|
|
119
|
+
candidate.userId.length > 0;
|
|
120
|
+
|
|
121
|
+
// Device-only bootstrap (post join, pre-sign-in): durable credential without session.
|
|
122
|
+
if (!hasSessionIdentity && !hasDeviceCredential) {
|
|
115
123
|
return null;
|
|
116
124
|
}
|
|
125
|
+
|
|
117
126
|
const state: PersistedAuthState = {
|
|
118
|
-
sessionId: candidate.sessionId,
|
|
119
|
-
userId: candidate.userId,
|
|
127
|
+
sessionId: hasSessionIdentity ? (candidate.sessionId as string) : '',
|
|
128
|
+
userId: hasSessionIdentity ? (candidate.userId as string) : '',
|
|
120
129
|
};
|
|
121
130
|
if (typeof candidate.deviceId === 'string' && candidate.deviceId.length > 0) {
|
|
122
131
|
state.deviceId = candidate.deviceId;
|
|
@@ -0,0 +1,79 @@
|
|
|
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
|
+
|
|
6
|
+
import type { OxyServices } from '../OxyServices';
|
|
7
|
+
import type { AuthStateStore } from './authStateStore';
|
|
8
|
+
import {
|
|
9
|
+
buildHubSyncUrl,
|
|
10
|
+
buildIdpHubOrigin,
|
|
11
|
+
isIdpHubOrigin,
|
|
12
|
+
isOfficialWebOrigin,
|
|
13
|
+
} from '../utils/officialOrigins';
|
|
14
|
+
|
|
15
|
+
export interface SyncHubAfterSignInOptions {
|
|
16
|
+
/** Skip sync when false (OxyProvider hubSync prop). @default true */
|
|
17
|
+
enabled?: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* After a successful sign-in on an official web app, mint a hub ticket and
|
|
22
|
+
* redirect to auth.oxy.so/sync so the IdP hub can redeem it and persist the
|
|
23
|
+
* shared device credential for silent OAuth restore on other origins.
|
|
24
|
+
*
|
|
25
|
+
* No-op on native, non-official origins, and when already on the IdP hub.
|
|
26
|
+
*/
|
|
27
|
+
export async function syncHubAfterSignIn(
|
|
28
|
+
oxy: Pick<OxyServices, 'issueHubTicket'>,
|
|
29
|
+
opts?: SyncHubAfterSignInOptions,
|
|
30
|
+
): Promise<boolean> {
|
|
31
|
+
if (opts?.enabled === false) {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (typeof globalThis === 'undefined') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const location = (globalThis as { location?: Location }).location;
|
|
40
|
+
if (!location) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
if (isIdpHubOrigin()) {
|
|
45
|
+
return false;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
if (!isOfficialWebOrigin(location.origin)) {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const hubOrigin = buildIdpHubOrigin();
|
|
53
|
+
const issued = await oxy.issueHubTicket(hubOrigin);
|
|
54
|
+
const returnUrl = `${location.origin}${location.pathname}${location.search}`;
|
|
55
|
+
const syncUrl = buildHubSyncUrl(issued.ticket, returnUrl);
|
|
56
|
+
|
|
57
|
+
window.location.assign(syncUrl);
|
|
58
|
+
return true;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Redeem a hub ticket on auth.oxy.so and persist credentials locally. */
|
|
62
|
+
export async function redeemHubTicketOnHub(
|
|
63
|
+
oxy: Pick<OxyServices, 'redeemHubTicket'>,
|
|
64
|
+
store: AuthStateStore,
|
|
65
|
+
ticket: string,
|
|
66
|
+
): Promise<boolean> {
|
|
67
|
+
const hubOrigin = buildIdpHubOrigin();
|
|
68
|
+
const creds = await oxy.redeemHubTicket(ticket, hubOrigin);
|
|
69
|
+
const prior = await store.load();
|
|
70
|
+
await store.save({
|
|
71
|
+
sessionId: prior?.sessionId ?? '',
|
|
72
|
+
userId: prior?.userId ?? '',
|
|
73
|
+
deviceId: creds.deviceId,
|
|
74
|
+
deviceSecret: creds.deviceSecret,
|
|
75
|
+
...(prior?.accessToken ? { accessToken: prior.accessToken } : {}),
|
|
76
|
+
...(prior?.expiresAt ? { expiresAt: prior.expiresAt } : {}),
|
|
77
|
+
});
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { OxyServices } from '../OxyServices';
|
|
2
|
-
import type { SessionClientHost } from './SessionClient';
|
|
2
|
+
import type { DeviceCredential, SessionClientHost } from './SessionClient';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Thin `SessionClientHost` adapter over an `OxyServices` instance.
|
|
@@ -15,17 +15,25 @@ import type { SessionClientHost } from './SessionClient';
|
|
|
15
15
|
*/
|
|
16
16
|
export function createSessionClientHost(
|
|
17
17
|
oxyServices: OxyServices,
|
|
18
|
-
): SessionClientHost & {
|
|
18
|
+
): SessionClientHost & {
|
|
19
|
+
setCurrentAccountId(id: string | null): void;
|
|
20
|
+
setDeviceCredential(credential: DeviceCredential | null): void;
|
|
21
|
+
} {
|
|
19
22
|
let currentAccountId: string | null = null;
|
|
23
|
+
let deviceCredential: DeviceCredential | null = null;
|
|
20
24
|
return {
|
|
21
25
|
makeRequest: (method, url, data, options) => oxyServices.makeRequest(method, url, data, options),
|
|
22
26
|
getBaseURL: () => oxyServices.getBaseURL(),
|
|
23
27
|
getAccessToken: () => oxyServices.getAccessToken(),
|
|
28
|
+
getDeviceCredential: () => deviceCredential,
|
|
24
29
|
onTokensChanged: (listener) => oxyServices.onTokensChanged(listener),
|
|
25
30
|
setTokens: (accessToken) => oxyServices.setTokens(accessToken),
|
|
26
31
|
getCurrentAccountId: () => currentAccountId,
|
|
27
32
|
setCurrentAccountId: (id) => {
|
|
28
33
|
currentAccountId = id;
|
|
29
34
|
},
|
|
35
|
+
setDeviceCredential: (credential) => {
|
|
36
|
+
deviceCredential = credential;
|
|
37
|
+
},
|
|
30
38
|
};
|
|
31
39
|
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import {
|
|
2
|
+
buildHubSyncUrl,
|
|
3
|
+
buildIdpHubOrigin,
|
|
4
|
+
isAllowedDeviceJoinOrigin,
|
|
5
|
+
isIdpHubOrigin,
|
|
6
|
+
isOfficialWebOrigin,
|
|
7
|
+
normalizeOfficialReturnOrigin,
|
|
8
|
+
parseHubSyncReturnUrl,
|
|
9
|
+
} from '../officialOrigins';
|
|
10
|
+
|
|
11
|
+
describe('officialOrigins', () => {
|
|
12
|
+
it('builds the IdP hub origin', () => {
|
|
13
|
+
expect(buildIdpHubOrigin()).toBe('https://auth.oxy.so');
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('allows official first-party origins', () => {
|
|
17
|
+
expect(isOfficialWebOrigin('https://inbox.oxy.so')).toBe(true);
|
|
18
|
+
expect(isOfficialWebOrigin('https://mention.earth')).toBe(true);
|
|
19
|
+
expect(isOfficialWebOrigin('https://evil.example')).toBe(false);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('keeps the deprecated alias in sync with isOfficialWebOrigin', () => {
|
|
23
|
+
expect(isAllowedDeviceJoinOrigin('https://accounts.oxy.so')).toBe(true);
|
|
24
|
+
expect(isAllowedDeviceJoinOrigin('https://evil.example')).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('normalizes return origins to origin only', () => {
|
|
28
|
+
expect(normalizeOfficialReturnOrigin('https://accounts.oxy.so/settings')).toBe(
|
|
29
|
+
'https://accounts.oxy.so',
|
|
30
|
+
);
|
|
31
|
+
expect(normalizeOfficialReturnOrigin('https://evil.example/')).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('parses hub-sync return URLs', () => {
|
|
35
|
+
expect(parseHubSyncReturnUrl('https://inbox.oxy.so/messages')).toBe(
|
|
36
|
+
'https://inbox.oxy.so/messages',
|
|
37
|
+
);
|
|
38
|
+
expect(parseHubSyncReturnUrl('https://evil.example/')).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('builds hub sync URLs with ticket and optional return', () => {
|
|
42
|
+
const url = new URL(buildHubSyncUrl('tk-abc', 'https://accounts.oxy.so/'));
|
|
43
|
+
expect(url.pathname).toBe('/sync');
|
|
44
|
+
expect(url.searchParams.get('ticket')).toBe('tk-abc');
|
|
45
|
+
expect(url.searchParams.get('return')).toBe('https://accounts.oxy.so/');
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('isIdpHubOrigin', () => {
|
|
49
|
+
const originalLocation = globalThis.location;
|
|
50
|
+
|
|
51
|
+
afterEach(() => {
|
|
52
|
+
Object.defineProperty(globalThis, 'location', {
|
|
53
|
+
configurable: true,
|
|
54
|
+
value: originalLocation,
|
|
55
|
+
});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('returns true on auth.oxy.so', () => {
|
|
59
|
+
Object.defineProperty(globalThis, 'location', {
|
|
60
|
+
configurable: true,
|
|
61
|
+
value: { href: 'https://auth.oxy.so/sync' },
|
|
62
|
+
});
|
|
63
|
+
expect(isIdpHubOrigin()).toBe(true);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('returns false on satellite origins', () => {
|
|
67
|
+
Object.defineProperty(globalThis, 'location', {
|
|
68
|
+
configurable: true,
|
|
69
|
+
value: { href: 'https://inbox.oxy.so/' },
|
|
70
|
+
});
|
|
71
|
+
expect(isIdpHubOrigin()).toBe(false);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
package/src/utils/coldBoot.ts
CHANGED
|
@@ -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
|
|
@@ -106,7 +106,7 @@ export interface RunColdBootOptions<S> {
|
|
|
106
106
|
* fails to settle before the deadline, the runner abandons the await for that
|
|
107
107
|
* step (reporting it via `onStepDeadline`) and CONTINUES to the next step,
|
|
108
108
|
* each now racing against an already-expired deadline. This is deliberate:
|
|
109
|
-
* the runner keeps iterating so the TERMINAL step (e.g. `
|
|
109
|
+
* the runner keeps iterating so the TERMINAL step (e.g. `sessionColdBoot`'s
|
|
110
110
|
* `bootstrap-hop`, whose `run()` performs its navigation side effect
|
|
111
111
|
* synchronously before its first `await`) still gets to fire. A step that
|
|
112
112
|
* has nothing to contribute after the deadline simply doesn't settle and is
|
package/src/utils/oauthPkce.ts
CHANGED
|
@@ -61,6 +61,11 @@ export interface BuildOAuthAuthorizeUrlParams {
|
|
|
61
61
|
state: string;
|
|
62
62
|
/** The PKCE `codeChallenge` from {@link generatePkcePair}. */
|
|
63
63
|
codeChallenge: string;
|
|
64
|
+
/**
|
|
65
|
+
* OAuth `prompt` parameter. Use `none` for silent cross-origin session restore
|
|
66
|
+
* (no UI when the IdP hub already has a session + grant).
|
|
67
|
+
*/
|
|
68
|
+
prompt?: 'none' | 'login' | 'consent';
|
|
64
69
|
}
|
|
65
70
|
|
|
66
71
|
/**
|
|
@@ -184,6 +189,72 @@ export function buildOAuthAuthorizeUrl(params: BuildOAuthAuthorizeUrlParams): st
|
|
|
184
189
|
url.searchParams.set('scope', scope);
|
|
185
190
|
url.searchParams.set('code_challenge', codeChallenge);
|
|
186
191
|
url.searchParams.set('code_challenge_method', 'S256');
|
|
192
|
+
if (params.prompt) {
|
|
193
|
+
url.searchParams.set('prompt', params.prompt);
|
|
194
|
+
}
|
|
187
195
|
|
|
188
196
|
return url.toString();
|
|
189
197
|
}
|
|
198
|
+
|
|
199
|
+
/** `sessionStorage` key for the OAuth CSRF `state` across an authorize redirect. */
|
|
200
|
+
export const OXY_OAUTH_STATE_STORAGE_KEY = 'oxy_oauth_state';
|
|
201
|
+
|
|
202
|
+
/** `sessionStorage` key for the PKCE `code_verifier` across an authorize redirect. */
|
|
203
|
+
export const OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY = 'oxy_oauth_code_verifier';
|
|
204
|
+
|
|
205
|
+
/** `sessionStorage` key — at most one silent OAuth attempt per navigation. */
|
|
206
|
+
export const OXY_SILENT_OAUTH_ATTEMPTED_KEY = 'oxy.silent_oauth_attempted';
|
|
207
|
+
|
|
208
|
+
/** `sessionStorage` key — blocks further cross-origin auto-restore in this tab. */
|
|
209
|
+
export const OXY_CROSS_ORIGIN_RESTORE_ATTEMPTED_KEY = 'oxy.cross_origin_restore_attempted';
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Normalize a redirect URI to its origin. Official Oxy apps register apex
|
|
213
|
+
* origins (`https://inbox.oxy.so`) — never path-qualified URLs.
|
|
214
|
+
*/
|
|
215
|
+
export function normalizeOAuthRedirectUri(input: string): string {
|
|
216
|
+
try {
|
|
217
|
+
return new URL(input).origin;
|
|
218
|
+
} catch {
|
|
219
|
+
return input;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Persist the OAuth handshake for a full-page redirect return (web only). */
|
|
224
|
+
export function persistOAuthHandshake(state: string, codeVerifier: string): boolean {
|
|
225
|
+
const store = (globalThis as { sessionStorage?: Storage }).sessionStorage;
|
|
226
|
+
try {
|
|
227
|
+
if (!store) throw new Error('sessionStorage is unavailable');
|
|
228
|
+
store.setItem(OXY_OAUTH_STATE_STORAGE_KEY, state);
|
|
229
|
+
store.setItem(OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY, codeVerifier);
|
|
230
|
+
return true;
|
|
231
|
+
} catch (error) {
|
|
232
|
+
logger.warn(
|
|
233
|
+
'Could not persist OAuth handshake to sessionStorage',
|
|
234
|
+
{ component: 'oauthPkce' },
|
|
235
|
+
error,
|
|
236
|
+
);
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Read the persisted OAuth handshake, or `null` when absent. */
|
|
242
|
+
export function readOAuthHandshake(): { state: string; codeVerifier: string } | null {
|
|
243
|
+
const store = (globalThis as { sessionStorage?: Storage }).sessionStorage;
|
|
244
|
+
if (!store) return null;
|
|
245
|
+
const state = store.getItem(OXY_OAUTH_STATE_STORAGE_KEY);
|
|
246
|
+
const codeVerifier = store.getItem(OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY);
|
|
247
|
+
if (!state || !codeVerifier) return null;
|
|
248
|
+
return { state, codeVerifier };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** Drop persisted OAuth handshake keys after a successful or aborted return. */
|
|
252
|
+
export function clearOAuthHandshake(): void {
|
|
253
|
+
const store = (globalThis as { sessionStorage?: Storage }).sessionStorage;
|
|
254
|
+
try {
|
|
255
|
+
store?.removeItem(OXY_OAUTH_STATE_STORAGE_KEY);
|
|
256
|
+
store?.removeItem(OXY_OAUTH_CODE_VERIFIER_STORAGE_KEY);
|
|
257
|
+
} catch {
|
|
258
|
+
// Best-effort cleanup only.
|
|
259
|
+
}
|
|
260
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Official first-party web origin allowlist — shared by hub-ticket issuance,
|
|
3
|
+
* OAuth redirect validation, and cross-origin session restore.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { CENTRAL_IDP_APEX } from './authWebUrl';
|
|
7
|
+
import { registrableApex } from './registrableApex';
|
|
8
|
+
|
|
9
|
+
/** Official first-party registrable apexes (mirrors API trusted origins). */
|
|
10
|
+
const OFFICIAL_APEXES = new Set([
|
|
11
|
+
'oxy.so',
|
|
12
|
+
'fairco.in',
|
|
13
|
+
'mention.earth',
|
|
14
|
+
'homiio.com',
|
|
15
|
+
'alia.onl',
|
|
16
|
+
'syra.fm',
|
|
17
|
+
'allo.you',
|
|
18
|
+
'tnp.network',
|
|
19
|
+
'moovo.now',
|
|
20
|
+
'mercaria.co',
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
export function buildIdpHubOrigin(): string {
|
|
24
|
+
return `https://auth.${CENTRAL_IDP_APEX}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Whether the current web origin is the central IdP hub (`auth.oxy.so`). */
|
|
28
|
+
export function isIdpHubOrigin(): boolean {
|
|
29
|
+
if (typeof globalThis === 'undefined') {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
const location = (globalThis as { location?: Location }).location;
|
|
33
|
+
if (!location) {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const { hostname } = new URL(location.href);
|
|
38
|
+
return hostname === `auth.${CENTRAL_IDP_APEX}`;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function isLoopbackOrigin(origin: string): boolean {
|
|
45
|
+
try {
|
|
46
|
+
const parsed = new URL(origin);
|
|
47
|
+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
const host = parsed.hostname.toLowerCase();
|
|
51
|
+
return host === 'localhost' || host === '127.0.0.1' || host === '[::1]';
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Whether an origin belongs to the official Oxy web ecosystem. */
|
|
58
|
+
export function isOfficialWebOrigin(origin: string): boolean {
|
|
59
|
+
if (isLoopbackOrigin(origin)) {
|
|
60
|
+
return true;
|
|
61
|
+
}
|
|
62
|
+
try {
|
|
63
|
+
const parsed = new URL(origin);
|
|
64
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
const host = parsed.hostname.toLowerCase();
|
|
68
|
+
if (host === CENTRAL_IDP_APEX || host.endsWith(`.${CENTRAL_IDP_APEX}`)) {
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
const apex = registrableApex(host);
|
|
72
|
+
return apex != null && OFFICIAL_APEXES.has(apex);
|
|
73
|
+
} catch {
|
|
74
|
+
return false;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Normalize and validate a return URL against official origins. Returns origin only. */
|
|
79
|
+
export function normalizeOfficialReturnOrigin(raw: string): string | null {
|
|
80
|
+
try {
|
|
81
|
+
const parsed = new URL(raw);
|
|
82
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
if (!isOfficialWebOrigin(parsed.origin)) {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
return parsed.origin;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Validate a hub-sync return URL; returns the full normalized URL string. */
|
|
95
|
+
export function parseHubSyncReturnUrl(raw: string | null): string | null {
|
|
96
|
+
if (!raw) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
try {
|
|
100
|
+
const parsed = new URL(raw);
|
|
101
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (!isOfficialWebOrigin(parsed.origin)) {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
return parsed.toString();
|
|
108
|
+
} catch {
|
|
109
|
+
return null;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Build auth.oxy.so/sync redirect URL with a one-time hub ticket. */
|
|
114
|
+
export function buildHubSyncUrl(ticket: string, returnUrl?: string): string {
|
|
115
|
+
const url = new URL('/sync', buildIdpHubOrigin());
|
|
116
|
+
url.searchParams.set('ticket', ticket);
|
|
117
|
+
if (returnUrl) {
|
|
118
|
+
url.searchParams.set('return', returnUrl);
|
|
119
|
+
}
|
|
120
|
+
return url.toString();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @deprecated Use {@link isOfficialWebOrigin}. */
|
|
124
|
+
export const isAllowedDeviceJoinOrigin = isOfficialWebOrigin;
|