Files
firebase-ios-sdk/FirebaseAppDistribution/Sources/FIRAppDistribution.m
T
ec670ae0bb Persist auth information to the iOS keychain (#5460)
* Add initial code hash generation implementation

* Adding auth state to iOS keychain

* Cleanup logging and error handling for initial auth persistence implementation (#5413)

* Cleanup logging and error handling for initial auth persistence implementation

* Update Nullability of error fields and reformat

* Move the error domain and enum into the AuthPersistence file

* Format Mach files

* Externalize handling of nullable error and greedily return on error

* Add initial code hash generation implementation

* Adding auth state to iOS keychain

* Cleanup logging and error handling for initial auth persistence implementation (#5413)

* Cleanup logging and error handling for initial auth persistence implementation

* Update Nullability of error fields and reformat

* Move the error domain and enum into the AuthPersistence file

* Format Mach files

* Externalize handling of nullable error and greedily return on error

* Add unittests for AppDistribution auth persistence

* Split out the keychain calls from control flow and error handling

* Add mocking and tests for auth persistence tests

* Remove debug logging

* Update styling

* Re-add resources to podspec and break out block for readability

Co-authored-by: Jeremy Durham <jeremydurham@google.com>
Co-authored-by: Pranav Rajgopal <pranavrajgopal@google.com>
2020-04-30 14:29:33 -04:00

335 lines
13 KiB
Objective-C

// Copyright 2020 Google LLC
//
// 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 "FIRAppDistribution+Private.h"
#import "FIRAppDistributionAuthPersistence+Private.h"
#import "FIRAppDistributionMachO+Private.h"
#import "FIRAppDistributionRelease+Private.h"
#import <FirebaseCore/FIRAppInternal.h>
#import <FirebaseCore/FIRComponent.h>
#import <FirebaseCore/FIRComponentContainer.h>
#import <FirebaseCore/FIROptions.h>
#import <GoogleUtilities/GULAppDelegateSwizzler.h>
#import "FIRAppDistributionAppDelegateInterceptor.h"
/// Empty protocol to register with FirebaseCore's component system.
@protocol FIRAppDistributionInstanceProvider <NSObject>
@end
@interface FIRAppDistribution () <FIRLibrary, FIRAppDistributionInstanceProvider>
@property(nonatomic) BOOL isTesterSignedIn;
@end
@implementation FIRAppDistribution
// The OAuth scope needed to authorize the App Distribution Tester API
NSString *const kOIDScopeTesterAPI = @"https://www.googleapis.com/auth/cloud-platform";
// The App Distribution Tester API endpoint used to retrieve releases
NSString *const kReleasesEndpointURL =
@"https://firebaseapptesters.googleapis.com/v1alpha/devices/-/testerApps/%@/releases";
NSString *const kTesterAPIClientID =
@"319754533822-osu3v3hcci24umq6diathdm0dipds1fb.apps.googleusercontent.com";
NSString *const kIssuerURL = @"https://accounts.google.com";
NSString *const kAppDistroLibraryName = @"fire-fad";
#pragma mark - Singleton Support
- (instancetype)initWithApp:(FIRApp *)app appInfo:(NSDictionary *)appInfo {
self = [super init];
if (self) {
self.safariHostingViewController = [[UIViewController alloc] init];
[GULAppDelegateSwizzler proxyOriginalDelegate];
FIRAppDistributionAppDelegatorInterceptor *interceptor =
[FIRAppDistributionAppDelegatorInterceptor sharedInstance];
[GULAppDelegateSwizzler registerAppDelegateInterceptor:interceptor];
}
NSError *authRetrievalError;
self.authState = [FIRAppDistributionAuthPersistence retrieveAuthState:&authRetrievalError];
// TODO (schnecle): replace NSLog statement with FIRLogger log statement
if (authRetrievalError) {
NSLog(@"Error retrieving token from keychain: %@", [authRetrievalError localizedDescription]);
}
self.isTesterSignedIn = self.authState ? YES : NO;
return self;
}
+ (void)load {
NSString *version =
[NSString stringWithUTF8String:(const char *const)STR_EXPAND(FIRAppDistribution_VERSION)];
[FIRApp registerInternalLibrary:(Class<FIRLibrary>)self
withName:kAppDistroLibraryName
withVersion:version];
}
+ (NSArray<FIRComponent *> *)componentsToRegister {
FIRComponentCreationBlock creationBlock =
^id _Nullable(FIRComponentContainer *container, BOOL *isCacheable) {
if (!container.app.isDefaultApp) {
// TODO: Implement error handling
@throw([NSException exceptionWithName:@"NotImplementedException"
reason:@"This code path is not implemented yet"
userInfo:nil]);
return nil;
}
*isCacheable = YES;
return [[FIRAppDistribution alloc] initWithApp:container.app
appInfo:NSBundle.mainBundle.infoDictionary];
};
FIRComponent *component =
[FIRComponent componentWithProtocol:@protocol(FIRAppDistributionInstanceProvider)
instantiationTiming:FIRInstantiationTimingEagerInDefaultApp
dependencies:@[]
creationBlock:creationBlock];
return @[ component ];
}
+ (instancetype)appDistribution {
// The container will return the same instance since isCacheable is set
FIRApp *defaultApp = [FIRApp defaultApp]; // Missing configure will be logged here.
// Get the instance from the `FIRApp`'s container. This will create a new instance the
// first time it is called, and since `isCacheable` is set in the component creation
// block, it will return the existing instance on subsequent calls.
id<FIRAppDistributionInstanceProvider> instance =
FIR_COMPONENT(FIRAppDistributionInstanceProvider, defaultApp.container);
// In the component creation block, we return an instance of `FIRAppDistribution`. Cast it and
// return it.
return (FIRAppDistribution *)instance;
}
- (void)signInTesterWithCompletion:(void (^)(NSError *_Nullable error))completion {
NSURL *issuer = [NSURL URLWithString:kIssuerURL];
[OIDAuthorizationService
discoverServiceConfigurationForIssuer:issuer
completion:^(OIDServiceConfiguration *_Nullable configuration,
NSError *_Nullable error) {
[self handleOauthDiscoveryCompletion:configuration
error:error
appDistributionSignInCompletion:completion];
}];
}
- (void)signOutTester {
NSError *error;
BOOL didClearAuthState = [FIRAppDistributionAuthPersistence clearAuthState:&error];
// TODO (schnecle): Add in FIRLogger to report when we have failed to clear auth state
if (!didClearAuthState) {
NSLog(@"Error clearing token from keychain: %@", [error localizedDescription]);
}
self.authState = nil;
self.isTesterSignedIn = false;
}
- (void)fetchReleases:(FIRAppDistributionUpdateCheckCompletion)completion {
[self.authState performActionWithFreshTokens:^(NSString *_Nonnull accessToken,
NSString *_Nonnull idToken,
NSError *_Nullable error) {
if (error) {
// TODO (schnecle): Add in FIRLogger log statement
NSLog(@"Error fetching fresh tokens: %@", [error localizedDescription]);
[self signOutTester];
return;
}
// perform your API request using the tokens
NSURLSession *URLSession = [NSURLSession sharedSession];
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init];
NSString *URLString =
[NSString stringWithFormat:kReleasesEndpointURL, [[FIRApp defaultApp] options].googleAppID];
[request setURL:[NSURL URLWithString:URLString]];
[request setHTTPMethod:@"GET"];
[request setValue:[NSString stringWithFormat:@"Bearer %@", accessToken]
forHTTPHeaderField:@"Authorization"];
NSURLSessionDataTask *listReleasesDataTask = [URLSession
dataTaskWithRequest:request
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
if (error) {
// TODO: Reformat error into error code
completion(nil, error);
return;
}
NSHTTPURLResponse *HTTPResponse = (NSHTTPURLResponse *)response;
if (HTTPResponse.statusCode == 200) {
[self handleReleasesAPIResponseWithData:data completion:completion];
} else {
// TODO: Handle non-200 http response
NSLog(@"ERROR - Non 200 service response - %@", HTTPResponse);
@throw([NSException exceptionWithName:@"NotImplementedException"
reason:@"This code path is not implemented yet"
userInfo:nil]);
}
}];
[listReleasesDataTask resume];
}];
}
- (void)handleOauthDiscoveryCompletion:(OIDServiceConfiguration *_Nullable)configuration
error:(NSError *_Nullable)error
appDistributionSignInCompletion:(void (^)(NSError *_Nullable error))completion {
if (!configuration) {
// TODO: Handle when we cannot get configuration
NSLog(@"ERROR - Cannot discover oauth config");
@throw([NSException exceptionWithName:@"NotImplementedException"
reason:@"This code path is not implemented yet"
userInfo:nil]);
return;
}
NSString *redirectURL = [@"dev.firebase.appdistribution."
stringByAppendingString:[[[NSBundle mainBundle] bundleIdentifier]
stringByAppendingString:@":/launch"]];
OIDAuthorizationRequest *request = [[OIDAuthorizationRequest alloc]
initWithConfiguration:configuration
clientId:kTesterAPIClientID
scopes:@[ OIDScopeOpenID, OIDScopeProfile, kOIDScopeTesterAPI ]
redirectURL:[NSURL URLWithString:redirectURL]
responseType:OIDResponseTypeCode
additionalParameters:nil];
[self setupUIWindowForLogin];
void (^processAuthState)(OIDAuthState *_Nullable authState, NSError *_Nullable error) = ^void(
OIDAuthState *_Nullable authState, NSError *_Nullable error) {
self.authState = authState;
// Capture errors in persistence but do not bubble them
// up
NSError *authPersistenceError;
if (authState) {
[FIRAppDistributionAuthPersistence persistAuthState:authState error:&authPersistenceError];
}
// TODO (schnecle): Log errors in persistence using
// FIRLogger
if (authPersistenceError) {
NSLog(@"Error persisting token to keychain: %@", [error localizedDescription]);
}
self.isTesterSignedIn = self.authState ? YES : NO;
completion(error);
};
// performs authentication request
[FIRAppDistributionAppDelegatorInterceptor sharedInstance].currentAuthorizationFlow =
[OIDAuthState authStateByPresentingAuthorizationRequest:request
presentingViewController:self.safariHostingViewController
callback:processAuthState];
}
- (void)setupUIWindowForLogin {
if (self.window) {
return;
}
// Create an empty window + viewController to host the Safari UI.
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.window.rootViewController = self.safariHostingViewController;
// Place it at the highest level within the stack.
self.window.windowLevel = +CGFLOAT_MAX;
// Run it.
[self.window makeKeyAndVisible];
}
- (void)cleanupUIWindow {
if (self.window) {
self.window.hidden = YES;
self.window = nil;
}
}
- (void)handleReleasesAPIResponseWithData:data
completion:(FIRAppDistributionUpdateCheckCompletion)completion {
NSError *error = nil;
NSDictionary *object = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
NSArray *releaseList = [object objectForKey:@"releases"];
for (NSDictionary *releaseDict in releaseList) {
if (![[releaseDict objectForKey:@"latest"] boolValue]) continue;
NSString *codeHash = [releaseDict objectForKey:@"codeHash"];
FIRAppDistributionMachO *machO =
[[FIRAppDistributionMachO alloc] initWithPath:[[NSBundle mainBundle] executablePath]];
if (![codeHash isEqualToString:machO.codeHash]) {
FIRAppDistributionRelease *release =
[[FIRAppDistributionRelease alloc] initWithDictionary:releaseDict];
dispatch_async(dispatch_get_main_queue(), ^{
completion(release, nil);
});
}
}
}
- (void)checkForUpdateWithCompletion:(FIRAppDistributionUpdateCheckCompletion)completion {
if (self.isTesterSignedIn) {
[self fetchReleases:completion];
} else {
UIAlertController *alert = [UIAlertController
alertControllerWithTitle:@"Enable in-app alerts"
message:@"Sign in with your Firebase App Distribution Google account to "
@"turn on in-app alerts for new test releases."
preferredStyle:UIAlertControllerStyleAlert];
UIAlertAction *yesButton =
[UIAlertAction actionWithTitle:@"Turn on"
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *action) {
[self signInTesterWithCompletion:^(NSError *_Nullable error) {
if (error) {
completion(nil, error);
return;
}
[self fetchReleases:completion];
}];
}];
UIAlertAction *noButton = [UIAlertAction actionWithTitle:@"Not now"
style:UIAlertActionStyleDefault
handler:^(UIAlertAction *action) {
// precaution to ensure window gets destroyed
[self cleanupUIWindow];
completion(nil, nil);
}];
[alert addAction:noButton];
[alert addAction:yesButton];
// Create an empty window + viewController to host the Safari UI.
[self setupUIWindowForLogin];
[self.window.rootViewController presentViewController:alert animated:YES completion:nil];
}
}
@end