Merge pull request #29 from dermesser/service-account-pr

feat(flow): implement the service account "flow"
This commit is contained in:
Sebastian Thiel
2016-09-25 17:58:34 +02:00
committed by GitHub
9 changed files with 431 additions and 58 deletions

11
Cargo.lock generated
View File

@@ -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)",

View File

@@ -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"]

View File

@@ -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"
}

View File

@@ -4,11 +4,10 @@ use std::hash::{SipHasher, Hash, Hasher};
use std::thread::sleep;
use std::cmp::min;
use std::error::Error;
use std::fmt;
use std::convert::From;
use authenticator_delegate::{AuthenticatorDelegate, PollError, PollInformation};
use types::{RequestError, Token, FlowType, ApplicationSecret};
use types::{RequestError, StringError, Token, FlowType, ApplicationSecret};
use device::DeviceFlow;
use installed::{InstalledFlow, InstalledFlowReturnMethod};
use refresh::{RefreshResult, RefreshFlow};
@@ -41,47 +40,6 @@ pub struct Authenticator<D, S, C> {
secret: ApplicationSecret,
}
#[derive(Debug)]
struct StringError {
error: String,
}
impl fmt::Display for StringError {
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
self.description().fmt(f)
}
}
impl StringError {
fn new(error: String, desc: Option<&String>) -> StringError {
let mut error = error;
if let Some(d) = desc {
error.push_str(": ");
error.push_str(&*d);
}
StringError { error: error }
}
}
impl<'a> From<&'a Error> for StringError {
fn from(err: &'a Error) -> StringError {
StringError::new(err.description().to_string(), None)
}
}
impl From<String> for StringError {
fn from(value: String) -> StringError {
StringError::new(value, None)
}
}
impl Error for StringError {
fn description(&self) -> &str {
&self.error
}
}
/// A provider for authorization tokens, yielding tokens valid for a given scope.
/// The `api_key()` method is an alternative in case there are no scopes or
/// if no user is involved.

View File

@@ -8,21 +8,26 @@
// Refer to the project root for licensing information.
use serde_json;
use std::io;
use std::fs;
use std::io::{self, Read};
use std::fs;
use std::path::Path;
use service_account::ServiceAccountKey;
use types::{ConsoleApplicationSecret, ApplicationSecret};
pub fn read_application_secret(file: &String) -> io::Result<ApplicationSecret> {
/// Read an application secret from a file.
pub fn read_application_secret(path: &Path) -> io::Result<ApplicationSecret> {
use std::io::Read;
let mut secret = String::new();
let mut file = try!(fs::OpenOptions::new().read(true).open(file));
let mut file = try!(fs::OpenOptions::new().read(true).open(path));
try!(file.read_to_string(&mut secret));
parse_application_secret(&secret)
}
/// Read an application secret from a JSON string.
pub fn parse_application_secret(secret: &String) -> io::Result<ApplicationSecret> {
let result: serde_json::Result<ConsoleApplicationSecret> = serde_json::from_str(secret);
match result {
@@ -42,3 +47,16 @@ pub fn parse_application_secret(secret: &String) -> io::Result<ApplicationSecret
}
}
}
/// Read a service account key from a JSON file. You can download the JSON keys from the Google
/// Cloud Console or the respective console of your service provider.
pub fn service_account_key_from_file(path: &String) -> io::Result<ServiceAccountKey> {
let mut key = String::new();
let mut file = try!(fs::OpenOptions::new().read(true).open(path));
try!(file.read_to_string(&mut key));
match serde_json::from_str(&key) {
Err(e) => Err(io::Error::new(io::ErrorKind::InvalidData, format!("{}", e))),
Ok(decoded) => Ok(decoded),
}
}

View File

@@ -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;
@@ -30,3 +31,4 @@ pub use authenticator::{Authenticator, Retry, GetToken};
pub use authenticator_delegate::{AuthenticatorDelegate, DefaultAuthenticatorDelegate, PollError,
PollInformation};
pub use helper::*;
pub use service_account::*;

323
src/service_account.rs Normal file
View File

