kipuka/auth/mod.rs
1//! Authentication layer for the Kipuka EST server.
2//!
3//! RFC 7030 §3.2.3 defines several client authentication mechanisms for EST:
4//!
5//! - **mTLS** — client presents a certificate during the TLS handshake.
6//! - **HTTP Basic (OTP)** — username=entity-id, password=one-time password.
7//! - **HTTP Negotiate (GSSAPI)** — Kerberos/SPNEGO authentication.
8//!
9//! Each EST endpoint declares an authentication policy ([`AuthPolicy`]) that
10//! the [`EstAuth`] extractor enforces before the handler runs. Admin routes
11//! use a separate authentication mechanism (see [`super::routes::admin`]).
12
13pub mod cms_auth;
14pub mod enroll_authz;
15pub mod failure_tracker;
16pub mod gssapi;
17pub mod mtls;
18pub mod name_match;
19pub mod otp;
20
21use std::sync::Arc;
22
23use axum::extract::{FromRef, FromRequestParts};
24use axum::http::request::Parts;
25use axum::response::{IntoResponse, Response};
26
27use crate::error::KipukaError;
28use crate::state::AppState;
29
30/// OID for the id-kp-cmcRA Extended Key Usage (RFC 6402 section 2.10).
31///
32/// Used by `/fullcmc` and CMS-EST endpoints to verify the signer holds
33/// the CMC Registration Authority EKU (RHELBU-3536 R15).
34pub const CMC_RA_EKU_OID: &str = "1.3.6.1.5.5.7.3.28";
35
36/// How a client authenticated to the EST server.
37///
38/// Stored in [`AuthResult`] so handlers can make authorization decisions
39/// based on the authentication method used (e.g., `/simplereenroll`
40/// requires mTLS, `/fullcmc` requires id-kp-cmcRA EKU).
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum AuthMethod {
43 /// mTLS client certificate presented during TLS handshake.
44 Mtls,
45 /// HTTP Basic authentication with a one-time password.
46 Otp,
47 /// GSSAPI/SPNEGO (Kerberos) via the `Authorization: Negotiate` header.
48 Gssapi,
49 /// CMS SignedData authentication (RFC 8295).
50 Cms,
51 /// No authentication (used for unauthenticated endpoints like `/cacerts`).
52 None,
53}
54
55/// Result of a successful authentication.
56///
57/// Contains the authenticated identity, the method used, and any
58/// attributes extracted from the credential (e.g., certificate subject,
59/// SANs, EKU extensions).
60#[derive(Debug, Clone)]
61pub struct AuthResult {
62 /// The authenticated identity string.
63 ///
64 /// For mTLS: the certificate subject DN or SAN.
65 /// For OTP: the entity-id from HTTP Basic username.
66 /// For GSSAPI: the Kerberos principal name.
67 pub identity: String,
68
69 /// How the client authenticated.
70 pub method: AuthMethod,
71
72 /// DER-encoded client certificate (mTLS only).
73 ///
74 /// Available for POP linking validation in `/simpleenroll` and
75 /// `/simplereenroll` handlers.
76 pub client_cert_der: Option<Vec<u8>>,
77
78 /// Subject DN from the client certificate (mTLS only).
79 pub subject_dn: Option<String>,
80
81 /// Subject Alternative Names from the client certificate (mTLS only).
82 pub subject_alt_names: Vec<String>,
83
84 /// Extended Key Usage OIDs from the client certificate (mTLS only).
85 ///
86 /// Used by `/fullcmc` to verify the signer holds id-kp-cmcRA
87 /// (OID 1.3.6.1.5.5.7.3.28) per RHELBU-3536 R15.
88 pub extended_key_usage: Vec<String>,
89}
90
91impl AuthResult {
92 /// Create an unauthenticated result for endpoints that do not require auth.
93 pub fn anonymous() -> Self {
94 Self {
95 identity: String::new(),
96 method: AuthMethod::None,
97 client_cert_der: None,
98 subject_dn: None,
99 subject_alt_names: Vec::new(),
100 extended_key_usage: Vec::new(),
101 }
102 }
103
104 /// Returns `true` if the client certificate carries the id-kp-cmcRA EKU.
105 ///
106 /// OID: 1.3.6.1.5.5.7.3.28 (RFC 6402 §2.10).
107 pub fn has_cmc_ra_eku(&self) -> bool {
108 self.extended_key_usage
109 .iter()
110 .any(|oid| oid == CMC_RA_EKU_OID)
111 }
112}
113
114/// Authentication policy for an EST endpoint.
115///
116/// Determines which authentication methods are acceptable and whether
117/// authentication is required at all.
118#[derive(Debug, Clone)]
119pub enum AuthPolicy {
120 /// No authentication required (e.g., `/cacerts`, `/csrattrs`).
121 None,
122 /// At least one of the listed methods must succeed.
123 AnyOf(Vec<AuthMethod>),
124 /// A specific method is required (e.g., mTLS for `/simplereenroll`).
125 Required(AuthMethod),
126}
127
128/// Axum extractor that authenticates EST requests.
129///
130/// Tries each configured authentication method in order:
131/// 1. mTLS client certificate (from TLS session extensions)
132/// 2. HTTP Basic (OTP)
133/// 3. GSSAPI/SPNEGO (`Authorization: Negotiate`)
134///
135/// The extractor succeeds if the endpoint's [`AuthPolicy`] is satisfied.
136/// On failure, returns an appropriate HTTP 401/403 response.
137pub struct EstAuth(pub AuthResult);
138
139impl<S> FromRequestParts<S> for EstAuth
140where
141 S: Send + Sync,
142 Arc<AppState>: FromRef<S>,
143{
144 type Rejection = Response;
145
146 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Response> {
147 let app = Arc::<AppState>::from_ref(state);
148
149 let label = crate::routes::LabelExtractor::from_request_parts(parts, state).await?;
150 let allowed = app
151 .config
152 .est
153 .labels
154 .iter()
155 .find(|l| l.name == label.label)
156 .map(|l| &l.auth_methods);
157 let permits = |method: crate::config::EstAuthMethod| {
158 allowed.is_none_or(|methods| methods.is_empty() || methods.contains(&method))
159 };
160
161 // Try mTLS first — the client certificate is available as a request extension
162 // injected by the TLS accept loop.
163 if (permits(crate::config::EstAuthMethod::Mtls)
164 || permits(crate::config::EstAuthMethod::Certificate))
165 && let Some(auth) = mtls::try_extract_mtls(parts, &app).await
166 {
167 return Ok(EstAuth(auth));
168 }
169
170 // Try HTTP Basic (OTP) authentication.
171 if permits(crate::config::EstAuthMethod::Otp)
172 && let Some(result) = otp::try_extract_otp(parts, &app).await
173 {
174 match result {
175 Ok(auth) => return Ok(EstAuth(auth)),
176 Err(e) => return Err(e),
177 }
178 }
179
180 // Try GSSAPI/SPNEGO authentication.
181 if permits(crate::config::EstAuthMethod::Gssapi)
182 && let Some(result) = gssapi::try_extract_gssapi(parts, &app).await
183 {
184 match result {
185 Ok(auth) => return Ok(EstAuth(auth)),
186 Err(e) => return Err(e),
187 }
188 }
189
190 // No authentication method succeeded.
191 Err(KipukaError::Auth("no valid credentials provided".into()).into_response())
192 }
193}
194
195/// Axum extractor that allows unauthenticated access.
196///
197/// Used on endpoints like `/cacerts` and `/csrattrs` that do not require
198/// authentication per RFC 7030 §4.1 and §4.5. If credentials are
199/// present they are validated; if absent, an anonymous result is returned.
200pub struct OptionalAuth(pub AuthResult);
201
202impl<S> FromRequestParts<S> for OptionalAuth
203where
204 S: Send + Sync,
205 Arc<AppState>: FromRef<S>,
206{
207 type Rejection = Response;
208
209 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Response> {
210 let app = Arc::<AppState>::from_ref(state);
211
212 // Try mTLS — if a client cert is present, validate it.
213 if let Some(auth) = mtls::try_extract_mtls(parts, &app).await {
214 return Ok(OptionalAuth(auth));
215 }
216
217 // No credentials — that is fine for optional-auth endpoints.
218 Ok(OptionalAuth(AuthResult::anonymous()))
219 }
220}
221
222pub(crate) mod certificate;