@angular-helpers/security 21.2.0 → 21.4.1

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,6 +1,8 @@
1
1
  import * as i0 from '@angular/core';
2
- import { inject, DestroyRef, Injectable, PLATFORM_ID, InjectionToken, makeEnvironmentProviders } from '@angular/core';
3
- import { isPlatformBrowser } from '@angular/common';
2
+ import { inject, DestroyRef, Injectable, PLATFORM_ID, InjectionToken, signal, computed, NgZone, Injector, makeEnvironmentProviders } from '@angular/core';
3
+ import { isPlatformBrowser, DOCUMENT } from '@angular/common';
4
+ import { Observable, Subject, fromEvent, merge } from 'rxjs';
5
+ import { throttleTime } from 'rxjs/operators';
4
6
 
5
7
  /**
6
8
  * Security service for regular expressions that prevents ReDoS
@@ -64,10 +66,10 @@ class RegexSecurityService {
64
66
  message: 'Nested quantifiers (catastrophic backtracking)',
65
67
  },
66
68
  { pattern: /\+\+/, risk: 'high', message: 'Nested plus quantifiers' },
67
- { pattern: /\(\?\=/, risk: 'medium', message: 'Lookahead assertions' },
68
- { pattern: /\(\?\!/, risk: 'medium', message: 'Negative lookahead' },
69
- { pattern: /\(\?\:/, risk: 'low', message: 'Non-capturing groups' },
70
- { pattern: /\(\?\</, risk: 'high', message: 'Lookbehind assertions' },
69
+ { pattern: /\(\?=/, risk: 'medium', message: 'Lookahead assertions' },
70
+ { pattern: /\(\?!/, risk: 'medium', message: 'Negative lookahead' },
71
+ { pattern: /\(\?:/, risk: 'low', message: 'Non-capturing groups' },
72
+ { pattern: /\(\?</, risk: 'high', message: 'Lookbehind assertions' },
71
73
  { pattern: /\(\?\(\?\)/, risk: 'critical', message: 'Recursive patterns' },
72
74
  { pattern: /(\{(\d+,)?\d+\})/, risk: 'medium', message: 'Quantified repetition' },
73
75
  {
@@ -247,9 +249,9 @@ class RegexSecurityService {
247
249
  complexity += (pattern.match(/\+\+/g) || []).length * 5;
248
250
  complexity += (pattern.match(/\?\?/g) || []).length * 3;
249
251
  // Lookaheads/lookbehinds
250
- complexity += (pattern.match(/\(\?\=/g) || []).length * 2;
251
- complexity += (pattern.match(/\(\?\!/g) || []).length * 2;
252
- complexity += (pattern.match(/\(\?\</g) || []).length * 3;
252
+ complexity += (pattern.match(/\(\?=/g) || []).length * 2;
253
+ complexity += (pattern.match(/\(\?!/g) || []).length * 2;
254
+ complexity += (pattern.match(/\(\?</g) || []).length * 3;
253
255
  // Nested groups
254
256
  const openParens = (pattern.match(/\(/g) || []).length;
255
257
  complexity += openParens * 0.5;
@@ -725,7 +727,185 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImpor
725
727
  type: Injectable
726
728
  }], ctorParameters: () => [] });
727
729
 
728
- const SANITIZER_CONFIG = new InjectionToken('SANITIZER_CONFIG');
730
+ /**
731
+ * Internal validator helpers shared by both Reactive Forms validators
732
+ * (`SecurityValidators`) and Signal Forms validators (`@angular-helpers/security/signal-forms`).
733
+ *
734
+ * All helpers in this module are pure and side-effect free. Browser-only helpers
735
+ * (those using DOMParser) are clearly marked and throw when called outside a browser.
736
+ *
737
+ * This module is INTERNAL — not part of the public API surface.
738
+ */
739
+ const COMMON_PASSWORDS = new Set([
740
+ 'password',
741
+ '123456',
742
+ 'qwerty',
743
+ 'letmein',
744
+ 'admin',
745
+ 'welcome',
746
+ '111111',
747
+ 'abc123',
748
+ 'monkey',
749
+ 'master',
750
+ 'login',
751
+ 'pass',
752
+ ]);
753
+ const ALPHA_SEQUENCES = 'abcdefghijklmnopqrstuvwxyz';
754
+ const DIGIT_SEQUENCES = '0123456789';
755
+ const KEYBOARD_ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
756
+ const SCORE_LABELS = {
757
+ 0: 'very-weak',
758
+ 1: 'weak',
759
+ 2: 'fair',
760
+ 3: 'strong',
761
+ 4: 'very-strong',
762
+ };
763
+ /**
764
+ * Entropy-based password assessment. Pure function — safe to call outside Angular context.
765
+ *
766
+ * Thresholds (bits of entropy):
767
+ * - 0 (very-weak): < 28
768
+ * - 1 (weak): 28–35
769
+ * - 2 (fair): 36–49
770
+ * - 3 (strong): 50–69
771
+ * - 4 (very-strong): ≥ 70
772
+ */
773
+ function assessPasswordStrength(password) {
774
+ if (!password) {
775
+ return {
776
+ score: 0,
777
+ label: 'very-weak',
778
+ entropy: 0,
779
+ feedback: ['Password cannot be empty'],
780
+ };
781
+ }
782
+ const feedback = [];
783
+ const chars = [...password];
784
+ const length = chars.length;
785
+ const hasLower = /[a-z]/.test(password);
786
+ const hasUpper = /[A-Z]/.test(password);
787
+ const hasDigit = /[0-9]/.test(password);
788
+ const hasSymbol = /[!@#$%^&*()\-_=+[\]{}|;:'",.<>/?\\`~]/.test(password);
789
+ const hasExtended = chars.some((c) => c.codePointAt(0) > 127);
790
+ let poolSize = 0;
791
+ if (hasLower)
792
+ poolSize += 26;
793
+ if (hasUpper)
794
+ poolSize += 26;
795
+ if (hasDigit)
796
+ poolSize += 10;
797
+ if (hasSymbol)
798
+ poolSize += 32;
799
+ if (hasExtended)
800
+ poolSize += 64;
801
+ if (poolSize === 0)
802
+ poolSize = 26;
803
+ let entropy = length * Math.log2(poolSize);
804
+ const hasAlphaSeq = containsSequence(password.toLowerCase(), ALPHA_SEQUENCES, 3);
805
+ const hasDigitSeq = containsSequence(password, DIGIT_SEQUENCES, 3);
806
+ const hasRepeat = /(.)\1{2,}/.test(password);
807
+ const hasKeyboard = KEYBOARD_ROWS.some((row) => containsSequence(password.toLowerCase(), row, 4));
808
+ if (hasAlphaSeq || hasDigitSeq) {
809
+ entropy *= 0.8;
810
+ feedback.push('Avoid predictable sequences (abc, 123)');
811
+ }
812
+ if (hasRepeat) {
813
+ entropy *= 0.9;
814
+ feedback.push('Avoid repeated characters');
815
+ }
816
+ if (hasKeyboard) {
817
+ entropy *= 0.85;
818
+ feedback.push('Avoid keyboard patterns (qwerty, asdf)');
819
+ }
820
+ if (length < 8) {
821
+ feedback.push('Use at least 8 characters');
822
+ }
823
+ if (!hasUpper)
824
+ feedback.push('Add uppercase letters');
825
+ if (!hasDigit)
826
+ feedback.push('Add numbers');
827
+ if (!hasSymbol)
828
+ feedback.push('Add special characters');
829
+ const isCommon = COMMON_PASSWORDS.has(password.toLowerCase());
830
+ let score = entropyToScore(entropy);
831
+ if (isCommon) {
832
+ score = Math.min(score, 1);
833
+ if (!feedback.includes('Use at least 8 characters')) {
834
+ feedback.push('This is a commonly used password');
835
+ }
836
+ }
837
+ return {
838
+ score,
839
+ label: SCORE_LABELS[score],
840
+ entropy: Math.round(entropy * 100) / 100,
841
+ feedback,
842
+ };
843
+ }
844
+ function entropyToScore(entropy) {
845
+ if (entropy < 28)
846
+ return 0;
847
+ if (entropy < 36)
848
+ return 1;
849
+ if (entropy < 50)
850
+ return 2;
851
+ if (entropy < 70)
852
+ return 3;
853
+ return 4;
854
+ }
855
+ function containsSequence(input, sequence, minLength) {
856
+ for (let i = 0; i <= sequence.length - minLength; i++) {
857
+ if (input.includes(sequence.substring(i, i + minLength)))
858
+ return true;
859
+ }
860
+ return false;
861
+ }
862
+ /**
863
+ * URL safety check. Returns the normalized URL when the scheme is allowed, `null` otherwise.
864
+ * Malformed URLs, relative URLs, and blocked schemes all return `null`.
865
+ */
866
+ function sanitizeUrlString(input, allowedSchemes = ['http:', 'https:']) {
867
+ if (!input)
868
+ return null;
869
+ try {
870
+ const url = new URL(input);
871
+ return allowedSchemes.includes(url.protocol) ? url.toString() : null;
872
+ }
873
+ catch {
874
+ return null;
875
+ }
876
+ }
877
+ /**
878
+ * Boolean helper around `sanitizeUrlString`. `true` when the URL is well-formed and allowed.
879
+ */
880
+ function isUrlSafe(input, allowedSchemes = ['http:', 'https:']) {
881
+ return sanitizeUrlString(input, allowedSchemes) !== null;
882
+ }
883
+ const SCRIPT_INJECTION_PATTERN = /<\s*script\b|javascript:|on\w+\s*=/i;
884
+ /**
885
+ * Lightweight check for common script-injection sentinels. Complements (does NOT replace)
886
+ * a full HTML sanitizer or Content Security Policy.
887
+ */
888
+ function containsScriptInjection(input) {
889
+ if (!input)
890
+ return false;
891
+ return SCRIPT_INJECTION_PATTERN.test(input);
892
+ }
893
+ const SQL_HINT_PATTERNS = [
894
+ /'\s*or\s+'?1'?\s*=\s*'?1/i,
895
+ /--\s*$/m,
896
+ /;\s*--/,
897
+ /\/\*/,
898
+ /\bunion\s+select\b/i,
899
+ ];
900
+ /**
901
+ * Heuristic check for SQL-injection sentinel strings.
902
+ * Intended as defense-in-depth for client-side form inputs; never a substitute for parameterized queries.
903
+ */
904
+ function containsSqlInjectionHints(input) {
905
+ if (!input)
906
+ return false;
907
+ return SQL_HINT_PATTERNS.some((pattern) => pattern.test(input));
908
+ }
729
909
  const DEFAULT_ALLOWED_TAGS = [
730
910
  'b',
731
911
  'i',
@@ -742,13 +922,81 @@ const DEFAULT_ALLOWED_TAGS = [
742
922
  const DEFAULT_ALLOWED_ATTRIBUTES = {
743
923
  a: ['href'],
744
924
  };
745
- const SAFE_URL_SCHEMES = ['http:', 'https:'];
925
+ /**
926
+ * Sanitizes an HTML string by allowlist. Browser-only: requires DOMParser.
927
+ *
928
+ * @throws {Error} When called in a non-browser environment.
929
+ */
930
+ function sanitizeHtmlString(input, options = {}) {
931
+ if (!input)
932
+ return '';
933
+ if (typeof DOMParser === 'undefined') {
934
+ throw new Error('sanitizeHtmlString requires a browser environment (DOMParser unavailable)');
935
+ }
936
+ const allowedTags = new Set(options.allowedTags ?? DEFAULT_ALLOWED_TAGS);
937
+ const allowedAttributes = options.allowedAttributes ?? DEFAULT_ALLOWED_ATTRIBUTES;
938
+ const parser = new DOMParser();
939
+ const doc = parser.parseFromString(input, 'text/html');
940
+ processHtmlNode(doc.body, allowedTags, allowedAttributes);
941
+ return doc.body.innerHTML;
942
+ }
943
+ /**
944
+ * Returns `true` when sanitizing `input` produces the same string (i.e. nothing was stripped).
945
+ * Empty strings are considered safe.
946
+ *
947
+ * @throws {Error} When called in a non-browser environment.
948
+ */
949
+ function isHtmlSafe(input, options = {}) {
950
+ if (!input)
951
+ return true;
952
+ return sanitizeHtmlString(input, options) === input;
953
+ }
954
+ function processHtmlNode(node, allowedTags, allowedAttributes) {
955
+ const children = Array.from(node.childNodes);
956
+ for (const child of children) {
957
+ if (child.nodeType !== Node.ELEMENT_NODE)
958
+ continue;
959
+ const element = child;
960
+ const tagName = element.tagName.toLowerCase();
961
+ if (!allowedTags.has(tagName)) {
962
+ const text = element.textContent ?? '';
963
+ node.replaceChild(document.createTextNode(text), element);
964
+ continue;
965
+ }
966
+ sanitizeElementAttributes(element, tagName, allowedAttributes);
967
+ processHtmlNode(element, allowedTags, allowedAttributes);
968
+ }
969
+ }
970
+ function sanitizeElementAttributes(element, tagName, allowedAttributes) {
971
+ const attrsToRemove = [];
972
+ const allowed = allowedAttributes[tagName] ?? [];
973
+ for (let i = 0; i < element.attributes.length; i++) {
974
+ const attr = element.attributes[i];
975
+ if (attr.name.startsWith('on')) {
976
+ attrsToRemove.push(attr.name);
977
+ continue;
978
+ }
979
+ if (!allowed.includes(attr.name)) {
980
+ attrsToRemove.push(attr.name);
981
+ continue;
982
+ }
983
+ if (attr.name === 'href' && sanitizeUrlString(attr.value) === null) {
984
+ attrsToRemove.push(attr.name);
985
+ }
986
+ }
987
+ attrsToRemove.forEach((name) => element.removeAttribute(name));
988
+ }
989
+
990
+ const SANITIZER_CONFIG = new InjectionToken('SANITIZER_CONFIG');
746
991
  /**
747
992
  * Service for structured input sanitization to defend against XSS, URL injection, and unsafe HTML.
748
993
  *
749
994
  * This service is defense-in-depth and DOES NOT replace a Content Security Policy (CSP).
750
995
  * Always configure a proper CSP alongside using this service.
751
996
  *
997
+ * Delegates to shared pure helpers in `internal/validators-core` so Signal Forms and Reactive Forms
998
+ * validators share the exact same sanitization logic.
999
+ *
752
1000
  * @example
753
1001
  * const clean = sanitizer.sanitizeHtml('<b>Hello</b><script>alert(1)</script>');
754
1002
  * // → '<b>Hello</b>'
@@ -763,7 +1011,7 @@ class InputSanitizerService {
763
1011
  allowedAttributes;
764
1012
  constructor() {
765
1013
  const config = inject(SANITIZER_CONFIG, { optional: true }) ?? {};
766
- this.allowedTags = new Set(config.allowedTags ?? DEFAULT_ALLOWED_TAGS);
1014
+ this.allowedTags = config.allowedTags ?? DEFAULT_ALLOWED_TAGS;
767
1015
  this.allowedAttributes = config.allowedAttributes ?? DEFAULT_ALLOWED_ATTRIBUTES;
768
1016
  }
769
1017
  isSupported() {
@@ -779,12 +1027,10 @@ class InputSanitizerService {
779
1027
  if (!this.isSupported()) {
780
1028
  throw new Error('sanitizeHtml requires a browser environment (DOMParser unavailable)');
781
1029
  }
782
- if (!input)
783
- return '';
784
- const parser = new DOMParser();
785
- const doc = parser.parseFromString(input, 'text/html');
786
- this.processNode(doc.body);
787
- return doc.body.innerHTML;
1030
+ return sanitizeHtmlString(input, {
1031
+ allowedTags: this.allowedTags,
1032
+ allowedAttributes: this.allowedAttributes,
1033
+ });
788
1034
  }
789
1035
  /**
790
1036
  * Validates and normalizes a URL string.
@@ -792,15 +1038,7 @@ class InputSanitizerService {
792
1038
  * Returns `null` for `javascript:`, `data:`, `vbscript:`, relative URLs, or malformed input.
793
1039
  */
794
1040
  sanitizeUrl(input) {
795
- if (!input)
796
- return null;
797
- try {
798
- const url = new URL(input);
799
- return SAFE_URL_SCHEMES.includes(url.protocol) ? url.toString() : null;
800
- }
801
- catch {
802
- return null;
803
- }
1041
+ return sanitizeUrlString(input);
804
1042
  }
805
1043
  /**
806
1044
  * Escapes HTML special characters for safe text interpolation.
@@ -830,41 +1068,6 @@ class InputSanitizerService {
830
1068
  return null;
831
1069
  }
832
1070
  }
833
- processNode(node) {
834
- const children = Array.from(node.childNodes);
835
- for (const child of children) {
836
- if (child.nodeType !== Node.ELEMENT_NODE)
837
- continue;
838
- const element = child;
839
- const tagName = element.tagName.toLowerCase();
840
- if (!this.allowedTags.has(tagName)) {
841
- const text = element.textContent ?? '';
842
- node.replaceChild(document.createTextNode(text), element);
843
- continue;
844
- }
845
- this.sanitizeAttributes(element, tagName);
846
- this.processNode(element);
847
- }
848
- }
849
- sanitizeAttributes(element, tagName) {
850
- const attrsToRemove = [];
851
- const allowed = this.allowedAttributes[tagName] ?? [];
852
- for (let i = 0; i < element.attributes.length; i++) {
853
- const attr = element.attributes[i];
854
- if (attr.name.startsWith('on')) {
855
- attrsToRemove.push(attr.name);
856
- continue;
857
- }
858
- if (!allowed.includes(attr.name)) {
859
- attrsToRemove.push(attr.name);
860
- continue;
861
- }
862
- if (attr.name === 'href' && this.sanitizeUrl(attr.value) === null) {
863
- attrsToRemove.push(attr.name);
864
- }
865
- }
866
- attrsToRemove.forEach((name) => element.removeAttribute(name));
867
- }
868
1071
  static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: InputSanitizerService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
869
1072
  static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: InputSanitizerService });
870
1073
  }
@@ -872,34 +1075,13 @@ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImpor
872
1075
  type: Injectable
873
1076
  }], ctorParameters: () => [] });
874
1077
 
875
- const COMMON_PASSWORDS = new Set([
876
- 'password',
877
- '123456',
878
- 'qwerty',
879
- 'letmein',
880
- 'admin',
881
- 'welcome',
882
- '111111',
883
- 'abc123',
884
- 'monkey',
885
- 'master',
886
- 'login',
887
- 'pass',
888
- ]);
889
- const ALPHA_SEQUENCES = 'abcdefghijklmnopqrstuvwxyz';
890
- const DIGIT_SEQUENCES = '0123456789';
891
- const KEYBOARD_ROWS = ['qwertyuiop', 'asdfghjkl', 'zxcvbnm'];
892
- const SCORE_LABELS = {
893
- 0: 'very-weak',
894
- 1: 'weak',
895
- 2: 'fair',
896
- 3: 'strong',
897
- 4: 'very-strong',
898
- };
899
1078
  /**
900
1079
  * Service for entropy-based password strength evaluation.
901
1080
  * All methods are synchronous and side-effect free — safely wrappable in Angular `computed()`.
902
1081
  *
1082
+ * Delegates to the shared {@link assessPasswordStrength} helper so Reactive Forms validators,
1083
+ * Signal Forms validators, and direct service consumers all share identical logic.
1084
+ *
903
1085
  * Score thresholds (bits of entropy):
904
1086
  * - 0 (very-weak): < 28 bits
905
1087
  * - 1 (weak): 28–35 bits
@@ -919,99 +1101,849 @@ class PasswordStrengthService {
919
1101
  * Never throws — returns score 0 for empty or null-like input.
920
1102
  */
921
1103
  assess(password) {
922
- if (!password) {
923
- return {
924
- score: 0,
925
- label: 'very-weak',
926
- entropy: 0,
927
- feedback: ['Password cannot be empty'],
1104
+ return assessPasswordStrength(password);
1105
+ }
1106
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1107
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService });
1108
+ }
1109
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService, decorators: [{
1110
+ type: Injectable
1111
+ }] });
1112
+
1113
+ /**
1114
+ * Thrown when a string is not a well-formed JWT (wrong number of segments,
1115
+ * malformed base64url payload, or non-JSON payload).
1116
+ */
1117
+ class InvalidJwtError extends Error {
1118
+ constructor(message) {
1119
+ super(message);
1120
+ this.name = 'InvalidJwtError';
1121
+ }
1122
+ }
1123
+ /**
1124
+ * Client-side JWT inspection utilities.
1125
+ *
1126
+ * **This service decodes JWT payloads for client-side inspection only. Signature verification
1127
+ * MUST happen server-side** — this service never validates signatures, issuer, audience,
1128
+ * or any trust-related property.
1129
+ *
1130
+ * Use cases:
1131
+ * - Reading the expiration to schedule refresh
1132
+ * - Extracting user-facing claims (e.g. `name`, `email`) for UX
1133
+ * - Detecting expired tokens to redirect to login
1134
+ *
1135
+ * @example
1136
+ * const token = localStorage.getItem('access_token');
1137
+ * if (!token || jwt.isExpired(token, 30)) {
1138
+ * router.navigate(['/login']);
1139
+ * }
1140
+ * const userId = jwt.claim<string>(token, 'sub');
1141
+ */
1142
+ class JwtService {
1143
+ /**
1144
+ * Decodes the payload segment of a JWT and returns it typed.
1145
+ *
1146
+ * @throws {InvalidJwtError} When the token is not three dot-separated segments,
1147
+ * or when the payload cannot be base64url-decoded and parsed as JSON.
1148
+ */
1149
+ decode(token) {
1150
+ if (typeof token !== 'string' || token.length === 0) {
1151
+ throw new InvalidJwtError('JWT is empty');
1152
+ }
1153
+ const segments = token.split('.');
1154
+ if (segments.length !== 3) {
1155
+ throw new InvalidJwtError(`JWT must have 3 segments, got ${segments.length}`);
1156
+ }
1157
+ const payloadSegment = segments[1];
1158
+ let json;
1159
+ try {
1160
+ json = base64UrlDecode(payloadSegment);
1161
+ }
1162
+ catch (cause) {
1163
+ throw new InvalidJwtError(`JWT payload is not valid base64url: ${cause instanceof Error ? cause.message : 'unknown'}`);
1164
+ }
1165
+ try {
1166
+ return JSON.parse(json);
1167
+ }
1168
+ catch (cause) {
1169
+ throw new InvalidJwtError(`JWT payload is not valid JSON: ${cause instanceof Error ? cause.message : 'unknown'}`);
1170
+ }
1171
+ }
1172
+ /**
1173
+ * Returns `true` when the token is expired relative to `Date.now()`.
1174
+ * Missing or non-numeric `exp` counts as expired (fail-secure).
1175
+ *
1176
+ * @param leewaySeconds Optional clock-skew tolerance in seconds. Default: `0`.
1177
+ */
1178
+ isExpired(token, leewaySeconds = 0) {
1179
+ let payload;
1180
+ try {
1181
+ payload = this.decode(token);
1182
+ }
1183
+ catch {
1184
+ return true;
1185
+ }
1186
+ if (typeof payload.exp !== 'number')
1187
+ return true;
1188
+ const expiresAtMs = payload.exp * 1000;
1189
+ return expiresAtMs <= Date.now() - leewaySeconds * 1000;
1190
+ }
1191
+ /**
1192
+ * Returns milliseconds until the token expires.
1193
+ * Negative when already expired. `0` when `exp` is missing.
1194
+ */
1195
+ expiresIn(token) {
1196
+ let payload;
1197
+ try {
1198
+ payload = this.decode(token);
1199
+ }
1200
+ catch {
1201
+ return -1;
1202
+ }
1203
+ if (typeof payload.exp !== 'number')
1204
+ return 0;
1205
+ return payload.exp * 1000 - Date.now();
1206
+ }
1207
+ /**
1208
+ * Extracts a single claim from the payload. Returns `null` when the claim is absent
1209
+ * or the token is malformed.
1210
+ */
1211
+ claim(token, name) {
1212
+ let payload;
1213
+ try {
1214
+ payload = this.decode(token);
1215
+ }
1216
+ catch {
1217
+ return null;
1218
+ }
1219
+ return payload[name] ?? null;
1220
+ }
1221
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: JwtService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1222
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: JwtService });
1223
+ }
1224
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: JwtService, decorators: [{
1225
+ type: Injectable
1226
+ }] });
1227
+ function base64UrlDecode(input) {
1228
+ const padded = input.replace(/-/g, '+').replace(/_/g, '/');
1229
+ const padLength = (4 - (padded.length % 4)) % 4;
1230
+ const base64 = padded + '='.repeat(padLength);
1231
+ const binary = atob(base64);
1232
+ const bytes = new Uint8Array(binary.length);
1233
+ for (let i = 0; i < binary.length; i++) {
1234
+ bytes[i] = binary.charCodeAt(i);
1235
+ }
1236
+ return new TextDecoder().decode(bytes);
1237
+ }
1238
+
1239
+ class ClipboardUnsupportedError extends Error {
1240
+ constructor() {
1241
+ super('Clipboard API not available in this environment');
1242
+ this.name = 'ClipboardUnsupportedError';
1243
+ }
1244
+ }
1245
+ const DEFAULT_CLEAR_MS = 15_000;
1246
+ /**
1247
+ * Copies sensitive strings to the clipboard with automatic, verified clearing.
1248
+ *
1249
+ * Mirrors the behaviour of password managers (1Password, Bitwarden): the clipboard is
1250
+ * cleared only when its current content still matches what we wrote, preventing clobbering
1251
+ * of unrelated user copies.
1252
+ *
1253
+ * Requires a secure context and `navigator.clipboard`. The auto-clear step additionally
1254
+ * requires `navigator.clipboard.readText()` permission; when denied, the clear is skipped
1255
+ * and the status emits `'read-denied'`.
1256
+ *
1257
+ * Any pending clear timer is cancelled automatically when the owning injector is destroyed.
1258
+ *
1259
+ * @example
1260
+ * await sensitiveClipboard.copy(password, { clearAfterMs: 15_000 });
1261
+ */
1262
+ class SensitiveClipboardService {
1263
+ platformId = inject(PLATFORM_ID);
1264
+ destroyRef = inject(DestroyRef);
1265
+ pendingTimer = null;
1266
+ constructor() {
1267
+ this.destroyRef.onDestroy(() => this.cancelPendingClear());
1268
+ }
1269
+ isSupported() {
1270
+ return (isPlatformBrowser(this.platformId) &&
1271
+ typeof navigator !== 'undefined' &&
1272
+ typeof navigator.clipboard?.writeText === 'function');
1273
+ }
1274
+ /**
1275
+ * Writes `text` to the clipboard and schedules an auto-clear.
1276
+ *
1277
+ * @throws {ClipboardUnsupportedError} When the Clipboard API is unavailable.
1278
+ */
1279
+ async copy(text, options = {}) {
1280
+ if (!this.isSupported()) {
1281
+ throw new ClipboardUnsupportedError();
1282
+ }
1283
+ const clearAfterMs = options.clearAfterMs ?? DEFAULT_CLEAR_MS;
1284
+ await navigator.clipboard.writeText(text);
1285
+ this.cancelPendingClear();
1286
+ if (clearAfterMs <= 0)
1287
+ return;
1288
+ this.pendingTimer = setTimeout(() => {
1289
+ void this.safeClear(text);
1290
+ }, clearAfterMs);
1291
+ }
1292
+ /**
1293
+ * Reactive variant of {@link copy}. Emits `'copied'` immediately after writing, then
1294
+ * `'cleared'` | `'read-denied'` | `'error'` once the auto-clear completes (or is skipped).
1295
+ */
1296
+ copy$(text, options = {}) {
1297
+ return new Observable((subscriber) => {
1298
+ if (!this.isSupported()) {
1299
+ subscriber.error(new ClipboardUnsupportedError());
1300
+ return () => {
1301
+ /* no teardown */
1302
+ };
1303
+ }
1304
+ const clearAfterMs = options.clearAfterMs ?? DEFAULT_CLEAR_MS;
1305
+ let cleared = false;
1306
+ let timer = null;
1307
+ navigator.clipboard
1308
+ .writeText(text)
1309
+ .then(() => {
1310
+ subscriber.next('copied');
1311
+ if (clearAfterMs <= 0) {
1312
+ subscriber.complete();
1313
+ return;
1314
+ }
1315
+ timer = setTimeout(async () => {
1316
+ const status = await this.safeClear(text);
1317
+ cleared = true;
1318
+ subscriber.next(status);
1319
+ subscriber.complete();
1320
+ }, clearAfterMs);
1321
+ })
1322
+ .catch(() => {
1323
+ subscriber.next('error');
1324
+ subscriber.complete();
1325
+ });
1326
+ return () => {
1327
+ if (!cleared && timer !== null)
1328
+ clearTimeout(timer);
928
1329
  };
1330
+ });
1331
+ }
1332
+ /**
1333
+ * Cancels any pending auto-clear timer. The clipboard content is not modified.
1334
+ */
1335
+ cancelPendingClear() {
1336
+ if (this.pendingTimer !== null) {
1337
+ clearTimeout(this.pendingTimer);
1338
+ this.pendingTimer = null;
1339
+ }
1340
+ }
1341
+ async safeClear(expected) {
1342
+ try {
1343
+ const current = await navigator.clipboard.readText();
1344
+ if (current !== expected)
1345
+ return 'read-denied';
1346
+ await navigator.clipboard.writeText('');
1347
+ return 'cleared';
1348
+ }
1349
+ catch {
1350
+ return 'read-denied';
1351
+ }
1352
+ }
1353
+ /**
1354
+ * Forcefully clears the clipboard unconditionally.
1355
+ */
1356
+ async clear() {
1357
+ if (!this.isSupported())
1358
+ return;
1359
+ this.cancelPendingClear();
1360
+ try {
1361
+ await navigator.clipboard.writeText('');
1362
+ }
1363
+ catch {
1364
+ // ignore
929
1365
  }
930
- const feedback = [];
931
- const chars = [...password];
932
- const length = chars.length;
933
- const hasLower = /[a-z]/.test(password);
934
- const hasUpper = /[A-Z]/.test(password);
935
- const hasDigit = /[0-9]/.test(password);
936
- const hasSymbol = /[!@#$%^&*()\-_=+[\]{}|;:'",.<>/?\\`~]/.test(password);
937
- const hasExtended = chars.some((c) => c.codePointAt(0) > 127);
938
- let poolSize = 0;
939
- if (hasLower)
940
- poolSize += 26;
941
- if (hasUpper)
942
- poolSize += 26;
943
- if (hasDigit)
944
- poolSize += 10;
945
- if (hasSymbol)
946
- poolSize += 32;
947
- if (hasExtended)
948
- poolSize += 64;
949
- if (poolSize === 0)
950
- poolSize = 26;
951
- let entropy = length * Math.log2(poolSize);
952
- const hasAlphaSeq = this.containsSequence(password.toLowerCase(), ALPHA_SEQUENCES, 3);
953
- const hasDigitSeq = this.containsSequence(password, DIGIT_SEQUENCES, 3);
954
- const hasRepeat = /(.)\1{2,}/.test(password);
955
- const hasKeyboard = KEYBOARD_ROWS.some((row) => this.containsSequence(password.toLowerCase(), row, 4));
956
- if (hasAlphaSeq || hasDigitSeq) {
957
- entropy *= 0.8;
958
- feedback.push('Avoid predictable sequences (abc, 123)');
959
- }
960
- if (hasRepeat) {
961
- entropy *= 0.9;
962
- feedback.push('Avoid repeated characters');
963
- }
964
- if (hasKeyboard) {
965
- entropy *= 0.85;
966
- feedback.push('Avoid keyboard patterns (qwerty, asdf)');
967
- }
968
- if (length < 8) {
969
- feedback.push('Use at least 8 characters');
970
- }
971
- if (!hasUpper)
972
- feedback.push('Add uppercase letters');
973
- if (!hasDigit)
974
- feedback.push('Add numbers');
975
- if (!hasSymbol)
976
- feedback.push('Add special characters');
977
- const isCommon = COMMON_PASSWORDS.has(password.toLowerCase());
978
- let score = this.entropyToScore(entropy);
979
- if (isCommon) {
980
- score = Math.min(score, 1);
981
- if (!feedback.includes('Use at least 8 characters')) {
982
- feedback.push('This is a commonly used password');
1366
+ }
1367
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SensitiveClipboardService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1368
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SensitiveClipboardService });
1369
+ }
1370
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SensitiveClipboardService, decorators: [{
1371
+ type: Injectable
1372
+ }], ctorParameters: () => [] });
1373
+
1374
+ const HIBP_CONFIG = new InjectionToken('HIBP_CONFIG');
1375
+ const DEFAULT_ENDPOINT = 'https://api.pwnedpasswords.com/range/';
1376
+ /**
1377
+ * Checks whether a password appears in the Have I Been Pwned breach corpus using the
1378
+ * k-anonymity API. Only the first 5 hex characters of the SHA-1 hash leave the browser;
1379
+ * the full password is never transmitted.
1380
+ *
1381
+ * The service is fail-open: network errors return `{ leaked: false, error: 'network' }`
1382
+ * to avoid blocking form submissions when HIBP is unreachable. Use this signal to
1383
+ * *complement* entropy-based checks, never as the sole gate.
1384
+ *
1385
+ * @example
1386
+ * const { leaked, count } = await hibp.isPasswordLeaked(password);
1387
+ * if (leaked) alert(`This password has appeared in ${count} breaches`);
1388
+ */
1389
+ class HibpService {
1390
+ platformId = inject(PLATFORM_ID);
1391
+ webCrypto = inject(WebCryptoService);
1392
+ endpoint;
1393
+ constructor() {
1394
+ const config = inject(HIBP_CONFIG, { optional: true }) ?? {};
1395
+ this.endpoint = config.endpoint ?? DEFAULT_ENDPOINT;
1396
+ }
1397
+ isSupported() {
1398
+ return (isPlatformBrowser(this.platformId) &&
1399
+ typeof fetch === 'function' &&
1400
+ this.webCrypto.isSupported());
1401
+ }
1402
+ /**
1403
+ * Returns whether the given password is present in the HIBP breach corpus.
1404
+ * Never throws: network failures return `{ leaked: false, error: 'network' }`,
1405
+ * unsupported environments return `{ leaked: false, error: 'unsupported' }`.
1406
+ */
1407
+ async isPasswordLeaked(password) {
1408
+ if (!this.isSupported()) {
1409
+ return { leaked: false, count: 0, error: 'unsupported' };
1410
+ }
1411
+ if (!password) {
1412
+ return { leaked: false, count: 0 };
1413
+ }
1414
+ let hashHex;
1415
+ try {
1416
+ hashHex = (await this.webCrypto.hash(password, 'SHA-1')).toUpperCase();
1417
+ }
1418
+ catch {
1419
+ return { leaked: false, count: 0, error: 'unsupported' };
1420
+ }
1421
+ const prefix = hashHex.slice(0, 5);
1422
+ const suffix = hashHex.slice(5);
1423
+ let body;
1424
+ try {
1425
+ const response = await fetch(`${this.endpoint}${prefix}`, {
1426
+ method: 'GET',
1427
+ headers: { 'Add-Padding': 'true' },
1428
+ });
1429
+ if (response.status === 404) {
1430
+ return { leaked: false, count: 0 };
983
1431
  }
1432
+ if (!response.ok) {
1433
+ return { leaked: false, count: 0, error: 'network' };
1434
+ }
1435
+ body = await response.text();
984
1436
  }
985
- return {
986
- score,
987
- label: SCORE_LABELS[score],
988
- entropy: Math.round(entropy * 100) / 100,
989
- feedback,
990
- };
1437
+ catch {
1438
+ return { leaked: false, count: 0, error: 'network' };
1439
+ }
1440
+ const match = findSuffixMatch(body, suffix);
1441
+ return match > 0 ? { leaked: true, count: match } : { leaked: false, count: 0 };
991
1442
  }
992
- entropyToScore(entropy) {
993
- if (entropy < 28)
994
- return 0;
995
- if (entropy < 36)
996
- return 1;
997
- if (entropy < 50)
998
- return 2;
999
- if (entropy < 70)
1000
- return 3;
1001
- return 4;
1002
- }
1003
- containsSequence(input, sequence, minLength) {
1004
- for (let i = 0; i <= sequence.length - minLength; i++) {
1005
- if (input.includes(sequence.substring(i, i + minLength)))
1006
- return true;
1443
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: HibpService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1444
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: HibpService });
1445
+ }
1446
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: HibpService, decorators: [{
1447
+ type: Injectable
1448
+ }], ctorParameters: () => [] });
1449
+ function findSuffixMatch(body, suffix) {
1450
+ const lines = body.split(/\r?\n/);
1451
+ for (const line of lines) {
1452
+ const separatorIndex = line.indexOf(':');
1453
+ if (separatorIndex === -1)
1454
+ continue;
1455
+ const lineSuffix = line.slice(0, separatorIndex).trim().toUpperCase();
1456
+ if (lineSuffix !== suffix)
1457
+ continue;
1458
+ const count = parseInt(line.slice(separatorIndex + 1).trim(), 10);
1459
+ // Padding rows (not in breach) have count 0; treat them as no match.
1460
+ return Number.isFinite(count) && count > 0 ? count : 0;
1461
+ }
1462
+ return 0;
1463
+ }
1464
+
1465
+ const RATE_LIMITER_CONFIG = new InjectionToken('RATE_LIMITER_CONFIG');
1466
+ /**
1467
+ * Thrown by {@link RateLimiterService.consume} when the configured capacity is exhausted.
1468
+ */
1469
+ class RateLimitExceededError extends Error {
1470
+ key;
1471
+ retryAfterMs;
1472
+ constructor(key, retryAfterMs) {
1473
+ super(`Rate limit exceeded for "${key}". Retry after ${retryAfterMs}ms.`);
1474
+ this.key = key;
1475
+ this.retryAfterMs = retryAfterMs;
1476
+ this.name = 'RateLimitExceededError';
1477
+ }
1478
+ }
1479
+ /**
1480
+ * Client-side rate limiter with per-key policies. Use to protect the user's own backend
1481
+ * from accidental bursts (search-as-you-type, button mashing, automated retries) or to
1482
+ * pace client-side operations.
1483
+ *
1484
+ * Two policies are supported:
1485
+ * - **Token bucket**: smooth rate limiting with burst capacity.
1486
+ * - **Sliding window**: strict max operations per time window.
1487
+ *
1488
+ * Unknown keys are fail-open: `canExecute` returns `true`, `consume` is a no-op. Register
1489
+ * policies explicitly with {@link configure} or via the `RATE_LIMITER_CONFIG` token.
1490
+ *
1491
+ * All state is kept in memory for the lifetime of the service instance — cross-tab
1492
+ * synchronization is out of scope for v1.
1493
+ *
1494
+ * @example
1495
+ * rateLimiter.configure('search', { type: 'token-bucket', capacity: 5, refillPerSecond: 1 });
1496
+ *
1497
+ * async search(query: string) {
1498
+ * await rateLimiter.consume('search'); // throws RateLimitExceededError when exhausted
1499
+ * return this.api.search(query);
1500
+ * }
1501
+ */
1502
+ class RateLimiterService {
1503
+ destroyRef = inject(DestroyRef);
1504
+ buckets = new Map();
1505
+ constructor() {
1506
+ const config = inject(RATE_LIMITER_CONFIG, { optional: true });
1507
+ if (config?.defaults) {
1508
+ for (const [key, policy] of Object.entries(config.defaults)) {
1509
+ this.configure(key, policy);
1510
+ }
1007
1511
  }
1008
- return false;
1512
+ this.destroyRef.onDestroy(() => this.buckets.clear());
1009
1513
  }
1010
- static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1011
- static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService });
1514
+ /**
1515
+ * Registers or updates the policy for `key`. Re-configuring an existing key resets its state.
1516
+ */
1517
+ configure(key, policy) {
1518
+ validatePolicy(policy);
1519
+ const now = Date.now();
1520
+ const initialRemaining = policy.type === 'token-bucket' ? policy.capacity : policy.max;
1521
+ this.buckets.set(key, {
1522
+ policy,
1523
+ tokens: policy.type === 'token-bucket' ? policy.capacity : 0,
1524
+ lastRefillAt: now,
1525
+ timestamps: [],
1526
+ remaining: signal(initialRemaining),
1527
+ });
1528
+ }
1529
+ /**
1530
+ * Attempts to consume `tokens` units from the bucket. Resolves on success; rejects with
1531
+ * {@link RateLimitExceededError} when the bucket is exhausted.
1532
+ *
1533
+ * For undeclared keys, this method resolves immediately without consuming anything
1534
+ * (fail-open behaviour — intentional to avoid silent failures when a policy is missing).
1535
+ */
1536
+ async consume(key, tokens = 1) {
1537
+ const bucket = this.buckets.get(key);
1538
+ if (!bucket)
1539
+ return;
1540
+ const now = Date.now();
1541
+ if (bucket.policy.type === 'token-bucket') {
1542
+ this.refillTokenBucket(bucket, now);
1543
+ if (tokens > bucket.policy.capacity) {
1544
+ throw new RateLimitExceededError(key, Infinity);
1545
+ }
1546
+ if (bucket.tokens < tokens) {
1547
+ const deficit = tokens - bucket.tokens;
1548
+ const retryAfterMs = Math.ceil((deficit / bucket.policy.refillPerSecond) * 1000);
1549
+ throw new RateLimitExceededError(key, retryAfterMs);
1550
+ }
1551
+ bucket.tokens -= tokens;
1552
+ bucket.remaining.set(Math.floor(bucket.tokens));
1553
+ return;
1554
+ }
1555
+ // sliding-window
1556
+ const windowStart = now - bucket.policy.windowMs;
1557
+ bucket.timestamps = bucket.timestamps.filter((t) => t > windowStart);
1558
+ if (bucket.timestamps.length + tokens > bucket.policy.max) {
1559
+ const oldest = bucket.timestamps[0] ?? now;
1560
+ const retryAfterMs = Math.max(0, oldest + bucket.policy.windowMs - now);
1561
+ throw new RateLimitExceededError(key, retryAfterMs);
1562
+ }
1563
+ for (let i = 0; i < tokens; i++)
1564
+ bucket.timestamps.push(now);
1565
+ bucket.remaining.set(bucket.policy.max - bucket.timestamps.length);
1566
+ }
1567
+ /**
1568
+ * Reactive signal indicating whether a single unit can be consumed from `key` right now.
1569
+ * For undeclared keys, returns `signal(true)`.
1570
+ */
1571
+ canExecute(key) {
1572
+ const bucket = this.buckets.get(key);
1573
+ if (!bucket)
1574
+ return signal(true).asReadonly();
1575
+ return computed(() => bucket.remaining() >= 1);
1576
+ }
1577
+ /**
1578
+ * Reactive signal holding the remaining capacity for `key`.
1579
+ * For undeclared keys, returns `signal(Infinity)`.
1580
+ */
1581
+ remaining(key) {
1582
+ const bucket = this.buckets.get(key);
1583
+ if (!bucket)
1584
+ return signal(Number.POSITIVE_INFINITY).asReadonly();
1585
+ return bucket.remaining.asReadonly();
1586
+ }
1587
+ /**
1588
+ * Resets the counter for `key` to its maximum. No-op for undeclared keys.
1589
+ */
1590
+ reset(key) {
1591
+ const bucket = this.buckets.get(key);
1592
+ if (!bucket)
1593
+ return;
1594
+ bucket.timestamps = [];
1595
+ if (bucket.policy.type === 'token-bucket') {
1596
+ bucket.tokens = bucket.policy.capacity;
1597
+ bucket.lastRefillAt = Date.now();
1598
+ bucket.remaining.set(bucket.policy.capacity);
1599
+ }
1600
+ else {
1601
+ bucket.remaining.set(bucket.policy.max);
1602
+ }
1603
+ }
1604
+ refillTokenBucket(bucket, now) {
1605
+ if (bucket.policy.type !== 'token-bucket')
1606
+ return;
1607
+ const elapsedSec = (now - bucket.lastRefillAt) / 1000;
1608
+ if (elapsedSec <= 0)
1609
+ return;
1610
+ const refilled = elapsedSec * bucket.policy.refillPerSecond;
1611
+ bucket.tokens = Math.min(bucket.policy.capacity, bucket.tokens + refilled);
1612
+ bucket.lastRefillAt = now;
1613
+ bucket.remaining.set(Math.floor(bucket.tokens));
1614
+ }
1615
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RateLimiterService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1616
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RateLimiterService });
1012
1617
  }
