dioxus_clerk/server/config.rs
1//! Configuration for server-side Clerk request verification.
2
3use std::time::Duration;
4
5use crate::core::ClerkError;
6
7const DEFAULT_BACKEND_API_BASE_URL: &str = "https://api.clerk.com/v1";
8pub(crate) const DEFAULT_JWKS_CACHE_TTL: Duration = Duration::from_secs(60 * 60);
9pub(crate) const DEFAULT_UNKNOWN_KID_REFRESH_INTERVAL: Duration = Duration::from_secs(60 * 5);
10/// After a failed JWKS fetch, further refresh attempts are suppressed for this
11/// long so an upstream outage does not serialize every request into its own
12/// slow fetch while holding the refresh lock.
13pub(crate) const DEFAULT_JWKS_FAILURE_BACKOFF: Duration = Duration::from_secs(10);
14/// How long past the cache TTL a stored keyset may still be served when a
15/// refresh fails (stale-while-error), so a Clerk outage does not turn every
16/// credentialed request into a 503 while the cached keys would still verify.
17pub(crate) const DEFAULT_JWKS_MAX_STALE: Duration = Duration::from_secs(60 * 60 * 24);
18pub(crate) const DEFAULT_CLOCK_SKEW: Duration = Duration::from_secs(5);
19
20/// Configuration for [`crate::server::ClerkAuthLayer`].
21///
22/// # Security: default claim acceptance
23///
24/// By default (with no issuers, audiences, or authorized parties configured)
25/// verification accepts **any** RS256 JWT that is signed by your instance's
26/// JWKS and passes standard `exp`/`nbf` validation. The `iss`, `aud`, and
27/// `azp` claims are not checked.
28///
29/// This is safe for the common single-instance case: the JWKS endpoint is
30/// scoped to your Clerk instance by the secret key, so a token that verifies
31/// against it was necessarily minted by *your* instance. It is not a bypass.
32///
33/// # Security: session tokens only, by default
34///
35/// Verification accepts only Clerk **session** tokens by default: a token must
36/// carry a `sid` (session id) claim. Clerk **JWT-template** tokens (minted with
37/// `getToken({ template })` for third-party integrations) are signed by the
38/// same instance JWKS and carry a `sub`, but omit `sid`, so without this check
39/// a leaked template token could be replayed to authenticate as its user.
40/// Requiring `sid` rejects them while accepting every genuine session token.
41/// Opt out with [`allow_non_session_tokens`](Self::allow_non_session_tokens)
42/// only if you deliberately verify non-session Clerk JWTs here.
43///
44/// The claim checks below are further defense-in-depth for setups where the
45/// single-instance assumption is weaker: multiple apps or environments sharing
46/// an instance, satellite domains, or tokens minted for a different audience
47/// that you do not want one service to accept for another. For those, harden
48/// verification:
49///
50/// - [`add_authorized_party`](Self::add_authorized_party): restrict the
51/// `azp` origins that may present tokens (Clerk's recommended check; tokens
52/// without `azp` are still accepted, matching Clerk's guidance).
53/// - [`add_issuer`](Self::add_issuer): pin the instance frontend origin.
54/// - [`add_audience`](Self::add_audience): require a specific `aud` when you
55/// mint audience-scoped tokens.
56#[derive(Clone)]
57pub struct ClerkAuthLayerConfig {
58 pub(crate) secret_key: String,
59 backend_api_base_url: String,
60 allow_insecure_backend_api_base_url: bool,
61 pub(crate) static_jwks: Option<String>,
62 pub(crate) authorized_parties: Vec<String>,
63 pub(crate) audiences: Vec<String>,
64 pub(crate) issuers: Vec<String>,
65 pub(crate) clock_skew: Duration,
66 pub(crate) require_session_id: bool,
67}
68
69impl ClerkAuthLayerConfig {
70 /// Creates a config using Clerk's default Backend API base URL.
71 pub fn new(secret_key: impl Into<String>) -> Self {
72 Self {
73 secret_key: secret_key.into(),
74 backend_api_base_url: DEFAULT_BACKEND_API_BASE_URL.into(),
75 allow_insecure_backend_api_base_url: false,
76 static_jwks: None,
77 authorized_parties: vec![],
78 audiences: vec![],
79 issuers: vec![],
80 clock_skew: DEFAULT_CLOCK_SKEW,
81 require_session_id: true,
82 }
83 }
84
85 /// Creates a config from the conventional `CLERK_SECRET_KEY` environment variable.
86 pub fn from_env() -> Result<Self, ClerkError> {
87 let secret_key = std::env::var("CLERK_SECRET_KEY").map_err(|_| {
88 ClerkError::InvalidConfig("missing CLERK_SECRET_KEY environment variable".into())
89 })?;
90 Ok(Self::new(secret_key))
91 }
92
93 /// Sets the HTTPS Clerk Backend API base URL, including API version.
94 ///
95 /// For example: `https://api.clerk.com/v1`. The JWKS endpoint is derived
96 /// by appending `/jwks` to this base URL.
97 pub fn with_backend_api_base_url(mut self, url: impl Into<String>) -> Self {
98 self.backend_api_base_url = url.into();
99 self.allow_insecure_backend_api_base_url = false;
100 self
101 }
102
103 /// Sets a local/test-only HTTP Backend API base URL.
104 ///
105 /// This explicitly allows `http://` URLs for local JWKS mocks. Do not use
106 /// it in production; plain HTTP allows JWKS response tampering.
107 pub fn with_insecure_backend_api_base_url(mut self, url: impl Into<String>) -> Self {
108 let url = url.into();
109 tracing::warn!(
110 url = %url,
111 "Clerk backend API base URL configured over insecure HTTP: JWKS signing keys can be tampered with in transit. Use with_backend_api_base_url with an https:// URL outside of local development and tests."
112 );
113 self.backend_api_base_url = url;
114 self.allow_insecure_backend_api_base_url = true;
115 self
116 }
117
118 /// Verifies against a fixed JWKS instead of fetching one from Clerk.
119 ///
120 /// `jwks_json` is a JWKS document (`{"keys": [...]}`). When set, the
121 /// Backend API base URL is never contacted: there is no HTTP client, no
122 /// cache, and no refresh, so the secret key is unused and may be empty.
123 ///
124 /// This is the offline path for tests — pair it with
125 /// [`TestIssuer`](crate::testing::TestIssuer) to verify locally minted
126 /// tokens without standing up a JWKS mock server at all.
127 ///
128 /// It is also usable in production to pin signing keys, but note that a
129 /// fixed keyset does not rotate: when Clerk rotates its signing keys,
130 /// every token signed by a new key is rejected until the configured JWKS
131 /// is updated and the process restarts. Prefer the fetched default unless
132 /// you have a specific reason to pin.
133 pub fn with_static_jwks(mut self, jwks_json: impl Into<String>) -> Self {
134 self.static_jwks = Some(jwks_json.into());
135 self
136 }
137
138 /// Adds a single allowed `azp` authorized-party origin to the configured set.
139 pub fn add_authorized_party(mut self, party: impl Into<String>) -> Self {
140 self.authorized_parties.push(party.into());
141 self
142 }
143
144 /// Sets allowed `azp` authorized-party origins.
145 ///
146 /// When configured, a token with an `azp` claim must match one of these
147 /// values. Tokens without `azp` continue to be accepted, matching Clerk's
148 /// manual verification guidance.
149 pub fn with_authorized_parties(
150 mut self,
151 parties: impl IntoIterator<Item = impl Into<String>>,
152 ) -> Self {
153 self.authorized_parties = parties.into_iter().map(Into::into).collect();
154 self
155 }
156
157 /// Adds a single allowed JWT audience to the configured set.
158 pub fn add_audience(mut self, audience: impl Into<String>) -> Self {
159 self.audiences.push(audience.into());
160 self
161 }
162
163 /// Sets allowed JWT audiences.
164 pub fn with_audiences(
165 mut self,
166 audiences: impl IntoIterator<Item = impl Into<String>>,
167 ) -> Self {
168 self.audiences = audiences.into_iter().map(Into::into).collect();
169 self
170 }
171
172 /// Adds a single allowed JWT issuer to the configured set.
173 ///
174 /// Clerk session tokens carry the instance frontend origin as `iss`
175 /// (for example `https://your-app.clerk.accounts.dev`). When configured,
176 /// tokens whose `iss` claim is missing or does not match are rejected.
177 pub fn add_issuer(mut self, issuer: impl Into<String>) -> Self {
178 self.issuers.push(issuer.into());
179 self
180 }
181
182 /// Sets allowed JWT issuers.
183 pub fn with_issuers(mut self, issuers: impl IntoIterator<Item = impl Into<String>>) -> Self {
184 self.issuers = issuers.into_iter().map(Into::into).collect();
185 self
186 }
187
188 /// Sets accepted clock skew for JWT `exp`, `nbf`, and `iat` validation.
189 pub fn with_clock_skew(mut self, clock_skew: Duration) -> Self {
190 self.clock_skew = clock_skew;
191 self
192 }
193
194 /// Accept instance-signed JWTs that are not session tokens (tokens without
195 /// a `sid` claim, such as Clerk JWT-template tokens).
196 ///
197 /// By default verification requires a `sid` so a leaked JWT-template token
198 /// cannot be replayed as a session (see the type-level security note). Call
199 /// this only when you deliberately verify non-session Clerk JWTs here, and
200 /// pair it with [`add_audience`](Self::add_audience) to keep tokens from one
201 /// template being accepted for another.
202 pub fn allow_non_session_tokens(mut self) -> Self {
203 self.require_session_id = false;
204 self
205 }
206
207 pub(crate) fn jwks_url(&self) -> Result<reqwest::Url, ClerkError> {
208 let jwks_url = format!("{}/jwks", self.backend_api_base_url.trim_end_matches('/'));
209 let url = reqwest::Url::parse(&jwks_url).map_err(|error| {
210 ClerkError::InvalidConfig(format!("invalid Clerk Backend API base URL: {error}"))
211 })?;
212
213 if url.scheme() != "https" && !self.allow_insecure_backend_api_base_url {
214 return Err(ClerkError::InvalidConfig(
215 "Clerk Backend API base URL must use https; use with_insecure_backend_api_base_url only for local tests".into(),
216 ));
217 }
218
219 Ok(url)
220 }
221}
222
223impl std::fmt::Debug for ClerkAuthLayerConfig {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 f.debug_struct("ClerkAuthLayerConfig")
226 .field("secret_key", &"<redacted>")
227 .field("backend_api_base_url", &self.backend_api_base_url)
228 .field(
229 "allow_insecure_backend_api_base_url",
230 &self.allow_insecure_backend_api_base_url,
231 )
232 .field("static_jwks", &self.static_jwks.is_some())
233 .field("authorized_parties", &self.authorized_parties)
234 .field("audiences", &self.audiences)
235 .field("issuers", &self.issuers)
236 .field("clock_skew", &self.clock_skew)
237 .field("require_session_id", &self.require_session_id)
238 .finish()
239 }
240}