@oxyhq/core 5.2.1 → 5.3.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/CrossDomainAuth.js +32 -42
- package/dist/cjs/index.js +24 -2
- package/dist/cjs/mixins/OxyServices.sso.js +36 -0
- package/dist/cjs/mixins/OxyServices.user.js +24 -0
- package/dist/cjs/server/index.js +13 -1
- package/dist/cjs/session/SessionClient.js +142 -0
- package/dist/cjs/session/createSessionClient.js +26 -0
- package/dist/cjs/session/projectSessionState.js +75 -0
- package/dist/cjs/session/sessionClientHost.js +30 -0
- package/dist/cjs/session/socketLoader.js +55 -0
- package/dist/cjs/utils/ssoBounce.js +9 -9
- package/dist/cjs/utils/ssoEstablish.js +110 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/CrossDomainAuth.js +32 -42
- package/dist/esm/index.js +14 -0
- package/dist/esm/mixins/OxyServices.sso.js +36 -0
- package/dist/esm/mixins/OxyServices.user.js +24 -0
- package/dist/esm/server/index.js +10 -0
- package/dist/esm/session/SessionClient.js +138 -0
- package/dist/esm/session/createSessionClient.js +23 -0
- package/dist/esm/session/projectSessionState.js +69 -0
- package/dist/esm/session/sessionClientHost.js +27 -0
- package/dist/esm/session/socketLoader.js +19 -0
- package/dist/esm/utils/ssoBounce.js +9 -9
- package/dist/esm/utils/ssoEstablish.js +107 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/CrossDomainAuth.d.ts +33 -13
- package/dist/types/index.d.ts +7 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +24 -0
- package/dist/types/mixins/OxyServices.user.d.ts +14 -0
- package/dist/types/server/index.d.ts +2 -0
- package/dist/types/session/SessionClient.d.ts +55 -0
- package/dist/types/session/createSessionClient.d.ts +23 -0
- package/dist/types/session/projectSessionState.d.ts +43 -0
- package/dist/types/session/sessionClientHost.d.ts +18 -0
- package/dist/types/session/socketLoader.d.ts +9 -0
- package/dist/types/utils/ssoBounce.d.ts +9 -9
- package/dist/types/utils/ssoEstablish.d.ts +85 -0
- package/package.json +1 -1
- package/src/CrossDomainAuth.ts +33 -44
- package/src/__tests__/crossDomainAuth.test.ts +33 -16
- package/src/index.ts +24 -0
- package/src/mixins/OxyServices.sso.ts +55 -0
- package/src/mixins/OxyServices.user.ts +26 -0
- package/src/mixins/__tests__/sso.test.ts +41 -0
- package/src/server/index.ts +12 -0
- package/src/session/SessionClient.ts +175 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +79 -0
- package/src/session/__tests__/SessionClient.socket.test.ts +132 -0
- package/src/session/__tests__/SessionClient.state.test.ts +64 -0
- package/src/session/__tests__/sessionIntegration.test.ts +202 -0
- package/src/session/createSessionClient.ts +31 -0
- package/src/session/projectSessionState.ts +83 -0
- package/src/session/sessionClientHost.ts +32 -0
- package/src/session/socketLoader.ts +28 -0
- package/src/utils/__tests__/ssoEstablish.test.ts +204 -0
- package/src/utils/ssoBounce.ts +9 -9
- package/src/utils/ssoEstablish.ts +174 -0
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-Domain Authentication Helper
|
|
3
3
|
*
|
|
4
|
-
* Provides a simplified API for cross-domain SSO authentication
|
|
5
|
-
*
|
|
4
|
+
* Provides a simplified API for cross-domain SSO authentication. The
|
|
5
|
+
* automatic sign-in path uses a full-page redirect through the central IdP
|
|
6
|
+
* (`auth.oxy.so`) — a tokenless, universal mechanism that works in every
|
|
7
|
+
* browser.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* FedCM (`signInWithFedCM`) is intentionally NOT part of the automatic
|
|
10
|
+
* (`'auto'`) path: it is a Chrome-only browser API, and a misconfigured or
|
|
11
|
+
* unreachable FedCM endpoint fails fast and silently, which — combined with a
|
|
12
|
+
* caller's auth-guard effect re-invoking `signIn()` whenever the user is still
|
|
13
|
+
* unauthenticated — produced a real production incident (an accelerating
|
|
14
|
+
* `autoSignIn` → FedCM-fails → redirect retry loop). `signInWithFedCM` remains
|
|
15
|
+
* available for callers that want to opt into it EXPLICITLY
|
|
16
|
+
* (`signIn({ method: 'fedcm' })`).
|
|
9
17
|
*
|
|
10
18
|
* Usage:
|
|
11
19
|
* ```typescript
|
|
@@ -13,7 +21,7 @@
|
|
|
13
21
|
*
|
|
14
22
|
* const auth = new CrossDomainAuth(oxyServices);
|
|
15
23
|
*
|
|
16
|
-
* // Automatic method selection
|
|
24
|
+
* // Automatic method selection (always redirect)
|
|
17
25
|
* const session = await auth.signIn();
|
|
18
26
|
*
|
|
19
27
|
* // Or use a specific method
|
|
@@ -47,18 +55,25 @@ export declare class CrossDomainAuth {
|
|
|
47
55
|
private oxyServices;
|
|
48
56
|
constructor(oxyServices: OxyServices);
|
|
49
57
|
/**
|
|
50
|
-
* Sign in with automatic method selection
|
|
58
|
+
* Sign in with automatic method selection.
|
|
51
59
|
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
60
|
+
* Auto mode always uses the full-page redirect (see the class doc comment
|
|
61
|
+
* for why FedCM was removed from this path). Pass `{ method: 'fedcm' }` to
|
|
62
|
+
* opt into FedCM explicitly.
|
|
55
63
|
*
|
|
56
64
|
* @param options - Authentication options
|
|
57
65
|
* @returns Session with user data and access token
|
|
58
66
|
*/
|
|
59
67
|
signIn(options?: CrossDomainAuthOptions): Promise<SessionLoginResponse | null>;
|
|
60
68
|
/**
|
|
61
|
-
* Automatic sign-in
|
|
69
|
+
* Automatic sign-in.
|
|
70
|
+
*
|
|
71
|
+
* Goes straight to the full-page redirect — the sole automatic method.
|
|
72
|
+
* FedCM is deliberately NOT attempted here (see the class doc comment):
|
|
73
|
+
* it is Chrome-only, and its fast/silent failure mode combined with a
|
|
74
|
+
* caller's auth-guard effect re-invoking `signIn()` produced a real
|
|
75
|
+
* production sign-in loop. Use `signIn({ method: 'fedcm' })` to opt in
|
|
76
|
+
* explicitly.
|
|
62
77
|
*
|
|
63
78
|
* @private
|
|
64
79
|
*/
|
|
@@ -84,8 +99,9 @@ export declare class CrossDomainAuth {
|
|
|
84
99
|
/**
|
|
85
100
|
* Silent sign-in (check for existing session)
|
|
86
101
|
*
|
|
87
|
-
* Tries to automatically sign in without user interaction
|
|
88
|
-
*
|
|
102
|
+
* Tries to automatically sign in without user interaction, via the
|
|
103
|
+
* iframe-based silent auth against the per-apex `/auth/silent` IdP host.
|
|
104
|
+
* FedCM is deliberately NOT attempted here (see the class doc comment).
|
|
89
105
|
*
|
|
90
106
|
* @returns Session if user is already signed in, null otherwise
|
|
91
107
|
*/
|
|
@@ -104,10 +120,14 @@ export declare class CrossDomainAuth {
|
|
|
104
120
|
/**
|
|
105
121
|
* Get recommended authentication method for current environment
|
|
106
122
|
*
|
|
123
|
+
* Redirect is the sole recommended automatic method — it works in every
|
|
124
|
+
* browser, unlike FedCM (Chrome-only). Callers that want FedCM must opt in
|
|
125
|
+
* explicitly via `signIn({ method: 'fedcm' })`.
|
|
126
|
+
*
|
|
107
127
|
* @returns Recommended method name and reason
|
|
108
128
|
*/
|
|
109
129
|
getRecommendedMethod(): {
|
|
110
|
-
method: '
|
|
130
|
+
method: 'redirect';
|
|
111
131
|
reason: string;
|
|
112
132
|
};
|
|
113
133
|
/**
|
package/dist/types/index.d.ts
CHANGED
|
@@ -93,10 +93,17 @@ export { CENTRAL_AUTH_URL, CENTRAL_IDP_APEX, resolveCentralAuthUrl } from './uti
|
|
|
93
93
|
export { parseSsoReturnFragment, consumeSsoReturn } from './utils/ssoReturn';
|
|
94
94
|
export type { SsoReturnKind, SsoReturnResult, ConsumeSsoReturnDeps } from './utils/ssoReturn';
|
|
95
95
|
export { generateSsoState } from './mixins/OxyServices.sso';
|
|
96
|
+
export { establishIdpSessionAfterClaim } from './utils/ssoEstablish';
|
|
97
|
+
export type { SsoEstablishClient, EstablishAfterClaimDeps } from './utils/ssoEstablish';
|
|
96
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';
|
|
97
99
|
export type { SsoBounceGate } from './utils/ssoBounce';
|
|
98
100
|
export { runColdBoot } from './utils/coldBoot';
|
|
99
101
|
export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
|
|
102
|
+
export { SessionClient } from './session/SessionClient';
|
|
103
|
+
export type { TokenTransport, SessionClientHost, SessionClientOptions } from './session/SessionClient';
|
|
104
|
+
export { createSessionClientHost } from './session/sessionClientHost';
|
|
105
|
+
export { createSessionClient } from './session/createSessionClient';
|
|
106
|
+
export { deviceStateToClientSessions, activeSessionIdOf, activeUserOf, accountIdsOf, } from './session/projectSessionState';
|
|
100
107
|
export { packageInfo } from './constants/version';
|
|
101
108
|
import { OxyServices } from './OxyServices';
|
|
102
109
|
export default OxyServices;
|
|
@@ -59,6 +59,30 @@ export declare function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Ba
|
|
|
59
59
|
* @returns The resolved {@link SessionLoginResponse}.
|
|
60
60
|
*/
|
|
61
61
|
exchangeSsoCode(code: string, state?: string): Promise<SessionLoginResponse>;
|
|
62
|
+
/**
|
|
63
|
+
* Mint a server-formed `/sso/establish` URL for the caller's OWN session,
|
|
64
|
+
* bound to an approved RP `origin`.
|
|
65
|
+
*
|
|
66
|
+
* Bearer-authenticated (the session id is taken from the caller's own
|
|
67
|
+
* bearer, server-side — never from any argument). The server validates that
|
|
68
|
+
* `origin` is an approved client origin (and matches the request `Origin`),
|
|
69
|
+
* derives the per-apex IdP host (`auth.<apex>`), mints a short-lived HS256
|
|
70
|
+
* establish-token, and returns a fully-formed
|
|
71
|
+
* `https://<auth-host>/sso/establish?et=…&return_to=<origin>/__oxy/sso-callback&state=<state>`.
|
|
72
|
+
*
|
|
73
|
+
* Used AFTER a web device-flow claim to plant the durable first-party
|
|
74
|
+
* `fedcm_session` cookie so a reload can re-mint a token (see
|
|
75
|
+
* {@link establishIdpSessionAfterClaim}). Cache-free (a POST is never
|
|
76
|
+
* cached, but `cache: false` is explicit).
|
|
77
|
+
*
|
|
78
|
+
* @param origin - The RP origin (`window.location.origin`) to establish for.
|
|
79
|
+
* @param state - The CSRF state echoed back in the callback fragment; the
|
|
80
|
+
* caller persists the SAME value under `ssoStateKey(origin)` so the
|
|
81
|
+
* post-bounce `sso-return` step validates it.
|
|
82
|
+
*/
|
|
83
|
+
requestSsoEstablishUrl(origin: string, state: string): Promise<{
|
|
84
|
+
establishUrl: string;
|
|
85
|
+
}>;
|
|
62
86
|
httpService: import("../HttpService").HttpService;
|
|
63
87
|
cloudURL: string;
|
|
64
88
|
config: import("../OxyServices.base").OxyConfig;
|
|
@@ -327,6 +327,20 @@ export declare function OxyServicesUserMixin<T extends typeof OxyServicesBase>(B
|
|
|
327
327
|
total: number;
|
|
328
328
|
hasMore: boolean;
|
|
329
329
|
}>;
|
|
330
|
+
/**
|
|
331
|
+
* Get the authenticated VIEWER's OWN mutual-follow user ids — the accounts the
|
|
332
|
+
* viewer follows that ALSO follow the viewer back (a bidirectional follow
|
|
333
|
+
* edge). The viewer is derived server-side from the SDK's auth token (never a
|
|
334
|
+
* param), so there is no target id to pass.
|
|
335
|
+
*
|
|
336
|
+
* Returns a bounded, lean list of ids meant to SEED a "Mutuals" feed (the
|
|
337
|
+
* consumer hydrates/ranks the posts itself) — distinct from
|
|
338
|
+
* {@link getUserMutuals}, which returns hydrated "followers you know" DTOs
|
|
339
|
+
* about ANOTHER profile. An anonymous caller resolves to an empty array.
|
|
340
|
+
*/
|
|
341
|
+
getMutualUserIds(params?: {
|
|
342
|
+
limit?: number;
|
|
343
|
+
}): Promise<string[]>;
|
|
330
344
|
/**
|
|
331
345
|
* Get notifications
|
|
332
346
|
*/
|
|
@@ -23,3 +23,5 @@ export type { SafeFetchOptions, SafeFetchResult, SsrfCheckFail, SsrfCheckOk, Ssr
|
|
|
23
23
|
export { createOxyCors } from './cors';
|
|
24
24
|
export type { OxyCorsOptions } from './cors';
|
|
25
25
|
export { verifySecret } from './verifySecret';
|
|
26
|
+
export { registrableApex } from '../utils/fapiAutoDetect';
|
|
27
|
+
export { SSO_CALLBACK_PATH } from '../utils/ssoBounce';
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { type DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import type { MinimalSocket } from './socketLoader';
|
|
3
|
+
export interface TokenTransport {
|
|
4
|
+
/** Ensure this app holds a per-domain access token for state.activeAccountId (mint via FedCM/silent/sso/keychain). Best-effort. */
|
|
5
|
+
ensureActiveToken(state: DeviceSessionState): Promise<void>;
|
|
6
|
+
}
|
|
7
|
+
export interface SessionClientHost {
|
|
8
|
+
makeRequest<T>(method: 'GET' | 'POST', url: string, data?: unknown, options?: {
|
|
9
|
+
cache?: boolean;
|
|
10
|
+
}): Promise<T>;
|
|
11
|
+
getBaseURL(): string;
|
|
12
|
+
getAccessToken(): string | null;
|
|
13
|
+
onTokensChanged(listener: (token: string | null) => void): () => void;
|
|
14
|
+
setTokens(accessToken: string): void;
|
|
15
|
+
getCurrentAccountId(): string | null;
|
|
16
|
+
}
|
|
17
|
+
export interface SessionClientOptions {
|
|
18
|
+
transport?: TokenTransport;
|
|
19
|
+
}
|
|
20
|
+
type StateListener = (state: DeviceSessionState | null) => void;
|
|
21
|
+
export declare class SessionClient {
|
|
22
|
+
protected readonly host: SessionClientHost;
|
|
23
|
+
protected readonly options: SessionClientOptions;
|
|
24
|
+
private state;
|
|
25
|
+
private readonly listeners;
|
|
26
|
+
protected socket: MinimalSocket | null;
|
|
27
|
+
private tokenUnsub;
|
|
28
|
+
private started;
|
|
29
|
+
constructor(host: SessionClientHost, options?: SessionClientOptions);
|
|
30
|
+
getState(): DeviceSessionState | null;
|
|
31
|
+
subscribe(listener: StateListener): () => void;
|
|
32
|
+
protected notify(): void;
|
|
33
|
+
/** Validate + last-writer-wins by revision. Returns true if applied. */
|
|
34
|
+
protected applyState(raw: unknown): boolean;
|
|
35
|
+
/**
|
|
36
|
+
* Validate `{ state, activeToken }`, apply the state, and plant the active token host-side.
|
|
37
|
+
* Token-planting is decoupled from whether `applyState` advanced the revision: a socket push
|
|
38
|
+
* followed by this same `GET /state` fetch returns the SAME revision (applyState no-ops), but
|
|
39
|
+
* the token still needs to be planted. The account-match guard rejects a stale response for an
|
|
40
|
+
* account that is no longer active.
|
|
41
|
+
*/
|
|
42
|
+
private applySync;
|
|
43
|
+
bootstrap(): Promise<void>;
|
|
44
|
+
switchAccount(accountId: string): Promise<void>;
|
|
45
|
+
signOut(target: {
|
|
46
|
+
accountId: string;
|
|
47
|
+
} | {
|
|
48
|
+
all: true;
|
|
49
|
+
}): Promise<void>;
|
|
50
|
+
addCurrentAccount(): Promise<void>;
|
|
51
|
+
start(): Promise<void>;
|
|
52
|
+
stop(): void;
|
|
53
|
+
private connectSocket;
|
|
54
|
+
}
|
|
55
|
+
export {};
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { OxyServices } from '../OxyServices';
|
|
2
|
+
import { SessionClient, type TokenTransport } from './SessionClient';
|
|
3
|
+
import { createSessionClientHost } from './sessionClientHost';
|
|
4
|
+
/**
|
|
5
|
+
* Wires a `SessionClient` over the given `OxyServices` instance: builds the
|
|
6
|
+
* `SessionClientHost` adapter and passes it through together with a
|
|
7
|
+
* caller-supplied `TokenTransport`.
|
|
8
|
+
*
|
|
9
|
+
* The transport is a required parameter (not constructed here) because it is
|
|
10
|
+
* the one piece of this integration that is NOT platform-agnostic: `services`
|
|
11
|
+
* branches native (shared-keychain sign-in) vs. web (silent sign-in), while
|
|
12
|
+
* `auth-sdk` is web-only. Each consumer builds its own transport and passes
|
|
13
|
+
* it in; this factory only wires the platform-agnostic parts (host + client)
|
|
14
|
+
* so neither consumer re-implements them.
|
|
15
|
+
*
|
|
16
|
+
* The host is returned alongside the client (not just the client) so the
|
|
17
|
+
* caller can call `host.setCurrentAccountId(...)` as the active account
|
|
18
|
+
* changes.
|
|
19
|
+
*/
|
|
20
|
+
export declare function createSessionClient(oxyServices: OxyServices, transport: TokenTransport): {
|
|
21
|
+
client: SessionClient;
|
|
22
|
+
host: ReturnType<typeof createSessionClientHost>;
|
|
23
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { DeviceSessionState } from '@oxyhq/contracts';
|
|
2
|
+
import type { ClientSession } from '../models/session';
|
|
3
|
+
import type { User } from '../models/interfaces';
|
|
4
|
+
/**
|
|
5
|
+
* Pure projection helpers: `DeviceSessionState` (the device-scoped
|
|
6
|
+
* multi-account session-sync state produced by `SessionClient`) -> the
|
|
7
|
+
* shapes consumers (`@oxyhq/services`, `@oxyhq/auth`) render today
|
|
8
|
+
* (`ClientSession[]`, an active session id, an active `User`).
|
|
9
|
+
*
|
|
10
|
+
* No I/O. The caller fetches profiles via
|
|
11
|
+
* `oxyServices.getUsersByIds(accountIdsOf(state))` and builds `usersById`
|
|
12
|
+
* from the result before calling `deviceStateToClientSessions` /
|
|
13
|
+
* `activeUserOf`.
|
|
14
|
+
*/
|
|
15
|
+
/**
|
|
16
|
+
* Maps every `SessionAccount` in `state.accounts` to a `ClientSession`.
|
|
17
|
+
*
|
|
18
|
+
* `DeviceSessionState` carries no per-account `expiresAt` / `lastActive` —
|
|
19
|
+
* both are set to `state.updatedAt` (converted to an ISO-8601 string; the
|
|
20
|
+
* wire value is an epoch-ms number) as a provisional value.
|
|
21
|
+
*
|
|
22
|
+
* `usersById` is accepted for signature symmetry with `activeUserOf` even
|
|
23
|
+
* though `ClientSession` only stores `userId` — a session is still
|
|
24
|
+
* projected for an account whose id is absent from `usersById` (no
|
|
25
|
+
* placeholder user is fabricated).
|
|
26
|
+
*/
|
|
27
|
+
export declare function deviceStateToClientSessions(state: DeviceSessionState, usersById: Map<string, User>): ClientSession[];
|
|
28
|
+
/**
|
|
29
|
+
* The active account's `sessionId`, or `null` when there is no state or no
|
|
30
|
+
* active account is set.
|
|
31
|
+
*/
|
|
32
|
+
export declare function activeSessionIdOf(state: DeviceSessionState | null): string | null;
|
|
33
|
+
/**
|
|
34
|
+
* The active account's `User`, resolved from `usersById`. `null` when there
|
|
35
|
+
* is no state, no active account is set, or the active account id is absent
|
|
36
|
+
* from `usersById`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function activeUserOf(state: DeviceSessionState | null, usersById: Map<string, User>): User | null;
|
|
39
|
+
/**
|
|
40
|
+
* All account ids in `state`, suitable for an `oxyServices.getUsersByIds(...)`
|
|
41
|
+
* fetch. `[]` for `null` state.
|
|
42
|
+
*/
|
|
43
|
+
export declare function accountIdsOf(state: DeviceSessionState | null): string[];
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { OxyServices } from '../OxyServices';
|
|
2
|
+
import type { SessionClientHost } from './SessionClient';
|
|
3
|
+
/**
|
|
4
|
+
* Thin `SessionClientHost` adapter over an `OxyServices` instance.
|
|
5
|
+
*
|
|
6
|
+
* `SessionClient` is host-agnostic: it only needs a REST + token surface.
|
|
7
|
+
* `OxyServices` already exposes all of that except `getCurrentAccountId`,
|
|
8
|
+
* which has no direct equivalent — the adapter holds a mutable ref set by
|
|
9
|
+
* the caller (`OxyContext` in `@oxyhq/services`, `WebOxyProvider` in
|
|
10
|
+
* `@oxyhq/auth`) via `setCurrentAccountId`.
|
|
11
|
+
*
|
|
12
|
+
* Shared here (rather than duplicated per consumer) because it is entirely
|
|
13
|
+
* platform-agnostic: every method it calls exists identically on
|
|
14
|
+
* `OxyServices` regardless of host (web, Expo/RN, Node).
|
|
15
|
+
*/
|
|
16
|
+
export declare function createSessionClientHost(oxyServices: OxyServices): SessionClientHost & {
|
|
17
|
+
setCurrentAccountId(id: string | null): void;
|
|
18
|
+
};
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export interface MinimalSocket {
|
|
2
|
+
connected: boolean;
|
|
3
|
+
on(event: string, handler: (...args: unknown[]) => void): void;
|
|
4
|
+
off(event: string, handler?: (...args: unknown[]) => void): void;
|
|
5
|
+
connect(): void;
|
|
6
|
+
disconnect(): void;
|
|
7
|
+
}
|
|
8
|
+
export type SocketIOFactory = (uri: string, opts?: Record<string, unknown>) => MinimalSocket;
|
|
9
|
+
export declare function getSocketIO(): Promise<SocketIOFactory | null>;
|
|
@@ -103,18 +103,18 @@ export declare function ssoPriorSessionKey(origin: string): string;
|
|
|
103
103
|
* per-tab `sessionStorage` the loop-breaker keys use — it must survive a reload.
|
|
104
104
|
*
|
|
105
105
|
* It exists purely to suppress AUTOMATIC silent restore after a deliberate
|
|
106
|
-
* sign-out: a still-live IdP session (the central `fedcm_session`
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
106
|
+
* sign-out: a still-live IdP session (the central `fedcm_session`) would
|
|
107
|
+
* otherwise let the per-apex `/auth/silent` iframe re-mint a session on the
|
|
108
|
+
* very next cold boot, so a user who pressed "Sign out" gets silently signed
|
|
109
|
+
* back in on reload. With this flag set, that silent cold-boot step is
|
|
110
|
+
* skipped while the Gmail-style returning-account fast-path is otherwise
|
|
111
|
+
* preserved.
|
|
112
112
|
*
|
|
113
113
|
* Lifecycle (mirrors the existing gate machinery — set on a definitive event,
|
|
114
114
|
* cleared on its inverse):
|
|
115
115
|
* - SET on EXPLICIT full sign-out (alongside clearing the prior-session hint
|
|
116
116
|
* and the SSO bounce state).
|
|
117
|
-
* - CLEARED on ANY deliberate sign-in (password,
|
|
117
|
+
* - CLEARED on ANY deliberate sign-in (password, account switch, device
|
|
118
118
|
* claim) so a real sign-in fully re-enables silent restore — there is no
|
|
119
119
|
* "stuck signed out" state.
|
|
120
120
|
*
|
|
@@ -206,8 +206,8 @@ export declare function guardActive(storage: Pick<Storage, 'getItem'>, origin: s
|
|
|
206
206
|
* Whether AUTOMATIC silent restore is SUPPRESSED for this origin because the
|
|
207
207
|
* user deliberately signed out (the durable {@link ssoSignedOutKey} flag).
|
|
208
208
|
*
|
|
209
|
-
* When `true`, the silent cold-boot
|
|
210
|
-
* still-live IdP session WITHOUT user intent —
|
|
209
|
+
* When `true`, the silent cold-boot step that can re-mint a session from a
|
|
210
|
+
* still-live IdP session WITHOUT user intent — the per-apex
|
|
211
211
|
* `/auth/silent` iframe — MUST be skipped, so a user who pressed "Sign out" is
|
|
212
212
|
* not silently signed back in on the next reload. Interactive sign-in clears the
|
|
213
213
|
* flag, so this never blocks a deliberate re-sign-in.
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-claim durable-session establish hop (web device-flow / "Sign in with
|
|
3
|
+
* Oxy" QR).
|
|
4
|
+
*
|
|
5
|
+
* A WEB device-flow claim (`claimSessionByToken`) plants only IN-MEMORY tokens:
|
|
6
|
+
* unlike a redirect/FedCM/silent sign-in, it never causes the IdP to plant a
|
|
7
|
+
* `fedcm_session` cookie. So a reload has nothing to restore from — the
|
|
8
|
+
* silent-iframe and `/sso` paths find no IdP session and the session is lost.
|
|
9
|
+
*
|
|
10
|
+
* This primitive closes that gap. AFTER the claim has committed and the session
|
|
11
|
+
* has been durably persisted, it performs ONE top-level establish hop through
|
|
12
|
+
* the RP's own per-apex IdP host (`auth.<rp-apex>`), reusing the EXISTING
|
|
13
|
+
* `/sso/establish` endpoint: the server mints a short-lived, host+audience-bound
|
|
14
|
+
* establish-token and returns a fully-formed establish URL; navigating to it
|
|
15
|
+
* plants the durable first-party `fedcm_session` cookie and bounces back to the
|
|
16
|
+
* RP callback with an opaque code the standard `sso-return` cold-boot step
|
|
17
|
+
* exchanges.
|
|
18
|
+
*
|
|
19
|
+
* It reuses the SAME per-origin `sessionStorage` bounce contract
|
|
20
|
+
* (`ssoStateKey` / `ssoGuardKey` / `ssoDestKey`) that {@link buildSsoBounceUrl}
|
|
21
|
+
* primes for the terminal `/sso` bounce, so the post-bounce `sso-return` step
|
|
22
|
+
* (`consumeSsoReturn`) validates the CSRF `state`, exchanges the code, and
|
|
23
|
+
* restores the user's real destination with no extra wiring.
|
|
24
|
+
*
|
|
25
|
+
* Contract:
|
|
26
|
+
* - WEB only — off-web / native it is a no-op returning `false`.
|
|
27
|
+
* - NEVER fires while sitting on the central IdP origin (that would loop the
|
|
28
|
+
* IdP against itself).
|
|
29
|
+
* - Bounce state is persisted ONLY after the establish-URL request succeeds, so
|
|
30
|
+
* a failed request leaves no stale state behind.
|
|
31
|
+
* - SINGLE attempt: the caller invokes this exactly once per successful claim.
|
|
32
|
+
* On ANY failure it does NOT navigate and returns `false`, leaving the
|
|
33
|
+
* committed in-memory session exactly as-is (the user is no worse off than
|
|
34
|
+
* before this hop existed).
|
|
35
|
+
* - Total: never throws. Failures are reported via {@link deps.onError} only.
|
|
36
|
+
*/
|
|
37
|
+
/**
|
|
38
|
+
* The minimal SDK surface this hop needs: mint a server-formed establish URL
|
|
39
|
+
* bound to the caller's own session for an approved RP origin. Structural so the
|
|
40
|
+
* primitive is unit-testable with a stub and never imports the full client.
|
|
41
|
+
*/
|
|
42
|
+
export interface SsoEstablishClient {
|
|
43
|
+
requestSsoEstablishUrl(origin: string, state: string): Promise<{
|
|
44
|
+
establishUrl: string;
|
|
45
|
+
}>;
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Injectable web seams for {@link establishIdpSessionAfterClaim}. Every seam is
|
|
49
|
+
* overridable so the primitive is fully unit-testable with fakes and so native
|
|
50
|
+
* callers rely on the defaults (which resolve to `window.*` only when a browser
|
|
51
|
+
* is present). Defaults are evaluated lazily inside the function so importing
|
|
52
|
+
* this module never touches `window`.
|
|
53
|
+
*/
|
|
54
|
+
export interface EstablishAfterClaimDeps {
|
|
55
|
+
/** Per-tab SSO bounce store. Default: `window.sessionStorage`. */
|
|
56
|
+
storage?: Pick<Storage, 'getItem' | 'setItem'>;
|
|
57
|
+
/** The current location. Default: `window.location`. */
|
|
58
|
+
location?: Pick<Location, 'origin' | 'href'>;
|
|
59
|
+
/** Top-level navigation seam. Default: {@link ssoNavigate} (`location.assign`). */
|
|
60
|
+
navigate?: (url: string) => void;
|
|
61
|
+
/**
|
|
62
|
+
* Whether the current environment is a web browser with usable
|
|
63
|
+
* `sessionStorage`. Default: `typeof window !== 'undefined' && typeof
|
|
64
|
+
* window.sessionStorage !== 'undefined'`.
|
|
65
|
+
*/
|
|
66
|
+
isWeb?: () => boolean;
|
|
67
|
+
/** CSRF state generator. Default: {@link generateSsoState}. */
|
|
68
|
+
generateState?: () => string;
|
|
69
|
+
/** Epoch-ms clock for the bounce guard. Default: `Date.now`. */
|
|
70
|
+
now?: () => number;
|
|
71
|
+
/**
|
|
72
|
+
* Optional debug hook invoked with the thrown error when the establish
|
|
73
|
+
* request (or state persistence) fails. NEVER rethrown. Default: no-op.
|
|
74
|
+
*/
|
|
75
|
+
onError?: (error: unknown) => void;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Perform the post-claim establish hop. Returns `true` when a navigation to the
|
|
79
|
+
* establish URL was initiated (the document is being torn down and replaced by
|
|
80
|
+
* the IdP), `false` on every no-op / failure path.
|
|
81
|
+
*
|
|
82
|
+
* @param client - The exchange surface (`oxyServices.requestSsoEstablishUrl`).
|
|
83
|
+
* @param deps - Injectable web seams; see {@link EstablishAfterClaimDeps}.
|
|
84
|
+
*/
|
|
85
|
+
export declare function establishIdpSessionAfterClaim(client: SsoEstablishClient, deps?: EstablishAfterClaimDeps): Promise<boolean>;
|
package/package.json
CHANGED
package/src/CrossDomainAuth.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-Domain Authentication Helper
|
|
3
3
|
*
|
|
4
|
-
* Provides a simplified API for cross-domain SSO authentication
|
|
5
|
-
*
|
|
4
|
+
* Provides a simplified API for cross-domain SSO authentication. The
|
|
5
|
+
* automatic sign-in path uses a full-page redirect through the central IdP
|
|
6
|
+
* (`auth.oxy.so`) — a tokenless, universal mechanism that works in every
|
|
7
|
+
* browser.
|
|
6
8
|
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
+
* FedCM (`signInWithFedCM`) is intentionally NOT part of the automatic
|
|
10
|
+
* (`'auto'`) path: it is a Chrome-only browser API, and a misconfigured or
|
|
11
|
+
* unreachable FedCM endpoint fails fast and silently, which — combined with a
|
|
12
|
+
* caller's auth-guard effect re-invoking `signIn()` whenever the user is still
|
|
13
|
+
* unauthenticated — produced a real production incident (an accelerating
|
|
14
|
+
* `autoSignIn` → FedCM-fails → redirect retry loop). `signInWithFedCM` remains
|
|
15
|
+
* available for callers that want to opt into it EXPLICITLY
|
|
16
|
+
* (`signIn({ method: 'fedcm' })`).
|
|
9
17
|
*
|
|
10
18
|
* Usage:
|
|
11
19
|
* ```typescript
|
|
@@ -13,7 +21,7 @@
|
|
|
13
21
|
*
|
|
14
22
|
* const auth = new CrossDomainAuth(oxyServices);
|
|
15
23
|
*
|
|
16
|
-
* // Automatic method selection
|
|
24
|
+
* // Automatic method selection (always redirect)
|
|
17
25
|
* const session = await auth.signIn();
|
|
18
26
|
*
|
|
19
27
|
* // Or use a specific method
|
|
@@ -54,11 +62,11 @@ export class CrossDomainAuth {
|
|
|
54
62
|
constructor(private oxyServices: OxyServices) {}
|
|
55
63
|
|
|
56
64
|
/**
|
|
57
|
-
* Sign in with automatic method selection
|
|
65
|
+
* Sign in with automatic method selection.
|
|
58
66
|
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
*
|
|
67
|
+
* Auto mode always uses the full-page redirect (see the class doc comment
|
|
68
|
+
* for why FedCM was removed from this path). Pass `{ method: 'fedcm' }` to
|
|
69
|
+
* opt into FedCM explicitly.
|
|
62
70
|
*
|
|
63
71
|
* @param options - Authentication options
|
|
64
72
|
* @returns Session with user data and access token
|
|
@@ -80,22 +88,18 @@ export class CrossDomainAuth {
|
|
|
80
88
|
}
|
|
81
89
|
|
|
82
90
|
/**
|
|
83
|
-
* Automatic sign-in
|
|
91
|
+
* Automatic sign-in.
|
|
92
|
+
*
|
|
93
|
+
* Goes straight to the full-page redirect — the sole automatic method.
|
|
94
|
+
* FedCM is deliberately NOT attempted here (see the class doc comment):
|
|
95
|
+
* it is Chrome-only, and its fast/silent failure mode combined with a
|
|
96
|
+
* caller's auth-guard effect re-invoking `signIn()` produced a real
|
|
97
|
+
* production sign-in loop. Use `signIn({ method: 'fedcm' })` to opt in
|
|
98
|
+
* explicitly.
|
|
84
99
|
*
|
|
85
100
|
* @private
|
|
86
101
|
*/
|
|
87
102
|
private async autoSignIn(options: CrossDomainAuthOptions): Promise<SessionLoginResponse | null> {
|
|
88
|
-
// 1. Try FedCM first (best UX, most modern)
|
|
89
|
-
if (this.isFedCMSupported()) {
|
|
90
|
-
try {
|
|
91
|
-
options.onMethodSelected?.('fedcm');
|
|
92
|
-
return await this.signInWithFedCM(options);
|
|
93
|
-
} catch (error) {
|
|
94
|
-
logger.warn('FedCM failed, falling back to redirect', { component: 'CrossDomainAuth', method: 'autoSignIn' }, error);
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
// 2. Fallback to redirect (always works)
|
|
99
103
|
options.onMethodSelected?.('redirect');
|
|
100
104
|
this.signInWithRedirect(options);
|
|
101
105
|
return null;
|
|
@@ -136,25 +140,13 @@ export class CrossDomainAuth {
|
|
|
136
140
|
/**
|
|
137
141
|
* Silent sign-in (check for existing session)
|
|
138
142
|
*
|
|
139
|
-
* Tries to automatically sign in without user interaction
|
|
140
|
-
*
|
|
143
|
+
* Tries to automatically sign in without user interaction, via the
|
|
144
|
+
* iframe-based silent auth against the per-apex `/auth/silent` IdP host.
|
|
145
|
+
* FedCM is deliberately NOT attempted here (see the class doc comment).
|
|
141
146
|
*
|
|
142
147
|
* @returns Session if user is already signed in, null otherwise
|
|
143
148
|
*/
|
|
144
149
|
async silentSignIn(): Promise<SessionLoginResponse | null> {
|
|
145
|
-
// Try FedCM silent sign-in first (if supported)
|
|
146
|
-
if (this.isFedCMSupported()) {
|
|
147
|
-
try {
|
|
148
|
-
const session = await this.oxyServices.silentSignInWithFedCM();
|
|
149
|
-
if (session) {
|
|
150
|
-
return session;
|
|
151
|
-
}
|
|
152
|
-
} catch (error) {
|
|
153
|
-
logger.debug('FedCM silent sign-in did not resolve', { component: 'CrossDomainAuth', method: 'silentSignIn' }, error);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
// Fallback to iframe-based silent auth
|
|
158
150
|
try {
|
|
159
151
|
return await this.oxyServices.silentSignIn();
|
|
160
152
|
} catch (error) {
|
|
@@ -185,16 +177,13 @@ export class CrossDomainAuth {
|
|
|
185
177
|
/**
|
|
186
178
|
* Get recommended authentication method for current environment
|
|
187
179
|
*
|
|
180
|
+
* Redirect is the sole recommended automatic method — it works in every
|
|
181
|
+
* browser, unlike FedCM (Chrome-only). Callers that want FedCM must opt in
|
|
182
|
+
* explicitly via `signIn({ method: 'fedcm' })`.
|
|
183
|
+
*
|
|
188
184
|
* @returns Recommended method name and reason
|
|
189
185
|
*/
|
|
190
|
-
getRecommendedMethod(): { method: '
|
|
191
|
-
if (this.isFedCMSupported()) {
|
|
192
|
-
return {
|
|
193
|
-
method: 'fedcm',
|
|
194
|
-
reason: 'FedCM is supported - provides best UX with browser-native auth',
|
|
195
|
-
};
|
|
196
|
-
}
|
|
197
|
-
|
|
186
|
+
getRecommendedMethod(): { method: 'redirect'; reason: string } {
|
|
198
187
|
if (typeof window !== 'undefined') {
|
|
199
188
|
return {
|
|
200
189
|
method: 'redirect',
|