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.
@@ -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) — run login flow in a container
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
- /** Parse OAuth callback (login_token, provider) from URL */
16
- function parseOAuthCallback() {
17
- if (typeof window === 'undefined' || !window.location) return null;
18
- const params = new URLSearchParams(window.location.search);
19
- const loginToken = params.get('login_token');
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 clearOAuthParams() {
27
- if (typeof window === 'undefined' || !window.history) return;
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);
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 = parseOAuthCallback();
63
+ const oauth = parseOAuthRedirect();
69
64
  if (oauth) {
70
- state.set('loginToken', oauth.loginToken);
71
- state.set('socialProvider', oauth.provider);
72
- clearOAuthParams();
73
- flow.setSocialCallback(oauth.provider);
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
- state.set('loginResult', res);
157
- flow.goNext();
158
- if (typeof onLoginComplete === 'function') {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "central-auth-js-sdk",
3
- "version": "1.0.13",
3
+ "version": "1.2.0",
4
4
  "description": "Vanilla JS sign-up and login flow SDK — embeddable auth UI with configurable methods",
5
5
  "type": "module",
6
6
  "main": "index.js",
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 parseOAuthCallback() {
16
- if (typeof window === 'undefined' || !window.location) return null;
17
- const params = new URLSearchParams(window.location.search);
18
- const loginToken = params.get('login_token');
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
- if (typeof window === 'undefined' || !window.history) return;
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 = parseOAuthCallback();
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');