michal-giza commited on
Commit
fc72199
Β·
verified Β·
1 Parent(s): d77888a

Update gateway: store seeding, CORS, JWT auth, Postgres support

Browse files
.env.example CHANGED
@@ -2,5 +2,8 @@
2
  UPSTREAM_API_KEY=replace-me
3
  GATEWAY_BIND=0.0.0.0:8080
4
  RUST_LOG=info,gateway=debug,ingestion=debug
 
 
 
5
  # Comma-separated active regions; ingestion cadence scales with regionsΓ—providers, not users (NFR-6.1).
6
  ACTIVE_REGIONS=PL,GB
 
2
  UPSTREAM_API_KEY=replace-me
3
  GATEWAY_BIND=0.0.0.0:8080
4
  RUST_LOG=info,gateway=debug,ingestion=debug
5
+ # Auth: set to enable HS256 JWT verification on write endpoints (sub = user id).
6
+ # Unset β†’ dev fallback where the bearer token IS the user id. Set as a Space secret.
7
+ # AUTH_JWT_SECRET=replace-with-a-long-random-secret
8
  # Comma-separated active regions; ingestion cadence scales with regionsΓ—providers, not users (NFR-6.1).
9
  ACTIVE_REGIONS=PL,GB
Cargo.lock CHANGED
@@ -275,6 +275,15 @@ dependencies = [
275
  "zeroize",
276
  ]
277
 
 
 
 
 
 
 
 
 
 
278
  [[package]]
279
  name = "digest"
280
  version = "0.10.7"
@@ -471,6 +480,7 @@ dependencies = [
471
  "axum",
472
  "chrono",
473
  "domain",
 
474
  "serde",
475
  "serde_json",
476
  "store",
@@ -499,8 +509,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
499
  checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
500
  dependencies = [
501
  "cfg-if",
 
502
  "libc",
503
  "wasi",
 
504
  ]
505
 
506
  [[package]]
@@ -818,6 +830,21 @@ dependencies = [
818
  "wasm-bindgen",
819
  ]
820
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
821
  [[package]]
822
  name = "lazy_static"
823
  version = "1.5.0"
@@ -939,6 +966,16 @@ dependencies = [
939
  "windows-sys 0.61.2",
940
  ]
941
 
 
 
 
 
 
 
 
 
 
 
942
  [[package]]
943
  name = "num-bigint-dig"
944
  version = "0.8.6"
@@ -955,6 +992,12 @@ dependencies = [
955
  "zeroize",
956
  ]
957
 
 
 
 
 
 
 
958
  [[package]]
959
  name = "num-integer"
960
  version = "0.1.46"
@@ -1020,6 +1063,16 @@ dependencies = [
1020
  "windows-link",
1021
  ]
1022
 
 
 
 
 
 
 
 
 
 
 
1023
  [[package]]
1024
  name = "pem-rfc7468"
1025
  version = "0.7.0"
@@ -1083,6 +1136,12 @@ dependencies = [
1083
  "zerovec",
1084
  ]
1085
 
 
 
 
 
 
 
1086
  [[package]]
1087
  name = "ppv-lite86"
1088
  version = "0.2.21"
@@ -1384,6 +1443,18 @@ dependencies = [
1384
  "rand_core",
1385
  ]
1386
 
 
 
 
 
 
 
 
 
 
 
 
 
1387
  [[package]]
1388
  name = "slab"
1389
  version = "0.4.12"
@@ -1715,6 +1786,37 @@ dependencies = [
1715
  "cfg-if",
1716
  ]
1717
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1718
  [[package]]
1719
  name = "tinystr"
1720
  version = "0.8.3"
 
275
  "zeroize",
276
  ]
277
 
278
+ [[package]]
279
+ name = "deranged"
280
+ version = "0.5.8"
281
+ source = "registry+https://github.com/rust-lang/crates.io-index"
282
+ checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
283
+ dependencies = [
284
+ "powerfmt",
285
+ ]
286
+
287
  [[package]]
288
  name = "digest"
289
  version = "0.10.7"
 
480
  "axum",
481
  "chrono",
482
  "domain",
