diff --git a/GoogleSignIn/Sources/GIDDevice/API/GIDDevice.h b/GoogleSignIn/Sources/GIDDevice/API/GIDDevice.h new file mode 100644 index 00000000..c91ac861 --- /dev/null +++ b/GoogleSignIn/Sources/GIDDevice/API/GIDDevice.h @@ -0,0 +1,39 @@ +/* + * Copyright 2026 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 + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// The subset of `UIDevice` that Google Sign-In reads when describing the device to Google's EMM +/// (Enterprise Mobility Management) backend. `UIDevice` conforms; see `UIDevice+GIDDevice.h`. +@protocol GIDDevice + +/// The name of the operating system, e.g. "iOS". +@property(nonatomic, readonly, copy) NSString *systemName; + +/// The version of the operating system, e.g. "17.0.1". +@property(nonatomic, readonly, copy) NSString *systemVersion; + +@end + +NS_ASSUME_NONNULL_END + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDDevice/Fake/GIDFakeDevice.h b/GoogleSignIn/Sources/GIDDevice/Fake/GIDFakeDevice.h new file mode 100644 index 00000000..38ef5b72 --- /dev/null +++ b/GoogleSignIn/Sources/GIDDevice/Fake/GIDFakeDevice.h @@ -0,0 +1,47 @@ +/* + * Copyright 2026 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 + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +#import + +#import "GoogleSignIn/Sources/GIDDevice/API/GIDDevice.h" + +NS_ASSUME_NONNULL_BEGIN + +/// An in-memory `GIDDevice` for tests. +@interface GIDFakeDevice : NSObject + +/// The operating system name to report. +@property(nonatomic, copy) NSString *systemName; + +/// The operating system version to report. +@property(nonatomic, copy) NSString *systemVersion; + +/// Initializes a fake device reporting `systemName` and `systemVersion`. +- (instancetype)initWithSystemName:(NSString *)systemName + systemVersion:(NSString *)systemVersion NS_DESIGNATED_INITIALIZER; + +/// Unavailable. Use `-initWithSystemName:systemVersion:`. +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDDevice/Fake/GIDFakeDevice.m b/GoogleSignIn/Sources/GIDDevice/Fake/GIDFakeDevice.m new file mode 100644 index 00000000..71fcf5d9 --- /dev/null +++ b/GoogleSignIn/Sources/GIDDevice/Fake/GIDFakeDevice.m @@ -0,0 +1,44 @@ +/* + * Copyright 2026 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 + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +#import "GoogleSignIn/Sources/GIDDevice/Fake/GIDFakeDevice.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation GIDFakeDevice + +@synthesize systemName = _systemName; +@synthesize systemVersion = _systemVersion; + +- (instancetype)initWithSystemName:(NSString *)systemName + systemVersion:(NSString *)systemVersion { + self = [super init]; + if (self) { + _systemName = [systemName copy]; + _systemVersion = [systemVersion copy]; + } + return self; +} + +@end + +NS_ASSUME_NONNULL_END + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDDevice/Implementation/UIDevice+GIDDevice.h b/GoogleSignIn/Sources/GIDDevice/Implementation/UIDevice+GIDDevice.h new file mode 100644 index 00000000..b7d98b56 --- /dev/null +++ b/GoogleSignIn/Sources/GIDDevice/Implementation/UIDevice+GIDDevice.h @@ -0,0 +1,34 @@ +/* + * Copyright 2026 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 + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +#import + +#import "GoogleSignIn/Sources/GIDDevice/API/GIDDevice.h" + +NS_ASSUME_NONNULL_BEGIN + +/// `UIDevice` already implements every `GIDDevice` requirement; this category only declares the +/// conformance. +@interface UIDevice (GIDDevice) +@end + +NS_ASSUME_NONNULL_END + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDDevice/Implementation/UIDevice+GIDDevice.m b/GoogleSignIn/Sources/GIDDevice/Implementation/UIDevice+GIDDevice.m new file mode 100644 index 00000000..51237e61 --- /dev/null +++ b/GoogleSignIn/Sources/GIDDevice/Implementation/UIDevice+GIDDevice.m @@ -0,0 +1,30 @@ +/* + * Copyright 2026 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 + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +#import "GoogleSignIn/Sources/GIDDevice/Implementation/UIDevice+GIDDevice.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation UIDevice (GIDDevice) +@end + +NS_ASSUME_NONNULL_END + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDEMMSupport.h b/GoogleSignIn/Sources/GIDEMMSupport.h index 7a5de88f..e9c4f602 100644 --- a/GoogleSignIn/Sources/GIDEMMSupport.h +++ b/GoogleSignIn/Sources/GIDEMMSupport.h @@ -22,25 +22,54 @@ @import GTMAppAuth; +#import "GoogleSignIn/Sources/GIDDevice/API/GIDDevice.h" +#import "GoogleSignIn/Sources/GIDEMMErrorHandler.h" + NS_ASSUME_NONNULL_BEGIN /// A class to support EMM (Enterprise Mobility Management). @interface GIDEMMSupport : NSObject -- (instancetype)init NS_DESIGNATED_INITIALIZER; +/// Creates an instance that reports EMM errors through `errorHandler` and describes the device +/// to the EMM backend using `device`. +- (instancetype)initWithErrorHandler:(GIDEMMErrorHandler *)errorHandler + device:(id)device NS_DESIGNATED_INITIALIZER; + +/// Creates an instance using `+[GIDEMMErrorHandler sharedInstance]` and `[UIDevice currentDevice]`. +- (instancetype)init; + +/// Handles potential EMM error from token fetch response. +- (void)handleTokenFetchEMMError:(nullable NSError *)error + completion:(void (^)(NSError *_Nullable))completion; + +/// Returns a new set of URL parameters that contains updated EMM-related URL parameters if needed. +- (NSDictionary *)updatedEMMParametersWithParameters: + (NSDictionary *)parameters; + +/// Returns a new set of URL parameters that also contains EMM-related URL parameters if needed. +/// +/// @note This function only returns parameter keys in the `NSDictionary` for string/number/bools. +- (NSDictionary *)parametersWithParameters: + (NSDictionary *)parameters + emmSupport:(nullable NSString *)emmSupport + isPasscodeInfoRequired:(BOOL)isPasscodeInfoRequired; /// Handles potential EMM error from token fetch response. +/// Equivalent to calling the instance method on `[[GIDEMMSupport alloc] init]`. + (void)handleTokenFetchEMMError:(nullable NSError *)error completion:(void (^)(NSError *_Nullable))completion; -/// Gets a new set of URL parameters that contains updated EMM-related URL parameters if needed. +/// Returns a new set of URL parameters that contains updated EMM-related URL parameters if needed. +/// Equivalent to calling the instance method on `[[GIDEMMSupport alloc] init]`. + (NSDictionary *)updatedEMMParametersWithParameters: - (NSDictionary *)parameters; + (NSDictionary *)parameters; -/// Gets a new set of URL parameters that also contains EMM-related URL parameters if needed. +/// Returns a new set of URL parameters that also contains EMM-related URL parameters if needed. +/// Equivalent to calling the instance method on `[[GIDEMMSupport alloc] init]`. /// /// @note This function only returns parameter keys in the `NSDictionary` for string/number/bools. -+ (NSDictionary *)parametersWithParameters:(NSDictionary *)parameters ++ (NSDictionary *)parametersWithParameters: + (NSDictionary *)parameters emmSupport:(nullable NSString *)emmSupport isPasscodeInfoRequired:(BOOL)isPasscodeInfoRequired; diff --git a/GoogleSignIn/Sources/GIDEMMSupport.m b/GoogleSignIn/Sources/GIDEMMSupport.m index 1940df4f..2f8f3ef3 100644 --- a/GoogleSignIn/Sources/GIDEMMSupport.m +++ b/GoogleSignIn/Sources/GIDEMMSupport.m @@ -22,6 +22,7 @@ #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h" +#import "GoogleSignIn/Sources/GIDDevice/Implementation/UIDevice+GIDDevice.h" #import "GoogleSignIn/Sources/GIDEMMErrorHandler.h" #import "GoogleSignIn/Sources/GIDMDMPasscodeState.h" @@ -58,18 +59,34 @@ typedef NS_ENUM(NSInteger, ErrorCode) { ErrorCodeAppVerificationRequired, }; -@implementation GIDEMMSupport +@implementation GIDEMMSupport { + // Reports EMM errors from token fetch responses. + GIDEMMErrorHandler *_errorHandler; + // Describes the device to the EMM backend. + id _device; +} + +- (instancetype)initWithErrorHandler:(GIDEMMErrorHandler *)errorHandler + device:(id)device { + self = [super init]; + if (self) { + _errorHandler = errorHandler; + _device = device; + } + return self; +} - (instancetype)init { - return [super init]; + return [self initWithErrorHandler:[GIDEMMErrorHandler sharedInstance] + device:[UIDevice currentDevice]]; } -+ (void)handleTokenFetchEMMError:(nullable NSError *)error +- (void)handleTokenFetchEMMError:(nullable NSError *)error completion:(void (^)(NSError *_Nullable))completion { NSDictionary *errorJSON = error.userInfo[OIDOAuthErrorResponseErrorKey]; if (errorJSON) { - [[GIDEMMErrorHandler sharedInstance] handleErrorFromResponse:errorJSON - completion:^(BOOL handled) { + [_errorHandler handleErrorFromResponse:errorJSON + completion:^(BOOL handled) { if (handled) { completion([NSError errorWithDomain:kGIDSignInErrorDomain code:kGIDSignInErrorCodeEMM @@ -83,14 +100,15 @@ + (void)handleTokenFetchEMMError:(nullable NSError *)error } } -+ (NSDictionary *)updatedEMMParametersWithParameters: - (NSDictionary *)parameters { +- (NSDictionary *)updatedEMMParametersWithParameters: + (NSDictionary *)parameters { return [self parametersWithParameters:parameters emmSupport:parameters[kEMMSupportParameterName] isPasscodeInfoRequired:parameters[kEMMPasscodeInfoParameterName] != nil]; } -+ (NSDictionary *)parametersWithParameters:(NSDictionary *)parameters +- (NSDictionary *)parametersWithParameters: + (NSDictionary *)parameters emmSupport:(nullable NSString *)emmSupport isPasscodeInfoRequired:(BOOL)isPasscodeInfoRequired { if (!emmSupport) { @@ -98,31 +116,49 @@ + (void)handleTokenFetchEMMError:(nullable NSError *)error } NSMutableDictionary *allParameters = [(parameters ?: @{}) mutableCopy]; allParameters[kEMMSupportParameterName] = emmSupport; - UIDevice *device = [UIDevice currentDevice]; - NSString *systemName = device.systemName; + NSString *systemName = _device.systemName; if ([systemName isEqualToString:kOldIOSSystemName]) { systemName = kNewIOSSystemName; } allParameters[kEMMOSVersionParameterName] = - [NSString stringWithFormat:@"%@ %@", systemName, device.systemVersion]; + [NSString stringWithFormat:@"%@ %@", systemName, _device.systemVersion]; if (isPasscodeInfoRequired) { allParameters[kEMMPasscodeInfoParameterName] = [GIDMDMPasscodeState passcodeState].info; } return [GIDEMMSupport dictionaryWithStringValuesFromDictionary:allParameters]; } ++ (void)handleTokenFetchEMMError:(nullable NSError *)error + completion:(void (^)(NSError *_Nullable))completion { + [[[self alloc] init] handleTokenFetchEMMError:error completion:completion]; +} + ++ (NSDictionary *)updatedEMMParametersWithParameters: + (NSDictionary *)parameters { + return [[[self alloc] init] updatedEMMParametersWithParameters:parameters]; +} + ++ (NSDictionary *)parametersWithParameters: + (NSDictionary *)parameters + emmSupport:(nullable NSString *)emmSupport + isPasscodeInfoRequired:(BOOL)isPasscodeInfoRequired { + return [[[self alloc] init] parametersWithParameters:parameters + emmSupport:emmSupport + isPasscodeInfoRequired:isPasscodeInfoRequired]; +} + #pragma mark - GTMAuthSessionDelegate - (nullable NSDictionary *) additionalTokenRefreshParametersForAuthSession:(GTMAuthSession *)authSession { - return [GIDEMMSupport updatedEMMParametersWithParameters: + return [self updatedEMMParametersWithParameters: authSession.authState.lastTokenResponse.additionalParameters]; } - (void)updateErrorForAuthSession:(GTMAuthSession *)authSession originalError:(NSError *)originalError completion:(void (^)(NSError * _Nullable))completion { - [GIDEMMSupport handleTokenFetchEMMError:originalError completion:^(NSError *_Nullable error) { + [self handleTokenFetchEMMError:originalError completion:^(NSError *_Nullable error) { completion(error); }]; } diff --git a/GoogleSignIn/Sources/GIDLocalAuthentication/API/GIDLocalAuthenticationContext.h b/GoogleSignIn/Sources/GIDLocalAuthentication/API/GIDLocalAuthenticationContext.h new file mode 100644 index 00000000..d8322361 --- /dev/null +++ b/GoogleSignIn/Sources/GIDLocalAuthentication/API/GIDLocalAuthenticationContext.h @@ -0,0 +1,37 @@ +/* + * Copyright 2026 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 + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +/// The subset of `LAContext` that Google Sign-In uses to probe whether a device passcode is set. +/// `LAContext` conforms; see `LAContext+GIDLocalAuthenticationContext.h`. +@protocol GIDLocalAuthenticationContext + +/// Returns whether `policy` can be evaluated, populating `error` when it cannot. +- (BOOL)canEvaluatePolicy:(LAPolicy)policy error:(NSError * _Nullable * _Nullable)error; + +@end + +NS_ASSUME_NONNULL_END + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDLocalAuthentication/Fake/GIDFakeLocalAuthenticationContext.h b/GoogleSignIn/Sources/GIDLocalAuthentication/Fake/GIDFakeLocalAuthenticationContext.h new file mode 100644 index 00000000..20f64269 --- /dev/null +++ b/GoogleSignIn/Sources/GIDLocalAuthentication/Fake/GIDFakeLocalAuthenticationContext.h @@ -0,0 +1,43 @@ +/* + * Copyright 2026 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 + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +#import + +#import "GoogleSignIn/Sources/GIDLocalAuthentication/API/GIDLocalAuthenticationContext.h" + +NS_ASSUME_NONNULL_BEGIN + +/// An in-memory `GIDLocalAuthenticationContext` for tests. +@interface GIDFakeLocalAuthenticationContext : NSObject + +/// The value `-canEvaluatePolicy:error:` returns. Defaults to YES. +@property(nonatomic) BOOL canEvaluatePolicyResult; + +/// The error `-canEvaluatePolicy:error:` reports, if any. Defaults to nil. +@property(nonatomic, nullable) NSError *canEvaluatePolicyError; + +/// Every policy passed to `-canEvaluatePolicy:error:`, in call order, boxed as `NSNumber`. +@property(nonatomic, readonly) NSArray *evaluatedPolicies; + +@end + +NS_ASSUME_NONNULL_END + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDLocalAuthentication/Fake/GIDFakeLocalAuthenticationContext.m b/GoogleSignIn/Sources/GIDLocalAuthentication/Fake/GIDFakeLocalAuthenticationContext.m new file mode 100644 index 00000000..e3aa0f5e --- /dev/null +++ b/GoogleSignIn/Sources/GIDLocalAuthentication/Fake/GIDFakeLocalAuthenticationContext.m @@ -0,0 +1,55 @@ +/* + * Copyright 2026 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 + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +#import "GoogleSignIn/Sources/GIDLocalAuthentication/Fake/GIDFakeLocalAuthenticationContext.h" + +NS_ASSUME_NONNULL_BEGIN + +@implementation GIDFakeLocalAuthenticationContext { + // Every policy passed to `-canEvaluatePolicy:error:`, in call order. + NSMutableArray *_evaluatedPolicies; +} + +- (instancetype)init { + self = [super init]; + if (self) { + _canEvaluatePolicyResult = YES; + _evaluatedPolicies = [NSMutableArray array]; + } + return self; +} + +- (NSArray *)evaluatedPolicies { + return [_evaluatedPolicies copy]; +} + +- (BOOL)canEvaluatePolicy:(LAPolicy)policy error:(NSError * _Nullable * _Nullable)error { + [_evaluatedPolicies addObject:@(policy)]; + if (error) { + *error = self.canEvaluatePolicyError; + } + return self.canEvaluatePolicyResult; +} + +@end + +NS_ASSUME_NONNULL_END + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDLocalAuthentication/Implementation/LAContext+GIDLocalAuthenticationContext.h b/GoogleSignIn/Sources/GIDLocalAuthentication/Implementation/LAContext+GIDLocalAuthenticationContext.h new file mode 100644 index 00000000..da49c142 --- /dev/null +++ b/GoogleSignIn/Sources/GIDLocalAuthentication/Implementation/LAContext+GIDLocalAuthenticationContext.h @@ -0,0 +1,34 @@ +/* + * Copyright 2026 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 + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +#import + +#import "GoogleSignIn/Sources/GIDLocalAuthentication/API/GIDLocalAuthenticationContext.h" + +NS_ASSUME_NONNULL_BEGIN + +/// `LAContext` already implements every `GIDLocalAuthenticationContext` requirement; this category +/// only declares the conformance. +@interface LAContext (GIDLocalAuthenticationContext) +@end + +NS_ASSUME_NONNULL_END + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDLocalAuthentication/Implementation/LAContext+GIDLocalAuthenticationContext.m b/GoogleSignIn/Sources/GIDLocalAuthentication/Implementation/LAContext+GIDLocalAuthenticationContext.m new file mode 100644 index 00000000..de2ccd75 --- /dev/null +++ b/GoogleSignIn/Sources/GIDLocalAuthentication/Implementation/LAContext+GIDLocalAuthenticationContext.m @@ -0,0 +1,30 @@ +/* + * Copyright 2026 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 + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +#import "GoogleSignIn/Sources/GIDLocalAuthentication/Implementation/LAContext+GIDLocalAuthenticationContext.h" // NOLINT + +NS_ASSUME_NONNULL_BEGIN + +@implementation LAContext (GIDLocalAuthenticationContext) +@end + +NS_ASSUME_NONNULL_END + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDMDMPasscodeCache.h b/GoogleSignIn/Sources/GIDMDMPasscodeCache.h index 569fc22a..4dcb1054 100644 --- a/GoogleSignIn/Sources/GIDMDMPasscodeCache.h +++ b/GoogleSignIn/Sources/GIDMDMPasscodeCache.h @@ -19,6 +19,8 @@ #import +#import "GoogleSignIn/Sources/GIDLocalAuthentication/API/GIDLocalAuthenticationContext.h" + @class GIDMDMPasscodeState; NS_ASSUME_NONNULL_BEGIN @@ -34,6 +36,17 @@ NS_ASSUME_NONNULL_BEGIN */ + (instancetype)sharedInstance; +/** + * Creates a cache that probes the passcode state through `localAuthenticationContext`. Pass nil + * when LocalAuthentication is unavailable; the cache then reports only the Keychain probe. + */ +- (instancetype)initWithLocalAuthenticationContext: + (nullable id)localAuthenticationContext + NS_DESIGNATED_INITIALIZER; + +/** Unavailable; use `+sharedInstance` or the designated initializer. */ +- (instancetype)init NS_UNAVAILABLE; + /** * Retrieves the current passcode state. */ diff --git a/GoogleSignIn/Sources/GIDMDMPasscodeCache.m b/GoogleSignIn/Sources/GIDMDMPasscodeCache.m index bedb3a31..97f083d5 100644 --- a/GoogleSignIn/Sources/GIDMDMPasscodeCache.m +++ b/GoogleSignIn/Sources/GIDMDMPasscodeCache.m @@ -22,6 +22,7 @@ #import #import +#import "GoogleSignIn/Sources/GIDLocalAuthentication/Implementation/LAContext+GIDLocalAuthenticationContext.h" // NOLINT #import "GoogleSignIn/Sources/GIDMDMPasscodeState.h" #import "GoogleSignIn/Sources/GIDMDMPasscodeState_Private.h" @@ -55,6 +56,9 @@ static const int64_t kObtainKeychainInfoWaitTime = 3 * NSEC_PER_SEC; @implementation GIDMDMPasscodeCache { + /** Probes the passcode state; nil when LocalAuthentication is unavailable. */ + id _localAuthenticationContext; + /** Whether or not LocalAuthentication API is available. */ BOOL _hasLocalAuthentication; @@ -77,10 +81,12 @@ @implementation GIDMDMPasscodeCache { GIDMDMPasscodeState *_cachedState; } -- (instancetype)init { +- (instancetype)initWithLocalAuthenticationContext: + (nullable id)localAuthenticationContext { self = [super init]; if (self) { - _hasLocalAuthentication = [self hasLocalAuthentication]; + _localAuthenticationContext = localAuthenticationContext; + _hasLocalAuthentication = (localAuthenticationContext != nil) && [self hasLocalAuthentication]; _hasKeychain = [self hasKeychain]; [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(applicationDidEnterBackground:) @@ -98,7 +104,15 @@ + (instancetype)sharedInstance { static GIDMDMPasscodeCache *sharedInstance; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ - sharedInstance = [[GIDMDMPasscodeCache alloc] init]; + LAContext *context; + @try { + context = [[LAContext alloc] init]; + } @catch (NSException *) { + // In theory there should be no exceptions but in practice there may be: + // b/23200390, b/23218643. + context = nil; + } + sharedInstance = [[GIDMDMPasscodeCache alloc] initWithLocalAuthenticationContext:context]; }); return sharedInstance; } @@ -185,18 +199,11 @@ - (void)obtainLocalAuthenticationInfo { NSLog(@"Calling LocalAuthentication API for device passcode state..."); #endif _hasEnteredBackground = NO; - static LAContext *context; - @try { - if (!context) { - context = [[LAContext alloc] init]; - } - } @catch (NSException *) { - // In theory there should be no exceptions but in practice there may be: b/23200390, b/23218643. - return; - } int result; NSError *error; - result = [context canEvaluatePolicy:LAPolicyDeviceOwnerAuthentication error:&error] ? 1 : 0; + result = + [_localAuthenticationContext canEvaluatePolicy:LAPolicyDeviceOwnerAuthentication + error:&error] ? 1 : 0; if (error) { _localAuthenticationInfo = @{ kResultKey : @(result), diff --git a/GoogleSignIn/Tests/Unit/GIDMDMPasscodeStateTests.m b/GoogleSignIn/Tests/Unit/GIDMDMPasscodeStateTests.m index 785d841e..6c14dcd0 100644 --- a/GoogleSignIn/Tests/Unit/GIDMDMPasscodeStateTests.m +++ b/GoogleSignIn/Tests/Unit/GIDMDMPasscodeStateTests.m @@ -21,71 +21,34 @@ #import #import +#import "GoogleSignIn/Sources/GIDLocalAuthentication/Fake/GIDFakeLocalAuthenticationContext.h" +#import "GoogleSignIn/Sources/GIDMDMPasscodeCache.h" #import "GoogleSignIn/Sources/GIDMDMPasscodeState.h" -#import -#import - @interface GIDMDMPasscodeStateTests : XCTestCase @end @implementation GIDMDMPasscodeStateTests { - /** Whether or not the iOS version is equal or greater than 9.0. */ - BOOL _isIOS9orAbove; - - /** Whether or not `canEvaluatePolicy:error:` method has been called. */ - BOOL _canEvaluatePolicyCalled; - - /** The next result to be returned from the `canEvaluatePolicy:error:` method. */ - BOOL _nextCanEvaluatePolicyResult; - - /** The next error to be returned from the `canEvaluatePolicy:error:` method. */ - NSError *_nextCanEvaluatePolicyError; + /** Stands in for the `LAContext` the cache probes. */ + GIDFakeLocalAuthenticationContext *_context; + /** The cache under test. */ + GIDMDMPasscodeCache *_cache; } - (void)setUp { [super setUp]; - _isIOS9orAbove = [[NSProcessInfo processInfo] - isOperatingSystemAtLeastVersion:(NSOperatingSystemVersion){.majorVersion = 9}]; - if (!_isIOS9orAbove) { - return; - } - _canEvaluatePolicyCalled = NO; - id canEvaluatePolicyError = ^BOOL(id context, LAPolicy policy, NSError * _Nullable *error) { - self->_canEvaluatePolicyCalled = YES; - XCTAssertEqual(policy, LAPolicyDeviceOwnerAuthentication); - if (error) { - *error = self->_nextCanEvaluatePolicyError; - } - return self->_nextCanEvaluatePolicyResult; - }; - [GULSwizzler swizzleClass:[LAContext class] - selector:@selector(canEvaluatePolicy:error:) - isClassSelector:NO - withBlock:canEvaluatePolicyError]; - [self postApplicationDidEnterBackgroundNotification]; -} - -- (void)tearDown { - if (!_isIOS9orAbove) { - return; - } - [GULSwizzler unswizzleClass:[LAContext class] - selector:@selector(canEvaluatePolicy:error:) - isClassSelector:NO]; + _context = [[GIDFakeLocalAuthenticationContext alloc] init]; + _cache = [[GIDMDMPasscodeCache alloc] initWithLocalAuthenticationContext:_context]; } /** * Verifies the correct response when LocalAuthentication API returns without an error. */ - (void)testLocalAuthenticationNoError { - if (!_isIOS9orAbove) { - return; - } - _nextCanEvaluatePolicyResult = YES; - _nextCanEvaluatePolicyError = nil; - GIDMDMPasscodeState *passcodeState = [GIDMDMPasscodeState passcodeState]; - XCTAssertTrue(_canEvaluatePolicyCalled); + _context.canEvaluatePolicyResult = YES; + _context.canEvaluatePolicyError = nil; + GIDMDMPasscodeState *passcodeState = [_cache passcodeState]; + XCTAssertEqualObjects(_context.evaluatedPolicies, @[ @(LAPolicyDeviceOwnerAuthentication) ]); XCTAssertEqualObjects(passcodeState.status, @"YES"); NSDictionary *dict = [self dictWithEncodedString:passcodeState.info]; [self assertJSONNumber:dict[@"LocalAuthentication"][@"result"] isInteger:1]; @@ -97,17 +60,14 @@ - (void)testLocalAuthenticationNoError { * Verifies the correct response when LocalAuthentication API returns with an error. */ - (void)testLocalAuthenticationHasError { - if (!_isIOS9orAbove) { - return; - } NSString *fakeErrorDomain = @"asdf.hjkl"; NSInteger fakeErrorCode = -12345; - _nextCanEvaluatePolicyResult = NO; - _nextCanEvaluatePolicyError = [NSError errorWithDomain:fakeErrorDomain - code:fakeErrorCode - userInfo:nil]; - GIDMDMPasscodeState *passcodeState = [GIDMDMPasscodeState passcodeState]; - XCTAssertTrue(_canEvaluatePolicyCalled); + _context.canEvaluatePolicyResult = NO; + _context.canEvaluatePolicyError = [NSError errorWithDomain:fakeErrorDomain + code:fakeErrorCode + userInfo:nil]; + GIDMDMPasscodeState *passcodeState = [_cache passcodeState]; + XCTAssertEqualObjects(_context.evaluatedPolicies, @[ @(LAPolicyDeviceOwnerAuthentication) ]); XCTAssertEqualObjects(passcodeState.status, @"NO"); NSDictionary *dict = [self dictWithEncodedString:passcodeState.info]; [self assertJSONNumber:dict[@"LocalAuthentication"][@"result"] isInteger:0]; @@ -119,20 +79,16 @@ - (void)testLocalAuthenticationHasError { * Verifies caching behavior regarding to calling LocalAuthentication API. */ - (void)testLocalAuthenticationCache { - if (!_isIOS9orAbove) { - return; - } - GIDMDMPasscodeState *oldPasscodeState = [GIDMDMPasscodeState passcodeState]; - _canEvaluatePolicyCalled = false; - GIDMDMPasscodeState *newPasscodeState = [GIDMDMPasscodeState passcodeState]; - XCTAssertFalse(_canEvaluatePolicyCalled); + GIDMDMPasscodeState *oldPasscodeState = [_cache passcodeState]; + GIDMDMPasscodeState *newPasscodeState = [_cache passcodeState]; + XCTAssertEqual(_context.evaluatedPolicies.count, 1u); XCTAssertEqualObjects(oldPasscodeState.status, newPasscodeState.status); XCTAssertEqualObjects(oldPasscodeState.info, newPasscodeState.info); - // Verify that the cache is cleared after background notification. + // Verify that the cache re-probes LocalAuthentication after the app enters the background. [self postApplicationDidEnterBackgroundNotification]; - [GIDMDMPasscodeState passcodeState]; - XCTAssertTrue(_canEvaluatePolicyCalled); + [_cache passcodeState]; + XCTAssertEqual(_context.evaluatedPolicies.count, 2u); } /** @@ -140,11 +96,18 @@ - (void)testLocalAuthenticationCache { * Keychain API is in C thus there is no easy way to swizzler them. */ - (void)testKeychain { - GIDMDMPasscodeState *passcodeState = [GIDMDMPasscodeState passcodeState]; + GIDMDMPasscodeState *passcodeState = [_cache passcodeState]; NSDictionary *dict = [self dictWithEncodedString:passcodeState.info]; XCTAssertTrue([dict[@"Keychain"][@"result"] isKindOfClass:[NSNumber class]]); } +/** Verifies `+[GIDMDMPasscodeState passcodeState]` returns a state with a status and info. */ +- (void)testPasscodeStateClassMethod { + GIDMDMPasscodeState *passcodeState = [GIDMDMPasscodeState passcodeState]; + XCTAssertNotNil(passcodeState.status); + XCTAssertNotNil([self dictWithEncodedString:passcodeState.info]); +} + #pragma mark - Helpers /** diff --git a/GoogleSignIn/Tests/Unit/GIDSignInTest.m b/GoogleSignIn/Tests/Unit/GIDSignInTest.m index 1cd8b695..acc147e1 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInTest.m @@ -1936,8 +1936,8 @@ - (void)testTokenEndpointEMMError { code:12345 userInfo:@{ OIDOAuthErrorFieldError : errorJSON }]; id emmSupport = OCMStrictClassMock([GIDEMMSupport class]); - [[emmSupport expect] handleTokenFetchEMMError:emmError - completion:SAVE_TO_ARG_BLOCK(completion)]; + [[[emmSupport expect] classMethod] handleTokenFetchEMMError:emmError + completion:SAVE_TO_ARG_BLOCK(completion)]; [self OAuthLoginWithAddScopesFlow:NO authError:nil