Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions crates/openshell-core/src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,48 @@ impl tonic::service::Interceptor for EdgeAuthInterceptor {
Ok(req)
}
}

/// Return the credential from an `authorization` header value that uses the
/// `Bearer` scheme.
///
/// The scheme is matched ASCII case-insensitively and may be followed by more
/// than one space (RFC 9110 section 11.1). The credential is returned
/// verbatim, so callers keep their own validation.
#[must_use]
pub fn strip_bearer_scheme(value: &str) -> Option<&str> {
let (scheme, credential) = value.split_once(' ')?;
scheme
.eq_ignore_ascii_case("Bearer")
.then(|| credential.trim_start_matches(' '))
}

#[cfg(test)]
mod tests {
use super::strip_bearer_scheme;

#[test]
fn bearer_scheme_is_case_insensitive() {
for scheme in ["Bearer", "bearer", "BEARER", "bEaReR"] {
assert_eq!(strip_bearer_scheme(&format!("{scheme} abc")), Some("abc"));
}
}

#[test]
fn bearer_scheme_allows_repeated_spaces() {
assert_eq!(strip_bearer_scheme("Bearer abc"), Some("abc"));
}

#[test]
fn bearer_scheme_returns_credential_verbatim() {
assert_eq!(strip_bearer_scheme("Bearer "), Some(""));
assert_eq!(strip_bearer_scheme("Bearer a b"), Some("a b"));
assert_eq!(strip_bearer_scheme("Bearer abc "), Some("abc "));
}

#[test]
fn other_schemes_are_not_bearer() {
for value in ["Basic abc", "Bearerabc", "Bearer", "", "Bearer\tabc", "abc"] {
assert_eq!(strip_bearer_scheme(value), None, "{value:?}");
}
}
}
3 changes: 1 addition & 2 deletions crates/openshell-core/src/grpc_client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -648,14 +648,13 @@ fn compute_refresh_delay(slot: &TokenSlot) -> Option<Duration> {
.ok()
.and_then(|v| v.to_str().ok().map(str::to_string))
.unwrap_or_default();
let bearer = token.strip_prefix("Bearer ").unwrap_or(&token);
let now_ms = i64::try_from(
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| d.as_millis()),
)
.unwrap_or(i64::MAX);
let expires_at = parse_jwt_exp_ms(bearer);
let expires_at = parse_jwt_exp_ms(&token);
if expires_at == Some(0) {
return None;
}
Expand Down
3 changes: 2 additions & 1 deletion crates/openshell-core/src/jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
#[must_use]
pub fn parse_exp_secs(token: &str) -> Option<i64> {
use base64::Engine;
let raw = token.strip_prefix("Bearer ").unwrap_or(token);
let raw = crate::auth::strip_bearer_scheme(token).unwrap_or(token);
let mut parts = raw.splitn(3, '.');
let _header = parts.next()?;
let payload_b64 = parts.next()?;
Expand Down Expand Up @@ -879,6 +879,7 @@ mod tests {
sub: None,
});
assert_eq!(parse_exp_secs(&format!("Bearer {token}")), Some(42));
assert_eq!(parse_exp_secs(&format!("bearer {token}")), Some(42));
}

#[test]
Expand Down
23 changes: 21 additions & 2 deletions crates/openshell-sandbox-backend/src/sandbox_auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,7 @@ impl SandboxProtocolAuthenticator {
let value = value
.to_str()
.map_err(|_| SandboxAuthError::InvalidBearer)?;
let token = value
.strip_prefix("Bearer ")
let token = openshell_core::auth::strip_bearer_scheme(value)
.filter(|token| !token.is_empty() && !token.chars().any(char::is_whitespace))
.ok_or(SandboxAuthError::InvalidBearer)?;
let session = self.verifier.verify(token)?;
Expand Down Expand Up @@ -459,6 +458,26 @@ mod tests {
);
}

#[test]
fn bearer_scheme_is_case_insensitive() {
let (authenticator, token) = fixture(1);
for scheme in ["bearer", "BEARER"] {
let mut metadata = MetadataMap::new();
metadata.insert(
"authorization",
format!("{scheme} {}", token.token.expose_secret())
.parse()
.expect("metadata value"),
);
assert!(
authenticator
.authenticate(SandboxConnectionId::new(), &metadata)
.is_ok(),
"{scheme}"
);
}
}

