browsertap_shared/
token.rs1use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
2use chrono::{DateTime, Duration, Utc};
3use hmac::{Hmac, Mac};
4use serde::{Deserialize, Serialize};
5use sha2::Sha256;
6use thiserror::Error;
7use uuid::Uuid;
8
9type HmacSha256 = Hmac<Sha256>;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum TokenScope {
15 Session,
17 Cli,
19}
20
21impl TokenScope {
22 pub fn ttl(&self) -> Duration {
23 match self {
24 TokenScope::Session => Duration::minutes(5),
25 TokenScope::Cli => Duration::hours(1),
26 }
27 }
28}
29
30#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct TokenPayload {
33 pub token_id: Uuid,
34 pub scope: TokenScope,
35 pub subject: String,
36 pub session_id: Uuid,
37 pub issued_at: DateTime<Utc>,
38 pub expires_at: DateTime<Utc>,
39}
40
41impl TokenPayload {
42 pub fn new(scope: TokenScope, subject: impl Into<String>, session_id: Uuid) -> Self {
43 let now = Utc::now();
44 Self {
45 token_id: Uuid::new_v4(),
46 scope,
47 subject: subject.into(),
48 session_id,
49 issued_at: now,
50 expires_at: now + scope.ttl(),
51 }
52 }
53
54 pub fn is_expired(&self) -> bool {
55 Utc::now() > self.expires_at
56 }
57}
58
59#[derive(Debug, Error)]
60pub enum TokenError {
61 #[error("invalid token format")]
62 InvalidFormat,
63 #[error("invalid base64 encoding")]
64 InvalidBase64,
65 #[error("invalid JSON payload")]
66 InvalidPayload,
67 #[error("invalid signature")]
68 InvalidSignature,
69 #[error("token expired")]
70 Expired,
71 #[error("scope mismatch: expected {expected:?}, got {actual:?}")]
72 ScopeMismatch {
73 expected: TokenScope,
74 actual: TokenScope,
75 },
76}
77
78pub fn sign_token(payload: &TokenPayload, secret: &[u8]) -> Result<String, TokenError> {
82 let json = serde_json::to_vec(payload).map_err(|_| TokenError::InvalidPayload)?;
83 let encoded_payload = URL_SAFE_NO_PAD.encode(&json);
84
85 let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key size");
86 mac.update(encoded_payload.as_bytes());
87 let signature = mac.finalize().into_bytes();
88 let encoded_sig = URL_SAFE_NO_PAD.encode(signature);
89
90 Ok(format!("{encoded_payload}.{encoded_sig}"))
91}
92
93pub fn verify_token(token: &str, secret: &[u8]) -> Result<TokenPayload, TokenError> {
97 let (encoded_payload, encoded_sig) = token.split_once('.').ok_or(TokenError::InvalidFormat)?;
98
99 let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC accepts any key size");
101 mac.update(encoded_payload.as_bytes());
102 let expected_sig = URL_SAFE_NO_PAD
103 .decode(encoded_sig)
104 .map_err(|_| TokenError::InvalidBase64)?;
105 mac.verify_slice(&expected_sig)
106 .map_err(|_| TokenError::InvalidSignature)?;
107
108 let json = URL_SAFE_NO_PAD
110 .decode(encoded_payload)
111 .map_err(|_| TokenError::InvalidBase64)?;
112 let payload: TokenPayload =
113 serde_json::from_slice(&json).map_err(|_| TokenError::InvalidPayload)?;
114
115 if payload.is_expired() {
116 return Err(TokenError::Expired);
117 }
118
119 Ok(payload)
120}
121
122pub fn verify_token_with_scope(
124 token: &str,
125 secret: &[u8],
126 expected_scope: TokenScope,
127) -> Result<TokenPayload, TokenError> {
128 let payload = verify_token(token, secret)?;
129 if payload.scope != expected_scope {
130 return Err(TokenError::ScopeMismatch {
131 expected: expected_scope,
132 actual: payload.scope,
133 });
134 }
135 Ok(payload)
136}
137
138pub fn generate_secret() -> Vec<u8> {
140 use rand::RngCore;
141 let mut secret = vec![0u8; 32];
142 rand::thread_rng().fill_bytes(&mut secret);
143 secret
144}
145
146pub fn secret_to_hex(secret: &[u8]) -> String {
148 secret.iter().map(|b| format!("{b:02x}")).collect()
149}
150
151pub fn secret_from_hex(hex: &str) -> Result<Vec<u8>, TokenError> {
153 (0..hex.len())
154 .step_by(2)
155 .map(|i| u8::from_str_radix(&hex[i..i + 2], 16).map_err(|_| TokenError::InvalidFormat))
156 .collect()
157}
158
159#[cfg(test)]
160mod tests {
161 use super::*;
162
163 #[test]
164 fn sign_and_verify_roundtrip() {
165 let secret = generate_secret();
166 let session_id = Uuid::new_v4();
167 let payload = TokenPayload::new(TokenScope::Session, "test-browser", session_id);
168
169 let token = sign_token(&payload, &secret).unwrap();
170 let verified = verify_token(&token, &secret).unwrap();
171
172 assert_eq!(verified.token_id, payload.token_id);
173 assert_eq!(verified.scope, TokenScope::Session);
174 assert_eq!(verified.session_id, session_id);
175 }
176
177 #[test]
178 fn wrong_secret_fails() {
179 let secret = generate_secret();
180 let wrong_secret = generate_secret();
181 let payload = TokenPayload::new(TokenScope::Cli, "test-cli", Uuid::new_v4());
182
183 let token = sign_token(&payload, &secret).unwrap();
184 let result = verify_token(&token, &wrong_secret);
185
186 assert!(matches!(result, Err(TokenError::InvalidSignature)));
187 }
188
189 #[test]
190 fn scope_enforcement() {
191 let secret = generate_secret();
192 let payload = TokenPayload::new(TokenScope::Session, "browser", Uuid::new_v4());
193 let token = sign_token(&payload, &secret).unwrap();
194
195 let result = verify_token_with_scope(&token, &secret, TokenScope::Cli);
196 assert!(matches!(result, Err(TokenError::ScopeMismatch { .. })));
197 }
198
199 #[test]
200 fn secret_hex_roundtrip() {
201 let secret = generate_secret();
202 let hex = secret_to_hex(&secret);
203 let decoded = secret_from_hex(&hex).unwrap();
204 assert_eq!(secret, decoded);
205 }
206}