diff --git a/GoogleSignIn/Sources/GIDSignIn.m b/GoogleSignIn/Sources/GIDSignIn.m index 1c043735..4da18b2c 100644 --- a/GoogleSignIn/Sources/GIDSignIn.m +++ b/GoogleSignIn/Sources/GIDSignIn.m @@ -28,6 +28,7 @@ #import "GoogleSignIn/Sources/GIDCallbackQueue.h" #import "GoogleSignIn/Sources/GIDScopes.h" #import "GoogleSignIn/Sources/GIDSignInCallbackSchemes.h" +#import "GoogleSignIn/Sources/GIDTokenClaimsInternalOptions.h" #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST #import #import "GoogleSignIn/Sources/GIDAppCheck/Implementations/GIDAppCheck.h" @@ -136,6 +137,9 @@ static NSString *const kLoginHintParameter = @"login_hint"; static NSString *const kHostedDomainParameter = @"hd"; +// Parameter for requesting the token claims. +static NSString *const kTokenClaimsParameter = @"claims"; + // Parameters for auth and token exchange endpoints using App Attest. static NSString *const kClientAssertionParameter = @"client_assertion"; static NSString *const kClientAssertionTypeParameter = @"client_assertion_type"; @@ -169,6 +173,7 @@ @implementation GIDSignIn { // set when a sign-in flow is begun via |signInWithOptions:| when the options passed don't // represent a sign in continuation. GIDSignInInternalOptions *_currentOptions; + GIDTokenClaimsInternalOptions *_tokenClaimsInternalOptions; #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST GIDAppCheck *_appCheck API_AVAILABLE(ios(14)); #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST @@ -284,14 +289,63 @@ - (void)signInWithPresentingViewController:(UIViewController *)presentingViewCon additionalScopes:(nullable NSArray *)additionalScopes nonce:(nullable NSString *)nonce completion:(nullable GIDSignInCompletion)completion { + [self signInWithPresentingViewController:presentingViewController + hint:hint + additionalScopes:additionalScopes + nonce:nonce + tokenClaims:nil + completion:completion]; +} + +- (void)signInWithPresentingViewController:(UIViewController *)presentingViewController + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable GIDSignInCompletion)completion { + [self signInWithPresentingViewController:presentingViewController + hint:nil + tokenClaims:tokenClaims + completion:completion]; +} + +- (void)signInWithPresentingViewController:(UIViewController *)presentingViewController + hint:(nullable NSString *)hint + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable GIDSignInCompletion)completion { + [self signInWithPresentingViewController:presentingViewController + hint:hint + additionalScopes:@[] + tokenClaims:tokenClaims + completion:completion]; +} + +- (void)signInWithPresentingViewController:(UIViewController *)presentingViewController + hint:(nullable NSString *)hint + additionalScopes:(nullable NSArray *)additionalScopes + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable GIDSignInCompletion)completion { + [self signInWithPresentingViewController:presentingViewController + hint:hint + additionalScopes:additionalScopes + nonce:nil + tokenClaims:tokenClaims + completion:completion]; +} + + +- (void)signInWithPresentingViewController:(UIViewController *)presentingViewController + hint:(nullable NSString *)hint + additionalScopes:(nullable NSArray *)additionalScopes + nonce:(nullable NSString *)nonce + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable GIDSignInCompletion)completion { GIDSignInInternalOptions *options = - [GIDSignInInternalOptions defaultOptionsWithConfiguration:_configuration - presentingViewController:presentingViewController - loginHint:hint - addScopesFlow:NO - scopes:additionalScopes - nonce:nonce - completion:completion]; + [GIDSignInInternalOptions defaultOptionsWithConfiguration:_configuration + presentingViewController:presentingViewController + loginHint:hint + addScopesFlow:NO + scopes:additionalScopes + nonce:nonce + tokenClaims:tokenClaims + completion:completion]; [self signInWithOptions:options]; } @@ -375,14 +429,62 @@ - (void)signInWithPresentingWindow:(NSWindow *)presentingWindow additionalScopes:(nullable NSArray *)additionalScopes nonce:(nullable NSString *)nonce completion:(nullable GIDSignInCompletion)completion { + [self signInWithPresentingWindow:presentingWindow + hint:hint + additionalScopes:additionalScopes + nonce:nonce + tokenClaims:nil + completion:completion]; +} + +- (void)signInWithPresentingWindow:(NSWindow *)presentingWindow + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable GIDSignInCompletion)completion { + [self signInWithPresentingWindow:presentingWindow + hint:nil + tokenClaims:tokenClaims + completion:completion]; +} + +- (void)signInWithPresentingWindow:(NSWindow *)presentingWindow + hint:(nullable NSString *)hint + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable GIDSignInCompletion)completion { + [self signInWithPresentingWindow:presentingWindow + hint:hint + additionalScopes:@[] + tokenClaims:tokenClaims + completion:completion]; +} + +- (void)signInWithPresentingWindow:(NSWindow *)presentingWindow + hint:(nullable NSString *)hint + additionalScopes:(nullable NSArray *)additionalScopes + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable GIDSignInCompletion)completion { + [self signInWithPresentingWindow:presentingWindow + hint:hint + additionalScopes:additionalScopes + nonce:nil + tokenClaims:tokenClaims + completion:completion]; +} + +- (void)signInWithPresentingWindow:(NSWindow *)presentingWindow + hint:(nullable NSString *)hint + additionalScopes:(nullable NSArray *)additionalScopes + nonce:(nullable NSString *)nonce + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable GIDSignInCompletion)completion { GIDSignInInternalOptions *options = - [GIDSignInInternalOptions defaultOptionsWithConfiguration:_configuration - presentingWindow:presentingWindow - loginHint:hint - addScopesFlow:NO - scopes:additionalScopes - nonce:nonce - completion:completion]; + [GIDSignInInternalOptions defaultOptionsWithConfiguration:_configuration + presentingWindow:presentingWindow + loginHint:hint + addScopesFlow:NO + scopes:additionalScopes + nonce:nonce + tokenClaims:tokenClaims + completion:completion]; [self signInWithOptions:options]; } @@ -542,6 +644,7 @@ - (instancetype)initWithKeychainStore:(GTMKeychainStore *)keychainStore self = [super init]; if (self) { _keychainStore = keychainStore; + _tokenClaimsInternalOptions = [[GIDTokenClaimsInternalOptions alloc] init]; // Get the bundle of the current executable. NSBundle *bundle = NSBundle.mainBundle; @@ -636,6 +739,21 @@ - (void)signInWithOptions:(GIDSignInInternalOptions *)options { } }]; } else { + NSError *claimsError; + + // If tokenClaims are invalid or JSON serialization fails, return with an error. + options.tokenClaimsAsJSON = [_tokenClaimsInternalOptions + validatedJSONStringForClaims:options.tokenClaims + error:&claimsError]; + if (claimsError) { + if (options.completion) { + self->_currentOptions = nil; + dispatch_async(dispatch_get_main_queue(), ^{ + options.completion(nil, claimsError); + }); + } + return; + } [self authenticateWithOptions:options]; } } @@ -765,6 +883,9 @@ - (void)authorizationRequestWithOptions:(GIDSignInInternalOptions *)options comp if (options.configuration.hostedDomain) { additionalParameters[kHostedDomainParameter] = options.configuration.hostedDomain; } + if (options.tokenClaimsAsJSON) { + additionalParameters[kTokenClaimsParameter] = options.tokenClaimsAsJSON; + } #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST [additionalParameters addEntriesFromDictionary: diff --git a/GoogleSignIn/Sources/GIDSignInInternalOptions.h b/GoogleSignIn/Sources/GIDSignInInternalOptions.h index 1ea78f46..f21d75d7 100644 --- a/GoogleSignIn/Sources/GIDSignInInternalOptions.h +++ b/GoogleSignIn/Sources/GIDSignInInternalOptions.h @@ -68,6 +68,12 @@ NS_ASSUME_NONNULL_BEGIN /// and to mitigate replay attacks. @property(nonatomic, readonly, copy, nullable) NSString *nonce; +/// The tokenClaims requested by the Clients. +@property(nonatomic, readonly, copy, nullable) NSSet *tokenClaims; + +/// The JSON token claims to be used during the flow. +@property(nonatomic, copy, nullable) NSString *tokenClaimsAsJSON; + /// Creates the default options. #if TARGET_OS_IOS || TARGET_OS_MACCATALYST + (instancetype)defaultOptionsWithConfiguration:(nullable GIDConfiguration *)configuration @@ -82,6 +88,7 @@ NS_ASSUME_NONNULL_BEGIN addScopesFlow:(BOOL)addScopesFlow scopes:(nullable NSArray *)scopes nonce:(nullable NSString *)nonce + tokenClaims:(nullable NSSet *)tokenClaims completion:(nullable GIDSignInCompletion)completion; #elif TARGET_OS_OSX @@ -97,6 +104,7 @@ NS_ASSUME_NONNULL_BEGIN addScopesFlow:(BOOL)addScopesFlow scopes:(nullable NSArray *)scopes nonce:(nullable NSString *)nonce + tokenClaims:(nullable NSSet *)tokenClaims completion:(nullable GIDSignInCompletion)completion; #endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST diff --git a/GoogleSignIn/Sources/GIDSignInInternalOptions.m b/GoogleSignIn/Sources/GIDSignInInternalOptions.m index 523bd48d..0799906a 100644 --- a/GoogleSignIn/Sources/GIDSignInInternalOptions.m +++ b/GoogleSignIn/Sources/GIDSignInInternalOptions.m @@ -32,6 +32,7 @@ + (instancetype)defaultOptionsWithConfiguration:(nullable GIDConfiguration *)con addScopesFlow:(BOOL)addScopesFlow scopes:(nullable NSArray *)scopes nonce:(nullable NSString *)nonce + tokenClaims:(nullable NSSet *)tokenClaims completion:(nullable GIDSignInCompletion)completion { #elif TARGET_OS_OSX + (instancetype)defaultOptionsWithConfiguration:(nullable GIDConfiguration *)configuration @@ -40,6 +41,7 @@ + (instancetype)defaultOptionsWithConfiguration:(nullable GIDConfiguration *)con addScopesFlow:(BOOL)addScopesFlow scopes:(nullable NSArray *)scopes nonce:(nullable NSString *)nonce + tokenClaims:(nullable NSSet *)tokenClaims completion:(nullable GIDSignInCompletion)completion { #endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST GIDSignInInternalOptions *options = [[GIDSignInInternalOptions alloc] init]; @@ -57,6 +59,7 @@ + (instancetype)defaultOptionsWithConfiguration:(nullable GIDConfiguration *)con options->_completion = completion; options->_scopes = [GIDScopes scopesWithBasicProfile:scopes]; options->_nonce = nonce; + options->_tokenClaims = tokenClaims; } return options; } @@ -84,6 +87,7 @@ + (instancetype)defaultOptionsWithConfiguration:(nullable GIDConfiguration *)con addScopesFlow:addScopesFlow scopes:@[] nonce:nil + tokenClaims:nil completion:completion]; return options; } @@ -120,6 +124,7 @@ - (instancetype)optionsWithExtraParameters:(NSDictionary *)extraParams options->_loginHint = _loginHint; options->_completion = _completion; options->_scopes = _scopes; + options->_tokenClaims = _tokenClaims; options->_extraParams = [extraParams copy]; } return options; diff --git a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h index 29cc0ef7..768d1764 100644 --- a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h +++ b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h @@ -26,6 +26,7 @@ @class GIDConfiguration; @class GIDGoogleUser; @class GIDSignInResult; +@class GIDTokenClaim; NS_ASSUME_NONNULL_BEGIN @@ -225,6 +226,95 @@ NS_EXTENSION_UNAVAILABLE("The sign-in flow is not supported in App Extensions.") NSError *_Nullable error))completion NS_EXTENSION_UNAVAILABLE("The sign-in flow is not supported in App Extensions."); +/// Starts an interactive sign-in flow on iOS using the provided tokenClaims. +/// +/// The completion will be called at the end of this process. Any saved sign-in state will be +/// replaced by the result of this flow. Note that this method should not be called when the app is +/// starting up, (e.g in `application:didFinishLaunchingWithOptions:`); instead use the +/// `restorePreviousSignInWithCompletion:` method to restore a previous sign-in. +/// +/// @param presentingViewController The view controller used to present the authorization flow. +/// @param tokenClaims An optional `NSSet` of tokenClaims to request. +/// @param completion The optional block that is called on completion. This block will +/// be called asynchronously on the main queue. +- (void)signInWithPresentingViewController:(UIViewController *)presentingViewController + tokenClaims:(nullable NSSet *)tokenClaims + completion: + (nullable void (^)(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error))completion + NS_EXTENSION_UNAVAILABLE("The sign-in flow is not supported in App Extensions."); + +/// Starts an interactive sign-in flow on iOS using the provided hint and tokenClaims. +/// +/// The completion will be called at the end of this process. Any saved sign-in state will be +/// replaced by the result of this flow. Note that this method should not be called when the app is +/// starting up, (e.g in `application:didFinishLaunchingWithOptions:`); instead use the +/// `restorePreviousSignInWithCompletion:` method to restore a previous sign-in. +/// +/// @param presentingViewController The view controller used to present the authorization flow. +/// @param hint An optional hint for the authorization server, for example the user's ID or email +/// address, to be prefilled if possible. +/// @param tokenClaims An optional `NSSet` of tokenClaims to request. +/// @param completion The optional block that is called on completion. This block will +/// be called asynchronously on the main queue. +- (void)signInWithPresentingViewController:(UIViewController *)presentingViewController + hint:(nullable NSString *)hint + tokenClaims:(nullable NSSet *)tokenClaims + completion: + (nullable void (^)(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error))completion + NS_EXTENSION_UNAVAILABLE("The sign-in flow is not supported in App Extensions."); + +/// Starts an interactive sign-in flow on iOS using the provided hint, additional scopes, +/// and tokenClaims. +/// +/// The completion will be called at the end of this process. Any saved sign-in state will be +/// replaced by the result of this flow. Note that this method should not be called when the app is +/// starting up, (e.g in `application:didFinishLaunchingWithOptions:`); instead use the +/// `restorePreviousSignInWithCompletion:` method to restore a previous sign-in. +/// +/// @param presentingViewController The view controller used to present the authorization flow. +/// @param hint An optional hint for the authorization server, for example the user's ID or email +/// address, to be prefilled if possible. +/// @param additionalScopes An optional array of scopes to request in addition to the basic profile scopes. +/// @param tokenClaims An optional `NSSet` of tokenClaims to request. +/// @param completion The optional block that is called on completion. This block will +/// be called asynchronously on the main queue. +- (void)signInWithPresentingViewController:(UIViewController *)presentingViewController + hint:(nullable NSString *)hint + additionalScopes:(nullable NSArray *)additionalScopes + tokenClaims:(nullable NSSet *)tokenClaims + completion: + (nullable void (^)(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error))completion + NS_EXTENSION_UNAVAILABLE("The sign-in flow is not supported in App Extensions."); + +/// Starts an interactive sign-in flow on iOS using the provided hint, additional scopes, nonce, +/// and tokenClaims. +/// +/// The completion will be called at the end of this process. Any saved sign-in state will be +/// replaced by the result of this flow. Note that this method should not be called when the app is +/// starting up, (e.g in `application:didFinishLaunchingWithOptions:`); instead use the +/// `restorePreviousSignInWithCompletion:` method to restore a previous sign-in. +/// +/// @param presentingViewController The view controller used to present the authorization flow. +/// @param hint An optional hint for the authorization server, for example the user's ID or email +/// address, to be prefilled if possible. +/// @param additionalScopes An optional array of scopes to request in addition to the basic profile scopes. +/// @param nonce A custom nonce. +/// @param tokenClaims An optional `NSSet` of tokenClaims to request. +/// @param completion The optional block that is called on completion. This block will +/// be called asynchronously on the main queue. +- (void)signInWithPresentingViewController:(UIViewController *)presentingViewController + hint:(nullable NSString *)hint + additionalScopes:(nullable NSArray *)additionalScopes + nonce:(nullable NSString *)nonce + tokenClaims:(nullable NSSet *)tokenClaims + completion: + (nullable void (^)(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error))completion + NS_EXTENSION_UNAVAILABLE("The sign-in flow is not supported in App Extensions."); + #elif TARGET_OS_OSX /// Starts an interactive sign-in flow on macOS. @@ -298,6 +388,86 @@ NS_EXTENSION_UNAVAILABLE("The sign-in flow is not supported in App Extensions.") completion:(nullable void (^)(GIDSignInResult *_Nullable signInResult, NSError *_Nullable error))completion; +/// Starts an interactive sign-in flow on macOS using the provided tokenClaims. +/// +/// The completion will be called at the end of this process. Any saved sign-in state will be +/// replaced by the result of this flow. Note that this method should not be called when the app is +/// starting up, (e.g in `application:didFinishLaunchingWithOptions:`); instead use the +/// `restorePreviousSignInWithCompletion:` method to restore a previous sign-in. +/// +/// @param presentingWindow The window used to supply `presentationContextProvider` for `ASWebAuthenticationSession`. +/// @param tokenClaims An optional `NSSet` of tokenClaims to request. +/// @param completion The optional block that is called on completion. This block will +/// be called asynchronously on the main queue. +- (void)signInWithPresentingWindow:(NSWindow *)presentingWindow + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable void (^)(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error))completion; + +/// Starts an interactive sign-in flow on macOS using the provided hint and tokenClaims. +/// +/// The completion will be called at the end of this process. Any saved sign-in state will be +/// replaced by the result of this flow. Note that this method should not be called when the app is +/// starting up, (e.g in `application:didFinishLaunchingWithOptions:`); instead use the +/// `restorePreviousSignInWithCompletion:` method to restore a previous sign-in. +/// +/// @param presentingWindow The window used to supply `presentationContextProvider` for `ASWebAuthenticationSession`. +/// @param hint An optional hint for the authorization server, for example the user's ID or email +/// address, to be prefilled if possible. +/// @param tokenClaims An optional `NSSet` of tokenClaims to request. +/// @param completion The optional block that is called on completion. This block will +/// be called asynchronously on the main queue. +- (void)signInWithPresentingWindow:(NSWindow *)presentingWindow + hint:(nullable NSString *)hint + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable void (^)(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error))completion; + +/// Starts an interactive sign-in flow on macOS using the provided hint, additional scopes, +/// and tokenClaims. +/// +/// The completion will be called at the end of this process. Any saved sign-in state will be +/// replaced by the result of this flow. Note that this method should not be called when the app is +/// starting up, (e.g in `application:didFinishLaunchingWithOptions:`); instead use the +/// `restorePreviousSignInWithCompletion:` method to restore a previous sign-in. +/// +/// @param presentingWindow The window used to supply `presentationContextProvider` for `ASWebAuthenticationSession`. +/// @param hint An optional hint for the authorization server, for example the user's ID or email +/// address, to be prefilled if possible. +/// @param additionalScopes An optional array of scopes to request in addition to the basic profile scopes. +/// @param tokenClaims An optional `NSSet` of tokenClaims to request. +/// @param completion The optional block that is called on completion. This block will +/// be called asynchronously on the main queue. +- (void)signInWithPresentingWindow:(NSWindow *)presentingWindow + hint:(nullable NSString *)hint + additionalScopes:(nullable NSArray *)additionalScopes + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable void (^)(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error))completion; + +/// Starts an interactive sign-in flow on macOS using the provided hint, additional scopes, nonce, +/// and tokenClaims. +/// +/// The completion will be called at the end of this process. Any saved sign-in state will be +/// replaced by the result of this flow. Note that this method should not be called when the app is +/// starting up, (e.g in `application:didFinishLaunchingWithOptions:`); instead use the +/// `restorePreviousSignInWithCompletion:` method to restore a previous sign-in. +/// +/// @param presentingWindow The window used to supply `presentationContextProvider` for `ASWebAuthenticationSession`. +/// @param hint An optional hint for the authorization server, for example the user's ID or email +/// address, to be prefilled if possible. +/// @param additionalScopes An optional array of scopes to request in addition to the basic profile scopes. +/// @param nonce A custom nonce. +/// @param tokenClaims An optional `NSSet` of tokenClaims to request. +/// @param completion The optional block that is called on completion. This block will +/// be called asynchronously on the main queue. +- (void)signInWithPresentingWindow:(NSWindow *)presentingWindow + hint:(nullable NSString *)hint + additionalScopes:(nullable NSArray *)additionalScopes + nonce:(nullable NSString *)nonce + tokenClaims:(nullable NSSet *)tokenClaims + completion:(nullable void (^)(GIDSignInResult *_Nullable signInResult, + NSError *_Nullable error))completion; #endif diff --git a/GoogleSignIn/Tests/Unit/GIDSignInInternalOptionsTest.m b/GoogleSignIn/Tests/Unit/GIDSignInInternalOptionsTest.m index bcc48910..1d6c2b3c 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInInternalOptionsTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInInternalOptionsTest.m @@ -17,6 +17,7 @@ #import "GoogleSignIn/Sources/GIDSignInInternalOptions.h" #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDConfiguration.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDTokenClaim.h" #ifdef SWIFT_PACKAGE @import OCMock; @@ -63,6 +64,54 @@ - (void)testDefaultOptions { #endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST } +- (void)testDefaultOptions_withAllParameters_initializesPropertiesCorrectly { + id configuration = OCMStrictClassMock([GIDConfiguration class]); +#if TARGET_OS_IOS || TARGET_OS_MACCATALYST + id presentingViewController = OCMStrictClassMock([UIViewController class]); +#elif TARGET_OS_OSX + id presentingWindow = OCMStrictClassMock([NSWindow class]); +#endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST + NSString *loginHint = @"login_hint"; + NSArray *scopes = @[@"scope1", @"scope2"]; + NSString *nonce = @"test_nonce"; + NSSet *tokenClaims = [NSSet setWithObject:[GIDTokenClaim authTimeClaim]]; + NSArray *expectedScopes = @[@"scope1", @"scope2", @"email", @"profile"]; + + GIDSignInCompletion completion = ^(GIDSignInResult *_Nullable signInResult, + NSError * _Nullable error) {}; + GIDSignInInternalOptions *options = + [GIDSignInInternalOptions defaultOptionsWithConfiguration:configuration +#if TARGET_OS_IOS || TARGET_OS_MACCATALYST + presentingViewController:presentingViewController +#elif TARGET_OS_OSX + presentingWindow:presentingWindow +#endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST + loginHint:loginHint + addScopesFlow:NO + scopes:scopes + nonce:nonce + tokenClaims:tokenClaims + completion:completion]; + XCTAssertTrue(options.interactive); + XCTAssertFalse(options.continuation); + XCTAssertFalse(options.addScopesFlow); + XCTAssertNil(options.extraParams); + + // Convert arrays to sets for comparison to make the test order-independent. + XCTAssertEqualObjects([NSSet setWithArray:options.scopes], [NSSet setWithArray:expectedScopes]); + XCTAssertEqualObjects(options.nonce, nonce); + XCTAssertEqualObjects(options.tokenClaims, tokenClaims); + XCTAssertNil(options.tokenClaimsAsJSON); + + OCMVerifyAll(configuration); +#if TARGET_OS_IOS || TARGET_OS_MACCATALYST + OCMVerifyAll(presentingViewController); +#elif TARGET_OS_OSX + OCMVerifyAll(presentingWindow); +#endif // TARGET_OS_IOS || TARGET_OS_MACCATALYST +} + + - (void)testSilentOptions { GIDSignInCompletion completion = ^(GIDSignInResult *_Nullable signInResult, NSError * _Nullable error) {}; diff --git a/GoogleSignIn/Tests/Unit/GIDSignInTest.m b/GoogleSignIn/Tests/Unit/GIDSignInTest.m index b36e197a..17453785 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInTest.m @@ -32,6 +32,7 @@ #import "GoogleSignIn/Sources/GIDGoogleUser_Private.h" #import "GoogleSignIn/Sources/GIDSignIn_Private.h" #import "GoogleSignIn/Sources/GIDSignInPreferences.h" +#import "GoogleSignIn/Sources/GIDTokenClaimsInternalOptions.h" #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST #import @@ -159,6 +160,12 @@ static NSString *const kGrantedScope = @"grantedScope"; static NSString *const kNewScope = @"newScope"; +static NSString *const kEssentialAuthTimeClaimsJsonString = + @"{\"id_token\":{\"auth_time\":{\"essential\":true}}}"; +static NSString *const kNonEssentialAuthTimeClaimsJsonString = + @"{\"id_token\":{\"auth_time\":{\"essential\":false}}}"; + + #if TARGET_OS_IOS || TARGET_OS_MACCATALYST // This category is used to allow the test to swizzle a private method. @interface UIViewController (Testing) @@ -517,18 +524,18 @@ - (void)testRestorePreviousSignInNoRefresh_hasPreviousUser { OCMStub([idTokenDecoded alloc]).andReturn(idTokenDecoded); OCMStub([idTokenDecoded initWithIDTokenString:OCMOCK_ANY]).andReturn(idTokenDecoded); OCMStub([idTokenDecoded subject]).andReturn(kFakeGaiaID); - + // Mock generating a GIDConfiguration when initializing GIDGoogleUser. OIDAuthorizationResponse *authResponse = [OIDAuthorizationResponse testInstance]; - + OCMStub([_authState lastAuthorizationResponse]).andReturn(authResponse); OCMStub([_tokenResponse idToken]).andReturn(kFakeIDToken); OCMStub([_tokenResponse request]).andReturn(_tokenRequest); OCMStub([_tokenRequest additionalParameters]).andReturn(nil); OCMStub([_tokenResponse accessToken]).andReturn(kAccessToken); OCMStub([_tokenResponse accessTokenExpirationDate]).andReturn(nil); - + [_signIn restorePreviousSignInNoRefresh]; [idTokenDecoded verify]; @@ -691,12 +698,14 @@ - (void)testOAuthLogin_AdditionalScopes { tokenError:nil emmPasscodeInfoRequired:NO keychainError:NO + tokenClaimsError:NO restoredSignIn:NO oldAccessToken:NO modalCancel:NO useAdditionalScopes:YES additionalScopes:nil - manualNonce:nil]; + manualNonce:nil + tokenClaims:nil]; expectedScopeString = [@[ @"email", @"profile" ] componentsJoinedByString:@" "]; XCTAssertEqualObjects(_savedAuthorizationRequest.scope, expectedScopeString); @@ -706,12 +715,14 @@ - (void)testOAuthLogin_AdditionalScopes { tokenError:nil emmPasscodeInfoRequired:NO keychainError:NO + tokenClaimsError:NO restoredSignIn:NO oldAccessToken:NO modalCancel:NO useAdditionalScopes:YES additionalScopes:@[ kScope ] - manualNonce:nil]; + manualNonce:nil + tokenClaims:nil]; expectedScopeString = [@[ kScope, @"email", @"profile" ] componentsJoinedByString:@" "]; XCTAssertEqualObjects(_savedAuthorizationRequest.scope, expectedScopeString); @@ -721,17 +732,101 @@ - (void)testOAuthLogin_AdditionalScopes { tokenError:nil emmPasscodeInfoRequired:NO keychainError:NO + tokenClaimsError:NO restoredSignIn:NO oldAccessToken:NO modalCancel:NO useAdditionalScopes:YES additionalScopes:@[ kScope, kScope2 ] - manualNonce:nil]; + manualNonce:nil + tokenClaims:nil]; expectedScopeString = [@[ kScope, kScope2, @"email", @"profile" ] componentsJoinedByString:@" "]; XCTAssertEqualObjects(_savedAuthorizationRequest.scope, expectedScopeString); } +- (void)testOAuthLogin_WithTokenClaims_FormatsParametersCorrectly { + GIDTokenClaim *authTimeClaim = [GIDTokenClaim authTimeClaim]; + GIDTokenClaim *essentialAuthTimeClaim = [GIDTokenClaim essentialAuthTimeClaim]; + + OCMStub([_keychainStore saveAuthSession:OCMOCK_ANY error:OCMArg.anyObjectRef] + ).andDo(^(NSInvocation *invocation){ + self->_keychainSaved = self->_saveAuthorizationReturnValue; + }); + + [self OAuthLoginWithAddScopesFlow:NO + authError:nil + tokenError:nil + emmPasscodeInfoRequired:NO + keychainError:NO + tokenClaimsError:NO + restoredSignIn:NO + oldAccessToken:NO + modalCancel:NO + useAdditionalScopes:NO + additionalScopes:nil + manualNonce:nil + tokenClaims:[NSSet setWithObject:essentialAuthTimeClaim]]; + + XCTAssertEqualObjects(_savedAuthorizationRequest.additionalParameters[@"claims"], + kEssentialAuthTimeClaimsJsonString, + @"Claims JSON should be correctly formatted"); + + [self OAuthLoginWithAddScopesFlow:NO + authError:nil + tokenError:nil + emmPasscodeInfoRequired:NO + keychainError:NO + tokenClaimsError:NO + restoredSignIn:NO + oldAccessToken:NO + modalCancel:NO + useAdditionalScopes:NO + additionalScopes:nil + manualNonce:nil + tokenClaims:[NSSet setWithObject:authTimeClaim]]; + + XCTAssertEqualObjects(_savedAuthorizationRequest.additionalParameters[@"claims"], + kNonEssentialAuthTimeClaimsJsonString, + @"Claims JSON should be correctly formatted"); +} + +- (void)testOAuthLogin_WithTokenClaims_ReturnsIdTokenWithCorrectClaims { + GIDTokenClaim *authTimeClaim = [GIDTokenClaim authTimeClaim]; + + OCMStub([_keychainStore saveAuthSession:OCMOCK_ANY error:OCMArg.anyObjectRef] + ).andDo(^(NSInvocation *invocation){ + self->_keychainSaved = self->_saveAuthorizationReturnValue; + }); + + [self OAuthLoginWithAddScopesFlow:NO + authError:nil + tokenError:nil + emmPasscodeInfoRequired:NO + keychainError:NO + tokenClaimsError:NO + restoredSignIn:NO + oldAccessToken:NO + modalCancel:NO + useAdditionalScopes:NO + additionalScopes:nil + manualNonce:nil + tokenClaims:[NSSet setWithObject:authTimeClaim]]; + + XCTAssertNotNil(_signIn.currentUser, @"The currentUser should not be nil after a successful sign-in."); + NSString *idTokenString = _signIn.currentUser.idToken.tokenString; + XCTAssertNotNil(idTokenString, @"ID token string should not be nil."); + NSArray *components = [idTokenString componentsSeparatedByString:@"."]; + XCTAssertEqual(components.count, 3, @"JWT should have 3 parts."); + NSData *payloadData = [[NSData alloc] + initWithBase64EncodedString:components[1] + options:NSDataBase64DecodingIgnoreUnknownCharacters]; + NSDictionary *claims = [NSJSONSerialization JSONObjectWithData:payloadData options:0 error:nil]; + XCTAssertEqualObjects(claims[@"auth_time"], + kAuthTime, + @"The 'auth_time' claim should be present and correct."); +} + - (void)testAddScopes { // Restore the previous sign-in account. This is the preparation for adding scopes. OCMStub( @@ -752,7 +847,7 @@ - (void)testAddScopes { id profile = OCMStrictClassMock([GIDProfileData class]); OCMStub([profile email]).andReturn(kUserEmail); - + // Mock for the method `addScopes`. GIDConfiguration *configuration = [[GIDConfiguration alloc] initWithClientID:kClientId serverClientID:nil @@ -784,7 +879,7 @@ - (void)testAddScopes { [parsedScopes removeObject:@""]; grantedScopes = [parsedScopes copy]; } - + NSArray *expectedScopes = @[kNewScope, kGrantedScope]; XCTAssertEqualObjects(grantedScopes, expectedScopes); @@ -831,18 +926,20 @@ - (void)testManualNonce { }); NSString* manualNonce = @"manual_nonce"; - + [self OAuthLoginWithAddScopesFlow:NO authError:nil tokenError:nil emmPasscodeInfoRequired:NO keychainError:NO + tokenClaimsError:NO restoredSignIn:NO oldAccessToken:NO modalCancel:NO useAdditionalScopes:NO additionalScopes:@[] - manualNonce:manualNonce]; + manualNonce:manualNonce + tokenClaims:nil]; XCTAssertEqualObjects(_savedAuthorizationRequest.nonce, manualNonce, @@ -950,6 +1047,36 @@ - (void)testOAuthLogin_KeychainError { XCTAssertEqual(_authError.code, kGIDSignInErrorCodeKeychain); } +- (void)testOAuthLogin_TokenClaims_FailsWithError { + GIDTokenClaim *authTimeClaim = [GIDTokenClaim authTimeClaim]; + GIDTokenClaim *essentialAuthTimeClaim = [GIDTokenClaim essentialAuthTimeClaim]; + NSSet *conflictingClaims = [NSSet setWithObjects:authTimeClaim, essentialAuthTimeClaim, nil]; + + [self OAuthLoginWithAddScopesFlow:NO + authError:nil + tokenError:nil + emmPasscodeInfoRequired:NO + keychainError:NO + tokenClaimsError:YES + restoredSignIn:NO + oldAccessToken:NO + modalCancel:NO + useAdditionalScopes:NO + additionalScopes:nil + manualNonce:nil + tokenClaims:conflictingClaims]; + + // Wait for the completion handler to be called + [self waitForExpectationsWithTimeout:1.0 handler:nil]; + XCTAssertNotNil(_authError, @"An error object should have been returned."); + XCTAssertEqual(_authError.code, kGIDSignInErrorCodeAmbiguousClaims, + @"The error code should be for ambiguous claims."); + XCTAssertEqualObjects(_authError.domain, kGIDSignInErrorDomain, + @"The error domain should be the GIDSignIn error domain."); + XCTAssertEqualObjects(_authError.localizedDescription, kGIDTokenClaimErrorDescription, + @"The error description should clearly explain the ambiguity."); +} + - (void)testSignOut { #if TARGET_OS_IOS || !TARGET_OS_MACCATALYST // OCMStub([_authorization authState]).andReturn(_authState); @@ -1339,7 +1466,7 @@ - (void)testTokenEndpointEMMError { NSError *handledError = [NSError errorWithDomain:kGIDSignInErrorDomain code:kGIDSignInErrorCodeEMM userInfo:emmError.userInfo]; - + completion(handledError); [self waitForExpectationsWithTimeout:1 handler:nil]; @@ -1424,12 +1551,14 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow tokenError:tokenError emmPasscodeInfoRequired:emmPasscodeInfoRequired keychainError:keychainError + tokenClaimsError:NO restoredSignIn:restoredSignIn oldAccessToken:oldAccessToken modalCancel:modalCancel useAdditionalScopes:NO additionalScopes:nil - manualNonce:nil]; + manualNonce:nil + tokenClaims:nil]; } // The authorization flow with parameters to control which branches to take. @@ -1438,12 +1567,14 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow tokenError:(NSError *)tokenError emmPasscodeInfoRequired:(BOOL)emmPasscodeInfoRequired keychainError:(BOOL)keychainError + tokenClaimsError:(BOOL)tokenClaimsError restoredSignIn:(BOOL)restoredSignIn oldAccessToken:(BOOL)oldAccessToken modalCancel:(BOOL)modalCancel useAdditionalScopes:(BOOL)useAdditionalScopes - additionalScopes:(NSArray *)additionalScopes - manualNonce:(NSString *)nonce { + additionalScopes:(NSArray *)additionalScopes + manualNonce:(NSString *)nonce + tokenClaims:(NSSet *)tokenClaims { if (restoredSignIn) { // clearAndAuthenticateWithOptions [[[_authorization expect] andReturn:_authState] authState]; @@ -1458,13 +1589,21 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow nonce:nonce errorString:authError]; + NSString *idToken = tokenClaims ? [OIDTokenResponse fatIDTokenWithAuthTime] : [OIDTokenResponse fatIDToken]; OIDTokenResponse *tokenResponse = - [OIDTokenResponse testInstanceWithIDToken:[OIDTokenResponse fatIDToken] + [OIDTokenResponse testInstanceWithIDToken:idToken accessToken:restoredSignIn ? kAccessToken : nil expiresIn:oldAccessToken ? @(300) : nil refreshToken:kRefreshToken tokenRequest:nil]; + if (tokenClaims) { + // Creating this stub to use `currentUser.idToken`. + id mockIDToken = OCMClassMock([GIDToken class]); + OCMStub([mockIDToken tokenString]).andReturn(tokenResponse.idToken); + OCMStub([_user idToken]).andReturn(mockIDToken); + } + OIDTokenRequest *tokenRequest = [[OIDTokenRequest alloc] initWithConfiguration:authResponse.request.configuration grantType:OIDGrantTypeRefreshToken @@ -1533,10 +1672,17 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow hint:_hint additionalScopes:nil nonce:nonce + tokenClaims:tokenClaims completion:completion]; } } + // When token claims are invalid, sign-in fails skipping the entire authorization flow. + // Thus, no need to verify `_authorization` or `_authState` as they won't be generated. + if (tokenClaimsError) { + return; + } + [_authorization verify]; [_authState verify]; @@ -1631,7 +1777,7 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow profileData:SAVE_TO_ARG_BLOCK(profileData)]; } } - + // CompletionCallback - mock server auth code parsing if (!keychainError) { [[[_authState expect] andReturn:tokenResponse] lastTokenResponse]; @@ -1653,9 +1799,9 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow return; } [self waitForExpectationsWithTimeout:1 handler:nil]; - + [_authState verify]; - + XCTAssertTrue(_keychainSaved, @"should save to keychain"); if (addScopesFlow) { XCTAssertNotNil(updatedTokenResponse); @@ -1692,7 +1838,7 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow [self waitForExpectationsWithTimeout:1 handler:nil]; XCTAssertFalse(_keychainRemoved, @"should not remove keychain"); XCTAssertFalse(_keychainSaved, @"should not save to keychain again"); - + if (restoredSignIn) { // Ignore the return value OCMVerify((void)[_keychainStore retrieveAuthSessionWithError:OCMArg.anyObjectRef]); diff --git a/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.h b/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.h index b565a392..b8329c67 100644 --- a/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.h +++ b/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.h @@ -33,6 +33,7 @@ extern NSString *const kUserID; extern NSString *const kHostedDomain; extern NSString *const kIssuer; extern NSString *const kAudience; +extern NSString *const kAuthTime; extern NSTimeInterval const kIDTokenExpires; extern NSTimeInterval const kIssuedAt; @@ -59,10 +60,19 @@ extern NSString * const kFatPictureURL; refreshToken:(NSString *)refreshToken tokenRequest:(OIDTokenRequest *)tokenRequest; ++ (instancetype)testInstanceWithIDToken:(NSString *)idToken + accessToken:(NSString *)accessToken + expiresIn:(NSNumber *)expiresIn + refreshToken:(NSString *)refreshToken + authTime:(NSString *)authTime + tokenRequest:(OIDTokenRequest *)tokenRequest; + + (NSString *)idToken; + (NSString *)fatIDToken; ++ (NSString *)fatIDTokenWithAuthTime; + /** * @sub The subject of the ID token. * @exp The interval between 00:00:00 UTC on 1 January 1970 and the expiration date of the ID token. @@ -71,4 +81,6 @@ extern NSString * const kFatPictureURL; + (NSString *)idTokenWithSub:(NSString *)sub exp:(NSNumber *)exp fat:(BOOL)fat; ++ (NSString *)idTokenWithSub:(NSString *)sub exp:(NSNumber *)exp fat:(BOOL)fat authTime:(NSString *)authTime; + @end diff --git a/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.m b/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.m index 3285ec8d..bf2a5fa7 100644 --- a/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.m +++ b/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.m @@ -38,6 +38,7 @@ NSString *const kHostedDomain = @"fakehosteddomain.com"; NSString *const kIssuer = @"https://test.com"; NSString *const kAudience = @"audience"; +NSString *const kAuthTime = @"1757753868"; NSTimeInterval const kIDTokenExpires = 1000; NSTimeInterval const kIssuedAt = 0; @@ -70,6 +71,21 @@ + (instancetype)testInstanceWithIDToken:(NSString *)idToken expiresIn:(NSNumber *)expiresIn refreshToken:(NSString *)refreshToken tokenRequest:(OIDTokenRequest *)tokenRequest { + return [OIDTokenResponse testInstanceWithIDToken:idToken + accessToken:accessToken + expiresIn:expiresIn + refreshToken:refreshToken + authTime:nil + tokenRequest:tokenRequest]; +} + ++ (instancetype)testInstanceWithIDToken:(NSString *)idToken + accessToken:(NSString *)accessToken + expiresIn:(NSNumber *)expiresIn + refreshToken:(NSString *)refreshToken + authTime:(NSString *)authTime + tokenRequest:(OIDTokenRequest *)tokenRequest { + NSMutableDictionary *parameters = [[NSMutableDictionary alloc] initWithDictionary:@{ @"access_token" : accessToken ?: kAccessToken, @"expires_in" : expiresIn ?: @(kAccessTokenExpiresIn), @@ -93,11 +109,24 @@ + (NSString *)fatIDToken { return [self idTokenWithSub:kUserID exp:@(kIDTokenExpires) fat:YES]; } ++ (NSString *)fatIDTokenWithAuthTime { + return [self idTokenWithSub:kUserID exp:@(kIDTokenExpires) fat:YES authTime:kAuthTime]; +} + + (NSString *)idTokenWithSub:(NSString *)sub exp:(NSNumber *)exp { return [self idTokenWithSub:sub exp:exp fat:NO]; } -+ (NSString *)idTokenWithSub:(NSString *)sub exp:(NSNumber *)exp fat:(BOOL)fat { ++ (NSString *)idTokenWithSub:(NSString *)sub + exp:(NSNumber *)exp + fat:(BOOL)fat { + return [self idTokenWithSub:kUserID exp:exp fat:fat authTime:nil]; +} + ++ (NSString *)idTokenWithSub:(NSString *)sub + exp:(NSNumber *)exp + fat:(BOOL)fat + authTime:(NSString *)authTime{ NSError *error; NSDictionary *headerContents = @{ @"alg" : kAlg, @@ -110,7 +139,7 @@ + (NSString *)idTokenWithSub:(NSString *)sub exp:(NSNumber *)exp fat:(BOOL)fat { if (error || !headerJson) { return nil; } - NSMutableDictionary *payloadContents = + NSMutableDictionary *payloadContents = [NSMutableDictionary dictionaryWithDictionary:@{ @"sub" : sub, @"hd" : kHostedDomain, @@ -127,6 +156,11 @@ + (NSString *)idTokenWithSub:(NSString *)sub exp:(NSNumber *)exp fat:(BOOL)fat { kFatPictureURLKey : kFatPictureURL, }]; } + if (authTime) { + [payloadContents addEntriesFromDictionary:@{ + @"auth_time": kAuthTime, + }]; + } NSData *payloadJson = [NSJSONSerialization dataWithJSONObject:payloadContents options:NSJSONWritingPrettyPrinted error:&error];