This document is valid for the following ICM versions: 12.5 < 13.0, 13.4.x < 14.0, 14.1
For other versions, refer to
The API token login feature enables customers to log in to both the Progressive Web App (PWA) or any other REST-based client and the Responsive Starter Store.
This feature can be useful if certain elements of the PWA (e.g., product listing), but also elements of the Responsive Starter Store (for example, checkout) are to be used together in a project.
Term | Description |
|---|---|
ICM | The abbreviation for Intershop Commerce Management |
PWA | The abbreviation for Progressive Web App |
The API token login can be enabled generally or application-specifically (see Cookbook - Configuration). The key of the property is intershop.apitoken.cookie.enabled.
For example:
intershop.apitoken.cookie.enabled=true
Furthermore, an activation on application type level is possible using the property intershop.apitoken.cookie.applicationTypes, for instance:
intershop.apitoken.cookie.applicationTypes=intershop.B2CResponsive,intershop.SMBResponsive
The PWA must have cookies enabled. If so, a cookie is written when the ICM application server processes a request (page is not cached by the web adapter).
The cookie named apiToken contains a JSON object with the API token.
The attribute isAnonymous indicates the authentication state:
true for anonymous users
false for authenticated users
When the ICM starts handling a request and the cookie is present, the ICM ensures that the user is logged in or an anonymous basket is retrieved.
For technical reasons, ICM overwrites the PWA cookie with its own cookie. The difference is that another JSON attribute is added, which is called creator='icm'.
If the user is logged in to ICM, but no cookie is available when the ICM takes over, the user will be logged out.
Note
This feature is based on the assumption that PWA and ICM can read and write each other's cookies. That means that both cookies must have the same domain and the same path. Therefore, this feature only works if PWA and ICM are running in the same domain. Otherwise, the property intershop.apitoken.cookie.samesite needs to be set to lax or none (see paragraph configuration ).
Depending on the property intershop.apitoken.cookie.pathCalculationMode, the path set inside the cookie is calculated.
If intershop.apitoken.cookie.pathCalculationMode=headless, PWA and ICM URLs need to contain the same prefix. For details, see Cookie Path Calculation and Configuration. On ICM side, such URLs can, for instance, be achieved using URL rewriting.
Depending on the property intershop.apitoken.cookie.nameCalculationMode, the name used for the cookie is calculated.
If intershop.apitoken.cookie.nameCalculationMode=siteDependent, the PWA also needs to use the same cookie name calculation. For details, see Cookie Name Calculation and Configuration.
The property intershop.apitoken.cookie.applicationTypes controls for which application types API token cookies are used. This is important because these cookies are about 1 kB in size. If multiple cookies exist, the HTTP header space can become exhausted. Normally the space is limited to 8 kB, but all web components (e.g., Tomcat, Apache HTTPD, nginx) have different limitations. Therefore, it is highly recommended to set the property intershop.apitoken.cookie.applicationTypes to contain only the relevant application types (normally the storefront application types).
Note
The default value of intershop.apitoken.cookie.applicationTypes is empty to remain compatible with previous releases (empty means active for all application types).
Depending on the configuration property intershop.apitoken.cookie.pathCalculationMode, the cookie’s path is calculated as follows:
If the property’s value is:
root: The path is always /.
headless: The path is calculated using the application preference ExternalApplicationBaseURL of the PWA application (having preference HeadlessApplication=true, also see Concept - Headless Application Type - intershop.REST) in the same site as the request to the responsive application.
By default, the PWA writes its apiToken cookie on the domain root level. For the combination with the intershop.apitoken.cookie.pathCalculationMode=headless configuration, the cookie needs to be written on the baseHref path. This can be achieved by adapting the PWA's source code in the api-token.service.ts where the path: '/', needs to be removed when putting or removing the apiToken cookie.
if (!SSR) {
...
// save internal calculated apiToken as cookie whenever apiToken, basket, user or order information changes
this.calculateApiTokenCookieValueOnChanges$().subscribe(apiToken => {
const cookieContent = apiToken?.apiToken ? JSON.stringify(apiToken) : undefined;
if (cookieContent) {
this.cookiesService.put('apiToken', cookieContent, {
expires: this.cookieOptions?.expires ?? new Date(Date.now() + DEFAULT_EXPIRY_TIME),
secure: this.cookieOptions?.secure,
});
}
});
// remove apiToken cookie on logout
// path: '/' is added in order to remove cookie within a multi-site configuration (e.g. configured /en, /fr, /de routes)
this.logoutUser$().subscribe(() => this.cookiesService.remove('apiToken'));
...
}
In addition to writing the apiToken cookie at the baseHref path level, you need to adapt the ICM request proxying based on something other than just /INTERSHOP, since the Responsive Starter Store requests now must also have the same path (achieved by URL rewriting) as the PWA requests, so that the apiToken cookie can still be shared. You need to adapt this in the server.ts and the multi-channel.conf.tmpl. You also need to adapt the default-url-mapping-table.ts in a Hybrid Approach scenario.
Depending on the configuration property intershop.apitoken.cookie.nameCalculationMode, the cookie’s name is calculated as follows:
If the property’s value is:
fixed: The name is taken from the property intershop.apitoken.cookie.name as is.
siteDependent: The name is calculated following the pattern ${intershop.apitoken.cookie.name}-<siteName>.
The current Intershop PWA implementation reads and writes the API token cookie with the hard coded name apiToken, which is the default name used by ICM if not configured otherwise. To use a different cookie name with the default fixed name calculation mode, according customization in the PWA is required (e.g., search for the string apiToken and replace it with the intended cookie name where it is actually used with the methods of the cookieService).
The Intershop PWA does not provide the functionality to read and write siteDependent cookies out of the box. This needs to be customized if the PWA needs to be used with such a requirement. The following diff illustrates the necessary changes based on an Intershop PWA 9.0.0.
diff --git a/src/app/core/guards/auth.guard.ts b/src/app/core/guards/auth.guard.ts
index 2c9353239..b0b088436 100644
--- a/src/app/core/guards/auth.guard.ts
+++ b/src/app/core/guards/auth.guard.ts
@@ -4,7 +4,9 @@ import { Store, select } from '@ngrx/store';
import { iif, of, race, timer } from 'rxjs';
import { map, take } from 'rxjs/operators';
+import { getICMChannel } from 'ish-core/store/core/configuration';
import { getUserAuthorized } from 'ish-core/store/customer/user';
+import { apiTokenCookieName } from 'ish-core/utils/api-token/api-token.service';
import { CookiesService } from 'ish-core/utils/cookies/cookies.service';
import { whenTruthy } from 'ish-core/utils/operators';
@@ -33,7 +35,17 @@ export function authGuard(snapshot: ActivatedRouteSnapshot, state: RouterStateSn
store.pipe(select(getUserAuthorized), whenTruthy(), take(1)),
// send to login after timeout
// send right away if no user can be re-hydrated
- timer(!router.navigated && cookieService.get('apiToken') ? 4000 : 0).pipe(map(() => defaultRedirect))
+ store.pipe(
+ select(getICMChannel),
+ whenTruthy(),
+ take(1),
+ map(channel => {
+ const hasApiToken = cookieService.get(apiTokenCookieName(channel));
+ return !router.navigated && hasApiToken ? 4000 : 0;
+ }),
+ map(delay => timer(delay)),
+ map(() => defaultRedirect)
+ )
)
);
}
diff --git a/src/app/core/store/core/configuration/configuration.selectors.ts b/src/app/core/store/core/configuration/configuration.selectors.ts
index 8253d22c5..fea9efc86 100644
--- a/src/app/core/store/core/configuration/configuration.selectors.ts
+++ b/src/app/core/store/core/configuration/configuration.selectors.ts
@@ -9,6 +9,8 @@ import { ConfigurationState } from './configuration.reducer';
export const getConfigurationState = createSelector(getCoreState, state => state.configuration);
+export const getICMChannel = createSelector(getConfigurationState, state => state.channel);
+
const getICMApplication = createSelector(getConfigurationState, state => state.application || '-');
export const getResponsiveStarterStoreApplication = createSelector(
diff --git a/src/app/core/store/customer/user/user.effects.ts b/src/app/core/store/customer/user/user.effects.ts
index 6474cbb1d..060814a2f 100644
--- a/src/app/core/store/customer/user/user.effects.ts
+++ b/src/app/core/store/customer/user/user.effects.ts
@@ -10,6 +10,7 @@ import { CustomerRegistrationType } from 'ish-core/models/customer/customer.mode
import { PaymentService } from 'ish-core/services/payment/payment.service';
import { TokenService } from 'ish-core/services/token/token.service';
import { UserService } from 'ish-core/services/user/user.service';
+import { getICMChannel } from 'ish-core/store/core/configuration';
import { displaySuccessMessage } from 'ish-core/store/core/messages';
import { selectQueryParam, selectUrl } from 'ish-core/store/core/router';
import { getServerConfigParameter } from 'ish-core/store/core/server-config';
@@ -289,7 +290,8 @@ export class UserEffects {
determinePersonalizationStatus$ = createEffect(() =>
this.store.pipe(
select(getPGID),
- map(pgid => !this.apiTokenService.hasUserApiTokenCookie() || pgid),
+ concatLatestFrom(() => this.store.pipe(select(getICMChannel))),
+ map(([pgid, channel]) => !this.apiTokenService.hasUserApiTokenCookie(channel) || pgid),
whenTruthy(),
delay(100),
map(() => personalizationStatusDetermined())
diff --git a/src/app/core/utils/api-token/api-token.service.ts b/src/app/core/utils/api-token/api-token.service.ts
index feac6a125..ffafe2484 100644
--- a/src/app/core/utils/api-token/api-token.service.ts
+++ b/src/app/core/utils/api-token/api-token.service.ts
@@ -1,5 +1,6 @@
import { HttpErrorResponse, HttpEvent, HttpHandler, HttpParams, HttpRequest, HttpResponse } from '@angular/common/http';
import { ApplicationRef, Injectable } from '@angular/core';
+import { concatLatestFrom } from '@ngrx/effects';
import { Store, select } from '@ngrx/store';
import { CookieOptions } from 'express';
import { isEqual } from 'lodash-es';
@@ -37,6 +38,7 @@ import {
import { BasketView } from 'ish-core/models/basket/basket.model';
import { User } from 'ish-core/models/user/user.model';
import { ApiService } from 'ish-core/services/api/api.service';
+import { getICMChannel } from 'ish-core/store/core/configuration';
import { getCurrentBasket, getCurrentBasketId, loadBasketByAPIToken } from 'ish-core/store/customer/basket';
import { getOrder, getSelectedOrderId, loadOrderByAPIToken } from 'ish-core/store/customer/orders';
import {
@@ -61,6 +63,11 @@ export interface ApiTokenCookie {
// If no expiry date is supplied by the token endpoint, this value (in ms) is used
const DEFAULT_EXPIRY_TIME = 3600000;
+// construct the apiToken cookie name based on the given channel name
+export function apiTokenCookieName(channel?: string): string {
+ return `apiToken${channel ? `-${channel}` : ''}`;
+}
+
@Injectable({ providedIn: 'root' })
export class ApiTokenService {
/**
@@ -90,30 +97,42 @@ export class ApiTokenService {
private cookieChangeEvent$: Observable<[ApiTokenCookie, ApiTokenCookie]>;
constructor(private cookiesService: CookiesService, private store: Store, private appRef: ApplicationRef) {
- // setup initial values
- const initialCookie = this.parseCookie();
- this.initialCookie$ = new BehaviorSubject<ApiTokenCookie>(!SSR ? initialCookie : undefined);
- this.apiToken$ = new BehaviorSubject<string>(initialCookie?.apiToken);
+ // setup initial values - initialize with undefined first, will be set with proper channel info below
+ this.initialCookie$ = new BehaviorSubject<ApiTokenCookie>(undefined);
+ this.apiToken$ = new BehaviorSubject<string>(undefined);
+
+ // Initialize with channel-aware cookie parsing
+ if (!SSR) {
+ this.store.pipe(select(getICMChannel), whenTruthy(), take(1)).subscribe(channel => {
+ const initialCookie = this.parseCookie(channel);
+ this.initialCookie$.next(initialCookie);
+ this.apiToken$.next(initialCookie?.apiToken);
+ });
+ }
if (!SSR) {
// multicast apiTokenCookieChange$ to avoid multiple listeners
this.cookieChangeEvent$ = this.apiTokenCookieChange$().pipe(shareReplay(1));
// save internal calculated apiToken as cookie whenever apiToken, basket, user or order information changes
- this.calculateApiTokenCookieValueOnChanges$().subscribe(apiToken => {
- const cookieContent = apiToken?.apiToken ? JSON.stringify(apiToken) : undefined;
- if (cookieContent) {
- this.cookiesService.put('apiToken', cookieContent, {
- expires: this.cookieOptions?.expires ?? new Date(Date.now() + DEFAULT_EXPIRY_TIME),
- secure: this.cookieOptions?.secure,
- path: '/',
- });
- }
- });
+ this.calculateApiTokenCookieValueOnChanges$()
+ .pipe(concatLatestFrom(() => this.store.pipe(select(getICMChannel))))
+ .subscribe(([apiToken, channel]) => {
+ const cookieContent = apiToken?.apiToken ? JSON.stringify(apiToken) : undefined;
+ if (cookieContent) {
+ this.cookiesService.put(apiTokenCookieName(channel), cookieContent, {
+ expires: this.cookieOptions?.expires ?? new Date(Date.now() + DEFAULT_EXPIRY_TIME),
+ secure: this.cookieOptions?.secure,
+ path: '/',
+ });
+ }
+ });
// remove apiToken cookie on logout
// path: '/' is added in order to remove cookie within a multi-site configuration (e.g. configured /en, /fr, /de routes)
- this.logoutUser$().subscribe(() => this.cookiesService.remove('apiToken', { path: '/' }));
+ this.logoutUser$()
+ .pipe(concatLatestFrom(() => this.store.pipe(select(getICMChannel))))
+ .subscribe(([, channel]) => this.cookiesService.remove(apiTokenCookieName(channel), { path: '/' }));
// unset apiToken when cookie vanishes/ has been removed unexpectedly and notify public event stream
this.tokenVanish$().subscribe(type => {
@@ -147,8 +166,8 @@ export class ApiTokenService {
this.apiToken$.next(apiToken);
}
- hasUserApiTokenCookie() {
- const apiTokenCookie = this.parseCookie();
+ hasUserApiTokenCookie(channel?: string) {
+ const apiTokenCookie = this.parseCookie(channel);
return apiTokenCookie?.type === 'user' && !apiTokenCookie?.isAnonymous;
}
@@ -159,9 +178,9 @@ export class ApiTokenService {
if (SSR) {
return of(true);
}
- return this.initialCookie$.pipe(
- first(),
- switchMap(cookie => {
+ return this.store.pipe(select(getICMChannel), whenTruthy(), take(1)).pipe(
+ switchMap(channel => {
+ const cookie = this.parseCookie(channel);
if (types.includes(cookie?.type)) {
switch (cookie?.type) {
case 'user': {
@@ -216,7 +235,8 @@ export class ApiTokenService {
this.anonymousUserTokenMechanism(),
of(true)
).pipe(
- switchMap(() =>
+ concatLatestFrom(() => this.store.pipe(select(getICMChannel))),
+ switchMap(([, channel]) =>
this.appendAuthenticationHeader(req).pipe(
concatMap(request =>
next.handle(request).pipe(
@@ -239,7 +259,7 @@ export class ApiTokenService {
}),
catchError(err => {
if (this.isAuthTokenError(err)) {
- this.invalidateApiToken();
+ this.invalidateApiToken(channel);
// retry request without auth token
const retryRequest = request.clone({
@@ -260,8 +280,8 @@ export class ApiTokenService {
/**
* will remove apiToken and inform cookieVanishes$ listeners that cookie in not working as expected
*/
- private invalidateApiToken() {
- const cookie = this.parseCookie();
+ private invalidateApiToken(channel?: string) {
+ const cookie = this.parseCookie(channel);
this.removeApiToken();
@@ -315,7 +335,8 @@ export class ApiTokenService {
first(),
mergeMap(() =>
interval(1000).pipe(
- map(() => this.parseCookie()),
+ concatLatestFrom(() => this.store.pipe(select(getICMChannel))),
+ map(([, channel]) => this.parseCookie(channel)),
pairwise(),
distinctUntilChanged((prev, curr) => isEqual(prev, curr))
)
@@ -410,7 +431,8 @@ export class ApiTokenService {
private mapToApiTokenCookie(): OperatorFunction<[User, BasketView, string, string], ApiTokenCookie> {
return (source$: Observable<[User, BasketView, string, string]>) =>
source$.pipe(
- map(([user, basket, orderId, apiToken]): ApiTokenCookie => {
+ concatLatestFrom(() => this.store.pipe(select(getICMChannel))),
+ map(([[user, basket, orderId, apiToken], channel]): ApiTokenCookie => {
if (user) {
return { apiToken, type: 'user', isAnonymous: false, creator: 'pwa' };
} else if (basket) {
@@ -419,7 +441,7 @@ export class ApiTokenService {
return { apiToken, type: 'order', orderId, creator: 'pwa' };
}
- const apiTokenCookieString = this.cookiesService.get('apiToken');
+ const apiTokenCookieString = this.cookiesService.get(apiTokenCookieName(channel));
const apiTokenCookie: ApiTokenCookie = apiTokenCookieString ? JSON.parse(apiTokenCookieString) : undefined;
if (apiToken && apiTokenCookie) {
return { ...apiTokenCookie, apiToken }; // overwrite existing cookie information with new apiToken
@@ -428,8 +450,8 @@ export class ApiTokenService {
);
}
- private parseCookie(): ApiTokenCookie {
- const cookieContent = this.cookiesService.get('apiToken');
+ private parseCookie(channel?: string): ApiTokenCookie {
+ const cookieContent = this.cookiesService.get(apiTokenCookieName(channel));
if (cookieContent) {
try {
return JSON.parse(cookieContent);
ICM writes the cookie when the following conditions are met:
The ICM application server processes a request.
The feature is active/enabled.
In the same site as the request to the responsive application, there is a PWA application (with preference HeadlessApplication=true).
ICM deletes the cookie when the feature is active and the token inside the cookie is invalid.
The detailed workflow is as follows:
Key | Description | Type | Mandatory/Optional | Default value |
|---|---|---|---|---|
| If | boolean | optional |
|
| The name of the cookie to be used. | string | optional |
|
| The maximum age of the cookie in minutes. | integer | optional |
|
| If | boolean | optional |
|
| Defines the | enum (one of { | optional |
|
| Limits the application types for which the API token cookie is used. If empty, all application types use the cookie. | comma separated list of application type names | optional | <empty> |
| Defines how the path of the cookie is calculated, see Cookie Path Calculation. | enum (one of { | optional |
|
| Defines how the name of the cookie is calculated, see Cookie Name Calculation. | enum (one of { | optional |
|
To actually log in the token user (see diagram in ICM Cookie Handling), the pipeline UserLogin-LoginUser is called. For the logout, the pipeline UserLogin-Logout is called.
Both are implemented for the login and logout process in the platform. In icm-as-business, the pipeline is overwritten to delegate to the pipelines for ProcessUser-Login and ProcessUser-LogoutUser respectively.
If there are additional tasks in customer projects when a user is logged in/out, further overwriting may be necessary.
The information provided in the Knowledge Base may not be applicable to all systems and situations. Intershop Communications will not be liable to any party for any direct or indirect damages resulting from the use of the Customer Support section of the Intershop Corporate Website, including, without limitation, any lost profits, business interruption, loss of programs or other data on your information handling system.