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::future::Future;
26#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
27use std::time::Duration;
28
29use vitaminc::protected::OpaqueDebug;
30use zeroize::ZeroizeOnDrop;
31
32mod access_key;
33mod access_key_refresher;
34mod access_key_strategy;
35mod auth_strategy_fn;
36mod authorize_dto;
37mod auto_refresh;
38mod auto_strategy;
39mod clock;
40mod device_session_refresher;
41mod device_session_strategy;
42mod error;
43mod oidc_federation_strategy;
44mod oidc_refresher;
45mod refresher;
46mod service_token;
47mod token;
48mod token_store;
49
50#[cfg(not(target_arch = "wasm32"))]
51pub use error::StoreError;
52pub use error::{
53 AccessDenied, AlreadyConsumed, AuthError, AuthErrorKind, CustomError, InternalError,
54 InvalidAccessKeyError, InvalidClient, InvalidCrn, InvalidGrant, InvalidToken, InvalidUrl,
55 InvalidWorkspaceId, MissingWorkspaceCrn, NotAuthenticated, RequestError, ServerError,
56 TokenExpired, UnsupportedRegion, WorkspaceMismatch,
57};
58
59// Filesystem-backed device identity and the interactive device-code flow are
60// native-only — both pull `stack-profile` (which uses `dirs` + `gethostname`)
61// and the device-code flow launches a browser via `open::that`. Wasm consumers
62// use `DeviceSessionStrategy::with_token` or `AccessKeyStrategy`.
63#[cfg(not(target_arch = "wasm32"))]
64mod device_client;
65#[cfg(not(target_arch = "wasm32"))]
66mod device_code;
67
68#[cfg(any(test, feature = "test-utils"))]
69mod static_token_strategy;
70
71#[cfg(test)]
72mod test_support;
73
74pub use access_key::{AccessKey, InvalidAccessKey};
75pub use access_key_strategy::{AccessKeyStrategy, AccessKeyStrategyBuilder};
76pub use auth_strategy_fn::AuthStrategyFn;
77pub use auto_strategy::{AutoStrategy, AutoStrategyBuilder};
78pub use device_session_strategy::{DeviceSessionStrategy, DeviceSessionStrategyBuilder};
79pub use oidc_federation_strategy::{OidcFederationStrategy, OidcFederationStrategyBuilder};
80pub use oidc_refresher::{OidcProvider, OidcProviderFn};
81pub use service_token::ServiceToken;
82#[cfg(any(test, feature = "test-utils"))]
83pub use static_token_strategy::StaticTokenStrategy;
84pub use token::Token;
85pub use token_store::{InMemoryTokenStore, NoStore, TokenStore, TokenStoreFn};
86
87/// Deprecated alias for [`DeviceSessionStrategy`].
88///
89/// Renamed to make the *renewal* (existing CTS session) vs *federation*
90/// ([`OidcFederationStrategy`]) distinction explicit. The old name still
91/// resolves so existing code keeps compiling; it will be removed in a future
92/// major release.
93#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategy`")]
94pub type OAuthStrategy = DeviceSessionStrategy;
95
96/// Deprecated alias for [`DeviceSessionStrategyBuilder`].
97#[deprecated(since = "0.36.0", note = "renamed to `DeviceSessionStrategyBuilder`")]
98pub type OAuthStrategyBuilder = DeviceSessionStrategyBuilder;
99
100#[cfg(not(target_arch = "wasm32"))]
101pub use device_client::{bind_client_device, DeviceClientError};
102#[cfg(not(target_arch = "wasm32"))]
103pub use device_code::{DeviceCodeStrategy, DeviceCodeStrategyBuilder, PendingDeviceCode};
104
105// Re-exports from stack-profile for backward compatibility.
106#[cfg(not(target_arch = "wasm32"))]
107pub use stack_profile::DeviceIdentity;
108
109/// Token *acquisition* — strategies that produce a [`ServiceToken`].
110///
111/// Use [`AuthStrategy`] as the consumer-facing trait (e.g. when wiring
112/// strategies into `cipherstash-client`). [`AuthStrategyFn`] is the
113/// closure-shaped impl for callers that source tokens externally
114/// (FFI, custom IPC).
115///
116/// For the *persistence layer* — pluggable storage that slots into an
117/// existing strategy — see [`crate::store`].
118///
119/// All items in this module are also re-exported at the crate root.
120pub mod auth {
121 pub use crate::{
122 AccessKey, AccessKeyStrategy, AccessKeyStrategyBuilder, AuthError, AuthStrategy,
123 AuthStrategyBounds, AuthStrategyFn, AutoStrategy, AutoStrategyBuilder,
124 DeviceSessionStrategy, DeviceSessionStrategyBuilder, InvalidAccessKey,
125 OidcFederationStrategy, OidcFederationStrategyBuilder, OidcProvider, OidcProviderFn,
126 SecretToken, ServiceToken,
127 };
128
129 #[cfg(not(target_arch = "wasm32"))]
130 pub use crate::{
131 bind_client_device, DeviceClientError, DeviceCodeStrategy, DeviceCodeStrategyBuilder,
132 DeviceIdentity, PendingDeviceCode,
133 };
134
135 #[cfg(any(test, feature = "test-utils"))]
136 pub use crate::StaticTokenStrategy;
137
138 // Deprecated aliases, re-exported here too so `stack_auth::auth::OAuthStrategy`
139 // consumers keep compiling alongside the crate-root aliases. See the
140 // `OAuthStrategy` / `OAuthStrategyBuilder` definitions at the crate root.
141 #[allow(deprecated)]
142 pub use crate::{OAuthStrategy, OAuthStrategyBuilder};
143}
144
145/// Token *persistence* — pluggable backends for the service-token cache.
146///
147/// Use [`TokenStore`] as the trait, [`TokenStoreFn`] for closure-shaped
148/// impls (cookies, KV blobs, Redis), and [`InMemoryTokenStore`] / [`NoStore`]
149/// for ready-made implementations.
150///
151/// A `TokenStore` plugs into a concrete strategy via that strategy's
152/// builder (e.g.
153/// [`AccessKeyStrategyBuilder::with_token_store`](crate::AccessKeyStrategyBuilder::with_token_store))
154/// — it does *not* replace the strategy. For full token acquisition (custom
155/// fetcher, FFI-hosted strategy), see [`crate::auth`].
156///
157/// All items in this module are also re-exported at the crate root.
158pub mod store {
159 pub use crate::{InMemoryTokenStore, NoStore, Token, TokenStore, TokenStoreFn};
160}
161
162/// A strategy for obtaining access tokens.
163///
164/// Implementations handle all details of authentication, token caching, and
165/// refresh. Callers just call [`get_token`](AuthStrategy::get_token) whenever
166/// they need a valid token.
167///
168/// The trait is designed to be implemented for `&T`, so that callers can use
169/// shared references (e.g. `&DeviceSessionStrategy`) without consuming the strategy.
170///
171/// # Token refresh
172///
173/// All strategies that cache tokens ([`AccessKeyStrategy`], [`DeviceSessionStrategy`],
174/// [`AutoStrategy`]) share the same internal refresh engine. Understanding the
175/// refresh model helps predict how [`get_token`](AuthStrategy::get_token)
176/// behaves under concurrent access.
177///
178/// ## Expiry vs usability
179///
180/// A token has two time thresholds:
181///
182/// - **Expired** — the token is within **90 seconds** of its `expires_at`
183/// timestamp. This triggers a preemptive refresh attempt.
184/// - **Usable** — the token has **not yet reached** its `expires_at` timestamp.
185/// A token can be "expired" (in the preemptive sense) but still "usable"
186/// (the server will still accept it).
187///
188/// ## Concurrent refresh strategies
189///
190/// The gap between "expired" and "unusable" enables two refresh modes:
191///
192/// 1. **Expiring but still usable** — The first caller triggers a background
193/// refresh. Concurrent callers receive the current (still-valid) token
194/// immediately without blocking.
195/// 2. **Fully expired** — The first caller blocks while refreshing. Concurrent
196/// callers wait until the refresh completes, then all receive the new token.
197///
198/// Only one refresh runs at a time, regardless of how many callers request a
199/// token concurrently.
200///
201/// ## Flow diagram
202///
203/// ```mermaid
204/// flowchart TD
205/// Start["get_token()"] --> Lock["Acquire lock"]
206/// Lock --> Cached{Token cached?}
207/// Cached -- No --> InitAuth["Authenticate
208/// (lock held)"]
209/// InitAuth -- OK --> ReturnNew["Return new token"]
210/// InitAuth -- NotFound --> ErrNotFound["NotAuthenticated"]
211/// InitAuth -- Err --> ErrAuth["Return error"]
212/// Cached -- Yes --> CheckRefresh{Expired?}
213///
214/// CheckRefresh -- "No (fresh)" --> ReturnOk["Return cached token"]
215///
216/// CheckRefresh -- "Yes (needs refresh)" --> InProgress{Refresh in progress?}
217/// InProgress -- Yes --> WaitOrReturn["Return token if usable,
218/// else wait for refresh"]
219/// WaitOrReturn -- OK --> ReturnOk
220/// WaitOrReturn -- "refresh failed" --> ErrExpired["TokenExpired"]
221///
222/// InProgress -- No --> HasCred{Refresh credential?}
223/// HasCred -- None --> CheckUsable["Return token if usable,
224/// else TokenExpired"]
225///
226/// HasCred -- Yes --> Usable{Still usable?}
227///
228/// Usable -- "Yes (preemptive)" --> NonBlocking["Refresh in background
229/// (lock released)"]
230/// NonBlocking --> ReturnOld["Return current token"]
231///
232/// Usable -- "No (fully expired)" --> Blocking["Refresh
233/// (lock held)"]
234/// Blocking -- OK --> ReturnNew2["Return new token"]
235/// Blocking -- Err --> ErrExpired["TokenExpired"]
236/// ```
237#[cfg_attr(doc, aquamarine::aquamarine)]
238#[cfg(not(target_arch = "wasm32"))]
239pub trait AuthStrategy: Send {
240 /// Retrieve a valid access token, refreshing or re-authenticating as needed.
241 fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>> + Send;
242}
243
244/// Wasm32 variant of [`AuthStrategy`] — drops the `Send` bounds because
245/// reqwest's fetch-backed futures aren't `Send` and edge runtimes are
246/// single-threaded.
247#[cfg(target_arch = "wasm32")]
248pub trait AuthStrategy {
249 /// Retrieve a valid access token, refreshing or re-authenticating as needed.
250 fn get_token(self) -> impl Future<Output = Result<ServiceToken, AuthError>>;
251}
252
253/// Marker trait alias for the bounds an owned `AuthStrategy`-providing
254/// credential type `C` must satisfy when held inside a long-lived client
255/// (e.g. `cipherstash_client::ZeroKMS<C>` shared across requests).
256///
257/// - On native targets `C` must be `Send + Sync + 'static` so the client
258/// can be carried across tokio task / `reqwest` worker boundaries.
259/// - On `wasm32` the runtime is single-threaded and the typical credential
260/// backing (a JS callable held by a `JsValue`) cannot cross threads
261/// even in principle, so the `Send + Sync` requirement is dropped and
262/// only `'static` remains.
263///
264/// Implemented via a blanket impl — any type satisfying the per-target
265/// bounds automatically implements `AuthStrategyBounds`. Callers don't
266/// implement it directly.
267///
268/// Mirrors the `cfg`-split already in place on [`AuthStrategy`] itself,
269/// one layer up. Wasm consumers (e.g. `@cipherstash/protect-ffi` on
270/// `wasm32-unknown-unknown`) can hold a `!Send + !Sync` credential type
271/// without declaring `unsafe impl Send` / `Sync`.
272#[cfg(not(target_arch = "wasm32"))]
273pub trait AuthStrategyBounds: Send + Sync + 'static {}
274#[cfg(not(target_arch = "wasm32"))]
275impl<T: Send + Sync + 'static> AuthStrategyBounds for T {}
276
277#[cfg(target_arch = "wasm32")]
278pub trait AuthStrategyBounds: 'static {}
279#[cfg(target_arch = "wasm32")]
280impl<T: 'static> AuthStrategyBounds for T {}
281
282/// A sensitive token string that is zeroized on drop and hidden from debug output.
283///
284/// `SecretToken` wraps a `String` and enforces two invariants:
285///
286/// - **Zeroized on drop**: the backing memory is overwritten with zeros when
287/// the token goes out of scope, preventing it from lingering in memory.
288/// - **Opaque debug**: the [`Debug`] implementation prints `"***"` instead of
289/// the actual value, so tokens won't leak into logs or error messages.
290///
291/// Use [`SecretToken::new`] to wrap a string value (e.g. an access key
292/// loaded from configuration or an environment variable).
293#[derive(Clone, OpaqueDebug, ZeroizeOnDrop, serde::Deserialize, serde::Serialize)]
294#[serde(transparent)]
295pub struct SecretToken(String);
296
297impl SecretToken {
298 /// Create a new `SecretToken` from a string value.
299 pub fn new(value: impl Into<String>) -> Self {
300 Self(value.into())
301 }
302
303 /// Expose the inner token string for FFI boundaries.
304 pub fn as_str(&self) -> &str {
305 &self.0
306 }
307}
308
309/// Read the `CS_CTS_HOST` environment variable and parse it as a URL.
310///
311/// Returns `Ok(None)` if the variable is not set or empty.
312/// Returns `Ok(Some(url))` if the variable is set and valid.
313/// Returns `Err(_)` if the variable is set but not a valid URL.
314pub(crate) fn cts_base_url_from_env() -> Result<Option<url::Url>, AuthError> {
315 match std::env::var("CS_CTS_HOST") {
316 Ok(val) if !val.is_empty() => Ok(Some(val.parse()?)),
317 _ => Ok(None),
318 }
319}
320
321/// Ensure a URL has a trailing slash so that `Url::join` with relative paths
322/// appends to the path rather than replacing the last segment.
323pub(crate) fn ensure_trailing_slash(mut url: url::Url) -> url::Url {
324 if !url.path().ends_with('/') {
325 url.set_path(&format!("{}/", url.path()));
326 }
327 url
328}
329
330/// Decode a JWT payload by splitting on `.`, base64-decoding the middle
331/// segment, and deserializing the JSON. Signatures are **not** verified — we
332/// only ever read claims from a token we already hold.
333///
334/// This is the single decode path on every target. It deliberately avoids
335/// `jsonwebtoken`: on wasm32 that crate pulls `ring` (which won't build), and on
336/// native, `jsonwebtoken` 10 rejects any token whose header carries a non-string
337/// field (e.g. Clerk's `srf: true`) before it even looks at the claims.
338pub(crate) fn decode_jwt_payload<C>(token: &str) -> Result<C, AuthError>
339where
340 C: serde::de::DeserializeOwned,
341{
342 use base64::Engine;
343 let segments: Vec<&str> = token.split('.').collect();
344 if segments.len() != 3 {
345 return Err(AuthError::InvalidToken(error::InvalidToken(
346 "JWT must have three segments".to_string(),
347 )));
348 }
349 let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD
350 .decode(segments[1])
351 .map_err(|e| {
352 AuthError::InvalidToken(error::InvalidToken(format!("base64 decode failed: {e}")))
353 })?;
354 serde_json::from_slice(&payload).map_err(|e| {
355 AuthError::InvalidToken(error::InvalidToken(format!(
356 "failed to decode JWT claims: {e}"
357 )))
358 })
359}
360
361/// Create a [`reqwest::Client`] with standard timeouts.
362///
363/// In test builds, timeouts are omitted so that `tokio::test(start_paused = true)`
364/// does not auto-advance time past the connect timeout before the mock server
365/// can respond. On wasm32, reqwest's fetch backend doesn't expose
366/// `connect_timeout`/`pool_*` — the host runtime owns those concerns.
367#[cfg(any(test, feature = "test-utils"))]
368pub(crate) fn http_client() -> reqwest::Client {
369 reqwest::Client::builder()
370 .build()
371 .unwrap_or_else(|_| reqwest::Client::new())
372}
373
374#[cfg(all(not(any(test, feature = "test-utils")), not(target_arch = "wasm32")))]
375pub(crate) fn http_client() -> reqwest::Client {
376 reqwest::Client::builder()
377 .connect_timeout(Duration::from_secs(10))
378 .timeout(Duration::from_secs(30))
379 .pool_idle_timeout(Duration::from_secs(5))
380 .pool_max_idle_per_host(10)
381 .build()
382 .unwrap_or_else(|_| reqwest::Client::new())
383}
384
385#[cfg(all(not(any(test, feature = "test-utils")), target_arch = "wasm32"))]
386pub(crate) fn http_client() -> reqwest::Client {
387 // Wasm32 reqwest uses the host's `fetch`; timeouts and pooling are owned
388 // by the runtime, so `ClientBuilder` doesn't expose them here.
389 reqwest::Client::builder()
390 .build()
391 .unwrap_or_else(|_| reqwest::Client::new())
392}
393
394#[cfg(test)]
395mod tests {
396 use super::*;
397
398 /// The `error_code` strings are a stable contract surfaced across FFI
399 /// (JS `Error.code`, Node-API codes), so pin every variant's code. Covers
400 /// all variants except `Request`, whose inner `reqwest::Error` has no public
401 /// constructor; if a new variant is added without a code, `error_code`'s
402 /// exhaustive `kind()` dispatch fails to compile, so the contract can't
403 /// silently drift.
404 ///
405 /// Also pins [`AuthError::ERROR_CODES`] against what `error_code` actually
406 /// returns: every constructed variant's code must be declared there, and
407 /// `ERROR_CODES` must hold exactly those codes plus `REQUEST_ERROR` (the one
408 /// variant with no public constructor). So the list can't grow stale entries
409 /// or omit a real one — which is what the binding crates' union tests trust.
410 #[test]
411 #[allow(clippy::unwrap_used)]
412 fn auth_error_code_is_stable_for_every_variant() {
413 use std::collections::BTreeSet;
414
415 let workspace = "ZVATKW3VHMFG27DY"
416 .parse::<cts_common::WorkspaceId>()
417 .unwrap();
418
419 let cases: Vec<(AuthError, &str)> = vec![
420 (
421 AuthError::AccessDenied(crate::error::AccessDenied),
422 "ACCESS_DENIED",
423 ),
424 (
425 AuthError::TokenExpired(crate::error::TokenExpired),
426 "EXPIRED_TOKEN",
427 ),
428 (
429 AuthError::InvalidGrant(crate::error::InvalidGrant),
430 "INVALID_GRANT",
431 ),
432 (
433 AuthError::InvalidClient(crate::error::InvalidClient),
434 "INVALID_CLIENT",
435 ),
436 (
437 AuthError::NotAuthenticated(crate::error::NotAuthenticated),
438 "NOT_AUTHENTICATED",
439 ),
440 (
441 AuthError::MissingWorkspaceCrn(crate::error::MissingWorkspaceCrn),
442 "MISSING_WORKSPACE_CRN",
443 ),
444 (
445 AuthError::AlreadyConsumed(crate::error::AlreadyConsumed),
446 "ALREADY_CONSUMED",
447 ),
448 (
449 AuthError::Server(crate::error::ServerError("boom".into())),
450 "SERVER_ERROR",
451 ),
452 (
453 AuthError::Internal(crate::error::InternalError("boom".into())),
454 "INTERNAL_ERROR",
455 ),
456 (
457 AuthError::InvalidToken(crate::error::InvalidToken("malformed".into())),
458 "INVALID_TOKEN",
459 ),
460 (
461 AuthError::Custom(crate::error::CustomError("boom".into())),
462 "CUSTOM",
463 ),
464 (
465 AuthError::from("not a url".parse::<url::Url>().unwrap_err()),
466 "INVALID_URL",
467 ),
468 (
469 AuthError::from("not-a-region".parse::<cts_common::Region>().unwrap_err()),
470 "INVALID_REGION",
471 ),
472 (
473 AuthError::from("not-a-crn".parse::<cts_common::Crn>().unwrap_err()),
474 "INVALID_CRN",
475 ),
476 (
477 AuthError::from("!".parse::<cts_common::WorkspaceId>().unwrap_err()),
478 "INVALID_WORKSPACE_ID",
479 ),
480 (
481 AuthError::from("".parse::<crate::access_key::AccessKey>().unwrap_err()),
482 "INVALID_ACCESS_KEY",
483 ),
484 (
485 AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch {
486 expected_workspace: workspace,
487 token_workspace: workspace,
488 }),
489 "WORKSPACE_MISMATCH",
490 ),
491 #[cfg(not(target_arch = "wasm32"))]
492 (
493 AuthError::from(stack_profile::ProfileError::HomeDirNotFound),
494 "STORE_ERROR",
495 ),
496 ];
497
498 let declared: BTreeSet<&str> = AuthError::ERROR_CODES.iter().copied().collect();
499
500 let mut from_variants: BTreeSet<&str> = BTreeSet::new();
501 for (err, expected) in cases {
502 assert_eq!(err.error_code(), expected, "error_code for {err:?}");
503 assert!(
504 declared.contains(expected),
505 "{expected} is returned by error_code() but missing from AuthError::ERROR_CODES",
506 );
507 from_variants.insert(expected);
508 }
509
510 // `Request` has no public constructor, so it can't appear above; add its
511 // code explicitly so the set-equality below stays exact.
512 from_variants.insert("REQUEST_ERROR");
513
514 assert_eq!(
515 declared, from_variants,
516 "AuthError::ERROR_CODES drifted from the codes error_code() returns",
517 );
518 }
519
520 /// `from_error_code` reconstructs the fixed-message unit variants and
521 /// `WORKSPACE_MISMATCH` (from its payload) to their own code, and everything
522 /// else — message-carrying, foreign-wrapping, or unrecognised codes — to
523 /// `Custom`, preserving the message verbatim.
524 #[test]
525 fn from_error_code_maps_known_codes_and_falls_back_to_custom() {
526 use crate::AuthErrorKind;
527
528 let empty = serde_json::Map::new();
529
530 for code in [
531 "NOT_AUTHENTICATED",
532 "EXPIRED_TOKEN",
533 "ACCESS_DENIED",
534 "INVALID_GRANT",
535 "INVALID_CLIENT",
536 "MISSING_WORKSPACE_CRN",
537 "ALREADY_CONSUMED",
538 ] {
539 let err = AuthError::from_error_code(code, "unused for unit variants", &empty);
540 assert_eq!(err.error_code(), code, "unit code should round-trip");
541 assert!(
542 !matches!(err, AuthError::Custom(_)),
543 "{code} should map to its typed variant, not Custom",
544 );
545 }
546
547 // WORKSPACE_MISMATCH rebuilds from the exact `payload()` it serialized
548 // with — round-tripping the code (message is re-derived from the fields).
549 let workspace = "ZVATKW3VHMFG27DY"
550 .parse::<cts_common::WorkspaceId>()
551 .unwrap();
552 let payload = crate::error::WorkspaceMismatch {
553 expected_workspace: workspace,
554 token_workspace: workspace,
555 }
556 .payload();
557 let err = AuthError::from_error_code("WORKSPACE_MISMATCH", "unused", &payload);
558 assert_eq!(err.error_code(), "WORKSPACE_MISMATCH");
559 assert!(!matches!(err, AuthError::Custom(_)));
560
561 // A message-carrying variant, a foreign-wrapping one, WORKSPACE_MISMATCH
562 // with no usable payload, and an unrecognised code all collapse to Custom
563 // with the message kept as-is (no double-applied `Display` prefix).
564 for code in [
565 "SERVER_ERROR",
566 "REQUEST_ERROR",
567 "WORKSPACE_MISMATCH",
568 "SOME_UNRECOGNISED_CODE",
569 ] {
570 let err = AuthError::from_error_code(code, "Server error: boom", &empty);
571 assert_eq!(err.error_code(), "CUSTOM", "{code} should map to Custom");
572 assert_eq!(
573 err.to_string(),
574 "Server error: boom",
575 "Custom preserves the wire message verbatim",
576 );
577 }
578 }
579
580 /// Every variant annotated with `#[diagnostic(help(..))]` must surface that
581 /// help through `miette::Diagnostic` — it's what the CLI renders below the
582 /// error message. Unlike `error_code`'s exhaustive match, `help` is optional
583 /// and silently compiles if dropped, so pin all six (and a couple of
584 /// un-annotated variants that must stay `None`) explicitly.
585 #[test]
586 fn annotated_variants_expose_diagnostic_help() {
587 use miette::Diagnostic;
588
589 let workspace = "ZVATKW3VHMFG27DY"
590 .parse::<cts_common::WorkspaceId>()
591 .unwrap();
592
593 // (variant, substring its help must contain) — one row per annotation.
594 let with_help: Vec<(AuthError, &str)> = vec![
595 (
596 AuthError::from("not-a-region".parse::<cts_common::Region>().unwrap_err()),
597 "supported region",
598 ),
599 (
600 AuthError::from("not-a-crn".parse::<cts_common::Crn>().unwrap_err()),
601 "crn:<region>:<workspace-id>",
602 ),
603 (
604 AuthError::WorkspaceMismatch(crate::error::WorkspaceMismatch {
605 expected_workspace: workspace,
606 token_workspace: workspace,
607 }),
608 "different workspace",
609 ),
610 (
611 AuthError::MissingWorkspaceCrn(crate::error::MissingWorkspaceCrn),
612 "CS_WORKSPACE_CRN",
613 ),
614 (
615 AuthError::NotAuthenticated(crate::error::NotAuthenticated),
616 "stash login",
617 ),
618 (
619 AuthError::from("".parse::<crate::access_key::AccessKey>().unwrap_err()),
620 "CSAK<key-id>.<secret>",
621 ),
622 ];
623
624 for (err, substring) in with_help {
625 let help = err.help().map(|h| h.to_string());
626 assert!(
627 help.as_deref().is_some_and(|h| h.contains(substring)),
628 "{err:?} should carry help containing {substring:?}, got: {help:?}",
629 );
630 }
631
632 // Un-annotated variants must report no help — keeps the contract
633 // symmetric so a stray annotation doesn't slip in unnoticed.
634 for err in [
635 AuthError::TokenExpired(crate::error::TokenExpired),
636 AuthError::InvalidToken(crate::error::InvalidToken("malformed".to_string())),
637 ] {
638 assert!(
639 err.help().is_none(),
640 "{err:?} has no #[diagnostic(help)] and should report None",
641 );
642 }
643 }
644}