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}