-
Notifications
You must be signed in to change notification settings - Fork 4
Expand file tree
/
Copy pathjwt_auth.rs
More file actions
3011 lines (2692 loc) · 119 KB
/
Copy pathjwt_auth.rs
File metadata and controls
3011 lines (2692 loc) · 119 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
use std::borrow::Cow;
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, LazyLock, Weak};
use tokio::sync::Mutex;
use std::time::{Duration, Instant};
use arc_swap::ArcSwap;
use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet, PublicKeyUse};
use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, TokenData, Validation};
use openssl::x509::X509;
use regex::Regex;
use serde_json::Value;
use vector_lib::configurable::configurable_component;
use vector_lib::event::Event;
use vrl::path::{parse_target_path, OwnedTargetPath};
use crate::http::HttpClient;
/// Shorthand for the decoded JWT claims map used throughout this module.
type Claims = serde_json::Map<String, Value>;
/// Pre-parsed path for the `auth_field_name` log/trace metadata field.
pub(crate) static AUTH_FIELD_NAME_PATH: LazyLock<OwnedTargetPath> =
LazyLock::new(|| parse_target_path("auth_field_name").expect("valid static path"));
/// Pre-parsed path for the `auth_field_value` log/trace metadata field.
pub(crate) static AUTH_FIELD_VALUE_PATH: LazyLock<OwnedTargetPath> =
LazyLock::new(|| parse_target_path("auth_field_value").expect("valid static path"));
/// Metric tag key for the auth field name (metrics use plain string keys).
pub(crate) const AUTH_FIELD_NAME_TAG: &str = "auth_field_name";
/// Metric tag key for the auth field value.
pub(crate) const AUTH_FIELD_VALUE_TAG: &str = "auth_field_value";
/// JWT claim carrying the site's version at token-issue time (stamped by the
/// manager's auth-service — OBE-9896). Read for telemetry / per-version policy;
/// absent for older sites whose tokens predate the claim.
const SITE_VERSION_CLAIM: &str = "site_version";
/// Errors returned by [`Auth::authenticate`] (request-level).
#[derive(Debug, PartialEq)]
pub enum AuthError {
/// The `authorization` header was present but the token is invalid, malformed, expired,
/// or failed signature verification.
///
/// Maps to HTTP 401 / gRPC `Unauthenticated`. Reject the entire request.
InvalidToken(&'static str),
}
/// Errors produced by [`EventValidator::check`] (per-event).
///
/// Named after the equivalent HTTP status codes so the mapping to gRPC response
/// codes and metric outcome labels is unambiguous.
#[derive(Debug, Clone, PartialEq)]
pub enum AuthEventError {
/// The configured auth field was absent from the event or held a non-string value.
///
/// The request JWT itself was valid — only the per-event authorization field is missing.
AuthorizationMissing,
/// The field value was present but is not listed in the token's membership claim.
///
/// Equivalent to HTTP 403 — identity is known but not permitted.
/// Maps to gRPC `PermissionDenied`.
Forbidden,
}
impl AuthEventError {
/// Short label used as a metric tag value for the `outcome` dimension.
pub fn label(&self) -> &'static str {
match self {
AuthEventError::AuthorizationMissing => "authorization_missing",
AuthEventError::Forbidden => "forbidden",
}
}
}
/// Compiled form of the JWT membership-claim configuration.
///
/// Built once in [`AuthConfig::build`] from the raw config strings and stored
/// in [`Inner`] so the hot path (per-request token extraction) pays no
/// allocation or compilation cost.
#[derive(Clone, Debug)]
pub enum MembershipClaim {
/// Direct array lookup: all string values in the named claim are returned
/// as the allowed-values set.
Identity(String),
/// Regex-filtered lookup: only values from the named claim that produce a
/// match under the compiled pattern are included in the allowed-values set.
/// The matched substring (not the full value) is what enters the set.
Regexp(String, Regex),
}
impl MembershipClaim {
fn claim_name(&self) -> &str {
match self {
MembershipClaim::Identity(name) | MembershipClaim::Regexp(name, _) => name.as_str(),
}
}
/// Extract the allowed-values set from a decoded token's claims map.
///
/// Returns `Err(InvalidToken)` if the named claim is absent, has the wrong
/// type (`Identity` requires a JSON array; `Regexp` requires a JSON string),
/// or yields an empty set (empty array, no-match, all-optional groups unmatched).
pub fn extract(
&self,
claims: &Claims,
) -> Result<BTreeSet<String>, AuthError> {
let value = claims
.get(self.claim_name())
.ok_or(AuthError::InvalidToken("token missing membership claim"))?;
let set = match self {
MembershipClaim::Identity(_) => {
// Expects a JSON array.
let array = value
.as_array()
.ok_or(AuthError::InvalidToken(
"token missing membership claim (or is not a list of strings)",
))?;
let mut set = BTreeSet::new();
for v in array.iter().filter_map(Value::as_str) {
set.insert(v.to_owned());
}
set
}
MembershipClaim::Regexp(_, re) => {
// Expects a JSON string — standard for scalar claims like `email`.
let s = value
.as_str()
.ok_or(AuthError::InvalidToken("membership claim must be a string"))?;
let caps = re
.captures(s)
.ok_or(AuthError::InvalidToken("token missing membership claim"))?;
let mut set = BTreeSet::new();
// skip(1): index 0 is the full match; only explicit capture groups enter the set.
for m in caps.iter().skip(1).flatten() {
set.insert(m.as_str().to_owned());
}
set
}
};
if set.is_empty() {
return Err(AuthError::InvalidToken("token missing membership claim"));
}
Ok(set)
}
}
/// Source of a PEM value — either inline or loaded from a file at startup.
///
/// Used by both [`Authority::PublicKey`] (bare RSA public key PEM) and
/// [`Authority::TlsCert`] (X.509 certificate PEM). The semantic distinction
/// between "this is a public key" and "this is a certificate" is carried by
/// the [`Authority`] variant; this type only models the I/O shape.
///
/// ## Examples
///
/// Inline (use Vector's `${VAR}` interpolation for env vars):
/// ```toml
/// pub_key.type = "inline"
/// pub_key.value = "${RSA_PUBLIC_KEY}"
/// ```
///
/// File path (preferred for Kubernetes ConfigMap / secret volume mounts — the
/// file is read once at source startup):
/// ```toml
/// pub_key.type = "file"
/// pub_key.path = "/etc/certs/auth.pem"
/// ```
#[configurable_component]
#[derive(Clone, Debug)]
#[serde(rename_all = "snake_case", tag = "type", deny_unknown_fields)]
pub enum AuthorityData {
/// Inline PEM value.
///
/// Supports Vector's `${ENV_VAR}` interpolation. The value is read once at startup.
Inline {
/// PEM-encoded value (RSA public key or X.509 certificate, depending
/// on the enclosing [`Authority`] variant).
value: String,
},
/// Path to a file containing the PEM.
///
/// Preferred for Kubernetes ConfigMap or secret volume mounts.
/// The file is read once at source startup.
File {
/// Path to the PEM file.
path: String,
},
}
/// JWKS endpoint source (Keycloak / Auth0 / Okta / Cognito / Google / any
/// OIDC-compliant IdP). The JWKS is fetched at startup, indexed by `kid`,
/// and refreshed both periodically and reactively when a token arrives with
/// a `kid` not in the cache.
///
/// Selected via the [`Authority::Jwks`] variant on [`AuthConfig`].
///
/// ## Example
///
/// ```toml
/// [sources.my_source.auth.jwks]
/// jwks_url = "https://kc.example/realms/master/protocol/openid-connect/certs"
/// refresh_interval_secs = 300
/// ```
#[configurable_component]
#[derive(Clone, Debug)]
#[serde(deny_unknown_fields)]
pub struct JwksAuthority {
/// URL of the JWKS endpoint. Must return a JSON document of the form
/// `{"keys": [<JWK>...]}` as defined by RFC 7517.
pub jwks_url: String,
/// Background refresh interval, in seconds. Default: 300 (5 minutes).
#[serde(default = "default_jwks_refresh_interval_secs")]
pub refresh_interval_secs: u64,
/// Per-fetch timeout, in seconds. Applies to both the initial fetch
/// and subsequent refreshes. Default: 10.
#[serde(default = "default_jwks_fetch_timeout_secs")]
pub fetch_timeout_secs: u64,
/// Minimum interval between reactive (on-unknown-kid) refreshes, in
/// seconds. Acts as a cooldown to prevent refresh storms triggered by
/// adversarial traffic. Default: 30.
#[serde(default = "default_jwks_min_reactive_refresh_secs")]
pub min_reactive_refresh_secs: u64,
}
const fn default_jwks_refresh_interval_secs() -> u64 {
86400 // 1 day
}
const fn default_jwks_fetch_timeout_secs() -> u64 {
60
}
const fn default_jwks_min_reactive_refresh_secs() -> u64 {
900 // 15 minutes
}
/// Event field paths used to extract the membership value for per-event auth.
///
/// The `default` path is used for all event types unless a more specific override is set.
/// For metric events, `metric_tag` is a tag key rather than a field path.
///
/// ## Example
///
/// ```toml
/// [sources.my_source.auth.value_path]
/// default = "tenant_id"
/// metric_tag = "tenant_id"
/// ```
#[configurable_component]
#[derive(Clone, Debug)]
#[serde(deny_unknown_fields)]
pub struct AuthValuePath {
/// Field path (or metric tag key) used for all event types unless a type-specific
/// override is configured.
pub default: String,
/// Field path for log events. Overrides `default` when set.
#[serde(skip_serializing_if = "Option::is_none")]
pub log: Option<String>,
/// Tag key for metric events. Overrides `default` when set.
///
/// Note: for metrics `default` is also interpreted as a tag key if this field is absent.
#[serde(skip_serializing_if = "Option::is_none")]
pub metric_tag: Option<String>,
/// Field path for trace events. Overrides `default` when set.
#[serde(skip_serializing_if = "Option::is_none")]
pub trace: Option<String>,
}
impl AuthValuePath {
/// Returns the effective field path for a log event.
pub fn for_log(&self) -> &str {
self.log.as_deref().unwrap_or(&self.default)
}
/// Returns the effective tag key for a metric event.
pub fn for_metric(&self) -> &str {
self.metric_tag.as_deref().unwrap_or(&self.default)
}
/// Returns the effective field path for a trace event.
pub fn for_trace(&self) -> &str {
self.trace.as_deref().unwrap_or(&self.default)
}
}
/// A pre-parsed event path paired with the original user-configured name.
///
/// The `name` is what gets stamped onto authorized events as the
/// `auth_field_name` metadata. The `path` is the parsed form used for the
/// per-event lookup — built once at config load so the hot path skips the
/// VRL path parser.
#[derive(Debug)]
pub struct CompiledPath {
pub(crate) name: String,
pub(crate) path: OwnedTargetPath,
}
impl CompiledPath {
fn new(s: &str) -> Result<Self, vrl::path::PathParseError> {
Ok(Self {
name: s.to_string(),
path: parse_target_path(s)?,
})
}
}
/// Runtime form of [`AuthValuePath`] with paths pre-parsed.
///
/// Built once by [`AuthConfig::build`]; held inside the `Arc<Inner>` so every
/// `EventValidator` borrows it for free.
#[derive(Debug)]
pub struct CompiledValuePath {
pub(crate) log: CompiledPath,
/// Metric tag keys are plain strings, not paths — no parse step.
pub(crate) metric_tag: String,
pub(crate) trace: CompiledPath,
}
impl TryFrom<&AuthValuePath> for CompiledValuePath {
type Error = vrl::path::PathParseError;
fn try_from(vp: &AuthValuePath) -> Result<Self, Self::Error> {
Ok(Self {
log: CompiledPath::new(vp.log.as_deref().unwrap_or(&vp.default))?,
metric_tag: vp.metric_tag.as_deref().unwrap_or(&vp.default).to_string(),
trace: CompiledPath::new(vp.trace.as_deref().unwrap_or(&vp.default))?,
})
}
}
/// Stamp the auth field name/value onto an authorized event.
///
/// Uses pre-parsed [`OwnedTargetPath`]s for log/trace inserts so the hot path
/// avoids re-parsing `"auth_field_name"` / `"auth_field_value"` per event.
pub fn add_auth_metadata(event: &mut Event, name: &str, value: &str) {
match event {
Event::Log(log) => {
log.insert(&*AUTH_FIELD_NAME_PATH, name);
log.insert(&*AUTH_FIELD_VALUE_PATH, value);
}
Event::Metric(metric) => {
metric.replace_tag(AUTH_FIELD_NAME_TAG.to_owned(), name.to_owned());
metric.replace_tag(AUTH_FIELD_VALUE_TAG.to_owned(), value.to_owned());
}
Event::Trace(trace) => {
trace.insert(&*AUTH_FIELD_NAME_PATH, name);
trace.insert(&*AUTH_FIELD_VALUE_PATH, value);
}
}
}
/// JWT signing algorithm.
///
/// Only applicable when `authority` is `pub_key` or `tls_cert`. For the
/// `jwks` authority, accepted algorithms are derived automatically from the
/// keys published by the JWKS endpoint — setting `algorithms` together with
/// `jwks` is a configuration error.
#[configurable_component]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum AuthAlgorithm {
/// RSASSA-PKCS1-v1_5 using SHA-256.
#[serde(rename = "RS256")]
Rs256,
/// RSASSA-PKCS1-v1_5 using SHA-384.
#[serde(rename = "RS384")]
Rs384,
/// RSASSA-PKCS1-v1_5 using SHA-512.
#[serde(rename = "RS512")]
Rs512,
/// RSASSA-PSS using SHA-256.
#[serde(rename = "PS256")]
Ps256,
/// RSASSA-PSS using SHA-384.
#[serde(rename = "PS384")]
Ps384,
/// RSASSA-PSS using SHA-512.
#[serde(rename = "PS512")]
Ps512,
/// ECDSA using P-256 and SHA-256. Requires `jwks` authority.
#[serde(rename = "ES256")]
Es256,
/// ECDSA using P-384 and SHA-384. Requires `jwks` authority.
#[serde(rename = "ES384")]
Es384,
}
impl From<AuthAlgorithm> for Algorithm {
fn from(a: AuthAlgorithm) -> Self {
match a {
AuthAlgorithm::Rs256 => Algorithm::RS256,
AuthAlgorithm::Rs384 => Algorithm::RS384,
AuthAlgorithm::Rs512 => Algorithm::RS512,
AuthAlgorithm::Ps256 => Algorithm::PS256,
AuthAlgorithm::Ps384 => Algorithm::PS384,
AuthAlgorithm::Ps512 => Algorithm::PS512,
AuthAlgorithm::Es256 => Algorithm::ES256,
AuthAlgorithm::Es384 => Algorithm::ES384,
}
}
}
/// Default allowlist: full RSA family.
///
/// Covers all real-world IdPs using RSA public keys. EC variants (`ES*`) are
/// intentionally excluded from the default — they require the `jwks` authority
/// and must be opted into explicitly so that existing static-PEM configurations
/// are not silently affected.
///
/// Excludes:
/// - HMAC (`HS*`): wrong key type; enables the well-known RS↔HS confusion attack
/// - `none`: never accepted by jsonwebtoken regardless
pub(crate) fn default_algorithms() -> Vec<AuthAlgorithm> {
vec![
AuthAlgorithm::Rs256,
AuthAlgorithm::Rs384,
AuthAlgorithm::Rs512,
AuthAlgorithm::Ps256,
AuthAlgorithm::Ps384,
AuthAlgorithm::Ps512,
]
}
/// Source of the RSA public key used to verify auth token signatures.
///
/// Exactly one variant must be configured. Flattened into [`AuthConfig`], so the
/// variant key sits directly under `[auth]`:
///
/// ```toml
/// [auth]
/// pub_key.type = "inline"
/// pub_key.value = "${RSA_PUBLIC_KEY}"
/// ```
///
/// or
///
/// ```toml
/// [auth]
/// tls_cert.type = "file"
/// tls_cert.path = "/etc/pki/tls/certs/jwt-signer.crt"
/// ```
#[configurable_component]
#[derive(Clone, Debug)]
#[serde(rename_all = "snake_case", deny_unknown_fields)]
pub enum Authority {
/// Bare RSA public key PEM (`BEGIN PUBLIC KEY` / `BEGIN RSA PUBLIC KEY`).
#[serde(rename = "pub_key")]
PublicKey(AuthorityData),
/// X.509 certificate PEM; the embedded public key is extracted at startup.
///
/// Useful when the JWT signer's key is distributed as a TLS / trust-bundle
/// certificate. Only the public key bytes are kept at runtime — certificate
/// validity windows, issuer chains, and revocation status are **not** checked.
TlsCert(AuthorityData),
/// JWKS endpoint (Keycloak / Auth0 / Okta / Cognito / any OIDC IdP).
/// Multi-key, refreshes both periodically and reactively on unknown `kid`.
Jwks(JwksAuthority),
}
impl Authority {
/// Resolve the configured source into a runtime [`KeyStore`].
///
/// For static variants this is a synchronous PEM parse. For [`Self::Jwks`]
/// this performs the initial HTTPS fetch and spawns the background
/// refresh task — fail-fast if the endpoint is unreachable.
async fn build_key_store(&self) -> crate::Result<KeyStore> {
match self {
Authority::PublicKey(pk) => Self::static_key_from_pem(&pk.load("pub_key")?),
Authority::TlsCert(cert) => {
let pem = Self::extract_public_key_pem_from_cert_pem(&cert.load("tls_cert")?)?;
Self::static_key_from_pem(&pem)
}
Authority::Jwks(cfg) => Ok(KeyStore::Jwks(JwksCache::new(cfg).await?)),
}
}
fn static_key_from_pem(pem: &str) -> crate::Result<KeyStore> {
let key = DecodingKey::from_rsa_pem(pem.as_bytes())
.map_err(|error| format!("Failed to parse RSA public key PEM: {error}"))?;
Ok(KeyStore::Static(Arc::new(key)))
}
/// Build the [`Validation`] for this authority.
///
/// For static PEM authorities, validates the algorithm allowlist and builds a
/// `Validation` pinned to the configured (or default) algorithms. For JWKS,
/// rejects an explicit `algorithms` field (they are derived from the endpoint)
/// and returns a base `Validation` used as a template for the per-alg map.
fn build_validation(
&self,
algorithms: Option<&[AuthAlgorithm]>,
) -> crate::Result<Validation> {
match self {
Authority::Jwks(_) => {
if algorithms.is_some() {
return Err(
"auth.algorithms is not applicable with `jwks` authority; \
accepted algorithms are derived from the keys published by the \
JWKS endpoint"
.into(),
);
}
Ok(Validation::new(Algorithm::RS256)) // base for per-alg map; not used directly
}
_ => {
let default_algos = default_algorithms();
let algos = algorithms.unwrap_or(&default_algos);
if algos.is_empty() {
return Err("auth.algorithms must contain at least one algorithm".into());
}
// Seed with the first algorithm, then overwrite with the full list.
// jsonwebtoken checks the token's `alg` header against this list and
// rejects anything not present — this is what prevents alg:none and
// RS↔HS confusion attacks.
let mut v = Validation::new(algos[0].into());
v.algorithms = algos.iter().copied().map(Algorithm::from).collect();
Ok(v)
}
}
}
/// Build per-algorithm [`Validation`] objects for the JWKS hot path.
///
/// Returns a non-empty map only for [`Self::Jwks`]; all other variants return
/// an empty map (static PEM uses `validation` directly).
fn expand_jwks_validations(&self, base: &Validation) -> HashMap<Algorithm, Validation> {
match self {
Authority::Jwks(_) => [
Algorithm::RS256,
Algorithm::RS384,
Algorithm::RS512,
Algorithm::PS256,
Algorithm::PS384,
Algorithm::PS512,
Algorithm::ES256,
Algorithm::ES384,
]
.into_iter()
.map(|alg| {
let mut v = base.clone();
v.algorithms = vec![alg];
(alg, v)
})
.collect(),
_ => HashMap::new(),
}
}
/// Parse an X.509 certificate PEM and emit a `BEGIN PUBLIC KEY` (SPKI) PEM of its
/// embedded public key — the form `jsonwebtoken::DecodingKey::from_rsa_pem` accepts.
fn extract_public_key_pem_from_cert_pem(cert_pem: &str) -> crate::Result<String> {
let cert = X509::from_pem(cert_pem.as_bytes())
.map_err(|error| format!("Failed to parse X.509 certificate PEM: {error}"))?;
let pubkey = cert
.public_key()
.map_err(|error| format!("Failed to extract public key from certificate: {error}"))?;
let pem_bytes = pubkey
.public_key_to_pem()
.map_err(|error| format!("Failed to encode extracted public key as PEM: {error}"))?;
String::from_utf8(pem_bytes)
.map_err(|error| format!("Extracted public key PEM was not valid UTF-8: {error}").into())
}
}
/// Runtime verification key material. Two shapes:
///
/// - [`Self::Static`]: a single [`DecodingKey`] resolved at startup. The hot
/// path is a single pointer deref — no locks, no allocation.
/// - [`Self::Jwks`]: an [`ArcSwap`]-backed map keyed by `kid`. Reads are
/// lock-free atomic pointer loads; the background refresher swaps in a new
/// map on each successful fetch. Designed for the millions-of-requests-per-
/// second hot path.
enum KeyStore {
Static(Arc<DecodingKey>),
Jwks(Arc<JwksCache>),
}
/// Decoded JWKS, indexed by `kid`.
type KeyMap = BTreeMap<String, DecodingKey>;
/// Shared cache backing the [`Authority::Jwks`] variant.
///
/// Hot-path reads go through [`Self::snapshot`] which returns a lock-free
/// [`arc_swap::Guard`] over the current [`KeyMap`]. Refreshes — both periodic
/// (background tokio task) and reactive (on unknown `kid`) — produce a new
/// [`KeyMap`] and call [`ArcSwap::store`] to publish it atomically.
struct JwksCache {
keys: ArcSwap<KeyMap>,
fetcher: JwksFetcher,
/// Last-refresh timestamp guarding the reactive-refresh cooldown.
last_refresh: Mutex<Instant>,
min_reactive_refresh: Duration,
}
impl JwksCache {
/// Construct, perform the initial fetch (fail-fast), and spawn the
/// background refresh task. Returns `Arc<Self>` so the refresh task can
/// hold a `Weak` and self-terminate when the [`Auth`] is dropped.
async fn new(cfg: &JwksAuthority) -> crate::Result<Arc<Self>> {
let fetcher = JwksFetcher::new(cfg)?;
let initial = fetcher.fetch().await.map_err(|error| {
format!("auth.jwks: initial fetch from '{}' failed: {error}", cfg.jwks_url)
})?;
if initial.is_empty() {
return Err(format!(
"auth.jwks: '{}' returned no usable signing keys \
(none with `use=sig` and an RSA or EC key type)",
cfg.jwks_url
)
.into());
}
let cache = Arc::new(Self {
keys: ArcSwap::new(Arc::new(initial)),
fetcher,
// Subtract the cooldown so the very first reactive refresh is never
// blocked. Without this, a key rotated right before startup would be
// unreachable for `min_reactive_refresh_secs` seconds.
last_refresh: Mutex::new(
Instant::now() - Duration::from_secs(cfg.min_reactive_refresh_secs),
),
min_reactive_refresh: Duration::from_secs(cfg.min_reactive_refresh_secs),
});
Self::spawn_refresher(Arc::downgrade(&cache), Duration::from_secs(cfg.refresh_interval_secs));
Ok(cache)
}
fn spawn_refresher(weak: Weak<Self>, interval: Duration) {
tokio::spawn(async move {
let mut tick = tokio::time::interval(interval);
// Skip the immediate first firing — we already fetched in `new`.
tick.tick().await;
loop {
tick.tick().await;
let Some(strong) = weak.upgrade() else {
debug!(message = "JWKS refresher exiting: Auth dropped.");
return;
};
strong.try_update_keys().await;
}
});
}
/// Lock-free snapshot of the current key map. Caller holds the guard for
/// the duration of `decode` so the borrow into the map remains valid.
fn snapshot(&self) -> arc_swap::Guard<Arc<KeyMap>> {
self.keys.load()
}
/// Trigger a one-shot reactive refresh, gated by the cooldown window.
///
/// Concurrent callers: the cooldown timestamp is set *before* the network
/// fetch, so a second caller that races past the gate sees the updated
/// timestamp and returns without firing a duplicate request.
async fn refresh_if_due(&self) {
{
let mut last = self.last_refresh.lock().await;
if last.elapsed() < self.min_reactive_refresh {
return;
}
*last = Instant::now();
}
self.try_update_keys().await;
}
/// Fetch a fresh key map and atomically swap it in on success.
/// On empty response or error, keeps the previous keys and logs a warning.
async fn try_update_keys(&self) {
match self.fetcher.fetch().await {
Ok(map) if map.is_empty() => {
warn!(message = "JWKS refresh returned no usable keys; keeping previous keys.");
}
Ok(map) => {
self.keys.store(Arc::new(map));
*self.last_refresh.lock().await = Instant::now();
}
Err(error) => {
warn!(message = "JWKS refresh failed; keeping previous keys.", %error);
}
}
}
}
/// HTTPS fetcher for the JWKS endpoint.
///
/// Uses Vector's standard [`HttpClient`] so it shares TLS/proxy/user-agent
/// behavior with the rest of the binary. The [`ProxyConfig::from_env`] call
/// at construction time picks up the standard `HTTPS_PROXY` / `NO_PROXY`
/// environment variables automatically.
struct JwksFetcher {
url: http::Uri,
client: HttpClient,
timeout: Duration,
}
impl JwksFetcher {
fn new(cfg: &JwksAuthority) -> crate::Result<Self> {
let url: http::Uri = cfg.jwks_url.parse().map_err(|error| {
format!("auth.jwks.jwks_url '{}' is not a valid URL: {error}", cfg.jwks_url)
})?;
let proxy = vector_lib::config::proxy::ProxyConfig::from_env();
let client = HttpClient::new(None, &proxy, &crate::app_info())
.map_err(|error| format!("auth.jwks: failed to build HTTP client: {error}"))?;
Ok(Self {
url,
client,
timeout: Duration::from_secs(cfg.fetch_timeout_secs),
})
}
async fn fetch(&self) -> crate::Result<KeyMap> {
let request = http::Request::get(&self.url)
.header(http::header::ACCEPT, "application/json")
.body(hyper::Body::empty())
.map_err(|error| format!("failed to build JWKS request: {error}"))?;
let response = tokio::time::timeout(self.timeout, self.client.send(request))
.await
.map_err(|_| format!("timed out after {:?}", self.timeout))?
.map_err(|error| format!("HTTP request failed: {error}"))?;
if !response.status().is_success() {
return Err(format!("JWKS endpoint returned HTTP {}", response.status()).into());
}
let bytes = hyper::body::to_bytes(response.into_body())
.await
.map_err(|error| format!("failed to read JWKS response body: {error}"))?;
let jwk_set: JwkSet = serde_json::from_slice(&bytes)
.map_err(|error| format!("JWKS response is not valid JSON: {error}"))?;
Ok(self.build_key_map(jwk_set))
}
fn build_key_map(&self, jwk_set: JwkSet) -> KeyMap {
let mut map = KeyMap::new();
for jwk in &jwk_set.keys {
// Skip non-signing keys (Keycloak publishes both `enc` and `sig`).
if let Some(use_) = &jwk.common.public_key_use {
if !matches!(use_, PublicKeyUse::Signature) {
continue;
}
}
// Accept RSA and EC keys; skip symmetric (oct) and other key types.
if !matches!(
jwk.algorithm,
AlgorithmParameters::RSA(_) | AlgorithmParameters::EllipticCurve(_)
) {
continue;
}
let Some(kid) = jwk.common.key_id.clone() else {
// `kid`-less JWKS entries are unusable: we can't look them up
// per token without scanning every key. Skip with a hint.
warn!(message = "JWKS entry skipped: missing `kid`.");
continue;
};
match DecodingKey::from_jwk(jwk) {
Ok(key) => {
map.insert(kid, key);
}
Err(error) => {
warn!(message = "JWKS entry skipped: failed to build decoding key.", %error);
}
}
}
map
}
}
/// Config-layer representation of the membership claim.
///
/// Accepts either a plain claim name (string) or a claim name paired with a
/// regex pattern. The compiled [`MembershipClaimConfig`] is built once in
/// [`AuthConfig::build`] so no regex compilation happens on the hot path.
///
/// ## TOML examples
///
/// Plain (identity):
/// ```toml
/// membership_claim = "site_ids"
/// ```
///
/// Regex-filtered:
/// ```toml
/// membership_claim = { claim = "roles", pattern = "^tenant:[^:]+$" }
/// ```
#[configurable_component]
#[derive(Clone, Debug, PartialEq)]
#[serde(untagged)]
pub enum MembershipClaimConfig {
/// All string values in the named claim array are admitted as-is.
Identity(String),
/// Only claim values that match `pattern` are admitted; the matched
/// substring enters the allowed-values set.
Regexp {
/// Name of the JWT claim whose string value is matched against `pattern`.
claim: String,
/// Regex pattern applied to the claim string. All capture groups from a
/// successful match enter the allowed-values set; group 0 (full match) is excluded.
pattern: String,
},
}
impl MembershipClaimConfig {
/// Compile the config-layer claim into its runtime form.
///
/// Validates and compiles the regex pattern once at startup so the hot
/// path (`MembershipClaim::extract`) never allocates or compiles.
pub fn build(&self) -> crate::Result<MembershipClaim> {
match self {
Self::Identity(name) => Ok(MembershipClaim::Identity(name.clone())),
Self::Regexp { claim, pattern } => {
let re = Regex::new(pattern)
.map_err(|error| format!("auth.membership_claim pattern: {error}"))?;
Ok(MembershipClaim::Regexp(claim.clone(), re))
}
}
}
}
/// Auth configuration for sources.
///
/// `authority` selects the signing key source — a static PEM (`pub_key` /
/// `tls_cert`) or a live JWKS endpoint — and is flattened so its variant key
/// sits directly under `[auth]`. Static keys are parsed once at startup;
/// JWKS keys are fetched at startup and refreshed in the background.
#[configurable_component]
#[derive(Clone, Debug)]
#[serde(deny_unknown_fields)]
pub struct AuthConfig {
/// Source of the signing key used to verify auth token signatures.
///
/// Required — deserialization fails if no variant key is present.
#[serde(flatten, deserialize_with = "deserialize_authority_required")]
pub authority: Authority,
/// JWT signing algorithms accepted for token verification.
///
/// Only applicable when `authority` is `pub_key` or `tls_cert`. Tokens
/// whose `alg` header is not in this list are rejected. Pinning the
/// algorithm at the validator prevents `alg: none` and RS↔HS key-confusion
/// attacks.
///
/// Defaults to the full RSA family
/// (`RS256`/`RS384`/`RS512` + `PS256`/`PS384`/`PS512`) when omitted.
///
/// Must not be set when `authority` is `jwks` — accepted algorithms are
/// derived automatically from the JWKS endpoint.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub algorithms: Option<Vec<AuthAlgorithm>>,
/// Expected `iss` (issuer) claim.
#[serde(skip_serializing_if = "Option::is_none")]
pub issuer: Option<String>,
/// Expected `aud` (audience) claim values.
#[serde(skip_serializing_if = "Option::is_none")]
pub audience: Option<Vec<String>>,
/// JWT claim used for membership checks and per-event field stamping.
///
/// Accepts a plain claim name (`"site_ids"`) or a claim name with a regex
/// pattern (`{ claim = "roles", pattern = "^tenant:[^:]+$" }`). See
/// [`MembershipClaimConfig`] for the full format.
///
/// When absent, membership checking and field stamping are both skipped —
/// all events are accepted regardless of their field values.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub membership_claim: Option<MembershipClaimConfig>,
/// Event field paths used to extract the membership value for per-event auth.
///
/// When set, each event's field at the configured path is looked up and checked
/// against the token's membership claim. Events without a matching value are
/// filtered out. When absent, no per-event filtering is applied.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value_path: Option<AuthValuePath>,
}
/// Replace serde's flattened-enum error with an actionable message naming the
/// expected variant keys. Other errors (typos in inner fields, bad `type`
/// values) are passed through with an `auth.authority` prefix so the failing
/// config path is unambiguous.
fn deserialize_authority_required<'de, D>(d: D) -> Result<Authority, D::Error>
where
D: serde::Deserializer<'de>,
{
use serde::de::Error;
use serde::Deserialize;
Authority::deserialize(d).map_err(|original| {
let msg = original.to_string();
if msg.contains("no variant of enum") {
D::Error::custom(
"auth: must set one of `pub_key`, `tls_cert`, or `jwks` \
(e.g. `pub_key.type = \"file\"`, `pub_key.path = \"/path/to/key.pem\"`)",
)
} else {
D::Error::custom(format!("auth.authority: {msg}"))
}
})
}
impl AuthConfig {
/// Builds the runtime [`Auth`] by resolving the configured [`Authority`]
/// (a static PEM, a TLS cert via SPKI extraction, or a JWKS endpoint with
/// initial fetch + background refresh) and assembling the verifier.
///
/// All I/O and PEM parsing happen here — once at startup. The resulting
/// [`Auth`] is cheap to clone and holds no file handles. For the JWKS
/// authority, build returns an error if the initial fetch fails so that
/// misconfigurations surface at `vector validate` / source startup
/// rather than at first-request time.
pub async fn build(&self) -> crate::Result<Auth> {
// Validate config (algorithm/authority compatibility) before any I/O.
let mut validation = self.authority.build_validation(self.algorithms.as_deref())?;
let key_store = self.authority.build_key_store().await?;
if let Some(issuer) = &self.issuer {
validation.set_issuer(&[issuer]);
}
if let Some(audiences) = &self.audience {
validation.set_audience(audiences);
} else {
validation.validate_aud = false;
}
// For JWKS: precompute one `Validation` per supported algorithm so that
// `authenticate` never clones or allocates a `Validation` on the hot path.
let jwks_validations = self.authority.expand_jwks_validations(&validation);
let value_path = self
.value_path
.as_ref()
.map(CompiledValuePath::try_from)
.transpose()
.map_err(|error| format!("Failed to parse auth value_path: {error}"))?;
let membership_claim = self.membership_claim
.as_ref()
.map(MembershipClaimConfig::build)
.transpose()?;
Ok(Auth(Arc::new(Inner {
key_store,
validation,
jwks_validations,
membership_claim,
value_path,
})))
}
}
impl AuthorityData {
/// Resolve to the PEM string. `kind` is the configuration field name
/// (`"pub_key"` or `"tls_cert"`) used to make I/O failures point at
/// the right config field.
fn load(&self, kind: &str) -> crate::Result<String> {
match self {
Self::Inline { value } => Ok(value.clone()),
Self::File { path } => std::fs::read_to_string(path).map_err(|error| {
format!("Failed to read auth {kind} from '{path}': {error}").into()
}),
}
}
}
// Private — holds the resolved key material and validation config behind Arc
// so Auth is cheap to clone across tokio tasks without copying RSA key bytes
// or duplicating the JWKS cache.
struct Inner {
key_store: KeyStore,
validation: Validation,
/// Per-algorithm `Validation` objects for the JWKS hot path. Built once at
/// startup so `authenticate` never allocates a `Validation` per request.
/// Empty for static-PEM authorities.
jwks_validations: HashMap<Algorithm, Validation>,
membership_claim: Option<MembershipClaim>,
value_path: Option<CompiledValuePath>,
}
/// Per-request auth context returned by a successful [`Auth::authenticate`] call.
///
/// Holds the list of allowed membership values extracted from the JWT claim.
/// Pass to per-event validation helpers in the source's event-processing loop.
pub struct AuthContext {
/// `None` when `membership_claim` is absent — all events are admitted and