#[test]
fn reconnect_requires_disconnect_and_terminal_is_final() {
let (first_authenticator, first_token) = fixture(1);
Expand Down
27 changes: 27 additions & 0 deletions crates/openshell-server/src/auth/authenticator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,17 @@ pub trait Authenticator: Send + Sync + 'static {
) -> Result<Option<Principal>, Status>;
}

/// Bearer credential from the request's `authorization` header, if any.
///
/// The scheme is matched case-insensitively. Non-Bearer or non-UTF-8 headers
/// yield `None` so the chain falls through.
pub fn bearer_credential(headers: &http::HeaderMap) -> Option<&str> {
headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(openshell_core::auth::strip_bearer_scheme)
}

/// First-match-wins authenticator chain.
///
/// The chain owns its authenticators behind `Arc` so the entire chain is
Expand Down Expand Up @@ -150,6 +161,22 @@ mod tests {
})
}

#[test]
fn bearer_credential_matches_scheme_case_insensitively() {
for scheme in ["Bearer", "bearer", "BEARER"] {
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
format!("{scheme} abc").parse().unwrap(),
);
assert_eq!(bearer_credential(&headers), Some("abc"), "{scheme}");
}
let mut headers = http::HeaderMap::new();
assert_eq!(bearer_credential(&headers), None);
headers.insert(http::header::AUTHORIZATION, "Basic abc".parse().unwrap());
assert_eq!(bearer_credential(&headers), None);
}

