Skip to content

Commit ec670ae

Browse files
schneclejeremydurhampranavrajgopal
authored
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 <[email protected]> Co-authored-by: Pranav Rajgopal <[email protected]>
1 parent 83d7c78 commit ec670ae

9 files changed

+521
-46
lines changed

FirebaseAppDistribution.podspec

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ iOS SDK for App Distribution for Firebase.
3636
}
3737

3838
s.test_spec 'unit' do |unit_tests|
39-
unit_tests.source_files = 'FirebaseAppDistribution/Tests/Unit*/*.[mh]'
40-
unit_tests.resources = 'FirebaseAppDistribution/Tests/Unit/Resources/*'
39+
unit_tests.source_files = 'FirebaseAppDistribution/Tests/Unit*/*.[mh]'
40+
unit_tests.resources = 'FirebaseAppDistribution/Tests/Unit/Resources/*'
41+
unit_tests.dependency 'OCMock'
4142
end
4243

4344
# end

FirebaseAppDistribution/Sources/FIRAppDistribution.m

Lines changed: 83 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
// limitations under the License.
1414

1515
#import "FIRAppDistribution+Private.h"
16+
#import "FIRAppDistributionAuthPersistence+Private.h"
1617
#import "FIRAppDistributionMachO+Private.h"
1718
#import "FIRAppDistributionRelease+Private.h"
1819

@@ -60,8 +61,15 @@ - (instancetype)initWithApp:(FIRApp *)app appInfo:(NSDictionary *)appInfo {
6061
[GULAppDelegateSwizzler registerAppDelegateInterceptor:interceptor];
6162
}
6263

63-
// TODO: Lookup keychain to load auth state on init
64+
NSError *authRetrievalError;
65+
self.authState = [FIRAppDistributionAuthPersistence retrieveAuthState:&authRetrievalError];
66+
// TODO (schnecle): replace NSLog statement with FIRLogger log statement
67+
if (authRetrievalError) {
68+
NSLog(@"Error retrieving token from keychain: %@", [authRetrievalError localizedDescription]);
69+
}
70+
6471
self.isTesterSignedIn = self.authState ? YES : NO;
72+
6573
return self;
6674
}
6775

@@ -128,50 +136,70 @@ - (void)signInTesterWithCompletion:(void (^)(NSError *_Nullable error))completio
128136
}
129137

130138
- (void)signOutTester {
139+
NSError *error;
140+
BOOL didClearAuthState = [FIRAppDistributionAuthPersistence clearAuthState:&error];
141+
// TODO (schnecle): Add in FIRLogger to report when we have failed to clear auth state
142+
if (!didClearAuthState) {
143+
NSLog(@"Error clearing token from keychain: %@", [error localizedDescription]);
144+
}
145+
131146
self.authState = nil;
132147
self.isTesterSignedIn = false;
133148
}
134149

