Skip to content

Commit a1196d6

Browse files
committed
fix(docs): fix markdown lint errors in OIDC architecture docs
Add blank lines before lists and fenced code blocks to satisfy markdownlint MD031 and MD032 rules.
1 parent c5145a4 commit a1196d6

21 files changed

Lines changed: 127 additions & 114 deletions

File tree

architecture/oidc-auth.md

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,21 +4,14 @@ OpenShell supports OAuth2/OIDC (OpenID Connect) as an authentication mode alongs
44

55
## Architecture
66

7-
```
8-
+-------------------+
9-
| Keycloak / |
10-
| OIDC Provider |
11-
+--------+----------+
12-
|
13-
JWKS (cached) | Token exchange
14-
+---------+--------+---------+
15-
| |
16-
v v
17-
+----------+ Bearer token +-----------+ Auth Code +---------+
18-
| | -----------------> | | <-------------- | |
19-
| CLI | gRPC metadata | Gateway | + PKCE | Browser |
20-
| | <----------------- | Server | | |
21-
+----------+ response +-----------+ +---------+
7+
```mermaid
8+
graph LR
9+
CLI -->|"Bearer token<br/>(gRPC metadata)"| Gateway["Gateway<br/>Server"]
10+
Gateway -->|response| CLI
11+
Gateway -->|"JWKS (cached)"| Keycloak["Keycloak /<br/>OIDC Provider"]
12+
Browser -->|"Auth Code + PKCE"| Gateway
13+
Keycloak -->|"Token exchange"| CLI
14+
Keycloak -->|"Token exchange"| Browser
2215
```
2316

2417
## Auth Modes
@@ -151,6 +144,7 @@ GET {jwks_uri} -> { keys: [...] }
151144
```
152145

153146
Keys are cached in memory with a configurable TTL (default: 1 hour). A `refresh_mutex` serializes refresh operations so concurrent requests coalesce into a single HTTP fetch. The cache refreshes:
147+
154148
- When the TTL expires (on next request, re-checked under the mutex to avoid thundering herd).
155149
- Immediately when a JWT references a `kid` not in the cache (handles key rotation).
156150

@@ -192,6 +186,7 @@ These methods accept either an OIDC Bearer token (CLI users) or a sandbox secret
192186
| `OpenShell/GetSandboxConfig` | CLI reads effective sandbox policy and settings; sandbox callers may still use the shared secret |
193187

194188
**Sandbox-secret restriction on `UpdateConfig`:** When a sandbox-secret-authenticated caller invokes `UpdateConfig`, the handler in `policy.rs` enforces strict scope limits via `validate_sandbox_secret_update()`. The caller:
189+
195190
- **Must** provide a sandbox `name` (sandbox-scoped only).
196191
- **Must** include a `policy` payload (policy sync only).
197192
- **May not** set `global = true` (no global config mutation).

architecture/oidc-local-testing.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ mise run keycloak
1919
Wait for "Keycloak is ready." The script prints connection info including test users.
2020

2121
Verify:
22+
2223
```bash
2324
curl -s http://localhost:8180/realms/openshell/.well-known/openid-configuration | jq .issuer
2425
# Expected: "http://localhost:8180/realms/openshell"
@@ -37,6 +38,7 @@ cargo run -p openshell-server -- \
3738
```
3839

