Skip to main content

stack_auth/
lib.rs

1#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")]
2#![doc = include_str!("../README.md")]
3// Security lints
4#![deny(unsafe_code)]
5#![warn(clippy::unwrap_used)]
6#![warn(clippy::expect_used)]
7#![warn(clippy::panic)]
8// Prevent mem::forget from bypassing ZeroizeOnDrop
9#![warn(clippy::mem_forget)]
10// Prevent accidental data leaks via output
11#![warn(clippy::print_stdout)]
12#![warn(clippy::print_stderr)]
13#![warn(clippy::dbg_macro)]
14// Code quality
15#![warn(unreachable_pub)]
16#![warn(unused_results)]
17#![warn(clippy::todo)]
18#![warn(clippy::unimplemented)]
19// Relax in tests
20#![cfg_attr(test, allow(clippy::unwrap_used))]
21#![cfg_attr(test, allow(clippy::expect_used))]
22#![cfg_attr(test, allow(clippy::panic))]
23#![cfg_attr(test, allow(unused_results))]
24
25use std::convert::Infallible;
26use std::future::Future;
27#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
28use std::time::Duration;
29
30use vitaminc::protected::OpaqueDebug;
31use zeroize::ZeroizeOnDrop;
32
33mod access_key;
34mod access_key_refresher;
35mod access_key_strategy;
36mod auth_strategy_fn;
37mod authorize_dto;
38mod auto_refresh;
39mod auto_strategy;
40mod clock;
41mod device_session_refresher;
42mod device_session_strategy;
43mod oidc_federation_strategy;
44mod oidc_refresher;
45mod refresher;
46mod service_token;
47mod token;
48mod token_store;
49
50// Filesystem-backed device identity and the interactive device-code flow are
51// native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`)
52// and the device-code flow launches a browser via `open::that`. Wasm consumers
53// use `DeviceSessionStrategy::with_token` or `AccessKeyStrategy`.
54#[cfg(not(target_arch = "wasm32"))]
55mod device_client;
56#[cfg(not(target_arch = "wasm32"))]
57mod device_code;
58
59#[cfg(any(test, feature = "test-utils"))]
60mod static_token_strategy;
61
62#[cfg(test)]
63mod test_support;
64
65pub use access_key::{AccessKey, InvalidAccessKey};
66pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder};
67pub use auth_strategy_fn::AuthStrategyFn;
68pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder};
69pub use device_session_strategy::{DeviceSessionStrategy, DeviceSessionStrategyBuilder};
70pub use oidc_federation_strategy::{OidcFederationStrategy, OidcFederationStrategyBuilder};
71pub use oidc_refresher::{OidcProvider, OidcProviderFn};
72pub use service_token::ServiceToken;
73#[cfg(any(test, feature = "test-utils"))]
74pub use static_token_strategy::StaticTokenStrategy;
75pub use token::Token;
76pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn};
77
78/// Deprecated alias for [`DeviceSessionStrategy`].
79///
80/// Renamed to make the *renewal* (existing CTS session) vs *federation*
81/// ([`OidcFederationStrategy`]) distinction explicit. The old name still
82/// resolves so existing code keeps compiling; it will be removed in a future
83/// major release.
84#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategy`")]
85pub type OAuthStrategy = DeviceSessionStrategy;
86
87/// Deprecated alias for [`DeviceSessionStrategyBuilder`].
88#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategyBuilder`")]
89pub type OAuthStrategyBuilder = DeviceSessionStrategyBuilder;
90
91#[cfg(not(target_arch = "wasm32"))]
92pub use device_client::{bind_client_device, DeviceClientError};
93#[cfg(not(target_arch = "wasm32"))]
94pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode};
95
96// Re-exports from stack-profile for backward compatibility.
97#[cfg(not(target_arch = "wasm32"))]
98pub use stack_profile::DeviceIdentity;
99
100/// Token *acquisition* — strategies that produce a [`ServiceToken`].
101///
102/// Use [`AuthStrategy`] as the consumer-facing trait (e.g. when wiring
103/// strategies into `cipherstash-client`). [`AuthStrategyFn`] is the
104/// closure-shaped impl for callers that source tokens externally
105/// (FFI, custom IPC).
106///
107/// For the *persistence layer* — pluggable storage that slots into an
108/// existing strategy — see [`crate::store`].
109///
110/// All items in this module are also re-exported at the crate root.
111pub mod auth {
112    pub use crate::{
113        AccessKey, AccessKeyStrategy, AccessKeyStrategyBuilder, AuthError, AuthStrategy,
114        AuthStrategyBounds, AuthStrategyFn, AutoStrategy, AutoStrategyBuilder,
115        DeviceSessionStrategy, DeviceSessionStrategyBuilder, InvalidAccessKey,
116        OidcFederationStrategy, OidcFederationStrategyBuilder, OidcProvider, OidcProviderFn,
117        SecretToken, ServiceToken,
118    };
119
120    #[cfg(not(target_arch = "wasm32"))]
121    pub use crate::{
122        bind_client_device, DeviceClientError, DeviceCodeStrategy, DeviceCodeStrategyBuilder,
123        DeviceIdentity, PendingDeviceCode,
124    };
125
126    #[cfg(any(test, feature = "test-utils"))]
127    pub use crate::StaticTokenStrategy;
128
129    // Deprecated aliases, re-exported here too so `stack_auth::auth::OAuthStrategy`
130    // consumers keep compiling alongside the crate-root aliases. See the
131    // `OAuthStrategy` / `OAuthStrategyBuilder` definitions at the crate root.
132    #[allow(deprecated)]
133    pub use crate::{OAuthStrategy, OAuthStrategyBuilder};
134}
135
136/// Token *persistence* — pluggable backends for the service-token cache.
137///
138/// Use [`TokenStore`] as the trait, [`TokenStoreFn`] for closure-shaped
139/// impls (cookies, KV blobs, Redis), and [`InMemoryTokenStore`] / [`NoStore`]
140/// for ready-made implementations.
141///
142/// A `TokenStore` plugs into a concrete strategy via that strategy's
143/// builder (e.g.
144/// [`AccessKeyStrategyBuilder::with_token_store`](crate::AccessKeyStrategyBuilder::with_token_store))
145/// — it does *not* replace the strategy. For full token acquisition (custom
146/// fetcher, FFI-hosted strategy), see [`crate::auth`].
147///
148/// All items in this module are also re-exported at the crate root.
149pub mod store {
150    pub use crate::{InMemoryTokenStore, NoStore, Token, TokenStore, TokenStoreFn};
151}
152
153/// A strategy for obtaining access tokens.
154///
155/// Implementations handle all details of authentication, token caching, and
156/// refresh. Callers just call [`get_token`](AuthStrategy::get_token) whenever
157/// they need a valid token.
158///
159/// The trait is designed to be implemented for `&T`, so that callers can use
160/// shared references (e.g. `&DeviceSessionStrategy`) without consuming the strategy.
161///
162/// # Token refresh
163///
164/// All strategies that cache tokens ([`AccessKeyStrategy`], [`DeviceSessionStrategy`],
165/// [`AutoStrategy`]) share the same internal refresh engine. Understanding the
166/// refresh model helps predict how [`get_token`](AuthStrategy::get_token)
167/// behaves under concurrent access.
168///
169/// ## Expiry vs usability
170///
171/// A token has two time thresholds:
172///
173/// - **Expired** — the token is within **90 seconds** of its `expires_at`
174///   timestamp. This triggers a preemptive refresh attempt.
175/// - **Usable** — the token has **not yet reached** its `expires_at` timestamp.
176///   A token can be "expired" (in the preemptive sense) but still "usable"
177///   (the server will still accept it).
178///
179/// ## Concurrent refresh strategies
180///
181/// The gap between "expired" and "unusable" enables two refresh modes:
182///
183/// 1. **Expiring but still usable** — The first caller triggers a background
184///    refresh. Concurrent callers receive the current (still-valid) token
185///    immediately without blocking.
186/// 2. **Fully expired** — The first caller blocks while refreshing. Concurrent
187///    callers wait until the refresh completes, then all receive the new token.
188///
189/// Only one refresh runs at a time, regardless of how many callers request a
190/// token concurrently.
191///
192/// ## Flow diagram
193///
194/// ```mermaid
195/// flowchart TD
196///     Start["get_token()"] --> Lock["Acquire lock"]
197///     Lock --> Cached{Token cached?}
198///     Cached -- No --> InitAuth["Authenticate
199///     (lock held)"]
200///     InitAuth -- OK --> ReturnNew["Return new token"]
201///     InitAuth -- NotFound --> ErrNotFound["NotAuthenticated"]
202///     InitAuth -- Err --> ErrAuth["Return error"]
203///     Cached -- Yes --> CheckRefresh{Expired?}
204///
205///     CheckRefresh -- "No (fresh)" --> ReturnOk["Return cached token"]
206///
207///     CheckRefresh -- "Yes (needs refresh)" --> InProgress{Refresh in progress?}
208///     InProgress -- Yes --> WaitOrReturn["Return token if usable,
209///     else wait for refresh"]
210///     WaitOrReturn -- OK --> ReturnOk
211///     WaitOrReturn -- "refresh failed" --> ErrExpired["TokenExpired"]
212///
213///     InProgress -- No --> HasCred{Refresh credential?}
214///     HasCred -- None --> CheckUsable["Return token if usable,
215///     else TokenExpired"]
216///
217///     HasCred -- Yes --> Usable{Still usable?}
218///
219///     Usable -- "Yes (preemptive)" --> NonBlocking["Refresh in background
220///     (lock released)"]
221///     NonBlocking --> ReturnOld["Return current token"]
222///
223///     Usable -- "No (fully expired)" --> Blocking["Refresh
224///     (lock held)"]
225///     Blocking -- OK --> ReturnNew2["Return new token"]
226///     Blocking -- Err --> ErrExpired["TokenExpired"]
227/// ```
228#[cfg_attr(doc, aquamarine::aquamarine)]
229#[cfg(not(target_arch = "wasm32"))]
230pub trait AuthStrategy: Send {
231    /// Retrieve a valid access token, refreshing or re-authenticating as needed.
232    fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send;
233}
234
235/// Wasm32 variant of [`AuthStrategy`] — drops the `Send` bounds because
236/// reqwest's fetch-backed futures aren't `Send` and edge runtimes are
237/// single-threaded.
238#[cfg(target_arch = "wasm32")]
239pub trait AuthStrategy {
240    /// Retrieve a valid access token, refreshing or re-authenticating as needed.
241    fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>>;
242}
243
244/// Marker trait alias for the bounds an owned `AuthStrategy`-providing
245/// credential type `C` must satisfy when held inside a long-lived client
246/// (e.g. `cipherstash_client::ZeroKMS<C>` shared across requests).
247///
248/// - On native targets `C` must be `Send + Sync + 'static` so the client
249///   can be carried across tokio task / `reqwest` worker boundaries.
250/// - On `wasm32` the runtime is single-threaded and the typical credential
251///   backing (a JS callable held by a `JsValue`) cannot cross threads
252///   even in principle, so the `Send + Sync` requirement is dropped and
253///   only `'static` remains.
254///
255/// Implemented via a blanket impl — any type satisfying the per-target
256/// bounds automatically implements `AuthStrategyBounds`. Callers don't
257/// implement it directly.
258///
259/// Mirrors the `cfg`-split already in place on [`AuthStrategy`] itself,
260/// one layer up. Wasm consumers (e.g. `@cipherstash/protect-ffi` on
261/// `wasm32-unknown-unknown`) can hold a `!Send + !Sync` credential type
262/// without declaring `unsafe impl Send` / `Sync`.
263#[cfg(not(target_arch = "wasm32"))]
264pub trait AuthStrategyBounds: Send + Sync + 'static {}
265#[cfg(not(target_arch = "wasm32"))]
266impl<T: Send + Sync + 'static> AuthStrategyBounds for T {}
267
268#[cfg(target_arch = "wasm32")]
269pub trait AuthStrategyBounds: 'static {}
270#[cfg(target_arch = "wasm32")]
271impl<T: 'static> AuthStrategyBounds for T {}
272
273/// A sensitive token string that is zeroized on drop and hidden from debug output.
274///
275/// `SecretToken` wraps a `String` and enforces two invariants:
276///
277/// - **Zeroized on drop**: the backing memory is overwritten with zeros when
278///   the token goes out of scope, preventing it from lingering in memory.
279/// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of
280///   the actual value, so tokens won't leak into logs or error messages.
281///
282/// Use [`SecretToken::new`] to wrap a string value (e.g. an access key
283/// loaded from configuration or an environment variable).
284#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)]
285#[serde(transparent)]
286pub struct SecretToken(String);
287
288impl SecretToken {
289    /// Create a new `SecretToken` from a string value.
290    pub fn new(value: impl Into<String>) -> Self {
291        Self(value.into())
292    }
293
294    /// Expose the inner token string for FFI boundaries.
295    pub fn as_str(&self) -> &str {
296        &self.0
297    }
298}
299
300/// Errors that can occur during an authentication flow.
301#[derive(Debug, thiserror::Error, miette::Diagnostic)]
302#[non_exhaustive]
303pub enum AuthError {
304    /// The HTTP request to the auth server failed (network error, timeout, etc.).
305    #[error("HTTP request failed: {0}")]
306    Request(#[from] reqwest::Error),
307    /// The user denied the authorization request.
308    #[error("Authorization was denied")]
309    AccessDenied,
310    /// The grant type was rejected by the server.
311    #[error("Invalid grant")]
312    InvalidGrant,
313    /// The client ID is not recognized.
314    #[error("Invalid client")]
315    InvalidClient,
316    /// A URL could not be parsed.
317    #[error("Invalid URL: {0}")]
318    InvalidUrl(#[from] url::ParseError),
319    /// The requested region is not supported.
320    #[error("Unsupported region: {0}")]
321    Region(#[from] cts_common::RegionError),
322    /// The workspace CRN could not be parsed.
323    #[error("Invalid workspace CRN: {0}")]
324    InvalidCrn(cts_common::InvalidCrn),
325    /// The token issued by the auth server is for a different workspace than
326    /// the one configured on the strategy. Surfaces when the access key was
327    /// minted for a different workspace, or when the wrong CRN was passed.
328    #[error("Workspace mismatch: token issued for {token_workspace}, but strategy is configured for {expected_workspace}")]
329    WorkspaceMismatch {
330        /// The workspace the strategy was configured for (from the CRN).
331        expected_workspace: cts_common::WorkspaceId,
332        /// The workspace the auth server's token actually carries.
333        token_workspace: cts_common::WorkspaceId,
334    },
335    /// The workspace ID could not be parsed.
336    #[error("Invalid workspace ID: {0}")]
337    InvalidWorkspaceId(#[from] cts_common::InvalidWorkspaceId),
338    /// An access key was provided but the workspace CRN is missing.
339    ///
340    /// Set the `CS_WORKSPACE_CRN` environment variable or call
341    /// [`AutoStrategyBuilder::with_workspace_crn`](crate::AutoStrategyBuilder::with_workspace_crn).
342    #[error("Workspace CRN is required when using an access key — set CS_WORKSPACE_CRN or call AutoStrategyBuilder::with_workspace_crn")]
343    MissingWorkspaceCrn,
344    /// No credentials are available (e.g. not logged in, no access key configured).
345    #[error("Not authenticated")]
346    NotAuthenticated,
347    /// A token (access token or device code) has expired.
348    #[error("Token expired")]
349    TokenExpired,
350    /// The access key string is malformed (e.g. missing `CSAK` prefix or `.` separator).
351    #[error("Invalid access key: {0}")]
352    InvalidAccessKey(#[from] access_key::InvalidAccessKey),
353    /// The JWT could not be decoded or its claims are malformed.
354    #[error("Invalid token: {0}")]
355    InvalidToken(String),
356    /// An unexpected error was returned by the auth server.
357    #[error("Server error: {0}")]
358    Server(String),
359    /// A token store operation failed.
360    #[cfg(not(target_arch = "wasm32"))]
361    #[error("Token store error: {0}")]
362    Store(#[from] stack_profile::ProfileError),
363}
364
365impl AuthError {
366    /// Stable machine-readable identifier for surfacing across FFI boundaries
367    /// (e.g. JS `Error.code`, Node-API error codes). Named `error_code` rather
368    /// than `code` to avoid colliding with `miette::Diagnostic::code`, which
369    /// is inherited via `#[derive(Diagnostic)]`.
370    pub fn error_code(&self) -> &'static str {
371        match self {
372            Self::Request(_) => "REQUEST_ERROR",
373            Self::AccessDenied => "ACCESS_DENIED",
374            Self::TokenExpired => "EXPIRED_TOKEN",
375            Self::InvalidGrant => "INVALID_GRANT",
376            Self::InvalidClient => "INVALID_CLIENT",
377            Self::InvalidUrl(_) => "INVALID_URL",
378            Self::Region(_) => "INVALID_REGION",
379            Self::InvalidToken(_) => "INVALID_TOKEN",
380            Self::Server(_) => "SERVER_ERROR",
381            Self::NotAuthenticated => "NOT_AUTHENTICATED",
382            Self::MissingWorkspaceCrn => "MISSING_WORKSPACE_CRN",
383            Self::InvalidAccessKey(_) => "INVALID_ACCESS_KEY",
384            Self::InvalidCrn(_) => "INVALID_CRN",
385            Self::WorkspaceMismatch { .. } => "WORKSPACE_MISMATCH",
386            Self::InvalidWorkspaceId(_) => "INVALID_WORKSPACE_ID",
387            #[cfg(not(target_arch = "wasm32"))]
388            Self::Store(_) => "STORE_ERROR",
389        }
390    }
391}
392
393impl From<Infallible> for AuthError {
394    fn from(never: Infallible) -> Self {
395        match never {}
396    }
397}
398
399/// Read the `CS_CTS_HOST` environment variable and parse it as a URL.
400///
401/// Returns `Ok(None)` if the variable is not set or empty.
402/// Returns `Ok(Some(url))` if the variable is set and valid.
403/// Returns `Err(_)` if the variable is set but not a valid URL.
404pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> {
405    match std::env::var("CS_CTS_HOST") {
406        Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)),
407        _ => Ok(None),
408    }
409}
410
411/// Ensure a URL has a trailing slash so that `Url::join` with relative paths
412/// appends to the path rather than replacing the last segment.
413pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url {
414    if !url.path().ends_with('/') {
415        url.set_path(&format!("{}/", url.path()));
416    }
417    url
418}
419
420/// Decode a JWT payload by splitting on `.`, base64-decoding the middle
421/// segment, and deserializing the JSON. Used on wasm32 to avoid `jsonwebtoken`
422/// (which pulls `ring`). Signatures are not verified — same posture as the
423/// native path, which calls `insecure_disable_signature_validation()`.
424#[cfg(target_arch = "wasm32")]
425pub(crate) fn decode_jwt_payload_wasm<C>(token: &str) -> Result<C, AuthError>
426where
427    C: serde::de::DeserializeOwned,
428{
429    use base64::Engine;
430    let segments: Vec<&str> = token.split('.').collect();
431    if segments.len() != 3 {
432        return Err(AuthError::InvalidToken(
433            "JWT must have three segments".to_string(),
434        ));
435    }
436    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
437        .decode(segments[1])
438        .map_err(|e| AuthError::InvalidToken(format!("base64 decode failed: {e}")))?;
439    serde_json::from_slice(&payload)
440        .map_err(|e| AuthError::InvalidToken(format!("failed to decode JWT claims: {e}")))
441}
442
443/// Create a [`reqwest::Client`] with standard timeouts.
444///
445/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)`
446/// does not auto-advance time past the connect timeout before the mock server
447/// can respond. On wasm32, reqwest's fetch backend doesn't expose
448/// `connect_timeout`/`pool_*` — the host runtime owns those concerns.
449#[cfg(any(test, feature = "test-utils"))]
450pub(crate) fn http_client() -> reqwest::Client {
451    reqwest::Client::builder()
452        .build()
453        .unwrap_or_else(|_| reqwest::Client::new())
454}
455
456#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
457pub(crate) fn http_client() -> reqwest::Client {
458    reqwest::Client::builder()
459        .connect_timeout(Duration::from_secs(10))
460        .timeout(Duration::from_secs(30))
461        .pool_idle_timeout(Duration::from_secs(5))
462        .pool_max_idle_per_host(10)
463        .build()
464        .unwrap_or_else(|_| reqwest::Client::new())
465}
466
467#[cfg(all(not(any(test, feature = "test-utils")), target_arch = "wasm32"))]
468pub(crate) fn http_client() -> reqwest::Client {
469    // Wasm32 reqwest uses the host's `fetch`; timeouts and pooling are owned
470    // by the runtime, so `ClientBuilder` doesn't expose them here.
471    reqwest::Client::builder()
472        .build()
473        .unwrap_or_else(|_| reqwest::Client::new())
474}
475
476#[cfg(test)]
477mod tests {
478    use super::*;
479
480    /// The `error_code` strings are a stable contract surfaced across FFI
481    /// (JS `Error.code`, Node-API codes), so pin every variant's code. Covers
482    /// all variants except `Request`, whose inner `reqwest::Error` has no public
483    /// constructor; if a new variant is added without a code, `error_code`'s
484    /// exhaustive match fails to compile, so the contract can't silently drift.
485    #[test]
486    #[allow(clippy::unwrap_used)]
487    fn auth_error_code_is_stable_for_every_variant() {
488        let workspace = "ZVATKW3VHMFG27DY"
489            .parse::<cts_common::WorkspaceId>()
490            .unwrap();
491
492        let cases: Vec<(AuthError, &str)> = vec![
493            (AuthError::AccessDenied, "ACCESS_DENIED"),
494            (AuthError::TokenExpired, "EXPIRED_TOKEN"),
495            (AuthError::InvalidGrant, "INVALID_GRANT"),
496            (AuthError::InvalidClient, "INVALID_CLIENT"),
497            (AuthError::NotAuthenticated, "NOT_AUTHENTICATED"),
498            (AuthError::MissingWorkspaceCrn, "MISSING_WORKSPACE_CRN"),
499            (AuthError::Server("boom".into()), "SERVER_ERROR"),
500            (AuthError::InvalidToken("malformed".into()), "INVALID_TOKEN"),
501            (
502                AuthError::InvalidUrl("not a url".parse::<url::Url>().unwrap_err()),
503                "INVALID_URL",
504            ),
505            (
506                AuthError::Region("not-a-region".parse::<cts_common::Region>().unwrap_err()),
507                "INVALID_REGION",
508            ),
509            (
510                AuthError::InvalidCrn("not-a-crn".parse::<cts_common::Crn>().unwrap_err()),
511                "INVALID_CRN",
512            ),
513            (
514                AuthError::InvalidWorkspaceId("!".parse::<cts_common::WorkspaceId>().unwrap_err()),
515                "INVALID_WORKSPACE_ID",
516            ),
517            (
518                AuthError::InvalidAccessKey(
519                    "".parse::<crate::access_key::AccessKey>().unwrap_err(),
520                ),
521                "INVALID_ACCESS_KEY",
522            ),
523            (
524                AuthError::WorkspaceMismatch {
525                    expected_workspace: workspace,
526                    token_workspace: workspace,
527                },
528                "WORKSPACE_MISMATCH",
529            ),
530            #[cfg(not(target_arch = "wasm32"))]
531            (
532                AuthError::Store(stack_profile::ProfileError::HomeDirNotFound),
533                "STORE_ERROR",
534            ),
535        ];
536
537        for (err, expected) in cases {
538            assert_eq!(err.error_code(), expected, "error_code for {err:?}");
539        }
540    }
541}