135150
- (void)fetchReleases:(FIRAppDistributionUpdateCheckCompletion)completion {
136-
NSURLSession *URLSession = [NSURLSession sharedSession];
137-
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init];
138-
NSString *URLString =
139-
[NSString stringWithFormat:kReleasesEndpointURL, [[FIRApp defaultApp] options].googleAppID];
140-
[request setURL:[NSURL URLWithString:URLString]];
141-
[request setHTTPMethod:@"GET"];
142-
[request setValue:[NSString
143-
stringWithFormat:@"Bearer %@", self.authState.lastTokenResponse.accessToken]
144-
forHTTPHeaderField:@"Authorization"];
145-
146-
NSURLSessionDataTask *listReleasesDataTask = [URLSession
147-
dataTaskWithRequest:request
148-
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
149-
if (error) {
150-
// TODO: Reformat error into error code
151-
completion(nil, error);
152-
return;
153-
}
154-
155-
NSHTTPURLResponse *HTTPResponse = (NSHTTPURLResponse *)response;
156-
157-
if (HTTPResponse.statusCode == 200) {
158-
[self handleReleasesAPIResponseWithData:data completion:completion];
159-
} else {
160-
// TODO: Handle non-200 http response
161-
@throw([NSException exceptionWithName:@"NotImplementedException"
162-
reason:@"This code path is not implemented yet"
163-
userInfo:nil]);
164-
}
165-
}];
166-
167-
[listReleasesDataTask resume];
151+
[self.authState performActionWithFreshTokens:^(NSString *_Nonnull accessToken,
152+
NSString *_Nonnull idToken,
153+
NSError *_Nullable error) {
154+
if (error) {
155+
// TODO (schnecle): Add in FIRLogger log statement
156+
NSLog(@"Error fetching fresh tokens: %@", [error localizedDescription]);
157+
[self signOutTester];
158+
return;
159+
}
160+
161+
// perform your API request using the tokens
162+
NSURLSession *URLSession = [NSURLSession sharedSession];
163+
NSMutableURLRequest *request = [[NSMutableURLRequest alloc] init];
164+
NSString *URLString =
165+
[NSString stringWithFormat:kReleasesEndpointURL, [[FIRApp defaultApp] options].googleAppID];
166+
[request setURL:[NSURL URLWithString:URLString]];
167+
[request setHTTPMethod:@"GET"];
168+
[request setValue:[NSString stringWithFormat:@"Bearer %@", accessToken]
169+
forHTTPHeaderField:@"Authorization"];
170+
171+
NSURLSessionDataTask *listReleasesDataTask = [URLSession
172+
dataTaskWithRequest:request
173+
completionHandler:^(NSData *data, NSURLResponse *response, NSError *error) {
174+
if (error) {
175+
// TODO: Reformat error into error code
176+
completion(nil, error);
177+
return;
178+
}
179+
180+
NSHTTPURLResponse *HTTPResponse = (NSHTTPURLResponse *)response;
181+
182+
if (HTTPResponse.statusCode == 200) {
183+
[self handleReleasesAPIResponseWithData:data completion:completion];
184+
} else {
185+
// TODO: Handle non-200 http response
186+
NSLog(@"ERROR - Non 200 service response - %@", HTTPResponse);
187+
@throw([NSException exceptionWithName:@"NotImplementedException"
188+
reason:@"This code path is not implemented yet"
189+
userInfo:nil]);
190+
}
191+
}];
192+
193+
[listReleasesDataTask resume];
194+
}];
168195
}
169196

170197
- (void)handleOauthDiscoveryCompletion:(OIDServiceConfiguration *_Nullable)configuration
171198
error:(NSError *_Nullable)error
172199
appDistributionSignInCompletion:(void (^)(NSError *_Nullable error))completion {
173200
if (!configuration) {
174201
// TODO: Handle when we cannot get configuration
202+
NSLog(@"ERROR - Cannot discover oauth config");
175203
@throw([NSException exceptionWithName:@"NotImplementedException"
176204
reason:@"This code path is not implemented yet"
177205
userInfo:nil]);
@@ -191,19 +219,32 @@ - (void)handleOauthDiscoveryCompletion:(OIDServiceConfiguration *_Nullable)confi
191219
additionalParameters:nil];
192220

193221
[self setupUIWindowForLogin];
222+
223+
void (^processAuthState)(OIDAuthState *_Nullable authState, NSError *_Nullable error) = ^void(
224+
OIDAuthState *_Nullable authState, NSError *_Nullable error) {
225+
self.authState = authState;
226+
227+
// Capture errors in persistence but do not bubble them
228+
// up
229+
NSError *authPersistenceError;
230+
if (authState) {
231+
[FIRAppDistributionAuthPersistence persistAuthState:authState error:&authPersistenceError];
232+
}
233+
234+
// TODO (schnecle): Log errors in persistence using
235+
// FIRLogger
236+
if (authPersistenceError) {
237+
NSLog(@"Error persisting token to keychain: %@", [error localizedDescription]);
238+
}
239+
self.isTesterSignedIn = self.authState ? YES : NO;
240+
completion(error);
241+
};
242+
194243
// performs authentication request
195244
[FIRAppDistributionAppDelegatorInterceptor sharedInstance].currentAuthorizationFlow =
196245
[OIDAuthState authStateByPresentingAuthorizationRequest:request
197246
presentingViewController:self.safariHostingViewController
198-
callback:^(OIDAuthState *_Nullable authState,
199-
NSError *_Nullable error) {
200-
[self cleanupUIWindow];
201-
202-
self.authState = authState;
203-
self.isTesterSignedIn =
204-
self.authState ? YES : NO;
205-
completion(error);
206-
}];
247+
callback:processAuthState];
207248
}
208249