3940
You should see:
41+
4042
```
4143
OIDC JWT validation enabled (issuer: http://localhost:8180/realms/openshell)
4244
Server listening address=0.0.0.0:8080

crates/openshell-bootstrap/src/lib.rs

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ pub struct DeployOptions {
130130
pub oidc_audience: String,
131131
/// OIDC client ID for CLI login. Defaults to "openshell-cli".
132132
pub oidc_client_id: String,
133-
/// OIDC roles claim path (e.g. "realm_access.roles").
133+
/// OIDC roles claim path (e.g. `realm_access.roles`).
134134
pub oidc_roles_claim: Option<String>,
135135
/// OIDC admin role name.
136136
pub oidc_admin_role: Option<String>,
@@ -266,11 +266,11 @@ fn apply_oidc_gateway_metadata(
266266
&& let Some(existing) = existing
267267
&& existing.auth_mode.as_deref() == Some("oidc")
268268
{
269-
metadata.auth_mode = existing.auth_mode.clone();
270-
metadata.oidc_issuer = existing.oidc_issuer.clone();
271-
metadata.oidc_client_id = existing.oidc_client_id.clone();
272-
metadata.oidc_audience = existing.oidc_audience.clone();
273-
metadata.oidc_scopes = existing.oidc_scopes.clone();
269+
metadata.auth_mode.clone_from(&existing.auth_mode);
270+
metadata.oidc_issuer.clone_from(&existing.oidc_issuer);
271+
metadata.oidc_client_id.clone_from(&existing.oidc_client_id);
272+
metadata.oidc_audience.clone_from(&existing.oidc_audience);
273+
metadata.oidc_scopes.clone_from(&existing.oidc_scopes);
274274
}
275275
}
276276

crates/openshell-bootstrap/src/metadata.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -56,12 +56,12 @@ pub struct GatewayMetadata {
5656
pub oidc_client_id: Option<String>,
5757

5858
/// 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.
59+
/// `client_id`, the CLI requests this audience in the token exchange.
60+
/// When `None`, defaults to the `client_id`.
6161
#[serde(default, skip_serializing_if = "Option::is_none")]
6262
pub oidc_audience: Option<String>,
6363

64-
/// Space-separated OAuth2 scopes to request during OIDC login.
64+
/// Space-separated `OAuth2` scopes to request during OIDC login.
6565
/// When set, tokens will include these scopes for fine-grained access control.
6666
#[serde(default, skip_serializing_if = "Option::is_none")]
6767
pub oidc_scopes: Option<String>,

crates/openshell-bootstrap/src/oidc_token.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,10 @@ use std::path::PathBuf;
1616
/// OIDC token bundle persisted to disk.
1717
#[derive(Debug, Clone, Serialize, Deserialize)]
1818
pub struct OidcTokenBundle {
19-
/// OAuth2 access token (JWT).
19+
/// `OAuth2` access token (JWT).
2020
pub access_token: String,
2121

22-
/// OAuth2 refresh token. `None` for client_credentials grants.
22+
/// `OAuth2` refresh token. `None` for `client_credentials` grants.
2323
#[serde(default, skip_serializing_if = "Option::is_none")]
2424
pub refresh_token: Option<String>,
2525

crates/openshell-cli/src/bootstrap.rs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -191,13 +191,22 @@ pub async fn run_bootstrap(
191191

192192
// Deploy the gateway. The deploy flow auto-resumes from existing state
193193
// when it finds one. If that fails, fall back to a full recreate.
194-
let handle = match deploy_gateway_with_panel(build_options(false), &gateway_name, location)
195-
.await
194+
let handle = match Box::pin(deploy_gateway_with_panel(
195+
build_options(false),
196+
&gateway_name,
197+
location,
198+
))
199+
.await
196200
{
197201
Ok(handle) => handle,
198202
Err(resume_err) => {
199203
tracing::warn!("auto-bootstrap resume failed, falling back to recreate: {resume_err}");
200-
deploy_gateway_with_panel(build_options(true), &gateway_name, location).await?
204+
Box::pin(deploy_gateway_with_panel(
205+
build_options(true),
206+
&gateway_name,
207+
location,
208+
))
209+
.await?
201210
}
202211
};
203212
let server = handle.gateway_endpoint().to_string();

crates/openshell-cli/src/main.rs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -882,7 +882,7 @@ enum GatewayCommands {
882882
#[arg(long, requires = "oidc_issuer")]
883883
oidc_user_role: Option<String>,
884884

885-
/// Space-separated OAuth2 scopes to request during OIDC login.
885+
/// Space-separated `OAuth2` scopes to request during OIDC login.
886886
#[arg(long, requires = "oidc_issuer")]
887887
oidc_scopes: Option<String>,
888888

@@ -982,7 +982,7 @@ enum GatewayCommands {
982982
#[arg(long, requires = "oidc_issuer")]
983983
oidc_audience: Option<String>,
984984

985-
/// Space-separated OAuth2 scopes to request during OIDC login.
985+
/// Space-separated `OAuth2` scopes to request during OIDC login.
986986
/// When set, tokens will include these scopes for fine-grained access control.
987987
#[arg(long, requires = "oidc_issuer")]
988988
oidc_scopes: Option<String>,
@@ -1873,7 +1873,7 @@ async fn main() -> Result<()> {
18731873
oidc_user_role.as_deref(),
18741874
oidc_scopes.as_deref(),
18751875
oidc_scopes_claim.as_deref(),
1876-
)
1876+
))
18771877
.await?;
18781878
}
18791879
GatewayCommands::Stop {

crates/openshell-cli/src/oidc_auth.rs

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
//! OIDC authentication flows for CLI gateway login.
55
//!
66
//! Implements Authorization Code + PKCE (interactive browser flow) and
7-
//! Client Credentials (CI/automation) OAuth2 grant types against a
7+
//! Client Credentials (CI/automation) `OAuth2` grant types against a
88
//! Keycloak-compatible OIDC provider.
99
1010
use bytes::Bytes;
@@ -219,7 +219,7 @@ pub async fn oidc_client_credentials_flow(
219219
))
220220
}
221221

222-
/// Refresh an OIDC token using the refresh_token grant.
222+
/// Refresh an OIDC token using the `refresh_token` grant.
223223
///
224224
/// Preserves the existing refresh token if the server does not return a new
225225
/// one (per OAuth 2.0 spec, the refresh response may omit `refresh_token`).
@@ -245,7 +245,7 @@ pub async fn oidc_refresh_token(bundle: &OidcTokenBundle) -> Result<OidcTokenBun
245245
let mut refreshed =
246246
bundle_from_oauth2_response(&token_response, &bundle.issuer, &bundle.client_id);
247247
if refreshed.refresh_token.is_none() {
248-
refreshed.refresh_token = bundle.refresh_token.clone();
248+
refreshed.refresh_token.clone_from(&bundle.refresh_token);
249249
}
250250
Ok(refreshed)
251251
}
@@ -288,8 +288,8 @@ fn bundle_from_oauth2_response(
288288
.as_secs();
289289

290290
OidcTokenBundle {
291-
access_token: resp.access_token().secret().to_string(),
292-
refresh_token: resp.refresh_token().map(|rt| rt.secret().to_string()),
291+
access_token: resp.access_token().secret().clone(),
292+
refresh_token: resp.refresh_token().map(|rt| rt.secret().clone()),
293293
expires_at: resp.expires_in().map(|ei| now + ei.as_secs()),
294294
issuer: issuer.to_string(),
295295
client_id: client_id.to_string(),
@@ -305,7 +305,7 @@ fn percent_decode(s: &str) -> String {
305305
let hi = bytes.next().and_then(|b| char::from(b).to_digit(16));
306306
let lo = bytes.next().and_then(|b| char::from(b).to_digit(16));
307307
if let (Some(h), Some(l)) = (hi, lo) {
308-
out.push((h * 16 + l) as u8);
308+
out.push(u8::try_from(h * 16 + l).unwrap_or(b'%'));
309309
} else {
310310
out.push(b'%');
311311
}
@@ -354,7 +354,7 @@ async fn run_oidc_callback_server(
354354
tokio::spawn(async move {
355355
let service = service_fn(move |req| {
356356
let state = Arc::clone(&state);
357-
async move { Ok::<_, Infallible>(handle_oidc_callback(req, state).await) }
357+
async move { Ok::<_, Infallible>(handle_oidc_callback(req, state)) }
358358
});
359359

360360
if let Err(error) = Builder::new(TokioExecutor::new())
@@ -367,7 +367,7 @@ async fn run_oidc_callback_server(
367367
}
368368
}
369369

370-
async fn handle_oidc_callback(
370+
fn handle_oidc_callback(
371371
req: hyper::Request<hyper::body::Incoming>,
372372
state: Arc<CallbackState>,
373373
) -> Response<Full<Bytes>> {

crates/openshell-cli/src/run.rs

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -955,6 +955,7 @@ where
955955
///
956956
/// An `ssh://` endpoint (e.g., `ssh://user@host:8080`) is shorthand for
957957
/// `--remote user@host` with the gateway endpoint derived from the URL.
958+
#[allow(clippy::too_many_arguments)]
958959
pub async fn gateway_add(
959960
endpoint: &str,
960961
name: Option<&str>,
@@ -1338,7 +1339,7 @@ pub fn gateway_logout(name: &str) -> Result<()> {
13381339
}
13391340
}
13401341

