Skip to content

Commit ead26d8

Browse files
authored
feat(oidc): support multiple signing keys (#26)
* chore: update copyright license * refactor: accept multiple signing keys * chore: fix examples
1 parent 212b48b commit ead26d8

6 files changed

Lines changed: 56 additions & 31 deletions

File tree

crates/oidc-provider/src/jwks.rs

Lines changed: 39 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,31 @@ use base64::Engine;
44
use rsa::traits::PublicKeyParts;
55
use rsa::RsaPublicKey;
66

7-
/// Generate a JWKS JSON response containing the proxy's RSA public key.
7+
/// Generate a JWKS JSON response containing one or more RSA public keys.
88
///
99
/// This is served at the JWKS URI referenced by the OIDC discovery document,
1010
/// allowing relying parties (cloud providers) to verify JWTs signed by the proxy.
11-
pub fn jwks_json(public_key: &RsaPublicKey, kid: &str) -> String {
11+
/// Multiple keys support key rotation.
12+
pub fn jwks_json(keys: &[(&RsaPublicKey, &str)]) -> String {
1213
let b64 = &base64::engine::general_purpose::URL_SAFE_NO_PAD;
1314

14-
let n = public_key.n();
15-
let e = public_key.e();
16-
17-
let n_b64 = b64.encode(n.to_bytes_be());
18-
let e_b64 = b64.encode(e.to_bytes_be());
19-
20-
let jwks = serde_json::json!({
21-
"keys": [{
22-
"kty": "RSA",
23-
"alg": "RS256",
24-
"use": "sig",
25-
"kid": kid,
26-
"n": n_b64,
27-
"e": e_b64,
28-
}]
29-
});
30-
31-
jwks.to_string()
15+
let jwk_entries: Vec<serde_json::Value> = keys
16+
.iter()
17+
.map(|(public_key, kid)| {
18+
let n_b64 = b64.encode(public_key.n().to_bytes_be());
19+
let e_b64 = b64.encode(public_key.e().to_bytes_be());
20+
serde_json::json!({
21+
"kty": "RSA",
22+
"alg": "RS256",
23+
"use": "sig",
24+
"kid": kid,
25+
"n": n_b64,
26+
"e": e_b64,
27+
})
28+
})
29+
.collect();
30+
31+
serde_json::json!({ "keys": jwk_entries }).to_string()
3232
}
3333

3434
#[cfg(test)]
@@ -42,7 +42,7 @@ mod tests {
4242
let private_key = RsaPrivateKey::new(&mut rng, 2048).unwrap();
4343
let public_key: &RsaPublicKey = private_key.as_ref();
4444

45-
let json_str = jwks_json(public_key, "my-kid");
45+
let json_str = jwks_json(&[(public_key, "my-kid")]);
4646
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
4747

4848
let keys = parsed["keys"].as_array().unwrap();
@@ -57,6 +57,23 @@ mod tests {
5757
assert!(key["e"].as_str().unwrap().len() > 0);
5858
}
5959

60+
#[test]
61+
fn jwks_contains_multiple_keys() {
62+
let mut rng = rand::rngs::OsRng;
63+
let key1 = RsaPrivateKey::new(&mut rng, 2048).unwrap();
64+
let pub1: &RsaPublicKey = key1.as_ref();
65+
let key2 = RsaPrivateKey::new(&mut rng, 2048).unwrap();
66+
let pub2: &RsaPublicKey = key2.as_ref();
67+
68+
let json_str = jwks_json(&[(pub1, "kid-1"), (pub2, "kid-2")]);
69+
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
70+
71+
let keys = parsed["keys"].as_array().unwrap();
72+
assert_eq!(keys.len(), 2);
73+
assert_eq!(keys[0]["kid"], "kid-1");
74+
assert_eq!(keys[1]["kid"], "kid-2");
75+
}
76+
6077
#[test]
6178
fn jwks_roundtrips_through_rsa() {
6279
use rsa::BigUint;
@@ -65,7 +82,7 @@ mod tests {
6582
let private_key = RsaPrivateKey::new(&mut rng, 2048).unwrap();
6683
let public_key: &RsaPublicKey = private_key.as_ref();
6784

68-
let json_str = jwks_json(public_key, "k1");
85+
let json_str = jwks_json(&[(public_key, "k1")]);
6986
let parsed: serde_json::Value = serde_json::from_str(&json_str).unwrap();
7087

7188
let key = &parsed["keys"][0];

crates/oidc-provider/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -283,7 +283,7 @@ mod tests {
283283
let token = signer.sign("sub", "iss", "aud", &[]).unwrap();
284284

285285
// Generate JWKS from the same signer
286-
let jwks_str = jwks::jwks_json(signer.public_key(), signer.kid());
286+
let jwks_str = jwks::jwks_json(&[(signer.public_key(), signer.kid())]);
287287
let jwks: serde_json::Value = serde_json::from_str(&jwks_str).unwrap();
288288

289289
// Extract public key from JWKS

crates/oidc-provider/src/route_handler.rs

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,20 @@ impl RouteHandler for OidcConfigHandler {
2727

2828
/// Handler that serves the JWKS (JSON Web Key Set) document.
2929
struct OidcJwksHandler {
30-
signer: JwtSigner,
30+
signers: Vec<JwtSigner>,
3131
}
3232

3333
impl RouteHandler for OidcJwksHandler {
3434
fn handle<'a>(&'a self, req: &'a RequestInfo<'a>) -> RouteHandlerFuture<'a> {
3535
if req.method.as_str() != "GET" {
3636
return Box::pin(async { None });
3737
}
38-
let json = jwks_json(self.signer.public_key(), self.signer.kid());
38+
let keys: Vec<_> = self
39+
.signers
40+
.iter()
41+
.map(|s| (s.public_key(), s.kid()))
42+
.collect();
43+
let json = jwks_json(&keys);
3944
Box::pin(async move { Some(ProxyResult::json(200, json)) })
4045
}
4146
}
@@ -44,17 +49,20 @@ impl RouteHandler for OidcJwksHandler {
4449
pub trait OidcRouterExt {
4550
/// Register `/.well-known/openid-configuration` and `/.well-known/jwks.json`
4651
/// routes backed by the given issuer and signer.
47-
fn with_oidc_discovery(self, issuer: String, signer: JwtSigner) -> Self;
52+
///
53+
/// `additional_signers` contains previous keys that should still appear in
54+
/// the JWKS for key rotation (they are not used for signing new tokens).
55+
fn with_oidc_discovery(self, issuer: String, signers: Vec<JwtSigner>) -> Self;
4856
}
4957

5058
impl OidcRouterExt for Router {
51-
fn with_oidc_discovery(self, issuer: String, signer: JwtSigner) -> Self {
59+
fn with_oidc_discovery(self, issuer: String, signers: Vec<JwtSigner>) -> Self {
5260
let jwks_uri = format!("{}/.well-known/jwks.json", issuer);
5361

5462
self.route(
5563
"/.well-known/openid-configuration",
5664
OidcConfigHandler { issuer, jwks_uri },
5765
)
58-
.route("/.well-known/jwks.json", OidcJwksHandler { signer })
66+
.route("/.well-known/jwks.json", OidcJwksHandler { signers })
5967
}
6068
}

examples/cf-workers/src/lib.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ async fn fetch(req: web_sys::Request, env: Env, _ctx: Context) -> Result<web_sys
7575
// Build router with OIDC discovery (if configured) and STS.
7676
let mut router = Router::new();
7777
if let (Some(signer), Some(issuer)) = (oidc_signer, oidc_issuer) {
78-
router = router.with_oidc_discovery(issuer, signer);
78+
router = router.with_oidc_discovery(issuer, vec![signer]);
7979
}
8080
router = router.with_sts(sts_creds, jwks_cache, token_key.clone());
8181

examples/lambda/src/main.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@ async fn main() -> Result<(), Error> {
9393
// Build router with OIDC discovery (if configured) and STS.
9494
let mut router = Router::new();
9595
if let (Some(signer), Some(issuer)) = (oidc_signer, oidc_issuer) {
96-
router = router.with_oidc_discovery(issuer, signer);
96+
router = router.with_oidc_discovery(issuer, vec![signer]);
9797
}
9898
router = router.with_sts(sts_creds, jwks_cache, token_key.clone());
9999

examples/server/src/server.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ where
113113
// Build router with OIDC discovery (if configured) and STS.
114114
let mut proxy_router = ProxyRouter::new();
115115
if let (Some(signer), Some(issuer)) = (oidc_signer, oidc_issuer) {
116-
proxy_router = proxy_router.with_oidc_discovery(issuer, signer);
116+
proxy_router = proxy_router.with_oidc_discovery(issuer, vec![signer]);
117117
}
118118
proxy_router = proxy_router.with_sts(sts_creds, jwks_cache, token_key.clone());
119119

0 commit comments

Comments
 (0)