/* * Copyright 2019 Google * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ #import "FIRInstallationsIDController.h" #if __has_include() #import #else #import "FBLPromises.h" #endif #import #import "FIRInstallationsAPIService.h" #import "FIRInstallationsErrorUtil.h" #import "FIRInstallationsIIDStore.h" #import "FIRInstallationsIIDTokenStore.h" #import "FIRInstallationsItem.h" #import "FIRInstallationsLogger.h" #import "FIRInstallationsSingleOperationPromiseCache.h" #import "FIRInstallationsStore.h" #import "FIRSecureStorage.h" #import "FIRInstallationsHTTPError.h" #import "FIRInstallationsStoredAuthToken.h" const NSNotificationName FIRInstallationIDDidChangeNotification = @"FIRInstallationIDDidChangeNotification"; NSString *const kFIRInstallationIDDidChangeNotificationAppNameKey = @"FIRInstallationIDDidChangeNotification"; NSTimeInterval const kFIRInstallationsTokenExpirationThreshold = 60 * 60; // 1 hour. @interface FIRInstallationsIDController () @property(nonatomic, readonly) NSString *appID; @property(nonatomic, readonly) NSString *appName; @property(nonatomic, readonly) FIRInstallationsStore *installationsStore; @property(nonatomic, readonly) FIRInstallationsIIDStore *IIDStore; @property(nonatomic, readonly) FIRInstallationsIIDTokenStore *IIDTokenStore; @property(nonatomic, readonly) FIRInstallationsAPIService *APIService; @property(nonatomic, readonly) FIRInstallationsSingleOperationPromiseCache *getInstallationPromiseCache; @property(nonatomic, readonly) FIRInstallationsSingleOperationPromiseCache *authTokenPromiseCache; @property(nonatomic, readonly) FIRInstallationsSingleOperationPromiseCache *authTokenForcingRefreshPromiseCache; @property(nonatomic, readonly) FIRInstallationsSingleOperationPromiseCache *deleteInstallationPromiseCache; @end @implementation FIRInstallationsIDController - (instancetype)initWithGoogleAppID:(NSString *)appID appName:(NSString *)appName APIKey:(NSString *)APIKey projectID:(NSString *)projectID GCMSenderID:(NSString *)GCMSenderID accessGroup:(NSString *)accessGroup { FIRSecureStorage *secureStorage = [[FIRSecureStorage alloc] init]; FIRInstallationsStore *installationsStore = [[FIRInstallationsStore alloc] initWithSecureStorage:secureStorage accessGroup:accessGroup]; // Use `GCMSenderID` as project identifier when `projectID` is not available. NSString *APIServiceProjectID = (projectID.length > 0) ? projectID : GCMSenderID; FIRInstallationsAPIService *apiService = [[FIRInstallationsAPIService alloc] initWithAPIKey:APIKey projectID:APIServiceProjectID]; FIRInstallationsIIDStore *IIDStore = [[FIRInstallationsIIDStore alloc] init]; FIRInstallationsIIDTokenStore *IIDCheckingStore = [[FIRInstallationsIIDTokenStore alloc] initWithGCMSenderID:GCMSenderID]; return [self initWithGoogleAppID:appID appName:appName installationsStore:installationsStore APIService:apiService IIDStore:IIDStore IIDTokenStore:IIDCheckingStore]; } /// The initializer is supposed to be used by tests to inject `installationsStore`. - (instancetype)initWithGoogleAppID:(NSString *)appID appName:(NSString *)appName installationsStore:(FIRInstallationsStore *)installationsStore APIService:(FIRInstallationsAPIService *)APIService IIDStore:(FIRInstallationsIIDStore *)IIDStore IIDTokenStore:(FIRInstallationsIIDTokenStore *)IIDTokenStore { self = [super init]; if (self) { _appID = appID; _appName = appName; _installationsStore = installationsStore; _APIService = APIService; _IIDStore = IIDStore; _IIDTokenStore = IIDTokenStore; __weak FIRInstallationsIDController *weakSelf = self; _getInstallationPromiseCache = [[FIRInstallationsSingleOperationPromiseCache alloc] initWithNewOperationHandler:^FBLPromise *_Nonnull { FIRInstallationsIDController *strongSelf = weakSelf; return [strongSelf createGetInstallationItemPromise]; }]; _authTokenPromiseCache = [[FIRInstallationsSingleOperationPromiseCache alloc] initWithNewOperationHandler:^FBLPromise *_Nonnull { FIRInstallationsIDController *strongSelf = weakSelf; return [strongSelf installationWithValidAuthTokenForcingRefresh:NO]; }]; _authTokenForcingRefreshPromiseCache = [[FIRInstallationsSingleOperationPromiseCache alloc] initWithNewOperationHandler:^FBLPromise *_Nonnull { FIRInstallationsIDController *strongSelf = weakSelf; return [strongSelf installationWithValidAuthTokenForcingRefresh:YES]; }]; _deleteInstallationPromiseCache = [[FIRInstallationsSingleOperationPromiseCache alloc] initWithNewOperationHandler:^FBLPromise *_Nonnull { FIRInstallationsIDController *strongSelf = weakSelf; return [strongSelf createDeleteInstallationPromise]; }]; } return self; } #pragma mark - Get Installation. - (FBLPromise *)getInstallationItem { return [self.getInstallationPromiseCache getExistingPendingOrCreateNewPromise]; } - (FBLPromise *)createGetInstallationItemPromise { FIRLogDebug(kFIRLoggerInstallations, kFIRInstallationsMessageCodeNewGetInstallationOperationCreated, @"%s, appName: %@", __PRETTY_FUNCTION__, self.appName); FBLPromise *installationItemPromise = [self getStoredInstallation].recover(^id(NSError *error) { return [self createAndSaveFID]; }); // Initiate registration process on success if needed, but return the installation without waiting // for it. installationItemPromise.then(^id(FIRInstallationsItem *installation) { [self getAuthTokenForcingRefresh:NO]; return nil; }); return installationItemPromise; } - (FBLPromise *)getStoredInstallation { return [self.installationsStore installationForAppID:self.appID appName:self.appName].validate( ^BOOL(FIRInstallationsItem *installation) { BOOL isValid = NO; switch (installation.registrationStatus) { case FIRInstallationStatusUnregistered: case FIRInstallationStatusRegistered: isValid = YES; break; case FIRInstallationStatusUnknown: isValid = NO; break; } return isValid; }); } - (FBLPromise *)createAndSaveFID { return [self migrateOrGenerateInstallation] .then(^FBLPromise *(FIRInstallationsItem *installation) { return [self saveInstallation:installation]; }) .then(^FIRInstallationsItem *(FIRInstallationsItem *installation) { [self postFIDDidChangeNotification]; return installation; }); } - (FBLPromise *)saveInstallation:(FIRInstallationsItem *)installation { return [self.installationsStore saveInstallation:installation].then( ^FIRInstallationsItem *(NSNull *result) { return installation; }); } /** * Tries to migrate IID data stored by FirebaseInstanceID SDK or generates a new Installation ID if * not found. */ - (FBLPromise *)migrateOrGenerateInstallation { if (![self isDefaultApp]) { // Existing IID should be used only for default FirebaseApp. FIRInstallationsItem *installation = [self createInstallationWithFID:[FIRInstallationsItem generateFID] IIDDefaultToken:nil]; return [FBLPromise resolvedWith:installation]; } return [[[FBLPromise all:@[ [self.IIDStore existingIID], [self.IIDTokenStore existingIIDDefaultToken] ]] then:^id _Nullable(NSArray *_Nullable results) { NSString *existingIID = results[0]; NSString *IIDDefaultToken = results[1]; return [self createInstallationWithFID:existingIID IIDDefaultToken:IIDDefaultToken]; }] recover:^id _Nullable(NSError *_Nonnull error) { return [self createInstallationWithFID:[FIRInstallationsItem generateFID] IIDDefaultToken:nil]; }]; } - (FIRInstallationsItem *)createInstallationWithFID:(NSString *)FID IIDDefaultToken:(nullable NSString *)IIDDefaultToken { FIRInstallationsItem *installation = [[FIRInstallationsItem alloc] initWithAppID:self.appID firebaseAppName:self.appName]; installation.firebaseInstallationID = FID; installation.IIDDefaultToken = IIDDefaultToken; installation.registrationStatus = FIRInstallationStatusUnregistered; return installation; } #pragma mark - FID registration - (FBLPromise *)registerInstallationIfNeeded: (FIRInstallationsItem *)installation { switch (installation.registrationStatus) { case FIRInstallationStatusRegistered: // Already registered. Do nothing. return [FBLPromise resolvedWith:installation]; case FIRInstallationStatusUnknown: case FIRInstallationStatusUnregistered: // Registration required. Proceed. break; } return [self.APIService registerInstallation:installation] .catch(^(NSError *_Nonnull error) { if ([self doesRegistrationErrorRequireConfigChange:error]) { FIRLogError(kFIRLoggerInstallations, kFIRInstallationsMessageCodeInvalidFirebaseConfiguration, @"Firebase Installation registration failed for app with name: %@, error: " @"%@\nPlease make sure you use valid GoogleService-Info.plist", self.appName, error); } }) .then(^id(FIRInstallationsItem *registeredInstallation) { return [self saveInstallation:registeredInstallation]; }) .then(^FIRInstallationsItem *(FIRInstallationsItem *registeredInstallation) { // Server may respond with a different FID if the sent one cannot be accepted. if (![registeredInstallation.firebaseInstallationID isEqualToString:installation.firebaseInstallationID]) { [self postFIDDidChangeNotification]; } return registeredInstallation; }); } - (BOOL)doesRegistrationErrorRequireConfigChange:(NSError *)error { FIRInstallationsHTTPError *HTTPError = (FIRInstallationsHTTPError *)error; if (![HTTPError isKindOfClass:[FIRInstallationsHTTPError class]]) { return NO; } switch (HTTPError.HTTPResponse.statusCode) { // These are the errors that require Firebase configuration change. case FIRInstallationsRegistrationHTTPCodeInvalidArgument: case FIRInstallationsRegistrationHTTPCodeInvalidAPIKey: case FIRInstallationsRegistrationHTTPCodeAPIKeyToProjectIDMismatch: case FIRInstallationsRegistrationHTTPCodeProjectNotFound: return YES; default: return NO; } } #pragma mark - Auth Token - (FBLPromise *)getAuthTokenForcingRefresh:(BOOL)forceRefresh { if (forceRefresh || [self.authTokenForcingRefreshPromiseCache getExistingPendingPromise] != nil) { return [self.authTokenForcingRefreshPromiseCache getExistingPendingOrCreateNewPromise]; } else { return [self.authTokenPromiseCache getExistingPendingOrCreateNewPromise]; } } - (FBLPromise *)installationWithValidAuthTokenForcingRefresh: (BOOL)forceRefresh { FIRLogDebug(kFIRLoggerInstallations, kFIRInstallationsMessageCodeNewGetAuthTokenOperationCreated, @"-[FIRInstallationsIDController installationWithValidAuthTokenForcingRefresh:%@], " @"appName: %@", @(forceRefresh), self.appName); return [self getInstallationItem] .then(^FBLPromise *(FIRInstallationsItem *installation) { return [self registerInstallationIfNeeded:installation]; }) .then(^id(FIRInstallationsItem *registeredInstallation) { BOOL isTokenExpiredOrExpiresSoon = [registeredInstallation.authToken.expirationDate timeIntervalSinceDate:[NSDate date]] < kFIRInstallationsTokenExpirationThreshold; if (forceRefresh || isTokenExpiredOrExpiresSoon) { return [self refreshAuthTokenForInstallation:registeredInstallation]; } else { return registeredInstallation; } }) .recover(^id(NSError *error) { return [self regenerateFIDOnRefreshTokenErrorIfNeeded:error]; }); } - (FBLPromise *)refreshAuthTokenForInstallation: (FIRInstallationsItem *)installation { return [[self.APIService refreshAuthTokenForInstallation:installation] then:^id _Nullable(FIRInstallationsItem *_Nullable refreshedInstallation) { return [self saveInstallation:refreshedInstallation]; }]; } - (id)regenerateFIDOnRefreshTokenErrorIfNeeded:(NSError *)error { if (![error isKindOfClass:[FIRInstallationsHTTPError class]]) { // No recovery possible. Return the same error. return error; } FIRInstallationsHTTPError *HTTPError = (FIRInstallationsHTTPError *)error; switch (HTTPError.HTTPResponse.statusCode) { case FIRInstallationsAuthTokenHTTPCodeInvalidAuthentication: case FIRInstallationsAuthTokenHTTPCodeFIDNotFound: // The stored installation was damaged or blocked by the server. // Delete the stored installation then generate and register a new one. return [self getInstallationItem] .then(^FBLPromise *(FIRInstallationsItem *installation) { return [self deleteInstallationLocally:installation]; }) .then(^FBLPromise *(id result) { return [self installationWithValidAuthTokenForcingRefresh:NO]; }); default: // No recovery possible. Return the same error. return error; } } #pragma mark - Delete FID - (FBLPromise *)deleteInstallation { return [self.deleteInstallationPromiseCache getExistingPendingOrCreateNewPromise]; } - (FBLPromise *)createDeleteInstallationPromise { FIRLogDebug(kFIRLoggerInstallations, kFIRInstallationsMessageCodeNewDeleteInstallationOperationCreated, @"%s, appName: %@", __PRETTY_FUNCTION__, self.appName); // Check for ongoing requests first, if there is no a request, then check local storage for // existing installation. FBLPromise *currentInstallationPromise = [self mostRecentInstallationOperation] ?: [self getStoredInstallation]; return currentInstallationPromise .then(^id(FIRInstallationsItem *installation) { return [self sendDeleteInstallationRequestIfNeeded:installation]; }) .then(^id(FIRInstallationsItem *installation) { // Remove the installation from the local storage. return [self deleteInstallationLocally:installation]; }); } - (FBLPromise *)deleteInstallationLocally:(FIRInstallationsItem *)installation { return [self.installationsStore removeInstallationForAppID:installation.appID appName:installation.firebaseAppName] .then(^FBLPromise *(NSNull *result) { return [self deleteExistingIIDIfNeeded]; }) .then(^NSNull *(NSNull *result) { [self postFIDDidChangeNotification]; return result; }); } - (FBLPromise *)sendDeleteInstallationRequestIfNeeded: (FIRInstallationsItem *)installation { switch (installation.registrationStatus) { case FIRInstallationStatusUnknown: case FIRInstallationStatusUnregistered: // The installation is not registered, so it is safe to be deleted as is, so return early. return [FBLPromise resolvedWith:installation]; break; case FIRInstallationStatusRegistered: // Proceed to de-register the installation on the server. break; } return [self.APIService deleteInstallation:installation].recover(^id(NSError *APIError) { if ([FIRInstallationsErrorUtil isAPIError:APIError withHTTPCode:404]) { // The installation was not found on the server. // Return success. return installation; } else { // Re-throw the error otherwise. return APIError; } }); } - (FBLPromise *)deleteExistingIIDIfNeeded { if ([self isDefaultApp]) { return [self.IIDStore deleteExistingIID]; } else { return [FBLPromise resolvedWith:[NSNull null]]; } } - (nullable FBLPromise *)mostRecentInstallationOperation { return [self.authTokenForcingRefreshPromiseCache getExistingPendingPromise] ?: [self.authTokenPromiseCache getExistingPendingPromise] ?: [self.getInstallationPromiseCache getExistingPendingPromise]; } #pragma mark - Notifications - (void)postFIDDidChangeNotification { [[NSNotificationCenter defaultCenter] postNotificationName:FIRInstallationIDDidChangeNotification object:nil userInfo:@{kFIRInstallationIDDidChangeNotificationAppNameKey : self.appName}]; } #pragma mark - Default App - (BOOL)isDefaultApp { return [self.appName isEqualToString:kFIRDefaultAppName]; } @end