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