diff --git a/examples/supervisor-middleware-content-guard/README.md b/examples/supervisor-middleware-content-guard/README.md index aee8be5b7..7f756e6d8 100644 --- a/examples/supervisor-middleware-content-guard/README.md +++ b/examples/supervisor-middleware-content-guard/README.md @@ -88,6 +88,10 @@ The gateway and supervisors verify the middleware certificate against `tls_ca_ce The service returns `Unauthenticated` for missing or invalid credentials and `PermissionDenied` when a valid caller kind invokes an RPC it is not allowed to use. Unknown `kid` values require replacing the provisioned JWKS document and restarting this alpha example; production integrations should cache keys and refresh from the trusted gateway JWKS URL on an unknown `kid`. +### Authentication backends + +The example keeps authentication behind an async `ExtensionAuthenticator` interface. The current `GatewayJwtAuthenticator` validates the gateway-minted bearer token and maps its claims to a normalized caller identity used by RPC authorization. This keeps handlers independent of the credential format and leaves room for a future SDK implementation backed by SPIFFE JWT-SVID validation through the Workload API. SPIFFE X.509-SVID support would integrate separately with the gRPC TLS transport because it authenticates the peer certificate rather than a bearer token. + The service manifest describes its supported operation and phase. The policy attaches the complete service by the operator-owned `content-guard-example` registration name, not by the diagnostic manifest name. The `network_middlewares` map key `prototype-content-guard` is the stable policy-local identity. The optional `name` field is a human-readable label, and `order` must be unique across every middleware config in the policy. diff --git a/examples/supervisor-middleware-content-guard/src/main.rs b/examples/supervisor-middleware-content-guard/src/main.rs index 1bf860969..b0bcf09e7 100644 --- a/examples/supervisor-middleware-content-guard/src/main.rs +++ b/examples/supervisor-middleware-content-guard/src/main.rs @@ -23,7 +23,9 @@ use tonic::{Request, Response, Status}; mod verification; -use verification::{ExtensionCallerKind, ExtensionJwtClaims, ExtensionJwtVerifier}; +use verification::{ + AuthenticatedCaller, ExtensionAuthenticator, ExtensionCallerKind, GatewayJwtAuthenticator, +}; const MANIFEST_NAME: &str = "example/content-guard-service"; const OPERATION: SupervisorMiddlewareOperation = SupervisorMiddlewareOperation::HttpRequest; @@ -138,15 +140,15 @@ fn optional_string_field<'a>(config: &'a Struct, name: &str) -> Result, + authenticator: Arc, } impl ContentGuard { - fn authenticate( + async fn authenticate( &self, request: &Request, allowed_callers: &[ExtensionCallerKind], - ) -> Result { + ) -> Result { let authorization = request .metadata() .get("authorization") @@ -157,12 +159,16 @@ impl ContentGuard { .strip_prefix("Bearer ") .filter(|token| !token.is_empty()) .ok_or_else(|| Status::unauthenticated("expected a bearer token"))?; - let claims = self.verifier.verify(token).map_err(|error| { - eprintln!("extension token verification failed: {error}"); - Status::unauthenticated("invalid extension token") - })?; - authorize_caller(claims.caller_kind, allowed_callers)?; - Ok(claims) + let caller = self + .authenticator + .authenticate(token) + .await + .map_err(|error| { + eprintln!("extension authentication failed: {error}"); + Status::unauthenticated("invalid extension token") + })?; + authorize_caller(caller.kind, allowed_callers)?; + Ok(caller) } } @@ -188,7 +194,8 @@ impl SupervisorMiddleware for ContentGuard { ExtensionCallerKind::Gateway, ExtensionCallerKind::Supervisor, ], - )?; + ) + .await?; Ok(Response::new(MiddlewareManifest { name: MANIFEST_NAME.into(), service_version: env!("CARGO_PKG_VERSION").into(), @@ -205,7 +212,8 @@ impl SupervisorMiddleware for ContentGuard { &self, request: Request, ) -> Result, Status> { - self.authenticate(&request, &[ExtensionCallerKind::Gateway])?; + self.authenticate(&request, &[ExtensionCallerKind::Gateway]) + .await?; let request = request.into_inner(); let validation = GuardConfig::parse(request.config.as_ref()); Ok(Response::new(match validation { @@ -224,7 +232,8 @@ impl SupervisorMiddleware for ContentGuard { &self, request: Request, ) -> Result, Status> { - self.authenticate(&request, &[ExtensionCallerKind::Supervisor])?; + self.authenticate(&request, &[ExtensionCallerKind::Supervisor]) + .await?; let request = request.into_inner(); validate_phase(request.phase).map_err(Status::invalid_argument)?; let config = @@ -368,17 +377,17 @@ async fn main() -> Result<(), Box> { let tls_cert = tokio::fs::read(&cli.tls_cert).await?; let tls_key = tokio::fs::read(&cli.tls_key).await?; let gateway_jwks = tokio::fs::read(&cli.gateway_jwks).await?; - let verifier = Arc::new(ExtensionJwtVerifier::from_jwks( - &gateway_jwks, - cli.gateway_issuer, - cli.audience, - )?); + let authenticator: Arc = Arc::new( + GatewayJwtAuthenticator::from_jwks(&gateway_jwks, cli.gateway_issuer, cli.audience)?, + ); let tls = ServerTlsConfig::new().identity(Identity::from_pem(tls_cert, tls_key)); println!("serving {MANIFEST_NAME} on https://{}", cli.bind); Server::builder() .tls_config(tls)? - .add_service(SupervisorMiddlewareServer::new(ContentGuard { verifier })) + .add_service(SupervisorMiddlewareServer::new(ContentGuard { + authenticator, + })) .serve(cli.bind) .await?; Ok(()) diff --git a/examples/supervisor-middleware-content-guard/src/verification.rs b/examples/supervisor-middleware-content-guard/src/verification.rs index b56d98f56..350d3152e 100644 --- a/examples/supervisor-middleware-content-guard/src/verification.rs +++ b/examples/supervisor-middleware-content-guard/src/verification.rs @@ -25,8 +25,32 @@ pub enum ExtensionCallerKind { Supervisor, } +/// Authentication result consumed by extension RPC authorization. +/// +/// Authentication backends translate their credential format into this +/// representation so handlers do not depend on gateway JWT claim details. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AuthenticatedCaller { + pub kind: ExtensionCallerKind, + pub subject: String, + pub sandbox_id: Option, +} + +/// Credential backend for an OpenShell extension service. +/// +/// A future SDK can provide implementations backed by gateway JWKS, SPIFFE +/// JWT-SVID validation, or another trusted identity system without changing +/// the middleware RPC implementation. +#[tonic::async_trait] +pub trait ExtensionAuthenticator: std::fmt::Debug + Send + Sync { + async fn authenticate( + &self, + bearer_token: &str, + ) -> Result; +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] -pub struct ExtensionJwtClaims { +struct ExtensionJwtClaims { pub iss: String, pub aud: String, pub sub: String, @@ -43,15 +67,15 @@ pub struct ExtensionJwtClaims { /// The operator must obtain the JWKS document from a trusted gateway URL or /// provision it out of band. Constructing this verifier does not establish /// trust in the document by itself. -pub struct ExtensionJwtVerifier { +pub struct GatewayJwtAuthenticator { keys: HashMap, issuer: String, audience: String, } -impl std::fmt::Debug for ExtensionJwtVerifier { +impl std::fmt::Debug for GatewayJwtAuthenticator { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.debug_struct("ExtensionJwtVerifier") + f.debug_struct("GatewayJwtAuthenticator") .field("key_ids", &self.keys.keys()) .field("issuer", &self.issuer) .field("audience", &self.audience) @@ -59,7 +83,7 @@ impl std::fmt::Debug for ExtensionJwtVerifier { } } -impl ExtensionJwtVerifier { +impl GatewayJwtAuthenticator { pub fn from_jwks( jwks_json: &[u8], issuer: impl Into, @@ -110,7 +134,7 @@ impl ExtensionJwtVerifier { }) } - pub fn verify(&self, token: &str) -> Result { + fn verify(&self, token: &str) -> Result { let header = decode_header(token).map_err(VerificationError::InvalidToken)?; if header.alg != Algorithm::EdDSA { return Err(VerificationError::UnexpectedAlgorithm); @@ -140,6 +164,23 @@ impl ExtensionJwtVerifier { } } +#[tonic::async_trait] +impl ExtensionAuthenticator for GatewayJwtAuthenticator { + async fn authenticate( + &self, + bearer_token: &str, + ) -> Result { + let claims = self + .verify(bearer_token) + .map_err(AuthenticationError::backend)?; + Ok(AuthenticatedCaller { + kind: claims.caller_kind, + subject: claims.sub, + sandbox_id: claims.sandbox_id, + }) + } +} + fn validate_claim_shape(claims: &ExtensionJwtClaims) -> Result<(), VerificationError> { if claims.jti.is_empty() { return Err(VerificationError::InvalidClaims("jti must not be empty")); @@ -205,6 +246,18 @@ pub enum VerificationError { InvalidClaims(&'static str), } +#[derive(Debug, Error)] +#[error(transparent)] +pub struct AuthenticationError(Box); + +impl AuthenticationError { + /// Wraps an authentication backend's native error without coupling the + /// shared interface to that backend's error taxonomy. + pub fn backend(error: impl std::error::Error + Send + Sync + 'static) -> Self { + Self(Box::new(error)) + } +} + #[cfg(test)] mod tests { use jsonwebtoken::{EncodingKey, Header, encode}; @@ -246,9 +299,9 @@ mod tests { .unwrap() } - #[test] - fn verifies_gateway_and_supervisor_claims() { - let verifier = ExtensionJwtVerifier::from_jwks( + #[tokio::test] + async fn authenticates_gateway_and_supervisor_callers() { + let authenticator = GatewayJwtAuthenticator::from_jwks( JWKS, "openshell-gateway:test", "urn:openshell:extension:middleware:test", @@ -259,19 +312,17 @@ mod tests { ExtensionCallerKind::Gateway, ExtensionCallerKind::Supervisor, ] { - assert_eq!( - verifier - .verify(&token(&claims(caller_kind))) - .unwrap() - .caller_kind, - caller_kind - ); + let expected = claims(caller_kind); + let caller = authenticator.authenticate(&token(&expected)).await.unwrap(); + assert_eq!(caller.kind, caller_kind); + assert_eq!(caller.subject, expected.sub); + assert_eq!(caller.sandbox_id, expected.sandbox_id); } } #[test] fn rejects_wrong_audience_and_inconsistent_subject() { - let verifier = ExtensionJwtVerifier::from_jwks( + let verifier = GatewayJwtAuthenticator::from_jwks( JWKS, "openshell-gateway:test", "urn:openshell:extension:middleware:test",