Skip to main content

kipuka/auth/
gssapi.rs

1//! GSSAPI/Kerberos authentication for EST endpoints.
2//!
3//! Implements the `Authorization: Negotiate` (SPNEGO) authentication
4//! mechanism, following the same pattern as Akamu's GSSAPI support.
5//!
6//! Channel binding to the TLS session (tls-server-end-point, RFC 5929)
7//! is supported to prevent credential forwarding attacks.
8
9use std::sync::Arc;
10
11use axum::http::header::AUTHORIZATION;
12use axum::http::request::Parts;
13use axum::http::{HeaderValue, StatusCode};
14use axum::response::{IntoResponse, Response};
15use base64::Engine as _;
16use tracing::{debug, warn};
17
18use super::{AuthMethod, AuthResult};
19use crate::state::AppState;
20
21/// Request extension carrying the GSSAPI mutual-auth output token.
22///
23/// When `gss_accept_sec_context` produces an output token (i.e., the
24/// client requested mutual authentication), this extension is inserted
25/// into the request so handlers can include it in the response.
26///
27/// The inner [`HeaderValue`] is pre-formatted as `"Negotiate <base64>"`.
28#[derive(Clone)]
29pub struct NegotiateOutToken(pub HeaderValue);
30
31/// TLS channel binding data (tls-server-end-point, RFC 5929).
32///
33/// Injected into request extensions by the TLS accept loop.  Used to
34/// bind the GSSAPI context to the TLS session, preventing relay attacks.
35#[derive(Clone)]
36pub struct TlsChannelBinding(pub Vec<u8>);
37
38/// Attempt to extract and validate GSSAPI/SPNEGO credentials.
39///
40/// Returns:
41/// - `Some(Ok(AuthResult))` — GSSAPI authentication succeeded
42/// - `Some(Err(Response))` — Negotiate header present but invalid (401/403)
43/// - `None` — no Negotiate header present (try next auth method)
44pub async fn try_extract_gssapi(
45    parts: &mut Parts,
46    app: &Arc<AppState>,
47) -> Option<Result<AuthResult, Response>> {
48    let auth_header = parts.headers.get(AUTHORIZATION)?.to_str().ok()?;
49
50    // Only handle Negotiate tokens; Basic is handled by the OTP module.
51    let token_b64 = auth_header.strip_prefix("Negotiate ")?;
52
53    // Check that GSSAPI is configured.
54    let gss_cred = match app.gss_cred.as_ref() {
55        Some(cred) => Arc::clone(cred),
56        None => {
57            debug!("Negotiate header present but GSSAPI not configured");
58            return Some(Err((
59                StatusCode::UNAUTHORIZED,
60                "GSSAPI is not configured on this server",
61            )
62                .into_response()));
63        }
64    };
65
66    // Decode the base64 SPNEGO token.
67    let token_bytes = match base64::engine::general_purpose::STANDARD.decode(token_b64) {
68        Ok(t) => t,
69        Err(_) => {
70            return Some(Err((
71                StatusCode::BAD_REQUEST,
72                "malformed Negotiate token encoding",
73            )
74                .into_response()));
75        }
76    };
77
78    // Reject oversized tokens (128 KiB limit, matching Akamu).
79    const MAX_TOKEN_BYTES: usize = 128 * 1024;
80    if token_bytes.len() > MAX_TOKEN_BYTES {
81        return Some(Err((
82            StatusCode::BAD_REQUEST,
83            "Negotiate token exceeds size limit",
84        )
85            .into_response()));
86    }
87
88    // Extract TLS channel binding data for tls-server-end-point binding.
89    let channel_binding: Option<Vec<u8>> = parts
90        .extensions
91        .get::<TlsChannelBinding>()
92        .map(|b| b.0.clone());
93
94    // Use spawn_blocking for the synchronous GSSAPI FFI call so we do not
95    // block a tokio worker thread.
96    let binding_owned = channel_binding;
97    let token_owned = token_bytes;
98    let result = tokio::task::spawn_blocking(move || {
99        negotiate_accept(&gss_cred, &token_owned, binding_owned.as_deref())
100    })
101    .await;
102
103    let negotiate_result = match result {
104        Ok(r) => r,
105        Err(e) => {
106            tracing::error!(error = %e, "GSSAPI spawn_blocking panicked");
107            return Some(Err(StatusCode::INTERNAL_SERVER_ERROR.into_response()));
108        }
109    };
110
111    match negotiate_result {
112        Ok(NegotiateSuccess {
113            principal,
114            out_token,
115        }) => {
116            debug!(principal = %principal, "GSSAPI authentication succeeded");
117
118            // Store the output token (if any) for mutual authentication.
119            if !out_token.is_empty() {
120                let b64 = base64::engine::general_purpose::STANDARD.encode(&out_token);
121                if let Ok(hv) = HeaderValue::from_str(&format!("Negotiate {b64}")) {
122                    parts.extensions.insert(NegotiateOutToken(hv));
123                }
124            }
125
126            Some(Ok(AuthResult {
127                identity: principal,
128                method: AuthMethod::Gssapi,
129                client_cert_der: None,
130                subject_dn: None,
131                subject_alt_names: Vec::new(),
132                extended_key_usage: Vec::new(),
133            }))
134        }
135        Err(NegotiateError::Continue(out_token)) => {
136            // Multi-leg SPNEGO: return 401 with continuation token.
137            let b64 = base64::engine::general_purpose::STANDARD.encode(&out_token);
138            let mut resp = (StatusCode::UNAUTHORIZED, "").into_response();
139            if let Ok(hv) = HeaderValue::from_str(&format!("Negotiate {b64}")) {
140                resp.headers_mut().insert("WWW-Authenticate", hv);
141            }
142            Some(Err(resp))
143        }
144        Err(NegotiateError::Failed(msg)) => {
145            warn!(error = %msg, "GSSAPI authentication failed");
146            Some(Err(
147                (StatusCode::FORBIDDEN, "GSSAPI authentication failed").into_response()
148            ))
149        }
150    }
151}
152
153/// Build a 401 response with a `WWW-Authenticate: Negotiate` challenge.
154///
155/// Used when GSSAPI is configured but the client has not sent a Negotiate
156/// token.  Prompts the client to initiate a SPNEGO exchange.
157pub fn negotiate_challenge() -> Response {
158    let mut resp = (StatusCode::UNAUTHORIZED, "").into_response();
159    resp.headers_mut()
160        .insert("WWW-Authenticate", HeaderValue::from_static("Negotiate"));
161    resp
162}
163
164// ── Internal types ───────────────────────────────────────────────────────────
165
166struct NegotiateSuccess {
167    principal: String,
168    out_token: Vec<u8>,
169}
170
171#[allow(dead_code)]
172enum NegotiateError {
173    /// Multi-leg exchange: needs another round-trip with this output token.
174    Continue(Vec<u8>),
175    /// Authentication failed with the given reason.
176    Failed(String),
177}
178
179/// Synchronous SPNEGO token validation.
180///
181/// This function wraps the GSSAPI FFI calls and is designed to run inside
182/// `spawn_blocking`.  In the current implementation it delegates to
183/// `kipuka_util::gssapi` (placeholder); in production it would call
184/// `gss_accept_sec_context` via the `libgssapi` or `akamu_gssapi` crate.
185fn negotiate_accept(
186    _cred: &dyn std::any::Any,
187    token: &[u8],
188    channel_binding: Option<&[u8]>,
189) -> Result<NegotiateSuccess, NegotiateError> {
190    // TODO: Replace with actual GSSAPI implementation.
191    //
192    // The real implementation should:
193    // 1. Call gss_accept_sec_context with the server credential and input token
194    // 2. If the context is complete, extract the client principal name
195    // 3. Verify GSS_C_REPLAY_FLAG is set (replay detection)
196    // 4. If channel_binding is provided, verify it matches the TLS session
197    // 5. Return the principal and any output token for mutual auth
198
199    let _ = token;
200    let _ = channel_binding;
201
202    Err(NegotiateError::Failed(
203        "GSSAPI not yet implemented".to_string(),
204    ))
205}