@schibsted/account-sdk-browser 6.0.0-alpha.13 → 6.0.0-alpha.14

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.
@@ -1,5 +1,5 @@
1
1
  import { AccountBaseConfig, LogFunction } from './common';
2
- import { IdentityBeforeRedirectCallback, VarnishCookieOptions } from './identity';
2
+ import { ConsentReader, IdentityBeforeRedirectCallback, VarnishCookieOptions } from './identity';
3
3
  import { SessionResponse } from './session';
4
4
  /**
5
5
  * Configuration used to create an {@link Account} instance.
@@ -9,10 +9,17 @@ import { SessionResponse } from './session';
9
9
  export type AccountConfig = Omit<AccountBaseConfig, 'env'> & {
10
10
  /** Schibsted account environment. Defaults to PRE. */
11
11
  env?: AccountBaseConfig['env'];
12
- /** Receives debug log information. If not set, no logging will be done. */
12
+ /**
13
+ * Receives debug logs and consent reader errors.
14
+ * Without it, consent reader errors are reported through console.warn.
15
+ */
13
16
  log?: LogFunction;
14
17
  /** Callback triggered before a session refresh redirect happens. */
15
18
  callbackBeforeRedirect?: IdentityBeforeRedirectCallback;
19
+ /** Reads consents forwarded in login URLs. Defaults to the Schibsted Sourcepoint integration. */
20
+ getConsents?: ConsentReader;
21
+ /** Refresh consents of rendered login links when clicked. Defaults to true. */
22
+ refreshLoginLinks?: boolean;
16
23
  /** Product or feature ids used by `hasAccess()` when no ids are passed to the method. */
17
24
  defaultProductIds?: readonly string[];
18
25
  /** Optional Varnish cookie setup applied during Account initialization. */
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Log callback used by the SDK for debug output.
3
- * Receives a pre-formatted log line.
3
+ * Receives a pre-formatted log line and, when reading consents fails, the error.
4
4
  *
5
5
  * @group Configuration
6
6
  */
7
- export type LogFunction = (message: string) => void;
7
+ export type LogFunction = (message: string, error?: unknown) => void;
8
8
  /**
9
9
  * Supported Schibsted account environment keys.
10
10
  *
@@ -1,4 +1,8 @@
1
1
  import { AccountBaseConfig, LogFunction } from './common';
2
+ /**
3
+ * Synchronously returns the current consents as query parameters; must not wait for the user's decision.
4
+ */
5
+ export type ConsentReader = () => URLSearchParams | null;
2
6
  /**
3
7
  * Callback invoked before the SDK performs a full-page session refresh redirect.
4
8
  */
@@ -7,10 +11,15 @@ export type IdentityBeforeRedirectCallback = () => void | Promise<void>;
7
11
  * Options accepted by the {@link Identity} constructor.
8
12
  */
9
13
  export type IdentityOptions = AccountBaseConfig & {
10
- /** Receives debug log information. If not set, no logging will be done. */
14
+ /**
15
+ * Receives debug logs and consent reader errors.
16
+ * Without it, consent reader errors are reported through console.warn.
17
+ */
11
18
  log?: LogFunction;
12
19
  /** Callback triggered before a session refresh redirect happens. */
13
20
  callbackBeforeRedirect?: IdentityBeforeRedirectCallback;
21
+ /** Reads consents forwarded in login URLs. Defaults to the Schibsted Sourcepoint integration. */
22
+ getConsents?: ConsentReader;
14
23
  };
