stack-auth 0.39.0

Authentication library for CipherStash services
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
#![doc(html_favicon_url = "https://cipherstash.com/favicon.ico")]
#![doc = include_str!("../README.md")]
// Security lints
#![deny(unsafe_code)]
#![warn(clippy::unwrap_used)]
#![warn(clippy::expect_used)]
#![warn(clippy::panic)]
// Prevent mem::forget from bypassing ZeroizeOnDrop
#![warn(clippy::mem_forget)]
// Prevent accidental data leaks via output
#![warn(clippy::print_stdout)]
#![warn(clippy::print_stderr)]
#![warn(clippy::dbg_macro)]
// Code quality
#![warn(unreachable_pub)]
#![warn(unused_results)]
#![warn(clippy::todo)]
#![warn(clippy::unimplemented)]
// Relax in tests
#![cfg_attr(test, allow(clippy::unwrap_used))]
#![cfg_attr(test, allow(clippy::expect_used))]
#![cfg_attr(test, allow(clippy::panic))]
#![cfg_attr(test, allow(unused_results))]

use std::future::Future;
#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
use std::time::Duration;

use vitaminc::protected::OpaqueDebug;
use zeroize::ZeroizeOnDrop;

mod access_key;
mod access_key_refresher;
mod access_key_strategy;
mod auth_strategy_fn;
mod authorize_dto;
mod auto_refresh;
mod auto_strategy;
mod clock;
mod device_session_refresher;
mod device_session_strategy;
mod error;
mod oidc_federation_strategy;
mod oidc_refresher;
mod refresher;
mod service_token;
mod token;
mod token_store;

#[cfg(not(target_arch = "wasm32"))]
pub use error::StoreError;
pub use error::{
    AccessDenied, AlreadyConsumed, AuthError, AuthErrorKind, InternalError, InvalidAccessKeyError,
    InvalidClient, InvalidCrn, InvalidGrant, InvalidToken, InvalidUrl, InvalidWorkspaceId,
    MissingWorkspaceCrn, NotAuthenticated, RequestError, ServerError, TokenExpired,
    UnsupportedRegion, WorkspaceMismatch,
};

// Filesystem-backed device identity and the interactive device-code flow are
// native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`)
// and the device-code flow launches a browser via `open::that`. Wasm consumers
// use `DeviceSessionStrategy::with_token` or `AccessKeyStrategy`.
#[cfg(not(target_arch = "wasm32"))]
mod device_client;
#[cfg(not(target_arch = "wasm32"))]
mod device_code;

#[cfg(any(test, feature = "test-utils"))]
mod static_token_strategy;

#[cfg(test)]
mod test_support;

pub use access_key::{AccessKey, InvalidAccessKey};
pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder};
pub use auth_strategy_fn::AuthStrategyFn;
pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder};
pub use device_session_strategy::{DeviceSessionStrategy, DeviceSessionStrategyBuilder};
pub use oidc_federation_strategy::{OidcFederationStrategy, OidcFederationStrategyBuilder};
pub use oidc_refresher::{OidcProvider, OidcProviderFn};
pub use service_token::ServiceToken;
#[cfg(any(test, feature = "test-utils"))]
pub use static_token_strategy::StaticTokenStrategy;
pub use token::Token;
pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn};

/// Deprecated alias for [`DeviceSessionStrategy`].
///
/// Renamed to make the *renewal* (existing CTS session) vs *federation*
/// ([`OidcFederationStrategy`]) distinction explicit. The old name still
/// resolves so existing code keeps compiling; it will be removed in a future
/// major release.
#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategy`")]
pub type OAuthStrategy = DeviceSessionStrategy;

/// Deprecated alias for [`DeviceSessionStrategyBuilder`].
#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategyBuilder`")]
pub type OAuthStrategyBuilder = DeviceSessionStrategyBuilder;

#[cfg(not(target_arch = "wasm32"))]
pub use device_client::{bind_client_device, DeviceClientError};
#[cfg(not(target_arch = "wasm32"))]
pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode};

// Re-exports from stack-profile for backward compatibility.
#[cfg(not(target_arch = "wasm32"))]
pub use stack_profile::DeviceIdentity;

