@sidub-inc/licensing-client 1.5.170 → 1.6.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/README.md +101 -13
- package/dist/index.cjs +208 -173
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +178 -98
- package/dist/index.esm.js +209 -169
- package/dist/index.esm.js.map +1 -1
- package/docs/MIGRATION.md +122 -1
- package/docs/REACT_GUIDE.md +57 -10
- package/package.json +5 -3
package/dist/index.cjs
CHANGED
|
@@ -2,9 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
var React = require('react');
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Parses a Retry-After header value (delta-seconds or HTTP-date) into
|
|
7
|
+
* milliseconds. Returns undefined when absent or unparseable.
|
|
8
|
+
*/
|
|
9
|
+
function parseRetryAfter(headerValue) {
|
|
10
|
+
if (!headerValue)
|
|
11
|
+
return undefined;
|
|
12
|
+
const seconds = Number(headerValue);
|
|
13
|
+
if (!Number.isNaN(seconds) && seconds >= 0) {
|
|
14
|
+
return seconds * 1000;
|
|
15
|
+
}
|
|
16
|
+
const date = Date.parse(headerValue);
|
|
17
|
+
if (!Number.isNaN(date)) {
|
|
18
|
+
return Math.max(0, date - Date.now());
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
function asOptionalString(value) {
|
|
23
|
+
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
24
|
+
}
|
|
5
25
|
/**
|
|
6
26
|
* Error class for licensing-related errors.
|
|
7
27
|
* Thrown when API requests fail, authorization is denied, or network errors occur.
|
|
28
|
+
*
|
|
29
|
+
* When the failure came from the licensing service over HTTP, the server's
|
|
30
|
+
* shared ApiError contract {Code, Message, CorrelationId, Details[]} is parsed
|
|
31
|
+
* onto the error (MON-334 SR2): branch on {@link code} for machine handling and
|
|
32
|
+
* quote {@link correlationId} in support requests. Both are undefined only for
|
|
33
|
+
* failures that never reached the server (network errors, timeouts) or when the
|
|
34
|
+
* server did not supply them.
|
|
8
35
|
*/
|
|
9
36
|
class LicensingError extends Error {
|
|
10
37
|
/**
|
|
@@ -19,6 +46,61 @@ class LicensingError extends Error {
|
|
|
19
46
|
this.response = response;
|
|
20
47
|
this.name = 'LicensingError';
|
|
21
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* Builds a LicensingError from a non-OK HTTP response, parsing the server's
|
|
51
|
+
* shared ApiError contract {Code, Message, CorrelationId, Details[]} plus the
|
|
52
|
+
* X-Correlation-Id and Retry-After response headers (MON-334 SR2).
|
|
53
|
+
*
|
|
54
|
+
* The machine code and correlation id are never dropped: when the body is not
|
|
55
|
+
* a parseable ApiError the correlation id still comes from the header, and
|
|
56
|
+
* the raw body text is always preserved on {@link response}. The message
|
|
57
|
+
* prefers the server's human-readable ApiError.Message and falls back to the
|
|
58
|
+
* HTTP status text only when no server message is available.
|
|
59
|
+
*
|
|
60
|
+
* @param operation Human-readable operation name used as the message prefix
|
|
61
|
+
* (e.g. 'Authorization' → 'Authorization failed: ...').
|
|
62
|
+
* @param response The non-OK fetch response (or a structural equivalent).
|
|
63
|
+
* @returns The constructed error, ready to throw.
|
|
64
|
+
*/
|
|
65
|
+
static async fromHttpResponse(operation, response) {
|
|
66
|
+
const rawBody = response.text
|
|
67
|
+
? await response.text().catch(() => 'Unknown error')
|
|
68
|
+
: 'Unknown error';
|
|
69
|
+
let code;
|
|
70
|
+
let serverMessage;
|
|
71
|
+
let bodyCorrelationId;
|
|
72
|
+
let details;
|
|
73
|
+
try {
|
|
74
|
+
const parsed = JSON.parse(rawBody);
|
|
75
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
|
76
|
+
code = asOptionalString(parsed.Code ?? parsed.code);
|
|
77
|
+
serverMessage = asOptionalString(parsed.Message ?? parsed.message);
|
|
78
|
+
bodyCorrelationId = asOptionalString(parsed.CorrelationId ?? parsed.correlationId);
|
|
79
|
+
const rawDetails = parsed.Details ?? parsed.details;
|
|
80
|
+
if (Array.isArray(rawDetails) && rawDetails.length > 0) {
|
|
81
|
+
details = rawDetails.map((d) => {
|
|
82
|
+
const detail = (d ?? {});
|
|
83
|
+
return {
|
|
84
|
+
code: asOptionalString(detail.Code ?? detail.code),
|
|
85
|
+
message: asOptionalString(detail.Message ?? detail.message),
|
|
86
|
+
field: asOptionalString(detail.Field ?? detail.field),
|
|
87
|
+
targetId: asOptionalString(detail.TargetId ?? detail.targetId)
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Body is not JSON — the raw text is still preserved on `response`.
|
|
95
|
+
}
|
|
96
|
+
const headerCorrelationId = asOptionalString(response.headers?.get?.('X-Correlation-Id') ?? undefined);
|
|
97
|
+
const error = new LicensingError(`${operation} failed: ${serverMessage ?? response.statusText ?? 'HTTP error'}`, response.status, rawBody);
|
|
98
|
+
error.code = code;
|
|
99
|
+
error.correlationId = bodyCorrelationId ?? headerCorrelationId;
|
|
100
|
+
error.details = details;
|
|
101
|
+
error.retryAfterMs = parseRetryAfter(response.headers?.get?.('Retry-After'));
|
|
102
|
+
return error;
|
|
103
|
+
}
|
|
22
104
|
}
|
|
23
105
|
|
|
24
106
|
/**
|
|
@@ -127,6 +209,16 @@ const CURRENT_VERSION = 1;
|
|
|
127
209
|
* Encodes a licensing credential to a portable string.
|
|
128
210
|
* This matches the .NET LicensingCredential.ToEncodedString() format.
|
|
129
211
|
*
|
|
212
|
+
* SECURITY (MON-334 react-08): the encoded string is base64-OBFUSCATED, not
|
|
213
|
+
* encrypted — it contains the apiAccessKey in recoverable form. Do not store
|
|
214
|
+
* it anywhere page JavaScript can read (localStorage, sessionStorage,
|
|
215
|
+
* non-httpOnly cookies): any XSS on the page reads the credential. For
|
|
216
|
+
* browser apps prefer a backend-held credential with a short-lived token.
|
|
217
|
+
* The SDK itself never writes this value (or the apiAccessKey) to
|
|
218
|
+
* localStorage — it persists only authorization payloads and consumption
|
|
219
|
+
* counters, and the apiAccessKey appears in storage key names only as a
|
|
220
|
+
* non-reversible hash.
|
|
221
|
+
*
|
|
130
222
|
* @param credential The credential to encode
|
|
131
223
|
* @returns A portable encoded string prefixed with SIDUB_LIC_
|
|
132
224
|
*/
|
|
@@ -221,7 +313,8 @@ function hasValidCryptoConfig(credential) {
|
|
|
221
313
|
|
|
222
314
|
/**
|
|
223
315
|
* Gets the normalized feature key from a feature object.
|
|
224
|
-
* Handles both
|
|
316
|
+
* Handles both the SDK model (featureKey) and raw server (FeatureKey) naming.
|
|
317
|
+
* The legacy `featureId` alias was removed in 2.0 (MON-334 SR4).
|
|
225
318
|
*
|
|
226
319
|
* @param feature The feature object to extract the key from
|
|
227
320
|
* @returns The feature key, or empty string if not found
|
|
@@ -230,12 +323,12 @@ function getFeatureKey(feature) {
|
|
|
230
323
|
if (!feature) {
|
|
231
324
|
return '';
|
|
232
325
|
}
|
|
233
|
-
// Check both camelCase (
|
|
234
|
-
return feature.
|
|
326
|
+
// Check both camelCase (SDK model) and PascalCase (raw server) conventions
|
|
327
|
+
return feature.featureKey || feature.FeatureKey || '';
|
|
235
328
|
}
|
|
236
329
|
/**
|
|
237
330
|
* Checks if a feature matches the specified feature key.
|
|
238
|
-
* Handles both
|
|
331
|
+
* Handles both the SDK model (featureKey) and raw server (FeatureKey) naming.
|
|
239
332
|
*
|
|
240
333
|
* @param feature The feature to check
|
|
241
334
|
* @param featureKey The key to match against
|
|
@@ -403,7 +496,7 @@ class RateLimitAssertion extends LicenseAssertion {
|
|
|
403
496
|
this.featureKey = featureKeyOrFeature;
|
|
404
497
|
}
|
|
405
498
|
else {
|
|
406
|
-
this.featureKey = featureKeyOrFeature.
|
|
499
|
+
this.featureKey = featureKeyOrFeature.featureKey;
|
|
407
500
|
}
|
|
408
501
|
}
|
|
409
502
|
/**
|
|
@@ -746,86 +839,17 @@ class SignatureValidator {
|
|
|
746
839
|
await this.cryptoService.initialize();
|
|
747
840
|
this.initialized = true;
|
|
748
841
|
}
|
|
749
|
-
/**
|
|
750
|
-
* Validates the signature of a license authorization using a PARSED server response.
|
|
751
|
-
*
|
|
752
|
-
* The validation process:
|
|
753
|
-
* 1. Extracts the signature from __sidub_entitySignature
|
|
754
|
-
* 2. Re-serializes the raw response data (excluding signature field)
|
|
755
|
-
* 3. Verifies the signature using the configured public key
|
|
756
|
-
*
|
|
757
|
-
* @deprecated Use {@link validateResponseText} instead (MON-205). Step 2 re-serializes an
|
|
758
|
-
* already-parsed object, and `JSON.stringify` does not round-trip byte-identically —
|
|
759
|
-
* property order, number formatting and string escaping can each differ from what the
|
|
760
|
-
* server actually signed, so a genuine authorization can fail verification.
|
|
761
|
-
* `validateResponseText` verifies the original response bytes and is what
|
|
762
|
-
* `LicensingClient` now uses; this method is retained only for compatibility.
|
|
763
|
-
*
|
|
764
|
-
* @param rawResponse The raw server response (before client mapping)
|
|
765
|
-
* @param options Validation options
|
|
766
|
-
* @returns Validation result
|
|
767
|
-
* @throws {CryptoError} If throwOnInvalid is true and validation fails
|
|
768
|
-
*/
|
|
769
|
-
async validateRawResponse(rawResponse, options = {}) {
|
|
770
|
-
const { throwOnInvalid = true, required = this.isConfigured } = options;
|
|
771
|
-
// If no crypto service configured, skip validation
|
|
772
|
-
if (!this.cryptoService) {
|
|
773
|
-
if (required) {
|
|
774
|
-
const error = 'Signature validation is required but no cryptography configuration was provided.';
|
|
775
|
-
if (throwOnInvalid) {
|
|
776
|
-
throw new CryptoError(error, 'NOT_INITIALIZED');
|
|
777
|
-
}
|
|
778
|
-
return { isValid: false, skipped: false, error };
|
|
779
|
-
}
|
|
780
|
-
return { isValid: true, skipped: true };
|
|
781
|
-
}
|
|
782
|
-
// Ensure initialized
|
|
783
|
-
await this.initialize();
|
|
784
|
-
// Check if response has a signature
|
|
785
|
-
const signature = rawResponse.__sidub_entitySignature;
|
|
786
|
-
if (!signature) {
|
|
787
|
-
if (required) {
|
|
788
|
-
const error = 'Authorization does not contain a signature.';
|
|
789
|
-
if (throwOnInvalid) {
|
|
790
|
-
throw new CryptoError(error, 'INVALID_SIGNATURE');
|
|
791
|
-
}
|
|
792
|
-
return { isValid: false, skipped: false, error };
|
|
793
|
-
}
|
|
794
|
-
return { isValid: true, skipped: true };
|
|
795
|
-
}
|
|
796
|
-
try {
|
|
797
|
-
// Serialize the raw response data for verification (excluding signature)
|
|
798
|
-
const dataToVerify = this.serializeRawForVerification(rawResponse);
|
|
799
|
-
const dataBytes = new TextEncoder().encode(dataToVerify);
|
|
800
|
-
const isValid = await this.cryptoService.verifySignature(dataBytes.buffer, signature);
|
|
801
|
-
if (!isValid) {
|
|
802
|
-
const error = 'Authorization signature verification failed. The authorization may have been tampered with.';
|
|
803
|
-
if (throwOnInvalid) {
|
|
804
|
-
throw new CryptoError(error, 'INVALID_SIGNATURE');
|
|
805
|
-
}
|
|
806
|
-
return { isValid: false, skipped: false, error };
|
|
807
|
-
}
|
|
808
|
-
return { isValid: true, skipped: false };
|
|
809
|
-
}
|
|
810
|
-
catch (error) {
|
|
811
|
-
if (error instanceof CryptoError) {
|
|
812
|
-
throw error;
|
|
813
|
-
}
|
|
814
|
-
const message = `Signature validation error: ${error instanceof Error ? error.message : 'Unknown error'}`;
|
|
815
|
-
if (throwOnInvalid) {
|
|
816
|
-
throw new CryptoError(message, 'VERIFICATION_FAILED');
|
|
817
|
-
}
|
|
818
|
-
return { isValid: false, skipped: false, error: message };
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
842
|
/**
|
|
822
843
|
* Validates the signature of a license authorization against the exact raw
|
|
823
844
|
* response text (MON-205).
|
|
824
845
|
*
|
|
825
|
-
*
|
|
826
|
-
*
|
|
827
|
-
*
|
|
828
|
-
*
|
|
846
|
+
* This is the ONLY validation entry point. The deprecated
|
|
847
|
+
* `validateRawResponse` — which re-serialized a parsed object and was
|
|
848
|
+
* therefore sensitive to property-order and formatting differences, letting
|
|
849
|
+
* genuine authorizations fail verification — was removed in 2.0 (MON-334
|
|
850
|
+
* react-10). This method verifies against the original bytes: the
|
|
851
|
+
* `__sidub_entitySignature` member is removed textually and the remaining
|
|
852
|
+
* text is what the server actually signed.
|
|
829
853
|
*
|
|
830
854
|
* @param rawText The exact response body text as received from the server.
|
|
831
855
|
* @param options Validation options (same required/throwOnInvalid semantics).
|
|
@@ -881,16 +905,6 @@ class SignatureValidator {
|
|
|
881
905
|
return { isValid: false, skipped: false, error: message };
|
|
882
906
|
}
|
|
883
907
|
}
|
|
884
|
-
/**
|
|
885
|
-
* Serializes the raw server response for signature verification.
|
|
886
|
-
* Removes the __sidub_entitySignature field and serializes to JSON.
|
|
887
|
-
*/
|
|
888
|
-
serializeRawForVerification(rawResponse) {
|
|
889
|
-
// Create a copy without the signature field
|
|
890
|
-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
891
|
-
const { __sidub_entitySignature, ...dataWithoutSignature } = rawResponse;
|
|
892
|
-
return JSON.stringify(dataWithoutSignature);
|
|
893
|
-
}
|
|
894
908
|
}
|
|
895
909
|
|
|
896
910
|
/**
|
|
@@ -1028,13 +1042,18 @@ class AuthorizationCache {
|
|
|
1028
1042
|
* @param storagePrefix Optional localStorage key prefix for persisting entries across page refreshes.
|
|
1029
1043
|
* @param graceMs Stale-serve grace window in milliseconds beyond the freshness deadline.
|
|
1030
1044
|
* Defaults to 24h; capped at 7 days.
|
|
1045
|
+
* @param freshTtlMs Upper bound in milliseconds on how long an entry stays fresh
|
|
1046
|
+
* before revalidation (MON-334 react-09). Defaults to 24h (the historical cap);
|
|
1047
|
+
* lower values tighten the revocation window at the cost of more server calls.
|
|
1048
|
+
* Capped at 24h; 0 means every read revalidates.
|
|
1031
1049
|
*/
|
|
1032
|
-
constructor(maxSize = 100, storagePrefix, graceMs) {
|
|
1050
|
+
constructor(maxSize = 100, storagePrefix, graceMs, freshTtlMs) {
|
|
1033
1051
|
this.cache = new Map();
|
|
1034
1052
|
this.unsubscribe = null;
|
|
1035
1053
|
this.maxSize = maxSize;
|
|
1036
1054
|
this.storagePrefix = storagePrefix ?? null;
|
|
1037
1055
|
this.graceMs = Math.min(Math.max(graceMs ?? DEFAULT_GRACE_MS, 0), MAX_GRACE_MS);
|
|
1056
|
+
this.freshTtlMs = Math.min(Math.max(freshTtlMs ?? MAX_FRESH_TTL_MS, 0), MAX_FRESH_TTL_MS);
|
|
1038
1057
|
// Restore persisted entries, dropping ones past their stale window
|
|
1039
1058
|
if (this.storagePrefix) {
|
|
1040
1059
|
const keys = listKeys(this.storagePrefix);
|
|
@@ -1134,10 +1153,16 @@ class AuthorizationCache {
|
|
|
1134
1153
|
* Stores an authorization in the cache.
|
|
1135
1154
|
* Evicts the oldest entry if max size is reached.
|
|
1136
1155
|
*
|
|
1137
|
-
* Freshness is min(license expiry, now +
|
|
1156
|
+
* Freshness is min(license expiry, now + freshTtlMs) — default 1h when the
|
|
1138
1157
|
* authorization has no expiry. The stale window extends freshness by graceMs
|
|
1139
1158
|
* but never past the license's own expiry.
|
|
1140
1159
|
*
|
|
1160
|
+
* Revocation trade-off (MON-334 react-09): a revoked/denied license keeps
|
|
1161
|
+
* authorizing from cache until its freshness deadline — up to freshTtlMs
|
|
1162
|
+
* (default 24h) — plus, on transient outages only, the stale-serve grace.
|
|
1163
|
+
* Tighten freshTtlMs to shrink that window; denials (401/403/404) are never
|
|
1164
|
+
* stale-served.
|
|
1165
|
+
*
|
|
1141
1166
|
* @param licenseId The license identifier.
|
|
1142
1167
|
* @param serviceKeyId The service key identifier.
|
|
1143
1168
|
* @param authorization The license authorization to cache.
|
|
@@ -1147,7 +1172,8 @@ class AuthorizationCache {
|
|
|
1147
1172
|
const key = this.buildKey(licenseId, serviceKeyId, apiKey);
|
|
1148
1173
|
const now = Date.now();
|
|
1149
1174
|
const hardExpiry = authorization.expiresAt ? authorization.expiresAt.getTime() : undefined;
|
|
1150
|
-
const
|
|
1175
|
+
const noExpiryTtl = Math.min(DEFAULT_TTL_MS, this.freshTtlMs);
|
|
1176
|
+
const expiresAt = Math.min(hardExpiry ?? (now + noExpiryTtl), now + this.freshTtlMs);
|
|
1151
1177
|
const staleUntil = Math.min(expiresAt + this.graceMs, hardExpiry ?? Number.MAX_SAFE_INTEGER);
|
|
1152
1178
|
// If key already exists, delete first (so re-insert goes to end)
|
|
1153
1179
|
if (this.cache.has(key)) {
|
|
@@ -1414,6 +1440,8 @@ class LicensingClient {
|
|
|
1414
1440
|
this.disposables = [];
|
|
1415
1441
|
this.contextResolved = false;
|
|
1416
1442
|
this.contextResolutionPromise = null;
|
|
1443
|
+
// MON-334 react-03: the unsigned-mode warning fires at most once per client.
|
|
1444
|
+
this.signaturePostureWarned = false;
|
|
1417
1445
|
// MON-203: single-flight authorization fetches keyed on licenseId+serviceKeyId+apiKey.
|
|
1418
1446
|
this.inFlightAuthorizations = new Map();
|
|
1419
1447
|
// Decode credential if provided
|
|
@@ -1437,7 +1465,7 @@ class LicensingClient {
|
|
|
1437
1465
|
// Initialize authorization cache (enabled by default) with localStorage persistence
|
|
1438
1466
|
this.cacheEnabled = config.cacheEnabled !== false;
|
|
1439
1467
|
this.authorizationCache = this.cacheEnabled
|
|
1440
|
-
? new AuthorizationCache(config.cacheMaxSize ?? 100, LicensingClient.AUTH_STORAGE_PREFIX, config.staleGraceMs)
|
|
1468
|
+
? new AuthorizationCache(config.cacheMaxSize ?? 100, LicensingClient.AUTH_STORAGE_PREFIX, config.staleGraceMs, config.cacheFreshTtlMs)
|
|
1441
1469
|
: null;
|
|
1442
1470
|
// Register cross-tab sync for authorization cache
|
|
1443
1471
|
if (this.authorizationCache) {
|
|
@@ -1550,6 +1578,25 @@ class LicensingClient {
|
|
|
1550
1578
|
throw error;
|
|
1551
1579
|
}
|
|
1552
1580
|
}
|
|
1581
|
+
/**
|
|
1582
|
+
* Applies the gateway credential headers to an outgoing request. Mirrors the
|
|
1583
|
+
* .NET SDK's GatewayAuthHeaders/GatewayAuthHeaderNames exactly: the ApiAccessKey
|
|
1584
|
+
* is sent as the branded `dub-apiKey` header (the gateway's configured
|
|
1585
|
+
* subscription-key name) and additionally as the platform-default
|
|
1586
|
+
* `Ocp-Apim-Subscription-Key` header, so the SDK keeps authenticating against a
|
|
1587
|
+
* gateway provisioned with the Azure default name (the MON-42 → MON-52
|
|
1588
|
+
* regression class). The gateway strips the redundant copy before forwarding.
|
|
1589
|
+
*
|
|
1590
|
+
* Header-only by design (MON-66 parity, MON-334 SR3): the key is never placed
|
|
1591
|
+
* in the URL query string, where it would leak into browser history, Referer
|
|
1592
|
+
* headers on redirects, and CDN/proxy/gateway access logs.
|
|
1593
|
+
*/
|
|
1594
|
+
static applyAuthHeaders(headers, apiKey) {
|
|
1595
|
+
if (!apiKey)
|
|
1596
|
+
return;
|
|
1597
|
+
headers['dub-apiKey'] = apiKey;
|
|
1598
|
+
headers['Ocp-Apim-Subscription-Key'] = apiKey;
|
|
1599
|
+
}
|
|
1553
1600
|
/**
|
|
1554
1601
|
* Builds the cryptography configuration from the resolved config values
|
|
1555
1602
|
*/
|
|
@@ -1569,6 +1616,38 @@ class LicensingClient {
|
|
|
1569
1616
|
get isSignatureValidationEnabled() {
|
|
1570
1617
|
return this.validateSignatures && this.signatureValidator.isConfigured;
|
|
1571
1618
|
}
|
|
1619
|
+
/**
|
|
1620
|
+
* Makes the signature-validation posture explicit (MON-334 react-03).
|
|
1621
|
+
* Runs after context resolution, before any authorization is accepted:
|
|
1622
|
+
*
|
|
1623
|
+
* - `validateSignatures: true` with no verification key is a contradiction —
|
|
1624
|
+
* the integrator demanded validation that cannot happen. Previously this
|
|
1625
|
+
* silently skipped; it now throws {@link LicensingConfigurationException}.
|
|
1626
|
+
* - Validation silently OFF (no keys configured, `validateSignatures` unset)
|
|
1627
|
+
* emits one prominent console warning per client. Setting
|
|
1628
|
+
* `validateSignatures: false` explicitly acknowledges unsigned mode and
|
|
1629
|
+
* suppresses the warning.
|
|
1630
|
+
*
|
|
1631
|
+
* Check {@link isSignatureValidationEnabled} at runtime to observe the
|
|
1632
|
+
* effective posture.
|
|
1633
|
+
*/
|
|
1634
|
+
assertSignaturePosture() {
|
|
1635
|
+
if (this.validateSignatures && !this.signatureValidator.isConfigured) {
|
|
1636
|
+
throw new LicensingConfigurationException('Signature validation was requested (validateSignatures: true) but no verification key is configured. ' +
|
|
1637
|
+
'Provide serviceKeyId and serviceKeyPublicMember (or an encodedCredential that carries them), ' +
|
|
1638
|
+
'or set validateSignatures: false explicitly to accept unsigned authorizations.');
|
|
1639
|
+
}
|
|
1640
|
+
if (!this.validateSignatures &&
|
|
1641
|
+
this.config.validateSignatures === undefined &&
|
|
1642
|
+
!this.signaturePostureWarned) {
|
|
1643
|
+
this.signaturePostureWarned = true;
|
|
1644
|
+
console.warn('[Sidub.Licensing] Signature validation is DISABLED: no serviceKeyId/serviceKeyPublicMember is configured, ' +
|
|
1645
|
+
'so license authorizations are accepted without cryptographic verification. ' +
|
|
1646
|
+
'Configure an encodedCredential (which carries the verification key) to enable validation, ' +
|
|
1647
|
+
'or set validateSignatures: false explicitly to acknowledge unsigned mode and silence this warning. ' +
|
|
1648
|
+
'Inspect client.isSignatureValidationEnabled to observe the effective posture.');
|
|
1649
|
+
}
|
|
1650
|
+
}
|
|
1572
1651
|
/**
|
|
1573
1652
|
* Gets the configured license ID (from credential or explicit config)
|
|
1574
1653
|
*/
|
|
@@ -1597,6 +1676,9 @@ class LicensingClient {
|
|
|
1597
1676
|
*/
|
|
1598
1677
|
async getAuthorization(licenseId, apiKey) {
|
|
1599
1678
|
await this.ensureContextResolved();
|
|
1679
|
+
// react-03: surface the signature posture before any authorization —
|
|
1680
|
+
// cached or fetched — is accepted.
|
|
1681
|
+
this.assertSignaturePosture();
|
|
1600
1682
|
// Use provided licenseId or fall back to configured value
|
|
1601
1683
|
const effectiveLicenseId = licenseId || this.config.licenseId;
|
|
1602
1684
|
if (!effectiveLicenseId) {
|
|
@@ -1657,22 +1739,6 @@ class LicensingClient {
|
|
|
1657
1739
|
return true;
|
|
1658
1740
|
return status === 408 || status === 429 || status >= 500;
|
|
1659
1741
|
}
|
|
1660
|
-
/**
|
|
1661
|
-
* Parses a Retry-After header value (delta-seconds or HTTP-date) into milliseconds.
|
|
1662
|
-
*/
|
|
1663
|
-
static parseRetryAfter(headerValue) {
|
|
1664
|
-
if (!headerValue)
|
|
1665
|
-
return undefined;
|
|
1666
|
-
const seconds = Number(headerValue);
|
|
1667
|
-
if (!Number.isNaN(seconds) && seconds >= 0) {
|
|
1668
|
-
return seconds * 1000;
|
|
1669
|
-
}
|
|
1670
|
-
const date = Date.parse(headerValue);
|
|
1671
|
-
if (!Number.isNaN(date)) {
|
|
1672
|
-
return Math.max(0, date - Date.now());
|
|
1673
|
-
}
|
|
1674
|
-
return undefined;
|
|
1675
|
-
}
|
|
1676
1742
|
/**
|
|
1677
1743
|
* Retries transient failures with exponential backoff and jitter, honoring
|
|
1678
1744
|
* Retry-After when the server supplied one. config.timeout is the overall
|
|
@@ -1712,22 +1778,17 @@ class LicensingClient {
|
|
|
1712
1778
|
const headers = {
|
|
1713
1779
|
'Content-Type': 'application/json',
|
|
1714
1780
|
};
|
|
1715
|
-
//
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
}
|
|
1781
|
+
// Credential travels header-only (dub-apiKey + Ocp-Apim-Subscription-Key) —
|
|
1782
|
+
// never in the URL. The dead dub-issuerKey header was removed in 2.0 (MON-334).
|
|
1783
|
+
LicensingClient.applyAuthHeaders(headers, effectiveApiKey);
|
|
1719
1784
|
const parameters = {
|
|
1720
1785
|
RequestId: this.generateRequestId(),
|
|
1721
1786
|
LicenseId: effectiveLicenseId
|
|
1722
1787
|
};
|
|
1723
|
-
// Add query parameters for API key if provided
|
|
1724
|
-
const queryParams = effectiveApiKey
|
|
1725
|
-
? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
|
|
1726
|
-
: '';
|
|
1727
1788
|
try {
|
|
1728
1789
|
const controller = new AbortController();
|
|
1729
1790
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
1730
|
-
const response = await fetch(
|
|
1791
|
+
const response = await fetch(url, {
|
|
1731
1792
|
method: 'POST',
|
|
1732
1793
|
headers,
|
|
1733
1794
|
body: JSON.stringify(parameters),
|
|
@@ -1735,10 +1796,9 @@ class LicensingClient {
|
|
|
1735
1796
|
});
|
|
1736
1797
|
clearTimeout(timeoutId);
|
|
1737
1798
|
if (!response.ok) {
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
throw licensingError;
|
|
1799
|
+
// SR2: parse the shared ApiError body + X-Correlation-Id header —
|
|
1800
|
+
// the machine code and correlation id must never be dropped.
|
|
1801
|
+
throw await LicensingError.fromHttpResponse('Authorization', response);
|
|
1742
1802
|
}
|
|
1743
1803
|
// MON-205: capture the exact raw bytes; parse only after validation.
|
|
1744
1804
|
const rawText = await response.text();
|
|
@@ -1774,7 +1834,7 @@ class LicensingClient {
|
|
|
1774
1834
|
const rf = f;
|
|
1775
1835
|
const ss = rf.sampleSeconds ?? rf.SampleSeconds;
|
|
1776
1836
|
if (ss !== undefined && typeof ss === 'number') {
|
|
1777
|
-
this.featureMetadata.set(`${authorization.licenseId}.${f.
|
|
1837
|
+
this.featureMetadata.set(`${authorization.licenseId}.${f.featureKey}`, ss);
|
|
1778
1838
|
metadataChanged = true;
|
|
1779
1839
|
}
|
|
1780
1840
|
}
|
|
@@ -1816,10 +1876,12 @@ class LicensingClient {
|
|
|
1816
1876
|
}
|
|
1817
1877
|
return serverFeatures.map((f) => {
|
|
1818
1878
|
const sf = f;
|
|
1819
|
-
// Normalize
|
|
1820
|
-
|
|
1879
|
+
// Normalize featureKey from the PascalCase server wire field (SR4:
|
|
1880
|
+
// wire names frozen — the server sends FeatureKey; the SDK model
|
|
1881
|
+
// exposes camelCase featureKey).
|
|
1882
|
+
const featureKey = String(sf.FeatureKey ?? sf.featureKey ?? '');
|
|
1821
1883
|
// Build normalized feature with camelCase properties only
|
|
1822
|
-
const feature = {
|
|
1884
|
+
const feature = { featureKey };
|
|
1823
1885
|
// ServiceAccessLevel (ServiceAccessLicenseFeature)
|
|
1824
1886
|
const rawAccess = sf.ServiceAccessLevel ?? sf.serviceAccessLevel;
|
|
1825
1887
|
if (rawAccess !== undefined) {
|
|
@@ -1899,14 +1961,14 @@ class LicensingClient {
|
|
|
1899
1961
|
}
|
|
1900
1962
|
const features = auth.features.map((f) => {
|
|
1901
1963
|
const sf = f;
|
|
1902
|
-
const featureKey = String(sf.
|
|
1964
|
+
const featureKey = String(sf.featureKey ?? '');
|
|
1903
1965
|
const displayName = String(sf.displayName ?? sf.featureName ?? featureKey);
|
|
1904
1966
|
return { featureKey, displayName };
|
|
1905
1967
|
});
|
|
1906
1968
|
const consumption = [];
|
|
1907
1969
|
for (const f of auth.features) {
|
|
1908
1970
|
const sf = f;
|
|
1909
|
-
const featureKey = String(sf.
|
|
1971
|
+
const featureKey = String(sf.featureKey ?? '');
|
|
1910
1972
|
const rateLimit = sf.rateLimit;
|
|
1911
1973
|
const sampleSeconds = sf.sampleSeconds;
|
|
1912
1974
|
if (rateLimit === undefined || sampleSeconds === undefined)
|
|
@@ -1987,6 +2049,8 @@ class LicensingClient {
|
|
|
1987
2049
|
// Reset context resolution so ensureContextResolved() re-runs with new config
|
|
1988
2050
|
this.contextResolved = false;
|
|
1989
2051
|
this.contextResolutionPromise = null;
|
|
2052
|
+
// New credential epoch — the signature posture may have changed (react-03)
|
|
2053
|
+
this.signaturePostureWarned = false;
|
|
1990
2054
|
// Pre-refresh in-flight authorization fetches must not satisfy post-refresh calls
|
|
1991
2055
|
this.inFlightAuthorizations.clear();
|
|
1992
2056
|
}
|
|
@@ -2078,16 +2142,11 @@ class LicensingClient {
|
|
|
2078
2142
|
const headers = {
|
|
2079
2143
|
'Content-Type': 'application/json',
|
|
2080
2144
|
};
|
|
2081
|
-
|
|
2082
|
-
headers['dub-apiKey'] = apiKey;
|
|
2083
|
-
}
|
|
2084
|
-
const queryParams = apiKey
|
|
2085
|
-
? `?subscription-key=${encodeURIComponent(apiKey)}`
|
|
2086
|
-
: '';
|
|
2145
|
+
LicensingClient.applyAuthHeaders(headers, apiKey);
|
|
2087
2146
|
try {
|
|
2088
2147
|
const controller = new AbortController();
|
|
2089
2148
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
2090
|
-
const response = await fetch(
|
|
2149
|
+
const response = await fetch(url, {
|
|
2091
2150
|
method: 'POST',
|
|
2092
2151
|
headers,
|
|
2093
2152
|
body: JSON.stringify({
|
|
@@ -2099,7 +2158,7 @@ class LicensingClient {
|
|
|
2099
2158
|
IsBillable: isBillable,
|
|
2100
2159
|
LicenseOperation: {
|
|
2101
2160
|
LicenseFeature: {
|
|
2102
|
-
FeatureKey: operation.feature.
|
|
2161
|
+
FeatureKey: operation.feature.featureKey
|
|
2103
2162
|
},
|
|
2104
2163
|
Amount: operation.quantity ?? 1
|
|
2105
2164
|
}
|
|
@@ -2108,11 +2167,10 @@ class LicensingClient {
|
|
|
2108
2167
|
});
|
|
2109
2168
|
clearTimeout(timeoutId);
|
|
2110
2169
|
if (!response.ok) {
|
|
2111
|
-
|
|
2112
|
-
throw new LicensingError(`Consumption report failed: ${response.statusText}`, response.status, errorText);
|
|
2170
|
+
throw await LicensingError.fromHttpResponse('Consumption report', response);
|
|
2113
2171
|
}
|
|
2114
2172
|
// Update local feature state after successful server report (auto-create if needed)
|
|
2115
|
-
const stateKey = `${operation.licenseId}.${operation.feature.
|
|
2173
|
+
const stateKey = `${operation.licenseId}.${operation.feature.featureKey}`;
|
|
2116
2174
|
let state = this.featureStates.get(stateKey);
|
|
2117
2175
|
if (!state) {
|
|
2118
2176
|
const sampleSeconds = this.featureMetadata.get(stateKey) ?? 3600;
|
|
@@ -2159,12 +2217,7 @@ class LicensingClient {
|
|
|
2159
2217
|
const headers = {
|
|
2160
2218
|
'Content-Type': 'application/json',
|
|
2161
2219
|
};
|
|
2162
|
-
|
|
2163
|
-
headers['dub-apiKey'] = effectiveApiKey;
|
|
2164
|
-
}
|
|
2165
|
-
const queryParams = effectiveApiKey
|
|
2166
|
-
? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
|
|
2167
|
-
: '';
|
|
2220
|
+
LicensingClient.applyAuthHeaders(headers, effectiveApiKey);
|
|
2168
2221
|
const body = JSON.stringify({
|
|
2169
2222
|
LicenseId: effectiveLicenseId,
|
|
2170
2223
|
// MON-187: per-call idempotency key — the server dedups on SubmissionId.
|
|
@@ -2182,7 +2235,7 @@ class LicensingClient {
|
|
|
2182
2235
|
try {
|
|
2183
2236
|
const controller = new AbortController();
|
|
2184
2237
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
2185
|
-
const response = await fetch(
|
|
2238
|
+
const response = await fetch(url, {
|
|
2186
2239
|
method: 'POST',
|
|
2187
2240
|
headers,
|
|
2188
2241
|
body,
|
|
@@ -2190,8 +2243,7 @@ class LicensingClient {
|
|
|
2190
2243
|
});
|
|
2191
2244
|
clearTimeout(timeoutId);
|
|
2192
2245
|
if (!response.ok) {
|
|
2193
|
-
|
|
2194
|
-
throw new LicensingError(`Access check report failed: ${response.statusText}`, response.status, errorText);
|
|
2246
|
+
throw await LicensingError.fromHttpResponse('Access check report', response);
|
|
2195
2247
|
}
|
|
2196
2248
|
}
|
|
2197
2249
|
catch (error) {
|
|
@@ -2224,12 +2276,7 @@ class LicensingClient {
|
|
|
2224
2276
|
const headers = {
|
|
2225
2277
|
'Content-Type': 'application/json',
|
|
2226
2278
|
};
|
|
2227
|
-
|
|
2228
|
-
headers['dub-apiKey'] = effectiveApiKey;
|
|
2229
|
-
}
|
|
2230
|
-
const queryParams = effectiveApiKey
|
|
2231
|
-
? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
|
|
2232
|
-
: '';
|
|
2279
|
+
LicensingClient.applyAuthHeaders(headers, effectiveApiKey);
|
|
2233
2280
|
// Send PascalCase body matching CreateCheckoutSessionParameters.cs
|
|
2234
2281
|
const body = JSON.stringify({
|
|
2235
2282
|
RequestId: this.generateRequestId(),
|
|
@@ -2243,7 +2290,7 @@ class LicensingClient {
|
|
|
2243
2290
|
try {
|
|
2244
2291
|
const controller = new AbortController();
|
|
2245
2292
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
2246
|
-
const response = await fetch(
|
|
2293
|
+
const response = await fetch(url, {
|
|
2247
2294
|
method: 'POST',
|
|
2248
2295
|
headers,
|
|
2249
2296
|
body,
|
|
@@ -2251,8 +2298,7 @@ class LicensingClient {
|
|
|
2251
2298
|
});
|
|
2252
2299
|
clearTimeout(timeoutId);
|
|
2253
2300
|
if (!response.ok) {
|
|
2254
|
-
|
|
2255
|
-
throw new LicensingError(`Checkout session creation failed: ${response.statusText}`, response.status, errorText);
|
|
2301
|
+
throw await LicensingError.fromHttpResponse('Checkout session creation', response);
|
|
2256
2302
|
}
|
|
2257
2303
|
const data = await response.json();
|
|
2258
2304
|
// Map PascalCase server response to camelCase DTO
|
|
@@ -2291,12 +2337,7 @@ class LicensingClient {
|
|
|
2291
2337
|
const headers = {
|
|
2292
2338
|
'Content-Type': 'application/json',
|
|
2293
2339
|
};
|
|
2294
|
-
|
|
2295
|
-
headers['dub-apiKey'] = effectiveApiKey;
|
|
2296
|
-
}
|
|
2297
|
-
const queryParams = effectiveApiKey
|
|
2298
|
-
? `?subscription-key=${encodeURIComponent(effectiveApiKey)}`
|
|
2299
|
-
: '';
|
|
2340
|
+
LicensingClient.applyAuthHeaders(headers, effectiveApiKey);
|
|
2300
2341
|
// Send PascalCase body matching GetCheckoutSessionResultParameters.cs
|
|
2301
2342
|
const body = JSON.stringify({
|
|
2302
2343
|
SessionId: sessionId
|
|
@@ -2304,7 +2345,7 @@ class LicensingClient {
|
|
|
2304
2345
|
try {
|
|
2305
2346
|
const controller = new AbortController();
|
|
2306
2347
|
const timeoutId = setTimeout(() => controller.abort(), this.config.timeout);
|
|
2307
|
-
const response = await fetch(
|
|
2348
|
+
const response = await fetch(url, {
|
|
2308
2349
|
method: 'POST',
|
|
2309
2350
|
headers,
|
|
2310
2351
|
body,
|
|
@@ -2312,8 +2353,7 @@ class LicensingClient {
|
|
|
2312
2353
|
});
|
|
2313
2354
|
clearTimeout(timeoutId);
|
|
2314
2355
|
if (!response.ok) {
|
|
2315
|
-
|
|
2316
|
-
throw new LicensingError(`Checkout result retrieval failed: ${response.statusText}`, response.status, errorText);
|
|
2356
|
+
throw await LicensingError.fromHttpResponse('Checkout result retrieval', response);
|
|
2317
2357
|
}
|
|
2318
2358
|
const data = await response.json();
|
|
2319
2359
|
// Map PascalCase server response to camelCase DTO
|
|
@@ -2783,15 +2823,10 @@ exports.hasFeature = hasFeature;
|
|
|
2783
2823
|
exports.hasValidCryptoConfig = hasValidCryptoConfig;
|
|
2784
2824
|
exports.licensingContextFromEncodedString = licensingContextFromEncodedString;
|
|
2785
2825
|
exports.licensingContextToEncodedString = licensingContextToEncodedString;
|
|
2786
|
-
exports.listKeys = listKeys;
|
|
2787
|
-
exports.load = load;
|
|
2788
2826
|
exports.matchesFeatureKey = matchesFeatureKey;
|
|
2789
2827
|
exports.normalizeBillingIntervalUnit = normalizeBillingIntervalUnit;
|
|
2790
2828
|
exports.normalizeLicenseClassificationType = normalizeLicenseClassificationType;
|
|
2791
2829
|
exports.normalizeServiceAccessLevel = normalizeServiceAccessLevel;
|
|
2792
|
-
exports.onStorageChange = onStorageChange;
|
|
2793
|
-
exports.remove = remove;
|
|
2794
|
-
exports.save = save;
|
|
2795
2830
|
exports.tryDecodeCredential = tryDecodeCredential;
|
|
2796
2831
|
exports.useLicensingContext = useLicensingContext;
|
|
2797
2832
|
exports.useLicensingContextValue = useLicensingContextValue;
|