Skip to main content

wavekat_platform_client/
token.rs

1//! Newtype wrapper around the platform bearer token.
2//!
3//! Why a newtype rather than `String`?
4//!
5//! - **Redacted `Debug`.** `format!("{t:?}")` won't leak the secret into
6//!   logs or panic messages. The CLI bit us once where a `dbg!(cfg)`
7//!   spilled the full token into a stderr line that ended up in a
8//!   support transcript.
9//! - **No `Display`.** Callers must explicitly opt in via [`Token::as_str`]
10//!   to get the raw string, which makes accidental `format!("{t}")` (or
11//!   `println!("{t}")`) a compile error.
12//! - **Storage-agnostic.** Construction from any `Into<String>` so
13//!   consumers can hand in whatever shape their storage layer returns.
14
15use std::fmt;
16
17/// Bearer token used to authenticate against the platform.
18///
19/// Tokens are minted by completing the loopback OAuth handshake (see
20/// [`crate::oauth::loopback_handshake`]) or accepted out-of-band by the
21/// caller (e.g. read from an environment variable in CI).
22#[derive(Clone)]
23pub struct Token(String);
24
25impl Token {
26    /// Wrap a raw token string.
27    pub fn new(raw: impl Into<String>) -> Self {
28        Self(raw.into())
29    }
30
31    /// The raw token. Hand to HTTP code that needs to set the bearer
32    /// header. Avoid logging this — that's the whole reason `Display`
33    /// isn't implemented.
34    pub fn as_str(&self) -> &str {
35        &self.0
36    }
37}
38
39impl fmt::Debug for Token {
40    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
41        // Show the prefix up to (and including) the underscore — which
42        // is conventional for these tokens (`wk_…`) and useful for
43        // quickly distinguishing "I have a token" from "I don't" in
44        // logs without exposing any of the secret bytes.
45        let prefix = match self.0.split_once('_') {
46            Some((p, _)) => p,
47            None => "",
48        };
49        if prefix.is_empty() {
50            write!(f, "Token(***redacted)")
51        } else {
52            write!(f, "Token({prefix}_***redacted)")
53        }
54    }
55}
56
57#[cfg(test)]
58mod tests {
59    use super::*;
60
61    #[test]
62    fn debug_redacts_secret() {
63        let secret = "wk_abcdef1234567890";
64        let t = Token::new(secret);
65        let dbg = format!("{t:?}");
66        assert!(dbg.contains("***"), "{dbg} should contain ***");
67        assert!(!dbg.contains(secret), "{dbg} leaked the secret");
68        assert!(dbg.contains("wk_"), "{dbg} should keep the prefix");
69    }
70
71    #[test]
72    fn debug_handles_no_prefix() {
73        let t = Token::new("rawvalue");
74        let dbg = format!("{t:?}");
75        assert!(!dbg.contains("rawvalue"));
76        assert!(dbg.contains("***"));
77    }
78
79    #[test]
80    fn as_str_returns_raw() {
81        let t = Token::new("wk_xyz");
82        assert_eq!(t.as_str(), "wk_xyz");
83    }
84}