483
+ "jsonwebtoken",
484
  "serde",
485
  "serde_json",
486
  "store",
 
509
  checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
510
  dependencies = [
511
  "cfg-if",
512
+ "js-sys",
513
  "libc",
514
  "wasi",
515
+ "wasm-bindgen",
516
  ]
517
 
518
  [[package]]
 
830
  "wasm-bindgen",
831
  ]
832
 
833
+ [[package]]
834
+ name = "jsonwebtoken"
835
+ version = "9.3.1"
836
+ source = "registry+https://github.com/rust-lang/crates.io-index"
837
+ checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde"
838
+ dependencies = [
839
+ "base64",
840
+ "js-sys",
841
+ "pem",
842
+ "ring",
843
+ "serde",
844
+ "serde_json",
845
+ "simple_asn1",
846
+ ]
847
+
848
  [[package]]
849
  name = "lazy_static"
850
  version = "1.5.0"
 
966
  "windows-sys 0.61.2",
967
  ]
968
 
969
+ [[package]]
970
+ name = "num-bigint"
971
+ version = "0.4.6"
972
+ source = "registry+https://github.com/rust-lang/crates.io-index"
973
+ checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
974
+ dependencies = [
975
+ "num-integer",
976
+ "num-traits",
977
+ ]
978
+
979
  [[package]]
980
  name = "num-bigint-dig"
981
  version = "0.8.6"
 
992
  "zeroize",
993
  ]
994
 
995
+ [[package]]
996
+ name = "num-conv"
997
+ version = "0.2.2"
998
+ source = "registry+https://github.com/rust-lang/crates.io-index"
999
+ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
1000
+
1001
  [[package]]
1002
  name = "num-integer"
1003
  version = "0.1.46"
 
1063
  "windows-link",
1064
  ]
1065
 
1066
+ [[package]]
1067
+ name = "pem"
1068
+ version = "3.0.6"
1069
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1070
+ checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be"
1071
+ dependencies = [
1072
+ "base64",
1073
+ "serde_core",
1074
+ ]
1075
+
1076
  [[package]]
1077
  name = "pem-rfc7468"
1078
  version = "0.7.0"
 
1136
  "zerovec",
1137
  ]
1138
 
1139
+ [[package]]
1140
+ name = "powerfmt"
1141
+ version = "0.2.0"
1142
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1143
+ checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
1144
+
1145
  [[package]]
1146
  name = "ppv-lite86"
1147
  version = "0.2.21"
 
1443
  "rand_core",
1444
  ]
1445
 
1446
+ [[package]]
1447
+ name = "simple_asn1"
1448
+ version = "0.6.4"
1449
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1450
+ checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d"
1451
+ dependencies = [
1452
+ "num-bigint",
1453
+ "num-traits",
1454
+ "thiserror",
1455
+ "time",
1456
+ ]
1457
+
1458
  [[package]]
1459
  name = "slab"
1460
  version = "0.4.12"
 
1786
  "cfg-if",
1787
  ]
1788
 
1789
+ [[package]]
1790
+ name = "time"
1791
+ version = "0.3.47"
1792
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1793
+ checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
1794
+ dependencies = [
1795
+ "deranged",
1796
+ "itoa",
1797
+ "num-conv",
1798
+ "powerfmt",
1799
+ "serde_core",
1800
+ "time-core",
1801
+ "time-macros",
1802
+ ]
1803
+
1804
+ [[package]]
1805
+ name = "time-core"
1806
+ version = "0.1.8"
1807
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1808
+ checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
1809
+
1810
+ [[package]]
1811
+ name = "time-macros"
1812
+ version = "0.2.27"
1813
+ source = "registry+https://github.com/rust-lang/crates.io-index"
1814
+ checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
1815
+ dependencies = [
1816
+ "num-conv",
1817
+ "time-core",
1818
+ ]
1819
+
1820
  [[package]]
1821
  name = "tinystr"
1822
  version = "0.8.3"
Cargo.toml CHANGED
@@ -25,6 +25,7 @@ uuid = { version = "1", features = ["v4", "serde"] }
25
  async-trait = "0.1"
