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