Skip to main content

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// ---------------------------------------------------------------------------