central-auth-js-sdk 1.0.13 → 1.2.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/INTEGRATION_GUIDE.md +210 -52
- package/auth.js +78 -4
- package/core/api.js +8 -4
- package/core/login-flow.js +3 -2
- package/core/login-state.js +3 -0
- package/core/oauth-redirect.js +106 -0
- package/index.js +17 -3
- package/login.js +74 -35
- package/package.json +1 -1
- package/signup.js +13 -16
- package/styles.css +255 -177
- package/theme.js +2 -2
- package/ui/layout.js +0 -1
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared OAuth redirect handling for login and signup flows.
|
|
3
|
+
* Backend 302-redirects to redirect_uri with query params after Google/Microsoft auth.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const OAUTH_PARAM_KEYS = [
|
|
7
|
+
'success', 'provider', 'token', 'login_token', 'two_factor_required', 'fr_token',
|
|
8
|
+
'fr_token_expires_in_s', 'next_step', 'message', 'user_id', 'email', 'entity_domain', 'error',
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export const OAUTH_PENDING_CLASS = 'central-auth-oauth-pending';
|
|
12
|
+
|
|
13
|
+
/** @returns {boolean} True when the URL looks like an OAuth redirect callback */
|
|
14
|
+
export function hasOAuthRedirect() {
|
|
15
|
+
if (typeof window === 'undefined' || !window.location) return false;
|
|
16
|
+
const params = new URLSearchParams(window.location.search);
|
|
17
|
+
return params.has('provider')
|
|
18
|
+
|| params.has('login_token')
|
|
19
|
+
|| params.has('token')
|
|
20
|
+
|| params.has('success');
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse OAuth redirect query params.
|
|
25
|
+
* @returns {Object|null} Parsed redirect payload
|
|
26
|
+
*/
|
|
27
|
+
export function parseOAuthRedirect() {
|
|
28
|
+
if (!hasOAuthRedirect()) return null;
|
|
29
|
+
|
|
30
|
+
const params = new URLSearchParams(window.location.search);
|
|
31
|
+
const provider = (params.get('provider') || '').toLowerCase();
|
|
32
|
+
const hasProvider = provider === 'google' || provider === 'microsoft';
|
|
33
|
+
const loginToken = params.get('login_token');
|
|
34
|
+
const token = params.get('token');
|
|
35
|
+
const successParam = params.get('success');
|
|
36
|
+
const error = params.get('error');
|
|
37
|
+
const nextStep = (params.get('next_step') || '').toUpperCase();
|
|
38
|
+
|
|
39
|
+
const shared = {
|
|
40
|
+
provider: hasProvider ? provider : 'google',
|
|
41
|
+
email: params.get('email'),
|
|
42
|
+
userId: params.get('user_id'),
|
|
43
|
+
entityDomain: params.get('entity_domain'),
|
|
44
|
+
message: params.get('message'),
|
|
45
|
+
nextStep,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
if (successParam === 'false' || error) {
|
|
49
|
+
return { ...shared, kind: 'error', error: error || 'Login failed' };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const twoFactorRaw = params.get('two_factor_required');
|
|
53
|
+
const twoFactorRequired = twoFactorRaw === 'true' || twoFactorRaw === '1' || nextStep === 'FR_VERIFICATION';
|
|
54
|
+
|
|
55
|
+
if (loginToken) {
|
|
56
|
+
return {
|
|
57
|
+
...shared,
|
|
58
|
+
kind: 'pending',
|
|
59
|
+
loginToken,
|
|
60
|
+
twoFactorRequired,
|
|
61
|
+
frToken: params.get('fr_token') || null,
|
|
62
|
+
frTokenExpiresInS: params.get('fr_token_expires_in_s') || null,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (token) {
|
|
67
|
+
return { ...shared, kind: 'complete', token };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** @param {ReturnType<parseOAuthRedirect>} redirect */
|
|
74
|
+
export function getOAuthRedirectFlowType(redirect) {
|
|
75
|
+
if (!redirect) return null;
|
|
76
|
+
if (redirect.nextStep === 'FR_ENROLLMENT' || redirect.nextStep === 'MOBILE_VERIFICATION') {
|
|
77
|
+
return 'signup';
|
|
78
|
+
}
|
|
79
|
+
return 'login';
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Remove OAuth params from the URL without reloading */
|
|
83
|
+
export function clearOAuthRedirectParams() {
|
|
84
|
+
if (typeof window === 'undefined' || !window.history) return;
|
|
85
|
+
const params = new URLSearchParams(window.location.search);
|
|
86
|
+
OAUTH_PARAM_KEYS.forEach((key) => params.delete(key));
|
|
87
|
+
const clean = params.toString();
|
|
88
|
+
const url = window.location.pathname + (clean ? '?' + clean : '') + window.location.hash;
|
|
89
|
+
window.history.replaceState({}, '', url);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Mark the document while OAuth resume is pending (reduces parent-app flash).
|
|
94
|
+
* Parent apps can hide main UI with:
|
|
95
|
+
* html.central-auth-oauth-pending [data-central-auth-main] { display: none !important; }
|
|
96
|
+
* html.central-auth-oauth-pending [data-central-auth-sdk] { display: block !important; }
|
|
97
|
+
*/
|
|
98
|
+
export function markOAuthRedirectPending() {
|
|
99
|
+
if (!hasOAuthRedirect() || typeof document === 'undefined') return;
|
|
100
|
+
document.documentElement.classList.add(OAUTH_PENDING_CLASS);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function clearOAuthRedirectPendingMark() {
|
|
104
|
+
if (typeof document === 'undefined') return;
|
|
105
|
+
document.documentElement.classList.remove(OAUTH_PENDING_CLASS);
|
|
106
|
+
}
|
package/index.js
CHANGED
|
@@ -4,17 +4,31 @@
|
|
|
4
4
|
* APIs wired to Central Auth (central_auth_collection.json)
|
|
5
5
|
*
|
|
6
6
|
* Public API for host applications:
|
|
7
|
-
* initializeAuth(config) — set default configuration
|
|
7
|
+
* initializeAuth(config) — set default configuration (+ auto-resume OAuth when configured)
|
|
8
8
|
* startSignup(config) — run sign-up flow in a container
|
|
9
|
-
* startLogin(config)
|
|
9
|
+
* startLogin(config) — run login flow in a container
|
|
10
|
+
* autoResumeOAuthFlow(config) — mount login/signup after OAuth redirect (no-op otherwise)
|
|
10
11
|
*
|
|
11
12
|
* Lower-level: mount (signup), mountLogin (login)
|
|
12
13
|
*/
|
|
13
14
|
|
|
15
|
+
import { markOAuthRedirectPending } from './core/oauth-redirect.js';
|
|
16
|
+
|
|
17
|
+
markOAuthRedirectPending();
|
|
18
|
+
|
|
14
19
|
export { mount } from './signup.js';
|
|
15
20
|
export { FlowController, STEPS } from './core/flow.js';
|
|
16
21
|
export { StateManager } from './core/state.js';
|
|
17
22
|
export { createApi, dataUrlToFile, base64ToFile } from './core/api.js';
|
|
18
23
|
export { mountLogin } from './login.js';
|
|
19
|
-
export { initializeAuth, startSignup, startLogin } from './auth.js';
|
|
24
|
+
export { initializeAuth, startSignup, startLogin, autoResumeOAuthFlow } from './auth.js';
|
|
20
25
|
export { applyTheme, THEME_PRESETS } from './theme.js';
|
|
26
|
+
export {
|
|
27
|
+
hasOAuthRedirect,
|
|
28
|
+
hasOAuthRedirect as hasPendingOAuthRedirect,
|
|
29
|
+
parseOAuthRedirect,
|
|
30
|
+
getOAuthRedirectFlowType,
|
|
31
|
+
markOAuthRedirectPending,
|
|
32
|
+
clearOAuthRedirectPendingMark,
|
|
33
|
+
OAUTH_PENDING_CLASS,
|
|
34
|
+
} from './core/oauth-redirect.js';
|
package/login.js
CHANGED
|
@@ -8,29 +8,24 @@ import { resolveApiBaseUrl } from './core/env-config.js';
|
|
|
8
8
|
import { normalizeLoginConfig, getEnabledLoginMethods, LOGIN_METHODS } from './core/login-config.js';
|
|
9
9
|
import { LoginFlowController, LOGIN_STEPS } from './core/login-flow.js';
|
|
10
10
|
import { LoginStateManager } from './core/login-state.js';
|
|
11
|
+
import { parseOAuthRedirect, clearOAuthRedirectParams, clearOAuthRedirectPendingMark } from './core/oauth-redirect.js';
|
|
11
12
|
import { createLayout } from './ui/layout.js';
|
|
12
13
|
import { createLoginScreen } from './ui/screens/login/index.js';
|
|
13
14
|
import { applyTheme } from './theme.js';
|
|
14
15
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
if (
|
|
18
|
-
const
|
|
19
|
-
|
|
20
|
-
const provider = (params.get('provider') || '').toLowerCase();
|
|
21
|
-
if (!loginToken) return null;
|
|
22
|
-
if (provider !== 'google' && provider !== 'microsoft') return { loginToken, provider: 'google' };
|
|
23
|
-
return { loginToken, provider };
|
|
16
|
+
function isTwoFactorRequired(res) {
|
|
17
|
+
if (!res || typeof res !== 'object') return false;
|
|
18
|
+
if (res.two_factor_required === true) return true;
|
|
19
|
+
const step = String(res.next_step || '').toUpperCase();
|
|
20
|
+
return step === 'FR_VERIFICATION';
|
|
24
21
|
}
|
|
25
22
|
|
|
26
|
-
function
|
|
27
|
-
if (
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
const url = window.location.pathname + (clean ? '?' + clean : '') + window.location.hash;
|
|
33
|
-
window.history.replaceState({}, '', url);
|
|
23
|
+
function applyTwoFactorFromResponse(res, state) {
|
|
24
|
+
if (res.login_token) state.set('loginToken', res.login_token);
|
|
25
|
+
if (res.fr_token) state.set('frToken', res.fr_token);
|
|
26
|
+
if (res.fr_token_expires_in_s != null) state.set('frTokenExpiresInS', res.fr_token_expires_in_s);
|
|
27
|
+
if (res.provider) state.set('socialProvider', String(res.provider).toLowerCase());
|
|
28
|
+
if (res.message) state.set('twoFactorMessage', res.message);
|
|
34
29
|
}
|
|
35
30
|
|
|
36
31
|
/**
|
|
@@ -65,12 +60,36 @@ export function mountLogin(container, options = {}) {
|
|
|
65
60
|
const cleanupTheme = applyTheme(layout.root, theme);
|
|
66
61
|
const showError = (msg) => layout.showError(msg);
|
|
67
62
|
|
|
68
|
-
const oauth =
|
|
63
|
+
const oauth = parseOAuthRedirect();
|
|
69
64
|
if (oauth) {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
65
|
+
clearOAuthRedirectParams();
|
|
66
|
+
if (oauth.kind === 'error') {
|
|
67
|
+
state.set('error', oauth.error);
|
|
68
|
+
} else if (oauth.kind === 'complete') {
|
|
69
|
+
if (oauth.email) state.set('identifier', oauth.email);
|
|
70
|
+
const result = {
|
|
71
|
+
token: oauth.token,
|
|
72
|
+
user_id: oauth.userId,
|
|
73
|
+
email: oauth.email,
|
|
74
|
+
entity_domain: oauth.entityDomain,
|
|
75
|
+
message: oauth.message,
|
|
76
|
+
provider: oauth.provider,
|
|
77
|
+
};
|
|
78
|
+
state.set('loginResult', result);
|
|
79
|
+
flow.setSocialCallback(oauth.provider, { twoFactorRequired: false });
|
|
80
|
+
flow.setStep(LOGIN_STEPS.SUCCESS);
|
|
81
|
+
if (typeof onLoginComplete === 'function') {
|
|
82
|
+
onLoginComplete(buildLoginCompletePayload(result));
|
|
83
|
+
}
|
|
84
|
+
} else if (oauth.kind === 'pending') {
|
|
85
|
+
state.set('loginToken', oauth.loginToken);
|
|
86
|
+
state.set('socialProvider', oauth.provider);
|
|
87
|
+
if (oauth.email) state.set('identifier', oauth.email);
|
|
88
|
+
if (oauth.frToken) state.set('frToken', oauth.frToken);
|
|
89
|
+
if (oauth.frTokenExpiresInS) state.set('frTokenExpiresInS', oauth.frTokenExpiresInS);
|
|
90
|
+
if (oauth.message) state.set('twoFactorMessage', oauth.message);
|
|
91
|
+
flow.setSocialCallback(oauth.provider, { twoFactorRequired: oauth.twoFactorRequired });
|
|
92
|
+
}
|
|
74
93
|
}
|
|
75
94
|
|
|
76
95
|
const enabled = getEnabledLoginMethods(config);
|
|
@@ -104,6 +123,19 @@ export function mountLogin(container, options = {}) {
|
|
|
104
123
|
return payload;
|
|
105
124
|
}
|
|
106
125
|
|
|
126
|
+
function completeLogin(apiResult) {
|
|
127
|
+
state.set('loginResult', apiResult);
|
|
128
|
+
flow.goNext();
|
|
129
|
+
if (typeof onLoginComplete === 'function') {
|
|
130
|
+
onLoginComplete(buildLoginCompletePayload(apiResult));
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function requireFaceVerification(res) {
|
|
135
|
+
applyTwoFactorFromResponse(res, state);
|
|
136
|
+
flow.injectFrsStep();
|
|
137
|
+
}
|
|
138
|
+
|
|
107
139
|
async function handleIdentifierEmail(email) {
|
|
108
140
|
state.clearError();
|
|
109
141
|
state.set('identifier', email);
|
|
@@ -153,11 +185,11 @@ export function mountLogin(container, options = {}) {
|
|
|
153
185
|
}
|
|
154
186
|
try {
|
|
155
187
|
const res = await api.loginCustom(identifier, password, config.appIdentifier, null);
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
onLoginComplete(buildLoginCompletePayload(res));
|
|
188
|
+
if (isTwoFactorRequired(res)) {
|
|
189
|
+
requireFaceVerification(res);
|
|
190
|
+
return;
|
|
160
191
|
}
|
|
192
|
+
completeLogin(res);
|
|
161
193
|
} catch (err) {
|
|
162
194
|
if (isFrRequiredError(err)) {
|
|
163
195
|
flow.injectFrsStep();
|
|
@@ -180,6 +212,10 @@ export function mountLogin(container, options = {}) {
|
|
|
180
212
|
}
|
|
181
213
|
try {
|
|
182
214
|
const res = await api.otpVerify(identifier, '', channel, otp);
|
|
215
|
+
if (isTwoFactorRequired(res)) {
|
|
216
|
+
requireFaceVerification(res);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
183
219
|
state.set('loginResult', res);
|
|
184
220
|
flow.setStep(LOGIN_STEPS.SUCCESS);
|
|
185
221
|
if (typeof onLoginComplete === 'function') {
|
|
@@ -206,6 +242,7 @@ export function mountLogin(container, options = {}) {
|
|
|
206
242
|
}
|
|
207
243
|
const method = flow.currentMethod;
|
|
208
244
|
const identifier = state.get('identifier');
|
|
245
|
+
const frToken = state.get('frToken');
|
|
209
246
|
let apiResult = null;
|
|
210
247
|
|
|
211
248
|
layout.setLoading(true, 'Verifying your face…');
|
|
@@ -213,20 +250,16 @@ export function mountLogin(container, options = {}) {
|
|
|
213
250
|
if (method === LOGIN_METHODS.EMAIL_FRS) {
|
|
214
251
|
apiResult = await api.loginEmailFr(identifier, file, null, config.appIdentifier);
|
|
215
252
|
} else if (method === LOGIN_METHODS.EMAIL_PASSWORD || method === LOGIN_METHODS.MOBILE_PASSWORD) {
|
|
216
|
-
apiResult = await api.loginCustom(identifier, state.get('password'), config.appIdentifier, [file, file]);
|
|
253
|
+
apiResult = await api.loginCustom(identifier, state.get('password'), config.appIdentifier, [file, file], frToken);
|
|
217
254
|
} else if (method === LOGIN_METHODS.SOCIAL_GOOGLE) {
|
|
218
|
-
apiResult = await api.googleFrEnroll(state.get('loginToken'), file, null, config.appIdentifier);
|
|
255
|
+
apiResult = await api.googleFrEnroll(state.get('loginToken'), file, null, config.appIdentifier, frToken);
|
|
219
256
|
} else if (method === LOGIN_METHODS.SOCIAL_MICROSOFT) {
|
|
220
|
-
apiResult = await api.microsoftFrEnroll(state.get('loginToken'), file, null, config.appIdentifier);
|
|
257
|
+
apiResult = await api.microsoftFrEnroll(state.get('loginToken'), file, null, config.appIdentifier, frToken);
|
|
221
258
|
} else if (method === LOGIN_METHODS.EMAIL_OTP || method === LOGIN_METHODS.MOBILE_OTP) {
|
|
222
259
|
const channel = identifier.includes('@') ? 'email' : 'sms';
|
|
223
|
-
apiResult = await api.otpVerify(identifier, '', channel, state.get('otp'), [file, file]);
|
|
224
|
-
}
|
|
225
|
-
state.set('loginResult', apiResult);
|
|
226
|
-
flow.goNext();
|
|
227
|
-
if (typeof onLoginComplete === 'function') {
|
|
228
|
-
onLoginComplete(buildLoginCompletePayload(apiResult));
|
|
260
|
+
apiResult = await api.otpVerify(identifier, '', channel, state.get('otp'), [file, file], frToken);
|
|
229
261
|
}
|
|
262
|
+
completeLogin(apiResult);
|
|
230
263
|
} catch (err) {
|
|
231
264
|
const msg = err?.data?.error || err?.message || 'Verification failed';
|
|
232
265
|
state.set('error', msg);
|
|
@@ -312,6 +345,12 @@ export function mountLogin(container, options = {}) {
|
|
|
312
345
|
|
|
313
346
|
flow.subscribe(render);
|
|
314
347
|
render();
|
|
348
|
+
if (oauth?.kind === 'error') {
|
|
349
|
+
showError(oauth.error);
|
|
350
|
+
}
|
|
351
|
+
if (oauth) {
|
|
352
|
+
clearOAuthRedirectPendingMark();
|
|
353
|
+
}
|
|
315
354
|
|
|
316
355
|
return {
|
|
317
356
|
unmount() { cleanupTheme(); layout.root.remove(); },
|
package/package.json
CHANGED
package/signup.js
CHANGED
|
@@ -7,30 +7,21 @@ import { FlowController, STEPS } from './core/flow.js';
|
|
|
7
7
|
import { StateManager } from './core/state.js';
|
|
8
8
|
import { createApi, dataUrlToFile } from './core/api.js';
|
|
9
9
|
import { resolveApiBaseUrl } from './core/env-config.js';
|
|
10
|
+
import { parseOAuthRedirect, clearOAuthRedirectParams } from './core/oauth-redirect.js';
|
|
10
11
|
import { createLayout } from './ui/layout.js';
|
|
11
12
|
import { createScreen } from './ui/screens/index.js';
|
|
12
13
|
import { applyTheme } from './theme.js';
|
|
13
14
|
|
|
14
15
|
/** Parse URL query for OAuth callback params (login_token, provider) */
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
const provider = (params.get('provider') || '').toLowerCase();
|
|
20
|
-
if (!loginToken) return null;
|
|
21
|
-
if (provider !== 'google' && provider !== 'microsoft') return { loginToken, provider: 'google' };
|
|
22
|
-
return { loginToken, provider };
|
|
16
|
+
function parseSignupOAuthCallback() {
|
|
17
|
+
const redirect = parseOAuthRedirect();
|
|
18
|
+
if (!redirect || !redirect.loginToken) return null;
|
|
19
|
+
return redirect;
|
|
23
20
|
}
|
|
24
21
|
|
|
25
22
|
/** Remove OAuth params from URL without reload */
|
|
26
23
|
function clearOAuthParams() {
|
|
27
|
-
|
|
28
|
-
const params = new URLSearchParams(window.location.search);
|
|
29
|
-
params.delete('login_token');
|
|
30
|
-
params.delete('provider');
|
|
31
|
-
const clean = params.toString();
|
|
32
|
-
const url = window.location.pathname + (clean ? '?' + clean : '') + window.location.hash;
|
|
33
|
-
window.history.replaceState({}, '', url);
|
|
24
|
+
clearOAuthRedirectParams();
|
|
34
25
|
}
|
|
35
26
|
|
|
36
27
|
/** Map API next_step from POST /signup/start to our flow step */
|
|
@@ -90,12 +81,18 @@ export function mount(container, options = {}) {
|
|
|
90
81
|
const showError = (msg) => layout.showError(msg);
|
|
91
82
|
|
|
92
83
|
// SDK handles OAuth callback: if URL has login_token, resume social flow
|
|
93
|
-
const oauth =
|
|
84
|
+
const oauth = parseSignupOAuthCallback();
|
|
94
85
|
if (oauth) {
|
|
95
86
|
state.set('loginToken', oauth.loginToken);
|
|
96
87
|
state.set('socialProvider', oauth.provider);
|
|
88
|
+
if (oauth.email) state.set('email', oauth.email);
|
|
97
89
|
clearOAuthParams();
|
|
98
90
|
flow.setSocialFlow();
|
|
91
|
+
if (oauth.nextStep === 'FR_ENROLLMENT') {
|
|
92
|
+
flow.setStep(STEPS.FACE);
|
|
93
|
+
} else if (oauth.nextStep === 'MOBILE_VERIFICATION') {
|
|
94
|
+
flow.setStep(STEPS.MOBILE);
|
|
95
|
+
}
|
|
99
96
|
}
|
|
100
97
|
|
|
101
98
|
const isSocial = () => !!state.get('loginToken');
|