Skip to main content

vapour_protocol/
token.rs

1use base64::Engine;
2use base64::engine::general_purpose::{URL_SAFE, URL_SAFE_NO_PAD};
3use serde::Deserialize;
4
5#[derive(Deserialize)]
6struct RefreshTokenClaims {
7    sub: SubClaim,
8}
9
10#[derive(Deserialize)]
11#[serde(untagged)]
12enum SubClaim {
13    String(String),
14    Number(u64),
15}
16
17pub fn steamid_from_refresh_token(token: &str) -> Option<u64> {
18    let mut segments = token.split('.');
19    let _header = segments.next()?;
20    let payload = segments.next()?;
21    let _signature = segments.next()?;
22
23    if segments.next().is_some() || payload.is_empty() {
24        return None;
25    }
26
27    let decoded_payload = decode_jwt_segment(payload)?;
28    let claims: RefreshTokenClaims = serde_json::from_slice(&decoded_payload).ok()?;
29
30    match claims.sub {
31        SubClaim::String(value) => parse_steamid(&value),
32        SubClaim::Number(value) => Some(value),
33    }
34}
35
36fn decode_jwt_segment(segment: &str) -> Option<Vec<u8>> {
37    URL_SAFE_NO_PAD
38        .decode(segment)
39        .or_else(|_| URL_SAFE.decode(segment))
40        .ok()
41}
42
43fn parse_steamid(value: &str) -> Option<u64> {
44    if value.is_empty() || !value.bytes().all(|byte| byte.is_ascii_digit()) {
45        return None;
46    }
47
48    value.parse().ok()
49}
50
51#[cfg(test)]
52mod tests {
53    use super::steamid_from_refresh_token;
54    use base64::Engine;
55    use base64::engine::general_purpose::URL_SAFE_NO_PAD;
56
57    #[test]
58    fn extracts_steamid_from_string_sub_claim() {
59        let token = make_token(r#"{"sub":"76561198000000000","aud":["web"]}"#);
60
61        assert_eq!(
62            steamid_from_refresh_token(&token),
63            Some(76_561_198_000_000_000)
64        );
65    }
66
67    #[test]
68    fn extracts_steamid_from_numeric_sub_claim() {
69        let token = make_token(r#"{"sub":76561198000000000}"#);
70
71        assert_eq!(
72            steamid_from_refresh_token(&token),
73            Some(76_561_198_000_000_000)
74        );
75    }
76
77    #[test]
78    fn rejects_token_with_wrong_segment_count() {
79        assert_eq!(steamid_from_refresh_token("header.payload"), None,);
80        assert_eq!(
81            steamid_from_refresh_token("header.payload.signature.extra"),
82            None,
83        );
84    }
85
86    #[test]
87    fn rejects_invalid_base64_payload() {
88        assert_eq!(steamid_from_refresh_token("header.@@@.signature"), None);
89    }
90
91    #[test]
92    fn rejects_non_json_payload() {
93        let token = make_token("not-json");
94
95        assert_eq!(steamid_from_refresh_token(&token), None);
96    }
97
98    #[test]
99    fn rejects_missing_sub_claim() {
100        let token = make_token(r#"{"iss":"steam"}"#);
101
102        assert_eq!(steamid_from_refresh_token(&token), None);
103    }
104
105    #[test]
106    fn rejects_non_numeric_string_sub_claim() {
107        let token = make_token(r#"{"sub":"steam-user"}"#);
108
109        assert_eq!(steamid_from_refresh_token(&token), None);
110    }
111
112    #[test]
113    fn rejects_out_of_range_string_sub_claim() {
114        let token = make_token(r#"{"sub":"18446744073709551616"}"#);
115
116        assert_eq!(steamid_from_refresh_token(&token), None);
117    }
118
119    fn make_token(payload_json: &str) -> String {
120        let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
121        let payload = URL_SAFE_NO_PAD.encode(payload_json);
122        format!("{header}.{payload}.signature")
123    }
124}