1341-
eprintln!("{} Logged out of gateway '{name}'", "✓".green().bold(),);
1342+
eprintln!("{} Logged out of gateway '{name}'", "✓".green().bold());
13421343
Ok(())
13431344
}
13441345

@@ -1680,15 +1681,15 @@ pub async fn gateway_admin_deploy(
16801681
}
16811682
}
16821683

1683-
let handle = deploy_gateway_with_panel(options, name, location).await?;
1684+
let handle = Box::pin(deploy_gateway_with_panel(options, name, location)).await?;
16841685

16851686
// Persist oidc_scopes in gateway metadata so `gateway login` can
16861687
// request the correct scopes later.
1687-
if let Some(scopes) = oidc_scopes {
1688-
if let Ok(mut meta) = openshell_bootstrap::load_gateway_metadata(name) {
1689-
meta.oidc_scopes = Some(scopes.to_string());
1690-
let _ = store_gateway_metadata(name, &meta);
1691-
}
1688+
if let Some(scopes) = oidc_scopes
1689+
&& let Ok(mut meta) = openshell_bootstrap::load_gateway_metadata(name)
1690+
{
1691+
meta.oidc_scopes = Some(scopes.to_string());
1692+
let _ = store_gateway_metadata(name, &meta);
16921693
}
16931694

16941695
// Wait for the gRPC endpoint to actually accept connections before

crates/openshell-cli/src/tls.rs

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -292,21 +292,19 @@ pub async fn build_channel(server: &str, tls: &TlsOptions) -> Result<Channel> {
292292
// OIDC bearer auth over HTTPS: use mTLS certs for the transport layer
293293
// when available (server may still require client certs), and layer
294294
// the Bearer token on top via the interceptor.
295-
match require_tls_materials(server, tls) {
296-
Ok(materials) => build_tonic_tls_config(&materials),
297-
Err(_) => {
295+
require_tls_materials(server, tls).map_or_else(
296+
|_| {
298297
let resolved = tls.with_default_paths(server);
299-
if let Some(ca_path) = resolved.ca.as_ref() {
300-
if let Ok(ca_pem) = std::fs::read(ca_path) {
298+
resolved
299+
.ca
300+
.as_ref()
301+
.and_then(|ca_path| std::fs::read(ca_path).ok())
302+
.map_or_else(ClientTlsConfig::new, |ca_pem| {
301303
ClientTlsConfig::new().ca_certificate(Certificate::from_pem(ca_pem))
302-
} else {
303-
ClientTlsConfig::new()
304-
}
305-
} else {
306-
ClientTlsConfig::new()
307-
}
308-
}
309-
}
304+
})
305+
},
306+
|materials| build_tonic_tls_config(&materials),
307+
)
310308
} else if tls.edge_token.is_some() {
311309
// Edge bearer mode — routed through tunnel above; if we reach here
312310
// the server is not HTTPS so connect plaintext.
@@ -331,11 +329,13 @@ pub async fn grpc_client(server: &str, tls: &TlsOptions) -> Result<GrpcClient> {
331329
Ok(OpenShellClient::with_interceptor(channel, interceptor))
332330
}
333331

334-
/// Interceptor that injects authentication headers into every outgoing
335-
/// gRPC request. Supports OIDC Bearer tokens (standard `authorization`
336-
/// header) and Cloudflare Access tokens (custom headers). When no token
337-
/// is set, acts as a no-op. OIDC takes precedence over edge tokens.
332+
/// Interceptor that injects authentication headers into every outgoing gRPC request.
333+
///
334+
/// Supports OIDC Bearer tokens (standard `authorization` header) and
335+
/// Cloudflare Access tokens (custom headers). When no token is set, acts
336+
/// as a no-op. OIDC takes precedence over edge tokens.
338337
#[derive(Clone)]
338+
#[allow(clippy::struct_field_names)]
339339
pub struct EdgeAuthInterceptor {
340340
/// Standard `authorization: Bearer <token>` for OIDC.
341341
bearer_value: Option<tonic::metadata::MetadataValue<tonic::metadata::Ascii>>,

0 commit comments

Comments
 (0)