@oxyhq/core 5.4.3 → 5.5.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/index.js +2 -1
- package/dist/cjs/session/SessionClient.js +4 -1
- package/dist/cjs/session/createSessionClient.js +8 -2
- package/dist/cjs/utils/ssoBounce.js +22 -0
- package/dist/cjs/utils/ssoReturn.js +8 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/session/SessionClient.js +4 -1
- package/dist/esm/session/createSessionClient.js +8 -2
- package/dist/esm/utils/ssoBounce.js +21 -0
- package/dist/esm/utils/ssoReturn.js +8 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/index.d.ts +2 -1
- package/dist/types/session/SessionClient.d.ts +13 -1
- package/dist/types/session/createSessionClient.d.ts +8 -1
- package/dist/types/utils/ssoBounce.d.ts +18 -0
- package/dist/types/utils/ssoReturn.d.ts +11 -1
- package/package.json +1 -1
- package/src/index.ts +5 -0
- package/src/session/SessionClient.ts +17 -2
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +79 -0
- package/src/session/createSessionClient.ts +9 -1
- package/src/utils/__tests__/ssoReturn.test.ts +25 -0
- package/src/utils/ssoBounce.ts +22 -0
- package/src/utils/ssoReturn.ts +18 -1
package/dist/types/index.d.ts
CHANGED
|
@@ -95,12 +95,13 @@ export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './uti
|
|
|
95
95
|
export { generateSsoState } from './mixins/OxyServices.sso';
|
|
96
96
|
export { establishIdpSessionAfterClaim } from './utils/ssoEstablish';
|
|
97
97
|
export type { SsoEstablishClient, EstablishAfterClaimDeps } from './utils/ssoEstablish';
|
|
98
|
-
export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoPriorSessionKey, ssoSignedOutKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, silentRestoreSuppressed, allowSsoBounce, } from './utils/ssoBounce';
|
|
98
|
+
export { SSO_CALLBACK_PATH, SSO_GUARD_TTL_MS, ssoStateKey, ssoGuardKey, ssoDestKey, ssoNoSessionKey, ssoAttemptedKey, ssoPriorSessionKey, ssoSignedOutKey, ssoOutcomeKey, ssoCallbackBootstrapKey, ssoNavigate, getSsoCallbackBootstrapScript, buildSsoBounceUrl, isCentralIdPOrigin, guardActive, silentRestoreSuppressed, allowSsoBounce, } from './utils/ssoBounce';
|
|
99
99
|
export type { SsoBounceGate } from './utils/ssoBounce';
|
|
100
100
|
export { runColdBoot } from './utils/coldBoot';
|
|
101
101
|
export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
|
|
102
102
|
export { SessionClient } from './session/SessionClient';
|
|
103
103
|
export type { TokenTransport, SessionClientHost, SessionClientOptions } from './session/SessionClient';
|
|
104
|
+
export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
|
|
104
105
|
export { createSessionClientHost } from './session/sessionClientHost';
|
|
105
106
|
export { createSessionClient } from './session/createSessionClient';
|
|
106
107
|
export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountIdsOf, } from './session/projectSessionState';
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
-
import type { MinimalSocket } from './socketLoader';
|
|
2
|
+
import type { MinimalSocket, SocketIOFactory } from './socketLoader';
|
|
3
3
|
export interface TokenTransport {
|
|
4
4
|
/** Ensure this app holds a per-domain access token for state.activeAccountId (mint via FedCM/silent/sso/keychain). Best-effort. */
|
|
5
5
|
ensureActiveToken(state: DeviceSessionState): Promise<void>;
|
|
@@ -16,6 +16,18 @@ export interface SessionClientHost {
|
|
|
16
16
|
}
|
|
17
17
|
export interface SessionClientOptions {
|
|
18
18
|
transport?: TokenTransport;
|
|
19
|
+
/**
|
|
20
|
+
* Statically-injected `socket.io-client` factory (its `io` export).
|
|
21
|
+
* `@oxyhq/services` and `@oxyhq/auth` list `socket.io-client` as a real
|
|
22
|
+
* dependency and pass `io` in directly, so realtime session sync never
|
|
23
|
+
* depends on a runtime dynamic `import('socket.io-client')` of a bare
|
|
24
|
+
* specifier — which is bundler-fragile in Metro/Expo-web and Vite when
|
|
25
|
+
* `@oxyhq/core` is consumed as its published dist (the import resolves to
|
|
26
|
+
* nothing → `connectSocket` warns and falls back to REST-only). When this is
|
|
27
|
+
* provided, `connectSocket` uses it and never touches the lazy loader; when
|
|
28
|
+
* absent it falls back to `getSocketIO()`.
|
|
29
|
+
*/
|
|
30
|
+
socketFactory?: SocketIOFactory;
|
|
19
31
|
}
|
|
20
32
|
type StateListener = (state: DeviceSessionState | null) => void;
|
|
21
33
|
export declare class SessionClient {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OxyServices } from '../OxyServices';
|
|
2
2
|
import { SessionClient, type TokenTransport } from './SessionClient';
|
|
3
|
+
import type { SocketIOFactory } from './socketLoader';
|
|
3
4
|
import { createSessionClientHost } from './sessionClientHost';
|
|
4
5
|
/**
|
|
5
6
|
* Wires a `SessionClient` over the given `OxyServices` instance: builds the
|
|
@@ -16,8 +17,14 @@ import { createSessionClientHost } from './sessionClientHost';
|
|
|
16
17
|
* The host is returned alongside the client (not just the client) so the
|
|
17
18
|
* caller can call `host.setCurrentAccountId(...)` as the active account
|
|
18
19
|
* changes.
|
|
20
|
+
*
|
|
21
|
+
* `socketFactory` is the statically-injected `socket.io-client` `io` export.
|
|
22
|
+
* Consumers that bundle socket.io-client as a real dependency pass it so
|
|
23
|
+
* realtime sync never depends on core's lazy dynamic import of a bare
|
|
24
|
+
* specifier (bundler-fragile in Metro/Expo-web and Vite against the published
|
|
25
|
+
* dist). When omitted, the client falls back to the lazy loader.
|
|
19
26
|
*/
|
|
20
|
-
export declare function createSessionClient(oxyServices: OxyServices, transport: TokenTransport): {
|
|
27
|
+
export declare function createSessionClient(oxyServices: OxyServices, transport: TokenTransport, socketFactory?: SocketIOFactory): {
|
|
21
28
|
client: SessionClient;
|
|
22
29
|
host: ReturnType<typeof createSessionClientHost>;
|
|
23
30
|
};
|
|
@@ -122,6 +122,24 @@ export declare function ssoPriorSessionKey(origin: string): string;
|
|
|
122
122
|
* clears it first, so the user can always sign back in.
|
|
123
123
|
*/
|
|
124
124
|
export declare function ssoSignedOutKey(origin: string): string;
|
|
125
|
+
/**
|
|
126
|
+
* Per-origin key holding the LAST consumed SSO-return outcome (`ok` | `none` |
|
|
127
|
+
* `error`, plus an optional machine-readable `reason` on the non-`ok` outcomes).
|
|
128
|
+
*
|
|
129
|
+
* Lives in per-tab `sessionStorage` like the other loop-breaker keys, and for
|
|
130
|
+
* the same reason: a `none`/`error` return HARD-navigates the RP off the
|
|
131
|
+
* internal callback path back to its real destination (a fresh document load),
|
|
132
|
+
* so the outcome an RP wants to render ("the central IdP had no session — show a
|
|
133
|
+
* branded sign-in screen instead of bouncing again") must survive that
|
|
134
|
+
* round-trip. The RP reads it on the destination load to decide whether an
|
|
135
|
+
* AUTOMATIC (guard-driven) sign-in should re-bounce or defer to a user gesture.
|
|
136
|
+
*
|
|
137
|
+
* Written as a small JSON blob (`{kind, reason?}`). Set whenever a return is
|
|
138
|
+
* consumed; cleared on a successful session commit and on an explicit
|
|
139
|
+
* user-gesture sign-in / full sign-out (so a deliberate retry is never
|
|
140
|
+
* suppressed by a prior automatic none/error).
|
|
141
|
+
*/
|
|
142
|
+
export declare function ssoOutcomeKey(origin: string): string;
|
|
125
143
|
/**
|
|
126
144
|
* Per-origin marker written by the pre-hydration callback bootstrap.
|
|
127
145
|
*
|
|
@@ -29,12 +29,22 @@ export type SsoReturnKind = 'ok' | 'none' | 'error';
|
|
|
29
29
|
* The parsed result of an SSO return fragment.
|
|
30
30
|
*
|
|
31
31
|
* `code` is present only for `kind: 'ok'`. `state` echoes the CSRF state the RP
|
|
32
|
-
* generated for the bounce (when the IdP round-tripped it).
|
|
32
|
+
* generated for the bounce (when the IdP round-tripped it). `reason` is present
|
|
33
|
+
* only for a NON-`ok` outcome when the IdP supplied one.
|
|
33
34
|
*/
|
|
34
35
|
export interface SsoReturnResult {
|
|
35
36
|
kind: SsoReturnKind;
|
|
36
37
|
code?: string;
|
|
37
38
|
state?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Machine-readable reason accompanying a NON-`ok` outcome, when the central
|
|
41
|
+
* IdP supplies one (e.g. `no_cookie` | `stale_session` | `no_grant` |
|
|
42
|
+
* `no_grant_establish` on a `none` bounce). Purely informational: it lets a
|
|
43
|
+
* Relying Party surface WHY a silent probe returned no session (e.g. to brand
|
|
44
|
+
* a "sign in" screen) without re-deriving it. Absent on `ok`, and whenever the
|
|
45
|
+
* IdP did not include a `reason` param.
|
|
46
|
+
*/
|
|
47
|
+
reason?: string;
|
|
38
48
|
}
|
|
39
49
|
/**
|
|
40
50
|
* Parse an SSO return fragment.
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -545,6 +545,7 @@ export {
|
|
|
545
545
|
ssoAttemptedKey,
|
|
546
546
|
ssoPriorSessionKey,
|
|
547
547
|
ssoSignedOutKey,
|
|
548
|
+
ssoOutcomeKey,
|
|
548
549
|
ssoCallbackBootstrapKey,
|
|
549
550
|
ssoNavigate,
|
|
550
551
|
getSsoCallbackBootstrapScript,
|
|
@@ -571,6 +572,10 @@ export type {
|
|
|
571
572
|
// ---------------------------------------------------------------------------
|
|
572
573
|
export { SessionClient } from './session/SessionClient';
|
|
573
574
|
export type { TokenTransport, SessionClientHost, SessionClientOptions } from './session/SessionClient';
|
|
575
|
+
// The injectable socket factory type: consumers that bundle socket.io-client
|
|
576
|
+
// (services/auth-sdk) pass its `io` export as `socketFactory` so realtime sync
|
|
577
|
+
// never relies on core's lazy dynamic import of a bare specifier.
|
|
578
|
+
export type { SocketIOFactory, MinimalSocket } from './session/socketLoader';
|
|
574
579
|
|
|
575
580
|
// Shared SessionClient integration layer: the host adapter, the pure
|
|
576
581
|
// DeviceSessionState projection helpers, and the client factory are defined
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
} from '@oxyhq/contracts';
|
|
7
7
|
import { logger } from '../utils/loggerUtils';
|
|
8
8
|
import { getSocketIO } from './socketLoader';
|
|
9
|
-
import type { MinimalSocket } from './socketLoader';
|
|
9
|
+
import type { MinimalSocket, SocketIOFactory } from './socketLoader';
|
|
10
10
|
|
|
11
11
|
export interface TokenTransport {
|
|
12
12
|
/** Ensure this app holds a per-domain access token for state.activeAccountId (mint via FedCM/silent/sso/keychain). Best-effort. */
|
|
@@ -24,6 +24,18 @@ export interface SessionClientHost {
|
|
|
24
24
|
|
|
25
25
|
export interface SessionClientOptions {
|
|
26
26
|
transport?: TokenTransport;
|
|
27
|
+
/**
|
|
28
|
+
* Statically-injected `socket.io-client` factory (its `io` export).
|
|
29
|
+
* `@oxyhq/services` and `@oxyhq/auth` list `socket.io-client` as a real
|
|
30
|
+
* dependency and pass `io` in directly, so realtime session sync never
|
|
31
|
+
* depends on a runtime dynamic `import('socket.io-client')` of a bare
|
|
32
|
+
* specifier — which is bundler-fragile in Metro/Expo-web and Vite when
|
|
33
|
+
* `@oxyhq/core` is consumed as its published dist (the import resolves to
|
|
34
|
+
* nothing → `connectSocket` warns and falls back to REST-only). When this is
|
|
35
|
+
* provided, `connectSocket` uses it and never touches the lazy loader; when
|
|
36
|
+
* absent it falls back to `getSocketIO()`.
|
|
37
|
+
*/
|
|
38
|
+
socketFactory?: SocketIOFactory;
|
|
27
39
|
}
|
|
28
40
|
|
|
29
41
|
type StateListener = (state: DeviceSessionState | null) => void;
|
|
@@ -157,7 +169,10 @@ export class SessionClient {
|
|
|
157
169
|
}
|
|
158
170
|
|
|
159
171
|
private async connectSocket(): Promise<void> {
|
|
160
|
-
|
|
172
|
+
// Prefer a statically-injected factory (services/auth-sdk bundle
|
|
173
|
+
// socket.io-client as a real dep); fall back to the lazy loader — and warn
|
|
174
|
+
// if THAT yields nothing — only when no factory was injected.
|
|
175
|
+
const io = this.options.socketFactory ?? (await getSocketIO());
|
|
161
176
|
if (!io) {
|
|
162
177
|
logger.warn('[SessionClient] no socket.io-client; running REST-only (no realtime sync)', { component: 'SessionClient' });
|
|
163
178
|
return;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import * as socketLoader from '../socketLoader';
|
|
3
|
+
import type { MinimalSocket, SocketIOFactory } from '../socketLoader';
|
|
4
|
+
import { SessionClient, type SessionClientHost } from '../SessionClient';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* P0 (bundler-fragility): SessionClient must NOT depend on a runtime dynamic
|
|
8
|
+
* `import('socket.io-client')` when a consumer (services/auth-sdk) injects the
|
|
9
|
+
* `io` factory statically. These tests pin the two branches of `connectSocket`:
|
|
10
|
+
*
|
|
11
|
+
* 1. `socketFactory` injected → the injected factory is used and the lazy
|
|
12
|
+
* loader (`getSocketIO`) is NEVER invoked (the exact call that fails in the
|
|
13
|
+
* Metro/Expo-web + Vite published-dist consumers).
|
|
14
|
+
* 2. no `socketFactory` → the lazy loader IS invoked (fallback).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
type Handler = (...args: unknown[]) => void;
|
|
18
|
+
class FakeSocket implements MinimalSocket {
|
|
19
|
+
connected = false;
|
|
20
|
+
handlers = new Map<string, Handler[]>();
|
|
21
|
+
on(event: string, cb: Handler) { const l = this.handlers.get(event) ?? []; l.push(cb); this.handlers.set(event, l); }
|
|
22
|
+
off(event: string, cb?: Handler) { if (!cb) { this.handlers.delete(event); return; } this.handlers.set(event, (this.handlers.get(event) ?? []).filter((h) => h !== cb)); }
|
|
23
|
+
connect() { this.connected = true; }
|
|
24
|
+
disconnect() { this.connected = false; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const STATE = (rev: number): DeviceSessionState => ({ deviceId: 'd1', accounts: [{ accountId: 'a1', sessionId: 's1', authuser: 0 }], activeAccountId: 'a1', revision: rev, updatedAt: 1720000000000 });
|
|
28
|
+
const SYNC = (rev: number) => ({ state: STATE(rev), activeToken: { accessToken: `jwt-${rev}`, expiresAt: 'x' } });
|
|
29
|
+
|
|
30
|
+
function makeHost(over: Partial<SessionClientHost> = {}): SessionClientHost {
|
|
31
|
+
return {
|
|
32
|
+
makeRequest: jest.fn().mockResolvedValue(SYNC(1)),
|
|
33
|
+
getBaseURL: () => 'http://test.invalid',
|
|
34
|
+
getAccessToken: () => 'tok',
|
|
35
|
+
onTokensChanged: () => () => undefined,
|
|
36
|
+
setTokens: jest.fn(),
|
|
37
|
+
getCurrentAccountId: () => 'a1',
|
|
38
|
+
...over,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('SessionClient injected socketFactory', () => {
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
jest.restoreAllMocks();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('uses the injected factory and NEVER invokes the lazy loader', async () => {
|
|
48
|
+
const loaderSpy = jest.spyOn(socketLoader, 'getSocketIO');
|
|
49
|
+
let created: FakeSocket | null = null;
|
|
50
|
+
const factory: SocketIOFactory = jest.fn((_uri: string) => {
|
|
51
|
+
created = new FakeSocket();
|
|
52
|
+
created.connected = true;
|
|
53
|
+
return created;
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
const client = new SessionClient(makeHost(), { socketFactory: factory });
|
|
57
|
+
await client.start();
|
|
58
|
+
|
|
59
|
+
expect(factory).toHaveBeenCalledTimes(1);
|
|
60
|
+
expect(factory).toHaveBeenCalledWith('http://test.invalid', expect.objectContaining({ transports: ['websocket'] }));
|
|
61
|
+
expect(loaderSpy).not.toHaveBeenCalled();
|
|
62
|
+
expect(created).not.toBeNull();
|
|
63
|
+
client.stop();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it('falls back to the lazy loader when no factory is injected', async () => {
|
|
67
|
+
const fake = new FakeSocket();
|
|
68
|
+
fake.connected = true;
|
|
69
|
+
const lazyFactory: SocketIOFactory = jest.fn(() => fake);
|
|
70
|
+
const loaderSpy = jest.spyOn(socketLoader, 'getSocketIO').mockResolvedValue(lazyFactory);
|
|
71
|
+
|
|
72
|
+
const client = new SessionClient(makeHost());
|
|
73
|
+
await client.start();
|
|
74
|
+
|
|
75
|
+
expect(loaderSpy).toHaveBeenCalledTimes(1);
|
|
76
|
+
expect(lazyFactory).toHaveBeenCalledWith('http://test.invalid', expect.objectContaining({ transports: ['websocket'] }));
|
|
77
|
+
client.stop();
|
|
78
|
+
});
|
|
79
|
+
});
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { OxyServices } from '../OxyServices';
|
|
2
2
|
import { SessionClient, type TokenTransport } from './SessionClient';
|
|
3
|
+
import type { SocketIOFactory } from './socketLoader';
|
|
3
4
|
import { createSessionClientHost } from './sessionClientHost';
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -17,15 +18,22 @@ import { createSessionClientHost } from './sessionClientHost';
|
|
|
17
18
|
* The host is returned alongside the client (not just the client) so the
|
|
18
19
|
* caller can call `host.setCurrentAccountId(...)` as the active account
|
|
19
20
|
* changes.
|
|
21
|
+
*
|
|
22
|
+
* `socketFactory` is the statically-injected `socket.io-client` `io` export.
|
|
23
|
+
* Consumers that bundle socket.io-client as a real dependency pass it so
|
|
24
|
+
* realtime sync never depends on core's lazy dynamic import of a bare
|
|
25
|
+
* specifier (bundler-fragile in Metro/Expo-web and Vite against the published
|
|
26
|
+
* dist). When omitted, the client falls back to the lazy loader.
|
|
20
27
|
*/
|
|
21
28
|
export function createSessionClient(
|
|
22
29
|
oxyServices: OxyServices,
|
|
23
30
|
transport: TokenTransport,
|
|
31
|
+
socketFactory?: SocketIOFactory,
|
|
24
32
|
): {
|
|
25
33
|
client: SessionClient;
|
|
26
34
|
host: ReturnType<typeof createSessionClientHost>;
|
|
27
35
|
} {
|
|
28
36
|
const host = createSessionClientHost(oxyServices);
|
|
29
|
-
const client = new SessionClient(host, { transport });
|
|
37
|
+
const client = new SessionClient(host, { transport, socketFactory });
|
|
30
38
|
return { client, host };
|
|
31
39
|
}
|
|
@@ -70,6 +70,13 @@ describe('parseSsoReturnFragment', () => {
|
|
|
70
70
|
|
|
71
71
|
expect(result).toEqual({ kind: 'ok', code: 'a+b/c', state: 's t' });
|
|
72
72
|
});
|
|
73
|
+
|
|
74
|
+
it('ignores a stray reason on an ok outcome', () => {
|
|
75
|
+
const result = parseSsoReturnFragment('#oxy_sso=ok&code=abc123&state=xyz&reason=no_cookie');
|
|
76
|
+
|
|
77
|
+
expect(result).toEqual({ kind: 'ok', code: 'abc123', state: 'xyz' });
|
|
78
|
+
expect(result?.reason).toBeUndefined();
|
|
79
|
+
});
|
|
73
80
|
});
|
|
74
81
|
|
|
75
82
|
describe('none', () => {
|
|
@@ -85,6 +92,18 @@ describe('parseSsoReturnFragment', () => {
|
|
|
85
92
|
expect(result).toEqual({ kind: 'none', state: 'xyz' });
|
|
86
93
|
expect(result?.code).toBeUndefined();
|
|
87
94
|
});
|
|
95
|
+
|
|
96
|
+
it('carries a machine-readable reason on a none outcome', () => {
|
|
97
|
+
const result = parseSsoReturnFragment('#oxy_sso=none&reason=no_cookie&state=xyz');
|
|
98
|
+
|
|
99
|
+
expect(result).toEqual({ kind: 'none', state: 'xyz', reason: 'no_cookie' });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('omits reason when the none outcome carries none', () => {
|
|
103
|
+
const result = parseSsoReturnFragment('#oxy_sso=none&state=xyz');
|
|
104
|
+
|
|
105
|
+
expect(result?.reason).toBeUndefined();
|
|
106
|
+
});
|
|
88
107
|
});
|
|
89
108
|
|
|
90
109
|
describe('error', () => {
|
|
@@ -100,6 +119,12 @@ describe('parseSsoReturnFragment', () => {
|
|
|
100
119
|
expect(result).toEqual({ kind: 'error' });
|
|
101
120
|
expect(result?.code).toBeUndefined();
|
|
102
121
|
});
|
|
122
|
+
|
|
123
|
+
it('carries a machine-readable reason on an error outcome', () => {
|
|
124
|
+
const result = parseSsoReturnFragment('#oxy_sso=error&reason=no_grant_establish');
|
|
125
|
+
|
|
126
|
+
expect(result).toEqual({ kind: 'error', reason: 'no_grant_establish' });
|
|
127
|
+
});
|
|
103
128
|
});
|
|
104
129
|
|
|
105
130
|
describe('null (not an oxy_sso fragment)', () => {
|
package/src/utils/ssoBounce.ts
CHANGED
|
@@ -70,6 +70,7 @@ const ATTEMPTED_KEY_PREFIX = 'oxy_sso_attempted:';
|
|
|
70
70
|
const CALLBACK_BOOTSTRAP_KEY_PREFIX = 'oxy_sso_callback_bootstrap:';
|
|
71
71
|
const PRIOR_SESSION_KEY_PREFIX = 'oxy_sso_prior_session:';
|
|
72
72
|
const SIGNED_OUT_KEY_PREFIX = 'oxy_signed_out:';
|
|
73
|
+
const OUTCOME_KEY_PREFIX = 'oxy_sso_outcome:';
|
|
73
74
|
|
|
74
75
|
/** Per-origin CSRF state key (matched on return to defeat fragment forgery). */
|
|
75
76
|
export function ssoStateKey(origin: string): string {
|
|
@@ -157,6 +158,27 @@ export function ssoSignedOutKey(origin: string): string {
|
|
|
157
158
|
return `${SIGNED_OUT_KEY_PREFIX}${origin}`;
|
|
158
159
|
}
|
|
159
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Per-origin key holding the LAST consumed SSO-return outcome (`ok` | `none` |
|
|
163
|
+
* `error`, plus an optional machine-readable `reason` on the non-`ok` outcomes).
|
|
164
|
+
*
|
|
165
|
+
* Lives in per-tab `sessionStorage` like the other loop-breaker keys, and for
|
|
166
|
+
* the same reason: a `none`/`error` return HARD-navigates the RP off the
|
|
167
|
+
* internal callback path back to its real destination (a fresh document load),
|
|
168
|
+
* so the outcome an RP wants to render ("the central IdP had no session — show a
|
|
169
|
+
* branded sign-in screen instead of bouncing again") must survive that
|
|
170
|
+
* round-trip. The RP reads it on the destination load to decide whether an
|
|
171
|
+
* AUTOMATIC (guard-driven) sign-in should re-bounce or defer to a user gesture.
|
|
172
|
+
*
|
|
173
|
+
* Written as a small JSON blob (`{kind, reason?}`). Set whenever a return is
|
|
174
|
+
* consumed; cleared on a successful session commit and on an explicit
|
|
175
|
+
* user-gesture sign-in / full sign-out (so a deliberate retry is never
|
|
176
|
+
* suppressed by a prior automatic none/error).
|
|
177
|
+
*/
|
|
178
|
+
export function ssoOutcomeKey(origin: string): string {
|
|
179
|
+
return `${OUTCOME_KEY_PREFIX}${origin}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
160
182
|
/**
|
|
161
183
|
* Per-origin marker written by the pre-hydration callback bootstrap.
|
|
162
184
|
*
|
package/src/utils/ssoReturn.ts
CHANGED
|
@@ -41,12 +41,22 @@ export type SsoReturnKind = 'ok' | 'none' | 'error';
|
|
|
41
41
|
* The parsed result of an SSO return fragment.
|
|
42
42
|
*
|
|
43
43
|
* `code` is present only for `kind: 'ok'`. `state` echoes the CSRF state the RP
|
|
44
|
-
* generated for the bounce (when the IdP round-tripped it).
|
|
44
|
+
* generated for the bounce (when the IdP round-tripped it). `reason` is present
|
|
45
|
+
* only for a NON-`ok` outcome when the IdP supplied one.
|
|
45
46
|
*/
|
|
46
47
|
export interface SsoReturnResult {
|
|
47
48
|
kind: SsoReturnKind;
|
|
48
49
|
code?: string;
|
|
49
50
|
state?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Machine-readable reason accompanying a NON-`ok` outcome, when the central
|
|
53
|
+
* IdP supplies one (e.g. `no_cookie` | `stale_session` | `no_grant` |
|
|
54
|
+
* `no_grant_establish` on a `none` bounce). Purely informational: it lets a
|
|
55
|
+
* Relying Party surface WHY a silent probe returned no session (e.g. to brand
|
|
56
|
+
* a "sign in" screen) without re-deriving it. Absent on `ok`, and whenever the
|
|
57
|
+
* IdP did not include a `reason` param.
|
|
58
|
+
*/
|
|
59
|
+
reason?: string;
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
const VALID_KINDS: ReadonlySet<string> = new Set<SsoReturnKind>(['ok', 'none', 'error']);
|
|
@@ -99,6 +109,13 @@ export function parseSsoReturnFragment(hash: string | undefined | null): SsoRetu
|
|
|
99
109
|
if (code !== null && code.length > 0) {
|
|
100
110
|
result.code = code;
|
|
101
111
|
}
|
|
112
|
+
} else {
|
|
113
|
+
// A machine-readable reason accompanies a NON-`ok` outcome when the IdP
|
|
114
|
+
// supplies one. Success carries no reason, so it is only read here.
|
|
115
|
+
const reason = params.get('reason');
|
|
116
|
+
if (reason !== null && reason.length > 0) {
|
|
117
|
+
result.reason = reason;
|
|
118
|
+
}
|
|
102
119
|
}
|
|
103
120
|
|
|
104
121
|
return result;
|