blob: ab9c04157ccadf89690a4a882648d114077fc7d3 [file] [log] [blame]
// Copyright 2018 The Fuchsia Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
//! AuthCache manages an in-memory cache of recently used short-lived authentication tokens.
#![deny(warnings)]
#![deny(missing_docs)]
use failure::{format_err, Error, Fail};
use std::borrow::Cow;
use std::collections::HashMap;
use std::ops::Deref;
use std::sync::Arc;
use std::time::{Duration, SystemTime};
/// Constant offset for the cache expiry. Tokens will only be returned from
/// the cache if they have at least this much life remaining.
const PADDING_FOR_TOKEN_EXPIRY: Duration = Duration::from_secs(600);
/// An enumeration of the possile failure modes for operations on a `TokenCache`.
#[derive(Debug, PartialEq, Eq, Fail)]
pub enum AuthCacheError {
/// One or more inputs were not valid.
#[fail(display = "invalid argument")]
InvalidArguments,
/// The supplied key could not be found in the cache.
#[fail(display = "supplied key not found in cache")]
KeyNotFound,
}
/// Representation of a single OAuth token including its expiry time.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct OAuthToken {
expiry_time: SystemTime,
token: String,
}
impl OAuthToken {
/// Gets the `SystemTime` at which the token will no longer be valid.
pub fn expiry_time(&self) -> &SystemTime {
&self.expiry_time
}
}
impl Deref for OAuthToken {
type Target = str;
fn deref(&self) -> &str {
&*self.token
}
}
impl From<fidl_fuchsia_auth::AuthToken> for OAuthToken {
fn from(auth_token: fidl_fuchsia_auth::AuthToken) -> OAuthToken {
OAuthToken {
expiry_time: SystemTime::now() + Duration::from_secs(auth_token.expires_in),
token: auth_token.token,
}
}
}
/// Representation of a single Firebase token including its expiry time.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FirebaseAuthToken {
id_token: String,
local_id: Option<String>,
email: Option<String>,
expiry_time: SystemTime,
}
impl From<fidl_fuchsia_auth::FirebaseToken> for FirebaseAuthToken {
fn from(firebase_token: fidl_fuchsia_auth::FirebaseToken) -> FirebaseAuthToken {
FirebaseAuthToken {
id_token: firebase_token.id_token,
local_id: firebase_token.local_id,
email: firebase_token.email,
expiry_time: SystemTime::now() + Duration::from_secs(firebase_token.expires_in),
}
}
}
impl FirebaseAuthToken {
/// Returns a new FIDL `FirebaseToken` using data cloned from our
/// internal representation.
pub fn to_fidl(&self) -> fidl_fuchsia_auth::FirebaseToken {
fidl_fuchsia_auth::FirebaseToken {
id_token: self.id_token.clone(),
local_id: self.local_id.clone(),
email: self.email.clone(),
expires_in: match self.expiry_time.duration_since(SystemTime::now()) {
Ok(duration) => duration.as_secs(),
Err(_) => 0,
},
}
}
}
/// A collection of OAuth and Firebase tokens associated with an identity
/// provider and profile. Any combination of ID tokens, access tokens,
/// and Firebase tokens may be present in a cache entry.
#[derive(Clone, Debug, PartialEq, Eq)]
struct TokenSet {
/// A map from audience strings to cached OAuth ID tokens.
id_token_map: HashMap<String, Arc<OAuthToken>>,
/// A map from concatenatations of OAuth scope strings to cached OAuth Access
/// tokens.
access_token_map: HashMap<String, Arc<OAuthToken>>,
/// A map from firebase API keys to cached Firebase tokens.
firebase_token_map: HashMap<String, Arc<FirebaseAuthToken>>,
}
impl TokenSet {
/// Constructs a new, empty, `TokenSet`.
fn new() -> Self {
TokenSet {
id_token_map: HashMap::new(),
access_token_map: HashMap::new(),
firebase_token_map: HashMap::new(),
}
}
/// Returns true iff this set contains at least one valid OAuth or Firebase
/// token.
fn is_valid(&self) -> bool {
!self.id_token_map.is_empty()
|| !self.access_token_map.is_empty()
|| !self.firebase_token_map.is_empty()
}
/// Removes any expired OAuth and Firebase tokens from this set and
/// returns true iff no valid OAuth tokens remain.
fn remove_expired_tokens(&mut self) -> bool {
let current_time = SystemTime::now();
self.id_token_map
.retain(|_, v| current_time <= (v.expiry_time - PADDING_FOR_TOKEN_EXPIRY));
self.access_token_map
.retain(|_, v| current_time <= (v.expiry_time - PADDING_FOR_TOKEN_EXPIRY));
self.firebase_token_map
.retain(|_, v| current_time <= (v.expiry_time - PADDING_FOR_TOKEN_EXPIRY));
!self.is_valid()
}
}
/// A unique key for accessing an entry in the token cache.
#[derive(Clone, Hash, PartialEq, Eq)]
pub struct CacheKey {
idp_provider: String,
idp_credential_id: String,
}
impl CacheKey {
/// Create a new `CacheKey`, or returns `Error` if any input is empty.
pub fn new(idp_provider: String, idp_credential_id: String) -> Result<CacheKey, Error> {
if idp_provider.is_empty() {
Err(format_err!("idp_provider cannot be empty"))
} else if idp_credential_id.is_empty() {
Err(format_err!("idp_credential_id cannot be empty"))
} else {
Ok(CacheKey {
idp_provider,
idp_credential_id,
})
}
}
}
/// A cache of recently used OAuth and Firebase tokens. All tokens contain
/// an expiry time and are removed when this time is reached.
pub struct TokenCache {
// TODO(jsankey): Define and enforce a max size on the number of cache
// entries
map: HashMap<CacheKey, TokenSet>,
}
impl TokenCache {
/// Creates a new `TokenCache` with the specified initial size.
pub fn new(size: usize) -> TokenCache {
TokenCache {
map: HashMap::with_capacity(size),
}
}
/// Returns the OAuth ID token for the specified `cache_key` and `audience` if present and
/// not expired. This will cause any expired tokens on the same key to
/// be purged from the underlying cache.
pub fn get_id_token(
&mut self, cache_key: &CacheKey, audience: &str,
) -> Option<Arc<OAuthToken>> {
self.get_token_set(cache_key)
.and_then(|ts| ts.id_token_map.get(audience).map(|t| t.clone()))
}
/// Returns an OAuth Access token for the specified `cache_key` and `scopes`,
/// if present and not expired. This will cause any expired tokens on
/// the same key to be purged from the underlying cache.
pub fn get_access_token<T: Deref<Target = str>>(
&mut self, cache_key: &CacheKey, scopes: &[T],
) -> Option<Arc<OAuthToken>> {
self.get_token_set(cache_key).and_then(|ts| {
ts.access_token_map
.get(&*Self::scope_key(scopes))
.map(|t| t.clone())
})
}
/// Returns a Firebase token for the specified `cache_key` and `firebase_api_key`, if present
/// and not expired. This will cause any expired tokens on the same key to
/// be purged from the underlying cache.
pub fn get_firebase_token(
&mut self, cache_key: &CacheKey, firebase_api_key: &str,
) -> Option<Arc<FirebaseAuthToken>> {
self.get_token_set(cache_key).and_then(|ts| {
ts.firebase_token_map
.get(firebase_api_key)
.map(|t| t.clone())
})
}
/// Returns the set of unexpired tokens stored in the cache for the given
/// `cache_key`. Any expired tokens are also purged from the underlying cache.
fn get_token_set(&mut self, cache_key: &CacheKey) -> Option<&TokenSet> {
// First remove any expired tokens from the value if it exists then
// delete the entire entry if this now means it is invalid.
let mut expired_token_set = false;
if let Some(token_set) = self.map.get_mut(cache_key) {
expired_token_set = token_set.remove_expired_tokens();
}
if expired_token_set {
self.map.remove(cache_key);
return None;
}
// Any remaining key is now valid
self.map.get(cache_key)
}
/// Adds an OAuth ID token to the cache, replacing any existing token for the same `cache_key`
/// and `audience`.
pub fn put_id_token(&mut self, cache_key: CacheKey, audience: String, token: Arc<OAuthToken>) {
self.put_token(cache_key, |ts| {
ts.id_token_map.insert(audience, token);
});
}
/// Adds an OAuth Access token to the cache, replacing any existing token for the same
/// `cache_key` and `scopes`.
pub fn put_access_token<T: Deref<Target = str>>(
&mut self, cache_key: CacheKey, scopes: &[T], token: Arc<OAuthToken>,
) {
self.put_token(cache_key, |ts| {
ts.access_token_map
.insert(Self::scope_key(scopes).into_owned(), token);
});
}
/// Adds a Firebase token to the cache, replacing any existing token for
/// the same `cache_key` and `firebase_api_key`.
pub fn put_firebase_token(
&mut self, cache_key: CacheKey, firebase_api_key: String, token: Arc<FirebaseAuthToken>,
) {
self.put_token(cache_key, |ts| {
ts.firebase_token_map.insert(firebase_api_key, token);
});
}
/// Adds a token to the cache, using a supplied fn to perform the token set
/// manipulation.
fn put_token<F>(&mut self, cache_key: CacheKey, update_fn: F)
where
F: FnOnce(&mut TokenSet),
{
if let Some(token_set) = self.map.get_mut(&cache_key) {
update_fn(token_set);
return;
}
let mut token_set = TokenSet::new();
update_fn(&mut token_set);
self.map.insert(cache_key, token_set);
}
/// Removes all tokens associated with the supplied `cache_key`, returning an error
/// if none exist.
pub fn delete(&mut self, cache_key: &CacheKey) -> Result<(), AuthCacheError> {
if !self.map.contains_key(cache_key) {
Err(AuthCacheError::KeyNotFound)
} else {
self.map.remove(cache_key);
Ok(())
}
}
/// Returns true iff the supplied `cache_key` is present in this cache.
pub fn has_key(&self, cache_key: &CacheKey) -> bool {
self.map.contains_key(cache_key)
}
/// Constructs an access token hashing key based on a vector of OAuth scope
/// strings.
fn scope_key<'a, T: Deref<Target = str>>(scopes: &'a [T]) -> Cow<'a, str> {
// Use the scope strings concatenated with a newline as the key. Note that this
// is order dependent; a client that reqested the same scopes with two
// different orders would create two cache entries. We argue that the
// harm of this is limited compared to the cost of sorting scopes to
// create a canonical ordering on every access. Most clients are likely
// to use a consistent order anyway and we request this behaviour in the
// interface. TODO(jsankey): Consider a zero-copy solution for the
// simple case of a single scope.
match scopes.len() {
0 => Cow::Borrowed(""),
1 => Cow::Borrowed(scopes.first().unwrap()),
_ => Cow::Owned(scopes.iter().fold(String::new(), |acc, el| {
let sep = if acc.is_empty() { "" } else { "\n" };
acc + sep + el
})),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use fidl_fuchsia_auth::TokenType;
const CACHE_SIZE: usize = 3;
const TEST_IDP: &str = "test.com";
const TEST_CREDENTIAL_ID: &str = "test.com/profiles/user";
const TEST_EMAIL: &str = "user@test.com";
const TEST_AUDIENCE: &str = "test_audience";
const TEST_SCOPE_A: &str = "test_scope_a";
const TEST_SCOPE_B: &str = "test_scope_b";
const TEST_ID_TOKEN: &str = "ID token for test user";
const TEST_ACCESS_TOKEN: &str = "Access token for test user";
const TEST_API_KEY: &str = "Test API key";
const TEST_FIREBASE_ID_TOKEN: &str = "Firebase token for test user";
const TEST_FIREBASE_LOCAL_ID: &str = "Local ID for test firebase token";
const LONG_EXPIRY: Duration = Duration::from_secs(3000);
const ALREADY_EXPIRED: Duration = Duration::from_secs(1);
fn build_test_cache_key(suffix: &str) -> CacheKey {
CacheKey {
idp_provider: TEST_IDP.to_string(),
idp_credential_id: TEST_CREDENTIAL_ID.to_string() + suffix,
}
}
fn build_test_id_token(time_until_expiry: Duration, suffix: &str) -> Arc<OAuthToken> {
Arc::new(OAuthToken {
expiry_time: SystemTime::now() + time_until_expiry,
token: TEST_ID_TOKEN.to_string() + suffix,
})
}
fn build_test_access_token(time_until_expiry: Duration, suffix: &str) -> Arc<OAuthToken> {
Arc::new(OAuthToken {
expiry_time: SystemTime::now() + time_until_expiry,
token: TEST_ACCESS_TOKEN.to_string() + suffix,
})
}
fn build_test_firebase_token(
time_until_expiry: Duration, suffix: &str,
) -> Arc<FirebaseAuthToken> {
Arc::new(FirebaseAuthToken {
expiry_time: SystemTime::now() + time_until_expiry,
id_token: TEST_FIREBASE_ID_TOKEN.to_string() + suffix,
local_id: Some(TEST_FIREBASE_LOCAL_ID.to_string() + suffix),
email: Some(TEST_EMAIL.to_string()),
})
}
#[test]
fn test_oauth_from_fidl() {
let fidl_type = fidl_fuchsia_auth::AuthToken {
token_type: TokenType::AccessToken,
expires_in: LONG_EXPIRY.as_secs(),
token: TEST_ACCESS_TOKEN.to_string(),
};
let time_before_conversion = SystemTime::now();
let native_type = OAuthToken::from(fidl_type);
let time_after_conversion = SystemTime::now();
assert_eq!(&native_type.token, TEST_ACCESS_TOKEN);
assert!(native_type.expiry_time >= time_before_conversion + LONG_EXPIRY);
assert!(native_type.expiry_time <= time_after_conversion + LONG_EXPIRY);
// Also verify our implementation of the Deref trait
assert_eq!(&*native_type, TEST_ACCESS_TOKEN);
}
#[test]
fn test_firebase_from_fidl() {
let fidl_type = fidl_fuchsia_auth::FirebaseToken {
id_token: TEST_FIREBASE_ID_TOKEN.to_string(),
local_id: Some(TEST_FIREBASE_LOCAL_ID.to_string()),
email: Some(TEST_EMAIL.to_string()),
expires_in: LONG_EXPIRY.as_secs(),
};
let time_before_conversion = SystemTime::now();
let native_type = FirebaseAuthToken::from(fidl_type);
let time_after_conversion = SystemTime::now();
assert_eq!(&native_type.id_token, TEST_FIREBASE_ID_TOKEN);
assert_eq!(
native_type.local_id,
Some(TEST_FIREBASE_LOCAL_ID.to_string())
);
assert_eq!(native_type.email, Some(TEST_EMAIL.to_string()));
assert!(native_type.expiry_time >= time_before_conversion + LONG_EXPIRY);
assert!(native_type.expiry_time <= time_after_conversion + LONG_EXPIRY);
}
#[test]
fn test_firebase_to_fidl() {
let time_before_conversion = SystemTime::now();
let native_type = FirebaseAuthToken {
id_token: TEST_FIREBASE_ID_TOKEN.to_string(),
local_id: Some(TEST_FIREBASE_LOCAL_ID.to_string()),
email: Some(TEST_EMAIL.to_string()),
expiry_time: time_before_conversion + LONG_EXPIRY,
};
let fidl_type = native_type.to_fidl();
let elapsed_time_during_conversion = SystemTime::now()
.duration_since(time_before_conversion)
.unwrap();
assert_eq!(&fidl_type.id_token, TEST_FIREBASE_ID_TOKEN);
assert_eq!(fidl_type.local_id, Some(TEST_FIREBASE_LOCAL_ID.to_string()));
assert_eq!(fidl_type.email, Some(TEST_EMAIL.to_string()));
assert!(fidl_type.expires_in <= LONG_EXPIRY.as_secs());
assert!(
fidl_type.expires_in
>= (LONG_EXPIRY.as_secs() - elapsed_time_during_conversion.as_secs()) - 1
);
}
#[test]
fn test_get_and_put_id_token() {
let mut token_cache = TokenCache::new(CACHE_SIZE);
// Verify requesting an entry from an cache that does not contain it fails.
let key = build_test_cache_key("");
assert_eq!(token_cache.get_id_token(&key, TEST_AUDIENCE), None);
// Verify inserting then retrieving a token succeeds.
let token_1 = build_test_id_token(LONG_EXPIRY, "1");
token_cache.put_id_token(key.clone(), TEST_AUDIENCE.to_string(), token_1.clone());
assert_eq!(
token_cache.get_id_token(&key, TEST_AUDIENCE),
Some(token_1.clone())
);
// Verify a second token on a different audience can be stored in the key
// without conflict.
let audience_2 = "";
let token_2 = build_test_id_token(LONG_EXPIRY, "2");
assert_eq!(token_cache.get_id_token(&key, audience_2), None);
token_cache.put_id_token(key.clone(), audience_2.to_string(), token_2.clone());
assert_eq!(token_cache.get_id_token(&key, audience_2), Some(token_2));
}
#[test]
fn test_get_and_put_access_token() {
let mut token_cache = TokenCache::new(CACHE_SIZE);
// Verify requesting an entry from an cache that does not contain it fails.
let key = build_test_cache_key("");
let scopes = vec![TEST_SCOPE_A, TEST_SCOPE_B];
assert_eq!(token_cache.get_access_token(&key, &scopes), None);
// Verify inserting then retrieving a token succeeds.
let token_1 = build_test_access_token(LONG_EXPIRY, "1");
token_cache.put_access_token(key.clone(), &scopes, token_1.clone());
assert_eq!(
token_cache.get_access_token(&key, &scopes),
Some(token_1.clone())
);
// We don't create a canonical ordering of scopes, so can store a different
// token with the same scopes in reverse order.
let reversed_scopes = vec![TEST_SCOPE_B, TEST_SCOPE_A];
let token_2 = build_test_id_token(LONG_EXPIRY, "2");
token_cache.put_access_token(key.clone(), &reversed_scopes, token_2.clone());
assert_eq!(
token_cache.get_access_token(&key, &reversed_scopes),
Some(token_2)
);
// Check that storing with a single scope and an empty scope vector also work.
let single_scope = vec![TEST_SCOPE_A];
let token_3 = build_test_id_token(LONG_EXPIRY, "3");
token_cache.put_access_token(key.clone(), &single_scope, token_3.clone());
assert_eq!(
token_cache.get_access_token(&key, &single_scope),
Some(token_3)
);
let no_scopes: Vec<String> = vec![];
let token_4 = build_test_id_token(LONG_EXPIRY, "4");
token_cache.put_access_token(key.clone(), &no_scopes, token_4.clone());
assert_eq!(
token_cache.get_access_token(&key, &no_scopes),
Some(token_4)
);
// And finally check that we didn't dork up the original entry.
assert_eq!(
token_cache.get_access_token(&key, &scopes),
Some(token_1.clone())
);
}
#[test]
fn test_has_key() {
let mut token_cache = TokenCache::new(CACHE_SIZE);
let key = build_test_cache_key("");
assert_eq!(token_cache.has_key(&key), false);
token_cache.put_id_token(
key.clone(),
TEST_AUDIENCE.to_string(),
build_test_id_token(LONG_EXPIRY, ""),
);
assert_eq!(token_cache.has_key(&key), true);
}
#[test]
fn test_delete() {
let mut token_cache = TokenCache::new(CACHE_SIZE);
let key = build_test_cache_key("");
assert_eq!(token_cache.delete(&key), Err(AuthCacheError::KeyNotFound));
token_cache.put_access_token(
key.clone(),
&vec![TEST_SCOPE_A],
build_test_access_token(LONG_EXPIRY, ""),
);
assert_eq!(token_cache.has_key(&key), true);
assert_eq!(token_cache.delete(&key), Ok(()));
assert_eq!(token_cache.has_key(&key), false);
}
#[test]
fn test_remove_oauth_on_expiry() {
let mut token_cache = TokenCache::new(CACHE_SIZE);
// Insert one entry thats already expired and one that hasn't.
let scopes = vec![TEST_SCOPE_A, TEST_SCOPE_B];
let key_1 = build_test_cache_key("1");
let access_token_1 = build_test_access_token(LONG_EXPIRY, "1");
token_cache.put_access_token(key_1.clone(), &scopes, access_token_1.clone());
let key_2 = build_test_cache_key("2");
let access_token_2 = build_test_access_token(ALREADY_EXPIRED, "2");
token_cache.put_access_token(key_2.clone(), &scopes, access_token_2.clone());
// Both keys should be present.
assert_eq!(token_cache.has_key(&key_1), true);
assert_eq!(token_cache.has_key(&key_2), true);
// Getting the expired key should fail and remove it from the cache.
assert_eq!(
token_cache.get_access_token(&key_1, &scopes),
Some(access_token_1)
);
assert_eq!(token_cache.get_access_token(&key_2, &scopes), None);
assert_eq!(token_cache.has_key(&key_1), true);
assert_eq!(token_cache.has_key(&key_2), false);
}
#[test]
fn test_get_and_put_firebase_token() {
let mut token_cache = TokenCache::new(CACHE_SIZE);
// Create a new entry in the cache without any firebase tokens.
let key = build_test_cache_key("1");
token_cache.put_id_token(
key.clone(),
TEST_AUDIENCE.to_string(),
build_test_access_token(LONG_EXPIRY, ""),
);
assert_eq!(token_cache.get_firebase_token(&key, TEST_API_KEY), None);
// Add a firebase token and verify it can be retreived.
let firebase_token = build_test_firebase_token(LONG_EXPIRY, "");
token_cache.put_firebase_token(
key.clone(),
TEST_API_KEY.to_string(),
firebase_token.clone(),
);
assert_eq!(
token_cache.get_firebase_token(&key, TEST_API_KEY),
Some(firebase_token)
);
}
#[test]
fn test_remove_firebase_on_expiry() {
let mut token_cache = TokenCache::new(CACHE_SIZE);
// Create a new entry in the cache without any firebase tokens.
let key = build_test_cache_key("1");
// Add two firebase tokens, one expired, one not.
let firebase_token_1 = build_test_firebase_token(LONG_EXPIRY, "1");
let firebase_api_key_1 = TEST_API_KEY.to_string() + "1";
token_cache.put_firebase_token(
key.clone(),
firebase_api_key_1.clone(),
firebase_token_1.clone(),
);
let firebase_token_2 = build_test_firebase_token(ALREADY_EXPIRED, "2");
let firebase_api_key_2 = TEST_API_KEY.to_string() + "2";
token_cache.put_firebase_token(
key.clone(),
firebase_api_key_2.clone(),
firebase_token_2.clone(),
);
// Verify only the not expired token is accessible.
assert_eq!(
token_cache.get_firebase_token(&key, &firebase_api_key_1),
Some(firebase_token_1)
);
assert_eq!(
token_cache.get_firebase_token(&key, &firebase_api_key_2),
None
);
}
}