fraiseql_auth/oidc_server_client.rs
1//! Server-side OIDC client for PKCE authorization code flows.
2//!
3//! This is a minimal, runtime-facing client that:
4//! 1. Builds the OIDC `/authorize` redirect URL with PKCE parameters.
5//! 2. Exchanges the authorization code + `code_verifier` for tokens.
6//!
7//! It is intentionally separate from the more general [`crate::oauth::OAuth2Client`] and
8//! [`crate::oauth::OIDCClient`] types in `oauth`: those carry JWKS caches and session
9//! management state that the PKCE route handlers do not need.
10// The client secret is loaded from the environment at runtime and is NEVER
11// stored in the compiled schema or TOML config.
12
13use std::{fmt, sync::Arc};
14
15use serde::Deserialize;
16use zeroize::Zeroizing;
17
18// ---------------------------------------------------------------------------
19// Resolved OIDC endpoints (cached in compiled schema)
20// ---------------------------------------------------------------------------
21
22/// OIDC endpoints fetched from the discovery document and cached in the
23/// compiled schema under `"auth_endpoints"`.
24#[derive(Debug, Clone, Deserialize)]
25pub struct OidcEndpoints {
26 /// The provider's `/authorize` URL.
27 pub authorization_endpoint: String,
28 /// The provider's `/token` URL.
29 pub token_endpoint: String,
30}
31
32// ---------------------------------------------------------------------------
33// Token response from the provider
34// ---------------------------------------------------------------------------
35
36/// Minimal token response from the OIDC `/token` endpoint.
37#[derive(Debug, Deserialize)]
38pub struct OidcTokenResponse {
39 /// The access token.
40 pub access_token: String,
41 /// The OpenID Connect identity token (if requested).
42 pub id_token: Option<String>,
43 /// Seconds until the access token expires.
44 pub expires_in: Option<u64>,
45 /// Refresh token (if the provider issued one).
46 pub refresh_token: Option<String>,
47}
48
49// ---------------------------------------------------------------------------
50// OidcServerClient
51// ---------------------------------------------------------------------------
52
53/// Minimal OIDC client for server-side PKCE code exchange.
54///
55/// Constructed once at server startup from the compiled schema.
56/// The client secret is read from the environment at that time and
57/// held in memory — it is never written to disk or emitted in logs.
58pub struct OidcServerClient {
59 client_id: String,
60 /// Intentionally private: the secret must never be accessible via a field.
61 /// Stored as `Zeroizing<String>` so the key material is wiped from memory
62 /// when this struct is dropped.
63 pub(crate) client_secret: Zeroizing<String>,
64 server_redirect_uri: String,
65 authorization_endpoint: String,
66 token_endpoint: String,
67}
68
69/// Custom `Debug` implementation that redacts the client secret.
70#[allow(clippy::missing_fields_in_debug)] // Reason: endpoint fields omitted to keep debug concise and avoid leaking config in logs
71impl fmt::Debug for OidcServerClient {
72 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73 f.debug_struct("OidcServerClient")
74 .field("client_id", &self.client_id)
75 .field("client_secret", &"[REDACTED]")
76 .field("authorization_endpoint", &self.authorization_endpoint)
77 .finish_non_exhaustive()
78 }
79}
80
81impl OidcServerClient {
82 /// Maximum byte length for an inbound PKCE `code_verifier` (RFC 7636 §4.1).
83 ///
84 /// Values longer than 128 characters exceed the RFC ceiling; rejecting them
85 /// before the outbound token request prevents log injection and memory
86 /// exhaustion on the provider side.
87 pub(crate) const MAX_CODE_VERIFIER_BYTES: usize = 128;
88 /// Maximum byte size accepted from the OIDC token endpoint response.
89 ///
90 /// A well-formed token response is a few KiB at most. 1 MiB prevents a
91 /// malicious or compromised OIDC provider from exhausting server memory.
92 pub(crate) const MAX_OIDC_RESPONSE_BYTES: usize = 1024 * 1024;
93 /// Minimum byte length for an inbound PKCE `code_verifier` (RFC 7636 §4.1).
94 ///
95 /// RFC 7636 mandates that the code verifier be between 43 and 128 characters.
96 /// Rejecting values below this floor prevents malformed verifiers — which
97 /// a browser extension or man-in-the-browser attack might supply — from
98 /// reaching the upstream OIDC provider.
99 pub(crate) const MIN_CODE_VERIFIER_BYTES: usize = 43;
100
101 /// Construct a client directly from resolved credentials and endpoints.
102 ///
103 /// Prefer [`Self::from_compiled_schema`] in production code.
104 /// This constructor exists for testing and direct wiring.
105 pub fn new(
106 client_id: impl Into<String>,
107 client_secret: impl Into<String>,
108 server_redirect_uri: impl Into<String>,
109 authorization_endpoint: impl Into<String>,
110 token_endpoint: impl Into<String>,
111 ) -> Self {
112 Self {
113 client_id: client_id.into(),
114 client_secret: Zeroizing::new(client_secret.into()),
115 server_redirect_uri: server_redirect_uri.into(),
116 authorization_endpoint: authorization_endpoint.into(),
117 token_endpoint: token_endpoint.into(),
118 }
119 }
120
121 /// Build an `OidcServerClient` from the compiled schema JSON.
122 ///
123 /// Returns `None` if:
124 /// - `schema_json["auth"]` is absent, or
125 /// - the env var named by `client_secret_env` is not set, or
126 /// - the OIDC endpoint cache (`schema_json["auth_endpoints"]`) is absent.
127 ///
128 /// In all failure cases an explanatory `tracing::error!` is emitted so
129 /// operators can diagnose startup issues without reading source code.
130 pub fn from_compiled_schema(schema_json: &serde_json::Value) -> Option<Arc<Self>> {
131 // ── Load [auth] config ────────────────────────────────────────────
132 #[derive(Deserialize)]
133 struct AuthCfg {
134 client_id: String,
135 client_secret_env: String,
136 server_redirect_uri: String,
137 }
138
139 let auth_cfg: AuthCfg =
140 schema_json.get("auth").and_then(|v| serde_json::from_value(v.clone()).ok())?;
141
142 // ── Read client secret from env ───────────────────────────────────
143 let Ok(client_secret) = std::env::var(&auth_cfg.client_secret_env) else {
144 tracing::error!(
145 env_var = %auth_cfg.client_secret_env,
146 "PKCE init failed: env var for OIDC client secret is not set"
147 );
148 return None;
149 };
150
151 // ── Load cached endpoints ─────────────────────────────────────────
152 let Some(endpoints): Option<OidcEndpoints> = schema_json
153 .get("auth_endpoints")
154 .and_then(|v| serde_json::from_value(v.clone()).ok())
155 else {
156 tracing::error!(
157 "PKCE init failed: 'auth_endpoints' not found in compiled schema. \
158 Re-compile the schema so that the CLI caches the OIDC discovery \
159 document (authorization_endpoint, token_endpoint)."
160 );
161 return None;
162 };
163
164 Some(Arc::new(Self {
165 client_id: auth_cfg.client_id,
166 client_secret: Zeroizing::new(client_secret),
167 server_redirect_uri: auth_cfg.server_redirect_uri,
168 authorization_endpoint: endpoints.authorization_endpoint,
169 token_endpoint: endpoints.token_endpoint,
170 }))
171 }
172
173 /// Build the OIDC `/authorize` redirect URL with all required PKCE params.
174 ///
175 /// The `state`, `code_challenge`, and `redirect_uri` values are
176 /// percent-encoded so that base64-url characters (+, /, =) do not
177 /// break query string parsing on the provider side.
178 #[must_use]
179 pub fn authorization_url(
180 &self,
181 state: &str,
182 code_challenge: &str,
183 code_challenge_method: &str,
184 ) -> String {
185 format!(
186 "{}?response_type=code\
187 &client_id={}\
188 &redirect_uri={}\
189 &scope=openid%20email%20profile\
190 &state={}\
191 &code_challenge={}\
192 &code_challenge_method={}",
193 self.authorization_endpoint,
194 urlencoding::encode(&self.client_id),
195 urlencoding::encode(&self.server_redirect_uri),
196 urlencoding::encode(state),
197 urlencoding::encode(code_challenge),
198 code_challenge_method,
199 )
200 }
201
202 // 1 MiB
203
204 /// Exchange an authorization code for tokens.
205 ///
206 /// Sends a `POST` to the provider's `/token` endpoint with the PKCE
207 /// `code_verifier` and all required OAuth2 fields.
208 ///
209 /// # Errors
210 ///
211 /// Returns an error if the HTTP request fails, the provider returns a
212 /// non-success status, the response exceeds `MAX_OIDC_RESPONSE_BYTES`, or
213 /// the response body cannot be parsed as JSON.
214 pub async fn exchange_code(
215 &self,
216 code: &str,
217 code_verifier: &str,
218 http: &reqwest::Client,
219 ) -> Result<OidcTokenResponse, anyhow::Error> {
220 // SECURITY: RFC 7636 §4.1 mandates 43–128 ASCII characters for code_verifier.
221 // Reject out-of-range values before they reach the upstream OIDC provider.
222 anyhow::ensure!(
223 code_verifier.len() >= Self::MIN_CODE_VERIFIER_BYTES,
224 "code_verifier too short ({} bytes, min {})",
225 code_verifier.len(),
226 Self::MIN_CODE_VERIFIER_BYTES,
227 );
228 anyhow::ensure!(
229 code_verifier.len() <= Self::MAX_CODE_VERIFIER_BYTES,
230 "code_verifier too long ({} bytes, max {})",
231 code_verifier.len(),
232 Self::MAX_CODE_VERIFIER_BYTES,
233 );
234
235 let resp = http
236 .post(&self.token_endpoint)
237 .form(&[
238 ("grant_type", "authorization_code"),
239 ("code", code),
240 ("code_verifier", code_verifier),
241 ("redirect_uri", self.server_redirect_uri.as_str()),
242 ("client_id", self.client_id.as_str()),
243 ("client_secret", self.client_secret.as_str()),
244 ])
245 .send()
246 .await?;
247
248 let status = resp.status();
249
250 // Read body with error propagation — unwrap_or_default() would silently
251 // discard network errors and return an empty body, masking failures.
252 let body_bytes = resp
253 .bytes()
254 .await
255 .map_err(|e| anyhow::anyhow!("Failed to read token response: {e}"))?;
256
257 // Size guard BEFORE the status check: a compromised provider could exhaust
258 // memory by sending an oversized non-2xx response that bypassed a later cap.
259 anyhow::ensure!(
260 body_bytes.len() <= Self::MAX_OIDC_RESPONSE_BYTES,
261 "OIDC token response too large ({} bytes, max {})",
262 body_bytes.len(),
263 Self::MAX_OIDC_RESPONSE_BYTES
264 );
265
266 if !status.is_success() {
267 // Body is already bounded by the size check above — no need for .min().
268 let body = String::from_utf8_lossy(&body_bytes);
269 anyhow::bail!("token endpoint returned {status}: {body}");
270 }
271
272 Ok(serde_json::from_slice::<OidcTokenResponse>(&body_bytes)?)
273 }
274}
275
276// ---------------------------------------------------------------------------
277// Unit tests
278// ---------------------------------------------------------------------------