atlassian_app_auth/
lib.rs

1//! This is a small library for authenticating with an Atlassian API
2//! (such as the Jira API) as an Atlassian Connect App.
3//!
4//! See [examples/request.rs] for a full usage example.
5//!
6//! Note that the query string hash implementation is incomplete; there
7//! are a lot of special cases that are not yet handled.
8//!
9//! Relevant documentation:
10//!
11//! - <https://developer.atlassian.com/cloud/jira/platform/integrating-with-jira-cloud>
12//! - <https://developer.atlassian.com/cloud/jira/platform/security-for-connect-apps>
13//! - <https://developer.atlassian.com/cloud/jira/platform/understanding-jwt>
14//!
15//! [examples/request.rs]: https://github.com/nicholasbishop/atlassian-app-auth/blob/main/examples/request.rs
16
17#![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
25/// The set of characters to percent-encode for query parameters. The
26/// Jira documentation says these should be consistent with OAuth 1.0,
27/// which is defined in RFC 5849.
28///
29/// From <https://tools.ietf.org/html/rfc5849#page-29>:
30/// * (ALPHA, DIGIT, "-", ".", "_", "~") MUST NOT be encoded
31/// * All other characters MUST be encoded.
32pub 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
63/// Input parameters for creating a JWT.
64pub struct Parameters {
65    /// HTTP of the request.
66    pub method: String,
67
68    /// URL of the request.
69    pub url: Url,
70
71    /// Duration that this key will be valid for (starting from the
72    /// current time)
73    pub valid_for: time::Duration,
74
75    /// Connect App key. This is the same as the "key" field
76    /// of the app descriptor JSON file, and is also returned in the
77    /// "key" field of the installation lifecycle callback.
78    pub app_key: String,
79
80    /// Connect App shared secret. This is returned in the
81    /// "sharedSecret" field of the installation lifecycle callback.
82    pub shared_secret: String,
83}
84
85/// Authentication error enum.
86#[derive(thiserror::Error, Debug)]
87pub enum AuthError {
88    /// An error occurred when trying to encode the JWT.
89    #[error("JWT encoding failed: {0}")]
90    JwtError(#[from] jsonwebtoken::errors::Error),
91
92    /// Something very unexpected happened with time itself.
93    #[error("system time error: {0}")]
94    TimeError(#[from] time::SystemTimeError),
95}
96
97// TODO: there are quite a few special cases described in the doc
98// linked above that are not yet handled here.
99fn create_canonical_request(params: &Parameters) -> String {
100    let url = &params.url;
101    let method = params.method.as_str().to_uppercase();
102    // Assume the path is already canonical
103    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    /// The issuer of the claim. This matches the key in the app
128    /// descriptor (e.g. "com.example.app").
129    iss: String,
130
131    /// Custom Atlassian claim that prevents URL tampering.
132    qsh: String,
133
134    /// The time that this JWT was issued.
135    iat: u64,
136
137    /// JWT expiration time.
138    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            // The time that this JWT was issued (now)
151            iat: now,
152
153            // JWT expiration time
154            exp: now + params.valid_for.as_secs(),
155        })
156    }
157}
158
159/// Request header.
160pub struct Header {
161    /// Header name.
162    pub name: &'static str,
163    /// Header value.
164    pub value: String,
165}
166
167/// Create an authentication [`Header`].
168pub 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(&params),
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(&params),
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(&params),
228            "0073e2edb5df6a8af18c4398d32532f2b46a05295d10fac402131dd044032a61"
229        );
230    }
231}