mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-30 07:33:59 +08:00
refactor(example): abstract extension authentication
Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
This commit is contained in:
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user