@oxyhq/core 7.1.1 → 8.0.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 +48 -24
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +6 -6
- package/dist/cjs/i18n/locales/en-US.json +44 -1
- package/dist/cjs/i18n/locales/es-ES.json +44 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +45 -2
- package/dist/cjs/i18n/locales/locales/es-ES.json +45 -2
- package/dist/cjs/index.js +19 -16
- package/dist/cjs/server/index.js +1 -7
- package/dist/cjs/session/accountDialogController.js +1 -1
- package/dist/cjs/session/accountProjection.js +1 -1
- package/dist/cjs/session/projectSessionState.js +1 -1
- package/dist/cjs/session/sessionClientHost.js +1 -2
- package/dist/cjs/utils/accountUtils.js +1 -1
- package/dist/cjs/utils/oauthPkce.js +142 -0
- package/dist/cjs/utils/platform.js +1 -1
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +6 -6
- package/dist/esm/i18n/locales/en-US.json +44 -1
- package/dist/esm/i18n/locales/es-ES.json +44 -1
- package/dist/esm/i18n/locales/locales/en-US.json +45 -2
- package/dist/esm/i18n/locales/locales/es-ES.json +45 -2
- package/dist/esm/index.js +11 -13
- package/dist/esm/server/index.js +0 -5
- package/dist/esm/session/accountDialogController.js +1 -1
- package/dist/esm/session/accountProjection.js +1 -1
- package/dist/esm/session/projectSessionState.js +1 -1
- package/dist/esm/session/sessionClientHost.js +1 -2
- package/dist/esm/utils/accountUtils.js +1 -1
- package/dist/esm/utils/oauthPkce.js +135 -0
- package/dist/esm/utils/platform.js +1 -1
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +1 -1
- package/dist/types/index.d.ts +3 -2
- package/dist/types/mixins/OxyServices.accounts.d.ts +13 -3
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +4 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +3 -2
- package/dist/types/models/interfaces.d.ts +4 -4
- package/dist/types/server/index.d.ts +0 -1
- package/dist/types/session/accountDialogController.d.ts +1 -1
- package/dist/types/session/accountProjection.d.ts +1 -1
- package/dist/types/session/projectSessionState.d.ts +1 -1
- package/dist/types/session/refresh.d.ts +4 -8
- package/dist/types/session/sessionClientHost.d.ts +1 -2
- package/dist/types/utils/accountUtils.d.ts +1 -1
- package/dist/types/utils/oauthPkce.d.ts +74 -0
- package/dist/types/utils/platform.d.ts +1 -1
- package/package.json +3 -3
- package/src/HttpService.ts +6 -6
- package/src/i18n/locales/en-US.json +45 -2
- package/src/i18n/locales/es-ES.json +45 -2
- package/src/index.ts +23 -16
- package/src/mixins/OxyServices.accounts.ts +12 -0
- package/src/mixins/OxyServices.connectedApps.ts +4 -0
- package/src/mixins/OxyServices.devices.ts +6 -5
- package/src/mixins/__tests__/accounts.test.ts +1 -1
- package/src/models/interfaces.ts +7 -5
- package/src/server/index.ts +0 -6
- package/src/session/accountDialogController.ts +1 -1
- package/src/session/accountProjection.ts +1 -1
- package/src/session/projectSessionState.ts +1 -1
- package/src/session/refresh.ts +4 -8
- package/src/session/sessionClientHost.ts +1 -2
- package/src/utils/__tests__/coldBoot.test.ts +55 -65
- package/src/utils/__tests__/oauthPkce.test.ts +154 -0
- package/src/utils/accountUtils.ts +1 -1
- package/src/utils/oauthPkce.ts +189 -0
- package/src/utils/platform.ts +1 -1
- package/dist/cjs/utils/ssoBounce.js +0 -24
- package/dist/esm/utils/ssoBounce.js +0 -21
- package/dist/types/utils/ssoBounce.d.ts +0 -21
- package/src/utils/ssoBounce.ts +0 -22
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* Device Methods Mixin
|
|
3
3
|
*/
|
|
4
4
|
import type { OxyServicesBase } from '../OxyServices.base';
|
|
5
|
+
import type { DeviceLinkedSession, DeviceLinkedSessionLogoutResponse } from '../models/interfaces';
|
|
5
6
|
|
|
6
7
|
export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
7
8
|
return class extends Base {
|
|
@@ -54,11 +55,11 @@ export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
54
55
|
* @param sessionId - The session ID
|
|
55
56
|
* @returns Array of device sessions
|
|
56
57
|
*/
|
|
57
|
-
async getDeviceSessions(sessionId: string): Promise<
|
|
58
|
+
async getDeviceSessions(sessionId: string): Promise<DeviceLinkedSession[]> {
|
|
58
59
|
try {
|
|
59
60
|
// Use makeRequest for consistent error handling and optional caching
|
|
60
61
|
// Cache disabled by default to ensure fresh session data
|
|
61
|
-
return await this.makeRequest<
|
|
62
|
+
return await this.makeRequest<DeviceLinkedSession[]>('GET', `/session/device/sessions/${sessionId}`, undefined, {
|
|
62
63
|
cache: false, // Don't cache sessions - always get fresh data
|
|
63
64
|
deduplicate: true, // Deduplicate concurrent requests for same sessionId
|
|
64
65
|
});
|
|
@@ -74,12 +75,12 @@ export function OxyServicesDevicesMixin<T extends typeof OxyServicesBase>(Base:
|
|
|
74
75
|
* @param excludeCurrent - Whether to exclude the current session
|
|
75
76
|
* @returns Logout result
|
|
76
77
|
*/
|
|
77
|
-
async logoutAllDeviceSessions(sessionId: string, deviceId?: string, excludeCurrent?: boolean): Promise<
|
|
78
|
+
async logoutAllDeviceSessions(sessionId: string, deviceId?: string, excludeCurrent?: boolean): Promise<DeviceLinkedSessionLogoutResponse> {
|
|
78
79
|
try {
|
|
79
|
-
const urlParams:
|
|
80
|
+
const urlParams: Record<string, string> = {};
|
|
80
81
|
if (deviceId) urlParams.deviceId = deviceId;
|
|
81
82
|
if (excludeCurrent) urlParams.excludeCurrent = 'true';
|
|
82
|
-
return await this.makeRequest('POST', `/session/device/logout-all/${sessionId}`, urlParams, { cache: false });
|
|
83
|
+
return await this.makeRequest<DeviceLinkedSessionLogoutResponse>('POST', `/session/device/logout-all/${sessionId}`, urlParams, { cache: false });
|
|
83
84
|
} catch (error) {
|
|
84
85
|
throw this.handleError(error);
|
|
85
86
|
}
|
|
@@ -83,7 +83,7 @@ const appFixture: Application = {
|
|
|
83
83
|
isOfficial: true,
|
|
84
84
|
isInternal: false,
|
|
85
85
|
capabilities: [],
|
|
86
|
-
redirectUris: ['https://mention.earth/
|
|
86
|
+
redirectUris: ['https://mention.earth/oauth/callback'],
|
|
87
87
|
scopes: ['profile'],
|
|
88
88
|
createdByUserId: 'u1',
|
|
89
89
|
ownerAccountId: 'acc1',
|
package/src/models/interfaces.ts
CHANGED
|
@@ -645,8 +645,10 @@ export interface AssetUploadProgress {
|
|
|
645
645
|
error?: string;
|
|
646
646
|
}
|
|
647
647
|
|
|
648
|
-
// Device
|
|
649
|
-
|
|
648
|
+
// Device-linked session interfaces — the sessions that share one physical
|
|
649
|
+
// device (GET /session/device/sessions/:sessionId). Distinct from the
|
|
650
|
+
// server-authority `DeviceSession` Mongoose model / `DeviceSessionState`.
|
|
651
|
+
export interface DeviceLinkedSession {
|
|
650
652
|
sessionId: string;
|
|
651
653
|
deviceId: string;
|
|
652
654
|
deviceName: string;
|
|
@@ -658,12 +660,12 @@ export interface DeviceSession {
|
|
|
658
660
|
createdAt?: string;
|
|
659
661
|
}
|
|
660
662
|
|
|
661
|
-
export interface
|
|
663
|
+
export interface DeviceLinkedSessionsResponse {
|
|
662
664
|
deviceId: string;
|
|
663
|
-
sessions:
|
|
665
|
+
sessions: DeviceLinkedSession[];
|
|
664
666
|
}
|
|
665
667
|
|
|
666
|
-
export interface
|
|
668
|
+
export interface DeviceLinkedSessionLogoutResponse {
|
|
667
669
|
message: string;
|
|
668
670
|
deviceId: string;
|
|
669
671
|
sessionsTerminated: number;
|
package/src/server/index.ts
CHANGED
|
@@ -69,9 +69,3 @@ 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
|
-
|
|
73
|
-
// The single RP callback path the IdP redirects back to. A pure wire-contract
|
|
74
|
-
// constant (no browser deps at module top level), re-used server-side so the
|
|
75
|
-
// `/sso/establish-token` `return_to` cannot drift from what `/sso/establish`
|
|
76
|
-
// validates.
|
|
77
|
-
export { SSO_CALLBACK_PATH } from '../utils/ssoBounce';
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* A framework-agnostic state machine + subscribe/getSnapshot store (the same
|
|
5
5
|
* pattern {@link SessionClient} uses — no React, no RN) that both
|
|
6
|
-
*
|
|
6
|
+
* every `OxyProvider` platform variant (Expo/RN and RN-Web)
|
|
7
7
|
* bind to via `useSyncExternalStore`, so the account chooser is ONE
|
|
8
8
|
* implementation across the ecosystem instead of the five drifting copies it
|
|
9
9
|
* replaces.
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* merging the device's server-authoritative session set (`DeviceSessionState`
|
|
6
6
|
* from {@link SessionClient}) with the caller's account graph (`AccountNode[]`
|
|
7
7
|
* from `oxyServices.listAccounts()`), deduped by `accountId`. This lives in
|
|
8
|
-
* `@oxyhq/core` so `@oxyhq/services`
|
|
8
|
+
* `@oxyhq/core` so every `@oxyhq/services` platform variant — and
|
|
9
9
|
* `auth.oxy.so` — all render the SAME list from the SAME logic and cannot
|
|
10
10
|
* diverge.
|
|
11
11
|
*
|
|
@@ -5,7 +5,7 @@ import type { User } from '../models/interfaces';
|
|
|
5
5
|
/**
|
|
6
6
|
* Pure projection helpers: `DeviceSessionState` (the device-scoped
|
|
7
7
|
* multi-account session-sync state produced by `SessionClient`) -> the
|
|
8
|
-
* shapes
|
|
8
|
+
* shapes `@oxyhq/services` consumers render today
|
|
9
9
|
* (`ClientSession[]`, an active session id, an active `User`).
|
|
10
10
|
*
|
|
11
11
|
* No I/O. The caller fetches profiles via
|
package/src/session/refresh.ts
CHANGED
|
@@ -1,11 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Unified token refresh — THE single refresh implementation for web + native.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* `@oxyhq/services`'s `inSessionTokenRefresh.ts` (native shared-key). This
|
|
7
|
-
* module replaces both with ONE persisted-refresh-token rotation shared by
|
|
8
|
-
* every consumer:
|
|
4
|
+
* ONE persisted-refresh-token rotation shared by every consumer (it replaced
|
|
5
|
+
* the pre-device-first per-platform duplicates):
|
|
9
6
|
*
|
|
10
7
|
* - `refreshPersistedSession` — arm 1 rotates the stored refresh-token family
|
|
11
8
|
* (`POST /auth/refresh-token`), planting + persisting the rotated pair; arm 2
|
|
@@ -16,9 +13,8 @@
|
|
|
16
13
|
* - `createAuthRefreshHandler` / `installAuthRefreshHandler` wire arm 1+2 into
|
|
17
14
|
* `HttpService.setAuthRefreshHandler`, keeping that layer's single-flight
|
|
18
15
|
* dedup + cooldown (this module does NOT reimplement them).
|
|
19
|
-
* - `startTokenRefreshScheduler` — a proactive scheduler
|
|
20
|
-
*
|
|
21
|
-
* decoupled from any React / auth-sdk type: refreshes ~60s before `exp`,
|
|
16
|
+
* - `startTokenRefreshScheduler` — a proactive scheduler decoupled from any
|
|
17
|
+
* React type: refreshes ~60s before `exp`,
|
|
22
18
|
* re-arms on token change + web tab-focus, `.unref?.()`s its timer in Node.
|
|
23
19
|
*
|
|
24
20
|
* Framework-free; no module-level mutable state.
|
|
@@ -7,8 +7,7 @@ import type { SessionClientHost } from './SessionClient';
|
|
|
7
7
|
* `SessionClient` is host-agnostic: it only needs a REST + token surface.
|
|
8
8
|
* `OxyServices` already exposes all of that except `getCurrentAccountId`,
|
|
9
9
|
* which has no direct equivalent — the adapter holds a mutable ref set by
|
|
10
|
-
* the caller (`OxyContext` in `@oxyhq/services
|
|
11
|
-
* `@oxyhq/auth`) via `setCurrentAccountId`.
|
|
10
|
+
* the caller (`OxyContext` in `@oxyhq/services`) via `setCurrentAccountId`.
|
|
12
11
|
*
|
|
13
12
|
* Shared here (rather than duplicated per consumer) because it is entirely
|
|
14
13
|
* platform-agnostic: every method it calls exists identically on
|
|
@@ -234,10 +234,9 @@ describe('runColdBoot', () => {
|
|
|
234
234
|
});
|
|
235
235
|
|
|
236
236
|
/**
|
|
237
|
-
*
|
|
238
|
-
*
|
|
239
|
-
*
|
|
240
|
-
* forever and the terminal step never runs.
|
|
237
|
+
* A step whose `run()` promise NEVER settles (e.g. an async call that
|
|
238
|
+
* ignores its abort signal). WITHOUT a deadline the whole `runColdBoot`
|
|
239
|
+
* promise hangs forever and the terminal step never runs.
|
|
241
240
|
*/
|
|
242
241
|
it('hangs forever when a step never settles and no deadline is set', async () => {
|
|
243
242
|
const terminalRan = jest.fn();
|
|
@@ -247,7 +246,7 @@ describe('runColdBoot', () => {
|
|
|
247
246
|
steps: [
|
|
248
247
|
{
|
|
249
248
|
id: 'never-settles',
|
|
250
|
-
// Never resolves or rejects — models
|
|
249
|
+
// Never resolves or rejects — models a hung async call.
|
|
251
250
|
run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
|
|
252
251
|
},
|
|
253
252
|
{
|
|
@@ -275,9 +274,8 @@ describe('runColdBoot', () => {
|
|
|
275
274
|
|
|
276
275
|
/**
|
|
277
276
|
* With `overallDeadlineMs` set, the non-settling step is abandoned at the
|
|
278
|
-
* deadline, the runner CONTINUES to the terminal step
|
|
279
|
-
*
|
|
280
|
-
* `unauthenticated` within the bounded budget.
|
|
277
|
+
* deadline, the runner CONTINUES to the terminal step, and the whole boot
|
|
278
|
+
* settles to `unauthenticated` within the bounded budget.
|
|
281
279
|
*/
|
|
282
280
|
it('abandons a non-settling step at the deadline and still runs the terminal step', async () => {
|
|
283
281
|
const terminalRan = jest.fn();
|
|
@@ -310,12 +308,12 @@ describe('runColdBoot', () => {
|
|
|
310
308
|
});
|
|
311
309
|
|
|
312
310
|
/**
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
311
|
+
* A terminal step may run a synchronous side effect before its first
|
|
312
|
+
* `await`; that side effect must still execute when the deadline trips on
|
|
313
|
+
* an earlier step — the terminal fallback is preserved.
|
|
316
314
|
*/
|
|
317
315
|
it('lets the terminal step fire its synchronous side effect after the deadline trips', async () => {
|
|
318
|
-
const
|
|
316
|
+
const terminalSideEffect = jest.fn();
|
|
319
317
|
|
|
320
318
|
const outcomePromise = runColdBoot<TestSession>({
|
|
321
319
|
overallDeadlineMs: 3000,
|
|
@@ -325,10 +323,10 @@ describe('runColdBoot', () => {
|
|
|
325
323
|
run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
|
|
326
324
|
},
|
|
327
325
|
{
|
|
328
|
-
id: '
|
|
326
|
+
id: 'terminal',
|
|
329
327
|
run: async (): Promise<ColdBootStepResult<TestSession>> => {
|
|
330
|
-
// Synchronous
|
|
331
|
-
|
|
328
|
+
// Synchronous side effect that fires before the first await.
|
|
329
|
+
terminalSideEffect();
|
|
332
330
|
return { kind: 'skip' };
|
|
333
331
|
},
|
|
334
332
|
},
|
|
@@ -338,7 +336,7 @@ describe('runColdBoot', () => {
|
|
|
338
336
|
await jest.advanceTimersByTimeAsync(3000);
|
|
339
337
|
await outcomePromise;
|
|
340
338
|
|
|
341
|
-
expect(
|
|
339
|
+
expect(terminalSideEffect).toHaveBeenCalledTimes(1);
|
|
342
340
|
});
|
|
343
341
|
|
|
344
342
|
/**
|
|
@@ -375,29 +373,22 @@ describe('runColdBoot', () => {
|
|
|
375
373
|
});
|
|
376
374
|
|
|
377
375
|
/**
|
|
378
|
-
* End-to-end
|
|
379
|
-
*
|
|
380
|
-
*
|
|
381
|
-
*
|
|
382
|
-
* (
|
|
383
|
-
*
|
|
384
|
-
* `navigator.credentials.get({mediation:'silent'})` can sit pending forever,
|
|
385
|
-
* ignoring its AbortController. WITHOUT the overall deadline the whole chain
|
|
386
|
-
* hangs and the terminal `/sso` bounce never fires.
|
|
387
|
-
*
|
|
388
|
-
* These tests model that exact chain shape against the real deadline value and
|
|
389
|
-
* assert the runner (a) abandons the hung silent step at the deadline,
|
|
390
|
-
* (b) still fires the terminal bounce's synchronous side effect, and
|
|
376
|
+
* End-to-end deadline semantics over a longer chain at the real overall
|
|
377
|
+
* deadline a consumer arms (the runner itself has no default). Models a chain
|
|
378
|
+
* whose FIRST step never settles — its `run()` promise ignores the abort
|
|
379
|
+
* signal and stays pending — followed by several no-op steps and a terminal
|
|
380
|
+
* step. Asserts the runner (a) abandons the hung step at the deadline,
|
|
381
|
+
* (b) still fires the terminal step's synchronous side effect, and
|
|
391
382
|
* (c) always settles within the bounded budget — it never hangs.
|
|
392
383
|
*/
|
|
393
|
-
describe('
|
|
384
|
+
describe('long-chain overall-deadline semantics', () => {
|
|
394
385
|
/**
|
|
395
|
-
* Mirror of `
|
|
396
|
-
*
|
|
397
|
-
*
|
|
398
|
-
*
|
|
386
|
+
* Mirror of the overall-deadline value a consumer arms (e.g. `@oxyhq/services`
|
|
387
|
+
* cold boot — the only consumer that arms the deadline). Kept as a local
|
|
388
|
+
* literal because core does not — and must not — import a consumer package;
|
|
389
|
+
* if the consumer's value changes, update this to match.
|
|
399
390
|
*/
|
|
400
|
-
const
|
|
391
|
+
const OVERALL_DEADLINE = 20000;
|
|
401
392
|
|
|
402
393
|
beforeEach(() => {
|
|
403
394
|
jest.useFakeTimers();
|
|
@@ -407,55 +398,54 @@ describe('runColdBoot', () => {
|
|
|
407
398
|
jest.useRealTimers();
|
|
408
399
|
});
|
|
409
400
|
|
|
410
|
-
/** A step whose `run()` never settles —
|
|
411
|
-
const
|
|
412
|
-
id: '
|
|
401
|
+
/** A step whose `run()` never settles — models a hung async call. */
|
|
402
|
+
const neverSettlingStep = (): ColdBootStep<TestSession> => ({
|
|
403
|
+
id: 'never-settles',
|
|
413
404
|
run: () => new Promise<ColdBootStepResult<TestSession>>(() => {}),
|
|
414
405
|
});
|
|
415
406
|
|
|
416
|
-
it('abandons the hung
|
|
417
|
-
const
|
|
407
|
+
it('abandons the hung step at the deadline and fires the terminal step', async () => {
|
|
408
|
+
const terminalRan = jest.fn();
|
|
418
409
|
const onStepDeadline = jest.fn();
|
|
419
410
|
|
|
420
411
|
const outcomePromise = runColdBoot<TestSession>({
|
|
421
|
-
overallDeadlineMs:
|
|
412
|
+
overallDeadlineMs: OVERALL_DEADLINE,
|
|
422
413
|
onStepDeadline,
|
|
423
414
|
steps: [
|
|
424
|
-
|
|
415
|
+
neverSettlingStep(),
|
|
425
416
|
// Every intermediate step has nothing to contribute on this load.
|
|
426
|
-
skipStep('
|
|
427
|
-
skipStep('
|
|
428
|
-
skipStep('
|
|
417
|
+
skipStep('intermediate-1'),
|
|
418
|
+
skipStep('intermediate-2'),
|
|
419
|
+
skipStep('intermediate-3'),
|
|
429
420
|
{
|
|
430
|
-
id: '
|
|
431
|
-
//
|
|
432
|
-
// like the real top-level `/sso` bounce.
|
|
421
|
+
id: 'terminal',
|
|
422
|
+
// Runs a synchronous side effect before its first await.
|
|
433
423
|
run: async (): Promise<ColdBootStepResult<TestSession>> => {
|
|
434
|
-
|
|
424
|
+
terminalRan();
|
|
435
425
|
return { kind: 'skip' };
|
|
436
426
|
},
|
|
437
427
|
},
|
|
438
428
|
],
|
|
439
429
|
});
|
|
440
430
|
|
|
441
|
-
// Nothing settles before the deadline — the chain is
|
|
442
|
-
await jest.advanceTimersByTimeAsync(
|
|
443
|
-
expect(
|
|
431
|
+
// Nothing settles before the deadline — the chain is stuck on the first step.
|
|
432
|
+
await jest.advanceTimersByTimeAsync(OVERALL_DEADLINE - 1);
|
|
433
|
+
expect(terminalRan).not.toHaveBeenCalled();
|
|
444
434
|
|
|
445
|
-
// At the deadline the
|
|
435
|
+
// At the deadline the hung step is abandoned and the chain proceeds.
|
|
446
436
|
await jest.advanceTimersByTimeAsync(1);
|
|
447
437
|
const outcome = await outcomePromise;
|
|
448
438
|
|
|
449
|
-
expect(onStepDeadline).toHaveBeenCalledWith('
|
|
450
|
-
expect(
|
|
439
|
+
expect(onStepDeadline).toHaveBeenCalledWith('never-settles');
|
|
440
|
+
expect(terminalRan).toHaveBeenCalledTimes(1);
|
|
451
441
|
expect(outcome).toEqual({ kind: 'unauthenticated' });
|
|
452
442
|
});
|
|
453
443
|
|
|
454
444
|
it('settles within the bounded budget instead of hanging forever', async () => {
|
|
455
445
|
let settled = false;
|
|
456
446
|
const outcomePromise = runColdBoot<TestSession>({
|
|
457
|
-
overallDeadlineMs:
|
|
458
|
-
steps: [
|
|
447
|
+
overallDeadlineMs: OVERALL_DEADLINE,
|
|
448
|
+
steps: [neverSettlingStep(), skipStep('terminal')],
|
|
459
449
|
}).then((o) => {
|
|
460
450
|
settled = true;
|
|
461
451
|
return o;
|
|
@@ -463,7 +453,7 @@ describe('runColdBoot', () => {
|
|
|
463
453
|
|
|
464
454
|
// Just before the deadline: still pending (proves the deadline, not an
|
|
465
455
|
// accidental early settle, is what unblocks it).
|
|
466
|
-
await jest.advanceTimersByTimeAsync(
|
|
456
|
+
await jest.advanceTimersByTimeAsync(OVERALL_DEADLINE - 1);
|
|
467
457
|
expect(settled).toBe(false);
|
|
468
458
|
|
|
469
459
|
await jest.advanceTimersByTimeAsync(1);
|
|
@@ -471,19 +461,19 @@ describe('runColdBoot', () => {
|
|
|
471
461
|
expect(settled).toBe(true);
|
|
472
462
|
});
|
|
473
463
|
|
|
474
|
-
it('does not penalize a fast
|
|
464
|
+
it('does not penalize a fast first-step success — it wins well before the deadline', async () => {
|
|
475
465
|
const laterRan = jest.fn();
|
|
476
466
|
const outcomePromise = runColdBoot<TestSession>({
|
|
477
|
-
overallDeadlineMs:
|
|
467
|
+
overallDeadlineMs: OVERALL_DEADLINE,
|
|
478
468
|
steps: [
|
|
479
469
|
{
|
|
480
|
-
id: '
|
|
470
|
+
id: 'first',
|
|
481
471
|
run: async (): Promise<ColdBootStepResult<TestSession>> => {
|
|
482
472
|
await Promise.resolve();
|
|
483
|
-
return { kind: 'session', session: { userId: 'u-
|
|
473
|
+
return { kind: 'session', session: { userId: 'u-fast' } };
|
|
484
474
|
},
|
|
485
475
|
},
|
|
486
|
-
sessionStep('
|
|
476
|
+
sessionStep('terminal', 'u-should-not-run', laterRan),
|
|
487
477
|
],
|
|
488
478
|
});
|
|
489
479
|
|
|
@@ -492,8 +482,8 @@ describe('runColdBoot', () => {
|
|
|
492
482
|
|
|
493
483
|
expect(outcome).toEqual({
|
|
494
484
|
kind: 'session',
|
|
495
|
-
via: '
|
|
496
|
-
session: { userId: 'u-
|
|
485
|
+
via: 'first',
|
|
486
|
+
session: { userId: 'u-fast' },
|
|
497
487
|
});
|
|
498
488
|
expect(laterRan).not.toHaveBeenCalled();
|
|
499
489
|
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
buildOAuthAuthorizeUrl,
|
|
4
|
+
computeCodeChallenge,
|
|
5
|
+
DEFAULT_OAUTH_SCOPE,
|
|
6
|
+
generateOAuthState,
|
|
7
|
+
generatePkcePair,
|
|
8
|
+
OXY_AUTHORIZE_URL,
|
|
9
|
+
} from '../oauthPkce';
|
|
10
|
+
|
|
11
|
+
/** RFC 7636 unreserved subset produced by base64url (no `+`, `/`, `=`). */
|
|
12
|
+
const BASE64URL_RE = /^[A-Za-z0-9\-_]+$/;
|
|
13
|
+
|
|
14
|
+
/** Independent base64url(SHA-256(input)) via Node crypto for cross-checking. */
|
|
15
|
+
function nodeCodeChallenge(verifier: string): string {
|
|
16
|
+
return createHash('sha256').update(verifier).digest('base64url');
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
describe('computeCodeChallenge', () => {
|
|
20
|
+
it('matches the RFC 7636 Appendix B known-answer vector', async () => {
|
|
21
|
+
// From RFC 7636 §Appendix B: verifier -> S256 challenge.
|
|
22
|
+
const verifier = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
|
|
23
|
+
const expected = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
|
24
|
+
await expect(computeCodeChallenge(verifier)).resolves.toBe(expected);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('produces unpadded base64url (no +, /, or = characters)', async () => {
|
|
28
|
+
const challenge = await computeCodeChallenge('some-arbitrary-code-verifier-value-123');
|
|
29
|
+
expect(challenge).toMatch(BASE64URL_RE);
|
|
30
|
+
expect(challenge).not.toContain('=');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('agrees with an independent Node crypto implementation', async () => {
|
|
34
|
+
const verifier = 'another_verifier-value.with~allowed_chars-0123456789';
|
|
35
|
+
await expect(computeCodeChallenge(verifier)).resolves.toBe(nodeCodeChallenge(verifier));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('encodes the full 32-byte SHA-256 digest as 43 base64url chars', async () => {
|
|
39
|
+
const challenge = await computeCodeChallenge('x');
|
|
40
|
+
expect(challenge).toHaveLength(43);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('generatePkcePair', () => {
|
|
45
|
+
it('returns a verifier, challenge, and S256 method', async () => {
|
|
46
|
+
const pair = await generatePkcePair();
|
|
47
|
+
expect(pair.method).toBe('S256');
|
|
48
|
+
expect(typeof pair.codeVerifier).toBe('string');
|
|
49
|
+
expect(typeof pair.codeChallenge).toBe('string');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('produces a verifier in the RFC 7636 length range with only unreserved chars', async () => {
|
|
53
|
+
const { codeVerifier } = await generatePkcePair();
|
|
54
|
+
expect(codeVerifier).toMatch(BASE64URL_RE);
|
|
55
|
+
expect(codeVerifier.length).toBeGreaterThanOrEqual(43);
|
|
56
|
+
expect(codeVerifier.length).toBeLessThanOrEqual(128);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('derives the challenge as base64url(SHA-256(verifier))', async () => {
|
|
60
|
+
const { codeVerifier, codeChallenge } = await generatePkcePair();
|
|
61
|
+
await expect(computeCodeChallenge(codeVerifier)).resolves.toBe(codeChallenge);
|
|
62
|
+
expect(codeChallenge).toBe(nodeCodeChallenge(codeVerifier));
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
it('generates a fresh, unpredictable verifier on each call', async () => {
|
|
66
|
+
const [a, b] = await Promise.all([generatePkcePair(), generatePkcePair()]);
|
|
67
|
+
expect(a.codeVerifier).not.toBe(b.codeVerifier);
|
|
68
|
+
expect(a.codeChallenge).not.toBe(b.codeChallenge);
|
|
69
|
+
});
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
describe('generateOAuthState', () => {
|
|
73
|
+
it('returns an unreserved base64url token of 32 random bytes (43 chars)', async () => {
|
|
74
|
+
const state = await generateOAuthState();
|
|
75
|
+
expect(state).toMatch(BASE64URL_RE);
|
|
76
|
+
expect(state).toHaveLength(43);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('is unique across calls', async () => {
|
|
80
|
+
const [a, b] = await Promise.all([generateOAuthState(), generateOAuthState()]);
|
|
81
|
+
expect(a).not.toBe(b);
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
describe('buildOAuthAuthorizeUrl', () => {
|
|
86
|
+
const base = {
|
|
87
|
+
clientId: 'oxy_dk_example',
|
|
88
|
+
redirectUri: 'https://merchant.co/auth/callback',
|
|
89
|
+
state: 'csrf-state-token',
|
|
90
|
+
codeChallenge: 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM',
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
it('defaults to the Oxy authorize endpoint', () => {
|
|
94
|
+
const url = buildOAuthAuthorizeUrl(base);
|
|
95
|
+
expect(url.startsWith(`${OXY_AUTHORIZE_URL}?`)).toBe(true);
|
|
96
|
+
expect(OXY_AUTHORIZE_URL).toBe('https://auth.oxy.so/authorize');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('includes every required OAuth authorization-code + PKCE parameter', () => {
|
|
100
|
+
const params = new URL(buildOAuthAuthorizeUrl(base)).searchParams;
|
|
101
|
+
expect(params.get('client_id')).toBe(base.clientId);
|
|
102
|
+
expect(params.get('redirect_uri')).toBe(base.redirectUri);
|
|
103
|
+
expect(params.get('response_type')).toBe('code');
|
|
104
|
+
expect(params.get('state')).toBe(base.state);
|
|
105
|
+
expect(params.get('scope')).toBe(DEFAULT_OAUTH_SCOPE);
|
|
106
|
+
expect(params.get('code_challenge')).toBe(base.codeChallenge);
|
|
107
|
+
expect(params.get('code_challenge_method')).toBe('S256');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('defaults the scope to "openid profile"', () => {
|
|
111
|
+
const params = new URL(buildOAuthAuthorizeUrl(base)).searchParams;
|
|
112
|
+
expect(params.get('scope')).toBe('openid profile');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('honors a custom scope', () => {
|
|
116
|
+
const params = new URL(
|
|
117
|
+
buildOAuthAuthorizeUrl({ ...base, scope: 'openid profile email wallet' }),
|
|
118
|
+
).searchParams;
|
|
119
|
+
expect(params.get('scope')).toBe('openid profile email wallet');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('honors an authorizeBaseUrl override', () => {
|
|
123
|
+
const url = buildOAuthAuthorizeUrl({
|
|
124
|
+
...base,
|
|
125
|
+
authorizeBaseUrl: 'https://auth.merchant.co/authorize',
|
|
126
|
+
});
|
|
127
|
+
expect(url.startsWith('https://auth.merchant.co/authorize?')).toBe(true);
|
|
128
|
+
expect(new URL(url).searchParams.get('client_id')).toBe(base.clientId);
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it('preserves a query string already present on authorizeBaseUrl', () => {
|
|
132
|
+
const params = new URL(
|
|
133
|
+
buildOAuthAuthorizeUrl({
|
|
134
|
+
...base,
|
|
135
|
+
authorizeBaseUrl: 'https://auth.merchant.co/authorize?foo=bar&tenant=acme',
|
|
136
|
+
}),
|
|
137
|
+
).searchParams;
|
|
138
|
+
// Pre-existing params survive alongside the OAuth params.
|
|
139
|
+
expect(params.get('foo')).toBe('bar');
|
|
140
|
+
expect(params.get('tenant')).toBe('acme');
|
|
141
|
+
expect(params.get('client_id')).toBe(base.clientId);
|
|
142
|
+
expect(params.get('code_challenge_method')).toBe('S256');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('URL-encodes a redirect_uri that carries a query string and special chars', () => {
|
|
146
|
+
const redirectUri = 'https://merchant.co/auth/callback?next=/dashboard&lang=es';
|
|
147
|
+
const url = buildOAuthAuthorizeUrl({ ...base, redirectUri });
|
|
148
|
+
// The raw string must be percent-encoded (its own & must not leak as a delimiter).
|
|
149
|
+
expect(url).toContain('redirect_uri=https%3A%2F%2Fmerchant.co%2Fauth%2Fcallback');
|
|
150
|
+
expect(url).not.toContain('redirect_uri=https://merchant.co');
|
|
151
|
+
// Round-trips back to the exact original when parsed.
|
|
152
|
+
expect(new URL(url).searchParams.get('redirect_uri')).toBe(redirectUri);
|
|
153
|
+
});
|
|
154
|
+
});
|