1use std::fmt;
2
3use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, COOKIE};
4
5use crate::error::KagiError;
6
7#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
8pub enum CredentialKind {
9 BotToken,
10 SessionToken,
11}
12
13impl fmt::Display for CredentialKind {
14 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
15 match self {
16 Self::BotToken => formatter.write_str("BotToken"),
17 Self::SessionToken => formatter.write_str("SessionToken"),
18 }
19 }
20}
21
22#[derive(Clone, PartialEq, Eq, Hash)]
23pub struct BotToken(String);
24
25impl BotToken {
26 pub fn new(value: impl Into<String>) -> Result<Self, KagiError> {
27 let token = parse_token(value.into(), CredentialKind::BotToken)?;
28 Ok(Self(token))
29 }
30
31 pub(crate) fn as_secret(&self) -> &str {
32 &self.0
33 }
34}
35
36impl fmt::Debug for BotToken {
37 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
38 formatter.write_str("BotToken(REDACTED)")
39 }
40}
41
42#[derive(Clone, PartialEq, Eq, Hash)]
43pub struct SessionToken(String);
44
45impl SessionToken {
46 pub fn new(value: impl Into<String>) -> Result<Self, KagiError> {
47 let token = parse_token(value.into(), CredentialKind::SessionToken)?;
48 Ok(Self(token))
49 }
50
51 pub(crate) fn as_secret(&self) -> &str {
52 &self.0
53 }
54}
55
56impl fmt::Debug for SessionToken {
57 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
58 formatter.write_str("SessionToken(REDACTED)")
59 }
60}
61
62#[derive(Clone, Debug, PartialEq, Eq, Hash)]
63pub enum Credentials {
64 BotToken(BotToken),
65 SessionToken(SessionToken),
66}
67
68impl Credentials {
69 pub fn kind(&self) -> CredentialKind {
70 match self {
71 Self::BotToken(_) => CredentialKind::BotToken,
72 Self::SessionToken(_) => CredentialKind::SessionToken,
73 }
74 }
75
76 pub(crate) fn apply_to_headers(&self, headers: &mut HeaderMap) -> Result<(), KagiError> {
77 match self {
78 Self::BotToken(token) => {
79 let value = HeaderValue::from_str(&format!("Bot {}", token.as_secret())).map_err(
80 |source| KagiError::InvalidCredential {
81 kind: CredentialKind::BotToken,
82 reason: format!("token could not be encoded as header: {source}"),
83 },
84 )?;
85 headers.insert(AUTHORIZATION, value);
86 }
87 Self::SessionToken(token) => {
88 let value = HeaderValue::from_str(&format!("kagi_session={}", token.as_secret()))
89 .map_err(|source| KagiError::InvalidCredential {
90 kind: CredentialKind::SessionToken,
91 reason: format!("token could not be encoded as cookie: {source}"),
92 })?;
93 headers.insert(COOKIE, value);
94 }
95 }
96
97 Ok(())
98 }
99}
100
101impl From<BotToken> for Credentials {
102 fn from(value: BotToken) -> Self {
103 Self::BotToken(value)
104 }
105}
106
107impl From<SessionToken> for Credentials {
108 fn from(value: SessionToken) -> Self {
109 Self::SessionToken(value)
110 }
111}
112
113fn parse_token(raw_token: String, kind: CredentialKind) -> Result<String, KagiError> {
114 let trimmed = raw_token.trim();
115 if trimmed.is_empty() {
116 return Err(KagiError::InvalidCredential {
117 kind,
118 reason: "token cannot be empty".to_string(),
119 });
120 }
121
122 if trimmed.chars().any(char::is_whitespace) {
123 return Err(KagiError::InvalidCredential {
124 kind,
125 reason: "token cannot contain whitespace".to_string(),
126 });
127 }
128
129 Ok(trimmed.to_string())
130}
131
132#[cfg(test)]
133mod tests {
134 use super::BotToken;
135
136 #[test]
137 fn token_debug_is_redacted() {
138 let token = BotToken::new("super-secret").expect("token should parse");
139 assert_eq!(format!("{token:?}"), "BotToken(REDACTED)");
140 }
141}