Skip to content

Commit 2bf1cf8

Browse files
authored
refactor: tidy auth token validation and scope handling (#764)
* refactor: lift JWT claims and algorithm mapping out of validate * refactor: return (key, issuer) tuple instead of VerificationKey struct * test: cover jwt_algorithm mapping and edge cases * refactor: extract ScopeMode::is_satisfied_by for scope checks * refactor: render scope_mode via ScopeMode::as_str
1 parent e0adf9c commit 2bf1cf8

4 files changed

Lines changed: 260 additions & 235 deletions

File tree

crates/apollo-mcp-server/src/auth.rs

Lines changed: 48 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,30 @@ pub enum ScopeMode {
4747
RequireAny,
4848
}
4949

50+
impl ScopeMode {
51+
/// Whether the `present` scopes satisfy the `required` scopes under this
52+
/// mode. Callers skip this check when no scopes are configured, so
53+
/// `required` is expected to be non-empty.
54+
fn is_satisfied_by(self, required: &[String], present: &[String]) -> bool {
55+
match self {
56+
ScopeMode::Disabled => true,
57+
ScopeMode::RequireAll => required.iter().all(|req| present.contains(req)),
58+
ScopeMode::RequireAny => required.iter().any(|req| present.contains(req)),
59+
}
60+
}
61+
62+
/// The wire string for this mode, matching the serde `snake_case` rename.
63+
/// Used when rendering the `scope_mode` hint in the `WWW-Authenticate`
64+
/// header.
65+
fn as_str(self) -> &'static str {
66+
match self {
67+
ScopeMode::Disabled => "disabled",
68+
ScopeMode::RequireAll => "require_all",
69+
ScopeMode::RequireAny => "require_any",
70+
}
71+
}
72+
}
73+
5074
/// Errors that can occur when building a TLS-configured HTTP client
5175
#[derive(Debug, thiserror::Error)]
5276
pub enum TlsConfigError {
@@ -521,17 +545,9 @@ async fn oauth_validate(
521545

522546
// Scope validation: only applies when scopes are configured
523547
if !auth_config.scopes.is_empty() {
524-
let sufficient = match auth_config.scope_mode {
525-
ScopeMode::Disabled => true,
526-
ScopeMode::RequireAll => auth_config
527-
.scopes
528-
.iter()
529-
.all(|req| valid_token.scopes.contains(req)),
530-
ScopeMode::RequireAny => auth_config
531-
.scopes
532-
.iter()
533-
.any(|req| valid_token.scopes.contains(req)),
534-
};
548+
let sufficient = auth_config
549+
.scope_mode
550+
.is_satisfied_by(&auth_config.scopes, &valid_token.scopes);
535551

536552
if !sufficient {
537553
// Compute missing scopes for diagnostic logging
@@ -696,19 +712,32 @@ mod tests {
696712
}
697713
}
698714

715+
mod scope_mode {
716+
use super::*;
717+
718+
#[test]
719+
fn as_str_matches_serde_rename() {
720+
for mode in [
721+
ScopeMode::Disabled,
722+
ScopeMode::RequireAll,
723+
ScopeMode::RequireAny,
724+
] {
725+
let serde = serde_json::to_string(&mode).expect("serialize scope mode");
726+
assert_eq!(
727+
format!("\"{}\"", mode.as_str()),
728+
serde,
729+
"as_str drifted from the serde rename for {mode:?}"
730+
);
731+
}
732+
}
733+
}
734+
699735
mod scope_validation {
700736
use super::*;
701737
use rstest::rstest;
702738

703739
fn is_sufficient(mode: ScopeMode, required: &[String], present: &[String]) -> bool {
704-
if required.is_empty() {
705-
return true;
706-
}
707-
match mode {
708-
ScopeMode::Disabled => true,
709-
ScopeMode::RequireAll => required.iter().all(|req| present.contains(req)),
710-
ScopeMode::RequireAny => required.iter().any(|req| present.contains(req)),
711-
}
740+
required.is_empty() || mode.is_satisfied_by(required, present)
712741
}
713742

714743
fn s(vals: &[&str]) -> Vec<String> {

crates/apollo-mcp-server/src/auth/networked_key_resolver.rs

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,12 @@ use std::str::FromStr;
22
use std::time::Duration;
33

44
use jsonwebtoken::jwk::KeyAlgorithm;
5-
use jwks::Jwks;
5+
use jwks::{Jwk, Jwks};
66
use serde::Deserialize;
77
use tracing::{error, info, trace, warn};
88
use url::Url;
99

10-
use super::valid_token::{KeyResolver, VerificationKey};
10+
use super::valid_token::KeyResolver;
1111

1212
/// [`KeyResolver`] that fetches signing keys from the network via OIDC/OAuth
1313
/// discovery.
@@ -187,17 +187,14 @@ impl KeyResolver for NetworkedKeyResolver<'_> {
187187
/// `discovery_timeout` on the happy path. The JWKS fetch does not fall
188188
/// back to alternate discovery URLs on failure; real providers advertise
189189
/// the same `jwks_uri` from every well-known path.
190-
async fn resolve_key(&self, server: &Url, key_id: &str) -> Option<VerificationKey> {
190+
async fn resolve_key(&self, server: &Url, key_id: &str) -> Option<(Jwk, String)> {
191191
let metadata = discover_metadata(self.client, server, self.discovery_timeout).await?;
192192
let mut jwks = fetch_jwks(self.client, &metadata.jwks_uri, self.discovery_timeout).await?;
193193
let mut jwk = jwks.keys.remove(key_id)?;
194194
if jwk.alg.is_none() {
195195
jwk.alg = resolve_alg(&metadata.id_token_signing_alg_values_supported, server);
196196
}
197-
Some(VerificationKey {
198-
jwk,
199-
issuer: metadata.issuer,
200-
})
197+
Some((jwk, metadata.issuer))
201198
}
202199
}
203200

0 commit comments

Comments
 (0)