Skip to main content

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}