26
  thiserror = "2"
27
  anyhow = "1"
 
28
  sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "macros", "migrate"] }
29
  tracing = "0.1"
30
  tracing-subscriber = { version = "0.3", features = ["env-filter"] }
 
25
  async-trait = "0.1"
26
  thiserror = "2"
27
  anyhow = "1"
28
+ jsonwebtoken = "9"
29
  sqlx = { version = "0.8", default-features = false, features = ["runtime-tokio", "tls-rustls", "postgres", "chrono", "macros", "migrate"] }
30
  tracing = "0.1"
31
  tracing-subscriber = { version = "0.3", features = ["env-filter"] }
crates/gateway/Cargo.toml CHANGED
@@ -19,6 +19,7 @@ tower-http.workspace = true
19
  serde.workspace = true
20
  serde_json.workspace = true
21
  chrono.workspace = true
 
22
  tracing.workspace = true
23
  tracing-subscriber.workspace = true
24
  anyhow.workspace = true
 
19
  serde.workspace = true
20
  serde_json.workspace = true
21
  chrono.workspace = true
22
+ jsonwebtoken.workspace = true
23
  tracing.workspace = true
24
  tracing-subscriber.workspace = true
25
  anyhow.workspace = true
crates/gateway/src/auth.rs ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ //! Bearer-token β†’ user-id resolution (B-6).
2
+ //!
3
+ //! Two modes, chosen by the `AUTH_JWT_SECRET` env var:
4
+ //! - **Set:** the bearer must be a valid HS256 JWT; the `sub` claim is the user
5
+ //! id (signature + expiry verified). This is the production mode β€” an OAuth
6
+ //! provider (Google/Apple/Firebase) issues the token; the gateway only verifies.
7
+ //! - **Unset (dev/MVP):** the bearer string *is* the user id (the current stub).
8
+ //!
9
+ //! Keeping the dev fallback means the live deployment works until you provision
10
+ //! a signing secret; setting the secret flips on real verification with no code change.
11
+
12
+ use std::sync::OnceLock;
13
+
14
+ use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
15
+ use serde::Deserialize;
16
+
17
+ #[derive(Debug, Deserialize)]
18
+ struct Claims {
19
+ sub: String,
20
+ }
21
+
22
+ /// Cached signing secret (read once). `None` β†’ dev stub mode.
23
+ pub fn jwt_secret() -> Option<&'static str> {
24
+ static SECRET: OnceLock<Option<String>> = OnceLock::new();
25
+ SECRET
26
+ .get_or_init(|| std::env::var("AUTH_JWT_SECRET").ok().filter(|s| !s.is_empty()))
27
+ .as_deref()
28
+ }
29
+
30
+ /// Resolves the authenticated user id from a bearer token. Returns `None` when
31
+ /// the token is missing/empty (dev mode) or invalid/expired (JWT mode).
32
+ pub fn user_from_bearer(token: &str, secret: Option<&str>) -> Option<String> {
33
+ match secret {
34
+ None => {
35
+ let t = token.trim();
36
+ if t.is_empty() {
37
+ None
38
+ } else {
39
+ Some(t.to_string())
40
+ }
41
+ }
42
+ Some(sec) => {
43
+ // HS256; expiry validated by default when an `exp` claim is present.
44
+ let validation = Validation::new(Algorithm::HS256);
45
+ decode::<Claims>(token, &DecodingKey::from_secret(sec.as_bytes()), &validation)
46
+ .ok()
47
+ .map(|data| data.claims.sub)
48
+ }
49
+ }
50
+ }
51
+
52
+ #[cfg(test)]
53
+ #[allow(clippy::unwrap_used)]
54
+ mod tests {
55
+ use super::*;
56
+ use jsonwebtoken::{encode, EncodingKey, Header};
57
+ use serde::Serialize;
58
+
59
+ #[derive(Serialize)]
60
+ struct TestClaims {
61
+ sub: String,
62
+ exp: usize,
63
+ }
64
+
65
+ fn sign(sub: &str, secret: &str, exp: usize) -> String {
66
+ encode(
67
+ &Header::new(Algorithm::HS256),
68
+ &TestClaims { sub: sub.into(), exp },
69
+ &EncodingKey::from_secret(secret.as_bytes()),
70
+ )
71
+ .unwrap()
72
+ }
73
+
74
+ #[test]
75
+ fn dev_mode_uses_token_as_user_id() {
76
+ assert_eq!(user_from_bearer("alice", None), Some("alice".into()));
77
+ assert_eq!(user_from_bearer(" ", None), None);
78
+ }
79
+
80
+ #[test]
81
+ fn jwt_mode_accepts_valid_token_and_reads_sub() {
82
+ let t = sign("alice", "topsecret", 9_999_999_999);
83
+ assert_eq!(user_from_bearer(&t, Some("topsecret")), Some("alice".into()));
84
+ }
85
+
86
+ #[test]
87
+ fn jwt_mode_rejects_bad_signature_and_expiry() {
88
+ let t = sign("alice", "topsecret", 9_999_999_999);
89
+ assert_eq!(user_from_bearer(&t, Some("wrongsecret")), None); // bad signature
90
+ let expired = sign("alice", "topsecret", 1); // 1970 β†’ expired
91
+ assert_eq!(user_from_bearer(&expired, Some("topsecret")), None);
92
+ assert_eq!(user_from_bearer("not-a-jwt", Some("topsecret")), None);
93
+ }
94
+ }
crates/gateway/src/main.rs CHANGED
@@ -1,5 +1,6 @@
1
  //! WhereToWatch gateway β€” the ONLY backend the client talks to (NFR-1.3).
