diff --git a/src/identity/lib/BUILD.gn b/src/identity/lib/BUILD.gn index 543898d75dd378be59c249ac8d8e7e86ff81e727..72d23bfda5de5a8c3467f4a89faaa59d1648e51a 100644 --- a/src/identity/lib/BUILD.gn +++ b/src/identity/lib/BUILD.gn @@ -24,6 +24,7 @@ test_package("identity_lib_unittests") { "oauth:oauth_unittests", "token_cache:token_cache", "token_store:token_store", + "token_manager:token_manager", ] tests = [ @@ -39,5 +40,9 @@ test_package("identity_lib_unittests") { name = "oauth_unittests" environments = basic_envs }, + { + name = "token_manager_lib_test" + environments = basic_envs + } ] } diff --git a/src/identity/lib/meta/token_manager_lib_test.cmx b/src/identity/lib/meta/token_manager_lib_test.cmx new file mode 100644 index 0000000000000000000000000000000000000000..02ec2f695d5628b330bdb0b219c7a8dd5c2312f6 --- /dev/null +++ b/src/identity/lib/meta/token_manager_lib_test.cmx @@ -0,0 +1,8 @@ +{ + "program": { + "binary": "test/token_manager_lib_test" + }, + "sandbox": { + "services": [] + } +} diff --git a/src/identity/lib/token_cache/src/lib.rs b/src/identity/lib/token_cache/src/lib.rs index 9dcd90498ca70b749fbab14f5da60f4a92f2228a..3830dabd7255eff341bb5eadc9539458b8441140 100644 --- a/src/identity/lib/token_cache/src/lib.rs +++ b/src/identity/lib/token_cache/src/lib.rs @@ -8,11 +8,11 @@ use chrono::offset::Utc; use chrono::DateTime; -use failure::{format_err, Error, Fail}; -use log::warn; -use std::borrow::Cow; +use failure::Fail; +use log::{info, warn}; +use std::any::Any; use std::collections::HashMap; -use std::ops::Deref; +use std::hash::{Hash, Hasher}; use std::sync::Arc; use std::time::{Duration, SystemTime}; @@ -31,151 +31,71 @@ pub enum AuthCacheError { KeyNotFound, } -/// Representation of a single OAuth token including its expiry time. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct OAuthToken { - expiry_time: SystemTime, - token: String, +/// Trait for keys used in the cache. `CacheKey` requires the `Any` trait for +/// dynamic typing. As `Any` is not implemented for any struct containing a +/// non-'static reference, any valid implementation of `CacheKey` may not +/// contain references of non-'static lifetimes. +pub trait CacheKey: Any + Send + Sync { + /// Returns the identity provider type, ex. 'google' + fn auth_provider_type(&self) -> &str; + /// Returns the account identifier as given by the identity provider. + fn user_profile_id(&self) -> &str; + /// Returns an identifier appropriate for differentiating CacheKeys of the + /// same concrete type with the same auth_provider_type and user_profile_id. + fn subkey(&self) -> &str; } -impl OAuthToken { - /// Gets the `SystemTime` at which the token will no longer be valid. - pub fn expiry_time(&self) -> &SystemTime { - &self.expiry_time +impl Hash for CacheKey { + fn hash<H: Hasher>(&self, state: &mut H) { + self.auth_provider_type().hash(state); + self.user_profile_id().hash(state); + self.subkey().hash(state); + self.type_id().hash(state); } } -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, - } +impl PartialEq for CacheKey { + fn eq(&self, other: &(CacheKey + 'static)) -> bool { + self.auth_provider_type() == other.auth_provider_type() + && self.user_profile_id() == other.user_profile_id() + && self.subkey() == other.subkey() + && self.type_id() == other.type_id() } } -/// 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 Eq for CacheKey {} -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, - }, - } - } +/// Trait that specifies the concrete token type a token key can put or +/// retrieve from the cache. +pub trait KeyFor { + /// Token type the key exclusively identifies. + type TokenType; } -/// 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 concatenations 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>>, +/// Trait for tokens stored in the cache. `CacheToken` requires the `Any` +/// trait for dynamic typing. As `Any` is not implemented for any struct +/// containing a non-'static reference, any valid implementation of +/// `CacheToken` may not contain references of non-'static lifetimes. +pub trait CacheToken: Any + Send + Sync { + /// Returns the time at which a token becomes invalid. + fn expiry_time(&self) -> &SystemTime; } -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. +/// A cache of recently used authentication 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 + // TODO(satsukiu): Define and enforce a max size on the number of cache // entries - map: HashMap<CacheKey, TokenSet>, + /// A mapping holding cached tokens of arbitrary types. + token_map: HashMap<Box<CacheKey>, Arc<Any + Send + Sync>>, + /// `SystemTime` of the last cache operation. Used to validate time progression. last_time: SystemTime, } impl TokenCache { /// Creates a new `TokenCache` with the specified initial size. pub fn new(size: usize) -> TokenCache { - TokenCache { map: HashMap::with_capacity(size), last_time: SystemTime::now() } + TokenCache { token_map: HashMap::with_capacity(size), last_time: SystemTime::now() } } /// Sanity check that system time has not jumped backwards since last time this method was @@ -189,435 +109,300 @@ impl TokenCache { "time jumped backwards from {:?} to {:?}, clearing {:?} token cache entries", <DateTime<Utc>>::from(current_time), <DateTime<Utc>>::from(self.last_time), - self.map.len() + self.token_map.len(), ); - self.map.clear(); + self.token_map.clear(); } self.last_time = current_time; } - /// 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> { + /// Returns a token for the specified key if present and not expired. + pub fn get<K, V>(&mut self, key: &K) -> Option<Arc<V>> + where + K: CacheKey + KeyFor<TokenType = V>, + V: CacheToken, + { self.validate_time_progression(); - // 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) - } + let uncast_token = self.token_map.get(key as &CacheKey)?; - /// 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); - }); - } + let downcast_token = if let Ok(downcast_token) = uncast_token.clone().downcast::<V>() { + downcast_token + } else { + warn!("Error downcasting token in cache."); + return None; + }; - /// 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); - }); + if Self::is_token_expired(downcast_token.as_ref()) { + self.token_map.remove(key as &CacheKey); + None + } else { + Some(downcast_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) + /// Adds a token to the cache, replacing any existing token for the same key. + pub fn put<K, V>(&mut self, key: K, token: Arc<V>) where - F: FnOnce(&mut TokenSet), + K: CacheKey + KeyFor<TokenType = V>, + V: CacheToken, { self.validate_time_progression(); - 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); + self.token_map.insert(Box::new(key), token); } - /// 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> { + /// Deletes all the tokens associated with the given auth_provider_type and + /// user_profile_id. Returns an error if no matching keys are found. + pub fn delete_matching( + &mut self, + auth_provider_type: &str, + user_profile_id: &str, + ) -> Result<(), AuthCacheError> { self.validate_time_progression(); - if !self.map.contains_key(cache_key) { - Err(AuthCacheError::KeyNotFound) - } else { - self.map.remove(cache_key); + // TODO(satsukiu): evict expired tokens first. This gives consistent behavior when + // the only matching tokens are expired. + + let entries_before_delete = self.token_map.len(); + self.token_map.retain(|key, _| { + key.auth_provider_type() != auth_provider_type + || key.user_profile_id() != user_profile_id + }); + + let entries_after_delete = self.token_map.len(); + if entries_after_delete < entries_before_delete { + info!( + "Deleted {:?} matching entries from the token cache.", + entries_before_delete - entries_after_delete + ); Ok(()) + } else { + Err(AuthCacheError::KeyNotFound) } } - /// 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 requested 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 - })), - } + /// Returns true if the given token is expired. + fn is_token_expired(token: &CacheToken) -> bool { + let current_time = SystemTime::now(); + current_time > (*token.expiry_time() - PADDING_FOR_TOKEN_EXPIRY) } } #[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 TEST_AUTH_PROVIDER: &str = "test_auth_provider"; + const TEST_USER_ID: &str = "test_auth_provider/profiles/user"; + const TEST_TOKEN_CONTENTS: &str = "Token contents"; 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, - } + #[derive(Clone, Debug, PartialEq, Eq)] + struct TestToken { + expiry_time: SystemTime, + token: String, } - 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, - }) + impl CacheToken for TestToken { + fn expiry_time(&self) -> &SystemTime { + &self.expiry_time + } } - 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, - }) + #[derive(Clone, Debug, PartialEq, Eq)] + struct TestKey { + auth_provider_type: String, + user_profile_id: String, + audience: String, } - 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()), - }) + impl CacheKey for TestKey { + fn auth_provider_type(&self) -> &str { + &self.auth_provider_type + } + + fn user_profile_id(&self) -> &str { + &self.user_profile_id + } + + fn subkey(&self) -> &str { + &self.audience + } } - #[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(), - }; + impl KeyFor for TestKey { + type TokenType = TestToken; + } - let time_before_conversion = SystemTime::now(); - let native_type = OAuthToken::from(fidl_type); - let time_after_conversion = SystemTime::now(); + #[derive(Clone, Debug, PartialEq, Eq)] + struct AlternateTestToken { + expiry_time: SystemTime, + metadata: Vec<String>, + token: String, + } - 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); + impl CacheToken for AlternateTestToken { + fn expiry_time(&self) -> &SystemTime { + &self.expiry_time + } + } - // Also verify our implementation of the Deref trait - assert_eq!(&*native_type, TEST_ACCESS_TOKEN); + #[derive(Clone, Debug, PartialEq, Eq)] + struct AlternateTestKey { + auth_provider_type: String, + user_profile_id: String, + scopes: String, } - #[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(), - }; + impl CacheKey for AlternateTestKey { + fn auth_provider_type(&self) -> &str { + &self.auth_provider_type + } - let time_before_conversion = SystemTime::now(); - let native_type = FirebaseAuthToken::from(fidl_type); - let time_after_conversion = SystemTime::now(); + fn user_profile_id(&self) -> &str { + &self.user_profile_id + } - 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); + fn subkey(&self) -> &str { + &self.scopes + } } - #[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, - }; + impl KeyFor for AlternateTestKey { + type TokenType = AlternateTestToken; + } - 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 - ); + fn build_test_key(user_suffix: &str, audience: &str) -> TestKey { + TestKey { + auth_provider_type: TEST_AUTH_PROVIDER.to_string(), + user_profile_id: TEST_USER_ID.to_string() + user_suffix, + audience: audience.to_string(), + } } - #[test] - fn test_get_and_put_id_token() { - let mut token_cache = TokenCache::new(CACHE_SIZE); + fn build_alternate_test_key(user_suffix: &str, scopes: &str) -> AlternateTestKey { + AlternateTestKey { + auth_provider_type: TEST_AUTH_PROVIDER.to_string(), + user_profile_id: TEST_USER_ID.to_string() + user_suffix, + scopes: scopes.to_string(), + } + } - // 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); + fn build_test_token(time_until_expiry: Duration, suffix: &str) -> Arc<TestToken> { + Arc::new(TestToken { + expiry_time: SystemTime::now() + time_until_expiry, + token: TEST_TOKEN_CONTENTS.to_string() + suffix, + }) + } - // 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)); + fn build_alternate_test_token( + time_until_expiry: Duration, + suffix: &str, + ) -> Arc<AlternateTestToken> { + Arc::new(AlternateTestToken { + expiry_time: SystemTime::now() + time_until_expiry, + token: TEST_TOKEN_CONTENTS.to_string() + suffix, + metadata: vec![suffix.to_string()], + }) } #[test] - fn test_get_and_put_access_token() { + fn test_get_and_put_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); + let key_1 = build_test_key("", "audience_1"); + assert_eq!(token_cache.get(&key_1), 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())); - } + let token_1 = build_test_token(LONG_EXPIRY, "1"); + token_cache.put(key_1.clone(), token_1.clone()); + assert_eq!(token_cache.get(&key_1), 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); + // Verify a second token can be stored without conflict. + let key_2 = build_test_key("", "audience_2"); + let token_2 = build_test_token(LONG_EXPIRY, "2"); + assert_eq!(token_cache.get(&key_2), None); + token_cache.put(key_2.clone(), token_2.clone()); + assert_eq!(token_cache.get(&key_2), Some(token_2)); + assert_eq!(token_cache.get(&key_1), Some(token_1)); } #[test] - fn test_delete() { + fn test_get_and_put_multiple_token_types() { 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); + // Verify cache will get and put different datatypes + let key = build_test_key("", "audience_1"); + let token = build_test_token(LONG_EXPIRY, "1"); + token_cache.put(key.clone(), token.clone()); + let alternate_key = build_alternate_test_key("", "scope"); + let alternate_token = build_alternate_test_token(LONG_EXPIRY, "2"); + token_cache.put(alternate_key.clone(), alternate_token.clone()); + assert_eq!(token_cache.get(&key), Some(token)); + assert_eq!(token_cache.get(&alternate_key), Some(alternate_token)); - // Insert one entry that's 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); + // Verify keys of identical contents but different types do not clash. + let key_2 = build_test_key("", "clash-test"); + let token_2 = build_test_token(LONG_EXPIRY, "3"); + token_cache.put(key_2.clone(), token_2.clone()); + let alternate_key_2 = build_alternate_test_key("", "clash-test"); + let alternate_token_2 = build_alternate_test_token(LONG_EXPIRY, "4"); + token_cache.put(alternate_key_2.clone(), alternate_token_2.clone()); + + assert_eq!(token_cache.get(&key_2), Some(token_2)); + assert_eq!(token_cache.get(&alternate_key_2), Some(alternate_token_2)); } #[test] - fn test_get_and_put_firebase_token() { + fn test_delete_matching() { let mut token_cache = TokenCache::new(CACHE_SIZE); + let key = build_test_key("", "audience"); - // 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, ""), + // Verify deleting when cache is empty fails. + assert_eq!( + token_cache.delete_matching(key.auth_provider_type(), key.user_profile_id()), + Err(AuthCacheError::KeyNotFound) ); - assert_eq!(token_cache.get_firebase_token(&key, TEST_API_KEY), None); - - // Add a firebase token and verify it can be retrieved. - let firebase_token = build_test_firebase_token(LONG_EXPIRY, ""); - token_cache.put_firebase_token( - key.clone(), - TEST_API_KEY.to_string(), - firebase_token.clone(), + + // Verify matching keys are removed. + token_cache.put(key.clone(), build_test_token(LONG_EXPIRY, "")); + assert_eq!( + token_cache.delete_matching(key.auth_provider_type(), key.user_profile_id()), + Ok(()) ); - assert_eq!(token_cache.get_firebase_token(&key, TEST_API_KEY), Some(firebase_token)); + assert!(token_cache.get(&key).is_none()); + + // Verify non-matching keys are not removed. + let matching_key = build_test_key("matching", ""); + let non_matching_key = build_test_key("non-matching", ""); + let non_matching_token = build_test_token(LONG_EXPIRY, "non-matching"); + token_cache.put(matching_key.clone(), build_test_token(LONG_EXPIRY, "matching")); + token_cache.put(non_matching_key.clone(), non_matching_token.clone()); + assert_eq!( + token_cache + .delete_matching(matching_key.auth_provider_type(), matching_key.user_profile_id()), + Ok(()) + ); + assert!(token_cache.get(&matching_key).is_none()); + assert_eq!(token_cache.get(&non_matching_key), Some(non_matching_token)); } #[test] - fn test_remove_firebase_on_expiry() { + fn test_remove_expired_tokens() { 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); + // Insert one entry that's already expired and one that hasn't. + let key = build_test_key("valid", "audience_1"); + let token = build_test_token(LONG_EXPIRY, "1"); + token_cache.put(key.clone(), token.clone()); + let expired_key = build_alternate_test_key("expired", "scope"); + let expired_token = build_alternate_test_token(ALREADY_EXPIRED, "2"); + token_cache.put(expired_key.clone(), expired_token.clone()); + + // Getting the expired key should fail. + assert_eq!(token_cache.get(&key), Some(token)); + assert_eq!(token_cache.get(&expired_key), None); } } diff --git a/src/identity/lib/token_manager/BUILD.gn b/src/identity/lib/token_manager/BUILD.gn index 3b01b3951ed6ad91b80da0342a9c06042a03946a..b3884bda6118c11c8e991ac4a30f0c152ef24ec5 100644 --- a/src/identity/lib/token_manager/BUILD.gn +++ b/src/identity/lib/token_manager/BUILD.gn @@ -6,6 +6,7 @@ import("//build/rust/rustc_library.gni") rustc_library("token_manager") { edition = "2018" + with_unit_tests = true deps = [ "//garnet/public/lib/fidl/rust/fidl", diff --git a/src/identity/lib/token_manager/src/lib.rs b/src/identity/lib/token_manager/src/lib.rs index 9d60ccfb3185097e6eec89a3a9358be61b7e20ad..c8c20d952bbdf806da1a9600dff1865de4031bb1 100644 --- a/src/identity/lib/token_manager/src/lib.rs +++ b/src/identity/lib/token_manager/src/lib.rs @@ -22,6 +22,7 @@ use futures::future::FutureObj; mod auth_provider_connection; mod error; mod token_manager; +mod tokens; pub use crate::auth_provider_connection::AuthProviderConnection; pub use crate::error::{ResultExt, TokenManagerError}; diff --git a/src/identity/lib/token_manager/src/token_manager.rs b/src/identity/lib/token_manager/src/token_manager.rs index 4b312ea0e3e9fefd4b230911d8dfc3549e529fad..381d24f2fbd2c092994aa5d1654bfd01a526269e 100644 --- a/src/identity/lib/token_manager/src/token_manager.rs +++ b/src/identity/lib/token_manager/src/token_manager.rs @@ -2,6 +2,7 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +use crate::tokens::{AccessTokenKey, FirebaseAuthToken, FirebaseTokenKey, IdTokenKey, OAuthToken}; use crate::{AuthProviderSupplier, ResultExt, TokenManagerContext, TokenManagerError}; use failure::format_err; use fidl; @@ -18,7 +19,7 @@ use fidl_fuchsia_auth::{ use fuchsia_zircon as zx; use futures::prelude::*; use futures::try_join; -use identity_token_cache::{AuthCacheError, CacheKey, FirebaseAuthToken, OAuthToken, TokenCache}; +use identity_token_cache::{AuthCacheError, TokenCache}; use identity_token_store::file::AuthDbFile; use identity_token_store::{AuthDb, AuthDbError, CredentialKey, CredentialValue}; use log::{error, info, warn}; @@ -302,17 +303,20 @@ impl<T: AuthProviderSupplier> TokenManager<T> { user_profile_id: String, app_scopes: Vec<String>, ) -> TokenManagerResult<Arc<OAuthToken>> { - let (db_key, cache_key) = Self::create_keys(&app_config, &user_profile_id)?; - + let cache_key = AccessTokenKey::new( + app_config.auth_provider_type.clone(), + user_profile_id.clone(), + &app_scopes, + ) + .map_err(|_| TokenManagerError::new(Status::InvalidRequest))?; // Attempt to read the token from cache. - if let Some(cached_token) = - self.token_cache.lock().get_access_token(&cache_key, &app_scopes) - { + if let Some(cached_token) = self.token_cache.lock().get(&cache_key) { return Ok(cached_token); } // If no cached entry was found use an auth provider to mint a new one from the refresh // token, then place it in the cache. + let db_key = Self::create_db_key(&app_config, &user_profile_id)?; let refresh_token = self.get_refresh_token(&db_key)?; // TODO(ukode, jsankey): This iotid check against the auth_provider_type is brittle and is @@ -340,7 +344,7 @@ impl<T: AuthProviderSupplier> TokenManager<T> { user_profile_id: String, refresh_token: String, app_scopes: Vec<String>, - cache_key: CacheKey, + cache_key: AccessTokenKey, ) -> TokenManagerResult<Arc<OAuthToken>> { let auth_provider_type = &app_config.auth_provider_type; let auth_provider_proxy = await!(self.get_auth_provider_proxy(auth_provider_type))?; @@ -394,11 +398,7 @@ impl<T: AuthProviderSupplier> TokenManager<T> { // Cache access token let native_token = Arc::new(OAuthToken::from(*access_token)); - self.token_cache.lock().put_access_token( - cache_key, - &app_scopes, - Arc::clone(&native_token), - ); + self.token_cache.lock().put(cache_key, Arc::clone(&native_token)); // TODO(ukode): Cache auth_challenge Ok(native_token) @@ -414,7 +414,7 @@ impl<T: AuthProviderSupplier> TokenManager<T> { app_config: AppConfig, refresh_token: String, app_scopes: Vec<String>, - cache_key: CacheKey, + cache_key: AccessTokenKey, ) -> TokenManagerResult<Arc<OAuthToken>> { let auth_provider_type = &app_config.auth_provider_type; let auth_provider_proxy = await!(self.get_auth_provider_proxy(auth_provider_type))?; @@ -431,7 +431,7 @@ impl<T: AuthProviderSupplier> TokenManager<T> { let provider_token = provider_token.ok_or(TokenManagerError::from(status))?; let native_token = Arc::new(OAuthToken::from(*provider_token)); - self.token_cache.lock().put_access_token(cache_key, &app_scopes, Arc::clone(&native_token)); + self.token_cache.lock().put(cache_key, Arc::clone(&native_token)); Ok(native_token) } @@ -442,17 +442,22 @@ impl<T: AuthProviderSupplier> TokenManager<T> { user_profile_id: String, audience: Option<String>, ) -> TokenManagerResult<Arc<OAuthToken>> { - let (db_key, cache_key) = Self::create_keys(&app_config, &user_profile_id)?; let audience_str = audience.clone().unwrap_or("".to_string()); + let cache_key = IdTokenKey::new( + app_config.auth_provider_type.clone(), + user_profile_id.clone(), + audience_str, + ) + .map_err(|_| TokenManagerError::new(Status::InvalidRequest))?; // Attempt to read the token from cache. - if let Some(cached_token) = self.token_cache.lock().get_id_token(&cache_key, &audience_str) - { + if let Some(cached_token) = self.token_cache.lock().get(&cache_key) { return Ok(cached_token); } // If no cached entry was found use an auth provider to mint a new one from the refresh // token, then place it in the cache. + let db_key = Self::create_db_key(&app_config, &user_profile_id)?; let refresh_token = self.get_refresh_token(&db_key)?; let auth_provider_type = &app_config.auth_provider_type; let auth_provider_proxy = await!(self.get_auth_provider_proxy(auth_provider_type))?; @@ -466,7 +471,7 @@ impl<T: AuthProviderSupplier> TokenManager<T> { let provider_token = provider_token.ok_or(TokenManagerError::from(status))?; let native_token = Arc::new(OAuthToken::from(*provider_token)); - self.token_cache.lock().put_id_token(cache_key, audience_str, Arc::clone(&native_token)); + self.token_cache.lock().put(cache_key, Arc::clone(&native_token)); Ok(native_token) } @@ -478,11 +483,15 @@ impl<T: AuthProviderSupplier> TokenManager<T> { audience: String, api_key: String, ) -> TokenManagerResult<Arc<FirebaseAuthToken>> { - let (_, cache_key) = Self::create_keys(&app_config, &user_profile_id)?; + let cache_key = FirebaseTokenKey::new( + app_config.auth_provider_type.clone(), + user_profile_id.clone(), + api_key.clone(), + ) + .map_err(|_| TokenManagerError::new(Status::InvalidRequest))?; // Attempt to read the token from cache. - if let Some(cached_token) = self.token_cache.lock().get_firebase_token(&cache_key, &api_key) - { + if let Some(cached_token) = self.token_cache.lock().get(&cache_key) { return Ok(cached_token); } @@ -501,7 +510,7 @@ impl<T: AuthProviderSupplier> TokenManager<T> { })?; let provider_token = provider_token.ok_or(TokenManagerError::from(status))?; let native_token = Arc::new(FirebaseAuthToken::from(*provider_token)); - self.token_cache.lock().put_firebase_token(cache_key, api_key, Arc::clone(&native_token)); + self.token_cache.lock().put(cache_key, Arc::clone(&native_token)); Ok(native_token) } @@ -515,7 +524,7 @@ impl<T: AuthProviderSupplier> TokenManager<T> { user_profile_id: String, force: bool, ) -> TokenManagerResult<()> { - let (db_key, cache_key) = Self::create_keys(&app_config, &user_profile_id)?; + let db_key = Self::create_db_key(&app_config, &user_profile_id)?; // Try to find an associated refresh token, returning immediately with a success if we // can't. @@ -543,7 +552,7 @@ impl<T: AuthProviderSupplier> TokenManager<T> { } } - match self.token_cache.lock().delete(&cache_key) { + match self.token_cache.lock().delete_matching(&auth_provider_type, &user_profile_id) { Ok(()) | Err(AuthCacheError::KeyNotFound) => {} Err(err) => return Err(TokenManagerError::from(err)), } @@ -567,18 +576,15 @@ impl<T: AuthProviderSupplier> TokenManager<T> { .collect()) } - /// Returns index keys for referencing a token in both the database and cache. - fn create_keys( + /// Returns index keys for referencing a token in the database. + fn create_db_key( app_config: &AppConfig, user_profile_id: &String, - ) -> Result<(CredentialKey, CacheKey), TokenManagerError> { + ) -> Result<CredentialKey, TokenManagerError> { let db_key = CredentialKey::new(app_config.auth_provider_type.clone(), user_profile_id.clone()) .map_err(|_| TokenManagerError::new(Status::InvalidRequest))?; - let cache_key = - CacheKey::new(app_config.auth_provider_type.clone(), user_profile_id.clone()) - .map_err(|_| TokenManagerError::new(Status::InvalidRequest))?; - Ok((db_key, cache_key)) + Ok(db_key) } /// Returns an `AuthProviderProxy` for the specified `auth_provider_type` either by returning diff --git a/src/identity/lib/token_manager/src/tokens.rs b/src/identity/lib/token_manager/src/tokens.rs new file mode 100644 index 0000000000000000000000000000000000000000..619a431bb8f47ced4a022b29678726e75b93ae7b --- /dev/null +++ b/src/identity/lib/token_manager/src/tokens.rs @@ -0,0 +1,444 @@ +// Copyright 2019 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. + +use failure::{format_err, Error}; +use identity_token_cache::{CacheKey, CacheToken, KeyFor}; +use std::ops::Deref; +use std::time::{Duration, SystemTime}; + +/// Representation of a single OAuth token including its expiry time. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OAuthToken { + expiry_time: SystemTime, + token: String, +} + +impl CacheToken for OAuthToken { + 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 CacheToken for FirebaseAuthToken { + fn expiry_time(&self) -> &SystemTime { + &self.expiry_time + } +} + +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, + }, + } + } +} + +/// Key for storing OAuth access tokens in the token cache. +#[derive(Debug, PartialEq, Eq)] +pub struct AccessTokenKey { + auth_provider_type: String, + user_profile_id: String, + scopes: String, +} + +impl CacheKey for AccessTokenKey { + fn auth_provider_type(&self) -> &str { + &self.auth_provider_type + } + + fn user_profile_id(&self) -> &str { + &self.user_profile_id + } + + fn subkey(&self) -> &str { + &self.scopes + } +} + +impl KeyFor for AccessTokenKey { + type TokenType = OAuthToken; +} + +impl AccessTokenKey { + /// Create a new access token key. + pub fn new<T: Deref<Target = str>>( + auth_provider_type: String, + user_profile_id: String, + scopes: &[T], + ) -> Result<AccessTokenKey, Error> { + validate_provider_and_id(&auth_provider_type, &user_profile_id)?; + Ok(AccessTokenKey { + auth_provider_type: auth_provider_type, + user_profile_id: user_profile_id, + scopes: Self::combine_scopes(scopes), + }) + } + + fn combine_scopes<T: Deref<Target = str>>(scopes: &[T]) -> String { + // Use the scope strings concatenated with a newline as the key. Note that this + // is order dependent; a client that requested 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(satsukiu): Consider a zero-copy solution for the + // simple case of a single scope. + match scopes.len() { + 0 => String::from(""), + 1 => scopes.first().unwrap().to_string(), + _ => String::from(scopes.iter().fold(String::new(), |acc, el| { + let sep = if acc.is_empty() { "" } else { "\n" }; + acc + sep + el + })), + } + } +} + +/// Key for storing OpenID tokens in the token cache. +#[derive(Debug, PartialEq, Eq)] +pub struct IdTokenKey { + auth_provider_type: String, + user_profile_id: String, + audience: String, +} + +impl CacheKey for IdTokenKey { + fn auth_provider_type(&self) -> &str { + &self.auth_provider_type + } + + fn user_profile_id(&self) -> &str { + &self.user_profile_id + } + + fn subkey(&self) -> &str { + &self.audience + } +} + +impl KeyFor for IdTokenKey { + type TokenType = OAuthToken; +} + +impl IdTokenKey { + /// Create a new ID token key. + pub fn new( + auth_provider_type: String, + user_profile_id: String, + audience: String, + ) -> Result<IdTokenKey, Error> { + validate_provider_and_id(&auth_provider_type, &user_profile_id)?; + Ok(IdTokenKey { + auth_provider_type: auth_provider_type, + user_profile_id: user_profile_id, + audience: audience, + }) + } +} + +/// Key for storing Firebase tokens in the token cache. +#[derive(Debug, PartialEq, Eq)] +pub struct FirebaseTokenKey { + auth_provider_type: String, + user_profile_id: String, + api_key: String, +} + +impl CacheKey for FirebaseTokenKey { + fn auth_provider_type(&self) -> &str { + &self.auth_provider_type + } + + fn user_profile_id(&self) -> &str { + &self.user_profile_id + } + + fn subkey(&self) -> &str { + &self.api_key + } +} + +impl KeyFor for FirebaseTokenKey { + type TokenType = FirebaseAuthToken; +} + +impl FirebaseTokenKey { + /// Creates a new Firebase token key. + pub fn new( + auth_provider_type: String, + user_profile_id: String, + api_key: String, + ) -> Result<FirebaseTokenKey, Error> { + validate_provider_and_id(&auth_provider_type, &user_profile_id)?; + Ok(FirebaseTokenKey { + auth_provider_type: auth_provider_type, + user_profile_id: user_profile_id, + api_key: api_key, + }) + } +} + +/// Validates that the given auth_provider_type and user_profile_id are +/// nonempty. +fn validate_provider_and_id(auth_provider_type: &str, user_profile_id: &str) -> Result<(), Error> { + if auth_provider_type.is_empty() { + Err(format_err!("auth_provider_type cannot be empty")) + } else if user_profile_id.is_empty() { + Err(format_err!("user_profile_id cannot be empty")) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use fidl_fuchsia_auth::TokenType; + + const LONG_EXPIRY: Duration = Duration::from_secs(3000); + const TEST_ACCESS_TOKEN: &str = "access token"; + const TEST_FIREBASE_ID_TOKEN: &str = "firebase token"; + const TEST_FIREBASE_LOCAL_ID: &str = "firebase local id"; + const TEST_EMAIL: &str = "user@test.com"; + const TEST_AUTH_PROVIDER_TYPE: &str = "test-provider"; + const TEST_USER_PROFILE_ID: &str = "test-user-123"; + const TEST_SCOPE_1: &str = "scope-1"; + const TEST_SCOPE_2: &str = "scope-2"; + const TEST_AUDIENCE: &str = "audience"; + const TEST_FIREBASE_API: &str = "firebase-api"; + + #[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_create_access_token_key() { + let scopes = vec![TEST_SCOPE_1, TEST_SCOPE_2]; + let auth_token_key = AccessTokenKey::new( + TEST_AUTH_PROVIDER_TYPE.to_string(), + TEST_USER_PROFILE_ID.to_string(), + &scopes, + ) + .unwrap(); + assert_eq!( + AccessTokenKey { + auth_provider_type: TEST_AUTH_PROVIDER_TYPE.to_string(), + user_profile_id: TEST_USER_PROFILE_ID.to_string(), + scopes: TEST_SCOPE_1.to_string() + "\n" + TEST_SCOPE_2, + }, + auth_token_key + ); + + // Verify single scope creation + let single_scope = vec![TEST_SCOPE_1]; + let auth_token_key = AccessTokenKey::new( + TEST_AUTH_PROVIDER_TYPE.to_string(), + TEST_USER_PROFILE_ID.to_string(), + &single_scope, + ) + .unwrap(); + assert_eq!( + AccessTokenKey { + auth_provider_type: TEST_AUTH_PROVIDER_TYPE.to_string(), + user_profile_id: TEST_USER_PROFILE_ID.to_string(), + scopes: TEST_SCOPE_1.to_string(), + }, + auth_token_key + ); + + // Verify no scopes creation + let no_scopes: Vec<&str> = vec![]; + let auth_token_key = AccessTokenKey::new( + TEST_AUTH_PROVIDER_TYPE.to_string(), + TEST_USER_PROFILE_ID.to_string(), + &no_scopes, + ) + .unwrap(); + assert_eq!( + AccessTokenKey { + auth_provider_type: TEST_AUTH_PROVIDER_TYPE.to_string(), + user_profile_id: TEST_USER_PROFILE_ID.to_string(), + scopes: "".to_string(), + }, + auth_token_key + ); + + // Verify empty auth provider and user profile id cases fail. + assert!(AccessTokenKey::new("".to_string(), TEST_USER_PROFILE_ID.to_string(), &no_scopes) + .is_err()); + assert!(AccessTokenKey::new( + TEST_AUTH_PROVIDER_TYPE.to_string(), + "".to_string(), + &no_scopes + ) + .is_err()); + } + + #[test] + fn test_create_id_token_key() { + assert_eq!( + IdTokenKey::new( + TEST_AUTH_PROVIDER_TYPE.to_string(), + TEST_USER_PROFILE_ID.to_string(), + TEST_AUDIENCE.to_string() + ) + .unwrap(), + IdTokenKey { + auth_provider_type: TEST_AUTH_PROVIDER_TYPE.to_string(), + user_profile_id: TEST_USER_PROFILE_ID.to_string(), + audience: TEST_AUDIENCE.to_string() + } + ); + + // Verify empty auth provider and user profile id cases fail. + assert!(IdTokenKey::new( + "".to_string(), + TEST_USER_PROFILE_ID.to_string(), + TEST_AUDIENCE.to_string() + ) + .is_err()); + assert!(IdTokenKey::new( + TEST_AUTH_PROVIDER_TYPE.to_string(), + "".to_string(), + TEST_AUDIENCE.to_string() + ) + .is_err()); + } + + #[test] + fn test_create_firebase_token_key() { + assert_eq!( + FirebaseTokenKey::new( + TEST_AUTH_PROVIDER_TYPE.to_string(), + TEST_USER_PROFILE_ID.to_string(), + TEST_FIREBASE_API.to_string() + ) + .unwrap(), + FirebaseTokenKey { + auth_provider_type: TEST_AUTH_PROVIDER_TYPE.to_string(), + user_profile_id: TEST_USER_PROFILE_ID.to_string(), + api_key: TEST_FIREBASE_API.to_string() + } + ); + + // Verify empty auth provider and user profile id cases fail. + assert!(FirebaseTokenKey::new( + "".to_string(), + TEST_USER_PROFILE_ID.to_string(), + TEST_FIREBASE_API.to_string() + ) + .is_err()); + assert!(FirebaseTokenKey::new( + TEST_AUTH_PROVIDER_TYPE.to_string(), + "".to_string(), + TEST_FIREBASE_API.to_string() + ) + .is_err()); + } +}