15
24
  /**
16
25
  * Options for {@link Identity#enableVarnishCookie}.
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- declare const version = "6.0.0-alpha.13";
1
+ declare const version = "6.0.0-alpha.14";
2
2
  export default version;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@schibsted/account-sdk-browser",
3
- "version": "6.0.0-alpha.13",
3
+ "version": "6.0.0-alpha.14",
4
4
  "description": "Schibsted account SDK for browsers",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -35,6 +35,7 @@
35
35
  "license": "MIT",
36
36
  "devDependencies": {
37
37
  "@biomejs/biome": "2.4.15",
38
+ "@schibsted/consent-url-utils": "^2.0.1",
38
39
  "@vitest/coverage-v8": "^4.1.7",
39
40
  "jsdom": "^29.1.1",
40
41
  "rimraf": "^6.1.3",
@@ -48,5 +49,8 @@
48
49
  "repository": {
49
50
  "type": "git",
50
51
  "url": "git://schibsted.ghe.com/user-identity/account-sdk-browser.git"
52
+ },
53
+ "publishConfig": {
54
+ "registry": "https://registry.npmjs.org/"
51
55
  }
52
56
  }
package/src/account.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  } from './types/login.js';
18
18
  import type { HasAccessResult } from './types/monetization.js';
19
19
  import type { ConnectedSessionResponse, SessionResponse, SessionUserId } from './types/session.js';
20
+ import { assert } from './validate.js';
20
21
 
21
22
  /**
22
23
  * Unified client-side interface for a configured Schibsted Account integration.
@@ -46,7 +47,14 @@ export class Account {
46
47
  throw new SDKError('Schibsted Account SDK has already been initialized');
47
48
  }
48
49
 
49
- const { log, callbackBeforeRedirect, defaultProductIds, varnish } = config;
50
+ const {
51
+ log,
52
+ callbackBeforeRedirect,
53
+ getConsents,
54
+ refreshLoginLinks = true,
55
+ defaultProductIds,
56
+ varnish,
57
+ } = config;
50
58
  const sharedConfig = {
51
59
  clientId: config.clientId,
52
60
  redirectUri: config.redirectUri,
@@ -55,10 +63,16 @@ export class Account {
55
63
  window: browserWindow,
56
64
  };
57
65
 
66
+ assert(
67
+ typeof refreshLoginLinks === 'boolean',
68
+ 'refreshLoginLinks parameter must be a boolean',
69
+ );
70
+
58
71
  this.#identity = new Identity({
59
72
  ...sharedConfig,
60
73
  log,
61
74
  callbackBeforeRedirect,
75
+ getConsents,
62
76
  });
63
77
 
64
78
  if (defaultProductIds !== undefined && !Array.isArray(defaultProductIds)) {
@@ -84,6 +98,9 @@ export class Account {
84
98
  if (sessionState) {
85
99
  sessionStates.set(this, sessionState);
86
100
  }
101
+ if (refreshLoginLinks) {
102
+ this.#identity.enableLoginLinkRefresh();
103
+ }
87
104
  browserWindow.schAccount = this;
88
105
  browserWindow.dispatchEvent(
89
106
  new CustomEvent('schAccount:ready', { detail: { instance: this } }),
@@ -0,0 +1,94 @@
1
+ /* Copyright 2026 Schibsted Products & Technology AS. Licensed under the terms of the MIT license.
2
+ * See LICENSE.md in the project root.
3
+ */
4
+
5
+ import { getConsentsSync, serializeConsents } from '@schibsted/consent-url-utils';
6
+
7
+ const LINK_ACTIVATION_EVENTS = ['click', 'auxclick'];
8
+
9
+ /**
10
+ * Reads consents from the Schibsted Sourcepoint integration, the default consent reader.
11
+ * Pages without the integration have no consents to forward, so they are skipped before
12
+ * `getConsentsSync` would warn about the missing consent manager on every login URL.
13
+ * @private
14
+ */
15
+ export function readSourcepointConsents(window: Window): URLSearchParams | null {
16
+ if (!window._tcf_ && !window._scc_) {
17
+ return null;
18
+ }
19
+ return serializeConsents(getConsentsSync(window));
20
+ }
21
+
22
+ /**
23
+ * Sets the given parameters on a URL, replacing previous values of the same parameters.
24
+ * @private
25
+ * @param href
26
+ * @param consentParams
27
+ * @return - The URL with the parameters replaced
28
+ */
29
+ export function replaceConsentParams(href: string, consentParams: URLSearchParams): string {
30
+ const url = new URL(href);
31
+ // Two passes on purpose: the reader may return repeated keys (e.g. `purpose=a&purpose=b`).
32
+ // A single `set()` pass would keep only the last value, and a single `append()` pass would keep
33
+ // stale values of the link. So first drop every stale value, then append all fresh ones.
34
+ consentParams.forEach((_, key) => {
35
+ url.searchParams.delete(key);
36
+ });
37
+ consentParams.forEach((value, key) => {
38
+ url.searchParams.append(key, value);
39
+ });
40
+ return url.href;
41
+ }
42
+
43
+ /**
44
+ * Finds the link that was activated by a click, including links inside open shadow roots.
45
+ * @private
46
+ * @param event
47
+ */
48
+ function findActivatedLink(event: Event): HTMLAnchorElement | undefined {
49
+ const path = typeof event.composedPath === 'function' ? event.composedPath() : [event.target];
50
+ return path.find((node): node is HTMLAnchorElement => {
51
+ const link = node as Partial<HTMLAnchorElement> | null;
52
+ return !!link && link.tagName === 'A' && typeof link.href === 'string';
53
+ });
54
+ }
55
+
56
+ /**
57
+ * Refreshes consents of already rendered login links right before the browser follows them.
58
+ * Links are often generated before the user answers the consent dialog, so their consents are stale.
59
+ * The listener runs in the capture phase, before page handlers read the link.
60
+ * @private
61
+ * @param document
62
+ * @param options
63
+ * @param options.isLoginUrl - Tells whether a URL is a login URL to refresh
64
+ * @param options.getConsentParams - Current consent parameters
65
+ */
66
+ export function refreshLoginLinksOnActivation(
67
+ document: Document,
68
+ {
69
+ isLoginUrl,
70
+ getConsentParams,
71
+ }: {
72
+ isLoginUrl: (href: string) => boolean;
73
+ getConsentParams: () => URLSearchParams;
74
+ },
75
+ ): void {
76
+ const refreshLink = (event: Event) => {
77
+ const link = findActivatedLink(event);
78
+ if (!link || !isLoginUrl(link.href)) {
79
+ return;
80
+ }
81
+
82
+ const consentParams = getConsentParams();
83
+ // Unknown consents never overwrite the ones the link was created with.
84
+ if (consentParams.toString() === '') {
85
+ return;
86
+ }
87
+
88
+ link.href = replaceConsentParams(link.href, consentParams);
89
+ };
90
+
91
+ LINK_ACTIVATION_EVENTS.forEach((type) => {
92
+ document.addEventListener(type, refreshLink, true);
93
+ });
94
+ }
package/src/identity.ts CHANGED
@@ -4,6 +4,11 @@
4
4
 
