@oxyhq/core 2.1.2 → 2.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/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/OxyServices.base.js +9 -10
- package/dist/cjs/index.js +9 -1
- package/dist/cjs/mixins/OxyServices.popup.js +15 -4
- package/dist/cjs/mixins/OxyServices.sso.js +142 -0
- package/dist/cjs/mixins/index.js +4 -0
- package/dist/cjs/utils/authWebUrl.js +37 -0
- package/dist/cjs/utils/ssoReturn.js +72 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/OxyServices.base.js +9 -10
- package/dist/esm/index.js +4 -0
- package/dist/esm/mixins/OxyServices.popup.js +15 -4
- package/dist/esm/mixins/OxyServices.sso.js +138 -0
- package/dist/esm/mixins/index.js +4 -0
- package/dist/esm/utils/authWebUrl.js +33 -0
- package/dist/esm/utils/ssoReturn.js +69 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/OxyServices.d.ts +2 -0
- package/dist/types/index.d.ts +4 -0
- package/dist/types/mixins/OxyServices.assets.d.ts +1 -3
- package/dist/types/mixins/OxyServices.popup.d.ts +19 -0
- package/dist/types/mixins/OxyServices.sso.d.ts +111 -0
- package/dist/types/mixins/index.d.ts +2 -1
- package/dist/types/utils/authWebUrl.d.ts +31 -0
- package/dist/types/utils/ssoReturn.d.ts +46 -0
- package/package.json +1 -1
- package/src/OxyServices.base.ts +9 -10
- package/src/OxyServices.ts +4 -0
- package/src/index.ts +7 -0
- package/src/mixins/OxyServices.popup.ts +36 -4
- package/src/mixins/OxyServices.sso.ts +172 -0
- package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +32 -55
- package/src/mixins/__tests__/sso.test.ts +146 -0
- package/src/mixins/index.ts +6 -0
- package/src/utils/__tests__/authWebUrl.test.ts +40 -0
- package/src/utils/__tests__/ssoReturn.test.ts +120 -0
- package/src/utils/authWebUrl.ts +35 -0
- package/src/utils/ssoReturn.ts +94 -0
|
@@ -126,6 +126,8 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
126
126
|
openBlankPopup(width?: number, height?: number): Window | null;
|
|
127
127
|
signInWithRedirect(options?: RedirectAuthOptions): void;
|
|
128
128
|
signUpWithRedirect(options?: RedirectAuthOptions): void;
|
|
129
|
+
exchangeSsoCode(code: string): Promise<SessionLoginResponse>;
|
|
130
|
+
generateSsoState(): string;
|
|
129
131
|
auth(options?: {
|
|
130
132
|
debug?: boolean;
|
|
131
133
|
onError?: (error: unknown) => unknown;
|
package/dist/types/index.d.ts
CHANGED
|
@@ -73,6 +73,10 @@ export { updateAvatarVisibility } from './utils/avatarUtils';
|
|
|
73
73
|
export { buildAccountsArray, createQuickAccount, getAccountDisplayName, getAccountFallbackHandle, formatPublicKeyHandle, mergeAccountsFromRefreshAll, getAccountColor, } from './utils/accountUtils';
|
|
74
74
|
export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
|
|
75
75
|
export { autoDetectAuthWebUrl } from './utils/fapiAutoDetect';
|
|
76
|
+
export { CENTRAL_AUTH_URL, resolveCentralAuthUrl } from './utils/authWebUrl';
|
|
77
|
+
export { parseSsoReturnFragment } from './utils/ssoReturn';
|
|
78
|
+
export type { SsoReturnKind, SsoReturnResult } from './utils/ssoReturn';
|
|
79
|
+
export { generateSsoState } from './mixins/OxyServices.sso';
|
|
76
80
|
export { runColdBoot } from './utils/coldBoot';
|
|
77
81
|
export type { ColdBootStep, ColdBootStepResult, ColdBootSession, ColdBootSkip, ColdBootOutcome, RunColdBootOptions, } from './utils/coldBoot';
|
|
78
82
|
export { packageInfo } from './constants/version';
|
|
@@ -132,9 +132,7 @@ export declare function OxyServicesAssetsMixin<T extends typeof OxyServicesBase>
|
|
|
132
132
|
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
133
133
|
maxRetries?: number;
|
|
134
134
|
retryDelay?: number;
|
|
135
|
-
authTimeoutMs
|
|
136
|
-
* Get asset metadata
|
|
137
|
-
*/: number;
|
|
135
|
+
authTimeoutMs?: number;
|
|
138
136
|
}): Promise<T_1>;
|
|
139
137
|
validate(): Promise<boolean>;
|
|
140
138
|
handleError(error: unknown): Error;
|
|
@@ -25,6 +25,25 @@ export interface PopupAuthOptions {
|
|
|
25
25
|
}
|
|
26
26
|
export interface SilentAuthOptions {
|
|
27
27
|
timeout?: number;
|
|
28
|
+
/**
|
|
29
|
+
* Override the auth-web (IdP) origin used for the silent iframe, instead of
|
|
30
|
+
* the instance's configured `resolveAuthUrl()`.
|
|
31
|
+
*
|
|
32
|
+
* Why this exists: an instance configured with the CENTRAL IdP
|
|
33
|
+
* (`authWebUrl=https://auth.oxy.so`, for the opaque-code `/sso` bounce and
|
|
34
|
+
* FedCM) cannot read the DURABLE per-apex `fedcm_session` cookie via the
|
|
35
|
+
* central host — that cookie is first-party only on `auth.<rp-apex>` (e.g.
|
|
36
|
+
* `auth.mention.earth`). The cross-domain reload-restore path must point the
|
|
37
|
+
* `/auth/silent` iframe at the PER-APEX host so the cookie is same-site to
|
|
38
|
+
* the RP page (first-party under Safari ITP / Firefox TCP) and the restore
|
|
39
|
+
* is NOT a top-level navigation (no flash, works in a backgrounded tab).
|
|
40
|
+
*
|
|
41
|
+
* When provided this value is used BOTH for the iframe `src` AND for the
|
|
42
|
+
* `postMessage` origin validation in {@link waitForIframeAuth}, so the
|
|
43
|
+
* security check still matches the exact origin the iframe was loaded from.
|
|
44
|
+
* Must be an absolute origin (`https://auth.<apex>`); ignored if empty.
|
|
45
|
+
*/
|
|
46
|
+
authWebUrlOverride?: string;
|
|
28
47
|
}
|
|
29
48
|
/**
|
|
30
49
|
* Popup-based Cross-Domain Authentication Mixin
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central Cross-Domain SSO (opaque-code) Mixin
|
|
3
|
+
*
|
|
4
|
+
* Implements the Relying-Party half of TRUE central cross-domain SSO
|
|
5
|
+
* (Google/Meta/Clerk style). The central IdP at `auth.oxy.so` owns the session;
|
|
6
|
+
* an RP bounces a top-level redirect (prompt=none) to `auth.oxy.so/sso`, which
|
|
7
|
+
* returns an OPAQUE single-use code in the redirect fragment. The RP then
|
|
8
|
+
* exchanges that code here for the real session.
|
|
9
|
+
*
|
|
10
|
+
* Security properties:
|
|
11
|
+
* - NO token/JWT ever travels in a URL — only the opaque code does. The real
|
|
12
|
+
* `accessToken` is delivered exclusively in this exchange response body.
|
|
13
|
+
* - The exchange is a CORS POST with NO credentials/cookies — the opaque code
|
|
14
|
+
* is the only bearer of authority, and the central store burns it atomically
|
|
15
|
+
* (single-use). Sending no cookies keeps the request a clean, ambient-
|
|
16
|
+
* authority-free bearer exchange that the central `POST /sso/exchange`
|
|
17
|
+
* endpoint validates by `Origin` against the code's bound `clientOrigin`.
|
|
18
|
+
* - The code is minted server-side bound to the RP origin and expires in
|
|
19
|
+
* seconds, so a leaked code is useless cross-origin and short-lived.
|
|
20
|
+
*
|
|
21
|
+
* On success the mixin plants the returned access token via
|
|
22
|
+
* `httpService.setTokens(...)` — mirroring `exchangeIdTokenForSession` /
|
|
23
|
+
* `verifyChallenge` — so callers do NOT need to plant tokens manually.
|
|
24
|
+
*/
|
|
25
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
26
|
+
import type { SessionLoginResponse } from '../models/session';
|
|
27
|
+
/**
|
|
28
|
+
* Generate a cryptographically secure state value for the SSO bounce.
|
|
29
|
+
*
|
|
30
|
+
* Exposed as a module-level helper (in addition to the instance method below)
|
|
31
|
+
* so consumers that do not yet hold an `OxyServices` instance can still mint a
|
|
32
|
+
* bounce state. Reuses the same `crypto.randomUUID`-based generator the popup
|
|
33
|
+
* mixin uses for its CSRF state, with a `getRandomValues` fallback.
|
|
34
|
+
*/
|
|
35
|
+
export declare function generateSsoState(): string;
|
|
36
|
+
export declare function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Base: T): {
|
|
37
|
+
new (...args: any[]): {
|
|
38
|
+
/**
|
|
39
|
+
* Generate cryptographically secure state for the SSO bounce (CSRF
|
|
40
|
+
* protection). Delegates to the module-level {@link generateSsoState}
|
|
41
|
+
* helper, which uses the same `crypto.randomUUID`-based generator the popup
|
|
42
|
+
* mixin's `generateState()` uses — one shared secure-random implementation.
|
|
43
|
+
*/
|
|
44
|
+
generateSsoState(): string;
|
|
45
|
+
/**
|
|
46
|
+
* Exchange an opaque single-use SSO code for the real Oxy session.
|
|
47
|
+
*
|
|
48
|
+
* POSTs `{ code }` to `${getSessionBaseUrl()}/sso/exchange` as a CORS
|
|
49
|
+
* request with NO credentials/cookies. On success the returned access token
|
|
50
|
+
* is planted via `httpService.setTokens(...)` (matching
|
|
51
|
+
* `exchangeIdTokenForSession` / `verifyChallenge`), so callers do not need
|
|
52
|
+
* to plant tokens manually.
|
|
53
|
+
*
|
|
54
|
+
* @param code - The opaque single-use code delivered in the SSO return
|
|
55
|
+
* fragment (see {@link parseSsoReturnFragment}). The central store burns
|
|
56
|
+
* it atomically on exchange.
|
|
57
|
+
* @returns The resolved {@link SessionLoginResponse}.
|
|
58
|
+
*/
|
|
59
|
+
exchangeSsoCode(code: string): Promise<SessionLoginResponse>;
|
|
60
|
+
httpService: import("../HttpService").HttpService;
|
|
61
|
+
cloudURL: string;
|
|
62
|
+
config: import("../OxyServices.base").OxyConfig;
|
|
63
|
+
__resetTokensForTests(): void;
|
|
64
|
+
makeRequest<T_1>(method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE", url: string, data?: any, options?: import("../HttpService").RequestOptions): Promise<T_1>;
|
|
65
|
+
getBaseURL(): string;
|
|
66
|
+
getSessionBaseUrl(): string;
|
|
67
|
+
getClient(): import("../HttpService").HttpService;
|
|
68
|
+
getMetrics(): {
|
|
69
|
+
totalRequests: number;
|
|
70
|
+
successfulRequests: number;
|
|
71
|
+
failedRequests: number;
|
|
72
|
+
cacheHits: number;
|
|
73
|
+
cacheMisses: number;
|
|
74
|
+
averageResponseTime: number;
|
|
75
|
+
};
|
|
76
|
+
clearCache(): void;
|
|
77
|
+
clearCacheEntry(key: string): void;
|
|
78
|
+
clearCacheByPrefix(prefix: string): number;
|
|
79
|
+
getCacheStats(): {
|
|
80
|
+
size: number;
|
|
81
|
+
hits: number;
|
|
82
|
+
misses: number;
|
|
83
|
+
hitRate: number;
|
|
84
|
+
};
|
|
85
|
+
getCloudURL(): string;
|
|
86
|
+
setTokens(accessToken: string, refreshToken?: string): void;
|
|
87
|
+
clearTokens(): void;
|
|
88
|
+
onTokensChanged(listener: (accessToken: string | null) => void): () => void;
|
|
89
|
+
_cachedUserId: string | null | undefined;
|
|
90
|
+
_cachedAccessToken: string | null;
|
|
91
|
+
getCurrentUserId(): string | null;
|
|
92
|
+
hasValidToken(): boolean;
|
|
93
|
+
getAccessToken(): string | null;
|
|
94
|
+
setActingAs(userId: string | null): void;
|
|
95
|
+
getActingAs(): string | null;
|
|
96
|
+
waitForAuth(timeoutMs?: number): Promise<boolean>;
|
|
97
|
+
withAuthRetry<T_1>(operation: () => Promise<T_1>, operationName: string, options?: {
|
|
98
|
+
maxRetries?: number;
|
|
99
|
+
retryDelay?: number;
|
|
100
|
+
authTimeoutMs?: number;
|
|
101
|
+
}): Promise<T_1>;
|
|
102
|
+
validate(): Promise<boolean>;
|
|
103
|
+
handleError(error: unknown): Error;
|
|
104
|
+
healthCheck(): Promise<{
|
|
105
|
+
status: string;
|
|
106
|
+
users?: number;
|
|
107
|
+
timestamp?: string;
|
|
108
|
+
[key: string]: any;
|
|
109
|
+
}>;
|
|
110
|
+
};
|
|
111
|
+
} & T;
|
|
@@ -9,6 +9,7 @@ import { OxyServicesAuthMixin } from './OxyServices.auth';
|
|
|
9
9
|
import { OxyServicesFedCMMixin } from './OxyServices.fedcm';
|
|
10
10
|
import { OxyServicesPopupAuthMixin } from './OxyServices.popup';
|
|
11
11
|
import { OxyServicesRedirectAuthMixin } from './OxyServices.redirect';
|
|
12
|
+
import { OxyServicesSsoMixin } from './OxyServices.sso';
|
|
12
13
|
import { OxyServicesUserMixin } from './OxyServices.user';
|
|
13
14
|
import { OxyServicesPrivacyMixin } from './OxyServices.privacy';
|
|
14
15
|
import { OxyServicesLanguageMixin } from './OxyServices.language';
|
|
@@ -35,7 +36,7 @@ import { OxyServicesAppDataMixin } from './OxyServices.appData';
|
|
|
35
36
|
* If you add a new mixin to `MIXIN_PIPELINE`, add it here too so its methods
|
|
36
37
|
* are visible without a cast.
|
|
37
38
|
*/
|
|
38
|
-
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPopupAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesKarmaMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeveloperMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
39
|
+
type AllMixinInstances = InstanceType<ReturnType<typeof OxyServicesAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFedCMMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPopupAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesRedirectAuthMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSsoMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUserMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPrivacyMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLanguageMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesPaymentMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesKarmaMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAssetsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDeveloperMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesLocationMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAnalyticsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesDevicesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesSecurityMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesFeaturesMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesTopicsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesManagedAccountsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesContactsMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesAppDataMixin<typeof OxyServicesBase>>> & InstanceType<ReturnType<typeof OxyServicesUtilityMixin<typeof OxyServicesBase>>>;
|
|
39
40
|
/**
|
|
40
41
|
* Constructor type for the fully composed mixin pipeline. Each mixin returns
|
|
41
42
|
* a new constructor that augments its input; reducing across the pipeline
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central IdP (auth web) URL resolution for cross-domain SSO.
|
|
3
|
+
*
|
|
4
|
+
* The Oxy ecosystem runs a single, central Identity Provider at
|
|
5
|
+
* `auth.oxy.so`. For TRUE central cross-domain SSO (Google/Meta/Clerk style),
|
|
6
|
+
* FedCM and the opaque-code SSO bounce always target this one origin — it owns
|
|
7
|
+
* the host-only `fedcm_session` cookie and the central session store reachable
|
|
8
|
+
* via `api.oxy.so`. Relying Parties (mention.earth, homiio.com, alia.onl, …)
|
|
9
|
+
* delegate to it rather than standing up a per-apex IdP.
|
|
10
|
+
*
|
|
11
|
+
* This module is intentionally pure: it performs no DOM access, reads no
|
|
12
|
+
* `window`/`location`, and has no side effects. It is the single source of
|
|
13
|
+
* truth for the central IdP origin so call sites never hardcode the literal.
|
|
14
|
+
*
|
|
15
|
+
* Note: this is distinct from `autoDetectAuthWebUrl` (per-apex `auth.<rp-apex>`
|
|
16
|
+
* derivation). The central-SSO path deliberately does NOT auto-detect per-apex
|
|
17
|
+
* IdPs — it is central only. An explicitly-configured `authWebUrl` still wins.
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The canonical central Identity Provider origin for the Oxy ecosystem.
|
|
21
|
+
* No trailing slash.
|
|
22
|
+
*/
|
|
23
|
+
export declare const CENTRAL_AUTH_URL = "https://auth.oxy.so";
|
|
24
|
+
/**
|
|
25
|
+
* Resolve the central IdP origin, honouring an explicit override.
|
|
26
|
+
*
|
|
27
|
+
* @param explicit - A caller-supplied auth web URL, or `undefined`/empty to use
|
|
28
|
+
* the central default. An explicit non-empty value always wins.
|
|
29
|
+
* @returns The explicit value when provided, otherwise {@link CENTRAL_AUTH_URL}.
|
|
30
|
+
*/
|
|
31
|
+
export declare function resolveCentralAuthUrl(explicit?: string): string;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parse the SSO return fragment delivered by the central IdP.
|
|
3
|
+
*
|
|
4
|
+
* After a top-level redirect bounce to `auth.oxy.so/sso` (prompt=none), the
|
|
5
|
+
* central IdP returns the Relying Party to its `redirect_uri` with the result
|
|
6
|
+
* encoded in the URL fragment (the `#…` part). The fragment is used — not a
|
|
7
|
+
* query string — so the opaque single-use code never reaches a server access
|
|
8
|
+
* log, a `Referer` header, or browser history in a recoverable form.
|
|
9
|
+
*
|
|
10
|
+
* Three outcomes are possible:
|
|
11
|
+
* - `#oxy_sso=ok&code=<opaque>&state=<state>` — the IdP had a session; the RP
|
|
12
|
+
* exchanges `code` (via `oxy.exchangeSsoCode`) for the real session. NO
|
|
13
|
+
* token/JWT ever appears in the URL — only the opaque code.
|
|
14
|
+
* - `#oxy_sso=none&state=<state>` — the IdP had no session (prompt=none, user
|
|
15
|
+
* not signed in centrally). The RP shows its own signed-out UI.
|
|
16
|
+
* - `#oxy_sso=error&state=<state>` — the bounce failed. The RP recovers.
|
|
17
|
+
*
|
|
18
|
+
* This parser is pure and defensive: it never throws, and `kind` is strictly
|
|
19
|
+
* one of `'ok' | 'none' | 'error'`. It returns `null` when the fragment is not
|
|
20
|
+
* an oxy_sso fragment at all (i.e. `oxy_sso` is absent or an unrecognised
|
|
21
|
+
* value), so the caller can ignore unrelated fragments without special-casing.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* The recognised outcomes of an SSO bounce.
|
|
25
|
+
*/
|
|
26
|
+
export type SsoReturnKind = 'ok' | 'none' | 'error';
|
|
27
|
+
/**
|
|
28
|
+
* The parsed result of an SSO return fragment.
|
|
29
|
+
*
|
|
30
|
+
* `code` is present only for `kind: 'ok'`. `state` echoes the CSRF state the RP
|
|
31
|
+
* generated for the bounce (when the IdP round-tripped it).
|
|
32
|
+
*/
|
|
33
|
+
export interface SsoReturnResult {
|
|
34
|
+
kind: SsoReturnKind;
|
|
35
|
+
code?: string;
|
|
36
|
+
state?: string;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Parse an SSO return fragment.
|
|
40
|
+
*
|
|
41
|
+
* @param hash - The URL fragment, with or without the leading `#`
|
|
42
|
+
* (e.g. `location.hash`). May be `undefined`/empty.
|
|
43
|
+
* @returns The parsed result when `hash` is a recognised oxy_sso fragment,
|
|
44
|
+
* otherwise `null`. Never throws.
|
|
45
|
+
*/
|
|
46
|
+
export declare function parseSsoReturnFragment(hash: string | undefined | null): SsoReturnResult | null;
|
package/package.json
CHANGED
package/src/OxyServices.base.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { OxyConfig as OxyConfigBase, ApiError, User } from './models/interf
|
|
|
8
8
|
import { handleHttpError } from './utils/errorUtils';
|
|
9
9
|
import { HttpService, type RequestOptions } from './HttpService';
|
|
10
10
|
import { OxyAuthenticationError, OxyAuthenticationTimeoutError } from './OxyServices.errors';
|
|
11
|
-
import {
|
|
11
|
+
import { resolveCentralAuthUrl } from './utils/authWebUrl';
|
|
12
12
|
|
|
13
13
|
export interface OxyConfig extends OxyConfigBase {
|
|
14
14
|
cloudURL?: string;
|
|
@@ -36,18 +36,17 @@ export class OxyServicesBase {
|
|
|
36
36
|
throw new Error('OxyConfig is required');
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
-
//
|
|
40
|
-
// pin
|
|
41
|
-
//
|
|
42
|
-
// `
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
// `DEFAULT_AUTH_URL` fallback in effect — native behavior is unchanged.
|
|
39
|
+
// Default `authWebUrl` to the CENTRAL IdP (`auth.oxy.so`) when the caller
|
|
40
|
+
// did not pin it explicitly. TRUE central cross-domain SSO (Google/Meta/
|
|
41
|
+
// Clerk style) routes every RP through the one central IdP — it owns the
|
|
42
|
+
// host-only `fedcm_session` cookie and the central session store — so the
|
|
43
|
+
// SDK no longer derives a per-apex `auth.<rp-apex>` IdP by default.
|
|
44
|
+
// `autoDetectAuthWebUrl` is still exported for any call site that opts into
|
|
45
|
+
// per-apex resolution, but it is NOT the constructor default anymore.
|
|
47
46
|
// An explicit `authWebUrl` always wins (we only fill it when absent).
|
|
48
47
|
const resolvedConfig: OxyConfig = config.authWebUrl
|
|
49
48
|
? config
|
|
50
|
-
: { ...config, authWebUrl:
|
|
49
|
+
: { ...config, authWebUrl: resolveCentralAuthUrl(config.authWebUrl) };
|
|
51
50
|
|
|
52
51
|
this.config = resolvedConfig;
|
|
53
52
|
this.cloudURL = resolvedConfig.cloudURL || 'https://cloud.oxy.so';
|
package/src/OxyServices.ts
CHANGED
|
@@ -151,6 +151,10 @@ export interface OxyServices extends InstanceType<ReturnType<typeof composeOxySe
|
|
|
151
151
|
signInWithRedirect(options?: RedirectAuthOptions): void;
|
|
152
152
|
signUpWithRedirect(options?: RedirectAuthOptions): void;
|
|
153
153
|
|
|
154
|
+
// Central cross-domain SSO (opaque single-use code exchange)
|
|
155
|
+
exchangeSsoCode(code: string): Promise<SessionLoginResponse>;
|
|
156
|
+
generateSsoState(): string;
|
|
157
|
+
|
|
154
158
|
// Express.js middleware
|
|
155
159
|
auth(options?: {
|
|
156
160
|
debug?: boolean;
|
package/src/index.ts
CHANGED
|
@@ -369,6 +369,13 @@ export type { QuickAccount, DisplayNameUserShape } from './utils/accountUtils';
|
|
|
369
369
|
// Cross-domain SSO infrastructure
|
|
370
370
|
// ---------------------------------------------------------------------------
|
|
371
371
|
export { autoDetectAuthWebUrl } from './utils/fapiAutoDetect';
|
|
372
|
+
|
|
373
|
+
// Central cross-domain SSO (opaque single-use code bounce via auth.oxy.so)
|
|
374
|
+
export { CENTRAL_AUTH_URL, resolveCentralAuthUrl } from './utils/authWebUrl';
|
|
375
|
+
export { parseSsoReturnFragment } from './utils/ssoReturn';
|
|
376
|
+
export type { SsoReturnKind, SsoReturnResult } from './utils/ssoReturn';
|
|
377
|
+
export { generateSsoState } from './mixins/OxyServices.sso';
|
|
378
|
+
|
|
372
379
|
export { runColdBoot } from './utils/coldBoot';
|
|
373
380
|
export type {
|
|
374
381
|
ColdBootStep,
|
|
@@ -31,6 +31,25 @@ export interface PopupAuthOptions {
|
|
|
31
31
|
|
|
32
32
|
export interface SilentAuthOptions {
|
|
33
33
|
timeout?: number;
|
|
34
|
+
/**
|
|
35
|
+
* Override the auth-web (IdP) origin used for the silent iframe, instead of
|
|
36
|
+
* the instance's configured `resolveAuthUrl()`.
|
|
37
|
+
*
|
|
38
|
+
* Why this exists: an instance configured with the CENTRAL IdP
|
|
39
|
+
* (`authWebUrl=https://auth.oxy.so`, for the opaque-code `/sso` bounce and
|
|
40
|
+
* FedCM) cannot read the DURABLE per-apex `fedcm_session` cookie via the
|
|
41
|
+
* central host — that cookie is first-party only on `auth.<rp-apex>` (e.g.
|
|
42
|
+
* `auth.mention.earth`). The cross-domain reload-restore path must point the
|
|
43
|
+
* `/auth/silent` iframe at the PER-APEX host so the cookie is same-site to
|
|
44
|
+
* the RP page (first-party under Safari ITP / Firefox TCP) and the restore
|
|
45
|
+
* is NOT a top-level navigation (no flash, works in a backgrounded tab).
|
|
46
|
+
*
|
|
47
|
+
* When provided this value is used BOTH for the iframe `src` AND for the
|
|
48
|
+
* `postMessage` origin validation in {@link waitForIframeAuth}, so the
|
|
49
|
+
* security check still matches the exact origin the iframe was loaded from.
|
|
50
|
+
* Must be an absolute origin (`https://auth.<apex>`); ignored if empty.
|
|
51
|
+
*/
|
|
52
|
+
authWebUrlOverride?: string;
|
|
34
53
|
}
|
|
35
54
|
|
|
36
55
|
/**
|
|
@@ -244,6 +263,16 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
|
|
|
244
263
|
const nonce = this.generateNonce();
|
|
245
264
|
const clientId = window.location.origin;
|
|
246
265
|
|
|
266
|
+
// Resolve the IdP origin for the iframe. An explicit per-apex override (the
|
|
267
|
+
// durable cross-domain reload path — see `SilentAuthOptions.authWebUrlOverride`)
|
|
268
|
+
// wins over the instance's configured central auth URL. The SAME origin is
|
|
269
|
+
// handed to `waitForIframeAuth` so the postMessage origin check matches the
|
|
270
|
+
// exact host the iframe was loaded from.
|
|
271
|
+
const authOrigin =
|
|
272
|
+
options.authWebUrlOverride && options.authWebUrlOverride.length > 0
|
|
273
|
+
? options.authWebUrlOverride
|
|
274
|
+
: this.resolveAuthUrl();
|
|
275
|
+
|
|
247
276
|
const iframe = document.createElement('iframe');
|
|
248
277
|
iframe.style.display = 'none';
|
|
249
278
|
iframe.style.position = 'absolute';
|
|
@@ -251,13 +280,13 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
|
|
|
251
280
|
iframe.style.height = '0';
|
|
252
281
|
iframe.style.border = 'none';
|
|
253
282
|
|
|
254
|
-
const silentUrl = `${
|
|
283
|
+
const silentUrl = `${authOrigin}/auth/silent?` + `client_id=${encodeURIComponent(clientId)}&` + `nonce=${nonce}`;
|
|
255
284
|
|
|
256
285
|
iframe.src = silentUrl;
|
|
257
286
|
document.body.appendChild(iframe);
|
|
258
287
|
|
|
259
288
|
try {
|
|
260
|
-
const session = await this.waitForIframeAuth(iframe, timeout,
|
|
289
|
+
const session = await this.waitForIframeAuth(iframe, timeout, authOrigin);
|
|
261
290
|
|
|
262
291
|
// Bail early on incomplete responses. The iframe contract requires
|
|
263
292
|
// both an access token and a session id; anything less is unusable.
|
|
@@ -475,8 +504,11 @@ export function OxyServicesPopupAuthMixin<T extends typeof OxyServicesBase>(Base
|
|
|
475
504
|
}, timeout);
|
|
476
505
|
|
|
477
506
|
const messageHandler = (event: MessageEvent) => {
|
|
478
|
-
// Verify origin
|
|
479
|
-
|
|
507
|
+
// Verify origin against the EXACT host the iframe was loaded from
|
|
508
|
+
// (`expectedOrigin`). For the per-apex durable-restore path this is
|
|
509
|
+
// `auth.<rp-apex>`, not the instance's central `resolveAuthUrl()` — so
|
|
510
|
+
// we must honour the caller-supplied origin, never re-derive it here.
|
|
511
|
+
if (event.origin !== expectedOrigin) {
|
|
480
512
|
return;
|
|
481
513
|
}
|
|
482
514
|
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Central Cross-Domain SSO (opaque-code) Mixin
|
|
3
|
+
*
|
|
4
|
+
* Implements the Relying-Party half of TRUE central cross-domain SSO
|
|
5
|
+
* (Google/Meta/Clerk style). The central IdP at `auth.oxy.so` owns the session;
|
|
6
|
+
* an RP bounces a top-level redirect (prompt=none) to `auth.oxy.so/sso`, which
|
|
7
|
+
* returns an OPAQUE single-use code in the redirect fragment. The RP then
|
|
8
|
+
* exchanges that code here for the real session.
|
|
9
|
+
*
|
|
10
|
+
* Security properties:
|
|
11
|
+
* - NO token/JWT ever travels in a URL — only the opaque code does. The real
|
|
12
|
+
* `accessToken` is delivered exclusively in this exchange response body.
|
|
13
|
+
* - The exchange is a CORS POST with NO credentials/cookies — the opaque code
|
|
14
|
+
* is the only bearer of authority, and the central store burns it atomically
|
|
15
|
+
* (single-use). Sending no cookies keeps the request a clean, ambient-
|
|
16
|
+
* authority-free bearer exchange that the central `POST /sso/exchange`
|
|
17
|
+
* endpoint validates by `Origin` against the code's bound `clientOrigin`.
|
|
18
|
+
* - The code is minted server-side bound to the RP origin and expires in
|
|
19
|
+
* seconds, so a leaked code is useless cross-origin and short-lived.
|
|
20
|
+
*
|
|
21
|
+
* On success the mixin plants the returned access token via
|
|
22
|
+
* `httpService.setTokens(...)` — mirroring `exchangeIdTokenForSession` /
|
|
23
|
+
* `verifyChallenge` — so callers do NOT need to plant tokens manually.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import type { OxyServicesBase } from '../OxyServices.base';
|
|
27
|
+
import type { SessionLoginResponse, MinimalUserData } from '../models/session';
|
|
28
|
+
import { createDebugLogger } from '../shared/utils/debugUtils';
|
|
29
|
+
|
|
30
|
+
const debug = createDebugLogger('SSO');
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Wire shape of `POST /sso/exchange`. `expiresAt` and `authuser` are optional
|
|
34
|
+
* (the central store may omit them for legacy single-slot sessions).
|
|
35
|
+
*/
|
|
36
|
+
interface SsoExchangeWireResponse {
|
|
37
|
+
accessToken: string;
|
|
38
|
+
sessionId: string;
|
|
39
|
+
user: {
|
|
40
|
+
id?: string;
|
|
41
|
+
_id?: string;
|
|
42
|
+
username?: string;
|
|
43
|
+
avatar?: string;
|
|
44
|
+
};
|
|
45
|
+
expiresAt?: string;
|
|
46
|
+
authuser?: number;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Generate a cryptographically secure state value for the SSO bounce.
|
|
51
|
+
*
|
|
52
|
+
* Exposed as a module-level helper (in addition to the instance method below)
|
|
53
|
+
* so consumers that do not yet hold an `OxyServices` instance can still mint a
|
|
54
|
+
* bounce state. Reuses the same `crypto.randomUUID`-based generator the popup
|
|
55
|
+
* mixin uses for its CSRF state, with a `getRandomValues` fallback.
|
|
56
|
+
*/
|
|
57
|
+
export function generateSsoState(): string {
|
|
58
|
+
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
59
|
+
return crypto.randomUUID();
|
|
60
|
+
}
|
|
61
|
+
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
62
|
+
const bytes = new Uint8Array(16);
|
|
63
|
+
crypto.getRandomValues(bytes);
|
|
64
|
+
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
65
|
+
}
|
|
66
|
+
throw new Error('No secure random source available for SSO state generation');
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function OxyServicesSsoMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
70
|
+
return class extends Base {
|
|
71
|
+
constructor(...args: any[]) {
|
|
72
|
+
super(...(args as [any]));
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Generate cryptographically secure state for the SSO bounce (CSRF
|
|
77
|
+
* protection). Delegates to the module-level {@link generateSsoState}
|
|
78
|
+
* helper, which uses the same `crypto.randomUUID`-based generator the popup
|
|
79
|
+
* mixin's `generateState()` uses — one shared secure-random implementation.
|
|
80
|
+
*/
|
|
81
|
+
public generateSsoState(): string {
|
|
82
|
+
return generateSsoState();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Exchange an opaque single-use SSO code for the real Oxy session.
|
|
87
|
+
*
|
|
88
|
+
* POSTs `{ code }` to `${getSessionBaseUrl()}/sso/exchange` as a CORS
|
|
89
|
+
* request with NO credentials/cookies. On success the returned access token
|
|
90
|
+
* is planted via `httpService.setTokens(...)` (matching
|
|
91
|
+
* `exchangeIdTokenForSession` / `verifyChallenge`), so callers do not need
|
|
92
|
+
* to plant tokens manually.
|
|
93
|
+
*
|
|
94
|
+
* @param code - The opaque single-use code delivered in the SSO return
|
|
95
|
+
* fragment (see {@link parseSsoReturnFragment}). The central store burns
|
|
96
|
+
* it atomically on exchange.
|
|
97
|
+
* @returns The resolved {@link SessionLoginResponse}.
|
|
98
|
+
*/
|
|
99
|
+
public async exchangeSsoCode(code: string): Promise<SessionLoginResponse> {
|
|
100
|
+
if (typeof code !== 'string' || code.length === 0) {
|
|
101
|
+
throw this.handleError(new Error('exchangeSsoCode requires a non-empty code'));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const url = `${this.getSessionBaseUrl().replace(/\/$/, '')}/sso/exchange`;
|
|
105
|
+
debug.log('Exchanging SSO code for session...');
|
|
106
|
+
|
|
107
|
+
let response: Response;
|
|
108
|
+
try {
|
|
109
|
+
response = await fetch(url, {
|
|
110
|
+
method: 'POST',
|
|
111
|
+
// No cookies: the opaque code is the sole bearer of authority and the
|
|
112
|
+
// server validates by Origin against the code's bound clientOrigin.
|
|
113
|
+
credentials: 'omit',
|
|
114
|
+
headers: {
|
|
115
|
+
'Content-Type': 'application/json',
|
|
116
|
+
Accept: 'application/json',
|
|
117
|
+
},
|
|
118
|
+
body: JSON.stringify({ code }),
|
|
119
|
+
});
|
|
120
|
+
} catch (error) {
|
|
121
|
+
debug.error('SSO exchange request failed:', error instanceof Error ? error.message : String(error));
|
|
122
|
+
throw this.handleError(error);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!response.ok) {
|
|
126
|
+
throw this.handleError(new Error(`SSO exchange failed with HTTP ${response.status}`));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
let payload: SsoExchangeWireResponse;
|
|
130
|
+
try {
|
|
131
|
+
payload = (await response.json()) as SsoExchangeWireResponse;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw this.handleError(error);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (!payload || typeof payload.accessToken !== 'string' || payload.accessToken.length === 0) {
|
|
137
|
+
throw this.handleError(new Error('SSO exchange returned no access token'));
|
|
138
|
+
}
|
|
139
|
+
if (typeof payload.sessionId !== 'string' || payload.sessionId.length === 0) {
|
|
140
|
+
throw this.handleError(new Error('SSO exchange returned no sessionId'));
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const userId = payload.user?.id ?? payload.user?._id;
|
|
144
|
+
if (!userId || typeof payload.user?.username !== 'string') {
|
|
145
|
+
throw this.handleError(new Error('SSO exchange returned an invalid user'));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const user: MinimalUserData = {
|
|
149
|
+
id: userId,
|
|
150
|
+
username: payload.user.username,
|
|
151
|
+
avatar: payload.user.avatar,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
// Plant the access token exactly like exchangeIdTokenForSession does.
|
|
155
|
+
// The SSO exchange does not return a refresh token (the central store
|
|
156
|
+
// holds the refresh credential), so default it to an empty string.
|
|
157
|
+
this.httpService.setTokens(payload.accessToken, '');
|
|
158
|
+
|
|
159
|
+
debug.log('SSO exchange complete:', { hasSession: !!payload.sessionId });
|
|
160
|
+
|
|
161
|
+
const session: SessionLoginResponse = {
|
|
162
|
+
sessionId: payload.sessionId,
|
|
163
|
+
deviceId: '',
|
|
164
|
+
expiresAt: payload.expiresAt ?? '',
|
|
165
|
+
user,
|
|
166
|
+
accessToken: payload.accessToken,
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
return session;
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|