Adding support for jwt token authorization authentication with a central Jwks - #131
Adding support for jwt token authorization authentication with a central Jwks#131dawid-nowak wants to merge 7 commits into
Conversation
dawid-nowak
commented
Sep 3, 2026
- Enhanced support for AutzN/Z
- The verification keys are retrieved from centralized JWKS which needs to be configured at startup
- Isolation of claims and AuthorizedPrincipal. AuthorizedPrincipal is created based on the claims from the JWT token but it doesn't need to be. It should be used to isolate higher layers from the knowledge about the claims.
Signed-off-by: Dawid Nowak <nowakd@gmail.com>
Signed-off-by: Dawid Nowak <nowakd@gmail.com>
Signed-off-by: Dawid Nowak <nowakd@gmail.com>
Signed-off-by: Dawid Nowak <nowakd@gmail.com>
Signed-off-by: Dawid Nowak <nowakd@gmail.com>
Signed-off-by: Dawid Nowak <nowakd@gmail.com>
There was a problem hiding this comment.
Observed review and E2E outcomes:
- The expired-token test rejected before expiry validation because no verification keys were configured.
- The dataplane exited during startup because the E2E inputs did not provide the required JWKS/private-key configuration, so both external lanes remained at HTTP 502 and no MCP scenarios ran.
- Key selection and SaaS principal extraction do not preserve the required constraints and mappings.
Signed-off-by: Dawid Nowak <nowakd@gmail.com>
|
/conformance |
lucarlig
left a comment
There was a problem hiding this comment.
Concrete options for the open threads. The conformance topology would still need the self-hosted JWKS URL, the extractor would need a public re-export and injected constructor, and the HMAC fixtures would need RSA replacements if the algorithm policy is adopted.
| pub mcp_allowed_hosts: Option<Vec<Authority>>, | ||
|
|
||
| #[cfg(feature = "with_tools")] | ||
| #[arg(long)] |
There was a problem hiding this comment.
Would restoring the environment-backed input work here?
| #[arg(long)] | |
| #[arg(long, env = "CONTEXTFORGE_DATA_PLANE_TOKEN_VERIFICATION_PRIVATE_KEY")] |
| async fn authorize_token(&self, token: &str) -> Option<AuthorizationClaims> { | ||
| let header = decode_header(token).ok()?; | ||
| self.jwks.validate(token, &header).await | ||
| } |
There was a problem hiding this comment.
Would this make the accepted header contract explicit?
| async fn authorize_token(&self, token: &str) -> Option<AuthorizationClaims> { | |
| let header = decode_header(token).ok()?; | |
| self.jwks.validate(token, &header).await | |
| } | |
| async fn authorize_token(&self, token: &str) -> Option<AuthorizationClaims> { | |
| let header = decode_header(token).ok()?; | |
| header.kid.as_deref()?; | |
| matches!( | |
| header.alg, | |
| jsonwebtoken::Algorithm::RS256 | |
| | jsonwebtoken::Algorithm::RS384 | |
| | jsonwebtoken::Algorithm::RS512 | |
| | jsonwebtoken::Algorithm::ES256 | |
| | jsonwebtoken::Algorithm::ES384 | |
| ) | |
| .then_some(())?; | |
| self.jwks.validate(token, &header).await | |
| } |
| pub struct VerificationKey { | ||
| pub(crate) key_id: Option<String>, | ||
| pub(crate) decoding_key: DecodingKey, | ||
| } | ||
|
|
||
| impl VerificationKey { | ||
| fn from_jwk(jwk: Jwk) -> Result<Option<Self>, AuthorizationError> { | ||
| if jwk.common.public_key_use.as_ref().is_some_and(|key_use| key_use != &PublicKeyUse::Signature) | ||
| || jwk.common.key_operations.as_ref().is_some_and(|operations| !operations.contains(&KeyOperations::Verify)) | ||
| { | ||
| return Ok(None); | ||
| } | ||
|
|
||
| let decoding_key = DecodingKey::from_jwk(&jwk).map_err(AuthorizationError::InvalidKey)?; | ||
| if !matches!(decoding_key.family(), AlgorithmFamily::Rsa | AlgorithmFamily::Ec) { | ||
| return Ok(None); | ||
| } | ||
|
|
||
| Ok(Some(Self { key_id: jwk.common.key_id, decoding_key })) | ||
| } | ||
|
|
||
| pub(super) fn matches(&self, header: &Header) -> bool { | ||
| self.decoding_key.family() == header.alg.family() | ||
| && header | ||
| .kid | ||
| .as_ref() | ||
| .is_none_or(|header_key_id| self.key_id.as_ref().is_none_or(|key_id| key_id == header_key_id)) | ||
| } | ||
| } |
There was a problem hiding this comment.
Would carrying the declared JWK algorithm into matching cover the remaining key-selection gap?
| pub struct VerificationKey { | |
| pub(crate) key_id: Option<String>, | |
| pub(crate) decoding_key: DecodingKey, | |
| } | |
| impl VerificationKey { | |
| fn from_jwk(jwk: Jwk) -> Result<Option<Self>, AuthorizationError> { | |
| if jwk.common.public_key_use.as_ref().is_some_and(|key_use| key_use != &PublicKeyUse::Signature) | |
| || jwk.common.key_operations.as_ref().is_some_and(|operations| !operations.contains(&KeyOperations::Verify)) | |
| { | |
| return Ok(None); | |
| } | |
| let decoding_key = DecodingKey::from_jwk(&jwk).map_err(AuthorizationError::InvalidKey)?; | |
| if !matches!(decoding_key.family(), AlgorithmFamily::Rsa | AlgorithmFamily::Ec) { | |
| return Ok(None); | |
| } | |
| Ok(Some(Self { key_id: jwk.common.key_id, decoding_key })) | |
| } | |
| pub(super) fn matches(&self, header: &Header) -> bool { | |
| self.decoding_key.family() == header.alg.family() | |
| && header | |
| .kid | |
| .as_ref() | |
| .is_none_or(|header_key_id| self.key_id.as_ref().is_none_or(|key_id| key_id == header_key_id)) | |
| } | |
| } | |
| pub struct VerificationKey { | |
| pub(crate) key_id: Option<String>, | |
| pub(crate) algorithm: Option<Algorithm>, | |
| pub(crate) decoding_key: DecodingKey, | |
| } | |
| impl VerificationKey { | |
| fn from_jwk(jwk: Jwk) -> Result<Option<Self>, AuthorizationError> { | |
| if jwk.common.public_key_use.as_ref().is_some_and(|key_use| key_use != &PublicKeyUse::Signature) | |
| || jwk.common.key_operations.as_ref().is_some_and(|operations| !operations.contains(&KeyOperations::Verify)) | |
| { | |
| return Ok(None); | |
| } | |
| let algorithm = match jwk.common.key_algorithm { | |
| Some(value) => match Algorithm::try_from(value) { | |
| Ok(algorithm) | |
| if matches!( | |
| algorithm, | |
| Algorithm::RS256 | Algorithm::RS384 | Algorithm::RS512 | Algorithm::ES256 | Algorithm::ES384 | |
| ) => | |
| { | |
| Some(algorithm) | |
| }, | |
| Ok(_) | Err(_) => return Ok(None), | |
| }, | |
| None => None, | |
| }; | |
| let decoding_key = DecodingKey::from_jwk(&jwk).map_err(AuthorizationError::InvalidKey)?; | |
| if !matches!(decoding_key.family(), AlgorithmFamily::Rsa | AlgorithmFamily::Ec) { | |
| return Ok(None); | |
| } | |
| Ok(Some(Self { key_id: jwk.common.key_id, algorithm, decoding_key })) | |
| } | |
| pub(super) fn matches(&self, header: &Header) -> bool { | |
| self.decoding_key.family() == header.alg.family() | |
| && self.algorithm.is_none_or(|algorithm| algorithm == header.alg) | |
| && header | |
| .kid | |
| .as_ref() | |
| .is_none_or(|header_key_id| self.key_id.as_ref().is_none_or(|key_id| key_id == header_key_id)) | |
| } | |
| } |
|
|
||
| pub struct DefaultPrincipalExtractor {} | ||
|
|
||
| pub trait PrincipalExtractor { |
There was a problem hiding this comment.
Would this be the public extractor contract, followed by re-exporting it and accepting an Arc in a constructor?
| pub trait PrincipalExtractor { | |
| pub trait PrincipalExtractor: Send + Sync { |
|
|
||
| impl VerificationKey { | ||
| pub fn new(id: Option<String>, decoding_key: DecodingKey) -> Self { | ||
| Self { key_id: id, decoding_key } |
There was a problem hiding this comment.
This keeps the test-only constructor compatible with the algorithm-aware key.
| Self { key_id: id, decoding_key } | |
| Self { key_id: id, algorithm: None, decoding_key } |