diff --git a/CHANGELOG.md b/CHANGELOG.md index 180da5dd..13b13ffe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,6 @@ +# Unreleased +- Add `GIDSignIn.wrapperIdentifier`, an optional property for SDKs embedding Google Sign-In to self-identify in Google's diagnostic logs via a new `gidwrapper` parameter. It accepts up to 100 printable ASCII characters; longer values are truncated, and values containing non-ASCII or control characters are dropped. It is opt-in and default behavior is unchanged. + # 9.2.0 - Expose the refresh token expiration date ([#577](https://github.com/google/GoogleSignIn-iOS/pull/577)) - Support requesting the `amr` (Authentication Methods References) claim ([#600](https://github.com/google/GoogleSignIn-iOS/pull/600)) diff --git a/GoogleSignIn/Sources/GIDGoogleUser.m b/GoogleSignIn/Sources/GIDGoogleUser.m index 1da8f972..c6979570 100644 --- a/GoogleSignIn/Sources/GIDGoogleUser.m +++ b/GoogleSignIn/Sources/GIDGoogleUser.m @@ -153,8 +153,7 @@ - (void)refreshTokensIfNeededWithCompletion:(GIDGoogleUserCompletion)completion [additionalParameters addEntriesFromDictionary: self.authState.lastTokenResponse.request.additionalParameters]; #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST - additionalParameters[kSDKVersionLoggingParameter] = GIDVersion(); - additionalParameters[kEnvironmentLoggingParameter] = GIDEnvironment(); + [GIDSignInPreferences addLoggingParameters:additionalParameters]; OIDTokenRequest *tokenRefreshRequest = [self.authState tokenRefreshRequestWithAdditionalParameters:additionalParameters]; diff --git a/GoogleSignIn/Sources/GIDSignIn.m b/GoogleSignIn/Sources/GIDSignIn.m index a8cf1ce7..9d54dafb 100644 --- a/GoogleSignIn/Sources/GIDSignIn.m +++ b/GoogleSignIn/Sources/GIDSignIn.m @@ -573,16 +573,36 @@ - (void)disconnectWithCompletion:(nullable GIDDisconnectCompletion)completion { } return; } - NSString *revokeURLString = [NSString stringWithFormat:kRevokeTokenURLTemplate, + NSString *baseURLString = [NSString stringWithFormat:kRevokeTokenURLTemplate, [GIDSignInPreferences googleAuthorizationServer], token]; - // Append logging parameter - revokeURLString = [NSString stringWithFormat:@"%@&%@=%@&%@=%@", - revokeURLString, - kSDKVersionLoggingParameter, - GIDVersion(), - kEnvironmentLoggingParameter, - GIDEnvironment()]; - NSURL *revokeURL = [NSURL URLWithString:revokeURLString]; + NSURLComponents *components = [NSURLComponents componentsWithString:baseURLString]; + NSURL *revokeURL; + if (components) { + NSMutableArray *items = + [components.queryItems mutableCopy] ?: [NSMutableArray array]; + + NSMutableDictionary *loggingParams = [[NSMutableDictionary alloc] init]; + [GIDSignInPreferences addLoggingParameters:loggingParams]; + for (NSString *name in [loggingParams.allKeys sortedArrayUsingSelector:@selector(compare:)]) { + [items addObject:[NSURLQueryItem queryItemWithName:name value:loggingParams[name]]]; + } + + components.queryItems = items; + revokeURL = components.URL; + } + + if (!revokeURL) { + // The revoke URL could not be constructed, so the token was left untouched. + NSError *error = [NSError errorWithDomain:kGIDSignInErrorDomain + code:kGIDSignInErrorCodeUnknown + userInfo:nil]; + if (completion) { + dispatch_async(dispatch_get_main_queue(), ^{ + completion(error); + }); + } + return; + } [self startFetchURL:revokeURL fromAuthState:authState withComment:@"GIDSignIn: revoke tokens" @@ -625,6 +645,14 @@ + (GIDSignIn *)sharedInstance { return sharedInstance; } +- (nullable NSString *)wrapperIdentifier { + return [GIDSignInPreferences wrapperIdentifier]; +} + +- (void)setWrapperIdentifier:(nullable NSString *)wrapperIdentifier { + [GIDSignInPreferences setWrapperIdentifier:wrapperIdentifier]; +} + #pragma mark - Configuring and pre-warming #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST @@ -918,8 +946,7 @@ - (void)authorizationRequestWithOptions:(GIDSignInInternalOptions *)options comp #elif TARGET_OS_OSX || TARGET_OS_MACCATALYST [additionalParameters addEntriesFromDictionary:options.extraParams]; #endif // TARGET_OS_OSX || TARGET_OS_MACCATALYST - additionalParameters[kSDKVersionLoggingParameter] = GIDVersion(); - additionalParameters[kEnvironmentLoggingParameter] = GIDEnvironment(); + [GIDSignInPreferences addLoggingParameters:additionalParameters]; return additionalParameters; } @@ -1054,8 +1081,7 @@ - (void)maybeFetchToken:(GIDAuthFlow *)authFlow { emmSupport:authFlow.emmSupport isPasscodeInfoRequired:passcodeInfoRequired.length > 0]]; #endif // TARGET_OS_IOS && !TARGET_OS_MACCATALYST - additionalParameters[kSDKVersionLoggingParameter] = GIDVersion(); - additionalParameters[kEnvironmentLoggingParameter] = GIDEnvironment(); + [GIDSignInPreferences addLoggingParameters:additionalParameters]; OIDTokenRequest *tokenRequest; if (!authState.lastTokenResponse.accessToken && diff --git a/GoogleSignIn/Sources/GIDSignInPreferences.h b/GoogleSignIn/Sources/GIDSignInPreferences.h index 8bdb6719..02ad2d4b 100644 --- a/GoogleSignIn/Sources/GIDSignInPreferences.h +++ b/GoogleSignIn/Sources/GIDSignInPreferences.h @@ -20,13 +20,37 @@ NS_ASSUME_NONNULL_BEGIN extern NSString *const kSDKVersionLoggingParameter; extern NSString *const kEnvironmentLoggingParameter; - -NSString* GIDVersion(void); - -NSString* GIDEnvironment(void); +extern NSString *const kSDKWrapperLoggingParameter; @interface GIDSignInPreferences : NSObject +/// Returns the current Google Sign-In SDK version. ++ (NSString *)sdkVersion; + +/// Returns the current Apple execution environment, such as `ios` or `macos`. ++ (NSString *)environment; + +/// Returns the current SDK wrapper identifier, or `nil` if none is set. ++ (nullable NSString *)wrapperIdentifier; + +/// Sets the SDK wrapper identifier. +/// +/// A value may be up to 100 printable ASCII characters; a longer value is truncated to its first +/// 100 characters. A value that is empty, or that contains any non-ASCII or ASCII control +/// character, is dropped entirely and asserts in debug builds. +/// +/// The first accepted write wins; later differing writes are ignored. This method is thread-safe. +/// +/// @param wrapperIdentifier The identifier to report, or `nil` to reset it. ++ (void)setWrapperIdentifier:(nullable NSString *)wrapperIdentifier; + +/// Adds the standard logging parameters to the supplied dictionary. +/// +/// The parameters are `gpsdk`, `gidenv`, and, when a wrapper identifier is set, `gidwrapper`. +/// +/// @param params The dictionary to add the logging parameters to. ++ (void)addLoggingParameters:(NSMutableDictionary *)params; + + (NSString *)googleAuthorizationServer; + (NSString *)googleTokenServer; + (NSString *)googleUserInfoServer; diff --git a/GoogleSignIn/Sources/GIDSignInPreferences.m b/GoogleSignIn/Sources/GIDSignInPreferences.m index 3f0e27d1..bebe6cb0 100644 --- a/GoogleSignIn/Sources/GIDSignInPreferences.m +++ b/GoogleSignIn/Sources/GIDSignInPreferences.m @@ -14,6 +14,8 @@ #import "GoogleSignIn/Sources/GIDSignInPreferences.h" +#import + NS_ASSUME_NONNULL_BEGIN static NSString *const kLSOServer = @"accounts.google.com"; @@ -26,6 +28,9 @@ // The name of the query parameter used for logging the Apple execution environment. NSString *const kEnvironmentLoggingParameter = @"gidenv"; +// The name of the query parameter used to log the embedding SDK / wrapper. +NSString *const kSDKWrapperLoggingParameter = @"gidwrapper"; + // Supported Apple execution environments static NSString *const kAppleEnvironmentUnknown = @"unknown"; static NSString *const kAppleEnvironmentIOS = @"ios"; @@ -34,6 +39,9 @@ static NSString *const kAppleEnvironmentMacOSIOSOnMac = @"macos-ios"; static NSString *const kAppleEnvironmentMacOSMacCatalyst = @"macos-cat"; +static NSString *gWrapperIdentifier = nil; +static os_unfair_lock gWrapperIdentifierLock = OS_UNFAIR_LOCK_INIT; + #ifndef GID_SDK_VERSION #error "GID_SDK_VERSION is not defined: add -DGID_SDK_VERSION=x.x.x to the build invocation." #endif @@ -44,14 +52,45 @@ #define STR(x) STR_EXPAND(x) #define STR_EXPAND(x) #x -// The prefixed sdk version string to differentiate gid version values used with the legacy gpsdk -// logging key. -NSString* GIDVersion(void) { +// Returns the sanitized form of `candidate`, or nil if it must be dropped. +// Callers must not pass nil. +static NSString * _Nullable GIDSanitizedWrapperIdentifier(NSString *candidate) { + static NSCharacterSet *allowedSet; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + // The range of printable ASCII characters is U+0020 through U+007E inclusive. + allowedSet = [NSCharacterSet characterSetWithRange:NSMakeRange(0x20, 0x5F)]; + }); + + // The drop check happens before truncation: if the original string contains any character + // outside the printable ASCII range, we drop the entire value. + if ([candidate rangeOfCharacterFromSet:[allowedSet invertedSet]].location != NSNotFound) { + return nil; + } + + // An empty string is also discarded. + if (candidate.length == 0) { + return nil; + } + + // A surviving string longer than 100 characters is truncated to 100. + if (candidate.length > 100) { + // Truncating with -substringToIndex:100 is safe here precisely because the drop check has + // already guaranteed every character is single-unit ASCII, so there is no risk of splitting + // a surrogate pair. + return [candidate substringToIndex:100]; + } + + return candidate; +} + +@implementation GIDSignInPreferences + ++ (NSString *)sdkVersion { return [NSString stringWithFormat:@"gid-%@", @STR(GID_SDK_VERSION)]; } -// Get the current Apple execution environment. -NSString* GIDEnvironment(void) { ++ (NSString *)environment { NSString *appleEnvironment = kAppleEnvironmentUnknown; #if TARGET_OS_MACCATALYST @@ -80,7 +119,52 @@ return appleEnvironment; } -@implementation GIDSignInPreferences ++ (nullable NSString *)wrapperIdentifier { + os_unfair_lock_lock(&gWrapperIdentifierLock); + NSString *wrapper = [gWrapperIdentifier copy]; + os_unfair_lock_unlock(&gWrapperIdentifierLock); + return wrapper; +} + ++ (void)setWrapperIdentifier:(nullable NSString *)wrapperIdentifier { + if (wrapperIdentifier == nil) { + os_unfair_lock_lock(&gWrapperIdentifierLock); + gWrapperIdentifier = nil; + os_unfair_lock_unlock(&gWrapperIdentifierLock); + return; + } + + NSString *sanitized = GIDSanitizedWrapperIdentifier(wrapperIdentifier); + if (sanitized == nil) { +#if DEBUG + NSAssert(NO, @"SDK wrapper '%@' rejected: must not be empty and must only contain printable " + @"ASCII characters (U+0020 to U+007E). Value ignored.", wrapperIdentifier); +#endif + return; + } + + os_unfair_lock_lock(&gWrapperIdentifierLock); + NSString *current = gWrapperIdentifier; + if (current != nil && ![current isEqualToString:sanitized]) { + os_unfair_lock_unlock(&gWrapperIdentifierLock); +#if DEBUG + NSAssert(NO, @"SDK wrapper already set to '%@'; ignoring '%@'. More than one " + @"wrapper appears to be present.", current, sanitized); +#endif + return; + } + gWrapperIdentifier = [sanitized copy]; + os_unfair_lock_unlock(&gWrapperIdentifierLock); +} + ++ (void)addLoggingParameters:(NSMutableDictionary *)params { + params[kSDKVersionLoggingParameter] = [self sdkVersion]; + params[kEnvironmentLoggingParameter] = [self environment]; + NSString *wrapper = [self wrapperIdentifier]; + if (wrapper != nil) { + params[kSDKWrapperLoggingParameter] = wrapper; + } +} + (NSString *)googleAuthorizationServer { return kLSOServer; diff --git a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h index a6b95ead..c7995c79 100644 --- a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h +++ b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h @@ -73,6 +73,26 @@ typedef NS_ERROR_ENUM(kGIDSignInErrorDomain, GIDSignInErrorCode) { /// The active configuration for this instance of `GIDSignIn`. @property(nonatomic, nullable) GIDConfiguration *configuration; +/// An optional identifier naming the SDK or wrapper that embeds Google Sign-In, +/// reported to Google as a diagnostic parameter for aggregate metrics only; it +/// is never used for authentication or authorization. +/// +/// Format: up to 100 printable ASCII characters (U+0020 to U+007E). A longer +/// value is truncated to its first 100 characters. A value containing any +/// non-ASCII character or any ASCII control character is dropped in its +/// entirety, and asserts in debug builds. +/// +/// Policy: +/// * Choose one stable name and keep it stable across your releases. +/// * Do NOT encode your version in it; per-release identifiers make aggregate +/// metrics useless. +/// * Never include anything user-specific, app-specific, or identifying. +/// * Register your identifier with Google before shipping it. +/// +/// Set this once, before your first sign-in call. The first valid value wins; +/// later differing values are ignored. +@property(nonatomic, nullable) NSString *wrapperIdentifier; + #if TARGET_OS_IOS && !TARGET_OS_MACCATALYST /// Configures `GIDSignIn` for use. diff --git a/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m b/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m index dec1caaf..1afa6de7 100644 --- a/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m +++ b/GoogleSignIn/Tests/Unit/GIDGoogleUserTest.m @@ -23,6 +23,7 @@ #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDToken.h" #import "GoogleSignIn/Sources/GIDGoogleUser_Private.h" +#import "GoogleSignIn/Sources/GIDSignInPreferences.h" #import "GoogleSignIn/Tests/Unit/GIDGoogleUser+Testing.h" #import "GoogleSignIn/Tests/Unit/GIDProfileData+Testing.h" #import "GoogleSignIn/Tests/Unit/OIDAuthState+Testing.h" @@ -67,10 +68,13 @@ @interface GIDGoogleUserTest : XCTestCase @implementation GIDGoogleUserTest { // The saved token fetch handler. OIDTokenCallback _tokenFetchHandler; + // The saved token request. + OIDTokenRequest *_savedTokenRequest; } - (void)setUp { _tokenFetchHandler = nil; + _savedTokenRequest = nil; // We need to use swizzle here because OCMock can not stub class method with arguments. [GULSwizzler swizzleClass:[OIDAuthorizationService class] @@ -80,7 +84,8 @@ - (void)setUp { OIDTokenRequest *request, OIDAuthorizationResponse *authorizationResponse, OIDTokenCallback callback) { - // Save the OIDTokenCallback. + // Save the OIDTokenRequest and OIDTokenCallback. + self->_savedTokenRequest = request; self->_tokenFetchHandler = [callback copy]; }]; } @@ -89,6 +94,7 @@ - (void)tearDown { [GULSwizzler unswizzleClass:[OIDAuthorizationService class] selector:@selector(performTokenRequest:originalAuthorizationResponse:callback:) isClassSelector:YES]; + GIDSignIn.sharedInstance.wrapperIdentifier = nil; } #pragma mark - Tests @@ -478,6 +484,96 @@ - (void)testRefreshTokensIfNeededWithCompletion_noRefresh_givenRefreshTokenExpir [self waitForExpectationsWithTimeout:1 handler:nil]; } +- (void)testWrapperIdentifier_PresentOnRefreshRequestWhenSet { + GIDSignIn.sharedInstance.wrapperIdentifier = @"firebase"; + + // Both tokens expired 10 seconds ago. + GIDGoogleUser *user = [self googleUserWithAccessTokenExpiresIn:-10 idTokenExpiresIn:-10]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Callback is called"]; + + // Save the intermediate states. + [user refreshTokensIfNeededWithCompletion:^(GIDGoogleUser * _Nullable user, + NSError * _Nullable error) { + [expectation fulfill]; + }]; + + XCTAssertEqualObjects(_savedTokenRequest.additionalParameters[@"gidwrapper"], @"firebase"); + + // Clean up the handler by providing a fake response to fulfill any internal state. + OIDTokenResponse *fakeResponse = [OIDTokenResponse testInstanceWithIDToken:nil + accessToken:kNewAccessToken + expiresIn:@(kAccessTokenExpiresIn) + refreshToken:kRefreshToken + tokenRequest:_savedTokenRequest]; + _tokenFetchHandler(fakeResponse, nil); + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +- (void)testWrapperIdentifier_AbsentOnRefreshRequestWhenUnset { + GIDSignIn.sharedInstance.wrapperIdentifier = nil; + + // Both tokens expired 10 seconds ago. + GIDGoogleUser *user = [self googleUserWithAccessTokenExpiresIn:-10 idTokenExpiresIn:-10]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Callback is called"]; + + // Save the intermediate states. + [user refreshTokensIfNeededWithCompletion:^(GIDGoogleUser * _Nullable user, + NSError * _Nullable error) { + [expectation fulfill]; + }]; + + XCTAssertNil(_savedTokenRequest.additionalParameters[@"gidwrapper"]); + + // Clean up the handler by providing a fake response. + OIDTokenResponse *fakeResponse = [OIDTokenResponse testInstanceWithIDToken:nil + accessToken:kNewAccessToken + expiresIn:@(kAccessTokenExpiresIn) + refreshToken:kRefreshToken + tokenRequest:_savedTokenRequest]; + _tokenFetchHandler(fakeResponse, nil); + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + +- (void)testWrapperIdentifier_AbsentOnRefreshRequestWhenDropped { + // Assert that attempting to set a dropped identifier throws NSInternalInconsistencyException. + XCTAssertThrowsSpecificNamed(GIDSignIn.sharedInstance.wrapperIdentifier = @"firebasé", + NSException, NSInternalInconsistencyException); + + // The rejection leaves the store nil. + XCTAssertNil(GIDSignIn.sharedInstance.wrapperIdentifier); + + // Both tokens expired 10 seconds ago. + GIDGoogleUser *user = [self googleUserWithAccessTokenExpiresIn:-10 idTokenExpiresIn:-10]; + + XCTestExpectation *expectation = [self expectationWithDescription:@"Callback is called"]; + + // Save the intermediate states. + [user refreshTokensIfNeededWithCompletion:^(GIDGoogleUser * _Nullable user, + NSError * _Nullable error) { + [expectation fulfill]; + }]; + + // Assert the captured token request additionalParameters does NOT contain key @"gidwrapper". + XCTAssertNil(_savedTokenRequest.additionalParameters[@"gidwrapper"]); + + // Assert it DOES contain kSDKVersionLoggingParameter and kEnvironmentLoggingParameter. + XCTAssertEqualObjects(_savedTokenRequest.additionalParameters[kSDKVersionLoggingParameter], + [GIDSignInPreferences sdkVersion]); + XCTAssertEqualObjects(_savedTokenRequest.additionalParameters[kEnvironmentLoggingParameter], + [GIDSignInPreferences environment]); + + // Clean up the handler by providing a fake response. + OIDTokenResponse *fakeResponse = [OIDTokenResponse testInstanceWithIDToken:nil + accessToken:kNewAccessToken + expiresIn:@(kAccessTokenExpiresIn) + refreshToken:kRefreshToken + tokenRequest:_savedTokenRequest]; + _tokenFetchHandler(fakeResponse, nil); + [self waitForExpectationsWithTimeout:1 handler:nil]; +} + # pragma mark - Test `addScopes:` - (void)testAddScopes_success { diff --git a/GoogleSignIn/Tests/Unit/GIDSignInPreferencesTest.m b/GoogleSignIn/Tests/Unit/GIDSignInPreferencesTest.m index 80fa8949..260a4d92 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInPreferencesTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInPreferencesTest.m @@ -22,12 +22,12 @@ @interface GIDSignInPreferencesTest : XCTestCase @implementation GIDSignInPreferencesTest - (void)testGIDVersion { - NSString *version = GIDVersion(); + NSString *version = [GIDSignInPreferences sdkVersion]; XCTAssertTrue([version hasPrefix:@"gid-"]); } - (void)testGIDEnvironment { - NSString *environment = GIDEnvironment(); + NSString *environment = [GIDSignInPreferences environment]; NSString *expectedEnvironment; #if TARGET_OS_MACCATALYST @@ -44,4 +44,149 @@ - (void)testGIDEnvironment { XCTAssertEqualObjects(environment, expectedEnvironment); } +- (void)tearDown { + [GIDSignInPreferences setWrapperIdentifier:nil]; + [super tearDown]; +} + +- (void)testWrapperIdentifier_UnsetIsNil { + // Test that when no identifier is set, nil is returned. + [GIDSignInPreferences setWrapperIdentifier:nil]; + XCTAssertNil([GIDSignInPreferences wrapperIdentifier]); +} + +- (void)testWrapperIdentifier_AcceptsSimpleValue { + // Test that a simple lowercase alphanumeric identifier is accepted. + [GIDSignInPreferences setWrapperIdentifier:@"firebase"]; + XCTAssertEqualObjects([GIDSignInPreferences wrapperIdentifier], @"firebase"); +} + +- (void)testWrapperIdentifier_AcceptsHyphenatedValue { + // Test that a value with internal hyphens is accepted. + [GIDSignInPreferences setWrapperIdentifier:@"react-native"]; + XCTAssertEqualObjects([GIDSignInPreferences wrapperIdentifier], @"react-native"); +} + +- (void)testWrapperIdentifier_AcceptsDigits { + // Test that a value with digits is accepted. + [GIDSignInPreferences setWrapperIdentifier:@"wrapper2"]; + XCTAssertEqualObjects([GIDSignInPreferences wrapperIdentifier], @"wrapper2"); +} + +- (void)testWrapperIdentifier_AcceptsMaximumLength { + // Test that a 100-character valid identifier is accepted and not truncated. + NSString *maxLength = [@"a" stringByPaddingToLength:100 withString:@"a" startingAtIndex:0]; + [GIDSignInPreferences setWrapperIdentifier:maxLength]; + XCTAssertEqual([GIDSignInPreferences wrapperIdentifier].length, (NSUInteger)100); + XCTAssertEqualObjects([GIDSignInPreferences wrapperIdentifier], maxLength); +} + +- (void)testWrapperIdentifier_AcceptsMixedCaseSpacesAndPunctuation { + // Test that mixed case, spaces and punctuation are accepted. + NSString *value = @"React Native SDK (v2.0)"; + [GIDSignInPreferences setWrapperIdentifier:value]; + XCTAssertEqualObjects([GIDSignInPreferences wrapperIdentifier], value); +} + +- (void)testWrapperIdentifier_DropsEmptyString { + // Test that an empty string is dropped. + XCTAssertThrowsSpecificNamed([GIDSignInPreferences setWrapperIdentifier:@""], + NSException, + NSInternalInconsistencyException); + XCTAssertNil([GIDSignInPreferences wrapperIdentifier]); +} + +- (void)testWrapperIdentifier_FirstValidWriteWins { + // Test that the first valid write is persistent and subsequent differing writes are rejected. + [GIDSignInPreferences setWrapperIdentifier:@"first"]; + XCTAssertThrowsSpecificNamed([GIDSignInPreferences setWrapperIdentifier:@"second"], + NSException, + NSInternalInconsistencyException); + XCTAssertEqualObjects([GIDSignInPreferences wrapperIdentifier], @"first"); +} + +- (void)testWrapperIdentifier_RepeatedIdenticalWriteIsAccepted { + // Test that writing the same valid value again does not throw or change the state. + [GIDSignInPreferences setWrapperIdentifier:@"firebase"]; + XCTAssertNoThrow([GIDSignInPreferences setWrapperIdentifier:@"firebase"]); + XCTAssertEqualObjects([GIDSignInPreferences wrapperIdentifier], @"firebase"); +} + +- (void)testWrapperIdentifier_NilResets { + // Test that passing nil resets the store, allowing a new first-write. + [GIDSignInPreferences setWrapperIdentifier:@"firebase"]; + [GIDSignInPreferences setWrapperIdentifier:nil]; + XCTAssertNil([GIDSignInPreferences wrapperIdentifier]); + + [GIDSignInPreferences setWrapperIdentifier:@"second"]; + XCTAssertEqualObjects([GIDSignInPreferences wrapperIdentifier], @"second"); +} + +- (void)testWrapperIdentifier_DroppedWriteLeavesPreviousValue { + // Test that a dropped write does not clear or change a previously set valid value. + [GIDSignInPreferences setWrapperIdentifier:@"firebase"]; + XCTAssertThrowsSpecificNamed([GIDSignInPreferences setWrapperIdentifier:@"firebasé"], + NSException, + NSInternalInconsistencyException); + XCTAssertEqualObjects([GIDSignInPreferences wrapperIdentifier], @"firebase"); +} + +- (void)testWrapperIdentifier_TruncatesOverLongValue { + // Test that a legal string longer than 100 characters is truncated to its first 100 characters. + NSString *overLong = [@"a" stringByPaddingToLength:150 withString:@"a" startingAtIndex:0]; + XCTAssertNoThrow([GIDSignInPreferences setWrapperIdentifier:overLong]); + XCTAssertEqual([GIDSignInPreferences wrapperIdentifier].length, (NSUInteger)100); + XCTAssertEqualObjects([GIDSignInPreferences wrapperIdentifier], [overLong substringToIndex:100]); +} + +- (void)testWrapperIdentifier_DropsNonASCII { + // Test that a value containing a non-ASCII character throws and leaves the store nil. + XCTAssertThrowsSpecificNamed([GIDSignInPreferences setWrapperIdentifier:@"firebasé"], + NSException, + NSInternalInconsistencyException); + XCTAssertNil([GIDSignInPreferences wrapperIdentifier]); +} + +- (void)testWrapperIdentifier_DropsControlCharacters { + // Test that values containing ASCII control characters throw and leave the store nil. + XCTAssertThrowsSpecificNamed([GIDSignInPreferences setWrapperIdentifier:@"fire\nbase"], + NSException, + NSInternalInconsistencyException); + XCTAssertNil([GIDSignInPreferences wrapperIdentifier]); + + [GIDSignInPreferences setWrapperIdentifier:nil]; + XCTAssertThrowsSpecificNamed([GIDSignInPreferences setWrapperIdentifier:@"fire\tbase"], + NSException, + NSInternalInconsistencyException); + XCTAssertNil([GIDSignInPreferences wrapperIdentifier]); + + [GIDSignInPreferences setWrapperIdentifier:nil]; + NSString *del = [NSString stringWithFormat:@"fire%Cbase", (unichar)0x7F]; + XCTAssertThrowsSpecificNamed([GIDSignInPreferences setWrapperIdentifier:del], + NSException, + NSInternalInconsistencyException); + XCTAssertNil([GIDSignInPreferences wrapperIdentifier]); +} + +- (void)testWrapperIdentifier_DropsWhenDisallowedCharacterIsPastTruncationPoint { + // Test that the drop check deliberately runs on the untruncated string so a payload hidden + // past the truncation point cannot survive. + NSString *prefix = [@"a" stringByPaddingToLength:120 withString:@"a" startingAtIndex:0]; + NSString *overLong = [prefix stringByReplacingCharactersInRange:NSMakeRange(110, 1) + withString:@"é"]; + XCTAssertThrowsSpecificNamed([GIDSignInPreferences setWrapperIdentifier:overLong], + NSException, + NSInternalInconsistencyException); + XCTAssertNil([GIDSignInPreferences wrapperIdentifier]); +} + +- (void)testWrapperIdentifier_WriteOnceComparesSanitizedValue { + // Test that the write-once check compares the sanitized value, allowing a repeated + // write of a value that truncates to the same result. + NSString *overLong = [@"a" stringByPaddingToLength:150 withString:@"a" startingAtIndex:0]; + [GIDSignInPreferences setWrapperIdentifier:overLong]; + XCTAssertNoThrow([GIDSignInPreferences setWrapperIdentifier:overLong]); + XCTAssertEqualObjects([GIDSignInPreferences wrapperIdentifier], [overLong substringToIndex:100]); +} + @end diff --git a/GoogleSignIn/Tests/Unit/GIDSignInTest.m b/GoogleSignIn/Tests/Unit/GIDSignInTest.m index ab1c4003..2465399a 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInTest.m @@ -389,6 +389,7 @@ - (void)tearDown { [_testUserDefaults removePersistentDomainForName:kUserDefaultsSuiteName]; [_fakeMainBundle stopFaking]; + GIDSignIn.sharedInstance.wrapperIdentifier = nil; [super tearDown]; } @@ -1168,6 +1169,121 @@ - (void)testOAuthLogin_HostedDomain { XCTAssertEqualObjects(params[@"hd"], kHostedDomain, @"hosted domain should match"); } +- (void)testWrapperIdentifier_PresentInAuthorizationRequestWhenSet { + GIDSignIn.sharedInstance.wrapperIdentifier = @"firebase"; + OCMStub( + [_keychainStore saveAuthSession:OCMOCK_ANY error:OCMArg.anyObjectRef] + ).andDo(^(NSInvocation *invocation) { + self->_keychainSaved = self->_saveAuthorizationReturnValue; + }); + + [self OAuthLoginWithAddScopesFlow:NO + authError:nil + tokenError:nil + emmPasscodeInfoRequired:NO + claimsAsJSONRequired:NO + keychainError:NO + restoredSignIn:NO + oldAccessToken:NO + modalCancel:NO]; + + NSDictionary *params = _savedAuthorizationRequest.additionalParameters; + XCTAssertEqualObjects(params[@"gidwrapper"], @"firebase", + @"The authorization request should contain the 'gidwrapper' parameter " + "when set."); +} + +- (void)testWrapperIdentifier_AbsentFromAuthorizationRequestWhenUnset { + GIDSignIn.sharedInstance.wrapperIdentifier = nil; + OCMStub( + [_keychainStore saveAuthSession:OCMOCK_ANY error:OCMArg.anyObjectRef] + ).andDo(^(NSInvocation *invocation) { + self->_keychainSaved = self->_saveAuthorizationReturnValue; + }); + + [self OAuthLoginWithAddScopesFlow:NO + authError:nil + tokenError:nil + emmPasscodeInfoRequired:NO + claimsAsJSONRequired:NO + keychainError:NO + restoredSignIn:NO + oldAccessToken:NO + modalCancel:NO]; + + NSDictionary *params = _savedAuthorizationRequest.additionalParameters; + XCTAssertNil(params[@"gidwrapper"], + @"The authorization request should not contain the 'gidwrapper' parameter " + "when unset."); +} + +- (void)testWrapperIdentifier_DroppedValueIsIgnored { + XCTAssertThrowsSpecificNamed(GIDSignIn.sharedInstance.wrapperIdentifier = @"firebasé", + NSException, NSInternalInconsistencyException, + @"Setting a dropped wrapper identifier should throw."); + XCTAssertNil(GIDSignIn.sharedInstance.wrapperIdentifier, + @"The wrapper identifier should be nil after a dropped assignment."); +} + +- (void)testWrapperIdentifier_PresentOnRevokeURL { + GIDSignIn.sharedInstance.wrapperIdentifier = @"my-sdk"; + + [[[_authorization expect] andReturn:_authState] authState]; + [[[_authState expect] andReturn:_tokenResponse] lastTokenResponse]; + [[[_tokenResponse expect] andReturn:kAccessToken] accessToken]; + [[[_authorization expect] andReturn:_fetcherService] fetcherService]; + + [_signIn disconnectWithCompletion:nil]; + + XCTAssertTrue([self isFetcherStarted], @"should start fetching"); + NSURL *url = [self fetchedURL]; + NSURLComponents *components = [NSURLComponents componentsWithURL:url + resolvingAgainstBaseURL:NO]; + NSArray *queryItems = components.queryItems; + + XCTAssertEqualObjects([self valueForQueryItemName:@"gidwrapper" inArray:queryItems], + @"my-sdk", @"The revoke URL should contain the 'gidwrapper' parameter."); + XCTAssertEqualObjects([self valueForQueryItemName:kSDKVersionLoggingParameter inArray:queryItems], + [GIDSignInPreferences sdkVersion], + @"The revoke URL should contain the SDK version parameter."); + XCTAssertEqualObjects([self valueForQueryItemName:kEnvironmentLoggingParameter + inArray:queryItems], + [GIDSignInPreferences environment], + @"The revoke URL should contain the environment parameter."); + XCTAssertEqualObjects([self valueForQueryItemName:@"token" inArray:queryItems], + kAccessToken, @"The revoke URL should contain the 'token' parameter."); +} + +- (void)testWrapperIdentifier_AbsentFromRevokeURLWhenUnset { + GIDSignIn.sharedInstance.wrapperIdentifier = nil; + + [[[_authorization expect] andReturn:_authState] authState]; + [[[_authState expect] andReturn:_tokenResponse] lastTokenResponse]; + [[[_tokenResponse expect] andReturn:kAccessToken] accessToken]; + [[[_authorization expect] andReturn:_fetcherService] fetcherService]; + + [_signIn disconnectWithCompletion:nil]; + + XCTAssertTrue([self isFetcherStarted], @"should start fetching"); + NSURL *url = [self fetchedURL]; + NSURLComponents *components = [NSURLComponents componentsWithURL:url + resolvingAgainstBaseURL:NO]; + NSArray *queryItems = components.queryItems; + + XCTAssertNil([self valueForQueryItemName:@"gidwrapper" inArray:queryItems], + @"The revoke URL should not contain the 'gidwrapper' parameter when unset."); + XCTAssertEqualObjects([self valueForQueryItemName:kSDKVersionLoggingParameter inArray:queryItems], + [GIDSignInPreferences sdkVersion], + @"The revoke URL should still contain the SDK version parameter."); + XCTAssertEqualObjects([self valueForQueryItemName:kEnvironmentLoggingParameter + inArray:queryItems], + [GIDSignInPreferences environment], + @"The revoke URL should still contain the environment parameter."); + XCTAssertEqualObjects([self valueForQueryItemName:@"token" inArray:queryItems], + kAccessToken, + @"The revoke URL should still contain the 'token' parameter."); +} + - (void)testOAuthLogin_ConsentCanceled { [self OAuthLoginWithAddScopesFlow:NO authError:@"access_denied" @@ -1681,6 +1797,17 @@ - (void)testTokenEndpointEMMError { #pragma mark - Helpers +// Returns the value for the query item with the given name in the array of query items. +- (nullable NSString *)valueForQueryItemName:(NSString *)name + inArray:(NSArray *)queryItems { + for (NSURLQueryItem *item in queryItems) { + if ([item.name isEqualToString:name]) { + return item.value; + } + } + return nil; +} + // Whether or not a fetcher has been started. - (BOOL)isFetcherStarted { NSUInteger count = _fetcherService.fetchers.count; @@ -1723,9 +1850,11 @@ - (void)verifyAndRevokeToken:(NSString *)token NSDictionary *> *params = queryComponent.dictionaryValue; XCTAssertEqualObjects([params valueForKey:@"token"], token, @"token parameter should match"); - XCTAssertEqualObjects([params valueForKey:kSDKVersionLoggingParameter], GIDVersion(), + XCTAssertEqualObjects([params valueForKey:kSDKVersionLoggingParameter], + [GIDSignInPreferences sdkVersion], @"SDK version logging parameter should match"); - XCTAssertEqualObjects([params valueForKey:kEnvironmentLoggingParameter], GIDEnvironment(), + XCTAssertEqualObjects([params valueForKey:kEnvironmentLoggingParameter], + [GIDSignInPreferences environment], @"Environment logging parameter should match"); // Emulate result back from server. [self didFetch:nil error:nil]; @@ -1891,8 +2020,8 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow XCTAssertNotNil(_savedAuthorizationRequest); NSDictionary *params = _savedAuthorizationRequest.additionalParameters; XCTAssertEqualObjects(params[@"include_granted_scopes"], @"true"); - XCTAssertEqualObjects(params[kSDKVersionLoggingParameter], GIDVersion()); - XCTAssertEqualObjects(params[kEnvironmentLoggingParameter], GIDEnvironment()); + XCTAssertEqualObjects(params[kSDKVersionLoggingParameter], [GIDSignInPreferences sdkVersion]); + XCTAssertEqualObjects(params[kEnvironmentLoggingParameter], [GIDSignInPreferences environment]); XCTAssertNotNil(_savedAuthorizationCallback); #if TARGET_OS_IOS || TARGET_OS_MACCATALYST XCTAssertEqual(_savedPresentingViewController, _presentingViewController);