refactor(example): abstract extension authentication

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
This commit is contained in:
Piotr Mlocek
2026-08-07 14:22:18 -07:00
parent 2797dc31b1
commit 880288bb70
3 changed files with 100 additions and 36 deletions
@@ -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.
@@ -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<Option<&'
#[derive(Debug)]
struct ContentGuard {
verifier: Arc<ExtensionJwtVerifier>,
authenticator: Arc<dyn ExtensionAuthenticator>,
}
impl ContentGuard {
fn authenticate<T>(
async fn authenticate<T>(
&self,
request: &Request<T>,
allowed_callers: &[ExtensionCallerKind],
) -> Result<ExtensionJwtClaims, Status> {
) -> Result<AuthenticatedCaller, Status> {
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<ValidateConfigRequest>,
) -> Result<Response<ValidateConfigResponse>, 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<HttpRequestEvaluation>,
) -> Result<Response<HttpRequestResult>, 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<dyn std::error::Error>> {
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<dyn ExtensionAuthenticator> = 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(())
@@ -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<String>,
}
/// 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<AuthenticatedCaller, AuthenticationError>;
}
#[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<String, DecodingKey>,
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<String>,
@@ -110,7 +134,7 @@ impl ExtensionJwtVerifier {
})
}
pub fn verify(&self, token: &str) -> Result<ExtensionJwtClaims, VerificationError> {
fn verify(&self, token: &str) -> Result<ExtensionJwtClaims, VerificationError> {
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<AuthenticatedCaller, AuthenticationError> {
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<dyn std::error::Error + Send + Sync>);
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",