@@ -0,0 +1,323 @@
//! 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<T: AsRef<[u8]>>(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<u8> {
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<openssl::crypto::rsa::RSA, Box<error::Error>> {
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<String>,
pub project_id: Option<String>,
pub private_key_id: Option<String>,
pub private_key: Option<String>,
pub client_email: Option<String>,
pub client_id: Option<String>,
pub auth_uri: Option<String>,
pub token_uri: Option<String>,
pub auth_provier_x509_cert_url: Option<String>,
pub client_x509_cert_url: Option<String>,
}
#[derive(Serialize, Debug)]
struct Claims {
iss: String,
aud: String,
exp: i64,
iat: i64,
sub: Option<String>,
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<String, Box<error::Error>> {
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<str> + 'a,
I: IntoIterator<Item = &'a T>
{
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<C> {
client: C,
key: ServiceAccountKey,
cache: MemoryStorage,
}
/// This is the schema of the server's response.
#[derive(Deserialize, Debug)]
struct TokenResponse {
access_token: Option<String>,
token_type: Option<String>,
expires_in: Option<i64>,
}
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<C>
where C: BorrowMut<hyper::Client>
{
/// Returns a new `ServiceAccountAccess` token source.
#[allow(dead_code)]
pub fn new(key: ServiceAccountKey, client: C) -> ServiceAccountAccess<C> {
ServiceAccountAccess {
client: client,
key: key,
cache: MemoryStorage::default(),
}
}
fn request_token(&mut self, scopes: &Vec<&str>) -> result::Result<Token, Box<error::Error>> {
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));
let token: Result<TokenResponse, serde_json::error::Error> =
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<C: BorrowMut<hyper::Client>> GetToken for ServiceAccountAccess<C> {
fn token<'b, I, T>(&mut self, scopes: I) -> result::Result<Token, Box<error::Error>>
where T: AsRef<str> + Ord + 'b,
I: IntoIterator<Item = &'b T>
{
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<String> {
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");
}
}

View File

@@ -9,6 +9,7 @@ use std::collections::HashMap;
use std::error::Error;
use std::fmt;
use std::fs;
use std::hash::{SipHasher, Hash, Hasher};
use std::io;
use std::io::{Read, Write};
@@ -33,6 +34,21 @@ pub trait TokenStorage {
fn get(&self, scope_hash: u64, scopes: &Vec<&str>) -> Result<Option<Token>, Self::Error>;
}
/// Calculate a hash value describing the scopes, and return a sorted Vec of the scopes.
pub fn hash_scopes<'a, I, T>(scopes: I) -> (u64, Vec<&'a str>)
where T: AsRef<str> + Ord + 'a,
I: IntoIterator<Item = &'a T>
{
let mut sv: Vec<&str> = scopes.into_iter()
.map(|s| s.as_ref())
.collect::<Vec<&str>>();
sv.sort();
let mut sh = SipHasher::new();
&sv.hash(&mut sh);
let sv = sv;
(sh.finish(), sv)
}
/// A storage that remembers nothing.
#[derive(Default)]
pub struct NullStorage;

View File

@@ -1,4 +1,5 @@
use chrono::{DateTime, UTC, TimeZone};
use std::error::Error;
use std::fmt;
use std::str::FromStr;
use hyper;
@@ -59,6 +60,47 @@ impl fmt::Display for RequestError {
}
}
#[derive(Debug)]
pub struct StringError {
error: String,
}
impl fmt::Display for StringError {
fn fmt(&self, f: &mut fmt::Formatter) -> Result<(), fmt::Error> {
self.description().fmt(f)
}
}
impl StringError {
pub fn new(error: String, desc: Option<&String>) -> StringError {
let mut error = error;
if let Some(d) = desc {
error.push_str(": ");
error.push_str(&*d);
}
StringError { error: error }
}
}
impl<'a> From<&'a Error> for StringError {
fn from(err: &'a Error) -> StringError {
StringError::new(err.description().to_string(), None)
}
}
impl From<String> for StringError {
fn from(value: String) -> StringError {
StringError::new(value, None)
}
}
impl Error for StringError {
fn description(&self) -> &str {
&self.error
}
}
/// Represents all implemented token types
#[derive(Clone, PartialEq, Debug)]
@@ -158,7 +200,7 @@ impl Token {
/// # Panics
/// * if our access_token is unset
pub fn expired(&self) -> bool {
if self.access_token.len() == 0 || self.refresh_token.len() == 0 {
if self.access_token.len() == 0 {
panic!("called expired() on unset token");
}
self.expiry_date() <= UTC::now()