atlassian_app_auth/
lib.rs1#![warn(missing_docs)]
18
19use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
20use serde::Serialize;
21use sha2::Digest;
22use std::time;
23use url::Url;
24
25pub const QUERY_PARAM_ENCODE_SET: &AsciiSet = &CONTROLS
33 .add(b' ')
34 .add(b'!')
35 .add(b'"')
36 .add(b'#')
37 .add(b'$')
38 .add(b'%')
39 .add(b'&')
40 .add(b'\'')
41 .add(b'(')
42 .add(b')')
43 .add(b'*')
44 .add(b'+')
45 .add(b',')
46 .add(b'/')
47 .add(b':')
48 .add(b';')
49 .add(b'<')
50 .add(b'=')
51 .add(b'>')
52 .add(b'?')
53 .add(b'@')
54 .add(b'[')
55 .add(b'\\')
56 .add(b']')
57 .add(b'^')
58 .add(b'`')
59 .add(b'{')
60 .add(b'|')
61 .add(b'}');
62
63pub struct Parameters {
65 pub method: String,
67
68 pub url: Url,
70
71 pub valid_for: time::Duration,
74
75 pub app_key: String,
79
80 pub shared_secret: String,
83}
84
85#[derive(thiserror::Error, Debug)]
87pub enum AuthError {
88 #[error("JWT encoding failed: {0}")]
90 JwtError(#[from] jsonwebtoken::errors::Error),
91
92 #[error("system time error: {0}")]
94 TimeError(#[from] time::SystemTimeError),
95}
96
97fn create_canonical_request(params: &Parameters) -> String {
100 let url = ¶ms.url;
101 let method = params.method.as_str().to_uppercase();
102 let path = url.path();
104
105 let mut query_pairs = url
106 .query_pairs()
107 .map(|(key, val)| {
108 format!(
109 "{}={}",
110 key,
111 utf8_percent_encode(&val, QUERY_PARAM_ENCODE_SET)
112 )
113 })
114 .collect::<Vec<_>>();
115 query_pairs.sort_unstable();
116
117 format!("{}&{}&{}", method, path, query_pairs.join("&"))
118}
119
120fn create_query_string_hash(params: &Parameters) -> String {
121 let canonical_request = create_canonical_request(params);
122 format!("{:x}", sha2::Sha256::digest(canonical_request.as_bytes()))
123}
124
125#[derive(Debug, Serialize)]
126struct Claims {
127 iss: String,
130
131 qsh: String,
133
134 iat: u64,
136
137 exp: u64,
139}
140
141impl Claims {
142 fn new(params: &Parameters) -> Result<Claims, AuthError> {
143 let now = time::SystemTime::now()
144 .duration_since(time::UNIX_EPOCH)?
145 .as_secs();
146 Ok(Claims {
147 iss: params.app_key.clone(),
148 qsh: create_query_string_hash(params),
149
150 iat: now,
152
153 exp: now + params.valid_for.as_secs(),
155 })
156 }
157}
158
159pub struct Header {
161 pub name: &'static str,
163 pub value: String,
165}
166
167pub fn create_auth_header(params: &Parameters) -> Result<Header, AuthError> {
169 let claims = Claims::new(params)?;
170
171 let token = jsonwebtoken::encode(
172 &jsonwebtoken::Header::default(),
173 &claims,
174 &jsonwebtoken::EncodingKey::from_secret(
175 params.shared_secret.as_bytes(),
176 ),
177 )?;
178
179 Ok(Header {
180 name: "Authorization",
181 value: format!("JWT {}", token),
182 })
183}
184
185#[cfg(test)]
186mod tests {
187 use super::*;
188
189 fn create_params(method: &str, url: &str) -> Parameters {
190 Parameters {
191 method: method.into(),
192 url: Url::parse(url).unwrap(),
193 app_key: String::new(),
194 shared_secret: String::new(),
195 valid_for: time::Duration::new(0, 0),
196 }
197 }
198
199 #[test]
200 fn test_canonical_request() {
201 let params = create_params(
202 "get",
203 "https://somecorp.atlassian.net/rest/api/3/project/search?query=myproject",
204 );
205 assert_eq!(
206 create_canonical_request(¶ms),
207 "GET&/rest/api/3/project/search&query=myproject"
208 );
209 }
210
211 #[test]
212 fn test_canonical_request_query_params_encoding() {
213 let params = create_params(
214 "get",
215 "https://example.com/example?query=x y,z%2B*~",
216 );
217 assert_eq!(
218 create_canonical_request(¶ms),
219 "GET&/example&query=x%20y%2Cz%2B%2A~"
220 );
221 }
222
223 #[test]
224 fn test_query_string_hash() {
225 let params = create_params("get", "https://example.com/example");
226 assert_eq!(
227 create_query_string_hash(¶ms),
228 "0073e2edb5df6a8af18c4398d32532f2b46a05295d10fac402131dd044032a61"
229 );
230 }
231}