/// Token *acquisition* — strategies that produce a [`ServiceToken`].
///
/// Use [`AuthStrategy`] as the consumer-facing trait (e.g. when wiring
/// strategies into `cipherstash-client`). [`AuthStrategyFn`] is the
/// closure-shaped impl for callers that source tokens externally
/// (FFI, custom IPC).
///
/// For the *persistence layer* — pluggable storage that slots into an
/// existing strategy — see [`crate::store`].
///
/// All items in this module are also re-exported at the crate root.
pub mod auth {
    pub use crate::{
        AccessKey, AccessKeyStrategy, AccessKeyStrategyBuilder, AuthError, AuthStrategy,
        AuthStrategyBounds, AuthStrategyFn, AutoStrategy, AutoStrategyBuilder,
        DeviceSessionStrategy, DeviceSessionStrategyBuilder, InvalidAccessKey,
        OidcFederationStrategy, OidcFederationStrategyBuilder, OidcProvider, OidcProviderFn,
        SecretToken, ServiceToken,
    };

    #[cfg(not(target_arch = "wasm32"))]
    pub use crate::{
        bind_client_device, DeviceClientError, DeviceCodeStrategy, DeviceCodeStrategyBuilder,
        DeviceIdentity, PendingDeviceCode,
    };

    #[cfg(any(test, feature = "test-utils"))]
    pub use crate::StaticTokenStrategy;

    // Deprecated aliases, re-exported here too so `stack_auth::auth::OAuthStrategy`
    // consumers keep compiling alongside the crate-root aliases. See the
    // `OAuthStrategy` / `OAuthStrategyBuilder` definitions at the crate root.
    #[allow(deprecated)]
    pub use crate::{OAuthStrategy, OAuthStrategyBuilder};
}

/// Token *persistence* — pluggable backends for the service-token cache.
///
/// Use [`TokenStore`] as the trait, [`TokenStoreFn`] for closure-shaped
/// impls (cookies, KV blobs, Redis), and [`InMemoryTokenStore`] / [`NoStore`]
/// for ready-made implementations.
///
/// A `TokenStore` plugs into a concrete strategy via that strategy's
/// builder (e.g.
/// [`AccessKeyStrategyBuilder::with_token_store`](crate::AccessKeyStrategyBuilder::with_token_store))
/// — it does *not* replace the strategy. For full token acquisition (custom
/// fetcher, FFI-hosted strategy), see [`crate::auth`].
///
/// All items in this module are also re-exported at the crate root.
pub mod store {
    pub use crate::{InMemoryTokenStore, NoStore, Token, TokenStore, TokenStoreFn};
}

/// A strategy for obtaining access tokens.
///
/// Implementations handle all details of authentication, token caching, and
/// refresh. Callers just call [`get_token`](AuthStrategy::get_token) whenever
/// they need a valid token.
///
/// The trait is designed to be implemented for `&T`, so that callers can use
/// shared references (e.g. `&DeviceSessionStrategy`) without consuming the strategy.
///
/// # Token refresh
///
/// All strategies that cache tokens ([`AccessKeyStrategy`], [`DeviceSessionStrategy`],
/// [`AutoStrategy`]) share the same internal refresh engine. Understanding the
/// refresh model helps predict how [`get_token`](AuthStrategy::get_token)
/// behaves under concurrent access.
///
/// ## Expiry vs usability
///
/// A token has two time thresholds:
///
/// - **Expired** — the token is within **90 seconds** of its `expires_at`
///   timestamp. This triggers a preemptive refresh attempt.
/// - **Usable** — the token has **not yet reached** its `expires_at` timestamp.
///   A token can be "expired" (in the preemptive sense) but still "usable"
///   (the server will still accept it).
///
/// ## Concurrent refresh strategies
///
/// The gap between "expired" and "unusable" enables two refresh modes:
///
/// 1. **Expiring but still usable** — The first caller triggers a background
///    refresh. Concurrent callers receive the current (still-valid) token
///    immediately without blocking.
/// 2. **Fully expired** — The first caller blocks while refreshing. Concurrent
///    callers wait until the refresh completes, then all receive the new token.
///
/// Only one refresh runs at a time, regardless of how many callers request a
/// token concurrently.
///
/// ## Flow diagram
///
/// ```mermaid
/// flowchart TD
///     Start["get_token()"] --> Lock["Acquire lock"]
///     Lock --> Cached{Token cached?}
///     Cached -- No --> InitAuth["Authenticate
///     (lock held)"]
///     InitAuth -- OK --> ReturnNew["Return new token"]
///     InitAuth -- NotFound --> ErrNotFound["NotAuthenticated"]
///     InitAuth -- Err --> ErrAuth["Return error"]
///     Cached -- Yes --> CheckRefresh{Expired?}
///
///     CheckRefresh -- "No (fresh)" --> ReturnOk["Return cached token"]
///
///     CheckRefresh -- "Yes (needs refresh)" --> InProgress{Refresh in progress?}
///     InProgress -- Yes --> WaitOrReturn["Return token if usable,
///     else wait for refresh"]
///     WaitOrReturn -- OK --> ReturnOk
///     WaitOrReturn -- "refresh failed" --> ErrExpired["TokenExpired"]
///
///     InProgress -- No --> HasCred{Refresh credential?}
///     HasCred -- None --> CheckUsable["Return token if usable,
///     else TokenExpired"]
///
///     HasCred -- Yes --> Usable{Still usable?}
///
///     Usable -- "Yes (preemptive)" --> NonBlocking["Refresh in background
///     (lock released)"]
///     NonBlocking --> ReturnOld["Return current token"]
///
///     Usable -- "No (fully expired)" --> Blocking["Refresh
///     (lock held)"]
///     Blocking -- OK --> ReturnNew2["Return new token"]
///     Blocking -- Err --> ErrExpired["TokenExpired"]
/// ```
#[cfg_attr(doc, aquamarine::aquamarine)]
#[cfg(not(target_arch = "wasm32"))]
pub trait AuthStrategy: Send {
    /// Retrieve a valid access token, refreshing or re-authenticating as needed.
    fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send;
}

