@oxyhq/core 5.4.3 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cjs/.tsbuildinfo +1 -1
- package/dist/cjs/HttpService.js +6 -3
- package/dist/cjs/OxyServices.base.js +7 -102
- package/dist/cjs/boot/coldBootV2.js +350 -0
- package/dist/cjs/boot/deviceBootReturn.js +152 -0
- package/dist/cjs/crypto/keyManager.js +95 -0
- package/dist/cjs/i18n/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/es-ES.json +13 -1
- package/dist/cjs/i18n/locales/locales/en-US.json +13 -1
- package/dist/cjs/i18n/locales/locales/es-ES.json +13 -1
- package/dist/cjs/index.js +47 -43
- package/dist/cjs/mixins/OxyServices.accounts.js +6 -13
- package/dist/cjs/mixins/OxyServices.auth.js +66 -201
- package/dist/cjs/mixins/OxyServices.authorizedApps.js +38 -0
- package/dist/cjs/mixins/OxyServices.deviceBoot.js +119 -0
- package/dist/cjs/mixins/index.js +13 -17
- package/dist/cjs/session/SessionClient.js +44 -2
- package/dist/cjs/session/authStateStore.js +284 -0
- package/dist/cjs/session/createSessionClient.js +8 -2
- package/dist/cjs/session/refresh.js +264 -0
- package/dist/cjs/utils/accountUtils.js +1 -55
- package/dist/cjs/utils/authWebUrl.js +6 -15
- package/dist/cjs/utils/fapiAutoDetect.js +16 -64
- package/dist/cjs/utils/platform.js +19 -0
- package/dist/cjs/utils/ssoBounce.js +15 -340
- package/dist/cjs/utils/validationUtils.js +57 -0
- package/dist/esm/.tsbuildinfo +1 -1
- package/dist/esm/HttpService.js +6 -3
- package/dist/esm/OxyServices.base.js +7 -102
- package/dist/esm/boot/coldBootV2.js +344 -0
- package/dist/esm/boot/deviceBootReturn.js +146 -0
- package/dist/esm/crypto/keyManager.js +95 -0
- package/dist/esm/i18n/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/es-ES.json +13 -1
- package/dist/esm/i18n/locales/locales/en-US.json +13 -1
- package/dist/esm/i18n/locales/locales/es-ES.json +13 -1
- package/dist/esm/index.js +28 -18
- package/dist/esm/mixins/OxyServices.accounts.js +6 -13
- package/dist/esm/mixins/OxyServices.auth.js +66 -201
- package/dist/esm/mixins/OxyServices.authorizedApps.js +35 -0
- package/dist/esm/mixins/OxyServices.deviceBoot.js +116 -0
- package/dist/esm/mixins/index.js +13 -17
- package/dist/esm/session/SessionClient.js +44 -2
- package/dist/esm/session/authStateStore.js +278 -0
- package/dist/esm/session/createSessionClient.js +8 -2
- package/dist/esm/session/refresh.js +257 -0
- package/dist/esm/utils/accountUtils.js +0 -53
- package/dist/esm/utils/authWebUrl.js +6 -14
- package/dist/esm/utils/fapiAutoDetect.js +16 -63
- package/dist/esm/utils/platform.js +18 -0
- package/dist/esm/utils/ssoBounce.js +14 -324
- package/dist/esm/utils/validationUtils.js +56 -0
- package/dist/types/.tsbuildinfo +1 -1
- package/dist/types/HttpService.d.ts +13 -0
- package/dist/types/OxyServices.base.d.ts +0 -52
- package/dist/types/OxyServices.d.ts +0 -25
- package/dist/types/boot/coldBootV2.d.ts +76 -0
- package/dist/types/boot/deviceBootReturn.d.ts +83 -0
- package/dist/types/crypto/keyManager.d.ts +21 -0
- package/dist/types/index.d.ts +16 -21
- package/dist/types/mixins/OxyServices.accounts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.analytics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.appData.d.ts +0 -2
- package/dist/types/mixins/OxyServices.assets.d.ts +0 -2
- package/dist/types/mixins/OxyServices.auth.d.ts +35 -77
- package/dist/types/mixins/{OxyServices.redirect.d.ts → OxyServices.authorizedApps.d.ts} +35 -33
- package/dist/types/mixins/OxyServices.civic.d.ts +0 -2
- package/dist/types/mixins/OxyServices.connectedApps.d.ts +0 -2
- package/dist/types/mixins/OxyServices.contacts.d.ts +0 -2
- package/dist/types/mixins/OxyServices.deviceBoot.d.ts +110 -0
- package/dist/types/mixins/OxyServices.devices.d.ts +0 -2
- package/dist/types/mixins/OxyServices.features.d.ts +0 -2
- package/dist/types/mixins/OxyServices.identity.d.ts +0 -2
- package/dist/types/mixins/OxyServices.language.d.ts +0 -2
- package/dist/types/mixins/OxyServices.links.d.ts +0 -2
- package/dist/types/mixins/OxyServices.location.d.ts +0 -2
- package/dist/types/mixins/OxyServices.nodes.d.ts +0 -2
- package/dist/types/mixins/OxyServices.payment.d.ts +0 -2
- package/dist/types/mixins/OxyServices.privacy.d.ts +0 -2
- package/dist/types/mixins/OxyServices.reputation.d.ts +0 -2
- package/dist/types/mixins/OxyServices.security.d.ts +0 -2
- package/dist/types/mixins/OxyServices.topics.d.ts +0 -2
- package/dist/types/mixins/OxyServices.user.d.ts +0 -2
- package/dist/types/mixins/OxyServices.utility.d.ts +0 -2
- package/dist/types/mixins/index.d.ts +6 -9
- package/dist/types/models/interfaces.d.ts +0 -67
- package/dist/types/session/SessionClient.d.ts +38 -1
- package/dist/types/session/authStateStore.d.ts +119 -0
- package/dist/types/session/createSessionClient.d.ts +8 -1
- package/dist/types/session/refresh.d.ts +93 -0
- package/dist/types/utils/accountUtils.d.ts +0 -14
- package/dist/types/utils/authWebUrl.d.ts +6 -12
- package/dist/types/utils/fapiAutoDetect.d.ts +15 -38
- package/dist/types/utils/platform.d.ts +14 -0
- package/dist/types/utils/ssoBounce.d.ts +14 -262
- package/dist/types/utils/validationUtils.d.ts +15 -0
- package/package.json +2 -2
- package/src/HttpService.ts +19 -3
- package/src/OxyServices.base.ts +7 -112
- package/src/OxyServices.ts +0 -38
- package/src/boot/__tests__/coldBootV2.test.ts +317 -0
- package/src/boot/__tests__/deviceBootReturn.test.ts +158 -0
- package/src/boot/coldBootV2.ts +426 -0
- package/src/boot/deviceBootReturn.ts +195 -0
- package/src/crypto/__tests__/sharedDeviceToken.test.ts +24 -0
- package/src/crypto/keyManager.ts +101 -0
- package/src/i18n/locales/en-US.json +13 -1
- package/src/i18n/locales/es-ES.json +13 -1
- package/src/index.ts +78 -64
- package/src/mixins/OxyServices.accounts.ts +6 -13
- package/src/mixins/OxyServices.auth.ts +78 -253
- package/src/mixins/OxyServices.authorizedApps.ts +75 -0
- package/src/mixins/OxyServices.deviceBoot.ts +146 -0
- package/src/mixins/__tests__/OxyServices.deviceBoot.test.ts +107 -0
- package/src/mixins/__tests__/accounts.test.ts +17 -44
- package/src/mixins/__tests__/authorizedApps.test.ts +63 -0
- package/src/mixins/__tests__/passwordSignIn.test.ts +91 -0
- package/src/mixins/index.ts +16 -22
- package/src/models/interfaces.ts +0 -79
- package/src/session/SessionClient.ts +69 -3
- package/src/session/__tests__/SessionClient.additive.test.ts +92 -0
- package/src/session/__tests__/SessionClient.rest.test.ts +25 -0
- package/src/session/__tests__/SessionClient.socketFactory.test.ts +79 -0
- package/src/session/__tests__/SessionClient.state.test.ts +18 -5
- package/src/session/__tests__/authStateStore.test.ts +209 -0
- package/src/session/__tests__/refresh.test.ts +256 -0
- package/src/session/authStateStore.ts +335 -0
- package/src/session/createSessionClient.ts +9 -1
- package/src/session/refresh.ts +334 -0
- package/src/utils/__tests__/authWebUrl.test.ts +5 -29
- package/src/utils/__tests__/fapiAutoDetect.test.ts +5 -126
- package/src/utils/__tests__/validationUtils.test.ts +30 -0
- package/src/utils/accountUtils.ts +0 -62
- package/src/utils/authWebUrl.ts +6 -15
- package/src/utils/fapiAutoDetect.ts +16 -60
- package/src/utils/platform.ts +21 -0
- package/src/utils/ssoBounce.ts +14 -371
- package/src/utils/validationUtils.ts +62 -0
- package/dist/cjs/AuthManager.js +0 -1110
- package/dist/cjs/AuthManagerTypes.js +0 -13
- package/dist/cjs/CrossDomainAuth.js +0 -206
- package/dist/cjs/mixins/OxyServices.fedcm.js +0 -823
- package/dist/cjs/mixins/OxyServices.redirect.js +0 -95
- package/dist/cjs/mixins/OxyServices.silent.js +0 -204
- package/dist/cjs/mixins/OxyServices.sso.js +0 -208
- package/dist/cjs/utils/ssoEstablish.js +0 -110
- package/dist/cjs/utils/ssoReturn.js +0 -267
- package/dist/esm/AuthManager.js +0 -1105
- package/dist/esm/AuthManagerTypes.js +0 -12
- package/dist/esm/CrossDomainAuth.js +0 -201
- package/dist/esm/mixins/OxyServices.fedcm.js +0 -821
- package/dist/esm/mixins/OxyServices.redirect.js +0 -92
- package/dist/esm/mixins/OxyServices.silent.js +0 -202
- package/dist/esm/mixins/OxyServices.sso.js +0 -204
- package/dist/esm/utils/ssoEstablish.js +0 -107
- package/dist/esm/utils/ssoReturn.js +0 -263
- package/dist/types/AuthManager.d.ts +0 -380
- package/dist/types/AuthManagerTypes.d.ts +0 -81
- package/dist/types/CrossDomainAuth.d.ts +0 -164
- package/dist/types/mixins/OxyServices.fedcm.d.ts +0 -331
- package/dist/types/mixins/OxyServices.silent.d.ts +0 -132
- package/dist/types/mixins/OxyServices.sso.d.ts +0 -138
- package/dist/types/utils/ssoEstablish.d.ts +0 -85
- package/dist/types/utils/ssoReturn.d.ts +0 -146
- package/src/AuthManager.ts +0 -1269
- package/src/AuthManagerTypes.ts +0 -86
- package/src/CrossDomainAuth.ts +0 -243
- package/src/__tests__/authManager.cookiePath.test.ts +0 -390
- package/src/__tests__/authManager.security.test.ts +0 -377
- package/src/__tests__/crossDomainAuth.test.ts +0 -116
- package/src/__tests__/establishDeviceRefreshSlot.test.ts +0 -221
- package/src/mixins/OxyServices.fedcm.ts +0 -1026
- package/src/mixins/OxyServices.redirect.ts +0 -122
- package/src/mixins/OxyServices.silent.ts +0 -272
- package/src/mixins/OxyServices.sso.ts +0 -261
- package/src/mixins/__tests__/constructorAuthWebUrl.test.ts +0 -85
- package/src/mixins/__tests__/fedcm.test.ts +0 -667
- package/src/mixins/__tests__/sessionBaseUrl.test.ts +0 -61
- package/src/mixins/__tests__/silent.test.ts +0 -102
- package/src/mixins/__tests__/sso.test.ts +0 -228
- package/src/utils/__tests__/consumeSsoReturn.test.ts +0 -816
- package/src/utils/__tests__/ssoBounce.test.ts +0 -219
- package/src/utils/__tests__/ssoEstablish.test.ts +0 -204
- package/src/utils/__tests__/ssoReturn.test.ts +0 -251
- package/src/utils/ssoEstablish.ts +0 -174
- package/src/utils/ssoReturn.ts +0 -372
|
@@ -1,1026 +0,0 @@
|
|
|
1
|
-
import type { OxyServicesBase } from '../OxyServices.base';
|
|
2
|
-
import { OxyAuthenticationError } from '../OxyServices.errors';
|
|
3
|
-
import type { SessionLoginResponse } from '../models/session';
|
|
4
|
-
import { createDebugLogger } from '../shared/utils/debugUtils';
|
|
5
|
-
import { normalizeUserIdentity } from '../utils/userIdentity';
|
|
6
|
-
|
|
7
|
-
const debug = createDebugLogger('FedCM');
|
|
8
|
-
|
|
9
|
-
export interface FedCMAuthOptions {
|
|
10
|
-
nonce?: string;
|
|
11
|
-
context?: 'signin' | 'signup' | 'continue' | 'use';
|
|
12
|
-
loginHint?: string;
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
export interface FedCMConfig {
|
|
16
|
-
enabled: boolean;
|
|
17
|
-
configURL: string;
|
|
18
|
-
clientId?: string;
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* FedCM request mode values.
|
|
23
|
-
*
|
|
24
|
-
* The W3C FedCM spec renamed the `IdentityCredentialRequestOptions.mode` enum:
|
|
25
|
-
* `'widget'` → `'passive'` and `'button'` → `'active'`. Modern Chrome only
|
|
26
|
-
* accepts `'active'`/`'passive'` and throws a synchronous `TypeError` for the
|
|
27
|
-
* legacy values, while Chrome 125–131 only understands `'button'`/`'widget'`.
|
|
28
|
-
* Callers should use the modern values; the legacy values are accepted for
|
|
29
|
-
* convenience and normalised internally.
|
|
30
|
-
*/
|
|
31
|
-
export type FedCMRequestMode = 'active' | 'passive' | 'button' | 'widget';
|
|
32
|
-
|
|
33
|
-
// Modern (W3C spec) → legacy (Chrome 125–131) mode value mapping. Used to
|
|
34
|
-
// retry a credential request when an older browser rejects the modern enum.
|
|
35
|
-
const MODERN_TO_LEGACY_MODE: Record<'active' | 'passive', 'button' | 'widget'> = {
|
|
36
|
-
active: 'button',
|
|
37
|
-
passive: 'widget',
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
// Legacy → modern mapping so callers may pass either spelling.
|
|
41
|
-
const LEGACY_TO_MODERN_MODE: Record<'button' | 'widget', 'active' | 'passive'> = {
|
|
42
|
-
button: 'active',
|
|
43
|
-
widget: 'passive',
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
/**
|
|
47
|
-
* Normalise any accepted mode value to the modern W3C spelling
|
|
48
|
-
* (`'active'`/`'passive'`), which is what is sent to the browser first.
|
|
49
|
-
*/
|
|
50
|
-
function toModernMode(mode: FedCMRequestMode): 'active' | 'passive' {
|
|
51
|
-
return mode === 'button' || mode === 'widget' ? LEGACY_TO_MODERN_MODE[mode] : mode;
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/**
|
|
55
|
-
* Detect the synchronous `TypeError` a pre-spec browser throws when it does not
|
|
56
|
-
* recognise a modern `mode` enum value (e.g. Chrome 125–131 rejecting
|
|
57
|
-
* `'active'`/`'passive'`). Such a browser only understands the legacy
|
|
58
|
-
* `'button'`/`'widget'` values, so the caller can retry with those.
|
|
59
|
-
*/
|
|
60
|
-
function isUnknownModeEnumError(error: unknown): boolean {
|
|
61
|
-
if (!(error instanceof TypeError)) return false;
|
|
62
|
-
const message = error.message.toLowerCase();
|
|
63
|
-
return (
|
|
64
|
-
message.includes('identitycredentialrequestoptionsmode') ||
|
|
65
|
-
((message.includes('active') || message.includes('passive')) &&
|
|
66
|
-
(message.includes('enum') || message.includes('not a valid')))
|
|
67
|
-
);
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
/**
|
|
71
|
-
* Detect a `navigator.credentials.get` rejection that is consistent with
|
|
72
|
-
* "the supplied loginHint matched no account at the IdP".
|
|
73
|
-
*
|
|
74
|
-
* When an RP passes a `loginHint` and the IdP returns accounts but NONE of them
|
|
75
|
-
* declare that hint in their `login_hints`, Chrome filters every account out,
|
|
76
|
-
* greys it in the chooser ("You can't sign in using this account"), logs
|
|
77
|
-
* "Accounts were received, but none matched the login hint…", and ultimately
|
|
78
|
-
* rejects the credential request — surfacing as a `NotAllowedError` /
|
|
79
|
-
* `AbortError` (the same shape as a user-cancelled or timed-out request). A
|
|
80
|
-
* stale hint left over from a previously-signed-in/test account therefore hard
|
|
81
|
-
* -blocks sign-in.
|
|
82
|
-
*
|
|
83
|
-
* We can only safely apply the clear-and-retry recovery when a `loginHint` was
|
|
84
|
-
* actually supplied; without one this is just a normal cancel/timeout and must
|
|
85
|
-
* NOT be retried. Callers gate on `hadLoginHint` before calling this.
|
|
86
|
-
*/
|
|
87
|
-
function isPossibleHintMismatchError(error: unknown): boolean {
|
|
88
|
-
if (!(error instanceof Error)) return false;
|
|
89
|
-
// FedCM surfaces a filtered-out / no-eligible-account outcome as
|
|
90
|
-
// NotAllowedError (current Chrome) or AbortError (our own timeout abort while
|
|
91
|
-
// the chooser had no selectable account). Both are indistinguishable from a
|
|
92
|
-
// genuine user cancel at the API level, so the gate on "a hint was supplied"
|
|
93
|
-
// (in the caller) is what makes the retry safe and targeted.
|
|
94
|
-
return error.name === 'NotAllowedError' || error.name === 'AbortError';
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Minimal structural types for the FedCM `navigator.credentials.get` surface.
|
|
98
|
-
// The DOM lib does not ship these in every TypeScript version we build against,
|
|
99
|
-
// so we model only the fields this mixin reads/writes. This lets the FedCM code
|
|
100
|
-
// stay free of `any` without depending on lib-dom FedCM typings.
|
|
101
|
-
interface FedCMProviderRequest {
|
|
102
|
-
configURL: string;
|
|
103
|
-
clientId: string;
|
|
104
|
-
nonce: string;
|
|
105
|
-
params?: { nonce: string };
|
|
106
|
-
loginHint?: string;
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
interface FedCMIdentityRequest {
|
|
110
|
-
providers: FedCMProviderRequest[];
|
|
111
|
-
mode?: FedCMRequestMode;
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
interface FedCMCredentialRequest {
|
|
115
|
-
identity: FedCMIdentityRequest;
|
|
116
|
-
mediation: 'silent' | 'optional' | 'required';
|
|
117
|
-
signal: AbortSignal;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
interface FedCMIdentityCredential {
|
|
121
|
-
type?: string;
|
|
122
|
-
token?: string;
|
|
123
|
-
isAutoSelected?: boolean;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
interface FedCMCredentialsContainer {
|
|
127
|
-
get(options: FedCMCredentialRequest): Promise<FedCMIdentityCredential | null>;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Normalised result of a FedCM credential request: the IdP-issued ID token plus
|
|
132
|
-
* whether the browser auto-selected the account (no explicit user choice).
|
|
133
|
-
*/
|
|
134
|
-
interface FedCMTokenResult {
|
|
135
|
-
token: string;
|
|
136
|
-
isAutoSelected: boolean;
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
// Options accepted by the static `IdentityCredential.disconnect()` method
|
|
140
|
-
// (W3C FedCM "disconnect" / sign-out). Not declared in every lib-dom version.
|
|
141
|
-
interface FedCMDisconnectOptions {
|
|
142
|
-
configURL: string;
|
|
143
|
-
clientId: string;
|
|
144
|
-
accountHint: string;
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
// Minimal structural shape of the global `IdentityCredential` interface object,
|
|
148
|
-
// modelling only the static `disconnect` method this mixin invokes.
|
|
149
|
-
interface FedCMIdentityCredentialStatic {
|
|
150
|
-
disconnect(options: FedCMDisconnectOptions): Promise<void>;
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
const FEDCM_LOGIN_HINT_KEY = 'oxy_fedcm_login_hint';
|
|
154
|
-
|
|
155
|
-
// Global lock to prevent concurrent FedCM requests
|
|
156
|
-
// FedCM only allows one navigator.credentials.get request at a time
|
|
157
|
-
let fedCMRequestInProgress = false;
|
|
158
|
-
let fedCMRequestPromise: Promise<FedCMTokenResult | null> | null = null;
|
|
159
|
-
let currentMediationMode: string | null = null;
|
|
160
|
-
// AbortController of the in-flight request, exposed at module scope so an
|
|
161
|
-
// arriving INTERACTIVE request can abort a slow/hung SILENT one instead of
|
|
162
|
-
// blocking on it (see requestIdentityCredential). Set when a request starts,
|
|
163
|
-
// cleared in that request's `finally`.
|
|
164
|
-
let fedCMActiveController: AbortController | null = null;
|
|
165
|
-
|
|
166
|
-
/**
|
|
167
|
-
* Federated Credential Management (FedCM) Authentication Mixin
|
|
168
|
-
*
|
|
169
|
-
* Implements the modern browser-native identity federation API that enables
|
|
170
|
-
* Google-style cross-domain authentication without third-party cookies.
|
|
171
|
-
*
|
|
172
|
-
* Browser Support:
|
|
173
|
-
* - Chrome 108+
|
|
174
|
-
* - Safari 16.4+
|
|
175
|
-
* - Edge 108+
|
|
176
|
-
* - Firefox: Not yet supported (fallback required)
|
|
177
|
-
*
|
|
178
|
-
* Key Features:
|
|
179
|
-
* - No redirects or secondary windows required
|
|
180
|
-
* - Browser-native UI prompts
|
|
181
|
-
* - Privacy-preserving (IdP can't track users)
|
|
182
|
-
* - Automatic SSO across domains
|
|
183
|
-
* - Silent re-authentication support
|
|
184
|
-
*
|
|
185
|
-
* @see https://developer.mozilla.org/en-US/docs/Web/API/FedCM_API
|
|
186
|
-
*/
|
|
187
|
-
export function OxyServicesFedCMMixin<T extends typeof OxyServicesBase>(Base: T) {
|
|
188
|
-
return class extends Base {
|
|
189
|
-
constructor(...args: any[]) {
|
|
190
|
-
super(...(args as [any]));
|
|
191
|
-
}
|
|
192
|
-
public static readonly DEFAULT_CONFIG_URL = 'https://auth.oxy.so/fedcm.json';
|
|
193
|
-
|
|
194
|
-
public resolveFedcmConfigUrl(): string {
|
|
195
|
-
// `DEFAULT_CONFIG_URL` is a static on the composed class; read it off the
|
|
196
|
-
// most-derived constructor through a typed cast (not `any`).
|
|
197
|
-
const configCtor = this.constructor as typeof OxyServicesBase & {
|
|
198
|
-
DEFAULT_CONFIG_URL: string;
|
|
199
|
-
};
|
|
200
|
-
return this.config.authWebUrl
|
|
201
|
-
? `${this.config.authWebUrl}/fedcm.json`
|
|
202
|
-
: configCtor.DEFAULT_CONFIG_URL;
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
public static readonly FEDCM_TIMEOUT = 15000; // 15 seconds for interactive
|
|
206
|
-
// Silent mediation runs on page load as ONE step of the ordered cold-boot
|
|
207
|
-
// sequence (mint nonce → navigator.credentials.get → /fedcm/exchange). The
|
|
208
|
-
// real round-trip was measured at >3s for live users, so the budget must stay
|
|
209
|
-
// comfortably above 3s. It must ALSO be tight: on a logged-out browser this
|
|
210
|
-
// step never resolves a credential, and every millisecond it spends timing
|
|
211
|
-
// out is pure latency in front of the steps that actually hold the answer
|
|
212
|
-
// (stored-session bearer, the per-apex silent iframe, the /sso bounce). 4s is
|
|
213
|
-
// the floor that preserves the >3s success margin while bounding the dead
|
|
214
|
-
// wait — down from the previous 10s, which alone could account for most of a
|
|
215
|
-
// 20-30s cold-boot stall. Do NOT lower below 4s (it would clip live success).
|
|
216
|
-
public static readonly FEDCM_SILENT_TIMEOUT = 4000; // 4 seconds for silent mediation
|
|
217
|
-
|
|
218
|
-
// Grace margin between the cooperative abort deadline (`FEDCM_SILENT_TIMEOUT`
|
|
219
|
-
// / `FEDCM_TIMEOUT`) and the HARD settle of `requestIdentityCredential`. The
|
|
220
|
-
// abort fires first; a well-behaved browser surfaces its own `AbortError`
|
|
221
|
-
// within this window (keeping the existing error path intact). If — as seen
|
|
222
|
-
// in production — `navigator.credentials.get()` ignores the abort and the
|
|
223
|
-
// awaited promise never settles, the hard settle resolves the request to
|
|
224
|
-
// `null` this many ms later, guaranteeing the cold-boot step always settles.
|
|
225
|
-
// 500ms is ample for a browser to deliver an abort rejection while keeping the
|
|
226
|
-
// worst-case dead wait tight (silent: 4.5s, interactive: 15.5s).
|
|
227
|
-
public static readonly FEDCM_ABORT_SETTLE_GRACE_MS = 500;
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Check if FedCM is supported in the current browser
|
|
231
|
-
*/
|
|
232
|
-
static isFedCMSupported(): boolean {
|
|
233
|
-
if (typeof window === 'undefined') return false;
|
|
234
|
-
return 'IdentityCredential' in window && 'navigator' in window && 'credentials' in navigator;
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
/**
|
|
238
|
-
* Instance method to check FedCM support
|
|
239
|
-
*/
|
|
240
|
-
isFedCMSupported(): boolean {
|
|
241
|
-
return (this.constructor as typeof OxyServicesBase & { isFedCMSupported: () => boolean }).isFedCMSupported();
|
|
242
|
-
}
|
|
243
|
-
|
|
244
|
-
/**
|
|
245
|
-
* Sign in using FedCM (Federated Credential Management API)
|
|
246
|
-
*
|
|
247
|
-
* This provides a Google-style authentication experience:
|
|
248
|
-
* - Browser shows native "Sign in with Oxy" prompt
|
|
249
|
-
* - No redirect or secondary window required
|
|
250
|
-
* - User approves → credential exchange happens in browser
|
|
251
|
-
* - All apps automatically get SSO after first sign-in
|
|
252
|
-
*
|
|
253
|
-
* @param options - Authentication options
|
|
254
|
-
* @returns Session with access token and user data
|
|
255
|
-
* @throws {OxyAuthenticationError} If FedCM not supported or user cancels
|
|
256
|
-
*
|
|
257
|
-
* @example
|
|
258
|
-
* ```typescript
|
|
259
|
-
* try {
|
|
260
|
-
* const session = await oxyServices.signInWithFedCM();
|
|
261
|
-
* const user = session.user;
|
|
262
|
-
* } catch (error) {
|
|
263
|
-
* // Fallback to redirect auth
|
|
264
|
-
* oxyServices.signInWithRedirect();
|
|
265
|
-
* }
|
|
266
|
-
* ```
|
|
267
|
-
*/
|
|
268
|
-
async signInWithFedCM(options: FedCMAuthOptions = {}): Promise<SessionLoginResponse> {
|
|
269
|
-
if (!this.isFedCMSupported()) {
|
|
270
|
-
throw new OxyAuthenticationError(
|
|
271
|
-
'FedCM not supported in this browser. Please update your browser or use an alternative sign-in method.'
|
|
272
|
-
);
|
|
273
|
-
}
|
|
274
|
-
|
|
275
|
-
// Use provided loginHint, or fall back to stored last-used account ID.
|
|
276
|
-
const initialLoginHint = options.loginHint || this.getStoredLoginHint();
|
|
277
|
-
|
|
278
|
-
try {
|
|
279
|
-
return await this.attemptInteractiveSignIn(options, initialLoginHint);
|
|
280
|
-
} catch (error) {
|
|
281
|
-
// A STALE loginHint (e.g. left over from a previously-signed-in or test
|
|
282
|
-
// account) that matches no account at the IdP makes Chrome filter out
|
|
283
|
-
// every account and reject the request — indistinguishable from a user
|
|
284
|
-
// cancel. When that happens AND we supplied a hint, clear the bad hint
|
|
285
|
-
// and retry the credential request ONCE with no hint, which lets the
|
|
286
|
-
// chooser surface the genuinely available account(s). We only do this for
|
|
287
|
-
// a hint we pulled from storage (not a caller-supplied one), and only
|
|
288
|
-
// once, so a real cancel never loops.
|
|
289
|
-
const usedStoredHint = !!initialLoginHint && !options.loginHint;
|
|
290
|
-
if (usedStoredHint && isPossibleHintMismatchError(error)) {
|
|
291
|
-
debug.log(
|
|
292
|
-
'Interactive sign-in: stored loginHint matched no account; clearing it and retrying without a hint'
|
|
293
|
-
);
|
|
294
|
-
this.clearLoginHint();
|
|
295
|
-
return await this.attemptInteractiveSignIn(options, undefined);
|
|
296
|
-
}
|
|
297
|
-
throw this.normalizeInteractiveSignInError(error);
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
/**
|
|
302
|
-
* Run a single interactive FedCM credential request + token exchange for the
|
|
303
|
-
* given (possibly undefined) loginHint. A successful exchange plants the
|
|
304
|
-
* access token and persists the user id as the future loginHint — the hint is
|
|
305
|
-
* therefore only ever stored after a GENUINELY successful sign-in, never
|
|
306
|
-
* speculatively.
|
|
307
|
-
*
|
|
308
|
-
* @private
|
|
309
|
-
*/
|
|
310
|
-
public async attemptInteractiveSignIn(
|
|
311
|
-
options: FedCMAuthOptions,
|
|
312
|
-
loginHint: string | undefined
|
|
313
|
-
): Promise<SessionLoginResponse> {
|
|
314
|
-
// Prefer a server-minted, origin-bound nonce so the downstream
|
|
315
|
-
// `/fedcm/exchange` can validate it. A caller-supplied nonce is
|
|
316
|
-
// respected as-is for advanced use cases.
|
|
317
|
-
const nonce = options.nonce || (await this.getFedcmNonce());
|
|
318
|
-
const clientId = this.getClientId();
|
|
319
|
-
|
|
320
|
-
debug.log('Interactive sign-in: Requesting credential for', clientId, loginHint ? `(hint: ${loginHint})` : '');
|
|
321
|
-
|
|
322
|
-
// Request credential from browser's native identity flow.
|
|
323
|
-
// mode: 'active' signals this is a user-gesture-initiated (button) flow.
|
|
324
|
-
// 'active' is the current W3C spec value; requestIdentityCredential
|
|
325
|
-
// transparently retries with the legacy 'button' value for Chrome 125–131.
|
|
326
|
-
const credential = await this.requestIdentityCredential({
|
|
327
|
-
configURL: this.resolveFedcmConfigUrl(),
|
|
328
|
-
clientId,
|
|
329
|
-
nonce,
|
|
330
|
-
context: options.context,
|
|
331
|
-
loginHint,
|
|
332
|
-
mode: 'active',
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
if (!credential || !credential.token) {
|
|
336
|
-
throw new OxyAuthenticationError('No credential received from browser');
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
debug.log('Interactive sign-in: Got credential, exchanging for session');
|
|
340
|
-
|
|
341
|
-
// Exchange FedCM ID token for Oxy session
|
|
342
|
-
const session = await this.exchangeIdTokenForSession(credential.token);
|
|
343
|
-
|
|
344
|
-
// Store the access token in HttpService. Refresh stays in the httpOnly
|
|
345
|
-
// cookie slot set by the API.
|
|
346
|
-
if (session?.accessToken) {
|
|
347
|
-
this.httpService.setTokens(session.accessToken);
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// Store the user ID as loginHint for future FedCM requests — only now, after
|
|
351
|
-
// a real successful exchange, so we never persist a hint that cannot resolve.
|
|
352
|
-
if (session?.user?.id) {
|
|
353
|
-
this.storeLoginHint(session.user.id);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
debug.log('Interactive sign-in: Success!', { userId: session?.user?.id });
|
|
357
|
-
|
|
358
|
-
return session;
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/**
|
|
362
|
-
* Map a raw FedCM/exchange failure to a user-facing {@link OxyAuthenticationError}
|
|
363
|
-
* (or pass it through). Extracted so the clear-and-retry path can reuse the
|
|
364
|
-
* exact same error normalisation as the first attempt.
|
|
365
|
-
*
|
|
366
|
-
* @private
|
|
367
|
-
*/
|
|
368
|
-
public normalizeInteractiveSignInError(error: unknown): unknown {
|
|
369
|
-
debug.log('Interactive sign-in failed:', error);
|
|
370
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
371
|
-
// FedCM aborts/network failures surface as DOMException/Error instances,
|
|
372
|
-
// both of which carry a `name`. Anything else has no meaningful name.
|
|
373
|
-
const errorName = error instanceof Error ? error.name : '';
|
|
374
|
-
|
|
375
|
-
if (errorName === 'AbortError') {
|
|
376
|
-
return new OxyAuthenticationError('Sign-in was cancelled by user');
|
|
377
|
-
}
|
|
378
|
-
if (errorName === 'NetworkError') {
|
|
379
|
-
return new OxyAuthenticationError('Network error during sign-in. Please check your connection.');
|
|
380
|
-
}
|
|
381
|
-
if (errorMessage.includes('multiple accounts')) {
|
|
382
|
-
return new OxyAuthenticationError('Please sign out and sign in again to use FedCM with a single account');
|
|
383
|
-
}
|
|
384
|
-
if (errorMessage.includes('retrieving a token') || errorMessage.includes('Error retrieving')) {
|
|
385
|
-
debug.error('FedCM token retrieval error - this may be a browser or IdP configuration issue');
|
|
386
|
-
return new OxyAuthenticationError('Authentication failed. Please try again or use an alternative sign-in method.');
|
|
387
|
-
}
|
|
388
|
-
return error;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
/**
|
|
392
|
-
* Silent sign-in using FedCM
|
|
393
|
-
*
|
|
394
|
-
* Attempts to automatically re-authenticate the user without any UI.
|
|
395
|
-
* This is what enables "instant sign-in" across all Oxy domains after
|
|
396
|
-
* the user has signed in once.
|
|
397
|
-
*
|
|
398
|
-
* The browser will:
|
|
399
|
-
* 1. Check if user has previously signed in to Oxy
|
|
400
|
-
* 2. Check if user is still signed in at auth.oxy.so
|
|
401
|
-
* 3. If yes, automatically provide credential without prompting
|
|
402
|
-
*
|
|
403
|
-
* @returns Session if user is already signed in, null otherwise
|
|
404
|
-
*
|
|
405
|
-
* @example
|
|
406
|
-
* ```typescript
|
|
407
|
-
* // On app startup
|
|
408
|
-
* useEffect(() => {
|
|
409
|
-
* const checkAuth = async () => {
|
|
410
|
-
* const session = await oxyServices.silentSignInWithFedCM();
|
|
411
|
-
* if (session) {
|
|
412
|
-
* setUser(session.user);
|
|
413
|
-
* } else {
|
|
414
|
-
* // Show sign-in button
|
|
415
|
-
* }
|
|
416
|
-
* };
|
|
417
|
-
* checkAuth();
|
|
418
|
-
* }, []);
|
|
419
|
-
* ```
|
|
420
|
-
*/
|
|
421
|
-
async silentSignInWithFedCM(): Promise<SessionLoginResponse | null> {
|
|
422
|
-
if (!this.isFedCMSupported()) {
|
|
423
|
-
debug.log('Silent SSO: FedCM not supported in this browser');
|
|
424
|
-
return null;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
const clientId = this.getClientId();
|
|
428
|
-
debug.log('Silent SSO: Starting for', clientId);
|
|
429
|
-
|
|
430
|
-
// Only try silent mediation (no UI) - works if user previously consented.
|
|
431
|
-
// We intentionally do NOT fall back to optional mediation here because
|
|
432
|
-
// this runs on app startup — showing browser UI without user action is bad UX.
|
|
433
|
-
// Optional/interactive mediation should only happen when the user clicks "Sign In".
|
|
434
|
-
let credential: FedCMTokenResult | null = null;
|
|
435
|
-
|
|
436
|
-
const loginHint = this.getStoredLoginHint();
|
|
437
|
-
|
|
438
|
-
// Fast-skip: with no stored login hint this browser has never completed a
|
|
439
|
-
// FedCM sign-in for any Oxy account, so silent mediation cannot return a
|
|
440
|
-
// credential — the IdP has nothing to silently re-issue. Doing the full
|
|
441
|
-
// round-trip anyway (mint a nonce via `POST /fedcm/nonce`, then a
|
|
442
|
-
// `navigator.credentials.get` that aborts after `FEDCM_SILENT_TIMEOUT`) is
|
|
443
|
-
// pure latency in the cold-boot critical path. Return `null` immediately so
|
|
444
|
-
// the next cold-boot step (stored-session / iframe / bounce) runs without
|
|
445
|
-
// the wasted nonce mint + abort wait. A genuinely associated browser always
|
|
446
|
-
// has a hint (it is stored only after a real exchange), so this never skips
|
|
447
|
-
// a recoverable session.
|
|
448
|
-
if (!loginHint) {
|
|
449
|
-
debug.log('Silent SSO: No stored login hint — skipping silent mediation (no association on this browser)');
|
|
450
|
-
return null;
|
|
451
|
-
}
|
|
452
|
-
|
|
453
|
-
try {
|
|
454
|
-
// Server-minted, origin-bound nonce — required for `/fedcm/exchange`
|
|
455
|
-
// to accept the resulting ID token (anti-replay binding).
|
|
456
|
-
const nonce = await this.getFedcmNonce();
|
|
457
|
-
debug.log('Silent SSO: Attempting silent mediation...', loginHint ? `(hint: ${loginHint})` : '');
|
|
458
|
-
|
|
459
|
-
credential = await this.requestIdentityCredential({
|
|
460
|
-
configURL: this.resolveFedcmConfigUrl(),
|
|
461
|
-
clientId,
|
|
462
|
-
nonce,
|
|
463
|
-
loginHint,
|
|
464
|
-
mediation: 'silent',
|
|
465
|
-
});
|
|
466
|
-
|
|
467
|
-
debug.log('Silent SSO: Silent mediation result:', { hasCredential: !!credential, hasToken: !!credential?.token });
|
|
468
|
-
} catch (silentError) {
|
|
469
|
-
const errorName = silentError instanceof Error ? silentError.name : 'Unknown';
|
|
470
|
-
const errorMessage = silentError instanceof Error ? silentError.message : String(silentError);
|
|
471
|
-
|
|
472
|
-
// Handle specific FedCM errors with better logging
|
|
473
|
-
if (errorMessage.includes('multiple accounts')) {
|
|
474
|
-
debug.log('Silent SSO: User has used multiple accounts - silent mediation not available');
|
|
475
|
-
debug.log('Silent SSO: User needs to explicitly sign in to choose account');
|
|
476
|
-
} else if (errorMessage.includes('conditions')) {
|
|
477
|
-
debug.log('Silent SSO: Conditions not met (user may not be logged in at IdP or not in approved_clients)');
|
|
478
|
-
} else {
|
|
479
|
-
debug.log('Silent SSO: Silent mediation failed:', { name: errorName, message: errorMessage });
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
return null;
|
|
483
|
-
}
|
|
484
|
-
|
|
485
|
-
if (!credential || !credential.token) {
|
|
486
|
-
debug.log('Silent SSO: No credential returned (user not logged in at IdP or hasn\'t consented)');
|
|
487
|
-
return null;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
debug.log('Silent SSO: Got credential, exchanging for session...');
|
|
491
|
-
|
|
492
|
-
let session: SessionLoginResponse;
|
|
493
|
-
try {
|
|
494
|
-
session = await this.exchangeIdTokenForSession(credential.token);
|
|
495
|
-
} catch (exchangeError) {
|
|
496
|
-
debug.error('Silent SSO: Token exchange failed:', exchangeError);
|
|
497
|
-
return null;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Validate session response has required fields
|
|
501
|
-
if (!session) {
|
|
502
|
-
debug.error('Silent SSO: Exchange returned null session');
|
|
503
|
-
return null;
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
if (!session.sessionId) {
|
|
507
|
-
debug.error('Silent SSO: Exchange returned session without sessionId:', session);
|
|
508
|
-
return null;
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
if (!session.user) {
|
|
512
|
-
debug.error('Silent SSO: Exchange returned session without user:', session);
|
|
513
|
-
return null;
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
// Store the access token. Refresh stays in the httpOnly cookie slot set by
|
|
517
|
-
// the API.
|
|
518
|
-
if (session.accessToken) {
|
|
519
|
-
this.httpService.setTokens(session.accessToken);
|
|
520
|
-
debug.log('Silent SSO: Access token set');
|
|
521
|
-
} else {
|
|
522
|
-
debug.warn('Silent SSO: No accessToken in session response');
|
|
523
|
-
}
|
|
524
|
-
|
|
525
|
-
// Store the user ID as loginHint for future FedCM requests
|
|
526
|
-
if (session.user?.id) {
|
|
527
|
-
this.storeLoginHint(session.user.id);
|
|
528
|
-
}
|
|
529
|
-
|
|
530
|
-
debug.log('Silent SSO: Success!', {
|
|
531
|
-
sessionId: session.sessionId?.substring(0, 8) + '...',
|
|
532
|
-
userId: session.user?.id
|
|
533
|
-
});
|
|
534
|
-
|
|
535
|
-
return session;
|
|
536
|
-
}
|
|
537
|
-
|
|
538
|
-
/**
|
|
539
|
-
* Request identity credential from browser using FedCM API
|
|
540
|
-
*
|
|
541
|
-
* Uses a global lock to prevent concurrent requests, as FedCM only
|
|
542
|
-
* allows one navigator.credentials.get request at a time.
|
|
543
|
-
*
|
|
544
|
-
* Interactive requests (optional/required) wait for any silent request to finish first.
|
|
545
|
-
*
|
|
546
|
-
* @private
|
|
547
|
-
*/
|
|
548
|
-
public async requestIdentityCredential(options: {
|
|
549
|
-
configURL: string;
|
|
550
|
-
clientId: string;
|
|
551
|
-
nonce: string;
|
|
552
|
-
context?: string;
|
|
553
|
-
loginHint?: string;
|
|
554
|
-
mediation?: 'silent' | 'optional' | 'required';
|
|
555
|
-
/**
|
|
556
|
-
* FedCM request mode. The W3C spec values are `'active'` (user-gesture
|
|
557
|
-
* button flow) and `'passive'` (browser-initiated widget flow). Chrome
|
|
558
|
-
* 125–131 used the legacy names `'button'`/`'widget'`; those are accepted
|
|
559
|
-
* here and mapped to the modern values, with an automatic legacy retry if
|
|
560
|
-
* the running browser only understands the old enum.
|
|
561
|
-
*/
|
|
562
|
-
mode?: FedCMRequestMode;
|
|
563
|
-
}): Promise<FedCMTokenResult | null> {
|
|
564
|
-
const requestedMediation = options.mediation || 'optional';
|
|
565
|
-
const isInteractive = requestedMediation !== 'silent';
|
|
566
|
-
|
|
567
|
-
debug.log('requestIdentityCredential called:', {
|
|
568
|
-
mediation: requestedMediation,
|
|
569
|
-
clientId: options.clientId,
|
|
570
|
-
inProgress: fedCMRequestInProgress,
|
|
571
|
-
});
|
|
572
|
-
|
|
573
|
-
// If a request is already in progress...
|
|
574
|
-
if (fedCMRequestInProgress && fedCMRequestPromise) {
|
|
575
|
-
debug.log('Request already in progress, waiting...');
|
|
576
|
-
// If the in-flight request is SILENT and this new one is INTERACTIVE,
|
|
577
|
-
// abort the silent and proceed immediately. The silent round-trip can be
|
|
578
|
-
// slow (it runs on page load and may stall in the browser), and a user who
|
|
579
|
-
// just clicked "Sign In" must never be made to wait on — or be blocked by —
|
|
580
|
-
// it. Awaiting the silent here is what previously let a hung silent
|
|
581
|
-
// request deadlock the sign-in button, so we deliberately do NOT await it:
|
|
582
|
-
// we abort it (its own `finally` resets the lock as it settles) and fall
|
|
583
|
-
// through to start the interactive request synchronously below.
|
|
584
|
-
if (currentMediationMode === 'silent' && isInteractive) {
|
|
585
|
-
debug.log('Aborting in-flight silent request to make way for interactive request');
|
|
586
|
-
fedCMActiveController?.abort();
|
|
587
|
-
// Fall through. The interactive request synchronously overwrites the
|
|
588
|
-
// lock globals (below); the aborted silent's `finally` uses identity
|
|
589
|
-
// guards so it cannot later clobber this interactive request's state.
|
|
590
|
-
} else {
|
|
591
|
-
// Same type of request - wait for the existing one
|
|
592
|
-
try {
|
|
593
|
-
return await fedCMRequestPromise;
|
|
594
|
-
} catch {
|
|
595
|
-
return null;
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
fedCMRequestInProgress = true;
|
|
601
|
-
currentMediationMode = requestedMediation;
|
|
602
|
-
const controller = new AbortController();
|
|
603
|
-
fedCMActiveController = controller;
|
|
604
|
-
// Use shorter timeout for silent mediation since it should be quick.
|
|
605
|
-
// The timeout constants are static on the composed class; read them off the
|
|
606
|
-
// most-derived constructor through a typed cast (not `any`).
|
|
607
|
-
const timeoutCtor = this.constructor as typeof OxyServicesBase & {
|
|
608
|
-
FEDCM_SILENT_TIMEOUT: number;
|
|
609
|
-
FEDCM_TIMEOUT: number;
|
|
610
|
-
};
|
|
611
|
-
const timeoutMs = requestedMediation === 'silent'
|
|
612
|
-
? timeoutCtor.FEDCM_SILENT_TIMEOUT
|
|
613
|
-
: timeoutCtor.FEDCM_TIMEOUT;
|
|
614
|
-
const timeout = setTimeout(() => {
|
|
615
|
-
debug.log('Request timed out after', timeoutMs, 'ms (mediation:', requestedMediation + ')');
|
|
616
|
-
controller.abort();
|
|
617
|
-
}, timeoutMs);
|
|
618
|
-
|
|
619
|
-
// Hard settle guarantee for the timeout path.
|
|
620
|
-
//
|
|
621
|
-
// The `setTimeout` above aborts the request's `AbortController`, which is
|
|
622
|
-
// the COOPERATIVE cancel signal. For a regular `fetch` an abort deterministically
|
|
623
|
-
// rejects the awaited promise — but `navigator.credentials.get()` is a
|
|
624
|
-
// browser-internal FedCM primitive whose abort behaviour is NOT guaranteed
|
|
625
|
-
// to settle the awaited promise in every Chrome version / internal state
|
|
626
|
-
// (the credential request can sit "pending" while the browser-side flow is
|
|
627
|
-
// stuck, ignoring the signal). If that happens, `await credentials.get(...)`
|
|
628
|
-
// never resolves OR rejects, this IIFE hangs forever, and — because this is
|
|
629
|
-
// ONE step of the ordered cold-boot sequence — the whole cold boot hangs and
|
|
630
|
-
// the terminal `/sso` bounce never fires. That was the production hang.
|
|
631
|
-
//
|
|
632
|
-
// `settlePromise` races the credential lookup against a timer that ALWAYS
|
|
633
|
-
// resolves to `null` shortly after the abort deadline. The abort still fires
|
|
634
|
-
// first (so the browser is asked to cancel), but even if `credentials.get`
|
|
635
|
-
// never settles, the race resolves and the step falls through cleanly to the
|
|
636
|
-
// next cold-boot step. The small `FEDCM_ABORT_SETTLE_GRACE_MS` margin gives a
|
|
637
|
-
// well-behaved browser the chance to surface its own AbortError (preserving
|
|
638
|
-
// the existing error path) before we force a clean `null`.
|
|
639
|
-
let settleTimer: ReturnType<typeof setTimeout> | undefined;
|
|
640
|
-
const settlePromise = new Promise<FedCMIdentityCredential | null>((resolve) => {
|
|
641
|
-
const ctor = this.constructor as typeof OxyServicesBase & {
|
|
642
|
-
FEDCM_ABORT_SETTLE_GRACE_MS: number;
|
|
643
|
-
};
|
|
644
|
-
settleTimer = setTimeout(() => {
|
|
645
|
-
debug.log('Request hard-settled to null', timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS, 'ms (credentials.get never settled after abort)');
|
|
646
|
-
resolve(null);
|
|
647
|
-
}, timeoutMs + ctor.FEDCM_ABORT_SETTLE_GRACE_MS);
|
|
648
|
-
});
|
|
649
|
-
|
|
650
|
-
// Normalise the caller's mode to the modern W3C value first. A modern
|
|
651
|
-
// browser accepts it; an older one (Chrome 125–131) rejects it with a
|
|
652
|
-
// synchronous TypeError, in which case we retry with the legacy value.
|
|
653
|
-
const modernMode = options.mode ? toModernMode(options.mode) : undefined;
|
|
654
|
-
|
|
655
|
-
// Build the identity request for a specific mode value. The `mode` field
|
|
656
|
-
// lives on the `identity` object (sibling of `providers`), separate from
|
|
657
|
-
// the top-level `mediation` field.
|
|
658
|
-
const buildCredentialOptions = (modeValue: FedCMRequestMode | undefined): FedCMCredentialRequest => ({
|
|
659
|
-
identity: {
|
|
660
|
-
providers: [
|
|
661
|
-
{
|
|
662
|
-
configURL: options.configURL,
|
|
663
|
-
clientId: options.clientId,
|
|
664
|
-
// Older browsers read `nonce` at the top level; Chrome 145+
|
|
665
|
-
// expects it inside `params`. Send both for full coverage.
|
|
666
|
-
nonce: options.nonce,
|
|
667
|
-
params: {
|
|
668
|
-
nonce: options.nonce,
|
|
669
|
-
},
|
|
670
|
-
...(options.loginHint && { loginHint: options.loginHint }),
|
|
671
|
-
},
|
|
672
|
-
],
|
|
673
|
-
...(modeValue && { mode: modeValue }),
|
|
674
|
-
},
|
|
675
|
-
mediation: requestedMediation,
|
|
676
|
-
signal: controller.signal,
|
|
677
|
-
});
|
|
678
|
-
|
|
679
|
-
// The DOM lib's `CredentialsContainer` does not declare the FedCM `identity`
|
|
680
|
-
// request in every TypeScript version we build against. Re-type through the
|
|
681
|
-
// minimal structural interface above (not `any`) to keep this typed.
|
|
682
|
-
const credentials = navigator.credentials as unknown as FedCMCredentialsContainer;
|
|
683
|
-
|
|
684
|
-
fedCMRequestPromise = (async () => {
|
|
685
|
-
try {
|
|
686
|
-
debug.log('Calling navigator.credentials.get with mediation:', requestedMediation, modernMode ? `mode: ${modernMode}` : '');
|
|
687
|
-
let credential: FedCMIdentityCredential | null;
|
|
688
|
-
try {
|
|
689
|
-
// Race the browser FedCM lookup against the hard settle guarantee so
|
|
690
|
-
// a `credentials.get` that ignores the abort signal can never hang
|
|
691
|
-
// the cold boot (see `settlePromise`).
|
|
692
|
-
credential = await Promise.race([
|
|
693
|
-
credentials.get(buildCredentialOptions(modernMode)),
|
|
694
|
-
settlePromise,
|
|
695
|
-
]);
|
|
696
|
-
} catch (modeError) {
|
|
697
|
-
// Chrome 125–131 only knows the legacy 'button'/'widget' enum and
|
|
698
|
-
// throws a synchronous TypeError for the modern 'active'/'passive'
|
|
699
|
-
// values. Retry once with the legacy value so older browsers work.
|
|
700
|
-
if (modernMode && isUnknownModeEnumError(modeError)) {
|
|
701
|
-
const legacyMode = MODERN_TO_LEGACY_MODE[modernMode];
|
|
702
|
-
debug.log(`Browser rejected modern mode '${modernMode}'; retrying with legacy mode '${legacyMode}'`);
|
|
703
|
-
credential = await Promise.race([
|
|
704
|
-
credentials.get(buildCredentialOptions(legacyMode)),
|
|
705
|
-
settlePromise,
|
|
706
|
-
]);
|
|
707
|
-
} else {
|
|
708
|
-
throw modeError;
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
|
|
712
|
-
debug.log('navigator.credentials.get returned:', {
|
|
713
|
-
hasCredential: !!credential,
|
|
714
|
-
type: credential?.type,
|
|
715
|
-
hasToken: !!credential?.token,
|
|
716
|
-
});
|
|
717
|
-
|
|
718
|
-
if (!credential || credential.type !== 'identity' || !credential.token) {
|
|
719
|
-
debug.log('No valid identity credential returned');
|
|
720
|
-
return null;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
const isAutoSelected = !!credential.isAutoSelected;
|
|
724
|
-
debug.log('Got valid identity credential with token', { isAutoSelected });
|
|
725
|
-
return { token: credential.token, isAutoSelected };
|
|
726
|
-
} catch (error) {
|
|
727
|
-
const errorName = error instanceof Error ? error.name : 'Unknown';
|
|
728
|
-
const errorMessage = error instanceof Error ? error.message : String(error);
|
|
729
|
-
debug.log('navigator.credentials.get error:', { name: errorName, message: errorMessage });
|
|
730
|
-
throw error;
|
|
731
|
-
} finally {
|
|
732
|
-
clearTimeout(timeout);
|
|
733
|
-
if (settleTimer !== undefined) {
|
|
734
|
-
clearTimeout(settleTimer);
|
|
735
|
-
}
|
|
736
|
-
// Only reset the shared lock if it still belongs to THIS request. When an
|
|
737
|
-
// interactive request aborts a slow silent one, the silent settles (and
|
|
738
|
-
// runs this `finally`) AFTER the interactive has already taken over the
|
|
739
|
-
// lock and installed its own controller/promise. Guarding on identity
|
|
740
|
-
// (`fedCMActiveController === controller`) ensures the settling silent
|
|
741
|
-
// cannot null out the interactive request's in-progress state. The
|
|
742
|
-
// request that still owns the lock clears it; the superseded one is a
|
|
743
|
-
// no-op here.
|
|
744
|
-
if (fedCMActiveController === controller) {
|
|
745
|
-
fedCMRequestInProgress = false;
|
|
746
|
-
fedCMRequestPromise = null;
|
|
747
|
-
currentMediationMode = null;
|
|
748
|
-
fedCMActiveController = null;
|
|
749
|
-
}
|
|
750
|
-
}
|
|
751
|
-
})();
|
|
752
|
-
|
|
753
|
-
return fedCMRequestPromise;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
/**
|
|
757
|
-
* Exchange FedCM ID token for Oxy session
|
|
758
|
-
*
|
|
759
|
-
* The ID token is a JWT issued by auth.oxy.so that proves the user's
|
|
760
|
-
* identity. We exchange it for a full Oxy session with access token.
|
|
761
|
-
*
|
|
762
|
-
* @private
|
|
763
|
-
*/
|
|
764
|
-
public async exchangeIdTokenForSession(idToken: string): Promise<SessionLoginResponse> {
|
|
765
|
-
debug.log('Exchanging ID token for session...');
|
|
766
|
-
|
|
767
|
-
try {
|
|
768
|
-
const response = await this.makeRequest<SessionLoginResponse>(
|
|
769
|
-
'POST',
|
|
770
|
-
'/fedcm/exchange',
|
|
771
|
-
{ id_token: idToken },
|
|
772
|
-
{ cache: false }
|
|
773
|
-
);
|
|
774
|
-
|
|
775
|
-
debug.log('Token exchange complete:', {
|
|
776
|
-
hasSession: !!response?.sessionId,
|
|
777
|
-
hasUser: !!response?.user,
|
|
778
|
-
});
|
|
779
|
-
|
|
780
|
-
return {
|
|
781
|
-
...response,
|
|
782
|
-
user: normalizeUserIdentity(response.user),
|
|
783
|
-
};
|
|
784
|
-
} catch (error) {
|
|
785
|
-
debug.error('Token exchange failed:', error instanceof Error ? error.message : String(error));
|
|
786
|
-
throw error;
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
/**
|
|
791
|
-
* Revoke FedCM credential (sign out)
|
|
792
|
-
*
|
|
793
|
-
* Uses IdentityCredential.disconnect() to tell the browser to forget
|
|
794
|
-
* the RP-IdP-account association. This resets the "returning account"
|
|
795
|
-
* state, which is required for silent mediation to work again.
|
|
796
|
-
*/
|
|
797
|
-
async revokeFedCMCredential(): Promise<void> {
|
|
798
|
-
// Read hint before clearing so we can pass it to disconnect()
|
|
799
|
-
const accountHint = this.getStoredLoginHint();
|
|
800
|
-
this.clearLoginHint();
|
|
801
|
-
|
|
802
|
-
if (!this.isFedCMSupported()) {
|
|
803
|
-
return;
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
try {
|
|
807
|
-
// The DOM lib does not declare the global `IdentityCredential` interface
|
|
808
|
-
// object (with its static `disconnect`) in every TypeScript version we
|
|
809
|
-
// build against. Read it off `window` through the minimal structural type
|
|
810
|
-
// (not `any`), guarding that `disconnect` is actually present at runtime.
|
|
811
|
-
const fedCMWindow = window as unknown as {
|
|
812
|
-
IdentityCredential?: Partial<FedCMIdentityCredentialStatic>;
|
|
813
|
-
};
|
|
814
|
-
const identityCredential = fedCMWindow.IdentityCredential;
|
|
815
|
-
if (identityCredential && typeof identityCredential.disconnect === 'function') {
|
|
816
|
-
const clientId = this.getClientId();
|
|
817
|
-
await identityCredential.disconnect({
|
|
818
|
-
configURL: this.resolveFedcmConfigUrl(),
|
|
819
|
-
clientId,
|
|
820
|
-
accountHint: accountHint || '*',
|
|
821
|
-
});
|
|
822
|
-
debug.log('FedCM credential disconnected');
|
|
823
|
-
}
|
|
824
|
-
} catch (error) {
|
|
825
|
-
debug.log('FedCM disconnect failed (non-critical):', error instanceof Error ? error.message : String(error));
|
|
826
|
-
}
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
/**
|
|
830
|
-
* Get configuration for FedCM
|
|
831
|
-
*
|
|
832
|
-
* @returns FedCM configuration with browser support info
|
|
833
|
-
*/
|
|
834
|
-
getFedCMConfig(): FedCMConfig {
|
|
835
|
-
return {
|
|
836
|
-
enabled: this.isFedCMSupported(),
|
|
837
|
-
configURL: this.resolveFedcmConfigUrl(),
|
|
838
|
-
clientId: this.getClientId(),
|
|
839
|
-
};
|
|
840
|
-
}
|
|
841
|
-
|
|
842
|
-
/**
|
|
843
|
-
* Generate a cryptographically secure local nonce for FedCM.
|
|
844
|
-
*
|
|
845
|
-
* NOTE: this is a *local* fallback only. The server-side `/fedcm/exchange`
|
|
846
|
-
* endpoint requires the nonce embedded in the ID token to have been minted
|
|
847
|
-
* by `POST /fedcm/nonce` (see {@link mintServerNonce}) and bound to this
|
|
848
|
-
* origin. A purely local nonce will be rejected with `invalid_nonce`. Use
|
|
849
|
-
* {@link getFedcmNonce}, which prefers a server-minted nonce and only falls
|
|
850
|
-
* back to this generator when the mint endpoint is unreachable.
|
|
851
|
-
*
|
|
852
|
-
* @private
|
|
853
|
-
*/
|
|
854
|
-
public generateNonce(): string {
|
|
855
|
-
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
|
856
|
-
return crypto.randomUUID();
|
|
857
|
-
}
|
|
858
|
-
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
|
|
859
|
-
const bytes = new Uint8Array(16);
|
|
860
|
-
crypto.getRandomValues(bytes);
|
|
861
|
-
return Array.from(bytes, b => b.toString(16).padStart(2, '0')).join('');
|
|
862
|
-
}
|
|
863
|
-
throw new Error('No secure random source available for nonce generation');
|
|
864
|
-
}
|
|
865
|
-
|
|
866
|
-
/**
|
|
867
|
-
* Mint a single-use, origin-bound nonce from the Oxy API.
|
|
868
|
-
*
|
|
869
|
-
* The FedCM ID token issued by the IdP embeds this nonce as the `nonce`
|
|
870
|
-
* claim. When the consuming app calls `POST /fedcm/exchange`, the API burns
|
|
871
|
-
* the nonce (atomic `usedAt` transition) and verifies it was minted for the
|
|
872
|
-
* same origin as the token `aud`. This is the anti-replay binding required
|
|
873
|
-
* by the API's H9 hardening — without a server-minted nonce the exchange
|
|
874
|
-
* always fails.
|
|
875
|
-
*
|
|
876
|
-
* The browser attaches the `Origin` header automatically on this
|
|
877
|
-
* cross-origin request, so the API binds the nonce to the calling app's
|
|
878
|
-
* origin (which also becomes the FedCM `clientId`/token `aud`).
|
|
879
|
-
*
|
|
880
|
-
* @private
|
|
881
|
-
*/
|
|
882
|
-
public async mintServerNonce(): Promise<string> {
|
|
883
|
-
const result = await this.makeRequest<{ nonce: string; expiresAt: string }>(
|
|
884
|
-
'POST',
|
|
885
|
-
'/fedcm/nonce',
|
|
886
|
-
{},
|
|
887
|
-
{ cache: false }
|
|
888
|
-
);
|
|
889
|
-
if (!result?.nonce) {
|
|
890
|
-
throw new OxyAuthenticationError('FedCM nonce endpoint returned no nonce');
|
|
891
|
-
}
|
|
892
|
-
return result.nonce;
|
|
893
|
-
}
|
|
894
|
-
|
|
895
|
-
/**
|
|
896
|
-
* Resolve the nonce to use for a FedCM credential request.
|
|
897
|
-
*
|
|
898
|
-
* Prefers a server-minted, origin-bound nonce (required for the token
|
|
899
|
-
* exchange to succeed). If the mint endpoint is unreachable we fall back to
|
|
900
|
-
* a locally generated nonce so the browser flow can still proceed; the
|
|
901
|
-
* exchange may then fail server-side, but that is strictly better than
|
|
902
|
-
* throwing before the browser ever shows its UI.
|
|
903
|
-
*
|
|
904
|
-
* @private
|
|
905
|
-
*/
|
|
906
|
-
public async getFedcmNonce(): Promise<string> {
|
|
907
|
-
try {
|
|
908
|
-
return await this.mintServerNonce();
|
|
909
|
-
} catch (error) {
|
|
910
|
-
debug.warn(
|
|
911
|
-
'Could not mint server nonce, falling back to local nonce:',
|
|
912
|
-
error instanceof Error ? error.message : String(error)
|
|
913
|
-
);
|
|
914
|
-
return this.generateNonce();
|
|
915
|
-
}
|
|
916
|
-
}
|
|
917
|
-
|
|
918
|
-
/**
|
|
919
|
-
* Get the client ID for this origin
|
|
920
|
-
*
|
|
921
|
-
* @private
|
|
922
|
-
*/
|
|
923
|
-
public getClientId(): string {
|
|
924
|
-
if (typeof window === 'undefined') {
|
|
925
|
-
return 'unknown';
|
|
926
|
-
}
|
|
927
|
-
return window.location.origin;
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
/** @internal */
|
|
931
|
-
public getStoredLoginHint(): string | undefined {
|
|
932
|
-
if (typeof window === 'undefined') return undefined;
|
|
933
|
-
try {
|
|
934
|
-
return localStorage.getItem(FEDCM_LOGIN_HINT_KEY) || undefined;
|
|
935
|
-
} catch {
|
|
936
|
-
return undefined;
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
|
|
940
|
-
/** @internal */
|
|
941
|
-
public storeLoginHint(userId: string): void {
|
|
942
|
-
if (typeof window === 'undefined') return;
|
|
943
|
-
try {
|
|
944
|
-
localStorage.setItem(FEDCM_LOGIN_HINT_KEY, userId);
|
|
945
|
-
} catch {
|
|
946
|
-
// Storage full or blocked
|
|
947
|
-
}
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
/** @internal */
|
|
951
|
-
public clearLoginHint(): void {
|
|
952
|
-
if (typeof window === 'undefined') return;
|
|
953
|
-
try {
|
|
954
|
-
localStorage.removeItem(FEDCM_LOGIN_HINT_KEY);
|
|
955
|
-
} catch {
|
|
956
|
-
// Storage blocked
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
|
|
960
|
-
/**
|
|
961
|
-
* List the authenticated user's authorized RP apps.
|
|
962
|
-
*
|
|
963
|
-
* Returns the intersection of the user's FedCM grants and the currently-
|
|
964
|
-
* approved RP catalog — what powers the "Connected apps" management UI in
|
|
965
|
-
* @oxyhq/services. Requires a real user session; service tokens are
|
|
966
|
-
* rejected by the underlying endpoint.
|
|
967
|
-
*/
|
|
968
|
-
async listAuthorizedApps(): Promise<AuthorizedApp[]> {
|
|
969
|
-
try {
|
|
970
|
-
const response = await this.makeRequest<{ apps: AuthorizedApp[] }>(
|
|
971
|
-
'GET',
|
|
972
|
-
'/fedcm/me/authorized-apps',
|
|
973
|
-
undefined,
|
|
974
|
-
{
|
|
975
|
-
cache: true,
|
|
976
|
-
cacheTTL: 30 * 1000, // 30 second cache — short, this drives a manageable UI
|
|
977
|
-
}
|
|
978
|
-
);
|
|
979
|
-
return response.apps ?? [];
|
|
980
|
-
} catch (error) {
|
|
981
|
-
throw this.handleError(error);
|
|
982
|
-
}
|
|
983
|
-
}
|
|
984
|
-
|
|
985
|
-
/**
|
|
986
|
-
* Revoke the authenticated user's authorization for a specific RP origin.
|
|
987
|
-
*
|
|
988
|
-
* The next FedCM sign-in from that origin will require explicit re-consent.
|
|
989
|
-
* The corresponding cache entry is invalidated so a subsequent
|
|
990
|
-
* `listAuthorizedApps()` call sees fresh data.
|
|
991
|
-
*/
|
|
992
|
-
async revokeAuthorizedApp(origin: string): Promise<void> {
|
|
993
|
-
try {
|
|
994
|
-
await this.makeRequest(
|
|
995
|
-
'DELETE',
|
|
996
|
-
`/fedcm/me/authorized-apps/${encodeURIComponent(origin)}`,
|
|
997
|
-
undefined,
|
|
998
|
-
{ cache: false }
|
|
999
|
-
);
|
|
1000
|
-
this.clearCacheEntry('GET:/fedcm/me/authorized-apps');
|
|
1001
|
-
} catch (error) {
|
|
1002
|
-
throw this.handleError(error);
|
|
1003
|
-
}
|
|
1004
|
-
}
|
|
1005
|
-
};
|
|
1006
|
-
}
|
|
1007
|
-
|
|
1008
|
-
/**
|
|
1009
|
-
* Public summary of an RP application the user has authorized — mirrors the
|
|
1010
|
-
* `AuthorizedAppSummary` shape returned by `GET /fedcm/me/authorized-apps`.
|
|
1011
|
-
*/
|
|
1012
|
-
export interface AuthorizedApp {
|
|
1013
|
-
/** Normalised RP origin. */
|
|
1014
|
-
origin: string;
|
|
1015
|
-
/** Friendly display name. */
|
|
1016
|
-
name: string;
|
|
1017
|
-
/** Optional human-readable description. */
|
|
1018
|
-
description?: string;
|
|
1019
|
-
/** ISO-8601 timestamp of when the user first authorized this RP. */
|
|
1020
|
-
firstGrantedAt: string;
|
|
1021
|
-
/** ISO-8601 timestamp of the most recent FedCM exchange for this user+RP. */
|
|
1022
|
-
lastUsedAt: string;
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
// Export the mixin function as both named and default
|
|
1026
|
-
export { OxyServicesFedCMMixin as FedCMMixin };
|