dioxus_clerk/testing.rs
1//! Test helpers for applications that authenticate with Clerk through this
2//! crate.
3//!
4//! Tokens are minted locally from an RSA key this crate generates, so tests
5//! exercise the real [`ClerkAuthLayer`](crate::server::ClerkAuthLayer)
6//! verification path with no Clerk instance, no network, and no JWKS mock.
7//!
8//! # Testing your application
9//!
10//! When authentication is not the subject of the test — you just need a
11//! signed-in user so you can test what your app does — [`TestClerk`] is the
12//! whole API:
13//!
14// `layer` needs the `server` feature; this one is only compiled when it is on.
15#![cfg_attr(feature = "server", doc = "```no_run")]
16#![cfg_attr(not(feature = "server"), doc = "```ignore")]
17//! use dioxus_clerk::testing::TestClerk;
18//!
19//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
20//! let clerk = TestClerk::new()?;
21//!
22//! let layer = clerk.layer()?; // wire into your router
23//! let cookie = clerk.cookie("user_2abc")?; // send with a request
24//! # let _ = (layer, cookie);
25//! # Ok(())
26//! # }
27//! ```
28//!
29//! # Testing authentication itself
30//!
31//! When the auth behaviour *is* the subject — org permissions, expiry, tokens
32//! that should be rejected — [`TestSession`] builds the claims and
33//! [`TestIssuer`] signs them:
34//!
35#![cfg_attr(feature = "server", doc = "```no_run")]
36#![cfg_attr(not(feature = "server"), doc = "```ignore")]
37//! use dioxus_clerk::server::{ClerkAuthLayer, ClerkAuthLayerConfig};
38//! use dioxus_clerk::testing::{TestIssuer, TestSession};
39//!
40//! # fn main() -> Result<(), Box<dyn std::error::Error>> {
41//! let issuer = TestIssuer::generate()?;
42//!
43//! // The layer verifies against the issuer's keys; nothing is fetched.
44//! let config = ClerkAuthLayerConfig::new("").with_static_jwks(issuer.jwks_json()?);
45//! let layer = ClerkAuthLayer::from_config(config)?;
46//!
47//! let admin = issuer.sign(
48//! &TestSession::new("user_2abc")
49//! .with_organization("org_2ghi")
50//! .with_organization_role("org:admin"),
51//! )?;
52//! let expired = issuer.sign(&TestSession::new("user_2abc").expired())?;
53//! # let _ = (layer, admin, expired);
54//! # Ok(())
55//! # }
56//! ```
57//!
58//! For the full setup — sharing a key with a browser test runner, SSR tests
59//! that need no token, and Playwright configuration including the
60//! `window.Clerk` fake that keeps a browser suite offline — see the
61//! [testing guide](https://github.com/sagikazarmark/dioxus-clerk/blob/main/docs/testing.md).
62//!
63//! # Choosing a key
64//!
65//! This crate deliberately ships no key material. Pick whichever fits:
66//!
67//! - [`TestIssuer::generate`] — a fresh keypair, nothing on disk. Best when the
68//! tokens and the verifier live in the same process.
69//! - [`TestIssuer::from_pem_file`] — load a key you generated ahead of time.
70//! Needed when something *outside* the test process (a browser test runner, a
71//! separately spawned server) must sign or verify with the same key.
72//! - [`TestIssuer::from_pem_file_or_generate`] — load it, or create it on first
73//! use. Lets a gitignored key work on a fresh checkout with no setup step.
74//!
75//! Note that RSA-2048 key generation takes on the order of 100ms and is
76//! variable, which is fine per suite but adds up per test. Generate once and
77//! share it:
78//!
79//! ```no_run
80//! use std::sync::LazyLock;
81//! use dioxus_clerk::testing::TestIssuer;
82//!
83//! static ISSUER: LazyLock<TestIssuer> =
84//! LazyLock::new(|| TestIssuer::generate().expect("test issuer"));
85//! ```
86//!
87//! # Do not ship this
88//!
89//! Everything here mints tokens that a correctly configured verifier accepts.
90//! Keep the `testing` feature under `[dev-dependencies]`, and never point a
91//! production [`ClerkAuthLayer`](crate::server::ClerkAuthLayer) at a
92//! [`jwks_json`](TestIssuer::jwks_json) from this module.
93
94// This module documents how its tokens reach `ClerkAuthLayer`, so it links into
95// `crate::server` — which does not exist when `testing` is enabled on its own.
96// The links are correct wherever they are readable (docs.rs builds every
97// feature); only that one configuration cannot resolve them.
98#![cfg_attr(not(feature = "server"), allow(rustdoc::broken_intra_doc_links))]
99
100use std::collections::BTreeMap;
101use std::path::{Path, PathBuf};
102use std::time::{Duration, SystemTime, UNIX_EPOCH};
103
104use ct_codecs::{Base64UrlSafeNoPadding, Encoder};
105use jwt_simple::prelude::{JWTClaims, RS256KeyPair, RSAKeyPairLike};
106use serde_json::{Value, json};
107
108/// Default `kid` for a [`TestIssuer`], used in both the signed token header and
109/// the emitted JWKS so the two always agree.
110const DEFAULT_KEY_ID: &str = "dioxus-clerk-test-key";
111
112/// Default session lifetime, matching Clerk's own session token TTL.
113const DEFAULT_SESSION_LIFETIME: Duration = Duration::from_secs(60);
114
115/// RSA modulus size for [`TestIssuer::generate`]. Clerk signs with RS256, and
116/// the verifier rejects every other algorithm, so this is not configurable.
117const KEY_MODULUS_BITS: usize = 2048;
118
119/// Something went wrong minting a test token or loading a test key.
120#[derive(Debug, thiserror::Error)]
121#[non_exhaustive]
122pub enum TestIssuerError {
123 /// The key file could not be read or written.
124 #[error("failed to access test key at {path}: {source}")]
125 KeyFile {
126 /// The path that could not be read or written.
127 path: PathBuf,
128 /// The underlying filesystem error.
129 source: std::io::Error,
130 },
131
132 /// The key could not be generated, parsed, or serialized.
133 #[error("test key error: {0}")]
134 Key(String),
135
136 /// The token could not be signed.
137 #[error("failed to sign test token: {0}")]
138 Sign(String),
139
140 /// A session was built with organization permissions that are not in
141 /// Clerk's `org:<feature>:<permission>` form.
142 #[error(
143 "organization permission {0:?} is not in `org:<feature>:<permission>` form; \
144 use with_v1_organization_claims to emit unencoded permissions instead"
145 )]
146 OrganizationPermission(String),
147
148 /// The auth layer could not be built from this issuer's keys.
149 #[error("failed to build a test Clerk auth layer: {0}")]
150 Layer(String),
151}
152
153/// A ready-made Clerk setup for tests that are about the application rather
154/// than about authentication.
155///
156/// Wraps a [`TestIssuer`] so the common case — "this request is signed in as
157/// someone, now test what my app does" — is two calls and needs no knowledge of
158/// JWKS or token claims:
159///
160#[cfg_attr(feature = "server", doc = "```no_run")]
161#[cfg_attr(not(feature = "server"), doc = "```ignore")]
162/// # use dioxus_clerk::testing::TestClerk;
163/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
164/// let clerk = TestClerk::new()?;
165///
166/// // Wire the layer into your router, then send authenticated requests.
167/// let layer = clerk.layer()?;
168/// let cookie = clerk.cookie("user_2abc")?;
169/// # let _ = (layer, cookie);
170/// # Ok(())
171/// # }
172/// ```
173///
174/// Reach past it when authentication *is* what you are testing: [`session`] and
175/// [`cookie_for`] take a full [`TestSession`], and [`issuer`] exposes the
176/// underlying [`TestIssuer`].
177///
178/// [`session`]: Self::session
179/// [`cookie_for`]: Self::cookie_for
180/// [`issuer`]: Self::issuer
181pub struct TestClerk {
182 issuer: TestIssuer,
183}
184
185impl TestClerk {
186 /// Generates a fresh key and a Clerk setup around it.
187 ///
188 /// Costs one RSA-2048 key generation (~100ms, variable). Build it once per
189 /// suite and share it — see the [module docs](self).
190 pub fn new() -> Result<Self, TestIssuerError> {
191 Ok(Self::from_issuer(TestIssuer::generate()?))
192 }
193
194 /// Builds a Clerk setup around an existing issuer, so the key can be shared
195 /// with another process. See
196 /// [`TestIssuer::from_pem_file_or_generate`].
197 pub fn from_issuer(issuer: TestIssuer) -> Self {
198 Self { issuer }
199 }
200
201 /// The underlying issuer, for anything this wrapper does not cover.
202 pub fn issuer(&self) -> &TestIssuer {
203 &self.issuer
204 }
205
206 /// The JWKS document for this setup's key.
207 pub fn jwks_json(&self) -> Result<String, TestIssuerError> {
208 self.issuer.jwks_json()
209 }
210
211 /// A signed-in session for `user_id`, to customize before signing.
212 ///
213 /// `TestClerk::session("user_2abc")` is `TestSession::new("user_2abc")`;
214 /// it is here so a test does not have to import both types.
215 pub fn session(&self, user_id: impl Into<String>) -> TestSession {
216 TestSession::new(user_id)
217 }
218
219 /// A session token for `user_id`.
220 pub fn token(&self, user_id: impl Into<String>) -> Result<String, TestIssuerError> {
221 self.token_for(&TestSession::new(user_id))
222 }
223
224 /// A `__session` cookie header value for `user_id`, the credential Clerk's
225 /// browser SDK sends.
226 pub fn cookie(&self, user_id: impl Into<String>) -> Result<String, TestIssuerError> {
227 self.cookie_for(&TestSession::new(user_id))
228 }
229
230 /// An `Authorization` header value for `user_id`.
231 pub fn bearer(&self, user_id: impl Into<String>) -> Result<String, TestIssuerError> {
232 self.bearer_for(&TestSession::new(user_id))
233 }
234
235 /// A session token for a customized session.
236 pub fn token_for(&self, session: &TestSession) -> Result<String, TestIssuerError> {
237 self.issuer.sign(session)
238 }
239
240 /// A `__session` cookie header value for a customized session.
241 pub fn cookie_for(&self, session: &TestSession) -> Result<String, TestIssuerError> {
242 self.issuer.session_cookie(session)
243 }
244
245 /// An `Authorization` header value for a customized session.
246 pub fn bearer_for(&self, session: &TestSession) -> Result<String, TestIssuerError> {
247 Ok(format!("Bearer {}", self.issuer.sign(session)?))
248 }
249}
250
251#[cfg(feature = "server")]
252#[cfg_attr(docsrs, doc(cfg(feature = "server")))]
253impl TestClerk {
254 /// A layer config that verifies against this setup's key and never reaches
255 /// the network.
256 ///
257 /// Use this when the layer needs further configuration; otherwise
258 /// [`layer`](Self::layer) builds it directly.
259 pub fn config(&self) -> Result<crate::server::ClerkAuthLayerConfig, TestIssuerError> {
260 // The secret key only authenticates JWKS fetches, and a static keyset
261 // never fetches, so there is nothing meaningful to pass here.
262 Ok(crate::server::ClerkAuthLayerConfig::new("").with_static_jwks(self.jwks_json()?))
263 }
264
265 /// A `ClerkAuthLayer` that verifies tokens minted by this setup.
266 pub fn layer(&self) -> Result<crate::server::ClerkAuthLayer, TestIssuerError> {
267 crate::server::ClerkAuthLayer::from_config(self.config()?)
268 .map_err(|error| TestIssuerError::Layer(error.to_string()))
269 }
270}
271
272impl std::fmt::Debug for TestClerk {
273 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
274 f.debug_struct("TestClerk")
275 .field("issuer", &self.issuer)
276 .finish()
277 }
278}
279
280/// Mints Clerk-shaped session tokens signed by a local RSA key, and emits the
281/// matching JWKS for
282/// [`with_static_jwks`](crate::server::ClerkAuthLayerConfig::with_static_jwks).
283///
284/// See the [module docs](self) for how to choose between generating a key and
285/// loading one from disk.
286pub struct TestIssuer {
287 keypair: RS256KeyPair,
288 key_id: String,
289}
290
291impl TestIssuer {
292 /// Generates a fresh RSA-2048 keypair, held in memory only.
293 pub fn generate() -> Result<Self, TestIssuerError> {
294 let keypair = RS256KeyPair::generate(KEY_MODULUS_BITS)
295 .map_err(|error| TestIssuerError::Key(error.to_string()))?;
296
297 Ok(Self::from_keypair(keypair))
298 }
299
300 /// Loads a PKCS#1 or PKCS#8 RSA private key from PEM.
301 pub fn from_pem(pem: &str) -> Result<Self, TestIssuerError> {
302 let keypair =
303 RS256KeyPair::from_pem(pem).map_err(|error| TestIssuerError::Key(error.to_string()))?;
304
305 Ok(Self::from_keypair(keypair))
306 }
307
308 /// Loads a private key from a PEM file.
309 ///
310 /// Use this when the key must outlive the test process or be shared with
311 /// one — a browser test runner minting its own cookies, say. Prefer
312 /// [`from_pem_file_or_generate`](Self::from_pem_file_or_generate) if the
313 /// file is gitignored, so a fresh checkout does not have to run a setup
314 /// step first.
315 pub fn from_pem_file(path: impl AsRef<Path>) -> Result<Self, TestIssuerError> {
316 let path = path.as_ref();
317 let pem = std::fs::read_to_string(path).map_err(|source| TestIssuerError::KeyFile {
318 path: path.to_path_buf(),
319 source,
320 })?;
321
322 Self::from_pem(&pem)
323 }
324
325 /// Loads a private key from a PEM file, generating and writing one if the
326 /// file does not exist yet.
327 ///
328 /// This is the path for a gitignored key: the first run creates it, later
329 /// runs reuse it, and no separate `openssl` script is needed. Missing
330 /// parent directories are created.
331 ///
332 /// Concurrent first runs are safe. If another process wins the race to
333 /// create the file, this returns the key that process wrote rather than the
334 /// one generated here, so every process ends up on the same key.
335 pub fn from_pem_file_or_generate(path: impl AsRef<Path>) -> Result<Self, TestIssuerError> {
336 let path = path.as_ref();
337 match std::fs::read_to_string(path) {
338 Ok(pem) => return Self::from_pem(&pem),
339 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
340 Err(source) => {
341 return Err(TestIssuerError::KeyFile {
342 path: path.to_path_buf(),
343 source,
344 });
345 }
346 }
347
348 let issuer = Self::generate()?;
349 issuer.write_pem_file(path)?;
350
351 // Read back rather than returning `issuer`: a concurrent process may
352 // have created the file first, in which case its key is the one on
353 // disk and the one any other process will load.
354 Self::from_pem_file(path)
355 }
356
357 /// Writes this issuer's private key to `path`, creating parent directories.
358 ///
359 /// Leaves an existing file alone, so racing writers converge on whichever
360 /// key landed first.
361 fn write_pem_file(&self, path: &Path) -> Result<(), TestIssuerError> {
362 let key_file_error = |source: std::io::Error| TestIssuerError::KeyFile {
363 path: path.to_path_buf(),
364 source,
365 };
366
367 if let Some(parent) = path
368 .parent()
369 .filter(|parent| !parent.as_os_str().is_empty())
370 {
371 std::fs::create_dir_all(parent).map_err(key_file_error)?;
372 }
373
374 // Write to a writer-unique temporary file, then rename into place: a
375 // reader never observes a half-written key. The counter matters as much
376 // as the pid — the natural usage is one call per `#[test]`, so the
377 // racing writers are usually threads of a single process.
378 static WRITER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
379 let writer = WRITER.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
380 let temporary = path.with_extension(format!("tmp{}.{writer}", std::process::id()));
381 std::fs::write(&temporary, self.to_pem()?).map_err(key_file_error)?;
382 restrict_key_file_permissions(&temporary);
383
384 let renamed = std::fs::rename(&temporary, path);
385 if renamed.is_err() {
386 // Either the rename genuinely failed, or another process created
387 // the file first on a platform where rename refuses to clobber.
388 // The caller re-reads `path`, so a present file means success.
389 let _ = std::fs::remove_file(&temporary);
390 if !path.exists() {
391 return renamed.map_err(key_file_error);
392 }
393 }
394
395 Ok(())
396 }
397
398 fn from_keypair(keypair: RS256KeyPair) -> Self {
399 Self {
400 keypair: keypair.with_key_id(DEFAULT_KEY_ID),
401 key_id: DEFAULT_KEY_ID.to_string(),
402 }
403 }
404
405 /// Overrides the `kid` used in signed token headers and the emitted JWKS.
406 pub fn with_key_id(mut self, key_id: impl Into<String>) -> Self {
407 let key_id = key_id.into();
408 self.keypair = self.keypair.with_key_id(&key_id);
409 self.key_id = key_id;
410 self
411 }
412
413 /// The `kid` this issuer signs with.
414 pub fn key_id(&self) -> &str {
415 &self.key_id
416 }
417
418 /// Serializes the private key as PKCS#1 PEM.
419 pub fn to_pem(&self) -> Result<String, TestIssuerError> {
420 self.keypair
421 .to_pem()
422 .map_err(|error| TestIssuerError::Key(error.to_string()))
423 }
424
425 /// The JWKS document for this issuer's public key.
426 ///
427 /// Pass it to
428 /// [`with_static_jwks`](crate::server::ClerkAuthLayerConfig::with_static_jwks)
429 /// to verify offline, or serve it from a mock JWKS endpoint to exercise the
430 /// fetching and caching path as well.
431 pub fn jwks_json(&self) -> Result<String, TestIssuerError> {
432 let components = self.keypair.public_key().to_components();
433 let encode = |bytes: &[u8]| {
434 Base64UrlSafeNoPadding::encode_to_string(bytes)
435 .map_err(|error| TestIssuerError::Key(error.to_string()))
436 };
437
438 let jwks = json!({
439 "keys": [{
440 "use": "sig",
441 "kty": "RSA",
442 "kid": self.key_id,
443 "alg": "RS256",
444 "n": encode(&components.n)?,
445 "e": encode(&components.e)?,
446 }]
447 });
448
449 serde_json::to_string(&jwks).map_err(|error| TestIssuerError::Key(error.to_string()))
450 }
451
452 /// Signs `session` into a Clerk-shaped RS256 session token.
453 pub fn sign(&self, session: &TestSession) -> Result<String, TestIssuerError> {
454 let claims: JWTClaims<Value> = serde_json::from_value(session.to_claims()?)
455 .map_err(|error| TestIssuerError::Sign(error.to_string()))?;
456
457 self.keypair
458 .sign(claims)
459 .map_err(|error| TestIssuerError::Sign(error.to_string()))
460 }
461
462 /// Signs `session` and formats it as a `__session` cookie header value,
463 /// the credential Clerk's browser SDK sends and this crate reads.
464 pub fn session_cookie(&self, session: &TestSession) -> Result<String, TestIssuerError> {
465 Ok(format!("__session={}", self.sign(session)?))
466 }
467}
468
469impl std::fmt::Debug for TestIssuer {
470 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471 f.debug_struct("TestIssuer")
472 .field("key_id", &self.key_id)
473 .field("keypair", &"<redacted>")
474 .finish()
475 }
476}
477
478#[cfg(unix)]
479fn restrict_key_file_permissions(path: &Path) {
480 use std::os::unix::fs::PermissionsExt;
481
482 // Best-effort: a test key is not a secret worth failing a test run over.
483 let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
484}
485
486#[cfg(not(unix))]
487fn restrict_key_file_permissions(_path: &Path) {}
488
489/// A Clerk session to mint a token for.
490///
491/// Defaults to a currently-valid personal-account session: a `sid`, an `iat`
492/// and `nbf` of now, and an `exp` one minute out. Every part is overridable,
493/// including into shapes the verifier is supposed to reject — see
494/// [`expired`](Self::expired) and
495/// [`without_session_id`](Self::without_session_id).
496#[derive(Debug, Clone)]
497pub struct TestSession {
498 user_id: String,
499 session_id: Option<String>,
500 issuer: Option<String>,
501 audience: Option<String>,
502 authorized_party: Option<String>,
503 issued_at: Option<i64>,
504 not_before: Option<i64>,
505 expires_at: Option<i64>,
506 lifetime: Duration,
507 organization_id: Option<String>,
508 organization_slug: Option<String>,
509 organization_role: Option<String>,
510 organization_permissions: Vec<String>,
511 v1_organization_claims: bool,
512 extra_claims: BTreeMap<String, Value>,
513}
514
515impl TestSession {
516 /// A currently-valid session for `user_id`, with a derived `sid`.
517 pub fn new(user_id: impl Into<String>) -> Self {
518 let user_id = user_id.into();
519
520 Self {
521 session_id: Some(format!("sess_{user_id}")),
522 user_id,
523 issuer: None,
524 audience: None,
525 authorized_party: None,
526 issued_at: None,
527 not_before: None,
528 expires_at: None,
529 lifetime: DEFAULT_SESSION_LIFETIME,
530 organization_id: None,
531 organization_slug: None,
532 organization_role: None,
533 organization_permissions: vec![],
534 v1_organization_claims: false,
535 extra_claims: BTreeMap::new(),
536 }
537 }
538
539 /// Overrides the `sid` session id claim.
540 pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
541 self.session_id = Some(session_id.into());
542 self
543 }
544
545 /// Omits the `sid` claim, producing a token shaped like a Clerk JWT-template
546 /// token rather than a session token.
547 ///
548 /// Verification rejects these by default, so this is how to test that an
549 /// endpoint is not accepting them (or, with
550 /// [`allow_non_session_tokens`](crate::server::ClerkAuthLayerConfig::allow_non_session_tokens),
551 /// that it deliberately does).
552 pub fn without_session_id(mut self) -> Self {
553 self.session_id = None;
554 self
555 }
556
557 /// Sets the `iss` claim, to test
558 /// [`add_issuer`](crate::server::ClerkAuthLayerConfig::add_issuer) pinning.
559 pub fn with_issuer(mut self, issuer: impl Into<String>) -> Self {
560 self.issuer = Some(issuer.into());
561 self
562 }
563
564 /// Sets the `aud` claim, to test
565 /// [`add_audience`](crate::server::ClerkAuthLayerConfig::add_audience).
566 pub fn with_audience(mut self, audience: impl Into<String>) -> Self {
567 self.audience = Some(audience.into());
568 self
569 }
570
571 /// Sets the `azp` claim, to test
572 /// [`add_authorized_party`](crate::server::ClerkAuthLayerConfig::add_authorized_party).
573 pub fn with_authorized_party(mut self, authorized_party: impl Into<String>) -> Self {
574 self.authorized_party = Some(authorized_party.into());
575 self
576 }
577
578 /// Sets how long after `iat` the token expires. Defaults to one minute.
579 pub fn with_lifetime(mut self, lifetime: Duration) -> Self {
580 self.lifetime = lifetime;
581 self
582 }
583
584 /// Pins `iat` and `nbf` to a fixed Unix timestamp instead of now.
585 pub fn with_issued_at(mut self, issued_at: i64) -> Self {
586 self.issued_at = Some(issued_at);
587 self
588 }
589
590 /// Pins `nbf` to a fixed Unix timestamp, independent of `iat`.
591 ///
592 /// Set it in the future to produce a not-yet-valid token.
593 pub fn with_not_before(mut self, not_before: i64) -> Self {
594 self.not_before = Some(not_before);
595 self
596 }
597
598 /// Pins `exp` to a fixed Unix timestamp, ignoring
599 /// [`with_lifetime`](Self::with_lifetime).
600 pub fn with_expires_at(mut self, expires_at: i64) -> Self {
601 self.expires_at = Some(expires_at);
602 self
603 }
604
605 /// Makes the token already expired, for testing rejection and refresh paths.
606 ///
607 /// Backdates `iat`/`nbf` far enough that the token is expired well beyond
608 /// the configured clock skew.
609 pub fn expired(mut self) -> Self {
610 let issued_at = self.issued_at.unwrap_or_else(unix_now) - 3600;
611 self.issued_at = Some(issued_at);
612 self.expires_at = Some(issued_at + 60);
613 self
614 }
615
616 /// Puts the session in an organization.
617 ///
618 /// Emits Clerk's v2 `o` claim by default; see
619 /// [`with_v1_organization_claims`](Self::with_v1_organization_claims) for
620 /// the older flat shape.
621 pub fn with_organization(mut self, organization_id: impl Into<String>) -> Self {
622 self.organization_id = Some(organization_id.into());
623 self
624 }
625
626 /// Sets the organization slug (`o.slg`, or `org_slug` on v1).
627 pub fn with_organization_slug(mut self, slug: impl Into<String>) -> Self {
628 self.organization_slug = Some(slug.into());
629 self
630 }
631
632 /// Sets the organization role (`o.rol`, or `org_role` on v1).
633 ///
634 /// Accepts either `admin` or `org:admin`; verification normalizes both to
635 /// the `org:`-prefixed form.
636 pub fn with_organization_role(mut self, role: impl Into<String>) -> Self {
637 self.organization_role = Some(role.into());
638 self
639 }
640
641 /// Sets the organization permissions, as `org:<feature>:<permission>`
642 /// strings such as `org:dashboard:read`.
643 ///
644 /// On v2 these are encoded into Clerk's packed `fea`/`per`/`fpm` claim
645 /// trio, which is what the verifier decodes back into
646 /// [`ClerkAuth::org_permissions`](crate::core::ClerkAuth::org_permissions).
647 /// A permission not in that three-part form is an error at
648 /// [`sign`](TestIssuer::sign) time, since it could not round-trip.
649 pub fn with_organization_permissions(
650 mut self,
651 permissions: impl IntoIterator<Item = impl Into<String>>,
652 ) -> Self {
653 self.organization_permissions = permissions.into_iter().map(Into::into).collect();
654 self
655 }
656
657 /// Emits pre-v2 flat `org_id` / `org_slug` / `org_role` / `org_permissions`
658 /// claims instead of the packed `o` claim.
659 ///
660 /// Clerk issues v2 claims now; this covers the still-supported older shape,
661 /// and accepts permission strings in any form.
662 pub fn with_v1_organization_claims(mut self) -> Self {
663 self.v1_organization_claims = true;
664 self
665 }
666
667 /// Sets an arbitrary top-level claim, overriding anything above.
668 ///
669 /// The escape hatch for claims this builder does not model — and for
670 /// deliberately malformed tokens.
671 pub fn with_claim(mut self, name: impl Into<String>, value: impl Into<Value>) -> Self {
672 self.extra_claims.insert(name.into(), value.into());
673 self
674 }
675
676 /// The claim set this session serializes to.
677 ///
678 /// Exposed so a test can assert on claims directly, or hand them to another
679 /// signer.
680 pub fn to_claims(&self) -> Result<Value, TestIssuerError> {
681 let issued_at = self.issued_at.unwrap_or_else(unix_now);
682 let mut claims = json!({
683 "sub": self.user_id,
684 "iat": issued_at,
685 "nbf": self.not_before.unwrap_or(issued_at),
686 "exp": self.expires_at.unwrap_or_else(|| {
687 // Saturate rather than wrap: `with_lifetime(Duration::MAX)` is a
688 // plausible way to ask for "never expires".
689 issued_at.saturating_add(i64::try_from(self.lifetime.as_secs()).unwrap_or(i64::MAX))
690 }),
691 });
692
693 let object = claims
694 .as_object_mut()
695 .expect("claims are built as a JSON object");
696
697 for (name, value) in [
698 ("sid", self.session_id.as_ref()),
699 ("iss", self.issuer.as_ref()),
700 ("aud", self.audience.as_ref()),
701 ("azp", self.authorized_party.as_ref()),
702 ] {
703 if let Some(value) = value {
704 object.insert(name.to_string(), Value::String(value.clone()));
705 }
706 }
707
708 for (name, value) in self.organization_claims()? {
709 object.insert(name, value);
710 }
711
712 for (name, value) in &self.extra_claims {
713 object.insert(name.clone(), value.clone());
714 }
715
716 Ok(claims)
717 }
718
719 fn organization_claims(&self) -> Result<Vec<(String, Value)>, TestIssuerError> {
720 let Some(organization_id) = self.organization_id.as_ref() else {
721 return Ok(vec![]);
722 };
723
724 if self.v1_organization_claims {
725 let mut claims = vec![("org_id".to_string(), json!(organization_id))];
726 if let Some(slug) = &self.organization_slug {
727 claims.push(("org_slug".to_string(), json!(slug)));
728 }
729 if let Some(role) = &self.organization_role {
730 claims.push(("org_role".to_string(), json!(role)));
731 }
732 if !self.organization_permissions.is_empty() {
733 claims.push((
734 "org_permissions".to_string(),
735 json!(self.organization_permissions),
736 ));
737 }
738 return Ok(claims);
739 }
740
741 let mut organization = json!({ "id": organization_id });
742 let object = organization
743 .as_object_mut()
744 .expect("organization claim is built as a JSON object");
745 if let Some(slug) = &self.organization_slug {
746 object.insert("slg".to_string(), json!(slug));
747 }
748 if let Some(role) = &self.organization_role {
749 object.insert("rol".to_string(), json!(role));
750 }
751
752 let mut claims = vec![];
753 if !self.organization_permissions.is_empty() {
754 let packed = PackedPermissions::encode(&self.organization_permissions)?;
755 object.insert("per".to_string(), json!(packed.permissions));
756 object.insert("fpm".to_string(), json!(packed.feature_permission_map));
757 claims.push(("fea".to_string(), json!(packed.features)));
758 }
759 claims.push(("o".to_string(), organization));
760
761 Ok(claims)
762 }
763}
764
765/// Clerk's v2 organization permission encoding: a feature list (`fea`), a
766/// permission list (`per`), and one bitmask per feature over the permission
767/// list (`fpm`).
768struct PackedPermissions {
769 features: String,
770 permissions: String,
771 feature_permission_map: String,
772}
773
774impl PackedPermissions {
775 fn encode(permissions: &[String]) -> Result<Self, TestIssuerError> {
776 // Ordered-unique, because a bit index refers to a position in `per` and
777 // the decoder emits features in `fea` order.
778 let mut feature_names: Vec<&str> = vec![];
779 let mut permission_names: Vec<&str> = vec![];
780 let mut pairs: Vec<(usize, usize)> = vec![];
781
782 for permission in permissions {
783 let (feature, verb) = permission
784 .strip_prefix("org:")
785 .and_then(|rest| rest.split_once(':'))
786 .filter(|(feature, verb)| !feature.is_empty() && !verb.is_empty())
787 .ok_or_else(|| TestIssuerError::OrganizationPermission(permission.clone()))?;
788
789 let feature_index = index_of_or_push(&mut feature_names, feature);
790 let permission_index = index_of_or_push(&mut permission_names, verb);
791 pairs.push((feature_index, permission_index));
792 }
793
794 // The decoder rejects a `per` longer than a u128 has bits, and so does
795 // Clerk; a test session with that many distinct permission verbs is a
796 // mistake rather than a case worth encoding.
797 if permission_names.len() > u128::BITS as usize {
798 return Err(TestIssuerError::OrganizationPermission(format!(
799 "{} distinct permissions exceeds the {} the v2 claim encoding allows",
800 permission_names.len(),
801 u128::BITS
802 )));
803 }
804
805 let mut masks = vec![0u128; feature_names.len()];
806 for (feature_index, permission_index) in pairs {
807 masks[feature_index] |= 1 << permission_index;
808 }
809
810 Ok(Self {
811 features: feature_names
812 .iter()
813 .map(|feature| format!("o:{feature}"))
814 .collect::<Vec<_>>()
815 .join(","),
816 permissions: permission_names.join(","),
817 feature_permission_map: masks
818 .iter()
819 .map(u128::to_string)
820 .collect::<Vec<_>>()
821 .join(","),
822 })
823 }
824}
825
826fn index_of_or_push<'a>(names: &mut Vec<&'a str>, name: &'a str) -> usize {
827 match names.iter().position(|existing| *existing == name) {
828 Some(index) => index,
829 None => {
830 names.push(name);
831 names.len() - 1
832 }
833 }
834}
835
836fn unix_now() -> i64 {
837 SystemTime::now()
838 .duration_since(UNIX_EPOCH)
839 .map(|elapsed| i64::try_from(elapsed.as_secs()).unwrap_or(i64::MAX))
840 .unwrap_or(0)
841}