Files
OpenShell/crates/openshell-extension-core/src/auth.rs
T
Piotr Mlocek bdabb54cb3 fix(security): authenticate extension services (#2638)
* fix(security): authenticate extension services

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* feat(extension-core): verify gateway JWTs

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* refactor(extension-core): keep inbound verification external

This should become an extension SDK package.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* fix(security): harden the extension authentication contract

Follow-up hardening on the alpha extension authentication mechanism.

Claim contract:
- Extension tokens carry an explicit `typ` of `openshell-ext+jwt`. They
  share a signing key with sandbox-to-gateway admission tokens and were
  otherwise separated by audience alone, so a verifier that neglects to
  check `aud` could accept a gateway credential. The header is a second,
  independent discriminator.
- Publish OIDC-shaped discovery at `/.well-known/openid-configuration`
  so a service configured with only the gateway URL can learn the exact
  expected issuer and the JWKS location. It is shaped, not compliant:
  `issuer` is the gateway identity, not the serving URL.

Audience agreement:
- `MiddlewareManifest` and `InterceptorManifest` gain `expected_audience`.
  The audience is otherwise configured independently on each side of the
  boundary, where a mismatch surfaces only as an opaque authentication
  failure on every call. OpenShell now compares the two and fails at
  startup. An empty field keeps the check off for existing services.

Compatibility:
- Add `allow_insecure_transport` per registration. Enabling gateway JWT
  signing previously made any plaintext endpoint a hard startup failure,
  including the endpoint form used in our own documentation. The opt-out
  attaches no credential, is refused by the gateway if a supervisor asks
  for one, and warns at every startup.
- Make the transport requirement kind-aware. A middleware endpoint must
  be reachable from every sandbox supervisor, so only interceptors may
  use a gateway-local Unix socket.

Credential lifecycle:
- Replace the process-global slot map with a supervisor-owned
  `ExtensionCredentialStore` shared explicitly across the gateway
  connections the supervisor opens, removing test-order coupling.
- Rotate only when a credential is missing or has passed four fifths of
  its lifetime. Configuration polling ran every ten seconds against
  fifteen-minute credentials, so each poll re-ran gateway effective-policy
  resolution and re-minted the gateway token.
- Bound credential minting per sandbox, since each request resolves the
  caller's effective policy.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* docs: record alpha extension authentication in RFC appendices

Restore the RFC 0009 and 0010 bodies to their accepted text and move
every extension-authentication update into appendices instead. An RFC
records a decision at a point in time; superseding detail belongs
alongside it rather than rewritten into it.

RFC 0009's appendix carries the shared contract: claims, authorization,
key distribution, the `allow_insecure_transport` replacement for the
body's `allow_insecure`, and residual risks. RFC 0010's records only
what differs for interceptors and links to it. The existing
protocol-extensions appendix, which parked the phase 2 transport
question, now points forward to what was built.

Also document the audience handshake, the discovery endpoint, the
`typ` requirement, and `jti` replay guidance in the extensibility and
gateway configuration pages, and correct the middleware transport
guidance: middleware endpoints must be reachable from sandbox
supervisors, so Unix sockets are not an option there.

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* refactor(extension-core): abstract extension server trust

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* docs(core): update middleware manifest example

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* fix(extension-auth): preserve unsigned gateway compatibility

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

* fix(extension-auth): reject cross-domain token replay

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>

---------

Signed-off-by: Piotr Mlocek <pmlocek@nvidia.com>
2026-08-14 20:34:10 +00:00

253 lines
7.5 KiB
Rust

// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
use std::fmt;
use std::sync::{Arc, RwLock};
use std::time::{SystemTime, UNIX_EPOCH};
use tonic::metadata::AsciiMetadataValue;
use tonic::{Request, Status};
#[derive(Clone)]
pub struct BearerTokenSlot {
inner: Arc<RwLock<Option<Token>>>,
}
#[derive(Clone)]
struct Token {
authorization: AsciiMetadataValue,
expires_at_ms: i64,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
pub enum TokenSlotError {
#[error("extension bearer token is not valid for an HTTP authorization header")]
InvalidToken,
#[error("extension bearer token expiry must be a positive Unix timestamp in milliseconds")]
InvalidExpiry,
}
impl BearerTokenSlot {
/// Create an empty slot. Requests fail closed until [`Self::update`] is called.
pub fn empty() -> Self {
Self {
inner: Arc::new(RwLock::new(None)),
}
}
pub fn new(token: &str, expires_at_ms: i64) -> Result<Self, TokenSlotError> {
let slot = Self::empty();
slot.update(token, expires_at_ms)?;
Ok(slot)
}
/// Replace the credential without rebuilding channels or generated clients.
pub fn update(&self, token: &str, expires_at_ms: i64) -> Result<(), TokenSlotError> {
if expires_at_ms <= 0 {
return Err(TokenSlotError::InvalidExpiry);
}
if token.is_empty() || token.bytes().any(|byte| byte.is_ascii_whitespace()) {
return Err(TokenSlotError::InvalidToken);
}
let authorization = AsciiMetadataValue::try_from(format!("Bearer {token}"))
.map_err(|_| TokenSlotError::InvalidToken)?;
*self
.inner
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(Token {
authorization,
expires_at_ms,
});
Ok(())
}
/// Remove the credential immediately. Subsequent requests fail closed.
pub fn clear(&self) {
*self
.inner
.write()
.unwrap_or_else(std::sync::PoisonError::into_inner) = None;
}
pub fn expires_at_ms(&self) -> Option<i64> {
self.inner
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.as_ref()
.map(|token| token.expires_at_ms)
}
pub fn interceptor(&self) -> BearerTokenInterceptor {
BearerTokenInterceptor {
slot: Some(self.clone()),
}
}
fn authorization_at(&self, now_ms: i64) -> Result<AsciiMetadataValue, Status> {
let guard = self
.inner
.read()
.unwrap_or_else(std::sync::PoisonError::into_inner);
let token = guard
.as_ref()
.ok_or_else(|| Status::unauthenticated("extension bearer token is unavailable"))?;
if token.expires_at_ms <= now_ms {
return Err(Status::unauthenticated(
"extension bearer token has expired",
));
}
Ok(token.authorization.clone())
}
}
impl Default for BearerTokenSlot {
fn default() -> Self {
Self::empty()
}
}
impl fmt::Debug for BearerTokenSlot {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BearerTokenSlot")
.field("expires_at_ms", &self.expires_at_ms())
.finish_non_exhaustive()
}
}
#[derive(Clone)]
pub struct BearerTokenInterceptor {
slot: Option<BearerTokenSlot>,
}
impl BearerTokenInterceptor {
/// Create an explicit no-op interceptor for legacy registrations that have
/// not opted into audience authentication.
pub const fn disabled() -> Self {
Self { slot: None }
}
}
impl fmt::Debug for BearerTokenInterceptor {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter
.debug_struct("BearerTokenInterceptor")
.field("enabled", &self.slot.is_some())
.field("slot", &self.slot)
.finish()
}
}
impl tonic::service::Interceptor for BearerTokenInterceptor {
fn call(&mut self, mut request: Request<()>) -> Result<Request<()>, Status> {
let Some(slot) = &self.slot else {
return Ok(request);
};
let now_ms = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |duration| {
i64::try_from(duration.as_millis()).unwrap_or(i64::MAX)
});
let authorization = slot.authorization_at(now_ms)?;
request
.metadata_mut()
.insert("authorization", authorization);
Ok(request)
}
}
#[cfg(test)]
mod tests {
use tonic::service::Interceptor;
use super::*;
#[test]
fn slot_rotates_all_interceptor_clones_in_place() {
let slot = BearerTokenSlot::new("first-secret", i64::MAX).unwrap();
let mut first = slot.interceptor();
let mut second = first.clone();
assert_eq!(
first
.call(Request::new(()))
.unwrap()
.metadata()
.get("authorization")
.unwrap(),
"Bearer first-secret"
);
slot.update("second-secret", i64::MAX).unwrap();
assert_eq!(
second
.call(Request::new(()))
.unwrap()
.metadata()
.get("authorization")
.unwrap(),
"Bearer second-secret"
);
}
#[test]
fn empty_expired_and_cleared_slots_fail_closed() {
let slot = BearerTokenSlot::empty();
assert_eq!(
slot.interceptor()
.call(Request::new(()))
.unwrap_err()
.code(),
tonic::Code::Unauthenticated
);
slot.update("expired", 1).unwrap();
assert_eq!(
slot.interceptor()
.call(Request::new(()))
.unwrap_err()
.code(),
tonic::Code::Unauthenticated
);
slot.update("current", i64::MAX).unwrap();
slot.clear();
assert_eq!(
slot.interceptor()
.call(Request::new(()))
.unwrap_err()
.code(),
tonic::Code::Unauthenticated
);
}
#[test]
fn debug_and_errors_do_not_expose_token_material() {
let secret = "super-secret-extension-token";
let slot = BearerTokenSlot::new(secret, i64::MAX).unwrap();
assert!(!format!("{slot:?}").contains(secret));
assert!(!format!("{:?}", slot.interceptor()).contains(secret));
let error = BearerTokenSlot::new("contains\nnewline", i64::MAX).unwrap_err();
assert!(!error.to_string().contains("contains"));
assert_eq!(
BearerTokenSlot::new("", i64::MAX).unwrap_err(),
TokenSlotError::InvalidToken
);
}
#[test]
fn disabled_interceptor_leaves_authorization_untouched() {
let mut interceptor = BearerTokenInterceptor::disabled();
let mut request = Request::new(());
request
.metadata_mut()
.insert("authorization", "Bearer caller-value".parse().unwrap());
let request = interceptor.call(request).unwrap();
assert_eq!(
request.metadata().get("authorization").unwrap(),
"Bearer caller-value"
);
assert!(format!("{interceptor:?}").contains("enabled: false"));
}
}