5
5
  import Cache from './cache.js';
6
6
  import { ENDPOINTS } from './config.js';
7
+ import {
8
+ readSourcepointConsents,
9
+ refreshLoginLinksOnActivation,
10
+ replaceConsentParams,
11
+ } from './consents.js';
7
12
  import { cloneDeep } from './object.js';
8
13
  import * as popup from './popup.js';
9
14
  import type RESTClient from './rest-client.js';
@@ -22,6 +27,7 @@ import * as spidTalk from './spid-talk.js';
22
27
  import type { AccountEventName } from './types/account.js';
23
28
  import type { LogFunction } from './types/common.js';
24
29
  import type {
30
+ ConsentReader,
25
31
  IdentityBeforeRedirectCallback,
26
32
  IdentityOptions,
27
33
  VarnishCookieOptions,
@@ -42,7 +48,15 @@ import type {
42
48
  SessionResponse,
43
49
  } from './types/session.js';
44
50
  import { urlMapper } from './url.js';
45
- import { assert, isNonEmptyString, isObject, isStr, isStrIn, isUrl } from './validate.js';
51
+ import {
52
+ assert,
53
+ isFunction,
54
+ isNonEmptyString,
55
+ isObject,
56
+ isStr,
57
+ isStrIn,
58
+ isUrl,
59
+ } from './validate.js';
46
60
  import version from './version.js';
47
61
 
48
62
  declare global {
@@ -75,6 +89,7 @@ export class Identity {
75
89
  private readonly env: string;
76
90
  private readonly log?: LogFunction;
77
91
  private readonly callbackBeforeRedirect: IdentityBeforeRedirectCallback;
92
+ private readonly getConsents: ConsentReader;
78
93
 
79
94
  // Caches
80
95
  private readonly sessionStorageCache: Cache;
@@ -109,10 +124,12 @@ export class Identity {
109
124
  * @param options.sessionDomain - Example: "https://id.site.com"
110
125
  * @param options.redirectUri - Example: "https://site.com"
111
126
  * @param options.env - Schibsted account environment: `PRE` (default), `PRO`, `PRO_NO`, `PRO_FI` or `PRO_DK`
112
- * @param options.log - A function that receives debug log information. If not set,
113
- * no logging will be done
127
+ * @param options.log - Receives debug logs and consent reader errors.
128
+ * Without it, consent reader errors are reported through console.warn.
114
129
  * @param options.window - window object
115
130
  * @param options.callbackBeforeRedirect - callback triggered before session refresh redirect happen
131
+ * @param options.getConsents - reads consents forwarded in login URLs.
132
+ * Defaults to the Schibsted Sourcepoint integration
116
133
  * @throws {SDKError} - If any of options are invalid
117
134
  */
118
135
  constructor({
@@ -123,6 +140,7 @@ export class Identity {
123
140
  log,
124
141
  window = globalWindow(),
125
142
  callbackBeforeRedirect = () => {},
143
+ getConsents = () => readSourcepointConsents(window),
126
144
  }: IdentityOptions) {
127
145
  assert(isNonEmptyString(clientId), 'clientId parameter is required');
128
146
  assert(isObject(window), 'The reference to window is missing');
@@ -133,6 +151,8 @@ export class Identity {
133
151
  );
134
152
  assert(isStr(env), `env parameter is invalid: ${env}`);
135
153
 
154
+ assert(isFunction(getConsents), 'getConsents parameter must be a function');
155
+
136
156
  spidTalk.emulate(window);
137
157
  this.window = window;
138
158
  this.clientId = clientId;
@@ -140,6 +160,7 @@ export class Identity {
140
160
  this.env = env;
141
161
  this.log = log;
142
162
  this.callbackBeforeRedirect = callbackBeforeRedirect;
163
+ this.getConsents = getConsents;
143
164
 
144
165
  this.sessionStorageCache = new Cache(() => this.window && this.window.sessionStorage);
145
166
  this.localStorageCache = new Cache(() => this.window && this.window.localStorage);
@@ -189,6 +210,17 @@ export class Identity {
189
210
  this._unblockSessionCall();
190
211
  }
191
212
 
213
+ /** @internal */
214
+ enableLoginLinkRefresh(): void {
215
+ const { document } = this.window;
216
+ if (document && isFunction(document.addEventListener)) {
217
+ refreshLoginLinksOnActivation(document, {
218
+ isLoginUrl: (href) => this.isLoginUrl(href),
219
+ getConsentParams: () => this.getConsentParams(),
220
+ });
221
+ }
222
+ }
223
+
192
224
  addEventListener(type: AccountEventName, listener: EventListener): void {
193
225
  this.eventTarget.addEventListener(type, listener);
194
226
  }
@@ -855,23 +887,65 @@ export class Identity {
855
887
  `the state parameter should be a non empty string but it is ${state}`,
856
888
  );
857
889
 
858
- return this.oauthService.makeUrl('oauth/authorize', {
859
- response_type: 'code',
860
- redirect_uri: redirectUri,
861
- scope,
862
- state,
863
- acr_values: acrValues,
864
- login_hint: loginHint,
865
- tag,
866
- teaser,
867
- max_age: maxAge,
868
- locale,
869
- one_step_login: oneStepLogin || '',
870
- prompt: acrValues ? '' : prompt,
871
- x_domain_id: xDomainId,
872
- x_env_id: xEnvironmentId,
873
- utm_campaign: originCampaign,
874
- });
890
+ return replaceConsentParams(
891
+ this.oauthService.makeUrl('oauth/authorize', {
892
+ response_type: 'code',
893
+ redirect_uri: redirectUri,
894
+ scope,
895
+ state,
896
+ acr_values: acrValues,
897
+ login_hint: loginHint,
898
+ tag,
899
+ teaser,
900
+ max_age: maxAge,
901
+ locale,
902
+ one_step_login: oneStepLogin || '',
903
+ prompt: acrValues ? '' : prompt,
904
+ x_domain_id: xDomainId,
905
+ x_env_id: xEnvironmentId,
906
+ utm_campaign: originCampaign,
907
+ }),
908
+ this.getConsentParams(),
909
+ );
910
+ }
911
+
912
+ /**
913
+ * Reads the current consent parameters. A failing consent reader must not prevent a login,
914
+ * so the failure is logged and the login continues without consents.
915
+ * @private
916
+ * @return - Consent query parameters; empty when unavailable
917
+ */
918
+ private getConsentParams(): URLSearchParams {
919
+ try {
920
+ const consentParams = this.getConsents() || new URLSearchParams();
921
+ assert(
922
+ consentParams instanceof URLSearchParams,
923
+ `getConsents must return URLSearchParams but it returned ${consentParams}`,
924
+ );
925
+ return consentParams;
926
+ } catch (error) {
927
+ const log = this.log || console.warn;
928
+ log('Schibsted account SDK: could not read consents, logging in without them', error);
929
+ return new URLSearchParams();
930
+ }
931
+ }
932
+
933
+ /**
934
+ * Tells whether a URL is a login URL generated by this instance
935
+ * @private
936
+ * @param href
937
+ */
938
+ private isLoginUrl(href: string): boolean {
939
+ if (!isUrl(href)) {
940
+ return false;
941
+ }
942
+ const url = new URL(href);
943
+ const authorizeUrl = new URL(this.oauthService.makeUrl('oauth/authorize', {}, false));
944
+ return (
945
+ url.origin === authorizeUrl.origin &&
946
+ url.pathname === authorizeUrl.pathname &&
947
+ url.searchParams.get('client_id') === this.clientId
948
+ );
875
949
  }
876
950
 
877
951
  /**
package/src/index.ts CHANGED
@@ -25,6 +25,7 @@ export type {
25
25
  LogoutRequestedEventDetail,
26
26
  } from './types/account.js';
27
27
  export type { AccountEnvironment, LogFunction } from './types/common.js';
28
+ export type { ConsentReader } from './types/identity.js';
28
29
  export type {
29
30
  LoginOptions,
30
31
  SimplifiedLoginData,
@@ -1,5 +1,9 @@
1
1
  import type { AccountBaseConfig, LogFunction } from './common';
2
- import type { IdentityBeforeRedirectCallback, VarnishCookieOptions } from './identity';
2
+ import type {
3
+ ConsentReader,
4
+ IdentityBeforeRedirectCallback,
5
+ VarnishCookieOptions,
6
+ } from './identity';
3
7
  import type { SessionResponse } from './session';
4
8
 
5
9
  /**
@@ -10,10 +14,17 @@ import type { SessionResponse } from './session';
10
14
  export type AccountConfig = Omit<AccountBaseConfig, 'env'> & {
11
15
  /** Schibsted account environment. Defaults to PRE. */
12
16
  env?: AccountBaseConfig['env'];
13
- /** Receives debug log information. If not set, no logging will be done. */
17
+ /**
18
+ * Receives debug logs and consent reader errors.
19
+ * Without it, consent reader errors are reported through console.warn.
20
+ */
14
21
  log?: LogFunction;
15
22
  /** Callback triggered before a session refresh redirect happens. */
16
23
  callbackBeforeRedirect?: IdentityBeforeRedirectCallback;
24
+ /** Reads consents forwarded in login URLs. Defaults to the Schibsted Sourcepoint integration. */
25
+ getConsents?: ConsentReader;
26
+ /** Refresh consents of rendered login links when clicked. Defaults to true. */
27
+ refreshLoginLinks?: boolean;
17
28
  /** Product or feature ids used by `hasAccess()` when no ids are passed to the method. */
18
29
  defaultProductIds?: readonly string[];
19
30
  /** Optional Varnish cookie setup applied during Account initialization. */
@@ -2,11 +2,11 @@
2
2
 
3
3
  /**
4
4
  * Log callback used by the SDK for debug output.
5
- * Receives a pre-formatted log line.
5
+ * Receives a pre-formatted log line and, when reading consents fails, the error.
6
6
  *
7
7
  * @group Configuration
8
8
  */
9
- export type LogFunction = (message: string) => void;
9
+ export type LogFunction = (message: string, error?: unknown) => void;
10
10
 
11
11
  /**
12
12
  * Supported Schibsted account environment keys.
@@ -1,5 +1,10 @@
1
1
  import type { AccountBaseConfig, LogFunction } from './common';
2
2
 
3
+ /**
4
+ * Synchronously returns the current consents as query parameters; must not wait for the user's decision.
5
+ */
6
+ export type ConsentReader = () => URLSearchParams | null;
7
+
3
8
  /**
4
9
  * Callback invoked before the SDK performs a full-page session refresh redirect.
5
10
  */
@@ -9,10 +14,15 @@ export type IdentityBeforeRedirectCallback = () => void | Promise<void>;
9
14
  * Options accepted by the {@link Identity} constructor.
10
15
  */
11
16
  export type IdentityOptions = AccountBaseConfig & {
12
- /** Receives debug log information. If not set, no logging will be done. */
17
+ /**
18
+ * Receives debug logs and consent reader errors.
19
+ * Without it, consent reader errors are reported through console.warn.
20
+ */
13
21
  log?: LogFunction;
14
22
  /** Callback triggered before a session refresh redirect happens. */
15
23
  callbackBeforeRedirect?: IdentityBeforeRedirectCallback;
24
+ /** Reads consents forwarded in login URLs. Defaults to the Schibsted Sourcepoint integration. */
25
+ getConsents?: ConsentReader;
16
26
  };
17
27
 
18
28
  /**
package/src/version.ts CHANGED
@@ -1,4 +1,4 @@
1
1
  // Version is bumped automatically by release-please. See release-please-config.json.
2
2
 
3
- const version = '6.0.0-alpha.13'; // x-release-please-version
3
+ const version = '6.0.0-alpha.14'; // x-release-please-version
4
4
  export default version;