Skip to main content

browsertap_shared/
token.rs

1use 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/// Token scopes - browser sessions get short-lived tokens, CLI gets longer-lived ones.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub enum TokenScope {
15    /// Browser runtime token (short TTL: 5 minutes)
16    Session,
17    /// CLI/Agent token (longer TTL: 1 hour)
18    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/// Token payload - the claims inside a signed token.
31#[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
78/// Sign a token payload with HMAC-SHA256.
79///
80/// Format: `{base64url(json_payload)}.{base64url(hmac_signature)}`
81pub 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
93/// Verify and decode a token string.
94///
95/// Uses constant-time comparison for the signature to prevent timing attacks.
96pub 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    // Verify signature (constant-time via hmac crate)
100    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    // Decode payload
109    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
122/// Verify a token and enforce a specific scope.
123pub 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
138/// Generate a cryptographically secure random secret (32 bytes).
139pub 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
146/// Encode a secret as hex string for storage.
147pub fn secret_to_hex(secret: &[u8]) -> String {
148    secret.iter().map(|b| format!("{b:02x}")).collect()
149}
150
151/// Decode a hex string back to secret bytes.
152pub 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}