#[tokio::test]
async fn chain_returns_first_match() {
let first = Arc::new(MockAuthenticator::returning(Ok(Some(user_principal(
Expand Down
25 changes: 19 additions & 6 deletions crates/openshell-server/src/auth/compute_driver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

//! Compute-driver delegated sandbox bootstrap authentication.

use super::authenticator::Authenticator;
use super::authenticator::{Authenticator, bearer_credential};
use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal};
use crate::compute::ComputeRuntime;
use async_trait::async_trait;
Expand Down Expand Up @@ -34,11 +34,7 @@ impl Authenticator for ComputeDriverAuthenticator {
return Ok(None);
}

let Some(credential) = headers
.get(http::header::AUTHORIZATION)
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
else {
let Some(credential) = bearer_credential(headers) else {
return Ok(None);
};

Expand Down Expand Up @@ -118,6 +114,23 @@ mod tests {
));
}

#[tokio::test]
async fn accepts_lowercase_bearer_scheme() {
let auth = authenticator(NoopTestDriver::authenticating_sandbox("sandbox-a")).await;
let mut headers = http::HeaderMap::new();
headers.insert(
http::header::AUTHORIZATION,
http::HeaderValue::from_static("bearer driver-credential"),
);

let principal = auth
.authenticate(&headers, ISSUE_SANDBOX_TOKEN_PATH)
.await
.unwrap();

assert!(principal.is_some());
}

#[tokio::test]
async fn authenticator_is_scoped_to_issue_path() {
let auth = authenticator(NoopTestDriver::failing_sandbox_authentication(
Expand Down
8 changes: 2 additions & 6 deletions crates/openshell-server/src/auth/oidc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
//! This module owns authentication (verifying who the caller is).
//! Authorization (deciding what the caller can do) is in `authz.rs`.

use super::authenticator::Authenticator;
use super::authenticator::{Authenticator, bearer_credential};
use super::identity::{Identity, IdentityProvider};
use super::principal::{Principal, UserPrincipal};
use async_trait::async_trait;
Expand Down Expand Up @@ -891,11 +891,7 @@ impl Authenticator for OidcAuthenticator {
headers: &http::HeaderMap,
_path: &str,
) -> Result<Option<Principal>, Status> {
let Some(token) = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
else {
let Some(token) = bearer_credential(headers) else {
return Ok(None);
};

Expand Down
8 changes: 2 additions & 6 deletions crates/openshell-server/src/auth/peer.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
//! the apiserver `TokenReview` API, checks the live pod UID and required labels,
//! and only then produces a [`Principal::Peer`].

use super::authenticator::Authenticator;
use super::authenticator::{Authenticator, bearer_credential};
use super::principal::{PeerPrincipal, Principal};
use async_trait::async_trait;
use k8s_openapi::api::{
Expand Down Expand Up @@ -93,11 +93,7 @@ impl Authenticator for PeerServiceAccountAuthenticator {
return Ok(None);
}

let Some(token) = headers
.get("authorization")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.strip_prefix("Bearer "))
else {
let Some(token) = bearer_credential(headers) else {
return Ok(None);
};

Expand Down
8 changes: 2 additions & 6 deletions crates/openshell-server/src/auth/sandbox_jwt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
//! Algorithm: `EdDSA` (Ed25519). Pinned via `Validation::algorithms` to
//! prevent algorithm-confusion attacks.

use super::authenticator::Authenticator;
use super::authenticator::{Authenticator, bearer_credential};
use super::principal::{Principal, SandboxIdentitySource, SandboxPrincipal};
use async_trait::async_trait;
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
Expand Down Expand Up @@ -276,11 +276,7 @@ impl Authenticator for SandboxSessionJwtAuthenticator {
headers: &http::HeaderMap,
path: &str,
) -> Result<Option<Principal>, Status> {
let Some(token) = headers
.get("authorization")
.and_then(|value| value.to_str().ok())
.and_then(|value| value.strip_prefix("Bearer "))
else {
let Some(token) = bearer_credential(headers) else {
return Ok(None);
};
let Ok(header) = decode_header(token) else {
Expand Down
2 changes: 1 addition & 1 deletion crates/openshell-server/src/grpc/auth_rpc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,7 +200,7 @@ pub async fn handle_refresh_sandbox_token(
let gateway_token = authorization
.to_str()
.ok()
.and_then(|value| value.strip_prefix("Bearer "))
.and_then(openshell_core::auth::strip_bearer_scheme)
.ok_or_else(|| Status::unauthenticated("invalid bearer authorization metadata"))?;
let principal = session_authority.verify_gateway_token(gateway_token)?;
if principal.sandbox_id.as_str() != sandbox.sandbox_id {
Expand Down
10 changes: 3 additions & 7 deletions crates/openshell-server/src/service_routing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -777,14 +777,10 @@ fn validate_application_authorization<B>(
let value = value
.to_str()
.map_err(|_| ServiceRouteError::invalid_request())?;
let Some((scheme, credential)) = value.split_once(' ') else {
return Err(ServiceRouteError::invalid_request());
};
let credential = credential.trim_start_matches(' ');
if !scheme.eq_ignore_ascii_case("bearer") || !is_bearer_token68(credential) {
return Err(ServiceRouteError::invalid_request());
match openshell_core::auth::strip_bearer_scheme(value) {
Some(credential) if is_bearer_token68(credential) => Ok(()),
_ => Err(ServiceRouteError::invalid_request()),
}
Ok(())
}

fn is_bearer_token68(value: &str) -> bool {
Expand Down
2 changes: 1 addition & 1 deletion docs/how-it-works/gateways/authentication.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ The gateway validates OIDC access tokens against the issuer's JWKS endpoint. It

For EC and OKP keys, `crv` fully determines the algorithm. For RSA keys, the algorithm is selected from the JWK's declared `alg` field when it names one of the six RSA algorithms above; if `alg` is absent (as with Microsoft Entra ID, which omits it entirely), the gateway defaults to RS256. If `alg` names a non-RSA algorithm, the key is treated as contradictory and skipped (see below) rather than falling back to RS256.

The gateway rejects tokens when the JWT header `alg` does not match the algorithm pinned from the JWK. Tokens must include `iss`, `aud`, `exp`, and `sub` claims. The `iss` and `aud` values must match the configured issuer and audience.
The gateway accepts the `Bearer` scheme in the `Authorization` header in any letter case. It rejects tokens when the JWT header `alg` does not match the algorithm pinned from the JWK. Tokens must include `iss`, `aud`, `exp`, and `sub` claims. The `iss` and `aud` values must match the configured issuer and audience.

JWKs with `use: enc`, an `alg` that genuinely contradicts the derived algorithm (for example an RSA key declaring `ES256`), or `key_ops` that excludes `verify` are skipped. The gateway refreshes the JWKS cache when it encounters an unknown key ID, throttled to at most once per second to bound how much an unknown-`kid` lookup can amplify requests against the issuer. If a refresh yields zero usable keys, the gateway keeps serving the previous key set rather than dropping all signing keys and failing every request, but only for up to three times the configured TTL. If the JWKS is still empty after that grace period, the cached keys are evicted and all tokens are rejected until the issuer recovers.

Expand Down
Loading