1013
- i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: PasswordStrengthService, decorators: [{
1618
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: RateLimiterService, decorators: [{
1014
1619
  type: Injectable
1620
+ }], ctorParameters: () => [] });
1621
+ function validatePolicy(policy) {
1622
+ if (policy.type === 'token-bucket') {
1623
+ if (policy.capacity <= 0 || policy.refillPerSecond <= 0) {
1624
+ throw new RangeError('Token-bucket policy requires capacity > 0 and refillPerSecond > 0');
1625
+ }
1626
+ return;
1627
+ }
1628
+ if (policy.max <= 0 || policy.windowMs <= 0) {
1629
+ throw new RangeError('Sliding-window policy requires max > 0 and windowMs > 0');
1630
+ }
1631
+ }
1632
+
1633
+ const CSRF_CONFIG = new InjectionToken('CSRF_CONFIG');
1634
+ const DEFAULT_STORAGE_KEY = '__csrf_token__';
1635
+ const DEFAULT_TOKEN_BYTES = 32;
1636
+ /**
1637
+ * CSRF token helper using the double-submit cookie / header pattern.
1638
+ *
1639
+ * The service stores a cryptographically secure token in the configured storage and exposes
1640
+ * it to HTTP interceptors ({@link withCsrfHeader}). Token lifecycle (creation, rotation,
1641
+ * clearing) is the application's responsibility — typically the token is issued by the
1642
+ * backend at login and stored via {@link storeToken}.
1643
+ *
1644
+ * Use a different header name than Angular's built-in XSRF ({@link `X-XSRF-TOKEN`}) to avoid
1645
+ * interaction with `HttpClientXsrfModule`. Default: {@link `X-CSRF-Token`}.
1646
+ */
1647
+ class CsrfService {
1648
+ platformId = inject(PLATFORM_ID);
1649
+ webCrypto = inject(WebCryptoService);
1650
+ storageKey;
1651
+ storageTarget;
1652
+ constructor() {
1653
+ const config = inject(CSRF_CONFIG, { optional: true }) ?? {};
1654
+ this.storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY;
1655
+ this.storageTarget = config.storage ?? 'session';
1656
+ }
1657
+ isSupported() {
1658
+ return isPlatformBrowser(this.platformId) && typeof window !== 'undefined';
1659
+ }
1660
+ /**
1661
+ * Generates a new CSRF token as a 32-byte hex string. The token is NOT persisted
1662
+ * automatically — call {@link storeToken} to save it.
1663
+ */
1664
+ generateToken() {
1665
+ const bytes = this.webCrypto.generateRandomBytes(DEFAULT_TOKEN_BYTES);
1666
+ return Array.from(bytes)
1667
+ .map((b) => b.toString(16).padStart(2, '0'))
1668
+ .join('');
1669
+ }
1670
+ /**
1671
+ * Persists the token to the configured storage.
1672
+ */
1673
+ storeToken(token) {
1674
+ if (!this.isSupported())
1675
+ return;
1676
+ this.nativeStorage.setItem(this.storageKey, token);
1677
+ }
1678
+ /**
1679
+ * Returns the stored token, or `null` when unset or outside a browser environment.
1680
+ */
1681
+ getToken() {
1682
+ if (!this.isSupported())
1683
+ return null;
1684
+ return this.nativeStorage.getItem(this.storageKey);
1685
+ }
1686
+ /**
1687
+ * Removes the stored token.
1688
+ */
1689
+ clearToken() {
1690
+ if (!this.isSupported())
1691
+ return;
1692
+ this.nativeStorage.removeItem(this.storageKey);
1693
+ }
1694
+ get nativeStorage() {
1695
+ return this.storageTarget === 'session' ? sessionStorage : localStorage;
1696
+ }
1697
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: CsrfService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1698
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: CsrfService });
1699
+ }
1700
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: CsrfService, decorators: [{
1701
+ type: Injectable
1702
+ }], ctorParameters: () => [] });
1703
+ const DEFAULT_HEADER_NAME = 'X-CSRF-Token';
1704
+ const DEFAULT_METHODS = ['POST', 'PUT', 'PATCH', 'DELETE'];
1705
+ /**
1706
+ * Functional HTTP interceptor that injects the current CSRF token as a request header on
1707
+ * state-changing methods. When the token is absent, the header is omitted silently.
1708
+ *
1709
+ * Register via `provideHttpClient(withInterceptors([withCsrfHeader()]))`.
1710
+ *
1711
+ * @example
1712
+ * bootstrapApplication(App, {
1713
+ * providers: [
1714
+ * provideSecurity({ enableCsrf: true }),
1715
+ * provideHttpClient(withInterceptors([withCsrfHeader()])),
1716
+ * ],
1717
+ * });
1718
+ */
1719
+ function withCsrfHeader(options = {}) {
1720
+ const headerName = options.headerName ?? DEFAULT_HEADER_NAME;
1721
+ const methods = new Set(options.methods ?? DEFAULT_METHODS);
1722
+ return (request, next) => {
1723
+ if (!methods.has(request.method.toUpperCase())) {
1724
+ return next(request);
1725
+ }
1726
+ const csrf = inject(CsrfService);
1727
+ const token = csrf.getToken();
1728
+ if (!token)
1729
+ return next(request);
1730
+ return next(request.clone({ setHeaders: { [headerName]: token } }));
1731
+ };
1732
+ }
1733
+
1734
+ const DEFAULT_EVENTS = ['mousemove', 'keydown', 'mousedown', 'touchstart', 'scroll'];
1735
+ class SessionIdleService {
1736
+ ngZone = inject(NgZone);
1737
+ document = inject(DOCUMENT);
1738
+ injector = inject(Injector);
1739
+ destroyRef = inject(DestroyRef);
1740
+ _isIdle = signal(false, ...(ngDevMode ? [{ debugName: "_isIdle" }] : /* istanbul ignore next */ []));
1741
+ _isWarning = signal(false, ...(ngDevMode ? [{ debugName: "_isWarning" }] : /* istanbul ignore next */ []));
1742
+ _timeRemaining = signal(null, ...(ngDevMode ? [{ debugName: "_timeRemaining" }] : /* istanbul ignore next */ []));
1743
+ _timeoutSubject = new Subject();
1744
+ isIdle = this._isIdle.asReadonly();
1745
+ isWarning = this._isWarning.asReadonly();
1746
+ timeRemaining = this._timeRemaining.asReadonly();
1747
+ onTimeout = this._timeoutSubject.asObservable();
1748
+ config = null;
1749
+ lastActivityTime = 0;
1750
+ timerInterval = null;
1751
+ eventSubscription;
1752
+ constructor() {
1753
+ this.destroyRef.onDestroy(() => this.stop());
1754
+ }
1755
+ start(config) {
1756
+ this.stop();
1757
+ this.config = config;
1758
+ this._isIdle.set(false);
1759
+ this._isWarning.set(false);
1760
+ this._timeRemaining.set(config.timeoutMs);
1761
+ this.lastActivityTime = Date.now();
1762
+ const eventsToTrack = config.events || DEFAULT_EVENTS;
1763
+ this.ngZone.runOutsideAngular(() => {
1764
+ const observables = eventsToTrack.map((ev) => fromEvent(this.document, ev));
1765
+ this.eventSubscription = merge(...observables)
1766
+ .pipe(throttleTime(500))
1767
+ .subscribe(() => {
1768
+ this.lastActivityTime = Date.now();
1769
+ });
1770
+ this.timerInterval = setInterval(() => this.checkIdle(), 1000);
1771
+ });
1772
+ }
1773
+ stop() {
1774
+ if (this.timerInterval) {
1775
+ clearInterval(this.timerInterval);
1776
+ this.timerInterval = null;
1777
+ }
1778
+ if (this.eventSubscription) {
1779
+ this.eventSubscription.unsubscribe();
1780
+ this.eventSubscription = undefined;
1781
+ }
1782
+ this.config = null;
1783
+ this._timeRemaining.set(null);
1784
+ this._isIdle.set(false);
1785
+ this._isWarning.set(false);
1786
+ }
1787
+ reset() {
1788
+ if (!this.config)
1789
+ return;
1790
+ this.lastActivityTime = Date.now();
1791
+ this._timeRemaining.set(this.config.timeoutMs);
1792
+ this._isIdle.set(false);
1793
+ this._isWarning.set(false);
1794
+ }
1795
+ checkIdle() {
1796
+ if (!this.config || this._isIdle())
1797
+ return;
1798
+ const elapsed = Date.now() - this.lastActivityTime;
1799
+ const remaining = Math.max(0, this.config.timeoutMs - elapsed);
1800
+ if (remaining === 0) {
1801
+ this.triggerTimeout();
1802
+ }
1803
+ else {
1804
+ const warningThreshold = this.config.warningThresholdMs || 0;
1805
+ const shouldBeWarning = remaining <= warningThreshold;
1806
+ this.ngZone.run(() => {
1807
+ this._timeRemaining.set(remaining);
1808
+ if (this._isWarning() !== shouldBeWarning) {
1809
+ this._isWarning.set(shouldBeWarning);
1810
+ }
1811
+ });
1812
+ }
1813
+ }
1814
+ triggerTimeout() {
1815
+ const config = this.config;
1816
+ this.stop();
1817
+ // Restore config temporarily to check for autoClear flags
1818
+ this.config = config;
1819
+ this.ngZone.run(() => {
1820
+ this._timeRemaining.set(0);
1821
+ this._isIdle.set(true);
1822
+ this._timeoutSubject.next();
1823
+ if (this.config?.autoClearStorage) {
1824
+ const storage = this.injector.get(SecureStorageService, null);
1825
+ if (storage)
1826
+ storage.clear();
1827
+ }
1828
+ if (this.config?.autoClearClipboard) {
1829
+ const clipboard = this.injector.get(SensitiveClipboardService, null);
1830
+ if (clipboard)
1831
+ clipboard.clear();
1832
+ }
1833
+ this.config = null;
1834
+ });
1835
+ }
1836
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SessionIdleService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1837
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SessionIdleService, providedIn: 'root' });
1838
+ }
1839
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SessionIdleService, decorators: [{
1840
+ type: Injectable,
1841
+ args: [{
1842
+ providedIn: 'root',
1843
+ }]
1844
+ }], ctorParameters: () => [] });
1845
+
1846
+ const REPLAY_WINDOW_MS = 30_000;
1847
+ class SecureMessageService {
1848
+ ngZone = inject(NgZone);
1849
+ platformId = inject(PLATFORM_ID);
1850
+ document = inject(DOCUMENT);
1851
+ crypto = inject(WebCryptoService);
1852
+ config = null;
1853
+ _lastMessage = signal(null, ...(ngDevMode ? [{ debugName: "_lastMessage" }] : /* istanbul ignore next */ []));
1854
+ _messages$ = new Subject();
1855
+ messageHandler = null;
1856
+ get targetWindow() {
1857
+ return this.document.defaultView;
1858
+ }
1859
+ /** Returns a new HMAC-SHA-256 CryptoKey ready to use in `configure()`. */
1860
+ generateChannelKey() {
1861
+ return this.crypto.generateHmacKey('HMAC-SHA-256');
1862
+ }
1863
+ /**
1864
+ * Configures the service with a signing key and allowed origins.
1865
+ * Starts listening for incoming messages.
1866
+ */
1867
+ configure(config) {
1868
+ this.destroy();
1869
+ this.config = config;
1870
+ if (!isPlatformBrowser(this.platformId))
1871
+ return;
1872
+ this.ngZone.runOutsideAngular(() => {
1873
+ this.messageHandler = (event) => this.handleMessage(event);
1874
+ this.targetWindow.addEventListener('message', this.messageHandler);
1875
+ });
1876
+ }
1877
+ /**
1878
+ * Signs and sends a payload to a target window.
1879
+ * @throws if `targetOrigin` is `'*'`
1880
+ */
1881
+ async send(target, payload, targetOrigin) {
1882
+ if (targetOrigin === '*') {
1883
+ throw new Error('SecureMessageService: targetOrigin must be an explicit origin, not "*".');
1884
+ }
1885
+ if (!this.config) {
1886
+ throw new Error('SecureMessageService: call configure() before send().');
1887
+ }
1888
+ const timestamp = Date.now();
1889
+ const nonce = this.targetWindow.crypto.randomUUID();
1890
+ const body = { payload, timestamp, nonce };
1891
+ const signature = await this.crypto.sign(this.config.signingKey, JSON.stringify(body));
1892
+ const envelope = { __signed: true, ...body, signature };
1893
+ target.postMessage(envelope, targetOrigin);
1894
+ }
1895
+ /** Observable of verified incoming messages. */
1896
+ messages$() {
1897
+ return this._messages$.asObservable();
1898
+ }
1899
+ /** Signal with the last verified incoming message (null before first message). */
1900
+ lastMessage() {
1901
+ return this._lastMessage;
1902
+ }
1903
+ /** Removes the window listener and completes the internal Subject. */
1904
+ destroy() {
1905
+ if (this.messageHandler && isPlatformBrowser(this.platformId)) {
1906
+ this.targetWindow.removeEventListener('message', this.messageHandler);
1907
+ this.messageHandler = null;
1908
+ }
1909
+ this.config = null;
1910
+ }
1911
+ async handleMessage(event) {
1912
+ if (!this.config)
1913
+ return;
1914
+ // 1. Origin whitelist
1915
+ if (!this.config.allowedOrigins.includes(event.origin))
1916
+ return;
1917
+ // 2. Envelope shape
1918
+ const env = event.data;
1919
+ if (env?.__signed !== true)
1920
+ return;
1921
+ const { payload, timestamp, nonce, signature } = env;
1922
+ if (!payload || !timestamp || !nonce || !signature)
1923
+ return;
1924
+ // 3. Replay window
1925
+ if (Date.now() - timestamp > REPLAY_WINDOW_MS)
1926
+ return;
1927
+ // 4. Signature verification
1928
+ const body = { payload, timestamp, nonce };
1929
+ const valid = await this.crypto.verify(this.config.signingKey, JSON.stringify(body), signature);
1930
+ if (!valid)
1931
+ return;
1932
+ // 5. Emit inside NgZone so signals trigger CD
1933
+ this.ngZone.run(() => {
1934
+ const message = { data: payload, origin: event.origin, timestamp };
1935
+ this._lastMessage.set(message);
1936
+ this._messages$.next(message);
1937
+ });
1938
+ }
1939
+ static ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SecureMessageService, deps: [], target: i0.ɵɵFactoryTarget.Injectable });
1940
+ static ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SecureMessageService, providedIn: 'root' });
1941
+ }
1942
+ i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "21.2.4", ngImport: i0, type: SecureMessageService, decorators: [{
1943
+ type: Injectable,
1944
+ args: [{
1945
+ providedIn: 'root',
1946
+ }]
1015
1947
  }] });