/// Wasm32 variant of [`AuthStrategy`] — drops the `Send` bounds because
/// reqwest's fetch-backed futures aren't `Send` and edge runtimes are
/// single-threaded.
#[cfg(target_arch = "wasm32")]
pub trait AuthStrategy {
    /// Retrieve a valid access token, refreshing or re-authenticating as needed.
    fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>>;
}

/// Marker trait alias for the bounds an owned `AuthStrategy`-providing
/// credential type `C` must satisfy when held inside a long-lived client
/// (e.g. `cipherstash_client::ZeroKMS<C>` shared across requests).
///
/// - On native targets `C` must be `Send + Sync + 'static` so the client
///   can be carried across tokio task / `reqwest` worker boundaries.
/// - On `wasm32` the runtime is single-threaded and the typical credential
///   backing (a JS callable held by a `JsValue`) cannot cross threads
///   even in principle, so the `Send + Sync` requirement is dropped and
///   only `'static` remains.
///
/// Implemented via a blanket impl — any type satisfying the per-target
/// bounds automatically implements `AuthStrategyBounds`. Callers don't
/// implement it directly.
///
/// Mirrors the `cfg`-split already in place on [`AuthStrategy`] itself,
/// one layer up. Wasm consumers (e.g. `@cipherstash/protect-ffi` on
/// `wasm32-unknown-unknown`) can hold a `!Send + !Sync` credential type
/// without declaring `unsafe impl Send` / `Sync`.
#[cfg(not(target_arch = "wasm32"))]
pub trait AuthStrategyBounds: Send + Sync + 'static {}
#[cfg(not(target_arch = "wasm32"))]
impl<T: Send + Sync + 'static> AuthStrategyBounds for T {}

#[cfg(target_arch = "wasm32")]
pub trait AuthStrategyBounds: 'static {}
#[cfg(target_arch = "wasm32")]
impl<T: 'static> AuthStrategyBounds for T {}

/// A sensitive token string that is zeroized on drop and hidden from debug output.
///
/// `SecretToken` wraps a `String` and enforces two invariants:
///
/// - **Zeroized on drop**: the backing memory is overwritten with zeros when
///   the token goes out of scope, preventing it from lingering in memory.
/// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of
///   the actual value, so tokens won't leak into logs or error messages.
///
/// Use [`SecretToken::new`] to wrap a string value (e.g. an access key
/// loaded from configuration or an environment variable).
#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)]
#[serde(transparent)]
pub struct SecretToken(String);

impl SecretToken {
    /// Create a new `SecretToken` from a string value.
    pub fn new(value: impl Into<String>) -> Self {
        Self(value.into())
    }

    /// Expose the inner token string for FFI boundaries.
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

/// Read the `CS_CTS_HOST` environment variable and parse it as a URL.
///
/// Returns `Ok(None)` if the variable is not set or empty.
/// Returns `Ok(Some(url))` if the variable is set and valid.
/// Returns `Err(_)` if the variable is set but not a valid URL.
pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> {
    match std::env::var("CS_CTS_HOST") {
        Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)),
        _ => Ok(None),
    }
}

/// Ensure a URL has a trailing slash so that `Url::join` with relative paths
/// appends to the path rather than replacing the last segment.
pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url {
    if !url.path().ends_with('/') {
        url.set_path(&format!("{}/", url.path()));
    }
    url
}

