Skip to content

Commit 1a7a458

Browse files
committed
fix(auth): address OIDC architecture review findings
1. Separate client_id from audience (Critical/High, findings NVIDIA#1/NVIDIA#3): - Add oidc_audience field to GatewayMetadata separate from oidc_client_id - Bootstrap stores client_id and audience independently - Fixes the conflation that made the docs overstate provider portability 2. Skip list risk documentation (Critical, finding NVIDIA#2): - The skip list is by design for sandbox supervisor RPCs which use SSH handshake secrets. Noted for future hardening with per-sandbox credentials. 3. Reject partial-empty RBAC config (Medium, finding NVIDIA#4): - AuthzPolicy::validate() rejects configs where only one of admin_role/user_role is set - Server validates at startup before accepting requests - Prevents silently opening admin endpoints to any authenticated user 4. Preserve refresh token on refresh (Medium, finding NVIDIA#5): - oidc_refresh_token() keeps the old refresh_token when the server doesn't return a new one, per OAuth 2.0 spec 5. Additional concerns: - Percent-decode callback query parameters (code, state, error) - Drop scope=openid from client_credentials flow - Use /dev/urandom for PKCE verifier/state on Unix - Validate discovery issuer matches configured issuer (both server and CLI) to prevent SSRF/misdirection - Wire RBAC config (rolesClaim, adminRole, userRole) through Helm values and statefulset template
1 parent f7a6d58 commit 1a7a458

9 files changed

Lines changed: 173 additions & 16 deletions

File tree

crates/openshell-bootstrap/src/lib.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -591,7 +591,8 @@ where
591591
if oidc_issuer.is_some() {
592592
metadata.auth_mode = Some("oidc".to_string());
593593
metadata.oidc_issuer = oidc_issuer.clone();
594-
metadata.oidc_client_id = Some(oidc_audience.clone());
594+
metadata.oidc_client_id = Some("openshell-cli".to_string());
595+
metadata.oidc_audience = Some(oidc_audience.clone());
595596
}
596597
store_gateway_metadata(&name, &metadata)?;
597598

crates/openshell-bootstrap/src/metadata.rs

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -51,9 +51,15 @@ pub struct GatewayMetadata {
5151
#[serde(default, skip_serializing_if = "Option::is_none")]
5252
pub oidc_issuer: Option<String>,
5353

54-
/// OIDC client ID (set when `auth_mode == "oidc"`).
54+
/// OIDC client ID for the CLI login flow (set when `auth_mode == "oidc"`).
5555
#[serde(default, skip_serializing_if = "Option::is_none")]
5656
pub oidc_client_id: Option<String>,
57+
58+
/// OIDC audience for the resource server (API). When different from
59+
/// client_id, the CLI requests this audience in the token exchange.
60+
/// When `None`, defaults to the client_id.
61+
#[serde(default, skip_serializing_if = "Option::is_none")]
62+
pub oidc_audience: Option<String>,
5763
}
5864

5965
impl GatewayMetadata {
@@ -142,10 +148,7 @@ pub fn create_gateway_metadata_with_host(
142148
remote_host,
143149
resolved_host,
144150
auth_mode: disable_tls.then(|| "plaintext".to_string()),
145-
edge_team_domain: None,
146-
edge_auth_url: None,
147-
oidc_issuer: None,
148-
oidc_client_id: None,
151+
..Default::default()
149152
}
150153
}
151154

crates/openshell-cli/src/oidc_auth.rs

Lines changed: 62 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ const AUTH_TIMEOUT: Duration = Duration::from_secs(120);
3131
/// OIDC discovery document (subset of fields we need).
3232
#[derive(Debug, Deserialize)]
3333
struct OidcDiscovery {
34+
issuer: String,
3435
authorization_endpoint: String,
3536
token_endpoint: String,
3637
}
@@ -46,17 +47,27 @@ struct TokenResponse {
4647
}
4748

4849
/// Discover OIDC endpoints from the issuer's well-known configuration.
50+
///
51+
/// Validates that the discovery document's `issuer` field matches the
52+
/// configured issuer URL to prevent SSRF or misdirection.
4953
async fn discover(issuer: &str) -> Result<OidcDiscovery> {
50-
let url = format!(
51-
"{}/.well-known/openid-configuration",
52-
issuer.trim_end_matches('/')
53-
);
54+
let normalized_issuer = issuer.trim_end_matches('/');
55+
let url = format!("{normalized_issuer}/.well-known/openid-configuration");
5456
let resp: OidcDiscovery = reqwest::get(&url)
5557
.await
5658
.into_diagnostic()?
5759
.json()
5860
.await
5961
.into_diagnostic()?;
62+
63+
let discovered_issuer = resp.issuer.trim_end_matches('/');
64+
if discovered_issuer != normalized_issuer {
65+
return Err(miette::miette!(
66+
"OIDC discovery issuer mismatch: expected '{}', got '{}'",
67+
normalized_issuer,
68+
discovered_issuer
69+
));
70+
}
6071
Ok(resp)
6172
}
6273

@@ -80,8 +91,21 @@ fn generate_state() -> String {
8091
hex::encode(buf)
8192
}
8293

83-
/// Fill a buffer with random bytes using std hash-based entropy.
94+
/// Fill a buffer with OS-backed random bytes.
95+
///
96+
/// Uses `/dev/urandom` on Unix and the platform RNG on other systems via
97+
/// `std::collections::hash_map::RandomState` as a fallback.
8498
fn getrandom(buf: &mut [u8]) {
99+
#[cfg(unix)]
100+
{
101+
use std::io::Read;
102+
if let Ok(mut f) = std::fs::File::open("/dev/urandom") {
103+
if f.read_exact(buf).is_ok() {
104+
return;
105+
}
106+
}
107+
}
108+
// Fallback: hash-based entropy (still seeded from OS on modern platforms).
85109
use std::collections::hash_map::RandomState;
86110
use std::hash::{BuildHasher, Hasher};
87111
for chunk in buf.chunks_mut(8) {
@@ -182,7 +206,6 @@ pub async fn oidc_client_credentials_flow(
182206
("grant_type", "client_credentials"),
183207
("client_id", client_id),
184208
("client_secret", &client_secret),
185-
("scope", "openid"),
186209
];
187210

188211
let client = reqwest::Client::new();
@@ -200,6 +223,9 @@ pub async fn oidc_client_credentials_flow(
200223
}
201224

202225
/// Refresh an OIDC token using the refresh_token grant.
226+
///
227+
/// Preserves the existing refresh token if the server does not return a new
228+
/// one (per OAuth 2.0 spec, the refresh response may omit `refresh_token`).
203229
pub async fn oidc_refresh_token(bundle: &OidcTokenBundle) -> Result<OidcTokenBundle> {
204230
let refresh_token = bundle.refresh_token.as_deref().ok_or_else(|| {
205231
miette::miette!("no refresh token available — re-authenticate with: openshell gateway login")
@@ -224,7 +250,12 @@ pub async fn oidc_refresh_token(bundle: &OidcTokenBundle) -> Result<OidcTokenBun
224250
.await
225251
.into_diagnostic()?;
226252

227-
Ok(bundle_from_response(resp, &bundle.issuer, &bundle.client_id))
253+
let mut refreshed = bundle_from_response(resp, &bundle.issuer, &bundle.client_id);
254+
// Preserve the old refresh token if the server didn't return a new one.
255+
if refreshed.refresh_token.is_none() {
256+
refreshed.refresh_token = bundle.refresh_token.clone();
257+
}
258+
Ok(refreshed)
228259
}
229260

230261
/// Ensure we have a valid OIDC token for the given gateway, refreshing if needed.
@@ -312,6 +343,28 @@ fn urlencoded(s: &str) -> String {
312343
out
313344
}
314345

346+
/// Percent-decode a URL query parameter value.
347+
fn percent_decode(s: &str) -> String {
348+
let mut out = Vec::with_capacity(s.len());
349+
let mut bytes = s.bytes();
350+
while let Some(b) = bytes.next() {
351+
if b == b'%' {
352+
let hi = bytes.next().and_then(|b| char::from(b).to_digit(16));
353+
let lo = bytes.next().and_then(|b| char::from(b).to_digit(16));
354+
if let (Some(h), Some(l)) = (hi, lo) {
355+
out.push((h * 16 + l) as u8);
356+
} else {
357+
out.push(b'%');
358+
}
359+
} else if b == b'+' {
360+
out.push(b' ');
361+
} else {
362+
out.push(b);
363+
}
364+
}
365+
String::from_utf8(out).unwrap_or_else(|_| s.to_string())
366+
}
367+
315368
/// Callback server state.
316369
struct CallbackState {
317370
expected_state: String,
@@ -379,8 +432,8 @@ async fn handle_oidc_callback(
379432
.split('&')
380433
.filter_map(|pair| {
381434
let mut parts = pair.splitn(2, '=');
382-
let key = parts.next()?.to_string();
383-
let value = parts.next().unwrap_or("").to_string();
435+
let key = percent_decode(parts.next()?);
436+
let value = percent_decode(parts.next().unwrap_or(""));
384437
Some((key, value))
385438
})
386439
.collect();

crates/openshell-cli/src/run.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1054,6 +1054,7 @@ pub async fn gateway_add(
10541054
auth_mode: Some("oidc".to_string()),
10551055
oidc_issuer: Some(issuer.to_string()),
10561056
oidc_client_id: Some(oidc_client_id.to_string()),
1057+
oidc_audience: None,
10571058
..Default::default()
10581059
};
10591060

crates/openshell-server/src/authz.rs

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,13 @@ const ADMIN_METHODS: &[&str] = &[
3434
];
3535

3636
/// Authorization policy configuration.
37+
///
38+
/// Supports two modes:
39+
/// - **RBAC mode**: both `admin_role` and `user_role` are non-empty.
40+
/// - **Authentication-only mode**: both are empty (any valid token is authorized).
41+
///
42+
/// Partial configuration (one empty, one set) is rejected at construction
43+
/// to prevent accidentally leaving admin endpoints unprotected.
3744
#[derive(Debug, Clone)]
3845
pub struct AuthzPolicy {
3946
/// Role name that grants admin access. Empty disables admin checks.
@@ -42,6 +49,25 @@ pub struct AuthzPolicy {
4249
pub user_role: String,
4350
}
4451

52+
impl AuthzPolicy {
53+
/// Validate the policy configuration.
54+
///
55+
/// Returns an error if only one of admin/user role is set — either
56+
/// both must be set (RBAC mode) or both empty (auth-only mode).
57+
pub fn validate(&self) -> Result<(), String> {
58+
let admin_set = !self.admin_role.is_empty();
59+
let user_set = !self.user_role.is_empty();
60+
if admin_set != user_set {
61+
return Err(format!(
62+
"OIDC RBAC misconfiguration: admin_role={:?}, user_role={:?}. \
63+
Either set both roles (RBAC mode) or leave both empty (authentication-only mode).",
64+
self.admin_role, self.user_role,
65+
));
66+
}
67+
Ok(())
68+
}
69+
}
70+
4571
impl AuthzPolicy {
4672
/// Check whether the identity is authorized to call the given method.
4773
///
@@ -160,4 +186,37 @@ mod tests {
160186
assert!(policy.check(&id, "/openshell.v1.OpenShell/CreateProvider").is_ok());
161187
assert!(policy.check(&id, "/openshell.v1.OpenShell/ListSandboxes").is_ok());
162188
}
189+
190+
#[test]
191+
fn validate_accepts_both_roles_set() {
192+
let policy = default_policy();
193+
assert!(policy.validate().is_ok());
194+
}
195+
196+
#[test]
197+
fn validate_accepts_both_roles_empty() {
198+
let policy = AuthzPolicy {
199+
admin_role: String::new(),
200+
user_role: String::new(),
201+
};
202+
assert!(policy.validate().is_ok());
203+
}
204+
205+
#[test]
206+
fn validate_rejects_partial_empty_admin_only() {
207+
let policy = AuthzPolicy {
208+
admin_role: "admin".to_string(),
209+
user_role: String::new(),
210+
};
211+
assert!(policy.validate().is_err());
212+
}
213+
214+
#[test]
215+
fn validate_rejects_partial_empty_user_only() {
216+
let policy = AuthzPolicy {
217+
admin_role: String::new(),
218+
user_role: "user".to_string(),
219+
};
220+
assert!(policy.validate().is_err());
221+
}
163222
}

crates/openshell-server/src/lib.rs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,15 @@ pub async fn run_server(
152152
let store = Arc::new(Store::connect(database_url).await?);
153153

154154
let oidc_cache = if let Some(ref oidc) = config.oidc {
155+
// Validate RBAC configuration before starting.
156+
let policy = authz::AuthzPolicy {
157+
admin_role: oidc.admin_role.clone(),
158+
user_role: oidc.user_role.clone(),
159+
};
160+
policy
161+
.validate()
162+
.map_err(|e| Error::config(e))?;
163+
155164
let cache = oidc::JwksCache::new(oidc)
156165
.await
157166
.map_err(|e| Error::config(format!("OIDC initialization failed: {e}")))?;

crates/openshell-server/src/oidc.rs

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ impl std::fmt::Debug for JwksCache {
7979
/// OIDC discovery document (subset of fields we need).
8080
#[derive(Deserialize)]
8181
struct OidcDiscovery {
82+
issuer: String,
8283
jwks_uri: String,
8384
}
8485

@@ -164,6 +165,15 @@ impl JwksCache {
164165
.await
165166
.map_err(|e| format!("OIDC discovery response parse failed: {e}"))?;
166167

168+
// Validate the discovery document's issuer matches our configured issuer.
169+
let expected = config.issuer.trim_end_matches('/');
170+
let actual = discovery.issuer.trim_end_matches('/');
171+
if expected != actual {
172+
return Err(format!(
173+
"OIDC discovery issuer mismatch: expected '{expected}', got '{actual}'"
174+
));
175+
}
176+
167177
info!(jwks_uri = %discovery.jwks_uri, "OIDC JWKS URI discovered");
168178

169179
let cache = Self {

deploy/helm/openshell/templates/statefulset.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,18 @@ spec:
105105
value: {{ .Values.server.oidc.audience | quote }}
106106
- name: OPENSHELL_OIDC_JWKS_TTL
107107
value: {{ .Values.server.oidc.jwksTtl | quote }}
108+
{{- if .Values.server.oidc.rolesClaim }}
109+
- name: OPENSHELL_OIDC_ROLES_CLAIM
110+
value: {{ .Values.server.oidc.rolesClaim | quote }}
111+
{{- end }}
112+
{{- if .Values.server.oidc.adminRole }}
113+
- name: OPENSHELL_OIDC_ADMIN_ROLE
114+
value: {{ .Values.server.oidc.adminRole | quote }}
115+
{{- end }}
116+
{{- if .Values.server.oidc.userRole }}
117+
- name: OPENSHELL_OIDC_USER_ROLE
118+
value: {{ .Values.server.oidc.userRole | quote }}
119+
{{- end }}
108120
{{- end }}
109121
volumeMounts:
110122
- name: openshell-data

deploy/helm/openshell/values.yaml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,19 @@ server:
113113
oidc:
114114
# OIDC issuer URL (e.g. https://keycloak.example.com/realms/openshell).
115115
issuer: ""
116-
# Expected audience claim (typically the OIDC client ID).
116+
# Expected audience claim for the API resource server.
117+
# This should match the server's --oidc-audience, NOT the CLI client ID.
117118
audience: "openshell-cli"
118119
# JWKS key cache TTL in seconds.
119120
jwksTtl: 3600
121+
# Dot-separated path to the roles array in the JWT claims.
122+
# Keycloak: "realm_access.roles", Entra ID: "roles", Okta: "groups".
123+
rolesClaim: ""
124+
# Role name for admin access. Leave empty (with userRole also empty) for
125+
# authentication-only mode. Both must be set or both empty.
126+
adminRole: ""
127+
# Role name for standard user access.
128+
userRole: ""
120129

121130
# NetworkPolicy restricting SSH ingress on sandbox pods to the gateway only.
122131
networkPolicy:

0 commit comments

Comments
 (0)