209250
- (void)setupUIWindowForLogin {
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
// Copyright 2020 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
#import "FIRAppDistributionAuthPersistence+Private.h"
15+
16+
NS_ASSUME_NONNULL_BEGIN
17+
18+
NSString *const kFIRAppDistributionAuthPersistenceErrorDomain =
19+
@"com.firebase.app_distribution.auth_persistence";
20+
21+
@implementation FIRAppDistributionAuthPersistence
22+
23+
+ (void)handleAuthStateError:(NSError **_Nullable)error
24+
description:(NSString *)description
25+
code:(FIRAppDistributionKeychainError)code {
26+
if (error) {
27+
NSDictionary *userInfo = @{NSLocalizedDescriptionKey : description};
28+
*error = [NSError errorWithDomain:kFIRAppDistributionAuthPersistenceErrorDomain
29+
code:code
30+
userInfo:userInfo];
31+
}
32+
}
33+
34+
+ (BOOL)clearAuthState:(NSError **_Nullable)error {
35+
NSMutableDictionary *keychainQuery = [self getKeyChainQuery];
36+
BOOL success = [FIRAppDistributionKeychainUtility deleteKeychainItem:keychainQuery];
37+
38+
if (!success) {
39+
NSString *description = NSLocalizedString(
40+
@"Failed to clear auth state from keychain. Tester will overwrite data on sign in.",
41+
@"Error message for failure to retrieve auth state from keychain");
42+
[self handleAuthStateError:error
43+
description:description
44+
code:FIRAppDistributionErrorTokenDeletionFailure];
45+
return NO;
46+
}
47+
48+
return YES;
49+
}
50+
51+
+ (OIDAuthState *)retrieveAuthState:(NSError **_Nullable)error {
52+
NSMutableDictionary *keychainQuery = [self getKeyChainQuery];
53+
[keychainQuery setObject:(id)kCFBooleanTrue forKey:(id)kSecReturnData];
54+
[keychainQuery setObject:(id)kSecMatchLimitOne forKey:(id)kSecMatchLimit];
55+
NSData *passwordData = [FIRAppDistributionKeychainUtility fetchKeychainItemMatching:keychainQuery
56+
error:NULL];
57+
NSData *result = nil;
58+
59+
if (!passwordData) {
60+
NSString *description = NSLocalizedString(
61+
@"Failed to retrieve auth state from keychain. Tester will have to sign in again.",
62+
@"Error message for failure to retrieve auth state from keychain");
63+
[self handleAuthStateError:error
64+
description:description
65+
code:FIRAppDistributionErrorTokenRetrievalFailure];
66+
return nil;
67+
}
68+
69+
result = [passwordData copy];
70+
71+
if (!result) {
72+
NSString *description =
73+
NSLocalizedString(@"Failed to unarchive auth state. Tester will have to sign in again.",
74+
@"Error message for failure to retrieve auth state from keychain");
75+
[self handleAuthStateError:error
76+
description:description
77+
code:FIRAppDistributionErrorTokenRetrievalFailure];
78+
return nil;
79+
}
80+
81+
OIDAuthState *authState = [FIRAppDistributionKeychainUtility unarchiveKeychainResult:result];
82+
83+
return authState;
84+
}
85+
86+
+ (BOOL)persistAuthState:(OIDAuthState *)authState error:(NSError **_Nullable)error {
87+
NSData *authorizationData = [FIRAppDistributionKeychainUtility archiveDataForKeychain:authState];
88+
NSMutableDictionary *keychainQuery = [self getKeyChainQuery];
89+
BOOL success = NO;
90+
BOOL hasAuthState = [self retrieveAuthState:NULL];
91+
if (hasAuthState) {
92+
success = [FIRAppDistributionKeychainUtility updateKeychainItem:keychainQuery
93+
withDataDictionary:authorizationData];
94+
} else {
95+
success = [FIRAppDistributionKeychainUtility addKeychainItem:keychainQuery
96+
withDataDictionary:authorizationData];
97+
}
98+
99+
if (!success) {
100+
NSString *description = NSLocalizedString(
101+
@"Failed to persist auth state. Tester will have to sign in again after app close.",
102+
@"Error message for failure to persist auth state to keychain");
103+
[self handleAuthStateError:error
104+
description:description
105+
code:FIRAppDistributionErrorTokenPersistenceFailure];
106+
return NO;
107+
}
108+
109+
return YES;
110+
}
111+
112+
+ (NSMutableDictionary *)getKeyChainQuery {
113+
NSMutableDictionary *keychainQuery = [NSMutableDictionary
114+
dictionaryWithObjectsAndKeys:(id)kSecClassGenericPassword, (id)kSecClass, @"OAuth",
115+
(id)kSecAttrGeneric, @"OAuth", (id)kSecAttrAccount,
116+
@"fire-fad-auth", (id)kSecAttrService, nil];
117+
return keychainQuery;
118+
}
119+
120+
@end
121+
122+
NS_ASSUME_NONNULL_END
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
// Copyright 2020 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
#import <AppAuth/AppAuth.h>
15+
#import "FIRAppDistributionKeychainUtility+Private.h"
16+
17+
NSString *const kFIRAppDistributionKeychainErrorDomain = @"com.firebase.app_distribution.keychain";
18+
19+
@implementation FIRAppDistributionKeychainUtility
20+
21+
+ (void)handleAuthStateError:(NSError **_Nullable)error
22+
description:(NSString *)description
23+
code:(int)code {
24+
if (error) {
25+
NSDictionary *userInfo = @{NSLocalizedDescriptionKey : description};
26+
*error = [NSError errorWithDomain:kFIRAppDistributionKeychainErrorDomain
27+
code:code
28+
userInfo:userInfo];
29+
}
30+
}
31+
32+
+ (BOOL)addKeychainItem:(nonnull NSMutableDictionary *)keychainQuery
33+
withDataDictionary:(nonnull NSData *)data {
34+
[keychainQuery setObject:data forKey:(id)kSecValueData];
35+
OSStatus status = SecItemAdd((CFDictionaryRef)keychainQuery, NULL);
36+
37+
return status == noErr ? YES : NO;
38+
}
39+
40+
+ (BOOL)updateKeychainItem:(nonnull NSMutableDictionary *)keychainQuery
41+
withDataDictionary:(nonnull NSData *)data {
42+
OSStatus status =
43+
SecItemUpdate((CFDictionaryRef)keychainQuery, (CFDictionaryRef) @{(id)kSecValueData : data});
44+
return status == noErr ? YES : NO;
45+
}
46+
47+
+ (BOOL)deleteKeychainItem:(nonnull NSMutableDictionary *)keychainQuery {
48+
OSStatus status = SecItemDelete((CFDictionaryRef)keychainQuery);
49+
50+
return status != errSecSuccess && status != errSecItemNotFound ? NO : YES;
51+
}
52+
53+
+ (NSData *)fetchKeychainItemMatching:(nonnull NSMutableDictionary *)keychainQuery
54+
error:(NSError **_Nullable)error {
55+
NSData *keychainItem;
56+
OSStatus status = SecItemCopyMatching((CFDictionaryRef)keychainQuery, (void *)&keychainItem);
57+
58+
if (status != noErr || 0 == [keychainItem length]) {
59+
if (error) {
60+
NSString *description =
61+
NSLocalizedString(@"Failed to fetch keychain item.",
62+
@"Error message for failure to retrieve auth state from keychain");
63+
[self handleAuthStateError:error description:description code:0];
64+
return nil;
65+
}
66+
}
67+
68+
return keychainItem;
69+
}
70+
71+
+ (OIDAuthState *)unarchiveKeychainResult:(NSData *)result {
72+
return (OIDAuthState *)[NSKeyedUnarchiver unarchiveObjectWithData:result];
73+
}
74+
75+
+ (NSData *)archiveDataForKeychain:(OIDAuthState *)data {
76+
return [NSKeyedArchiver archivedDataWithRootObject:data];
77+
}
78+
79+
@end

FirebaseAppDistribution/Sources/FIRAppDistributionMachO.m

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,6 @@ - (instancetype)initWithPath:(NSString*)path {
3636
_slices = [NSMutableArray new];
3737
[self extractSlices];
3838
}
39-
4039
return self;
4140
}
4241

0 commit comments

Comments
 (0)