/// Decode a JWT payload by splitting on `.`, base64-decoding the middle
/// segment, and deserializing the JSON. Used on wasm32 to avoid `jsonwebtoken`
/// (which pulls `ring`). Signatures are not verified — same posture as the
/// native path, which calls `insecure_disable_signature_validation()`.
#[cfg(target_arch = "wasm32")]
pub(crate) fn decode_jwt_payload_wasm<C>(token: &str) -> Result<C, AuthError>
where
    C: serde::de::DeserializeOwned,
{
    use base64::Engine;
    let segments: Vec<&str> = token.split('.').collect();
    if segments.len() != 3 {
        return Err(AuthError::InvalidToken(error::InvalidToken(
            "JWT must have three segments".to_string(),
        )));
    }
    let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
        .decode(segments[1])
        .map_err(|e| {
            AuthError::InvalidToken(error::InvalidToken(format!("base64 decode failed: {e}")))
        })?;
    serde_json::from_slice(&payload).map_err(|e| {
        AuthError::InvalidToken(error::InvalidToken(format!(
            "failed to decode JWT claims: {e}"
        )))
    })
}

/// Create a [`reqwest::Client`] with standard timeouts.
///
/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)`
/// does not auto-advance time past the connect timeout before the mock server
/// can respond. On wasm32, reqwest's fetch backend doesn't expose
/// `connect_timeout`/`pool_*` — the host runtime owns those concerns.
#[cfg(any(test, feature = "test-utils"))]
pub(crate) fn http_client() -> reqwest::Client {
    reqwest::Client::builder()
        .build()
        .unwrap_or_else(|_| reqwest::Client::new())
}

#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
pub(crate) fn http_client() -> reqwest::Client {
    reqwest::Client::builder()
        .connect_timeout(Duration::from_secs(10))
        .timeout(Duration::from_secs(30))
        .pool_idle_timeout(Duration::from_secs(5))
        .pool_max_idle_per_host(10)
        .build()
        .unwrap_or_else(|_| reqwest::Client::new())
}

#[cfg(all(not(any(test, feature = "test-utils")), target_arch = "wasm32"))]
pub(crate) fn http_client() -> reqwest::Client {
    // Wasm32 reqwest uses the host's `fetch`; timeouts and pooling are owned
    // by the runtime, so `ClientBuilder` doesn't expose them here.
    reqwest::Client::builder()
        .build()
        .unwrap_or_else(|_| reqwest::Client::new())
}

#[cfg(test)]
mod tests {
    use super::*;

    /// The `error_code` strings are a stable contract surfaced across FFI
    /// (JS `Error.code`, Node-API codes), so pin every variant's code. Covers
    /// all variants except `Request`, whose inner `reqwest::Error` has no public
    /// constructor; if a new variant is added without a code, `error_code`'s
    /// exhaustive `kind()` dispatch fails to compile, so the contract can't
    /// silently drift.
    ///
    /// Also pins [`AuthError::ERROR_CODES`] against what `error_code` actually
    /// returns: every constructed variant's code must be declared there, and
    /// `ERROR_CODES` must hold exactly those codes plus `REQUEST_ERROR` (the one
    /// variant with no public constructor). So the list can't grow stale entries
    /// or omit a real one — which is what the binding crates' union tests trust.
    #[test]
    #[allow(clippy::unwrap_used)]
    fn auth_error_code_is_stable_for_every_variant() {
        use std::collections::BTreeSet;

        let workspace = "ZVATKW3VHMFG27DY"
            .parse::<cts_common::WorkspaceId>()
            .unwrap();

        let cases: Vec<(AuthError, &str)> = vec![
            (
                AuthError::AccessDenied(crate::error::AccessDenied),
                "ACCESS_DENIED",
            ),
            (
                AuthError::TokenExpired(crate::error::TokenExpired),
                "EXPIRED_TOKEN",
            ),
            (
                AuthError::InvalidGrant(crate::error::InvalidGrant),
                "INVALID_GRANT",
            ),
            (
                AuthError::InvalidClient(crate::error::InvalidClient),
                "INVALID_CLIENT",
            ),
            (
                AuthError::NotAuthenticated(crate::error::NotAuthenticated),
                "NOT_AUTHENTICATED",
            ),
            (
                AuthError::MissingWorkspaceCrn(crate::error::MissingWorkspaceCrn),
                "MISSING_WORKSPACE_CRN",
            ),
            (
                AuthError::AlreadyConsumed(crate::error::AlreadyConsumed),
                "ALREADY_CONSUMED",
            ),
            (
                AuthError::Server(crate::error::ServerError("boom".into())),
                "SERVER_ERROR",
            ),
            (
                AuthError::Internal(crate::error::InternalError("boom".into())),
                "INTERNAL_ERROR",
            ),
            (
                AuthError::InvalidToken(crate::error::InvalidToken("malformed".into())),
                "INVALID_TOKEN",
            ),
            (
                AuthError::from("not a url".parse::<url::Url>().unwrap_err()),
                "INVALID_URL",
            ),
            (
                AuthError::from("not-a-region".parse::<cts_common::Region>().unwrap_err()),
                "INVALID_REGION",
            ),
            (
                AuthError::from("not-a-crn".parse::<cts_common::Crn>().unwrap_err()),
                "INVALID_CRN",
            ),
            (
                AuthError::from("!".parse::<cts_common::WorkspaceId>().unwrap_err()),
                "INVALID_WORKSPACE_ID",
            ),
            (
                AuthError::from("".parse::<crate::access_key::AccessKey>().unwrap_err()),
                "INVALID_ACCESS_KEY",
            ),
            (
                AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch {
                    expected_workspace: workspace,
                    token_workspace: workspace,
                }),
                "WORKSPACE_MISMATCH",
            ),
            #[cfg(not(target_arch = "wasm32"))]
            (
                AuthError::from(stack_profile::ProfileError::HomeDirNotFound),
                "STORE_ERROR",
            ),
        ];

        let declared: BTreeSet<&str> = AuthError::ERROR_CODES.iter().copied().collect();

        let mut from_variants: BTreeSet<&str> = BTreeSet::new();
        for (err, expected) in cases {
            assert_eq!(err.error_code(), expected, "error_code for {err:?}");
            assert!(
                declared.contains(expected),
                "{expected} is returned by error_code() but missing from AuthError::ERROR_CODES",
            );
            from_variants.insert(expected);
        }

        // `Request` has no public constructor, so it can't appear above; add its
        // code explicitly so the set-equality below stays exact.
        from_variants.insert("REQUEST_ERROR");

        assert_eq!(
            declared, from_variants,
            "AuthError::ERROR_CODES drifted from the codes error_code() returns",
        );
    }

    /// Every variant annotated with `#[diagnostic(help(..))]` must surface that
    /// help through `miette::Diagnostic` — it's what the CLI renders below the
    /// error message. Unlike `error_code`'s exhaustive match, `help` is optional
    /// and silently compiles if dropped, so pin all six (and a couple of
    /// un-annotated variants that must stay `None`) explicitly.
    #[test]
    fn annotated_variants_expose_diagnostic_help() {
        use miette::Diagnostic;

        let workspace = "ZVATKW3VHMFG27DY"
            .parse::<cts_common::WorkspaceId>()
            .unwrap();

        // (variant, substring its help must contain) — one row per annotation.
        let with_help: Vec<(AuthError, &str)> = vec![
            (
                AuthError::from("not-a-region".parse::<cts_common::Region>().unwrap_err()),
                "supported region",
            ),
            (
                AuthError::from("not-a-crn".parse::<cts_common::Crn>().unwrap_err()),
                "crn:<region>:<workspace-id>",
            ),
            (
                AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch {
                    expected_workspace: workspace,
                    token_workspace: workspace,
                }),
                "different workspace",
            ),
            (
                AuthError::MissingWorkspaceCrn(crate::error::MissingWorkspaceCrn),
                "CS_WORKSPACE_CRN",
            ),
            (
                AuthError::NotAuthenticated(crate::error::NotAuthenticated),
                "stash login",
            ),
            (
                AuthError::from("".parse::<crate::access_key::AccessKey>().unwrap_err()),
                "CSAK<key-id>.<secret>",
            ),
        ];

        for (err, substring) in with_help {
            let help = err.help().map(|h| h.to_string());
            assert!(
                help.as_deref().is_some_and(|h| h.contains(substring)),
                "{err:?} should carry help containing {substring:?}, got: {help:?}",
            );
        }

        // Un-annotated variants must report no help — keeps the contract
        // symmetric so a stray annotation doesn't slip in unnoticed.
        for err in [
            AuthError::TokenExpired(crate::error::TokenExpired),
            AuthError::InvalidToken(crate::error::InvalidToken("malformed".to_string())),
        ] {
            assert!(
                err.help().is_none(),
                "{err:?} has no #[diagnostic(help)] and should report None",
            );
        }
    }
}