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}