diff --git a/Cargo.lock b/Cargo.lock index 1648a95..ffa366d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5,13 +5,14 @@ dependencies = [ "chrono 0.2.25 (registry+https://github.com/rust-lang/crates.io-index)", "getopts 0.2.14 (registry+https://github.com/rust-lang/crates.io-index)", "hyper 0.9.10 (registry+https://github.com/rust-lang/crates.io-index)", - "itertools 0.4.18 (registry+https://github.com/rust-lang/crates.io-index)", + "itertools 0.4.19 (registry+https://github.com/rust-lang/crates.io-index)", "log 0.3.6 (registry+https://github.com/rust-lang/crates.io-index)", - "mime 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", "open 1.1.1 (registry+https://github.com/rust-lang/crates.io-index)", + "openssl 0.7.14 (registry+https://github.com/rust-lang/crates.io-index)", + "rustc-serialize 0.3.19 (registry+https://github.com/rust-lang/crates.io-index)", "serde 0.8.8 (registry+https://github.com/rust-lang/crates.io-index)", "serde_codegen 0.8.8 (registry+https://github.com/rust-lang/crates.io-index)", - "serde_json 0.8.1 (registry+https://github.com/rust-lang/crates.io-index)", + "serde_json 0.8.2 (registry+https://github.com/rust-lang/crates.io-index)", "serde_macros 0.8.8 (registry+https://github.com/rust-lang/crates.io-index)", "url 0.5.10 (registry+https://github.com/rust-lang/crates.io-index)", "yup-hyper-mock 1.3.3 (registry+https://github.com/rust-lang/crates.io-index)", @@ -126,7 +127,7 @@ dependencies = [ [[package]] name = "itertools" -version = "0.4.18" +version = "0.4.19" source = "registry+https://github.com/rust-lang/crates.io-index" [[package]] @@ -369,7 +370,7 @@ dependencies = [ [[package]] name = "serde_json" -version = "0.8.1" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" dependencies = [ "dtoa 0.2.2 (registry+https://github.com/rust-lang/crates.io-index)", diff --git a/Cargo.toml b/Cargo.toml index 4147053..f301a83 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,14 +12,15 @@ build = "src/build.rs" [dependencies] chrono = ">= 0.2" -log = ">= 0.3" -mime = ">= 0.1" -url = "= 0.5" hyper = "^ 0.9.0" itertools = ">= 0.4" +log = ">= 0.3" +openssl = "0.7" +rustc-serialize = "0.3" serde = "0.8" serde_json = "0.8" serde_macros = { version = "0.8", optional = true } +url = "= 0.5" [features] default = ["with-serde-codegen"] diff --git a/examples/Sanguine-69411a0c0eea.json b/examples/Sanguine-69411a0c0eea.json new file mode 100644 index 0000000..3110a14 --- /dev/null +++ b/examples/Sanguine-69411a0c0eea.json @@ -0,0 +1,12 @@ +{ + "type": "service_account", + "project_id": "sanguine-rhythm-105020", + "private_key_id": "69411a0c0eeae2a42e025be2d337a3e5d2f816d4", + "private_key": "-----BEGIN PRIVATE KEY-----\nMIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQDWNgiReVu5vJOx\ne3OzaONRGqm+IKFMDwROAj97Rp65KY/t34ARR41bfC7u36LH+WJL7DcMm6EWhFWR\n6B49jVtloJ9Qa5ivVi2sh5Wv1V+2PD5DqGTq2RwlsvA1Xm/guS7tXct6TOUxnhDT\n0/VfwCbW1fn5lL0xNE5NS25vPUNwxteCJGZDanwZeLEaPXL/WCL7t7Kg2T3Gwr3O\n1z3TbirD9SDcPXvXn9w4hKNtmBZ5nGLUAQiKtwiumSlAlD65KcaHmUHRRZCV6625\naKfTBlxksZEh3Ac1NzreRz9NHqkaDCp3+Y/09K7EXSf2LkQI0XtvEHyeWFDVIzbc\niIymrFQ1AgMBAAECggEALMXKcK2O8Hlr3VLSUhOAP7l09RpY3E7fNXbe0eNm1YjM\nXomyeOodr5t7K85udaG4W+oZb3cv6kbqo76CY5ciHVG/Os3icfNvRHpqXQAaKzrY\nQMf3n+aVLYQDFQSSGcRa/J34I63i0cYZ+kx5IvREqe67euEN0jT+kMVNZc7GQ9HJ\nuOSPnDIRQJotiYWB1ooOpJ3aXFzumd+mnMQ54FJILzOUviPo/5F50ShZfAHtagyq\nYMkPwXCx4ygEoZ/8wf5JXWrqfvXxtLke5YAX0rGtnhwOAYvNM0CX08U4qv8cy5xX\nyBhhYnnwaZ5y95FJ5adz9sjCFrTRSItumH5G7ETVAQKBgQD31iAoj0NELhQw/iuO\npf6ctEMl4SScgFWKdKb5proKIKojlDAbjhyYQXwh8waQsulftzoAuor7f5mjedNj\nQTNMONRFICFVgl6BdEW25iofrVhvpK8WhhP3AxmIRJdwnDE1iEstn9MRICF0nmum\nDuiJYTQY0ZIabjspKGfeF76uIQKBgQDdRFzkPqTHz6tA+/r0O9LBeQvWJ5drgslw\nkbdWXgoMpoMFBhsSjR7ErpeTYEP5f35DovlUsJRZECnM/NPM92a8LDoq98b1/WiM\nxInkWgM3YAo5aw0LBAbDWEMfzIqDiFZQinWUZxXTP9ug/XNseHIXbWwXQqWLaSxS\nQXlQuuOblQKBgQDQz18+7R2epzgp4yxtvpvcCeD9XEkNdu8bfZdlhjz+5XCUE3nI\n7Z5YBeyBahIg/iy0kVrUXFdW+LZIzw70dG21DbiDGUQcmH/pkD5gkGHzWIjHX7iJ\nQKQ3nSv47NmvblnjoZa9tQgPSMQHTqaiPbNcdEKBmqj/jdpYnguNSvJEIQKBgEWZ\nxnqJdf3gRidX2/XNh5sSP3Xq2EeaSVEelQQW6qRWEGqZJBTuzRnjLYzPQDKQNyCB\neFp6fP+TBQMVGG6l9+wDIXz4md4xCx0UiaeJ5O8bR7wN/3lSl+oEroCnL372eOg2\n1YuL7aKYuLZoY1Fghcr2wYSDk11KBQYO0GFjmEsNAoGANRR8MroJinKzqA1lvtYa\nLj6Y7mhKw/qeAsCqC0E+a+DUNZCslKtd9dJ+PZDlwFjsZ8hm1JFNWwLOTGlVZkVd\nMOxNqMtZKHC9hlS1RDIZREK46to4R/ac/h515/j1wgwNx0uUnzz3inD2BWlFKkWs\nXri9sBiXiKG7bf7p1WPu2ek=\n-----END PRIVATE KEY-----\n", + "client_email": "oauth2-public-test@sanguine-rhythm-105020.iam.gserviceaccount.com", + "client_id": "110673384954054291652", + "auth_uri": "https://accounts.google.com/o/oauth2/auth", + "token_uri": "https://accounts.google.com/o/oauth2/token", + "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", + "client_x509_cert_url": "https://www.googleapis.com/robot/v1/metadata/x509/oauth2-public-test%40sanguine-rhythm-105020.iam.gserviceaccount.com" +} diff --git a/src/lib.rs.in b/src/lib.rs.in index 20f57c2..6a8c219 100644 --- a/src/lib.rs.in +++ b/src/lib.rs.in @@ -1,14 +1,14 @@ extern crate serde; extern crate serde_json; +extern crate rustc_serialize; extern crate chrono; - +extern crate openssl; extern crate hyper; #[cfg(test)] extern crate log; #[cfg(test)] extern crate yup_hyper_mock; -extern crate mime; extern crate url; extern crate itertools; @@ -18,6 +18,7 @@ mod device; mod helper; mod installed; mod refresh; +mod service_account; mod storage; mod types; diff --git a/src/service_account.rs b/src/service_account.rs new file mode 100644 index 0000000..66417c5 --- /dev/null +++ b/src/service_account.rs @@ -0,0 +1,324 @@ +//! This module provides a token source (`GetToken`) that obtains tokens for service accounts. +//! Service accounts are usually used by software (i.e., non-human actors) to get access to +//! resources. Currently, this module only works with RS256 JWTs, and uses the Google request URL +//! by default (this can be amended by a patch). +//! +//! Resources: +//! - [Using OAuth 2.0 for Server to Server +//! Applications](https://developers.google.com/identity/protocols/OAuth2ServiceAccount) +//! - [JSON Web Tokens](https://jwt.io/) +//! +//! Copyright (c) 2016 Google Inc (lewinb@google.com). +//! + +use std::borrow::BorrowMut; +use std::default::Default; +use std::error; +use std::io::{Read, Write}; +use std::result; + +use authenticator::GetToken; +use types::{StringError, Token}; +use storage::{hash_scopes, MemoryStorage, TokenStorage}; + +use hyper::header; +use url::form_urlencoded; + +use chrono; +use hyper; +use openssl; +use rustc_serialize; +use serde_json; + +const GRANT_TYPE: &'static str = "urn:ietf:params:oauth:grant-type:jwt-bearer"; +const TOKEN_REQUEST_URL: &'static str = "https://www.googleapis.com/oauth2/v4/token"; +const GOOGLE_RS256_HEAD: &'static str = "{\"alg\":\"RS256\",\"typ\":\"JWT\"}"; + +// Encodes s as Base64 +fn encode_base64>(s: T) -> String { + use rustc_serialize::base64::ToBase64; + + s.as_ref().to_base64(rustc_serialize::base64::URL_SAFE) +} + +// Calculates the SHA256 hash. +fn hash_sha256(data: &[u8]) -> Vec { + let mut hasher = openssl::crypto::hash::Hasher::new(openssl::crypto::hash::Type::SHA256); + let _ = hasher.write(data); + hasher.finish() +} + +// Signs the hash with key. +fn sign_rsa(key: &openssl::crypto::rsa::RSA, hash: &[u8]) -> String { + let signature = key.sign(openssl::crypto::hash::Type::SHA256, hash).unwrap(); + let b64_signature = encode_base64(signature); + + b64_signature +} + +// Reads an RSA key from pem_pkcs8 (the format of the 'private_key' field in the service account +// key). +fn decode_rsa_key(pem_pkcs8: &str) -> Result> { + let private_key = pem_pkcs8.to_string().replace("\\n", "\n"); + let privkey = openssl::crypto::rsa::RSA::private_key_from_pem(&mut private_key.as_bytes()); + + match privkey { + Err(e) => Err(Box::new(e)), + Ok(key) => Ok(key), + } +} + +/// JSON schema of secret service account key. You can obtain the key from +/// the Cloud Console at https://console.cloud.google.com/. +/// +/// You can use `helpers::service_account_key_from_file()` as a quick way to read a JSON client +/// secret into a ServiceAccountKey. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ServiceAccountKey { + #[serde(rename="type")] + pub key_type: Option, + pub project_id: Option, + pub private_key_id: Option, + pub private_key: Option, + pub client_email: Option, + pub client_id: Option, + pub auth_uri: Option, + pub token_uri: Option, + pub auth_provier_x509_cert_url: Option, + pub client_x509_cert_url: Option, +} + +#[derive(Serialize, Debug)] +struct Claims { + iss: String, + aud: String, + exp: i64, + iat: i64, + sub: Option, + scope: String, +} + +struct JWT { + header: String, + claims: Claims, +} + +impl JWT { + fn new(claims: Claims) -> JWT { + JWT { + header: GOOGLE_RS256_HEAD.to_string(), + claims: claims, + } + } + // Encodes the first two parts (header and claims) to base64 and assembles them into a form + // ready to be signed. + fn encode_claims(&self) -> String { + let mut head = encode_base64(&self.header); + let claims = encode_base64(serde_json::to_string(&self.claims).unwrap()); + + head.push_str("."); + head.push_str(&claims); + head + } + + fn sign(&self, private_key: &str) -> Result> { + let mut jwt_head = self.encode_claims(); + + let key = try!(decode_rsa_key(private_key)); + let hash = hash_sha256(&jwt_head.as_bytes()); + let signature = sign_rsa(&key, &hash); + + jwt_head.push_str("."); + jwt_head.push_str(&signature); + + Ok(jwt_head) + } +} + +fn init_claims_from_key<'a, I, T>(key: &ServiceAccountKey, scopes: I) -> Claims + where T: AsRef + 'a, + I: IntoIterator +{ + let iat = chrono::UTC::now().timestamp(); + let expiry = iat + 3600 - 5; // Max validity is 1h. + + let mut scopes_string = scopes.into_iter().fold(String::new(), |mut acc, sc| { + acc.push_str(sc.as_ref()); + acc.push_str(" "); + acc + }); + scopes_string.pop(); + + Claims { + iss: key.client_email.clone().unwrap(), + aud: TOKEN_REQUEST_URL.to_string(), + exp: expiry, + iat: iat, + sub: None, + scope: scopes_string, + } +} + +/// See "Additional claims" at https://developers.google.com/identity/protocols/OAuth2ServiceAccount +#[allow(dead_code)] +fn set_sub_claim(mut claims: Claims, sub: String) -> Claims { + claims.sub = Some(sub); + claims +} + +/// A token source (`GetToken`) yielding OAuth tokens for services that use ServiceAccount authorization. +/// This token source caches token and automatically renews expired ones. +pub struct ServiceAccountAccess { + client: C, + key: ServiceAccountKey, + cache: MemoryStorage, +} + +/// This is the schema of the server's response. +#[derive(Deserialize, Debug)] +struct TokenResponse { + access_token: Option, + token_type: Option, + expires_in: Option, +} + +impl TokenResponse { + fn to_oauth_token(self) -> Token { + let expires_ts = chrono::UTC::now().timestamp() + self.expires_in.unwrap_or(0); + + Token { + access_token: self.access_token.unwrap(), + token_type: self.token_type.unwrap(), + refresh_token: String::new(), + expires_in: self.expires_in, + expires_in_timestamp: Some(expires_ts), + } + } +} + +impl<'a, C> ServiceAccountAccess + where C: BorrowMut +{ + /// Returns a new `ServiceAccountAccess` token source. + #[allow(dead_code)] + pub fn new(key: ServiceAccountKey, client: C) -> ServiceAccountAccess { + ServiceAccountAccess { + client: client, + key: key, + cache: MemoryStorage::default(), + } + } + + fn request_token(&mut self, scopes: &Vec<&str>) -> result::Result> { + let signed = try!(JWT::new(init_claims_from_key(&self.key, scopes)) + .sign(self.key.private_key.as_ref().unwrap())); + + let body = form_urlencoded::serialize(vec![("grant_type".to_string(), + GRANT_TYPE.to_string()), + ("assertion".to_string(), signed)]); + + let mut response = String::new(); + let mut result = try!(self.client + .borrow_mut() + .post(TOKEN_REQUEST_URL) + .body(&body) + .header(header::ContentType("application/x-www-form-urlencoded".parse().unwrap())) + .send()); + + try!(result.read_to_string(&mut response)); + + println!("{}", response); + let token: Result = + serde_json::from_str(&response); + + match token { + Err(e) => return Err(Box::new(e)), + Ok(token) => { + if token.access_token.is_none() || token.token_type.is_none() || + token.expires_in.is_none() { + Err(Box::new(StringError::new("Token response lacks fields".to_string(), + Some(&format!("{:?}", token))))) + } else { + Ok(token.to_oauth_token()) + } + } + } + } +} + +impl> GetToken for ServiceAccountAccess { + fn token<'b, I, T>(&mut self, scopes: I) -> result::Result> + where T: AsRef + Ord + 'b, + I: IntoIterator + { + let (hash, scps) = hash_scopes(scopes); + + if let Some(token) = try!(self.cache.get(hash, &scps)) { + if !token.expired() { + return Ok(token); + } + } + + let token = try!(self.request_token(&scps)); + let _ = self.cache.set(hash, &scps, Some(token.clone())); + + Ok(token) + } + + fn api_key(&mut self) -> Option { + None + } +} + +#[cfg(test)] +mod tests { + use super::*; + use helper::service_account_key_from_file; + use hyper; + use authenticator::GetToken; + + // This is a valid but deactivated key. + const TEST_PRIVATE_KEY_PATH: &'static str = "examples/Sanguine-69411a0c0eea.json"; + + // This only works if you have the right key file; if needed, download your own and test it + // with that. + // #[test] + #[allow(dead_code)] + fn test_service_account_e2e() { + let key = service_account_key_from_file(&TEST_PRIVATE_KEY_PATH.to_string()).unwrap(); + let mut acc = ServiceAccountAccess::new(key, hyper::client::Client::new()); + println!("{:?}", + acc.token(vec![&"https://www.googleapis.com/auth/pubsub"]).unwrap()); + } + + #[test] + fn test_jwt_initialize_claims() { + let key = service_account_key_from_file(&TEST_PRIVATE_KEY_PATH.to_string()).unwrap(); + let scopes = vec!["scope1", "scope2", "scope3"]; + let claims = super::init_claims_from_key(&key, &scopes); + + assert_eq!(claims.iss, + "oauth2-public-test@sanguine-rhythm-105020.iam.gserviceaccount.com".to_string()); + assert_eq!(claims.scope, "scope1 scope2 scope3".to_string()); + assert_eq!(claims.aud, + "https://www.googleapis.com/oauth2/v4/token".to_string()); + assert!(claims.exp > 1000000000); + assert!(claims.iat < claims.exp); + assert_eq!(claims.exp - claims.iat, 3595); + } + + #[test] + fn test_jwt_sign() { + let key = service_account_key_from_file(&TEST_PRIVATE_KEY_PATH.to_string()).unwrap(); + let scopes = vec!["scope1", "scope2", "scope3"]; + let claims = super::init_claims_from_key(&key, &scopes); + let jwt = super::JWT::new(claims); + let signature = jwt.sign(key.private_key.as_ref().unwrap()); + + assert!(signature.is_ok()); + + let signature = signature.unwrap(); + assert_eq!(signature.split(".").nth(0).unwrap(), + "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9"); + } +}