2
 
 
3
  mod catalog;
4
  mod dto;
5
  mod routes;
 
1
  //! WhereToWatch gateway β€” the ONLY backend the client talks to (NFR-1.3).
2
 
3
+ mod auth;
4
  mod catalog;
5
  mod dto;
6
  mod routes;
crates/gateway/src/routes.rs CHANGED
@@ -35,9 +35,9 @@ pub fn routes(state: AppState) -> Router {
35
 
36
  // ── Auth guard ───────────────────────────────────────────────────────────
37
 
38
- /// Authenticated user. STUB: the bearer token is taken as the user id. Real
39
- /// OAuth/JWT verification lands in ticket B-6 (FR-9.3); the guard placement is
40
- /// what matters now β€” every write handler requires it.
41
  pub struct AuthUser(pub String);
42
 
43
  impl<S: Send + Sync> FromRequestParts<S> for AuthUser {
@@ -49,10 +49,11 @@ impl<S: Send + Sync> FromRequestParts<S> for AuthUser {
49
  .get(axum::http::header::AUTHORIZATION)
50
  .and_then(|v| v.to_str().ok())
51
  .and_then(|v| v.strip_prefix("Bearer "))
52
- .map(str::trim)
53
- .filter(|t| !t.is_empty())
54
  .ok_or(ApiError::Unauthorized)?;
55
- Ok(AuthUser(token.to_string()))
 
 
 
56
  }
57
  }
58
 
 
35
 
36
  // ── Auth guard ───────────────────────────────────────────────────────────
37
 
38
+ /// Authenticated user, resolved from the `Authorization: Bearer …` header by
39
+ /// [`crate::auth`]: a verified HS256 JWT's `sub` when `AUTH_JWT_SECRET` is set,
40
+ /// else the token-as-user-id dev fallback (FR-9.3). Every write handler requires it.
41
  pub struct AuthUser(pub String);
42
 
43
  impl<S: Send + Sync> FromRequestParts<S> for AuthUser {
 
49
  .get(axum::http::header::AUTHORIZATION)
50
  .and_then(|v| v.to_str().ok())
51
  .and_then(|v| v.strip_prefix("Bearer "))
 
 
52
  .ok_or(ApiError::Unauthorized)?;
53
+ // JWT-verified when AUTH_JWT_SECRET is set; else the token is the user id.
54
+ let user = crate::auth::user_from_bearer(token, crate::auth::jwt_secret())
55
+ .ok_or(ApiError::Unauthorized)?;
56
+ Ok(AuthUser(user))
57
  }
58
  }
59