diff --git a/packages/marko-web-identity-x/api/queries/load-user-by-external-id.js b/packages/marko-web-identity-x/api/queries/load-user-by-external-id.js new file mode 100644 index 000000000..d1739e3aa --- /dev/null +++ b/packages/marko-web-identity-x/api/queries/load-user-by-external-id.js @@ -0,0 +1,14 @@ +const gql = require('graphql-tag'); +const userFragment = require('../fragments/active-user'); + +module.exports = gql` + query LoadUserByExternalId( + $identifier: AppUserExternalIdentifierInput!, + $namespace: AppUserExternalNamespaceInput! + ) { + appUserByExternalId(input: { identifier: $identifier, namespace: $namespace }) { + ...ActiveUserFragment + } + } + ${userFragment} +`; diff --git a/packages/marko-web-identity-x/middleware.js b/packages/marko-web-identity-x/middleware.js index 9895e8635..9e21ac8c0 100644 --- a/packages/marko-web-identity-x/middleware.js +++ b/packages/marko-web-identity-x/middleware.js @@ -1,3 +1,4 @@ +const { asyncRoute } = require('@parameter1/base-cms-utils'); const IdentityX = require('./service'); /** @@ -6,9 +7,23 @@ const IdentityX = require('./service'); * @param {IdentityXConfiguration} config The IdentityX config object * @returns {function} The middleware function */ -module.exports = (config) => (req, res, next) => { +module.exports = (config) => asyncRoute(async (req, res, next) => { const service = new IdentityX({ req, res, config }); req.identityX = service; res.locals.identityX = service; - next(); -}; + + const cookie = service.getIdentity(res); + + // Don't overwrite an existing cookie + if (cookie) return next(); + + // Set cookie for logged in users + if (service.token) { + const { user } = await service.loadActiveContext(); + if (user && user.id) { + service.setIdentityCookie(user.id); + } + } + + return next(); +}); diff --git a/packages/marko-web-identity-x/package.json b/packages/marko-web-identity-x/package.json index 42c4bd6da..53bbdf63c 100644 --- a/packages/marko-web-identity-x/package.json +++ b/packages/marko-web-identity-x/package.json @@ -22,6 +22,7 @@ "apollo-link-http": "^1.5.17", "body-parser": "^1.20.1", "cookie": "0.3.1", + "debug": "^4.3.4", "dayjs": "^1.11.7", "express": "^4.18.2", "graphql-tag": "^2.12.6", diff --git a/packages/marko-web-identity-x/routes/authenticate.js b/packages/marko-web-identity-x/routes/authenticate.js index bfcede3b0..e78d08129 100644 --- a/packages/marko-web-identity-x/routes/authenticate.js +++ b/packages/marko-web-identity-x/routes/authenticate.js @@ -43,6 +43,7 @@ module.exports = asyncRoute(async (req, res) => { }); tokenCookie.setTo(res, authToken.value); contextCookie.setTo(res, { loginSource }); + identityX.setIdentityCookie(user.id); res.json({ ok: true, user, diff --git a/packages/marko-web-identity-x/service.js b/packages/marko-web-identity-x/service.js index 8f0e2c51c..7b9043452 100644 --- a/packages/marko-web-identity-x/service.js +++ b/packages/marko-web-identity-x/service.js @@ -1,8 +1,10 @@ const { get, getAsObject } = require('@parameter1/base-cms-object-path'); +const debug = require('debug')('identity-x'); const createClient = require('./utils/create-client'); const getActiveContext = require('./api/queries/get-active-context'); const checkContentAccess = require('./api/queries/check-content-access'); const loadUser = require('./api/queries/load-user'); +const appUserByExternalIdQuery = require('./api/queries/load-user-by-external-id'); const addExternalUserId = require('./api/mutations/add-external-user-id'); const setCustomAttributes = require('./api/mutations/set-custom-attributes'); const impersonateAppUser = require('./api/mutations/impersonate-app-user'); @@ -15,6 +17,7 @@ const callHooksFor = require('./utils/call-hooks-for'); const isEmpty = (v) => v == null || v === ''; const isFn = (v) => typeof v === 'function'; +const IDENTITY_COOKIE_NAME = '__idx_idt'; class IdentityX { constructor({ @@ -117,6 +120,60 @@ class IdentityX { return access; } + /** + * Returns the identity id from the request or supplied response. + * + * @returns {String} + */ + getIdentity(res) { + try { + const id = get(this.req, `cookies.${IDENTITY_COOKIE_NAME}`); + if (id) return id; + const sc = res.get('set-cookie'); + const scv = (typeof sc === 'string' ? [sc] : sc || []).reduce((o, c) => { + const [r] = `${c}`.split(';'); + const [k, v] = `${r}`.split('='); + return { ...o, [k]: v }; + }, {}); + if (scv) return get(scv, IDENTITY_COOKIE_NAME); + } catch (e) { + debug('Unable to parse identity', e); + } + return null; + } + + /** + * Sets the IdentityX Identity cookie to the response + */ + setIdentityCookie(id) { + const options = { + maxAge: 60 * 60 * 24 * 365, + httpOnly: false, + }; + this.res.cookie(IDENTITY_COOKIE_NAME, id, options); + } + + /** + * @param {Object} o + * @param {String} o.identifier + * @param {Object} o.namespace + * + * @returns {Promise} + */ + async findUserByExternalId({ identifier, namespace }) { + const apiToken = this.config.getApiToken(); + if (!apiToken) throw new Error('Unable to add external ID: No API token has been configured.'); + const { data } = await this.client.query({ + query: appUserByExternalIdQuery, + variables: { + identifier: { value: identifier }, + namespace, + }, + context: { apiToken }, + }); + return data.appUserByExternalId; + } + /** * * @param {object} params diff --git a/packages/marko-web-omeda-identity-x/api/queries/customer-by-encrypted-id.js b/packages/marko-web-omeda-identity-x/api/queries/customer-by-encrypted-id.js new file mode 100644 index 000000000..8861f9f3f --- /dev/null +++ b/packages/marko-web-omeda-identity-x/api/queries/customer-by-encrypted-id.js @@ -0,0 +1,10 @@ +const gql = require('graphql-tag'); + +module.exports = gql` + query IdXIdentifyCustomer($id: String!) { + customerByEncryptedId(input: { id: $id, errorOnNotFound: false }) { + id + primaryEmailAddress { emailAddress } + } + } +`; diff --git a/packages/marko-web-omeda-identity-x/index.js b/packages/marko-web-omeda-identity-x/index.js index af1ec79b9..cfcc9a247 100644 --- a/packages/marko-web-omeda-identity-x/index.js +++ b/packages/marko-web-omeda-identity-x/index.js @@ -7,6 +7,7 @@ const setPromoSourceCookie = require('./middleware/set-promo-source'); const stripOlyticsParam = require('./middleware/strip-olytics-param'); const resyncCustomerData = require('./middleware/resync-customer-data'); const setOlyticsCookie = require('./middleware/set-olytics-cookie'); +const setIdentityCookie = require('./middleware/set-identity-cookie'); const rapidIdentify = require('./middleware/rapid-identify'); const rapidIdentifyRouter = require('./routes/rapid-identify'); const props = require('./validation/props'); @@ -158,6 +159,7 @@ module.exports = (app, params = {}) => { identityX(app, idxConfig, { templates: idxRouteTemplates }); app.use(setOlyticsCookie({ brandKey })); + app.use(setIdentityCookie({ brandKey })); // install the Omeda data sync middleware app.use(resyncCustomerData({ diff --git a/packages/marko-web-omeda-identity-x/middleware/README.md b/packages/marko-web-omeda-identity-x/middleware/README.md new file mode 100644 index 000000000..0679dd6e6 --- /dev/null +++ b/packages/marko-web-omeda-identity-x/middleware/README.md @@ -0,0 +1,12 @@ +Omeda+IdentityX Middleware +=== + +## Set Identity Cookie +This [middleware](./set-identity-cookie.js) sets the IdentityX Identity cookie. This cookie represents the identity that all user behavior should be attributed to. + +Test cases: +1. Fully anonymous user (no omeda or idx tokens). No cookie should be set. +2. Logged in user w/o cookie: cookkie should be set +3. Logged in user w/ cookie: Cookie shoudl not be set. +4. Omeda identity cookie present, identityx user exists for ext id: cookie shoul dbe set. +5. Omeda identity cookie present, no identityx user exists: cookie should be set. diff --git a/packages/marko-web-omeda-identity-x/middleware/set-identity-cookie.js b/packages/marko-web-omeda-identity-x/middleware/set-identity-cookie.js new file mode 100644 index 000000000..e4f0c8410 --- /dev/null +++ b/packages/marko-web-omeda-identity-x/middleware/set-identity-cookie.js @@ -0,0 +1,73 @@ +const { asyncRoute } = require('@parameter1/base-cms-utils'); +const { get } = require('@parameter1/base-cms-object-path'); +const olyticsCookie = require('@parameter1/base-cms-marko-web-omeda/olytics/customer-cookie'); +const query = require('../api/queries/customer-by-encrypted-id'); + +/** + * + * @param {import('express').Request} req + * @param {import('express').Response} res + * @returns {String|null} + */ +const findOlyticsId = (req, res) => { + // Read from request cookies + const reqId = olyticsCookie.parseFrom(req); + if (reqId) return reqId; + // request query param + const { oly_enc_id: qId } = req.query; + if (qId) return qId; + // response cookie + const cookies = (res.get('set-cookie') || []).reduce((o, c) => { + const [r] = `${c}`.split(';'); + const [k, v] = `${r}`.split('='); + return { ...o, [k]: v }; + }, {}); + const resId = olyticsCookie.parseFrom({ cookies }); + return resId; +}; + +/** + * @typedef OIDXRequest + * @prop {import('@parameter1/omeda-graphql-client')} $omedaGraphQLClient + * @prop {import('@parameter1/base-cms-marko-web-identity-x/service')} identityX + * + * @param {Object} o + * @param {String} o.brandKey + */ +module.exports = ({ + brandKey, +}) => asyncRoute(async (req, res, next) => { + /** @type {OIDXRequest} */ + const { identityX: idx, $omedaGraphQLClient: omeda } = req; + const cookie = idx.getIdentity(res); + + // Don't overwrite an existing cookie + if (cookie) return next(); + + // get oly enc id. if we don't have one, bail + const omedaId = findOlyticsId(req, res); + if (!omedaId) return next(); + + // Look up idx user by encrypted id + const namespace = { provider: 'omeda', tenant: brandKey.toLowerCase(), type: 'customer' }; + const identity = await idx.findUserByExternalId({ identifier: omedaId, namespace }); + if (identity) { + idx.setIdentityCookie(identity.id); + return next(); + } + + const or = await omeda.query({ query, variables: { id: omedaId } }); + const email = get(or, 'data.customerByEncryptedId.primaryEmailAddress.emailAddress'); + if (email) { + // Upsert the user and add the external id to it. + const { id } = await idx.createAppUser({ email }); + await idx.addExternalUserId({ + userId: id, + identifier: { value: omedaId, type: 'encrypted' }, + namespace, + }); + idx.setIdentityCookie(id); + return next(); + } + return next(); +}); diff --git a/packages/marko-web-theme-monorail/components/identity-x/identify.marko b/packages/marko-web-theme-monorail/components/identity-x/identify.marko new file mode 100644 index 000000000..9ae4bb203 --- /dev/null +++ b/packages/marko-web-theme-monorail/components/identity-x/identify.marko @@ -0,0 +1,18 @@ +$ const { req, res } = out.global; +$ const { identityX } = req; +$ const identity = identityX.getIdentity(res); + + + + $ const payload = { + ...(identity && { identity }), + ...(hasUser && { + user: identityX.config.getGTMUserData(user), + user_id: user.id + }), + }; + + + + + diff --git a/packages/marko-web-theme-monorail/components/identity-x/marko.json b/packages/marko-web-theme-monorail/components/identity-x/marko.json index b242f882b..be45f5d9c 100644 --- a/packages/marko-web-theme-monorail/components/identity-x/marko.json +++ b/packages/marko-web-theme-monorail/components/identity-x/marko.json @@ -29,6 +29,9 @@ "": { "template": "./newsletter-inline.marko" }, + "": { + "template": "./identify.marko" + }, "": { "template": "./newsletter-footer.marko" } diff --git a/packages/web-common/website-context.js b/packages/web-common/website-context.js index e032225e9..9ec48da47 100644 --- a/packages/web-common/website-context.js +++ b/packages/web-common/website-context.js @@ -1,6 +1,7 @@ const gql = require('graphql-tag'); const siteFragment = require('./graphql/website-context-fragment'); +const { error } = console; const query = gql` query MarkoWebsiteContext { @@ -17,6 +18,11 @@ ${siteFragment} * @param {ApolloClient} apolloClient The BaseCMS Apollo GraphQL client that will perform the query. */ module.exports = async (apolloClient) => { - const { data } = await apolloClient.query({ query }); - return data.websiteContext; + try { + const { data } = await apolloClient.query({ query }); + return data.websiteContext; + } catch (e) { + error(e); + throw e; + } }; diff --git a/services/example-website/server/components/document.marko b/services/example-website/server/components/document.marko index c6f79af41..76ef6a7f6 100644 --- a/services/example-website/server/components/document.marko +++ b/services/example-website/server/components/document.marko @@ -46,6 +46,8 @@ $ const { nativeX, cdn, contentMeterState } = out.global; target-tag="body" /> + + <${input.head} />