@mindful-web/marko-web-identity-x 1.76.7 → 1.77.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/CLAUDE.md ADDED
@@ -0,0 +1,139 @@
1
+ # marko-web-identity-x — Claude Code Context
2
+
3
+ The Marko/Express wrapper for **IdentityX** (Parameter1's identity + access platform) on Mindful
4
+ Web sites. Owns the whole authenticated-user surface: login / register / authenticate (email-link)
5
+ / profile / logout, content access + download gating, comments, and the server-side IdentityX API
6
+ client. Add-ons layer on top: `marko-web-omeda-identity-x` (wraps this and adds Omeda hooks —
7
+ how most sites install it), `marko-web-auth0-identity-x`, `marko-web-identity-x-mailchimp`. Google
8
+ Sign-In (One-Tap + button) is **built in** here (see below).
9
+
10
+ ## Config model — the `IdentityXConfiguration` instance (`config.js`)
11
+
12
+ Unlike most Mindful packages, config is **not** read from `site.getAsObject(...)` — it's an
13
+ instance of `IdentityXConfiguration` (`new IdentityX({...})`), passed to the installer and injected
14
+ onto every request as `req.identityX.config`. Read in templates via
15
+ `req.identityX.config.get(...)` / `.getAsObject(...)` / typed getters.
16
+
17
+ ```js
18
+ const IdentityX = require('@mindful-web/marko-web-identity-x/config');
19
+ const config = new IdentityX({
20
+ appId: '<APP-ID>', // required
21
+ apiToken: process.env.IDENTITYX_API_TOKEN, // required for write ops
22
+ requiredServerFields: ['organization', 'countryCode'],
23
+ requiredClientFields: [...], // gate access until present
24
+ hiddenFields: [...], defaultCountryCode, booleanQuestionsLabel, gtmUserFields,
25
+ onHookError: newrelic.noticeError.bind(newrelic), // hook + google-auth error reporter
26
+ googleAuth: { clientId, missingFieldsBehavior, autoPrompt, signInButtonEnabled }, // opt-in; see below
27
+ });
28
+ ```
29
+
30
+ Arbitrary extra keys pass through `...rest` into `options` and are reachable via `config.get(path)`.
31
+ `endpointTypes` (`authenticate|changeEmail|login|logout|register|profile`) map to `/user/<type>`
32
+ routes via `getEndpointFor(type)` (override with an `endpoints` config key).
33
+
34
+ ## Wiring (`index.js`)
35
+
36
+ `IdentityX(app, config, { templates })`:
37
+ 1. `app.use(middleware(config))` — injects the service (below).
38
+ 2. `app.use('/__idx', routes)` — the JSON API router.
39
+ 3. For each `endpointType` with a supplied `template`, registers `GET <endpoint>` to render it
40
+ (the `authenticate` endpoint redirects to profile when a token is already present).
41
+
42
+ Most sites don't call this directly — they call `omedaIdentityX(app, { idxConfig, idxRouteTemplates, … })`,
43
+ which adds Omeda integration hooks to `idxConfig` and then calls `identityX(app, idxConfig, …)`
44
+ internally. So **`googleAuth` goes on the `idxConfig` instance**, not the omeda params and not site config.
45
+
46
+ ## Middleware (`middleware.js`)
47
+
48
+ Injects a per-request `IdentityX` **service** instance as `req.identityX` (+ `res.locals.identityX`),
49
+ reads the token cookie, and sets the identity cookie for logged-in users. `IdentityXRequest.identityX`
50
+ is the type every route/template relies on.
51
+
52
+ ## Routes — `/__idx/*` (`routes/index.js`)
53
+
54
+ JSON API behind the Vue components: `authenticate`, `login`, `login-fields`, `logout`, `profile`,
55
+ `access`, `download`, `change-email/{initiate,confirm}`, `comment(s)`/`comment-count`/`flag`,
56
+ `countries`/`regions`, and **`google`** (see below). `logout.js` removes the context cookie, calls
57
+ `onLogout` (tokenless) or `logoutAppUser`, and **clears the GIS `g_state` cookie** unconditionally.
58
+
59
+ ## Components / tags (`components/marko.json`)
60
+
61
+ Marko wrappers (`<marko-web-identity-x-*>`) that render a matching **browser Vue component**
62
+ (`browser/*.vue`, registered in `browser/index.js` as `IdentityX*`, overridable via
63
+ `Custom*Component`): `form-login`, `form-authenticate`, `form-register`, `form-profile`,
64
+ `form-change-email`, `form-logout`, `form-access`, `access`, `comment-stream`, `identify` /
65
+ `non-auth-identify`, `context` (a `|{ user, application, fields, isEnabled }|` provider), and the
66
+ Google tags. Vue components emit an EventBus stream bridged to `dataLayer` + p1events (see
67
+ `browser/index.js`).
68
+
69
+ ## Service (`service.js`)
70
+
71
+ Server-side IdentityX GraphQL client (Apollo, idx-compat). Key methods: `loadActiveContext`,
72
+ `loadAppUserByEmail` / `findUserById` / `findUserByExternalId`, `createAppUser`,
73
+ `impersonateAppUser`, `logoutAppUser`, `addExternalUserId`, `setUnverifiedAppUserData`,
74
+ `generateEntityId`, `sendLoginLink`, `checkContentAccess`, cookie helpers (`setToken`,
75
+ `setIdentityCookie`, `getIdentity`). Org-scoped writes use `getOrgUserApiToken()`.
76
+
77
+ ## Hooks (`hooks.js`)
78
+
79
+ `onLoadActiveContext`, `onLoginLinkSent`, `onAuthenticationSuccess`, `onLogout`,
80
+ `onChangeEmailLinkSent`, `onChangeEmailSuccess`, `onUserProfileUpdate`. Registered via
81
+ `config.addHook({ name, fn, shouldAwait })` and invoked with `utils/call-hooks-for`. This is the
82
+ seam Omeda uses for rapid-identify + subscription sync. **Note:** `onLogout` currently fires only
83
+ in the tokenless logout branch — a "fire `onLogout` always" change would let the `g_state` clear
84
+ become a hook rather than inline route code.
85
+
86
+ ## Google Sign-In (One-Tap + button) — folded in 2026-07
87
+
88
+ Verifies a Google Identity Services (GIS) credential server-side and drops the user into the
89
+ standard IdentityX session, bypassing the email-link step. Handles the One-Tap prompt **and** the
90
+ rendered "Sign in with Google" button; both post to `POST /__idx/google`. **Dormant until
91
+ `googleAuth.clientId` is set** — no client id ⇒ nothing renders and the route 400s. Previously a
92
+ standalone `marko-web-google-identity-x` package; folded in here because it was ~all ceremony gated
93
+ on a client id and already depended on idx internals.
94
+
95
+ **Config** (on the IdentityX config, above): `clientId` (master gate — per-deploy env),
96
+ `missingFieldsBehavior` (`profile-gate` default | `immediate` | `relaxed`; `relaxed` is a doc-only
97
+ alias of `immediate` — only `profile-gate` is special-cased), `autoPrompt` (site-wide floating
98
+ overlay), `signInButtonEnabled` (button; default true).
99
+
100
+ **Route — `routes/google.js` (`POST /__idx/google`).** Verify the JWT →
101
+ `loadAppUserByEmail`/`createAppUser` (**email is the primary key**) → link the Google `sub` as a
102
+ supplementary external id under `{ provider:'google', tenant:'oauth', type:'account' }` (idempotent;
103
+ conflict-guarded; non-fatal via `config.get('onHookError')`) → `impersonateAppUser({ method:'GOOGLE_ONE_TAP', verify:true })`
104
+ → backfill given/family name only if absent → fire `onLoginLinkSent` **then** (re-fetch)
105
+ `onAuthenticationSuccess`, same order/hooks as email-link so Omeda rapid-identify + subscription
106
+ sync run identically. `profile-gate` computes `requiresUserInput` by checking required fields are
107
+ non-empty (deliberately not `forceProfileReVerification`). Responds `{ ok, requiresUserInput,
108
+ redirectTo, user, entity, … }`; the client redirects to `/user/profile` when input is required.
109
+
110
+ **Tags** (`components/marko.json`, config read from `req.identityX.config`):
111
+ - `<marko-web-identity-x-google-init>` — loads the GSI client + defines `window.handleGoogleOneTapCredential`;
112
+ shows the One-Tap prompt when `autoPrompt`. The button depends on this being on the page.
113
+ - `<marko-web-identity-x-google-sign-in-button client-id= redirect-to= label=>` — the button; self-hides
114
+ when unconfigured or `signInButtonEnabled` is false.
115
+
116
+ **Auto-rendering.** Both tags render inside `form-login.marko` + `form-authenticate.marko`, so the
117
+ login/authenticate pages get the button with **no per-site edit**. For the site-wide floating prompt
118
+ (`autoPrompt: true`), a site additionally drops `<marko-web-identity-x-google-init/>` once in its
119
+ document layout, gated on `!req.identityX.token`. The button tag can also be placed in a content
120
+ meter / gate.
121
+
122
+ **CSS.** `.google-sign-in` styles ship in `marko-web-theme-monorail/scss/components/_identity-x.scss`
123
+ (with the rest of the auth CSS) — no separate import.
124
+
125
+ **Logout.** `routes/logout.js` clears the GIS `g_state` cookie so a later sign-in with a *different*
126
+ Google account isn't blocked by stale auto-select state (harmless no-op when Google Sign-In is unused).
127
+
128
+ **Deliberate decisions / limitations.** `loginSource: 'google-one-tap'` and
129
+ `method: 'GOOGLE_ONE_TAP'` are emitted for BOTH surfaces (don't distinguish button vs prompt) —
130
+ kept for analytics/enum continuity. One success p1event (`Identity / Authenticate`, label
131
+ `Google Sign In` for the button via GIS `select_by` `btn*`, else `Google One Tap`), fired client-side
132
+ after `setIdentity`. `google-auth-library` is now a base-idx dependency (every site pulls it,
133
+ server-only) — accepted because the feature is gated on the client id. The Google OAuth app must
134
+ list each site origin as an authorized JavaScript origin or the surfaces silently no-show.
135
+
136
+ ## Fleet / rollout
137
+
138
+ Not tracked in-repo. The Google-auth architecture change + fleet rollout plan lives outside the repo
139
+ at `../GOOGLE-AUTH-ROLLOUT.md` (mindful working dir).
package/README.md CHANGED
@@ -197,4 +197,52 @@ Each component will emit an event when an error is encountered and include the e
197
197
  | `identity-x-profile-errored` | `{ label, message: '...', ...additionalEventData }`
198
198
  | `identity-x-comment-post-errored` | `{ label, message: '...', ...additionalEventData }`
199
199
  | `identity-x-comment-report-errored` | `{ label, message: '...', ...additionalEventData }`
200
+
201
+ ## Google Sign-In (One-Tap + button)
202
+
203
+ Built in. Verifies a Google Identity Services (GIS) credential server-side and drops the user
204
+ into the standard IdentityX session, bypassing the email-link step. Handles both the One-Tap
205
+ prompt and the rendered "Sign in with Google" button; both post to `POST /__idx/google`.
206
+
207
+ **Dormant until configured** — with no `clientId`, nothing renders and the route 400s. Enable
208
+ per-site by adding a `googleAuth` block to the IdentityX config:
209
+
210
+ ```js
211
+ const config = new IdentityX({
212
+ appId: '<MY-APPLICATION-ID>',
213
+ googleAuth: {
214
+ clientId: process.env.GOOGLE_AUTH_CLIENT_ID || '', // GIS OAuth client id — the master gate
215
+ missingFieldsBehavior: 'profile-gate', // 'profile-gate' | 'immediate' | 'relaxed'
216
+ autoPrompt: false, // floating One-Tap overlay
217
+ signInButtonEnabled: true, // render the button
218
+ },
219
+ });
220
+ ```
221
+
222
+ Reading the values from env is optional — set literals here if you'd rather not manage infra
223
+ env vars. The client id's Google OAuth app must list each site origin as an **authorized
224
+ JavaScript origin**, or the prompt/button silently no-shows.
225
+
226
+ `missingFieldsBehavior`: `profile-gate` authenticates immediately but forces profile completion
227
+ before gated content; `immediate`/`relaxed` grant access with fields left blank (`relaxed` is a
228
+ doc-only alias of `immediate`).
229
+
230
+ **Rendering.** The button + GIS init are rendered automatically inside the `form-login` and
231
+ `form-authenticate` templates — no per-site placement, and both self-hide when unconfigured. For
232
+ the **site-wide floating One-Tap prompt** (`autoPrompt: true`), also drop
233
+ `<marko-web-identity-x-google-init/>` once in your document layout, gated on an unauthenticated
234
+ user, e.g. `<if(!req.identityX.token)> <marko-web-identity-x-google-init/> </if>`. The button tag
235
+ (`<marko-web-identity-x-google-sign-in-button redirect-to=... />`) can also be placed in a content meter
236
+ or gate if desired.
237
+
238
+ **CSS.** Styles ship with the rest of the auth CSS in `marko-web-theme-monorail`
239
+ (`scss/components/_identity-x.scss`) — no separate import.
240
+
241
+ **Logout.** The idx logout route clears the GIS `g_state` cookie so a later sign-in with a
242
+ *different* Google account isn't blocked by stale auto-select state (harmless no-op when Google
243
+ Sign-In isn't used).
244
+
245
+ The Google account id (JWT `sub`) is persisted as a supplementary idx external id under
246
+ `{ provider: 'google', tenant: 'oauth', type: 'account' }`; email stays the primary key.
247
+ Non-fatal linkage errors report through the config's `onHookError`.
200
248
  | `identity-x-comment-stream-errored` | `{ label, message: '...', ...additionalEventData }`
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/access.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/access.marko",
6
6
  marko_component = require("./access.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -53,7 +53,7 @@ marko_template._ = marko_renderer(render, {
53
53
  }, marko_component);
54
54
 
55
55
  marko_template.meta = {
56
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/access.marko",
56
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/access.marko",
57
57
  component: "./access.marko",
58
58
  tags: [
59
59
  "@mindful-web/marko-core/components/resolve.marko"
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/comment-stream.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/comment-stream.marko",
6
6
  marko_component = require("./comment-stream.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -85,7 +85,7 @@ marko_template._ = marko_renderer(render, {
85
85
  }, marko_component);
86
86
 
87
87
  marko_template.meta = {
88
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/comment-stream.marko",
88
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/comment-stream.marko",
89
89
  component: "./comment-stream.marko",
90
90
  tags: [
91
91
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/context.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/context.marko",
6
6
  marko_component = require("./context.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  marko_dynamicTag = require("marko/dist/runtime/helpers/dynamic-tag"),
@@ -49,7 +49,7 @@ marko_template._ = marko_renderer(render, {
49
49
  }, marko_component);
50
50
 
51
51
  marko_template.meta = {
52
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/context.marko",
52
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/context.marko",
53
53
  component: "./context.marko",
54
54
  tags: [
55
55
  "@mindful-web/marko-core/components/resolve.marko"
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/form-access.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/form-access.marko",
6
6
  marko_component = require("./form-access.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
@@ -174,7 +174,7 @@ marko_template._ = marko_renderer(render, {
174
174
  }, marko_component);
175
175
 
176
176
  marko_template.meta = {
177
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/form-access.marko",
177
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/form-access.marko",
178
178
  component: "./form-access.marko",
179
179
  tags: [
180
180
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -32,5 +32,7 @@ $ const lang = input.lang || defaultValue(site.config.lang, "en");
32
32
  lang
33
33
  };
34
34
  <marko-web-browser-component name="IdentityXAuthenticate" props=props />
35
+ <marko-web-identity-x-google-init/>
36
+ <marko-web-identity-x-google-sign-in-button redirect-to=query.redirectTo/>
35
37
  </marko-web-identity-x-context>
36
38
  </if>
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/form-authenticate.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/form-authenticate.marko",
6
6
  marko_component = require("./form-authenticate.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -13,6 +13,10 @@ var marko_template = module.exports = require("marko/dist/html").t(__filename),
13
13
  marko_web_browser_component_template = require("@mindful-web/marko-web/components/browser-component.marko"),
14
14
  marko_loadTag = require("marko/dist/runtime/helpers/load-tag"),
15
15
  marko_web_browser_component_tag = marko_loadTag(marko_web_browser_component_template),
16
+ marko_web_identity_x_google_init_template = require("./google-init.marko"),
17
+ marko_web_identity_x_google_init_tag = marko_loadTag(marko_web_identity_x_google_init_template),
18
+ marko_web_identity_x_google_sign_in_button_template = require("./google-sign-in-button.marko"),
19
+ marko_web_identity_x_google_sign_in_button_tag = marko_loadTag(marko_web_identity_x_google_sign_in_button_template),
16
20
  marko_web_identity_x_context_template = require("./context.marko"),
17
21
  marko_web_identity_x_context_tag = marko_loadTag(marko_web_identity_x_context_template);
18
22
 
@@ -61,6 +65,12 @@ function render(input, out, __component, component, state) {
61
65
  name: "IdentityXAuthenticate",
62
66
  props: props
63
67
  }, out, __component, "1");
68
+
69
+ marko_web_identity_x_google_init_tag({}, out, __component, "2");
70
+
71
+ marko_web_identity_x_google_sign_in_button_tag({
72
+ redirectTo: query.redirectTo
73
+ }, out, __component, "3");
64
74
  }
65
75
  }, out, __component, "0");
66
76
  }
@@ -71,10 +81,12 @@ marko_template._ = marko_renderer(render, {
71
81
  }, marko_component);
72
82
 
73
83
  marko_template.meta = {
74
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/form-authenticate.marko",
84
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/form-authenticate.marko",
75
85
  component: "./form-authenticate.marko",
76
86
  tags: [
77
87
  "@mindful-web/marko-web/components/browser-component.marko",
88
+ "./google-init.marko",
89
+ "./google-sign-in-button.marko",
78
90
  "./context.marko"
79
91
  ]
80
92
  };
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/form-change-email.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/form-change-email.marko",
6
6
  marko_component = require("./form-change-email.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -59,7 +59,7 @@ marko_template._ = marko_renderer(render, {
59
59
  }, marko_component);
60
60
 
61
61
  marko_template.meta = {
62
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/form-change-email.marko",
62
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/form-change-email.marko",
63
63
  component: "./form-change-email.marko",
64
64
  tags: [
65
65
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -40,6 +40,8 @@ $ const withFields = defaultValue(input.withFields, false);
40
40
  };
41
41
  <if(isEnabled)>
42
42
  <marko-web-browser-component name="IdentityXLogin" props=props />
43
+ <marko-web-identity-x-google-init/>
44
+ <marko-web-identity-x-google-sign-in-button redirect-to=input.redirect/>
43
45
  </if>
44
46
  </marko-web-identity-x-context>
45
47
  </if>
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/form-login.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/form-login.marko",
6
6
  marko_component = require("./form-login.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -15,6 +15,10 @@ var marko_template = module.exports = require("marko/dist/html").t(__filename),
15
15
  marko_web_browser_component_template = require("@mindful-web/marko-web/components/browser-component.marko"),
16
16
  marko_loadTag = require("marko/dist/runtime/helpers/load-tag"),
17
17
  marko_web_browser_component_tag = marko_loadTag(marko_web_browser_component_template),
18
+ marko_web_identity_x_google_init_template = require("./google-init.marko"),
19
+ marko_web_identity_x_google_init_tag = marko_loadTag(marko_web_identity_x_google_init_template),
20
+ marko_web_identity_x_google_sign_in_button_template = require("./google-sign-in-button.marko"),
21
+ marko_web_identity_x_google_sign_in_button_tag = marko_loadTag(marko_web_identity_x_google_sign_in_button_template),
18
22
  marko_web_identity_x_context_template = require("./context.marko"),
19
23
  marko_web_identity_x_context_tag = marko_loadTag(marko_web_identity_x_context_template);
20
24
 
@@ -71,6 +75,12 @@ function render(input, out, __component, component, state) {
71
75
  name: "IdentityXLogin",
72
76
  props: props
73
77
  }, out, __component, "1");
78
+
79
+ marko_web_identity_x_google_init_tag({}, out, __component, "2");
80
+
81
+ marko_web_identity_x_google_sign_in_button_tag({
82
+ redirectTo: input.redirect
83
+ }, out, __component, "3");
74
84
  }
75
85
  }
76
86
  }, out, __component, "0");
@@ -82,10 +92,12 @@ marko_template._ = marko_renderer(render, {
82
92
  }, marko_component);
83
93
 
84
94
  marko_template.meta = {
85
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/form-login.marko",
95
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/form-login.marko",
86
96
  component: "./form-login.marko",
87
97
  tags: [
88
98
  "@mindful-web/marko-web/components/browser-component.marko",
99
+ "./google-init.marko",
100
+ "./google-sign-in-button.marko",
89
101
  "./context.marko"
90
102
  ]
91
103
  };
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/form-logout.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/form-logout.marko",
6
6
  marko_renderer = require("marko/dist/runtime/components/renderer"),
7
7
  module_defaultValue = require("@mindful-web/marko-core/utils/default-value"),
8
8
  defaultValue = module_defaultValue.default || module_defaultValue,
@@ -29,7 +29,7 @@ marko_template._ = marko_renderer(render, {
29
29
  });
30
30
 
31
31
  marko_template.meta = {
32
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/form-logout.marko",
32
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/form-logout.marko",
33
33
  tags: [
34
34
  "@mindful-web/marko-web/components/browser-component.marko"
35
35
  ]
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/form-profile.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/form-profile.marko",
6
6
  marko_component = require("./form-profile.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -84,7 +84,7 @@ marko_template._ = marko_renderer(render, {
84
84
  }, marko_component);
85
85
 
86
86
  marko_template.meta = {
87
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/form-profile.marko",
87
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/form-profile.marko",
88
88
  component: "./form-profile.marko",
89
89
  tags: [
90
90
  "@mindful-web/marko-web/components/browser-component.marko",
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/form-register.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/form-register.marko",
6
6
  marko_renderer = require("marko/dist/runtime/components/renderer"),
7
7
  marko_assign = require("marko/dist/runtime/helpers/assign"),
8
8
  marko_web_identity_x_form_login_template = require("./form-login.marko"),
@@ -25,7 +25,7 @@ marko_template._ = marko_renderer(render, {
25
25
  });
26
26
 
27
27
  marko_template.meta = {
28
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/form-register.marko",
28
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/form-register.marko",
29
29
  tags: [
30
30
  "./form-login.marko"
31
31
  ]
@@ -0,0 +1,73 @@
1
+ $ const { req } = out.global;
2
+ $ const config = req.identityX && req.identityX.config;
3
+ $ const { clientId, autoPrompt } = config ? config.getAsObject('googleAuth') : {};
4
+ $ const callbackName = 'handleGoogleOneTapCredential';
5
+ $ const initScript = `
6
+ (function() {
7
+ window.${callbackName} = function(response) {
8
+ var redirectTo = window.__googleSignInRedirectTo || '';
9
+ var errorEl = document.getElementById('google-sign-in-error');
10
+ if (errorEl) errorEl.style.display = 'none';
11
+
12
+ // GIS 'select_by' distinguishes the surface: a 'btn*' value means the
13
+ // rendered "Sign in with Google" button, 'user*'/'auto' means the One-Tap
14
+ // prompt. Used to label the p1events authentication event accordingly.
15
+ var eventLabel = String(response.select_by || '').indexOf('btn') === 0
16
+ ? 'Google Sign In'
17
+ : 'Google One Tap';
18
+
19
+ fetch('/__idx/google', {
20
+ method: 'POST',
21
+ headers: { 'Content-Type': 'application/json' },
22
+ body: JSON.stringify({ credential: response.credential, redirectTo: redirectTo }),
23
+ })
24
+ .then(function(res) { return res.json(); })
25
+ .then(function(data) {
26
+ if (!data.ok) {
27
+ if (errorEl) {
28
+ errorEl.textContent = data.error || 'Google sign-in failed. Please try again.';
29
+ errorEl.style.display = 'block';
30
+ }
31
+ return;
32
+ }
33
+ if (window.p1events) {
34
+ // De-anonymize the session to the app user, then record the
35
+ // authentication — mirrors the identity-x conversion events.
36
+ window.p1events('setIdentity', data.entity);
37
+ window.p1events('track', {
38
+ category: 'Identity',
39
+ action: 'Authenticate',
40
+ label: eventLabel,
41
+ props: { idxEntity: data.entity, actionSource: 'google-one-tap' },
42
+ });
43
+ }
44
+ if (data.requiresUserInput) {
45
+ var destination = data.redirectTo || window.location.pathname;
46
+ window.location.href = '/user/profile?returnTo=' + encodeURIComponent(destination);
47
+ } else {
48
+ window.location.href = data.redirectTo || window.location.href;
49
+ }
50
+ })
51
+ .catch(function() {
52
+ if (errorEl) {
53
+ errorEl.textContent = 'An error occurred during Google sign-in. Please try again.';
54
+ errorEl.style.display = 'block';
55
+ }
56
+ });
57
+ };
58
+ })();
59
+ `;
60
+
61
+ <if(clientId)>
62
+ <div
63
+ id="g_id_onload"
64
+ data-client_id=clientId
65
+ data-callback=callbackName
66
+ data-auto_prompt=(autoPrompt ? 'true' : 'false')
67
+ data-auto_select="false"
68
+ data-cancel_on_tap_outside="true"
69
+ data-itp_support="true"
70
+ />
71
+ <script src="https://accounts.google.com/gsi/client" async defer />
72
+ <script>$!{initScript}</script>
73
+ </if>
@@ -0,0 +1,95 @@
1
+ // Compiled using marko@4.20.2 - DO NOT EDIT
2
+ "use strict";
3
+
4
+ var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/google-init.marko",
6
+ marko_renderer = require("marko/dist/runtime/components/renderer"),
7
+ marko_attr = require("marko/dist/runtime/html/helpers/attr"),
8
+ marko_str = require("marko/dist/runtime/helpers/to-string");
9
+
10
+ function render(input, out, __component, component, state) {
11
+ var data = input;
12
+
13
+ const { req } = out.global;
14
+
15
+ const config = req.identityX && req.identityX.config;
16
+
17
+ const { clientId, autoPrompt } = config ? config.getAsObject('googleAuth') : {};
18
+
19
+ const callbackName = 'handleGoogleOneTapCredential';
20
+
21
+ const initScript = `
22
+ (function() {
23
+ window.${callbackName} = function(response) {
24
+ var redirectTo = window.__googleSignInRedirectTo || '';
25
+ var errorEl = document.getElementById('google-sign-in-error');
26
+ if (errorEl) errorEl.style.display = 'none';
27
+
28
+ // GIS 'select_by' distinguishes the surface: a 'btn*' value means the
29
+ // rendered "Sign in with Google" button, 'user*'/'auto' means the One-Tap
30
+ // prompt. Used to label the p1events authentication event accordingly.
31
+ var eventLabel = String(response.select_by || '').indexOf('btn') === 0
32
+ ? 'Google Sign In'
33
+ : 'Google One Tap';
34
+
35
+ fetch('/__idx/google', {
36
+ method: 'POST',
37
+ headers: { 'Content-Type': 'application/json' },
38
+ body: JSON.stringify({ credential: response.credential, redirectTo: redirectTo }),
39
+ })
40
+ .then(function(res) { return res.json(); })
41
+ .then(function(data) {
42
+ if (!data.ok) {
43
+ if (errorEl) {
44
+ errorEl.textContent = data.error || 'Google sign-in failed. Please try again.';
45
+ errorEl.style.display = 'block';
46
+ }
47
+ return;
48
+ }
49
+ if (window.p1events) {
50
+ // De-anonymize the session to the app user, then record the
51
+ // authentication — mirrors the identity-x conversion events.
52
+ window.p1events('setIdentity', data.entity);
53
+ window.p1events('track', {
54
+ category: 'Identity',
55
+ action: 'Authenticate',
56
+ label: eventLabel,
57
+ props: { idxEntity: data.entity, actionSource: 'google-one-tap' },
58
+ });
59
+ }
60
+ if (data.requiresUserInput) {
61
+ var destination = data.redirectTo || window.location.pathname;
62
+ window.location.href = '/user/profile?returnTo=' + encodeURIComponent(destination);
63
+ } else {
64
+ window.location.href = data.redirectTo || window.location.href;
65
+ }
66
+ })
67
+ .catch(function() {
68
+ if (errorEl) {
69
+ errorEl.textContent = 'An error occurred during Google sign-in. Please try again.';
70
+ errorEl.style.display = 'block';
71
+ }
72
+ });
73
+ };
74
+ })();
75
+ `;
76
+
77
+ if (clientId) {
78
+ out.w("<div id=\"g_id_onload\"" +
79
+ marko_attr("data-client_id", clientId) +
80
+ marko_attr("data-callback", callbackName) +
81
+ marko_attr("data-auto_prompt", autoPrompt ? "true" : "false") +
82
+ " data-auto_select=\"false\" data-cancel_on_tap_outside=\"true\" data-itp_support=\"true\"></div><script src=\"https://accounts.google.com/gsi/client\" async defer></script><script>" +
83
+ marko_str(initScript) +
84
+ "</script>");
85
+ }
86
+ }
87
+
88
+ marko_template._ = marko_renderer(render, {
89
+ d_: true,
90
+ e_: marko_componentType
91
+ });
92
+
93
+ marko_template.meta = {
94
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/google-init.marko"
95
+ };
@@ -0,0 +1,24 @@
1
+ $ const { req } = out.global;
2
+ $ const config = req.identityX && req.identityX.config;
3
+ $ const googleAuth = config ? config.getAsObject('googleAuth') : {};
4
+ $ const clientId = input.clientId || googleAuth.clientId || '';
5
+ $ const redirectTo = input.redirectTo || '';
6
+ $ const label = input.label || 'Or sign in with Google';
7
+ $ const signInButtonEnabled = googleAuth.signInButtonEnabled !== false;
8
+
9
+ <if(clientId && signInButtonEnabled)>
10
+ <div class="google-sign-in">
11
+ <p class="google-sign-in__divider">${label}</p>
12
+ <div
13
+ class="g_id_signin"
14
+ data-type="standard"
15
+ data-theme="outline"
16
+ data-text="signin_with"
17
+ data-shape="rectangular"
18
+ data-logo_alignment="left"
19
+ data-width="300"
20
+ />
21
+ <p id="google-sign-in-error" class="google-sign-in__error" style="display:none;" role="alert" />
22
+ <script>window.__googleSignInRedirectTo = $!{JSON.stringify(redirectTo)};</script>
23
+ </div>
24
+ </if>
@@ -0,0 +1,44 @@
1
+ // Compiled using marko@4.20.2 - DO NOT EDIT
2
+ "use strict";
3
+
4
+ var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/google-sign-in-button.marko",
6
+ marko_renderer = require("marko/dist/runtime/components/renderer"),
7
+ helpers_escape_xml = require("marko/dist/runtime/html/helpers/escape-xml"),
8
+ marko_escapeXml = helpers_escape_xml.x,
9
+ marko_escapeScript = require("marko/dist/runtime/html/helpers/escape-script-placeholder");
10
+
11
+ function render(input, out, __component, component, state) {
12
+ var data = input;
13
+
14
+ const { req } = out.global;
15
+
16
+ const config = req.identityX && req.identityX.config;
17
+
18
+ const googleAuth = config ? config.getAsObject('googleAuth') : {};
19
+
20
+ const clientId = input.clientId || googleAuth.clientId || '';
21
+
22
+ const redirectTo = input.redirectTo || '';
23
+
24
+ const label = input.label || 'Or sign in with Google';
25
+
26
+ const signInButtonEnabled = googleAuth.signInButtonEnabled !== false;
27
+
28
+ if (clientId && signInButtonEnabled) {
29
+ out.w("<div class=\"google-sign-in\"><p class=\"google-sign-in__divider\">" +
30
+ marko_escapeXml(label) +
31
+ "</p><div class=\"g_id_signin\" data-type=\"standard\" data-theme=\"outline\" data-text=\"signin_with\" data-shape=\"rectangular\" data-logo_alignment=\"left\" data-width=\"300\"></div><p id=\"google-sign-in-error\" class=\"google-sign-in__error\" style=\"display:none;\" role=\"alert\"></p><script>" +
32
+ marko_escapeScript(("window.__googleSignInRedirectTo = " + JSON.stringify(redirectTo)) + ";") +
33
+ "</script></div>");
34
+ }
35
+ }
36
+
37
+ marko_template._ = marko_renderer(render, {
38
+ d_: true,
39
+ e_: marko_componentType
40
+ });
41
+
42
+ marko_template.meta = {
43
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/google-sign-in-button.marko"
44
+ };
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/identify.marko",
6
6
  marko_component = require("./identify.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -50,7 +50,7 @@ marko_template._ = marko_renderer(render, {
50
50
  }, marko_component);
51
51
 
52
52
  marko_template.meta = {
53
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/identify.marko",
53
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/identify.marko",
54
54
  component: "./identify.marko",
55
55
  tags: [
56
56
  "@mindful-web/marko-web-gtm/components/push.marko",
@@ -106,5 +106,14 @@
106
106
  "<marko-web-identity-x-identify>": {
107
107
  "template": "./identify.marko",
108
108
  "@provider-data": "object"
109
+ },
110
+ "<marko-web-identity-x-google-init>": {
111
+ "template": "./google-init.marko"
112
+ },
113
+ "<marko-web-identity-x-google-sign-in-button>": {
114
+ "template": "./google-sign-in-button.marko",
115
+ "@client-id": "string",
116
+ "@redirect-to": "string",
117
+ "@label": "string"
109
118
  }
110
119
  }
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/non-auth-identify.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/non-auth-identify.marko",
6
6
  marko_component = require("./non-auth-identify.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  marko_dynamicTag = require("marko/dist/runtime/helpers/dynamic-tag"),
@@ -45,7 +45,7 @@ marko_template._ = marko_renderer(render, {
45
45
  }, marko_component);
46
46
 
47
47
  marko_template.meta = {
48
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/non-auth-identify.marko",
48
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/non-auth-identify.marko",
49
49
  component: "./non-auth-identify.marko",
50
50
  tags: [
51
51
  "@mindful-web/marko-core/components/resolve.marko"
@@ -2,7 +2,7 @@
2
2
  "use strict";
3
3
 
4
4
  var marko_template = module.exports = require("marko/dist/html").t(__filename),
5
- marko_componentType = "/@mindful-web/marko-web-identity-x$1.76.7/components/subscribe.marko",
5
+ marko_componentType = "/@mindful-web/marko-web-identity-x$1.77.0/components/subscribe.marko",
6
6
  marko_component = require("./subscribe.marko"),
7
7
  marko_renderer = require("marko/dist/runtime/components/renderer"),
8
8
  module_objectPath_module = require("@mindful-web/object-path"),
@@ -73,7 +73,7 @@ marko_template._ = marko_renderer(render, {
73
73
  }, marko_component);
74
74
 
75
75
  marko_template.meta = {
76
- id: "/@mindful-web/marko-web-identity-x$1.76.7/components/subscribe.marko",
76
+ id: "/@mindful-web/marko-web-identity-x$1.77.0/components/subscribe.marko",
77
77
  component: "./subscribe.marko",
78
78
  tags: [
79
79
  "@mindful-web/marko-web/components/browser-component.marko",
package/config.js CHANGED
@@ -16,6 +16,13 @@ class IdentityXConfiguration {
16
16
  * @param {string[]} [options.activeCustomFieldIds] Limit displayed custom fields, if present.
17
17
  * @param {string[]} [options.hiddenFields] The fields to hide from the profile.
18
18
  * @param {function} [options.onHookError]
19
+ * @param {object} [options.googleAuth] Google Sign-In config. When `clientId` is
20
+ * set, the One-Tap prompt / "Sign in with Google" button render in the auth
21
+ * templates and `POST /__idx/google` accepts credentials. Dormant otherwise.
22
+ * @param {string} [options.googleAuth.clientId] GIS OAuth client id — the master gate.
23
+ * @param {("profile-gate"|"immediate"|"relaxed")} [options.googleAuth.missingFieldsBehavior]
24
+ * @param {boolean} [options.googleAuth.autoPrompt] Show the floating One-Tap overlay.
25
+ * @param {boolean} [options.googleAuth.signInButtonEnabled] Render the button (default true).
19
26
  * @param {object} options.rest
20
27
  */
21
28
  constructor({
@@ -31,6 +38,7 @@ class IdentityXConfiguration {
31
38
  defaultCountryCode,
32
39
  booleanQuestionsLabel,
33
40
  gtmUserFields = {},
41
+ googleAuth = {},
34
42
  onHookError,
35
43
  ...rest
36
44
  } = {}) {
@@ -49,6 +57,7 @@ class IdentityXConfiguration {
49
57
  defaultCountryCode,
50
58
  booleanQuestionsLabel,
51
59
  gtmUserFields,
60
+ googleAuth,
52
61
  onHookError: (e) => {
53
62
  if (process.env.NODE_ENV === 'development') {
54
63
  log('ERROR IN IDENTITY-X HOOK', e);
@@ -141,6 +150,14 @@ class IdentityXConfiguration {
141
150
  return this.getAsArray('activeCustomFieldIds');
142
151
  }
143
152
 
153
+ /**
154
+ * The Google Sign-In config block. Empty object when unconfigured, so a missing
155
+ * `clientId` reads as falsy and the feature stays dormant.
156
+ */
157
+ getGoogleAuth() {
158
+ return this.getAsObject('googleAuth');
159
+ }
160
+
144
161
  getAdditionalCustomFieldIds() {
145
162
  return this.getAsArray('additionalCustomFieldIds');
146
163
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mindful-web/marko-web-identity-x",
3
- "version": "1.76.7",
3
+ "version": "1.77.0",
4
4
  "description": "Marko Wrapper for IdentityX",
5
5
  "repository": "https://github.com/parameter1/mindful-web/tree/main/packages/marko-web-identity-x",
6
6
  "author": "Josh Worden <josh@parameter1.com>",
@@ -24,6 +24,7 @@
24
24
  "cookie": "0.3.1",
25
25
  "dayjs": "^1.11.7",
26
26
  "express": "^4.18.2",
27
+ "google-auth-library": "^9.0.0",
27
28
  "graphql-tag": "^2.12.6",
28
29
  "i18n-iso-countries": "^4.3.1",
29
30
  "jsonwebtoken": "^8.5.1",
@@ -37,5 +38,5 @@
37
38
  "publishConfig": {
38
39
  "access": "public"
39
40
  },
40
- "gitHead": "e6fc8615e76b1f00858bb0595230e652275c7333"
41
+ "gitHead": "c8c0dca117cb9c8f76ae5e8d5786cc99904a0da3"
41
42
  }
@@ -0,0 +1,206 @@
1
+ const gql = require('graphql-tag');
2
+ const { OAuth2Client } = require('google-auth-library');
3
+ const { asyncRoute } = require('@mindful-web/utils');
4
+ const IMPERSONATE_APP_USER = require('../api/mutations/impersonate-app-user');
5
+ const tokenCookie = require('../utils/token-cookie');
6
+ const contextCookie = require('../utils/context-cookie');
7
+ const callHooksFor = require('../utils/call-hooks-for');
8
+
9
+ const UPDATE_OWN_APP_USER = gql`
10
+ mutation GoogleSignInUpdateUser($input: UpdateOwnAppUserMutationInput!) {
11
+ updateOwnAppUser(input: $input) { id givenName familyName }
12
+ }
13
+ `;
14
+
15
+ const isEmpty = (v) => v == null || v === '';
16
+
17
+ const LOGIN_SOURCE = 'google-one-tap';
18
+
19
+ // Namespace under which the Google account id (the JWT `sub`) is stored as an
20
+ // idx external id, mirroring the Omeda { provider, tenant, type } convention.
21
+ // These values must remain stable — changing them orphans existing links.
22
+ const GOOGLE_NAMESPACE = { provider: 'google', tenant: 'oauth', type: 'account' };
23
+
24
+ // eslint-disable-next-line no-console
25
+ const defaultOnError = (e) => console.error('[identity-x google-auth]', e);
26
+
27
+ /**
28
+ * `POST /__idx/google` — the Google Sign-In route. Handles both the One-Tap
29
+ * prompt and the "Sign in with Google" button; both surfaces post here.
30
+ *
31
+ * Verifies the Google credential, finds-or-creates the IdentityX app user, links
32
+ * the Google account id (`sub`) as an external id, impersonates the user to mint
33
+ * a session token, and fires the standard `onLoginLinkSent` /
34
+ * `onAuthenticationSuccess` hooks so downstream integrations (e.g. Omeda
35
+ * rapid-identify + subscription sync) run exactly as they do for email-link
36
+ * users.
37
+ *
38
+ * Configuration lives on the IdentityX config under the `googleAuth` key
39
+ * (`{ clientId, missingFieldsBehavior, autoPrompt, signInButtonEnabled }`). The
40
+ * route is dormant until `clientId` is set: no client id ⇒ 400.
41
+ *
42
+ * Non-fatal errors from the supplementary `sub`-linkage are reported through the
43
+ * IdentityX config's `onHookError` (the same reporter sites already wire to
44
+ * newrelic for hook failures), falling back to `console.error`.
45
+ */
46
+ module.exports = asyncRoute(async (req, res) => {
47
+ /** @type {import('../service')} */
48
+ const { identityX, body } = req;
49
+ const { credential, redirectTo } = body;
50
+ const { config } = identityX;
51
+ const onError = config.get('onHookError') || defaultOnError;
52
+
53
+ const { clientId, missingFieldsBehavior = 'profile-gate' } = config.getAsObject('googleAuth');
54
+
55
+ if (!clientId) {
56
+ res.status(400).json({ ok: false, error: 'Google Sign-In is not configured for this site.' });
57
+ return;
58
+ }
59
+ if (!credential) {
60
+ res.status(400).json({ ok: false, error: 'No Google credential was provided.' });
61
+ return;
62
+ }
63
+
64
+ // Verify the Google JWT and extract user info.
65
+ const oauthClient = new OAuth2Client(clientId);
66
+ const ticket = await oauthClient.verifyIdToken({ idToken: credential, audience: clientId });
67
+ const googlePayload = ticket.getPayload();
68
+ const {
69
+ email,
70
+ given_name: givenName,
71
+ family_name: familyName,
72
+ sub: googleSub,
73
+ } = googlePayload;
74
+
75
+ if (!email) throw new Error('Google credential did not include an email address.');
76
+
77
+ // Find or create the identity-x user.
78
+ let appUser = await identityX.loadAppUserByEmail(email);
79
+ const createdNewUser = !appUser;
80
+ if (!appUser) {
81
+ appUser = await identityX.createAppUser({ email });
82
+ }
83
+
84
+ // Persist the Google account id (the JWT `sub`) as an idx external id so the
85
+ // user is linked to its Google identity. Email stays the primary key; this is
86
+ // supplementary provenance and must never block sign-in.
87
+ if (googleSub) {
88
+ const alreadyLinked = (appUser.externalIds || []).some((extId) => (
89
+ extId.namespace
90
+ && extId.namespace.provider === GOOGLE_NAMESPACE.provider
91
+ && extId.identifier
92
+ && extId.identifier.value === googleSub
93
+ ));
94
+ if (!alreadyLinked) {
95
+ try {
96
+ // If this sub is already attached to a different user (e.g. the Google
97
+ // account's email changed and a new user was created under it), report
98
+ // and skip rather than fanning the sub across two users.
99
+ const existing = await identityX.findUserByExternalId({
100
+ identifier: googleSub,
101
+ namespace: GOOGLE_NAMESPACE,
102
+ type: 'sub',
103
+ });
104
+ if (existing && existing.id !== appUser.id) {
105
+ onError(new Error(
106
+ `Google sub already linked to idx user ${existing.id}, not ${appUser.id}`,
107
+ ));
108
+ } else {
109
+ await identityX.addExternalUserId({
110
+ userId: appUser.id,
111
+ identifier: { value: googleSub, type: 'sub' },
112
+ namespace: GOOGLE_NAMESPACE,
113
+ });
114
+ }
115
+ } catch (e) {
116
+ // Supplementary linkage — never block authentication on a failure here.
117
+ onError(e);
118
+ }
119
+ }
120
+ }
121
+
122
+ // Impersonate the user with verify: true — marks them as verified and returns
123
+ // a session token, bypassing the normal email-link flow.
124
+ const { data: impersonateData } = await identityX.client.mutate({
125
+ mutation: IMPERSONATE_APP_USER,
126
+ variables: {
127
+ input: {
128
+ id: appUser.id,
129
+ method: 'GOOGLE_ONE_TAP',
130
+ verify: true,
131
+ },
132
+ },
133
+ context: { apiToken: identityX.getOrgUserApiToken() },
134
+ });
135
+ const { token: authToken } = impersonateData.impersonateAppUser;
136
+
137
+ tokenCookie.setTo(res, authToken.value);
138
+ contextCookie.setTo(res, { loginSource: LOGIN_SOURCE });
139
+ identityX.setIdentityCookie(appUser.id);
140
+
141
+ // Re-init the Apollo client with the user's own session token so that
142
+ // updateOwnAppUser is authenticated as this user (not as the org admin).
143
+ identityX.setToken(authToken);
144
+
145
+ // Set name fields from Google as verified data if not already present.
146
+ const needsNameUpdate = !appUser.givenName || !appUser.familyName;
147
+ if (needsNameUpdate && (givenName || familyName)) {
148
+ const nameInput = {};
149
+ if (givenName && !appUser.givenName) nameInput.givenName = givenName;
150
+ if (familyName && !appUser.familyName) nameInput.familyName = familyName;
151
+ await identityX.client.mutate({
152
+ mutation: UPDATE_OWN_APP_USER,
153
+ variables: { input: nameInput },
154
+ });
155
+ }
156
+
157
+ // Load the refreshed user so hooks and field checks have up-to-date data.
158
+ const user = await identityX.findUserById(appUser.id);
159
+ const entity = await identityX.generateEntityId({ userId: appUser.id });
160
+
161
+ // For profile-gate: check whether the user is actually missing required fields
162
+ // rather than using forceProfileReVerification, which isn't cleared by a normal
163
+ // /user/profile submission and would redirect complete users on every sign-in.
164
+ let requiresUserInput = false;
165
+ if (missingFieldsBehavior === 'profile-gate') {
166
+ const requiredFields = [
167
+ ...config.getRequiredServerFields(),
168
+ ...config.getRequiredClientFields(),
169
+ ];
170
+ requiresUserInput = requiredFields.some((key) => isEmpty(user[key]));
171
+ }
172
+
173
+ // Fire onLoginLinkSent before onAuthenticationSuccess so that hooks wired to
174
+ // it (e.g. Omeda rapid-identify and subscription sync) run for Google users
175
+ // the same way they do for email-link users. This also populates
176
+ // user.externalIds with the Omeda encryptedCustomerId so the subsequent
177
+ // onAuthenticationSuccess hook can set oly_enc_id in the response.
178
+ await callHooksFor(identityX, 'onLoginLinkSent', {
179
+ req,
180
+ source: LOGIN_SOURCE,
181
+ user,
182
+ });
183
+
184
+ // Re-fetch so onAuthenticationSuccess sees the externalIds written by
185
+ // the onLoginLinkSent hook (Omeda rapid-identify).
186
+ const userAfterLinkSent = await identityX.findUserById(appUser.id);
187
+
188
+ await callHooksFor(identityX, 'onAuthenticationSuccess', {
189
+ req,
190
+ res,
191
+ user: userAfterLinkSent,
192
+ authToken,
193
+ loginSource: LOGIN_SOURCE,
194
+ additionalEventData: { createdNewUser, requiresUserInput },
195
+ });
196
+
197
+ res.json({
198
+ ok: true,
199
+ requiresUserInput,
200
+ redirectTo: redirectTo || null,
201
+ applicationId: config.getAppId(),
202
+ user: userAfterLinkSent,
203
+ loginSource: LOGIN_SOURCE,
204
+ entity,
205
+ });
206
+ });
package/routes/index.js CHANGED
@@ -11,6 +11,7 @@ const countries = require('./countries');
11
11
  const createComment = require('./create-comment');
12
12
  const download = require('./download');
13
13
  const flagComment = require('./flag-comment');
14
+ const google = require('./google');
14
15
  const login = require('./login');
15
16
  const loginFields = require('./login-fields');
16
17
  const logout = require('./logout');
@@ -31,6 +32,7 @@ router.post('/change-email/initiate', changeEmailInit);
31
32
  router.post('/comment', createComment);
32
33
  router.post('/comment/flag/:id', flagComment);
33
34
  router.post('/download', download);
35
+ router.post('/google', google);
34
36
  router.post('/login-fields', loginFields);
35
37
  router.post('/login', login);
36
38
  router.post('/logout', logout);
package/routes/logout.js CHANGED
@@ -7,6 +7,11 @@ module.exports = asyncRoute(async (req, res) => {
7
7
  /** @type {import('../middleware').IdentityXRequest} */
8
8
  const { identityX } = req;
9
9
  contextCookie.removeFrom(res);
10
+ // Clear the Google Identity Services `g_state` cookie so a subsequent Google
11
+ // sign-in (especially with a *different* account) isn't blocked by stale
12
+ // auto-select state. GIS only loads for unauthenticated users, so nothing else
13
+ // resets this on logout. Harmless no-op for sites not using Google Sign-In.
14
+ res.clearCookie('g_state', { path: '/' });
10
15
  const token = tokenCookie.getFrom(req);
11
16
  if (!token) {
12
17
  await callHooksFor(identityX, 'onLogout', { req, res });