diff --git a/GoogleSignIn/Sources/GIDRestrictedScopesRegistry.h b/GoogleSignIn/Sources/GIDRestrictedScopesRegistry.h new file mode 100644 index 00000000..e07b455f --- /dev/null +++ b/GoogleSignIn/Sources/GIDRestrictedScopesRegistry.h @@ -0,0 +1,53 @@ +/* + * Copyright 2024 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 + +/// A registry to manage restricted scopes and their associated handling classes to track scopes +/// that require separate flows within an application. +@interface GIDRestrictedScopesRegistry : NSObject + +/// A set of strings representing the restricted scopes. +@property (nonatomic, strong, readonly) NSSet *restrictedScopes; + +/// A dictionary mapping restricted scopes to their corresponding handling classes. +@property (nonatomic, strong, readonly) NSDictionary *scopeToClassMapping; + +/// This designated initializer sets up the initial restricted scopes and their corresponding handling classes. +/// +/// @return An initialized `GIDRestrictedScopesRegistry` instance +- (instancetype)init; + +/// Checks if a given scope is restricted. +/// +/// @param scope The scope to check. +/// @return YES if the scope is restricted; otherwise, NO. +- (BOOL)isScopeRestricted:(NSString *)scope; + +/// Retrieves a dictionary mapping restricted scopes to their handling classes within a given set of scopes. +/// +/// @param scopes A set of scopes to lookup their handling class. +/// @return A dictionary where restricted scopes found in the input set are mapped to their corresponding handling classes. +/// If no restricted scopes are found, an empty dictionary is returned. +- (NSDictionary *)restrictedScopesToClassMappingInSet:(NSSet *)scopes; + +@end + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDRestrictedScopesRegistry.m b/GoogleSignIn/Sources/GIDRestrictedScopesRegistry.m new file mode 100644 index 00000000..c2b11ca5 --- /dev/null +++ b/GoogleSignIn/Sources/GIDRestrictedScopesRegistry.m @@ -0,0 +1,52 @@ +// Copyright 2024 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 "GoogleSignIn/Sources/GIDRestrictedScopesRegistry.h" + +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDVerifiableAccountDetail.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDVerifyAccountDetail.h" + +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + +@implementation GIDRestrictedScopesRegistry + +- (instancetype)init { + self = [super init]; + if (self) { + _restrictedScopes = [NSSet setWithObjects:kAccountDetailTypeAgeOver18Scope, nil]; + _scopeToClassMapping = @{ + kAccountDetailTypeAgeOver18Scope: [GIDVerifyAccountDetail class], + }; + } + return self; +} + +- (BOOL)isScopeRestricted:(NSString *)scope { + return [self.restrictedScopes containsObject:scope]; +} + +- (NSDictionary *)restrictedScopesToClassMappingInSet:(NSSet *)scopes { + NSMutableDictionary *mapping = [NSMutableDictionary dictionary]; + for (NSString *scope in scopes) { + if ([self isScopeRestricted:scope]) { + Class handlingClass = self.scopeToClassMapping[scope]; + mapping[scope] = handlingClass; + } + } + return [mapping copy]; +} + +@end + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDSignIn.m b/GoogleSignIn/Sources/GIDSignIn.m index bb4b4a00..2e27d0ed 100644 --- a/GoogleSignIn/Sources/GIDSignIn.m +++ b/GoogleSignIn/Sources/GIDSignIn.m @@ -20,12 +20,15 @@ #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDGoogleUser.h" #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDProfileData.h" #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignInResult.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDVerifiableAccountDetail.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDVerifyAccountDetail.h" #import "GoogleSignIn/Sources/GIDAuthorizationResponse/GIDAuthorizationResponseHelper.h" #import "GoogleSignIn/Sources/GIDAuthorizationResponse/Implementations/GIDAuthorizationResponseHandler.h" #import "GoogleSignIn/Sources/GIDAuthFlow.h" #import "GoogleSignIn/Sources/GIDEMMSupport.h" +#import "GoogleSignIn/Sources/GIDRestrictedScopesRegistry.h" #import "GoogleSignIn/Sources/GIDSignInConstants.h" #import "GoogleSignIn/Sources/GIDSignInInternalOptions.h" #import "GoogleSignIn/Sources/GIDSignInPreferences.h" @@ -142,6 +145,8 @@ @implementation GIDSignIn { GIDTimedLoader *_timedLoader; // Flag indicating developer's intent to use App Check. BOOL _configureAppCheckCalled; + // The class used to manage restricted scopes and their associated handling classes. + GIDRestrictedScopesRegistry *_registry; #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST } @@ -256,6 +261,10 @@ - (void)addScopes:(NSArray *)scopes loginHint:self.currentUser.profile.email addScopesFlow:YES completion:completion]; +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST + // Explicitly throw an exception for invalid or restricted scopes in the request. + [self assertValidScopes:scopes]; +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST NSSet *requestedScopes = [NSSet setWithArray:scopes]; NSMutableSet *grantedScopes = @@ -499,6 +508,7 @@ - (instancetype)initWithKeychainStore:(GTMKeychainStore *)keychainStore { callbackPath:kBrowserCallbackPath keychainName:kGTMAppAuthKeychainName isFreshInstall:isFreshInstall]; + _registry = [[GIDRestrictedScopesRegistry alloc] init]; #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST } return self; @@ -989,6 +999,27 @@ - (void)assertValidPresentingViewController { } } +#if TARGET_OS_IOS && !TARGET_OS_MACCATALYST +- (void)assertValidScopes:(NSArray *)scopes { + NSDictionary *restrictedScopesMapping = + [_registry restrictedScopesToClassMappingInSet:[NSSet setWithArray:scopes]]; + + if (restrictedScopesMapping.count > 0) { + NSMutableString *errorMessage = + [NSMutableString stringWithString:@"The following scopes are not supported in the 'addScopes' flow. " + "Please use the appropriate classes to handle these:\n"]; + [restrictedScopesMapping enumerateKeysAndObjectsUsingBlock:^(NSString * _Nonnull restrictedScope, + Class _Nonnull handlingClass, + BOOL * _Nonnull stop) { + [errorMessage appendFormat:@"%@ -> %@\n", restrictedScope, NSStringFromClass(handlingClass)]; + }]; + // NOLINTNEXTLINE(google-objc-avoid-throwing-exception) + [NSException raise:NSInvalidArgumentException + format:@"%@", errorMessage]; + } +} +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST + // Checks whether or not this is the first time the app runs. - (BOOL)isFreshInstall { NSUserDefaults* defaults = [NSUserDefaults standardUserDefaults]; diff --git a/GoogleSignIn/Tests/Unit/GIDRestrictedScopesRegistryTest.m b/GoogleSignIn/Tests/Unit/GIDRestrictedScopesRegistryTest.m new file mode 100644 index 00000000..eb67e6ae --- /dev/null +++ b/GoogleSignIn/Tests/Unit/GIDRestrictedScopesRegistryTest.m @@ -0,0 +1,46 @@ +// Copyright 2024 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/GIDRestrictedScopesRegistry.h" + +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDVerifiableAccountDetail.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDVerifyAccountDetail.h" + +@interface GIDRestrictedScopesRegistryTest : XCTestCase +@end + +@implementation GIDRestrictedScopesRegistryTest + +- (void)testIsScopeRestricted { + GIDRestrictedScopesRegistry *registry = [[GIDRestrictedScopesRegistry alloc] init]; + BOOL isRestricted = [registry isScopeRestricted:kAccountDetailTypeAgeOver18Scope]; + XCTAssertTrue(isRestricted); +} + +- (void)testRestrictedScopesToClassMappingInSet { + GIDRestrictedScopesRegistry *registry = [[GIDRestrictedScopesRegistry alloc] init]; + NSSet *scopes = [NSSet setWithObjects:kAccountDetailTypeAgeOver18Scope, @"some_other_scope", nil]; + NSDictionary *mapping = [registry restrictedScopesToClassMappingInSet:scopes]; + + XCTAssertEqual(mapping.count, 1); + XCTAssertEqualObjects(mapping[kAccountDetailTypeAgeOver18Scope], [GIDVerifyAccountDetail class]); + XCTAssertNil(mapping[@"some_other_scope"]); +} + +@end + +#endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Tests/Unit/GIDSignInTest.m b/GoogleSignIn/Tests/Unit/GIDSignInTest.m index e81226a6..4ff48b5f 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInTest.m @@ -1300,6 +1300,29 @@ - (void)testTokenEndpointEMMError { XCTAssertEqualObjects(_authError.domain, kGIDSignInErrorDomain); XCTAssertEqual(_authError.code, kGIDSignInErrorCodeEMM); XCTAssertNil(_signIn.currentUser, @"should not have current user"); +} + +- (void)testValidScopesException { + NSString *requestedScope = @"https://www.googleapis.com/auth/verified.age.over18.standard"; + NSString *expectedException = + [NSString stringWithFormat:@"The following scopes are not supported in the 'addScopes' flow. " + "Please use the appropriate classes to handle these:\n%@ -> %@\n", + requestedScope, NSStringFromClass([GIDVerifyAccountDetail class])]; + BOOL threw = NO; + @try { + [_signIn addScopes:@[requestedScope] +#if TARGET_OS_IOS || TARGET_OS_MACCATALYST + presentingViewController:_presentingViewController +#elif TARGET_OS_OSX + presentingWindow:_presentingWindow +#endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST + completion:_completion]; + } @catch (NSException *exception) { + threw = YES; + XCTAssertEqualObjects(exception.description, expectedException); + } @finally { + } + XCTAssert(threw); // TODO: Keep mocks from carrying forward to subsequent tests. (#410) [_authState stopMocking];