1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231
//! This is a small library for authenticating with an Atlassian API
//! (such as the Jira API) as an Atlassian Connect App.
//!
//! See [examples/request.rs] for a full usage example.
//!
//! Note that the query string hash implementation is incomplete; there
//! are a lot of special cases that are not yet handled.
//!
//! Relevant documentation:
//!
//! - <https://developer.atlassian.com/cloud/jira/platform/integrating-with-jira-cloud>
//! - <https://developer.atlassian.com/cloud/jira/platform/security-for-connect-apps>
//! - <https://developer.atlassian.com/cloud/jira/platform/understanding-jwt>
//!
//! [examples/request.rs]: https://github.com/nicholasbishop/atlassian-app-auth/blob/main/examples/request.rs
#![warn(missing_docs)]
use percent_encoding::{utf8_percent_encode, AsciiSet, CONTROLS};
use serde::Serialize;
use sha2::Digest;
use std::time;
use url::Url;
/// The set of characters to percent-encode for query parameters. The
/// Jira documentation says these should be consistent with OAuth 1.0,
/// which is defined in RFC 5849.
///
/// From <https://tools.ietf.org/html/rfc5849#page-29>:
/// * (ALPHA, DIGIT, "-", ".", "_", "~") MUST NOT be encoded
/// * All other characters MUST be encoded.
pub const QUERY_PARAM_ENCODE_SET: &AsciiSet = &CONTROLS
.add(b' ')
.add(b'!')
.add(b'"')
.add(b'#')
.add(b'$')
.add(b'%')
.add(b'&')
.add(b'\'')
.add(b'(')
.add(b')')
.add(b'*')
.add(b'+')
.add(b',')
.add(b'/')
.add(b':')
.add(b';')
.add(b'<')
.add(b'=')
.add(b'>')
.add(b'?')
.add(b'@')
.add(b'[')
.add(b'\\')
.add(b']')
.add(b'^')
.add(b'`')
.add(b'{')
.add(b'|')
.add(b'}');
/// Input parameters for creating a JWT.
pub struct Parameters {
/// HTTP of the request.
pub method: String,
/// URL of the request.
pub url: Url,
/// Duration that this key will be valid for (starting from the
/// current time)
pub valid_for: time::Duration,
/// Connect App key. This is the same as the "key" field
/// of the app descriptor JSON file, and is also returned in the
/// "key" field of the installation lifecycle callback.
pub app_key: String,
/// Connect App shared secret. This is returned in the
/// "sharedSecret" field of the installation lifecycle callback.
pub shared_secret: String,
}
/// Authentication error enum.
#[derive(thiserror::Error, Debug)]
pub enum AuthError {
/// An error occurred when trying to encode the JWT.
#[error("JWT encoding failed: {0}")]
JwtError(#[from] jsonwebtoken::errors::Error),
/// Something very unexpected happened with time itself.
#[error("system time error: {0}")]
TimeError(#[from] time::SystemTimeError),
}
// TODO: there are quite a few special cases described in the doc
// linked above that are not yet handled here.
fn create_canonical_request(params: &Parameters) -> String {
let url = ¶ms.url;
let method = params.method.as_str().to_uppercase();
// Assume the path is already canonical
let path = url.path();
let mut query_pairs = url
.query_pairs()
.map(|(key, val)| {
format!(
"{}={}",
key,
utf8_percent_encode(&val, QUERY_PARAM_ENCODE_SET)
)
})
.collect::<Vec<_>>();
query_pairs.sort_unstable();
format!("{}&{}&{}", method, path, query_pairs.join("&"))
}
fn create_query_string_hash(params: &Parameters) -> String {
let canonical_request = create_canonical_request(params);
format!("{:x}", sha2::Sha256::digest(canonical_request.as_bytes()))
}
#[derive(Debug, Serialize)]
struct Claims {
/// The issuer of the claim. This matches the key in the app
/// descriptor (e.g. "com.example.app").
iss: String,
/// Custom Atlassian claim that prevents URL tampering.
qsh: String,
/// The time that this JWT was issued.
iat: u64,
/// JWT expiration time.
exp: u64,
}
impl Claims {
fn new(params: &Parameters) -> Result<Claims, AuthError> {
let now = time::SystemTime::now()
.duration_since(time::UNIX_EPOCH)?
.as_secs();
Ok(Claims {
iss: params.app_key.clone(),
qsh: create_query_string_hash(params),
// The time that this JWT was issued (now)
iat: now,
// JWT expiration time
exp: now + params.valid_for.as_secs(),
})
}
}
/// Request header.
pub struct Header {
/// Header name.
pub name: &'static str,
/// Header value.
pub value: String,
}
/// Create an authentication [`Header`].
pub fn create_auth_header(params: &Parameters) -> Result<Header, AuthError> {
let claims = Claims::new(params)?;
let token = jsonwebtoken::encode(
&jsonwebtoken::Header::default(),
&claims,
&jsonwebtoken::EncodingKey::from_secret(
params.shared_secret.as_bytes(),
),
)?;
Ok(Header {
name: "Authorization",
value: format!("JWT {}", token),
})
}
#[cfg(test)]
mod tests {
use super::*;
fn create_params(method: &str, url: &str) -> Parameters {
Parameters {
method: method.into(),
url: Url::parse(url).unwrap(),
app_key: String::new(),
shared_secret: String::new(),
valid_for: time::Duration::new(0, 0),
}
}
#[test]
fn test_canonical_request() {
let params = create_params(
"get",
"https://somecorp.atlassian.net/rest/api/3/project/search?query=myproject",
);
assert_eq!(
create_canonical_request(¶ms),
"GET&/rest/api/3/project/search&query=myproject"
);
}
#[test]
fn test_canonical_request_query_params_encoding() {
let params = create_params(
"get",
"https://example.com/example?query=x y,z%2B*~",
);
assert_eq!(
create_canonical_request(¶ms),
"GET&/example&query=x%20y%2Cz%2B%2A~"
);
}
#[test]
fn test_query_string_hash() {
let params = create_params("get", "https://example.com/example");
assert_eq!(
create_query_string_hash(¶ms),
"0073e2edb5df6a8af18c4398d32532f2b46a05295d10fac402131dd044032a61"
);
}
}