1016
1948
 
1017
1949
  const defaultSecurityConfig = {
@@ -1020,27 +1952,45 @@ const defaultSecurityConfig = {
1020
1952
  enableSecureStorage: false,
1021
1953
  enableInputSanitizer: false,
1022
1954
  enablePasswordStrength: false,
1955
+ enableJwt: false,
1956
+ enableSensitiveClipboard: false,
1957
+ enableHibp: false,
1958
+ enableRateLimiter: false,
1959
+ enableCsrf: false,
1960
+ enableSessionIdle: false,
1961
+ enableSecureMessage: false,
1023
1962
  defaultTimeout: 5000,
1024
1963
  safeMode: false,
1025
1964
  };
1026
1965
  function provideSecurity(config = {}) {
1027
1966
  const mergedConfig = { ...defaultSecurityConfig, ...config };
1028
1967
  const providers = [];
1029
- if (mergedConfig.enableRegexSecurity) {
1968
+ if (mergedConfig.enableRegexSecurity)
1030
1969
  providers.push(RegexSecurityService);
1031
- }
1032
- if (mergedConfig.enableWebCrypto) {
1970
+ if (mergedConfig.enableWebCrypto)
1033
1971
  providers.push(WebCryptoService);
1034
- }
1035
- if (mergedConfig.enableSecureStorage) {
1972
+ if (mergedConfig.enableSecureStorage)
1036
1973
  providers.push(SecureStorageService);
1037
- }
1038
- if (mergedConfig.enableInputSanitizer) {
1974
+ if (mergedConfig.enableInputSanitizer)
1039
1975
  providers.push(InputSanitizerService);
1040
- }
1041
- if (mergedConfig.enablePasswordStrength) {
1976
+ if (mergedConfig.enablePasswordStrength)
1042
1977
  providers.push(PasswordStrengthService);
1043
- }
1978
+ if (mergedConfig.enableJwt)
1979
+ providers.push(JwtService);
1980
+ if (mergedConfig.enableSensitiveClipboard)
1981
+ providers.push(SensitiveClipboardService);
1982
+ if (mergedConfig.enableHibp) {
1983
+ providers.push(WebCryptoService, HibpService);
1984
+ }
1985
+ if (mergedConfig.enableRateLimiter)
1986
+ providers.push(RateLimiterService);
1987
+ if (mergedConfig.enableCsrf) {
1988
+ providers.push(WebCryptoService, CsrfService);
1989
+ }
1990
+ if (mergedConfig.enableSessionIdle)
1991
+ providers.push(SessionIdleService);
1992
+ if (mergedConfig.enableSecureMessage)
1993
+ providers.push(SecureMessageService);
1044
1994
  return makeEnvironmentProviders(providers);
1045
1995
  }
1046
1996
  function provideRegexSecurity() {
@@ -1064,9 +2014,41 @@ function provideInputSanitizer(config) {
1064
2014
  function providePasswordStrength() {
1065
2015
  return makeEnvironmentProviders([PasswordStrengthService]);
1066
2016
  }
2017
+ function provideJwt() {
2018
+ return makeEnvironmentProviders([JwtService]);
2019
+ }
2020
+ function provideSensitiveClipboard() {
2021
+ return makeEnvironmentProviders([SensitiveClipboardService]);
2022
+ }
2023
+ function provideHibp(config) {
2024
+ return makeEnvironmentProviders([
2025
+ WebCryptoService,
2026
+ HibpService,
2027
+ ...(config ? [{ provide: HIBP_CONFIG, useValue: config }] : []),
2028
+ ]);
2029
+ }
2030
+ function provideRateLimiter(config) {
2031
+ return makeEnvironmentProviders([
2032
+ RateLimiterService,
2033
+ ...(config ? [{ provide: RATE_LIMITER_CONFIG, useValue: config }] : []),
2034
+ ]);
2035
+ }
2036
+ function provideCsrf(config) {
2037
+ return makeEnvironmentProviders([
2038
+ WebCryptoService,
2039
+ CsrfService,
2040
+ ...(config ? [{ provide: CSRF_CONFIG, useValue: config }] : []),
2041
+ ]);
2042
+ }
2043
+ function provideSessionIdle() {
2044
+ return makeEnvironmentProviders([SessionIdleService]);
2045
+ }
2046
+ function provideSecureMessage() {
2047
+ return makeEnvironmentProviders([WebCryptoService, SecureMessageService]);
2048
+ }
1067
2049
 
1068
2050
  /**
1069
2051
  * Generated bundle index. Do not edit.
1070
2052
  */
1071
2053
 
1072
- export { InputSanitizerService, PasswordStrengthService, RegexSecurityBuilder, RegexSecurityService, SANITIZER_CONFIG, SECURE_STORAGE_CONFIG, SecureStorageService, WebCryptoService, defaultSecurityConfig, provideInputSanitizer, providePasswordStrength, provideRegexSecurity, provideSecureStorage, provideSecurity, provideWebCrypto };
2054
+ export { CSRF_CONFIG, ClipboardUnsupportedError, CsrfService, DEFAULT_ALLOWED_ATTRIBUTES, DEFAULT_ALLOWED_TAGS, HIBP_CONFIG, HibpService, InputSanitizerService, InvalidJwtError, JwtService, PasswordStrengthService, RATE_LIMITER_CONFIG, RateLimitExceededError, RateLimiterService, RegexSecurityBuilder, RegexSecurityService, SANITIZER_CONFIG, SECURE_STORAGE_CONFIG, SecureMessageService, SecureStorageService, SensitiveClipboardService, SessionIdleService, WebCryptoService, assessPasswordStrength, containsScriptInjection, containsSqlInjectionHints, defaultSecurityConfig, isHtmlSafe, isUrlSafe, provideCsrf, provideHibp, provideInputSanitizer, provideJwt, providePasswordStrength, provideRateLimiter, provideRegexSecurity, provideSecureMessage, provideSecureStorage, provideSecurity, provideSensitiveClipboard, provideSessionIdle, provideWebCrypto, sanitizeHtmlString, sanitizeUrlString, withCsrfHeader };