From 7f75975324a044528a31245fa31746e430458e25 Mon Sep 17 00:00:00 2001 From: AkshatGandhi <54901287+AkshatG6@users.noreply.github.com> Date: Fri, 5 Sep 2025 10:27:23 -0700 Subject: [PATCH 01/16] GIDTokenClaim Implementation + Unit Tests (#550) --- GoogleSignIn/Sources/GIDTokenClaim.m | 75 +++++++++++++++++++ .../Public/GoogleSignIn/GIDTokenClaim.h | 48 ++++++++++++ .../Public/GoogleSignIn/GoogleSignIn.h | 1 + GoogleSignIn/Tests/Unit/GIDTokenClaimTest.m | 49 ++++++++++++ 4 files changed, 173 insertions(+) create mode 100644 GoogleSignIn/Sources/GIDTokenClaim.m create mode 100644 GoogleSignIn/Sources/Public/GoogleSignIn/GIDTokenClaim.h create mode 100644 GoogleSignIn/Tests/Unit/GIDTokenClaimTest.m diff --git a/GoogleSignIn/Sources/GIDTokenClaim.m b/GoogleSignIn/Sources/GIDTokenClaim.m new file mode 100644 index 00000000..61e83d6a --- /dev/null +++ b/GoogleSignIn/Sources/GIDTokenClaim.m @@ -0,0 +1,75 @@ +/* + * Copyright 2025 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/Public/GoogleSignIn/GIDTokenClaim.h" + +NSString * const kAuthTimeClaimName = @"auth_time"; + +// Private interface to declare the internal initializer +@interface GIDTokenClaim () + +- (instancetype)initWithName:(NSString *)name + essential:(BOOL)essential NS_DESIGNATED_INITIALIZER; + +@end + +@implementation GIDTokenClaim + +// Private designated initializer +- (instancetype)initWithName:(NSString *)name essential:(BOOL)essential { + self = [super init]; + if (self) { + _name = [name copy]; + _essential = essential; + } + return self; +} + +#pragma mark - Factory Methods + ++ (instancetype)authTimeClaim { + return [[self alloc] initWithName:kAuthTimeClaimName essential:NO]; +} + ++ (instancetype)essentialAuthTimeClaim { + return [[self alloc] initWithName:kAuthTimeClaimName essential:YES]; +} + +#pragma mark - NSObject + +- (BOOL)isEqual:(id)object { + // 1. Check if the other object is the same instance in memory. + if (self == object) { + return YES; + } + + // 2. Check if the other object is not a GIDTokenClaim instance. + if (![object isKindOfClass:[GIDTokenClaim class]]) { + return NO; + } + + // 3. Compare the properties that define equality. + GIDTokenClaim *other = (GIDTokenClaim *)object; + return [self.name isEqualToString:other.name] && + self.isEssential == other.isEssential; +} + +- (NSUInteger)hash { + // The hash value should be based on the same properties used in isEqual: + return self.name.hash ^ @(self.isEssential).hash; +} + +@end diff --git a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDTokenClaim.h b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDTokenClaim.h new file mode 100644 index 00000000..7a2351cb --- /dev/null +++ b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDTokenClaim.h @@ -0,0 +1,48 @@ +/* + * Copyright 2025 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 + +NS_ASSUME_NONNULL_BEGIN + +extern NSString *const kAuthTimeClaimName; + +/** + * An object representing a single OIDC claim to be requested for an ID token. + */ +@interface GIDTokenClaim : NSObject + +/// The name of the claim, e.g., "auth_time". +@property (nonatomic, readonly) NSString *name; + +/// Whether the claim is requested as essential. +@property (nonatomic, readonly, getter=isEssential) BOOL essential; + +// Making initializers unavailable to force use of factory methods. +- (instancetype)init NS_UNAVAILABLE; + +#pragma mark - Factory Methods + +/// Creates a *non-essential* (voluntary) "auth_time" claim object. ++ (instancetype)authTimeClaim; + +/// Creates an *essential* "auth_time" claim object. ++ (instancetype)essentialAuthTimeClaim; + +@end + +NS_ASSUME_NONNULL_END + diff --git a/GoogleSignIn/Sources/Public/GoogleSignIn/GoogleSignIn.h b/GoogleSignIn/Sources/Public/GoogleSignIn/GoogleSignIn.h index 4fd17ede..02935be8 100644 --- a/GoogleSignIn/Sources/Public/GoogleSignIn/GoogleSignIn.h +++ b/GoogleSignIn/Sources/Public/GoogleSignIn/GoogleSignIn.h @@ -24,6 +24,7 @@ #import "GIDSignIn.h" #import "GIDToken.h" #import "GIDSignInResult.h" +#import "GIDTokenClaim.h" #if TARGET_OS_IOS || TARGET_OS_MACCATALYST #import "GIDSignInButton.h" #endif diff --git a/GoogleSignIn/Tests/Unit/GIDTokenClaimTest.m b/GoogleSignIn/Tests/Unit/GIDTokenClaimTest.m new file mode 100644 index 00000000..145e46f6 --- /dev/null +++ b/GoogleSignIn/Tests/Unit/GIDTokenClaimTest.m @@ -0,0 +1,49 @@ +// Copyright 2025 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 +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDTokenClaim.h" + +@interface GIDTokenClaimTest : XCTestCase +@end + +@implementation GIDTokenClaimTest + +- (void)testAuthTimeClaim_PropertiesAreCorrect { + GIDTokenClaim *claim = [GIDTokenClaim authTimeClaim]; + XCTAssertEqualObjects(claim.name, kAuthTimeClaimName); + XCTAssertFalse(claim.isEssential); +} + +- (void)testEssentialAuthTimeClaim_PropertiesAreCorrect { + GIDTokenClaim *claim = [GIDTokenClaim essentialAuthTimeClaim]; + XCTAssertEqualObjects(claim.name, kAuthTimeClaimName); + XCTAssertTrue(claim.isEssential); +} + +- (void)testEquality_WithEqualClaims { + GIDTokenClaim *claim1 = [GIDTokenClaim authTimeClaim]; + GIDTokenClaim *claim2 = [GIDTokenClaim authTimeClaim]; + XCTAssertEqualObjects(claim1, claim2); + XCTAssertEqual(claim1.hash, claim2.hash); +} + +- (void)testEquality_WithUnequalClaims { + GIDTokenClaim *claim1 = [GIDTokenClaim authTimeClaim]; + GIDTokenClaim *claim2 = [GIDTokenClaim essentialAuthTimeClaim]; + XCTAssertNotEqualObjects(claim1, claim2); +} + +@end From 51868b34bf0507de1150d877c24603c7cf29e5e8 Mon Sep 17 00:00:00 2001 From: AkshatGandhi <54901287+AkshatG6@users.noreply.github.com> Date: Tue, 9 Sep 2025 13:11:31 -0700 Subject: [PATCH 02/16] GIDTokenClaimsInternalOptions Implementation + Unit Tests #550 (#552) This pull request introduces the `GIDTokenClaimsInternalOptions` class, a new component designed to handle the validation and JSON serialization of token claims. Key changes: * Adds the `GIDTokenClaimsInternalOptions` class to validate the token claims and return a JSON object. * Adds the `GIDJSONSerializer` protocol with real and fake implementations to support serializing the token claims. * Provides unit tests to validate the implementation. --- .../GIDJSONSerializer/API/GIDJSONSerializer.h | 38 ++++++ .../Fake/GIDFakeJSONSerializerImpl.h | 37 ++++++ .../Fake/GIDFakeJSONSerializerImpl.m | 46 +++++++ .../Implementation/GIDJSONSerializerImpl.h | 26 ++++ .../Implementation/GIDJSONSerializerImpl.m | 46 +++++++ .../Sources/GIDTokenClaimsInternalOptions.h | 55 +++++++++ .../Sources/GIDTokenClaimsInternalOptions.m | 91 ++++++++++++++ .../Sources/Public/GoogleSignIn/GIDSignIn.h | 4 + .../Unit/GIDTokenClaimsInternalOptionsTest.m | 113 ++++++++++++++++++ 9 files changed, 456 insertions(+) create mode 100644 GoogleSignIn/Sources/GIDJSONSerializer/API/GIDJSONSerializer.h create mode 100644 GoogleSignIn/Sources/GIDJSONSerializer/Fake/GIDFakeJSONSerializerImpl.h create mode 100644 GoogleSignIn/Sources/GIDJSONSerializer/Fake/GIDFakeJSONSerializerImpl.m create mode 100644 GoogleSignIn/Sources/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.h create mode 100644 GoogleSignIn/Sources/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.m create mode 100644 GoogleSignIn/Sources/GIDTokenClaimsInternalOptions.h create mode 100644 GoogleSignIn/Sources/GIDTokenClaimsInternalOptions.m create mode 100644 GoogleSignIn/Tests/Unit/GIDTokenClaimsInternalOptionsTest.m diff --git a/GoogleSignIn/Sources/GIDJSONSerializer/API/GIDJSONSerializer.h b/GoogleSignIn/Sources/GIDJSONSerializer/API/GIDJSONSerializer.h new file mode 100644 index 00000000..06187949 --- /dev/null +++ b/GoogleSignIn/Sources/GIDJSONSerializer/API/GIDJSONSerializer.h @@ -0,0 +1,38 @@ +/* + * Copyright 2025 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 + +NS_ASSUME_NONNULL_BEGIN + +/** + * A protocol for serializing an `NSDictionary` into a JSON string. + */ +@protocol GIDJSONSerializer + +/** + * Serializes the given dictionary into a `JSON` string. + * + * @param jsonObject The dictionary to be serialized. + * @param error A pointer to an `NSError` object to be populated upon failure. + * @return A `JSON` string representation of the dictionary, or `nil` if an error occurs. + */ +- (nullable NSString *)stringWithJSONObject:(NSDictionary *)jsonObject + error:(NSError *_Nullable *_Nullable)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/GoogleSignIn/Sources/GIDJSONSerializer/Fake/GIDFakeJSONSerializerImpl.h b/GoogleSignIn/Sources/GIDJSONSerializer/Fake/GIDFakeJSONSerializerImpl.h new file mode 100644 index 00000000..469fc36d --- /dev/null +++ b/GoogleSignIn/Sources/GIDJSONSerializer/Fake/GIDFakeJSONSerializerImpl.h @@ -0,0 +1,37 @@ +/* + * Copyright 2025 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/GIDJSONSerializer/API/GIDJSONSerializer.h" + +NS_ASSUME_NONNULL_BEGIN + +/** A fake implementation of `GIDJSONSerializer` for testing purposes. */ +@interface GIDFakeJSONSerializerImpl : NSObject + +/** + * An error to be returned by `stringWithJSONObject:error:`. + * + * If this property is set, `stringWithJSONObject:error:` will return `nil` and + * populate the error parameter with this error. + */ +@property(nonatomic, nullable) NSError *serializationError; + +/** The dictionary passed to the serialization method. */ +@property(nonatomic, readonly, nullable) NSDictionary *capturedJSONObject; + +@end + +NS_ASSUME_NONNULL_END diff --git a/GoogleSignIn/Sources/GIDJSONSerializer/Fake/GIDFakeJSONSerializerImpl.m b/GoogleSignIn/Sources/GIDJSONSerializer/Fake/GIDFakeJSONSerializerImpl.m new file mode 100644 index 00000000..400c06ce --- /dev/null +++ b/GoogleSignIn/Sources/GIDJSONSerializer/Fake/GIDFakeJSONSerializerImpl.m @@ -0,0 +1,46 @@ +/* + * Copyright 2025 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/GIDJSONSerializer/Fake/GIDFakeJSONSerializerImpl.h" + +#import "GoogleSignIn/Sources/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h" + +@implementation GIDFakeJSONSerializerImpl + +- (nullable NSString *)stringWithJSONObject:(NSDictionary *)jsonObject + error:(NSError *_Nullable *_Nullable)error { + _capturedJSONObject = [jsonObject copy]; + + // Check if a serialization error should be simulated. + if (self.serializationError) { + if (error) { + *error = self.serializationError; + } + return nil; + } + + // If not failing, fall back to the real serialization path. + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:jsonObject + options:0 + error:error]; + if (!jsonData) { + return nil; + } + return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; +} + +@end diff --git a/GoogleSignIn/Sources/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.h b/GoogleSignIn/Sources/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.h new file mode 100644 index 00000000..5e1eb03d --- /dev/null +++ b/GoogleSignIn/Sources/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.h @@ -0,0 +1,26 @@ +/* + * Copyright 2025 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/GIDJSONSerializer/API/GIDJSONSerializer.h" + +NS_ASSUME_NONNULL_BEGIN + +extern NSString *const kGIDJSONSerializationErrorDescription; + +@interface GIDJSONSerializerImpl : NSObject +@end + +NS_ASSUME_NONNULL_END diff --git a/GoogleSignIn/Sources/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.m b/GoogleSignIn/Sources/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.m new file mode 100644 index 00000000..8a3e3d84 --- /dev/null +++ b/GoogleSignIn/Sources/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.m @@ -0,0 +1,46 @@ +/* + * Copyright 2025 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/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.h" + +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h" + +NSString * const kGIDJSONSerializationErrorDescription = + @"The provided object could not be serialized to a JSON string."; + +@implementation GIDJSONSerializerImpl + +- (nullable NSString *)stringWithJSONObject:(NSDictionary *)jsonObject + error:(NSError *_Nullable *_Nullable)error { + NSError *serializationError; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:jsonObject + options:0 + error:&serializationError]; + if (!jsonData) { + if (error) { + *error = [NSError errorWithDomain:kGIDSignInErrorDomain + code:kGIDSignInErrorCodeJSONSerializationFailure + userInfo:@{ + NSLocalizedDescriptionKey:kGIDJSONSerializationErrorDescription, + NSUnderlyingErrorKey:serializationError + }]; + } + return nil; + } + return [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; +} + +@end diff --git a/GoogleSignIn/Sources/GIDTokenClaimsInternalOptions.h b/GoogleSignIn/Sources/GIDTokenClaimsInternalOptions.h new file mode 100644 index 00000000..adc56dd1 --- /dev/null +++ b/GoogleSignIn/Sources/GIDTokenClaimsInternalOptions.h @@ -0,0 +1,55 @@ +/* + * Copyright 2025 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 + +@class GIDTokenClaim; + +NS_ASSUME_NONNULL_BEGIN + +extern NSString *const kGIDTokenClaimErrorDescription; +extern NSString *const kGIDTokenClaimEssentialPropertyKeyName; +extern NSString *const kGIDTokenClaimKeyName; + +@protocol GIDJSONSerializer; + +/** + * An internal utility class for processing and serializing the `NSSet` of `GIDTokenClaim` objects + * into the `JSON` format required for an `OIDAuthorizationRequest`. + */ +@interface GIDTokenClaimsInternalOptions : NSObject + +- (instancetype)init; + +- (instancetype)initWithJSONSerializer: + (id)jsonSerializer NS_DESIGNATED_INITIALIZER; + +/** + * Processes the `NSSet` of `GIDTokenClaim` objects, handling ambiguous claims, + * and returns a `JSON` string. + * + * @param claims The `NSSet` of `GIDTokenClaim` objects provided by the developer. + * @param error A pointer to an `NSError` object to be populated if an error occurs (e.g., if a + * claim is requested as both essential and non-essential). + * @return A `JSON` string representing the claims request, or `nil` if the input is empty or an + * error occurs. + */ +- (nullable NSString *)validatedJSONStringForClaims:(nullable NSSet *)claims + error:(NSError *_Nullable *_Nullable)error; + +@end + +NS_ASSUME_NONNULL_END diff --git a/GoogleSignIn/Sources/GIDTokenClaimsInternalOptions.m b/GoogleSignIn/Sources/GIDTokenClaimsInternalOptions.m new file mode 100644 index 00000000..049f00e1 --- /dev/null +++ b/GoogleSignIn/Sources/GIDTokenClaimsInternalOptions.m @@ -0,0 +1,91 @@ +/* + * Copyright 2025 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/GIDTokenClaimsInternalOptions.h" + +#import "GoogleSignIn/Sources/GIDJSONSerializer/API/GIDJSONSerializer.h" +#import "GoogleSignIn/Sources/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDTokenClaim.h" + +NSString * const kGIDTokenClaimErrorDescription = + @"The claim was requested as both essential and non-essential. " + @"Please provide only one version."; +NSString * const kGIDTokenClaimEssentialPropertyKey = @"essential"; +NSString * const kGIDTokenClaimKeyName = @"id_token"; + +@interface GIDTokenClaimsInternalOptions () +@property(nonatomic, readonly) id jsonSerializer; +@end + +@implementation GIDTokenClaimsInternalOptions + +- (instancetype)init { + return [self initWithJSONSerializer:[[GIDJSONSerializerImpl alloc] init]]; +} + +- (instancetype)initWithJSONSerializer:(id)jsonSerializer { + if (self = [super init]) { + _jsonSerializer = jsonSerializer; + } + return self; +} + +- (nullable NSString *)validatedJSONStringForClaims:(nullable NSSet *)claims + error:(NSError *_Nullable *_Nullable)error { + if (!claims || claims.count == 0) { + return nil; + } + + // === Step 1: Check for claims with ambiguous essential property. === + NSMutableDictionary *validTokenClaims = + [[NSMutableDictionary alloc] init]; + + for (GIDTokenClaim *currentClaim in claims) { + GIDTokenClaim *existingClaim = validTokenClaims[currentClaim.name]; + + // Check for a conflict: a claim with the same name but different essentiality. + if (existingClaim && existingClaim.isEssential != currentClaim.isEssential) { + if (error) { + *error = [NSError errorWithDomain:kGIDSignInErrorDomain + code:kGIDSignInErrorCodeAmbiguousClaims + userInfo:@{ + NSLocalizedDescriptionKey:kGIDTokenClaimErrorDescription + }]; + } + return nil; + } + validTokenClaims[currentClaim.name] = currentClaim; + } + + // === Step 2: Build the dictionary structure required for OIDC JSON === + NSMutableDictionary *tokenClaimsDictionary = + [[NSMutableDictionary alloc] init]; + for (GIDTokenClaim *claim in validTokenClaims.allValues) { + if (claim.isEssential) { + tokenClaimsDictionary[claim.name] = @{ kGIDTokenClaimEssentialPropertyKey: @YES }; + } else { + tokenClaimsDictionary[claim.name] = @{ kGIDTokenClaimEssentialPropertyKey: @NO }; + } + } + NSDictionary *finalRequestDictionary = + @{ kGIDTokenClaimKeyName: tokenClaimsDictionary }; + + // === Step 3: Serialize the final dictionary into a JSON string === + return [_jsonSerializer stringWithJSONObject:finalRequestDictionary error:error]; +} + +@end diff --git a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h index 1025a92a..29cc0ef7 100644 --- a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h +++ b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h @@ -45,10 +45,14 @@ typedef NS_ERROR_ENUM(kGIDSignInErrorDomain, GIDSignInErrorCode) { kGIDSignInErrorCodeCanceled = -5, /// Indicates an Enterprise Mobility Management related error has occurred. kGIDSignInErrorCodeEMM = -6, + /// Indicates a claim was requested as both essential and non-essential . + kGIDSignInErrorCodeAmbiguousClaims = -7, /// Indicates the requested scopes have already been granted to the `currentUser`. kGIDSignInErrorCodeScopesAlreadyGranted = -8, /// Indicates there is an operation on a previous user. kGIDSignInErrorCodeMismatchWithCurrentUser = -9, + /// Indicates that an object could not be serialized into a `JSON` string. + kGIDSignInErrorCodeJSONSerializationFailure = -10 }; /// This class is used to sign in users with their Google account and manage their session. diff --git a/GoogleSignIn/Tests/Unit/GIDTokenClaimsInternalOptionsTest.m b/GoogleSignIn/Tests/Unit/GIDTokenClaimsInternalOptionsTest.m new file mode 100644 index 00000000..4c90998f --- /dev/null +++ b/GoogleSignIn/Tests/Unit/GIDTokenClaimsInternalOptionsTest.m @@ -0,0 +1,113 @@ +// Copyright 2025 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 + +#import "GoogleSignIn/Sources/GIDJSONSerializer/Fake/GIDFakeJSONSerializerImpl.h" +#import "GoogleSignIn/Sources/GIDJSONSerializer/Implementation/GIDJSONSerializerImpl.h" +#import "GoogleSignIn/Sources/GIDTokenClaimsInternalOptions.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDSignIn.h" +#import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDTokenClaim.h" + +static NSString *const kEssentialAuthTimeExpectedJSON = @"{\"id_token\":{\"auth_time\":{\"essential\":true}}}"; +static NSString *const kNonEssentialAuthTimeExpectedJSON = @"{\"id_token\":{\"auth_time\":{\"essential\":false}}}"; + +@interface GIDTokenClaimsInternalOptionsTest : XCTestCase + +@property(nonatomic) GIDFakeJSONSerializerImpl *jsonSerializerFake; +@property(nonatomic) GIDTokenClaimsInternalOptions *tokenClaimsInternalOptions; + +@end + +@implementation GIDTokenClaimsInternalOptionsTest + +- (void)setUp { + [super setUp]; + _jsonSerializerFake = [[GIDFakeJSONSerializerImpl alloc] init]; + _tokenClaimsInternalOptions = [[GIDTokenClaimsInternalOptions alloc] initWithJSONSerializer:_jsonSerializerFake]; +} + +- (void)tearDown { + _jsonSerializerFake = nil; + _tokenClaimsInternalOptions = nil; + [super tearDown]; +} + +#pragma mark - Input Validation Tests + +- (void)testValidatedJSONStringForClaims_WithNilInput_ShouldReturnNil { + XCTAssertNil([_tokenClaimsInternalOptions validatedJSONStringForClaims:nil error:nil]); +} + +- (void)testValidatedJSONStringForClaims_WithEmptyInput_ShouldReturnNil { + XCTAssertNil([_tokenClaimsInternalOptions validatedJSONStringForClaims:[NSSet set] error:nil]); +} + +#pragma mark - Correct Formatting Tests + +- (void)testValidatedJSONStringForClaims_WithNonEssentialClaim_IsCorrectlyFormatted { + NSSet *claims = [NSSet setWithObject:[GIDTokenClaim authTimeClaim]]; + NSError *error; + NSString *result = [_tokenClaimsInternalOptions validatedJSONStringForClaims:claims error:&error]; + + XCTAssertNil(error); + XCTAssertEqualObjects(result, kNonEssentialAuthTimeExpectedJSON); +} + +- (void)testValidatedJSONStringForClaims_WithEssentialClaim_IsCorrectlyFormatted { + NSSet *claims = [NSSet setWithObject:[GIDTokenClaim essentialAuthTimeClaim]]; + NSError *error; + NSString *result = [_tokenClaimsInternalOptions validatedJSONStringForClaims:claims error:&error]; + + XCTAssertNil(error); + XCTAssertEqualObjects(result, kEssentialAuthTimeExpectedJSON); +} + +#pragma mark - Client Error Handling Tests + +- (void)testValidatedJSONStringForClaims_WithConflictingClaims_ReturnsNilAndPopulatesError { + NSSet *claims = [NSSet setWithObjects:[GIDTokenClaim authTimeClaim], + [GIDTokenClaim essentialAuthTimeClaim], + nil]; + NSError *error; + NSString *result = [_tokenClaimsInternalOptions validatedJSONStringForClaims:claims error:&error]; + + XCTAssertNil(result, @"Method should return nil for conflicting claims."); + XCTAssertNotNil(error, @"An error object should be populated."); + XCTAssertEqualObjects(error.domain, kGIDSignInErrorDomain, @"Error domain should be correct."); + XCTAssertEqual(error.code, kGIDSignInErrorCodeAmbiguousClaims, + @"Error code should be for ambiguous claims."); +} + +- (void)testValidatedJSONStringForClaims_WhenSerializationFails_ReturnsNilAndError { + NSSet *claims = [NSSet setWithObject:[GIDTokenClaim authTimeClaim]]; + NSError *expectedJSONError = [NSError errorWithDomain:kGIDSignInErrorDomain + code:kGIDSignInErrorCodeJSONSerializationFailure + userInfo:@{ + NSLocalizedDescriptionKey: kGIDJSONSerializationErrorDescription, + }]; + _jsonSerializerFake.serializationError = expectedJSONError; + NSError *actualError; + NSString *result = [_tokenClaimsInternalOptions validatedJSONStringForClaims:claims + error:&actualError]; + + XCTAssertNil(result, @"The result should be nil when JSON serialization fails."); + XCTAssertEqualObjects( + actualError, + expectedJSONError, + @"The error from serialization should be passed back to the caller." + ); +} + +@end From 076275836b3110f377d63b0fcf8ca37c1ed0d051 Mon Sep 17 00:00:00 2001 From: AkshatGandhi <54901287+AkshatG6@users.noreply.github.com> Date: Fri, 12 Sep 2025 14:44:13 -0700 Subject: [PATCH 03/16] Updated GIDSignIn + GIDSignInInternalOptions Implementations + Unit Tests (#553) --- GoogleSignIn/Sources/GIDSignIn.m | 149 ++++++++++++-- .../Sources/GIDSignInInternalOptions.h | 8 + .../Sources/GIDSignInInternalOptions.m | 5 + .../Sources/Public/GoogleSignIn/GIDSignIn.h | 170 ++++++++++++++++ .../Tests/Unit/GIDSignInInternalOptionsTest.m | 49 +++++ GoogleSignIn/Tests/Unit/GIDSignInTest.m | 184 ++++++++++++++++-- .../Tests/Unit/OIDTokenResponse+Testing.h | 12 ++ .../Tests/Unit/OIDTokenResponse+Testing.m | 38 +++- 8 files changed, 580 insertions(+), 35 deletions(-) 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]; From 40b969295bb20c33713d1367ba7dc8e292bdabbd Mon Sep 17 00:00:00 2001 From: AkshatGandhi <54901287+AkshatG6@users.noreply.github.com> Date: Mon, 15 Sep 2025 16:05:50 -0700 Subject: [PATCH 04/16] Updated sample app to support auth_time (#555) --- .../Services/GoogleSignInAuthenticator.swift | 9 +++- .../ViewModels/AuthenticationViewModel.swift | 54 +++++++++++++++++++ .../iOS/UserProfileView.swift | 3 ++ .../macOS/UserProfileView.swift | 3 ++ 4 files changed, 67 insertions(+), 2 deletions(-) diff --git a/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift b/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift index e12d79a2..88d00f2e 100644 --- a/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift +++ b/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift @@ -20,6 +20,7 @@ import GoogleSignIn /// An observable class for authenticating via Google. final class GoogleSignInAuthenticator: ObservableObject { private var authViewModel: AuthenticationViewModel + private var tokenClaims: Set = Set([GIDTokenClaim.authTime()]) /// Creates an instance of this authenticator. /// - parameter authViewModel: The view model this authenticator will set logged in status on. @@ -41,7 +42,8 @@ final class GoogleSignInAuthenticator: ObservableObject { withPresenting: rootViewController, hint: nil, additionalScopes: nil, - nonce: manualNonce + nonce: manualNonce, + tokenClaims: tokenClaims ) { signInResult, error in guard let signInResult = signInResult else { print("Error! \(String(describing: error))") @@ -66,7 +68,10 @@ final class GoogleSignInAuthenticator: ObservableObject { return } - GIDSignIn.sharedInstance.signIn(withPresenting: presentingWindow) { signInResult, error in + GIDSignIn.sharedInstance.signIn( + withPresenting: presentingWindow, + tokenClaims: tokenClaims + ) { signInResult, error in guard let signInResult = signInResult else { print("Error! \(String(describing: error))") return diff --git a/Samples/Swift/DaysUntilBirthday/Shared/ViewModels/AuthenticationViewModel.swift b/Samples/Swift/DaysUntilBirthday/Shared/ViewModels/AuthenticationViewModel.swift index 15bee104..b528fc68 100644 --- a/Samples/Swift/DaysUntilBirthday/Shared/ViewModels/AuthenticationViewModel.swift +++ b/Samples/Swift/DaysUntilBirthday/Shared/ViewModels/AuthenticationViewModel.swift @@ -25,6 +25,19 @@ final class AuthenticationViewModel: ObservableObject { private var authenticator: GoogleSignInAuthenticator { return GoogleSignInAuthenticator(authViewModel: self) } + + /// The user's `auth_time` as found in `idToken`. + /// - note: If the user is logged out, then this will default to `nil`. + var authTime: Date? { + switch state { + case .signedIn(let user): + guard let idToken = user.idToken?.tokenString else { return nil } + return decodeAuthTime(fromJWT: idToken) + case .signedOut: + return nil + } + } + /// The user-authorized scopes. /// - note: If the user is logged out, then this will default to empty. var authorizedScopes: [String] { @@ -69,7 +82,48 @@ final class AuthenticationViewModel: ObservableObject { @MainActor func addBirthdayReadScope(completion: @escaping () -> Void) { authenticator.addBirthdayReadScope(completion: completion) } + + var formattedAuthTimeString: String? { + guard let date = authTime else { return nil } + let formatter = DateFormatter() + formatter.dateFormat = "MMM d, yyyy 'at' h:mm a" + return formatter.string(from: date) + } +} +private extension AuthenticationViewModel { + func decodeAuthTime(fromJWT jwt: String) -> Date? { + let segments = jwt.components(separatedBy: ".") + guard let parts = decodeJWTSegment(segments[1]), + let authTimeInterval = parts["auth_time"] as? TimeInterval else { + return nil + } + return Date(timeIntervalSince1970: authTimeInterval) + } + + func decodeJWTSegment(_ segment: String) -> [String: Any]? { + guard let segmentData = base64UrlDecode(segment), + let segmentJSON = try? JSONSerialization.jsonObject(with: segmentData, options: []), + let payload = segmentJSON as? [String: Any] else { + return nil + } + return payload + } + + func base64UrlDecode(_ value: String) -> Data? { + var base64 = value + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let length = Double(base64.lengthOfBytes(using: String.Encoding.utf8)) + let requiredLength = 4 * ceil(length / 4.0) + let paddingLength = requiredLength - length + if paddingLength > 0 { + let padding = "".padding(toLength: Int(paddingLength), withPad: "=", startingAt: 0) + base64 = base64 + padding + } + return Data(base64Encoded: base64, options: .ignoreUnknownCharacters) + } } extension AuthenticationViewModel { diff --git a/Samples/Swift/DaysUntilBirthday/iOS/UserProfileView.swift b/Samples/Swift/DaysUntilBirthday/iOS/UserProfileView.swift index 93366f47..256b777b 100644 --- a/Samples/Swift/DaysUntilBirthday/iOS/UserProfileView.swift +++ b/Samples/Swift/DaysUntilBirthday/iOS/UserProfileView.swift @@ -35,6 +35,9 @@ struct UserProfileView: View { Text(userProfile.name) .font(.headline) Text(userProfile.email) + if let authTimeString = authViewModel.formattedAuthTimeString { + Text("Last sign-in date: \(authTimeString)") + } } } NavigationLink(NSLocalizedString("View Days Until Birthday", comment: "View birthday days"), diff --git a/Samples/Swift/DaysUntilBirthday/macOS/UserProfileView.swift b/Samples/Swift/DaysUntilBirthday/macOS/UserProfileView.swift index d7faad97..3fddc744 100644 --- a/Samples/Swift/DaysUntilBirthday/macOS/UserProfileView.swift +++ b/Samples/Swift/DaysUntilBirthday/macOS/UserProfileView.swift @@ -19,6 +19,9 @@ struct UserProfileView: View { Text(userProfile.name) .font(.headline) Text(userProfile.email) + if let authTimeString = authViewModel.formattedAuthTimeString { + Text("Last sign-in date: \(authTimeString)") + } } } Button(NSLocalizedString("Sign Out", comment: "Sign out button"), action: signOut) From 5a0d207e5f72c0753db535d861d367fa4f31e28b Mon Sep 17 00:00:00 2001 From: AkshatGandhi <54901287+AkshatG6@users.noreply.github.com> Date: Tue, 23 Sep 2025 16:31:37 -0700 Subject: [PATCH 05/16] =?UTF-8?q?Update=20`AddScopes`=20to=20include=20pre?= =?UTF-8?q?viously=20requested=20`tokenClaims`=20in=20the=E2=80=A6=20(#557?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- GoogleSignIn/Sources/GIDSignIn.m | 45 +++++-- GoogleSignIn/Tests/Unit/GIDSignInTest.m | 122 +++++++++++++++++- .../Unit/OIDAuthorizationRequest+Testing.h | 4 +- .../Unit/OIDAuthorizationRequest+Testing.m | 8 +- .../Unit/OIDAuthorizationResponse+Testing.m | 5 +- 5 files changed, 163 insertions(+), 21 deletions(-) diff --git a/GoogleSignIn/Sources/GIDSignIn.m b/GoogleSignIn/Sources/GIDSignIn.m index 4da18b2c..ff4fcdae 100644 --- a/GoogleSignIn/Sources/GIDSignIn.m +++ b/GoogleSignIn/Sources/GIDSignIn.m @@ -367,6 +367,14 @@ - (void)addScopes:(NSArray *)scopes addScopesFlow:YES completion:completion]; + OIDAuthorizationRequest *lastAuthorizationRequest = + self.currentUser.authState.lastAuthorizationResponse.request; + NSString *lastTokenClaimsAsJSON = + lastAuthorizationRequest.additionalParameters[kTokenClaimsParameter]; + if (lastTokenClaimsAsJSON) { + options.tokenClaimsAsJSON = lastTokenClaimsAsJSON; + } + NSSet *requestedScopes = [NSSet setWithArray:scopes]; NSMutableSet *grantedScopes = [NSMutableSet setWithArray:self.currentUser.grantedScopes]; @@ -499,6 +507,14 @@ - (void)addScopes:(NSArray *)scopes addScopesFlow:YES completion:completion]; + OIDAuthorizationRequest *lastAuthorizationRequest = + self.currentUser.authState.lastAuthorizationResponse.request; + NSString *lastTokenClaimsAsJSON = + lastAuthorizationRequest.additionalParameters[kTokenClaimsParameter]; + if (lastTokenClaimsAsJSON) { + options.tokenClaimsAsJSON = lastTokenClaimsAsJSON; + } + NSSet *requestedScopes = [NSSet setWithArray:scopes]; NSMutableSet *grantedScopes = [NSMutableSet setWithArray:self.currentUser.grantedScopes]; @@ -739,20 +755,23 @@ - (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); - }); + // Only serialize tokenClaims if options.tokenClaimsAsJSON isn't already set. + if (!options.tokenClaimsAsJSON) { + 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) { + _currentOptions = nil; + dispatch_async(dispatch_get_main_queue(), ^{ + options.completion(nil, claimsError); + }); + } + return; } - return; } [self authenticateWithOptions:options]; } diff --git a/GoogleSignIn/Tests/Unit/GIDSignInTest.m b/GoogleSignIn/Tests/Unit/GIDSignInTest.m index 17453785..55f239d0 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInTest.m @@ -644,6 +644,7 @@ - (void)testOAuthLogin { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -661,6 +662,7 @@ - (void)testOAuthLogin_RestoredSignIn { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:YES oldAccessToken:NO @@ -678,6 +680,7 @@ - (void)testOAuthLogin_RestoredSignInOldAccessToken { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:YES oldAccessToken:YES @@ -697,6 +700,7 @@ - (void)testOAuthLogin_AdditionalScopes { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO tokenClaimsError:NO restoredSignIn:NO @@ -714,6 +718,7 @@ - (void)testOAuthLogin_AdditionalScopes { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO tokenClaimsError:NO restoredSignIn:NO @@ -731,6 +736,7 @@ - (void)testOAuthLogin_AdditionalScopes { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO tokenClaimsError:NO restoredSignIn:NO @@ -758,6 +764,7 @@ - (void)testOAuthLogin_WithTokenClaims_FormatsParametersCorrectly { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO tokenClaimsError:NO restoredSignIn:NO @@ -776,6 +783,7 @@ - (void)testOAuthLogin_WithTokenClaims_FormatsParametersCorrectly { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO tokenClaimsError:NO restoredSignIn:NO @@ -803,6 +811,7 @@ - (void)testOAuthLogin_WithTokenClaims_ReturnsIdTokenWithCorrectClaims { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO tokenClaimsError:NO restoredSignIn:NO @@ -838,6 +847,7 @@ - (void)testAddScopes { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:YES oldAccessToken:NO @@ -856,11 +866,13 @@ - (void)testAddScopes { OCMStub([_user configuration]).andReturn(configuration); OCMStub([_user profile]).andReturn(profile); OCMStub([_user grantedScopes]).andReturn(@[kGrantedScope]); + OCMStub([_user authState]).andReturn(_authState); [self OAuthLoginWithAddScopesFlow:YES authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -888,6 +900,76 @@ - (void)testAddScopes { [profile stopMocking]; } +- (void)testAddScopes_WithPreviouslyRequestedClaims { + GIDTokenClaim *authTimeClaim = [GIDTokenClaim authTimeClaim]; + // Restore the previous sign-in account. This is the preparation for adding scopes. + OCMStub( + [_keychainStore saveAuthSession:OCMOCK_ANY error:OCMArg.anyObjectRef] + ).andDo(^(NSInvocation *invocation) { + self->_keychainSaved = self->_saveAuthorizationReturnValue; + }); + [self OAuthLoginWithAddScopesFlow:NO + authError:nil + tokenError:nil + emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO + keychainError:NO + tokenClaimsError:NO + restoredSignIn:NO + oldAccessToken:NO + modalCancel:NO + useAdditionalScopes:NO + additionalScopes:nil + manualNonce:nil + tokenClaims:[NSSet setWithObject:authTimeClaim]]; + + XCTAssertNotNil(_signIn.currentUser); + + id profile = OCMStrictClassMock([GIDProfileData class]); + OCMStub([profile email]).andReturn(kUserEmail); + + GIDConfiguration *configuration = [[GIDConfiguration alloc] initWithClientID:kClientId + serverClientID:nil + hostedDomain:nil + openIDRealm:kOpenIDRealm]; + OCMStub([_user configuration]).andReturn(configuration); + OCMStub([_user profile]).andReturn(profile); + OCMStub([_user grantedScopes]).andReturn(@[kGrantedScope]); + OCMStub([_user authState]).andReturn(_authState); + + [self OAuthLoginWithAddScopesFlow:YES + authError:nil + tokenError:nil + emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:YES + keychainError:NO + restoredSignIn:NO + oldAccessToken:NO + modalCancel:NO]; + + NSArray *grantedScopes; + NSString *grantedScopeString = _savedAuthorizationRequest.scope; + + if (grantedScopeString) { + NSCharacterSet *whiteSpaceChars = [NSCharacterSet whitespaceCharacterSet]; + grantedScopeString = + [grantedScopeString stringByTrimmingCharactersInSet:whiteSpaceChars]; + NSMutableArray *parsedScopes = + [[grantedScopeString componentsSeparatedByString:@" "] mutableCopy]; + [parsedScopes removeObject:@""]; + grantedScopes = [parsedScopes copy]; + } + + NSArray *expectedScopes = @[kNewScope, kGrantedScope]; + XCTAssertEqualObjects(grantedScopes, expectedScopes); + XCTAssertEqualObjects(_savedAuthorizationRequest.additionalParameters[@"claims"], + kNonEssentialAuthTimeClaimsJsonString, + @"Claims JSON should be correctly formatted"); + + [_user verify]; + [profile verify]; +} + - (void)testOpenIDRealm { _signIn.configuration = [[GIDConfiguration alloc] initWithClientID:kClientId serverClientID:nil @@ -904,6 +986,7 @@ - (void)testOpenIDRealm { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -931,6 +1014,7 @@ - (void)testManualNonce { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO tokenClaimsError:NO restoredSignIn:NO @@ -959,6 +1043,7 @@ - (void)testOAuthLogin_LoginHint { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -984,6 +1069,7 @@ - (void)testOAuthLogin_HostedDomain { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -998,6 +1084,7 @@ - (void)testOAuthLogin_ConsentCanceled { authError:@"access_denied" tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -1012,6 +1099,7 @@ - (void)testOAuthLogin_ModalCanceled { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -1036,6 +1124,7 @@ - (void)testOAuthLogin_KeychainError { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:YES restoredSignIn:NO oldAccessToken:NO @@ -1056,6 +1145,7 @@ - (void)testOAuthLogin_TokenClaims_FailsWithError { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO tokenClaimsError:YES restoredSignIn:NO @@ -1093,6 +1183,7 @@ - (void)testSignOut { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:YES oldAccessToken:NO @@ -1339,6 +1430,7 @@ - (void)testEmmSupportRequestParameters { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -1390,6 +1482,7 @@ - (void)testEmmPasscodeInfo { authError:nil tokenError:nil emmPasscodeInfoRequired:YES + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -1421,6 +1514,7 @@ - (void)testAuthEndpointEMMError { authError:callbackParams[@"error"] tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -1458,6 +1552,7 @@ - (void)testTokenEndpointEMMError { authError:nil tokenError:emmError emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -1542,6 +1637,7 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow authError:(NSString *)authError tokenError:(NSError *)tokenError emmPasscodeInfoRequired:(BOOL)emmPasscodeInfoRequired + tokenClaimsAsJSONRequired:(BOOL)tokenClaimsAsJSONRequired keychainError:(BOOL)keychainError restoredSignIn:(BOOL)restoredSignIn oldAccessToken:(BOOL)oldAccessToken @@ -1550,6 +1646,7 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow authError:authError tokenError:tokenError emmPasscodeInfoRequired:emmPasscodeInfoRequired + tokenClaimsAsJSONRequired:tokenClaimsAsJSONRequired keychainError:keychainError tokenClaimsError:NO restoredSignIn:restoredSignIn @@ -1566,6 +1663,7 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow authError:(NSString *)authError tokenError:(NSError *)tokenError emmPasscodeInfoRequired:(BOOL)emmPasscodeInfoRequired + tokenClaimsAsJSONRequired:(BOOL)tokenClaimsAsJSONRequired keychainError:(BOOL)keychainError tokenClaimsError:(BOOL)tokenClaimsError restoredSignIn:(BOOL)restoredSignIn @@ -1582,8 +1680,9 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow [[[_authState expect] andReturnValue:[NSNumber numberWithBool:isAuthorized]] isAuthorized]; } - NSDictionary *additionalParameters = emmPasscodeInfoRequired ? - @{ @"emm_passcode_info_required" : @"1" } : nil; + NSDictionary *additionalParameters = + [self additionalParametersWithEMMPasscodeInfoRequired:emmPasscodeInfoRequired + tokenClaimsAsJSONRequired:tokenClaimsAsJSONRequired]; OIDAuthorizationResponse *authResponse = [OIDAuthorizationResponse testInstanceWithAdditionalParameters:additionalParameters nonce:nonce @@ -1646,6 +1745,7 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow self->_authError = error; }; if (addScopesFlow) { + [[[_authState expect] andReturn:authResponse] lastAuthorizationResponse]; [_signIn addScopes:@[kNewScope] #if TARGET_OS_IOS || TARGET_OS_MACCATALYST presentingViewController:_presentingViewController @@ -1846,4 +1946,22 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow } } +#pragma mark - Private Helpers + +- (NSDictionary *) + additionalParametersWithEMMPasscodeInfoRequired:(BOOL)emmPasscodeInfoRequired + tokenClaimsAsJSONRequired:(BOOL)tokenClaimsAsJSONRequired { + NSMutableDictionary *additionalParameters = + [NSMutableDictionary dictionary]; + + if (emmPasscodeInfoRequired) { + additionalParameters[@"emm_passcode_info_required"] = @"1"; + } + if (tokenClaimsAsJSONRequired) { + additionalParameters[@"claims"] = kNonEssentialAuthTimeClaimsJsonString; + } + + return [additionalParameters copy]; +} + @end diff --git a/GoogleSignIn/Tests/Unit/OIDAuthorizationRequest+Testing.h b/GoogleSignIn/Tests/Unit/OIDAuthorizationRequest+Testing.h index a70d5e80..d3e691c5 100644 --- a/GoogleSignIn/Tests/Unit/OIDAuthorizationRequest+Testing.h +++ b/GoogleSignIn/Tests/Unit/OIDAuthorizationRequest+Testing.h @@ -29,6 +29,6 @@ extern NSString * _Nonnull const OIDAuthorizationRequestTestingCodeVerifier; + (instancetype _Nonnull)testInstance; -+ (instancetype _Nonnull)testInstanceWithNonce:(nullable NSString *)nonce; - ++ (instancetype _Nonnull)testInstanceWithNonce:(nullable NSString *)nonce + additionalParameters:(nullable NSDictionary *)additionalParameters; @end diff --git a/GoogleSignIn/Tests/Unit/OIDAuthorizationRequest+Testing.m b/GoogleSignIn/Tests/Unit/OIDAuthorizationRequest+Testing.m index a8dd0b81..a45dac90 100644 --- a/GoogleSignIn/Tests/Unit/OIDAuthorizationRequest+Testing.m +++ b/GoogleSignIn/Tests/Unit/OIDAuthorizationRequest+Testing.m @@ -32,10 +32,12 @@ @implementation OIDAuthorizationRequest (Testing) + (instancetype)testInstance { - return [self testInstanceWithNonce:nil]; + return [self testInstanceWithNonce:nil additionalParameters:nil]; } -+ (instancetype)testInstanceWithNonce:(nullable NSString *)nonce { ++ (instancetype)testInstanceWithNonce:(nullable NSString *)nonce + additionalParameters: + (nullable NSDictionary *)additionalParameters { return [[OIDAuthorizationRequest alloc] initWithConfiguration:[OIDServiceConfiguration testInstance] clientId:OIDAuthorizationRequestTestingClientID @@ -44,7 +46,7 @@ + (instancetype)testInstanceWithNonce:(nullable NSString *)nonce { redirectURL:[NSURL URLWithString:@"http://test.com"] responseType:OIDResponseTypeCode nonce:nonce - additionalParameters:nil]; + additionalParameters:additionalParameters]; } @end diff --git a/GoogleSignIn/Tests/Unit/OIDAuthorizationResponse+Testing.m b/GoogleSignIn/Tests/Unit/OIDAuthorizationResponse+Testing.m index cc4f1211..5404d411 100644 --- a/GoogleSignIn/Tests/Unit/OIDAuthorizationResponse+Testing.m +++ b/GoogleSignIn/Tests/Unit/OIDAuthorizationResponse+Testing.m @@ -46,7 +46,10 @@ + (instancetype)testInstanceWithAdditionalParameters: [parameters addEntriesFromDictionary:additionalParameters]; } } - return [[OIDAuthorizationResponse alloc] initWithRequest:[OIDAuthorizationRequest testInstanceWithNonce:nonce] + OIDAuthorizationRequest *request = + [OIDAuthorizationRequest testInstanceWithNonce:nonce + additionalParameters:additionalParameters]; + return [[OIDAuthorizationResponse alloc] initWithRequest:request parameters:parameters]; } From 7cca76bedeec3029257f6a0f4736a437f1bd9403 Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:10:31 -0700 Subject: [PATCH 06/16] Fixing refresh parameters mismatch bug --- GoogleSignIn/Sources/GIDEMMSupport.m | 57 +++++++- GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m | 137 ++++++++++++++++++++ 2 files changed, 191 insertions(+), 3 deletions(-) diff --git a/GoogleSignIn/Sources/GIDEMMSupport.m b/GoogleSignIn/Sources/GIDEMMSupport.m index 0e7b0369..1637f476 100644 --- a/GoogleSignIn/Sources/GIDEMMSupport.m +++ b/GoogleSignIn/Sources/GIDEMMSupport.m @@ -58,6 +58,13 @@ typedef NS_ENUM(NSInteger, ErrorCode) { ErrorCodeAppVerificationRequired, }; +@interface GIDEMMSupport () + ++ (NSDictionary *) + dictionaryWithStringValuesFromDictionary:(NSDictionary *)originalDictionary; + +@end + @implementation GIDEMMSupport - (instancetype)init { @@ -115,9 +122,11 @@ + (NSDictionary *)parametersWithParameters:(NSDictionary *)parameters #pragma mark - GTMAuthSessionDelegate - (nullable NSDictionary *) -additionalTokenRefreshParametersForAuthSession:(GTMAuthSession *)authSession { - return [GIDEMMSupport updatedEMMParametersWithParameters: - authSession.authState.lastTokenResponse.additionalParameters]; + additionalTokenRefreshParametersForAuthSession:(GTMAuthSession *)authSession { + NSDictionary *additionalParameters = authSession.authState.lastTokenResponse.additionalParameters; + NSDictionary *updatedAdditionalParameters = + [GIDEMMSupport updatedEMMParametersWithParameters:additionalParameters]; + return [GIDEMMSupport dictionaryWithStringValuesFromDictionary:updatedAdditionalParameters]; } - (void)updateErrorForAuthSession:(GTMAuthSession *)authSession @@ -128,6 +137,48 @@ - (void)updateErrorForAuthSession:(GTMAuthSession *)authSession }]; } +#pragma mark - Private Helpers + ++ (NSDictionary *) + dictionaryWithStringValuesFromDictionary:(NSDictionary *)originalDictionary { + NSMutableDictionary *stringifiedDictionary = + [NSMutableDictionary dictionaryWithCapacity:originalDictionary.count]; + + [originalDictionary enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) { + // Case 1: The value is already of `NSString` type. + if ([value isKindOfClass:[NSString class]]) { + stringifiedDictionary[key] = value; + return; + } + + // Case 2: The value is of `NSNumber` type (which includes `BOOL` type). + if ([value isKindOfClass:[NSNumber class]]) { + if (CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID()) { + stringifiedDictionary[key] = [value boolValue] ? @"true" : @"false"; + } else { + stringifiedDictionary[key] = [value stringValue]; + } + return; + } + + // Case 3: The value is of NSArray or NSDictionary type. + // To satisfy `GTMAppAuth`'s requirement for [String: String] parameters, the entire + // object is serialized into a single JSON string. + if ([NSJSONSerialization isValidJSONObject:value]) { + NSError *error = nil; + NSData *jsonData = [NSJSONSerialization dataWithJSONObject:value options:0 error:&error]; + + if (jsonData && !error) { + stringifiedDictionary[key] = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; + } else { + stringifiedDictionary[key] = [value description]; + } + return; + } + }]; + return stringifiedDictionary; +} + @end NS_ASSUME_NONNULL_END diff --git a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m index 990aa733..663c77f6 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m @@ -59,6 +59,11 @@ static NSString *const kDeviceOSKey = @"device_os"; static NSString *const kEMMPasscodeInfoKey = @"emm_passcode_info"; +@interface GIDEMMSupport (Private) ++ (NSDictionary *) + dictionaryWithStringValuesFromDictionary:(NSDictionary *)originalDictionary; +@end + @interface GIDEMMSupportTest : XCTestCase // The view controller that has been presented, if any. @property(nonatomic, strong, nullable) UIViewController *presentedViewController; @@ -274,6 +279,138 @@ - (void)testHandleTokenFetchEMMError_errorIsNotEMM { [self waitForExpectations:@[ called ] timeout:1]; } +# pragma mark - String Conversion Tests + +- (void)testStringConversion_withAnyNumber_isConvertedToString { + NSDictionary *inputDictionary = @{ @"number_key": @12345 }; + + NSDictionary *resultDictionary = [GIDEMMSupport + dictionaryWithStringValuesFromDictionary:inputDictionary]; + + XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]]); + XCTAssertEqualObjects(resultDictionary[@"number_key"], @"12345", + @"The NSNumber should be converted to a string."); +} + +- (void)testStringConversion_withNumberOne_isConvertedToString { + NSDictionary *inputDictionary = @{ @"number_key": @1 }; + + NSDictionary *resultDictionary = [GIDEMMSupport + dictionaryWithStringValuesFromDictionary:inputDictionary]; + + XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]]); + XCTAssertEqualObjects(resultDictionary[@"number_key"], @"1", + @"The NSNumber should be converted to a string."); +} + +- (void)testStringConversion_withNumberZero_isConvertedToString { + NSDictionary *inputDictionary = @{ @"number_key": @0}; + + NSDictionary *resultDictionary = [GIDEMMSupport + dictionaryWithStringValuesFromDictionary:inputDictionary]; + + XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]]); + XCTAssertEqualObjects(resultDictionary[@"number_key"], @"0", + @"The NSNumber should be converted to a string."); +} + +- (void)testStringConversion_withBooleanYes_isConvertedToTrueString { + NSDictionary *inputDictionary = @{ @"bool_key": @YES }; + + NSDictionary *resultDictionary = [GIDEMMSupport + dictionaryWithStringValuesFromDictionary:inputDictionary]; + + XCTAssertTrue([resultDictionary[@"bool_key"] isKindOfClass:[NSString class]], + @"The value should be an NSString."); + XCTAssertEqualObjects(resultDictionary[@"bool_key"], @"true", + @"The boolean YES should be converted to the string 'true'."); +} + +- (void)testStringConversion_withBooleanNo_isConvertedToFalseString { + NSDictionary *inputDictionary = @{ @"bool_key": @NO }; + + NSDictionary *resultDictionary = [GIDEMMSupport + dictionaryWithStringValuesFromDictionary:inputDictionary]; + + XCTAssertTrue([resultDictionary[@"bool_key"] isKindOfClass:[NSString class]], + @"The value should be an NSString."); + XCTAssertEqualObjects(resultDictionary[@"bool_key"], @"false", + @"The boolean NO should be converted to the string 'false'."); +} + +- (void)testStringConversion_withString_remainsUnchanged { + NSDictionary *inputDictionary = @{ @"string_key": @"hello" }; + + NSDictionary *resultDictionary = [GIDEMMSupport + dictionaryWithStringValuesFromDictionary:inputDictionary]; + + XCTAssertTrue([resultDictionary[@"string_key"] isKindOfClass:[NSString class]], + @"The value should still be an NSString."); + XCTAssertEqualObjects(resultDictionary[@"string_key"], @"hello", + @"The original string value should be preserved."); +} + +- (void)testStringConversion_withArray_isConvertedToJSONString { + NSDictionary *inputDictionary = @{ + @"array_key": @[ @1, @"two", @YES ] + }; + + NSDictionary *resultDictionary = [GIDEMMSupport + dictionaryWithStringValuesFromDictionary:inputDictionary]; + + XCTAssertTrue([resultDictionary[@"array_key"] isKindOfClass:[NSString class]], + @"The value should be an NSString."); + XCTAssertEqualObjects(resultDictionary[@"array_key"], @"[1,\"two\",true]", + @"The array should be serialized into a JSON string."); +} + +- (void)testStringConversion_withDictionary_isConvertedToJSONString { + NSDictionary *valueAsDictionary = @{ @"nested_key": @"nested_value" }; + NSDictionary *inputDictionary = @{ + @"dict_key": @{ + @"nested_key": @"nested_value" + } + }; + + NSDictionary *resultDictionary = [GIDEMMSupport + dictionaryWithStringValuesFromDictionary:inputDictionary]; + + XCTAssertTrue([resultDictionary[@"dict_key"] isKindOfClass:[NSString class]], + @"The value should be an NSString."); + XCTAssertEqualObjects(resultDictionary[@"dict_key"], @"{\"nested_key\":\"nested_value\"}", + @"The dictionary should be serialized into a JSON string."); +} + +- (void)testStringConversion_withMixedTypes_allAreConverted { + NSDictionary *inputDictionary = @{ + @"string_key": @"hello", + @"number_key": @987, + @"bool_key": @YES, + @"array_key": @[ @"a", @NO ], + }; + + NSDictionary *resultDictionary = [GIDEMMSupport + dictionaryWithStringValuesFromDictionary:inputDictionary]; + + XCTAssertTrue([resultDictionary[@"string_key"] isKindOfClass:[NSString class]]); + XCTAssertEqualObjects(resultDictionary[@"string_key"], @"hello"); + + XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]]); + XCTAssertEqualObjects(resultDictionary[@"number_key"], @"987"); + + XCTAssertTrue([resultDictionary[@"bool_key"] isKindOfClass:[NSString class]]); + XCTAssertEqualObjects(resultDictionary[@"bool_key"], @"true"); + + XCTAssertTrue([resultDictionary[@"array_key"] isKindOfClass:[NSString class]]); + XCTAssertEqualObjects(resultDictionary[@"array_key"], @"[\"a\",false]"); +} + +- (void)testStringConversion_withEmptyDictionary_returnsEmptyDictionary { + NSDictionary *resultDictionary = [GIDEMMSupport dictionaryWithStringValuesFromDictionary:@{}]; + + XCTAssertEqual(resultDictionary.count, 0, @"The resulting dictionary should be empty."); +} + # pragma mark - Helpers - (NSString *)systemVersion { From 97393fa4fdf66b93182a5428a9e7ca6cd1703d13 Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Tue, 7 Oct 2025 15:42:26 -0700 Subject: [PATCH 07/16] Fixed styling --- GoogleSignIn/Sources/GIDEMMSupport.m | 6 ++-- GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m | 34 +++++++++++++-------- 2 files changed, 25 insertions(+), 15 deletions(-) diff --git a/GoogleSignIn/Sources/GIDEMMSupport.m b/GoogleSignIn/Sources/GIDEMMSupport.m index 1637f476..8b118dd7 100644 --- a/GoogleSignIn/Sources/GIDEMMSupport.m +++ b/GoogleSignIn/Sources/GIDEMMSupport.m @@ -161,9 +161,9 @@ - (void)updateErrorForAuthSession:(GTMAuthSession *)authSession return; } - // Case 3: The value is of NSArray or NSDictionary type. - // To satisfy `GTMAppAuth`'s requirement for [String: String] parameters, the entire - // object is serialized into a single JSON string. + // Case 3: The value is of `NSArray` or `NSDictionary` type. + // To satisfy `GTMAppAuth`'s requirement for `NSDictionary` parameter, + // the entire value object is serialized into a single `JSON` string. if ([NSJSONSerialization isValidJSONObject:value]) { NSError *error = nil; NSData *jsonData = [NSJSONSerialization dataWithJSONObject:value options:0 error:&error]; diff --git a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m index 663c77f6..40194614 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m @@ -287,7 +287,8 @@ - (void)testStringConversion_withAnyNumber_isConvertedToString { NSDictionary *resultDictionary = [GIDEMMSupport dictionaryWithStringValuesFromDictionary:inputDictionary]; - XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]]); + XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]], + @"The value should be an NSString."); XCTAssertEqualObjects(resultDictionary[@"number_key"], @"12345", @"The NSNumber should be converted to a string."); } @@ -298,7 +299,8 @@ - (void)testStringConversion_withNumberOne_isConvertedToString { NSDictionary *resultDictionary = [GIDEMMSupport dictionaryWithStringValuesFromDictionary:inputDictionary]; - XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]]); + XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]], + @"The value should be an NSString."); XCTAssertEqualObjects(resultDictionary[@"number_key"], @"1", @"The NSNumber should be converted to a string."); } @@ -309,7 +311,8 @@ - (void)testStringConversion_withNumberZero_isConvertedToString { NSDictionary *resultDictionary = [GIDEMMSupport dictionaryWithStringValuesFromDictionary:inputDictionary]; - XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]]); + XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]], + @"The value should be an NSString."); XCTAssertEqualObjects(resultDictionary[@"number_key"], @"0", @"The NSNumber should be converted to a string."); } @@ -365,7 +368,6 @@ - (void)testStringConversion_withArray_isConvertedToJSONString { } - (void)testStringConversion_withDictionary_isConvertedToJSONString { - NSDictionary *valueAsDictionary = @{ @"nested_key": @"nested_value" }; NSDictionary *inputDictionary = @{ @"dict_key": @{ @"nested_key": @"nested_value" @@ -392,17 +394,25 @@ - (void)testStringConversion_withMixedTypes_allAreConverted { NSDictionary *resultDictionary = [GIDEMMSupport dictionaryWithStringValuesFromDictionary:inputDictionary]; - XCTAssertTrue([resultDictionary[@"string_key"] isKindOfClass:[NSString class]]); - XCTAssertEqualObjects(resultDictionary[@"string_key"], @"hello"); + XCTAssertTrue([resultDictionary[@"string_key"] isKindOfClass:[NSString class]], + @"The value should be an NSString."); + XCTAssertEqualObjects(resultDictionary[@"string_key"], @"hello", + @"The original string value should be preserved"); - XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]]); - XCTAssertEqualObjects(resultDictionary[@"number_key"], @"987"); + XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]], + @"The value should be an NSString."); + XCTAssertEqualObjects(resultDictionary[@"number_key"], @"987", + @"The NSNumber should be converted to a string."); - XCTAssertTrue([resultDictionary[@"bool_key"] isKindOfClass:[NSString class]]); - XCTAssertEqualObjects(resultDictionary[@"bool_key"], @"true"); + XCTAssertTrue([resultDictionary[@"bool_key"] isKindOfClass:[NSString class]], + @"The value should be an NSString."); + XCTAssertEqualObjects(resultDictionary[@"bool_key"], @"true", + @"The boolean YES should be converted to the string 'true'."); - XCTAssertTrue([resultDictionary[@"array_key"] isKindOfClass:[NSString class]]); - XCTAssertEqualObjects(resultDictionary[@"array_key"], @"[\"a\",false]"); + XCTAssertTrue([resultDictionary[@"array_key"] isKindOfClass:[NSString class]], + @"The value should be an NSString."); + XCTAssertEqualObjects(resultDictionary[@"array_key"], @"[\"a\",false]", + @"The array should be serialized into a JSON string."); } - (void)testStringConversion_withEmptyDictionary_returnsEmptyDictionary { From 8ef949b71d3a270016c3c3a2828645e92da114c9 Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Tue, 7 Oct 2025 17:59:06 -0700 Subject: [PATCH 08/16] Removed the support for handling nested containers in GIDEMMSupport.m --- GoogleSignIn/Sources/GIDEMMSupport.m | 18 ++---- GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m | 68 --------------------- 2 files changed, 4 insertions(+), 82 deletions(-) diff --git a/GoogleSignIn/Sources/GIDEMMSupport.m b/GoogleSignIn/Sources/GIDEMMSupport.m index 8b118dd7..c1c57a8d 100644 --- a/GoogleSignIn/Sources/GIDEMMSupport.m +++ b/GoogleSignIn/Sources/GIDEMMSupport.m @@ -161,20 +161,10 @@ - (void)updateErrorForAuthSession:(GTMAuthSession *)authSession return; } - // Case 3: The value is of `NSArray` or `NSDictionary` type. - // To satisfy `GTMAppAuth`'s requirement for `NSDictionary` parameter, - // the entire value object is serialized into a single `JSON` string. - if ([NSJSONSerialization isValidJSONObject:value]) { - NSError *error = nil; - NSData *jsonData = [NSJSONSerialization dataWithJSONObject:value options:0 error:&error]; - - if (jsonData && !error) { - stringifiedDictionary[key] = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding]; - } else { - stringifiedDictionary[key] = [value description]; - } - return; - } + // NOTE: Nested container objects (e.g., NSArray, NSDictionary) are intentionally ignored. + // The underlying AppAuth API expects a flat `NSDictionary` and is + // not designed for serialized, nested objects. A proper fix for nested objects + // would require a larger refactoring of the AppAuth and GTMAppAuth libraries. }]; return stringifiedDictionary; } diff --git a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m index 40194614..a7edbabb 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m @@ -353,74 +353,6 @@ - (void)testStringConversion_withString_remainsUnchanged { @"The original string value should be preserved."); } -- (void)testStringConversion_withArray_isConvertedToJSONString { - NSDictionary *inputDictionary = @{ - @"array_key": @[ @1, @"two", @YES ] - }; - - NSDictionary *resultDictionary = [GIDEMMSupport - dictionaryWithStringValuesFromDictionary:inputDictionary]; - - XCTAssertTrue([resultDictionary[@"array_key"] isKindOfClass:[NSString class]], - @"The value should be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"array_key"], @"[1,\"two\",true]", - @"The array should be serialized into a JSON string."); -} - -- (void)testStringConversion_withDictionary_isConvertedToJSONString { - NSDictionary *inputDictionary = @{ - @"dict_key": @{ - @"nested_key": @"nested_value" - } - }; - - NSDictionary *resultDictionary = [GIDEMMSupport - dictionaryWithStringValuesFromDictionary:inputDictionary]; - - XCTAssertTrue([resultDictionary[@"dict_key"] isKindOfClass:[NSString class]], - @"The value should be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"dict_key"], @"{\"nested_key\":\"nested_value\"}", - @"The dictionary should be serialized into a JSON string."); -} - -- (void)testStringConversion_withMixedTypes_allAreConverted { - NSDictionary *inputDictionary = @{ - @"string_key": @"hello", - @"number_key": @987, - @"bool_key": @YES, - @"array_key": @[ @"a", @NO ], - }; - - NSDictionary *resultDictionary = [GIDEMMSupport - dictionaryWithStringValuesFromDictionary:inputDictionary]; - - XCTAssertTrue([resultDictionary[@"string_key"] isKindOfClass:[NSString class]], - @"The value should be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"string_key"], @"hello", - @"The original string value should be preserved"); - - XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]], - @"The value should be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"number_key"], @"987", - @"The NSNumber should be converted to a string."); - - XCTAssertTrue([resultDictionary[@"bool_key"] isKindOfClass:[NSString class]], - @"The value should be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"bool_key"], @"true", - @"The boolean YES should be converted to the string 'true'."); - - XCTAssertTrue([resultDictionary[@"array_key"] isKindOfClass:[NSString class]], - @"The value should be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"array_key"], @"[\"a\",false]", - @"The array should be serialized into a JSON string."); -} - -- (void)testStringConversion_withEmptyDictionary_returnsEmptyDictionary { - NSDictionary *resultDictionary = [GIDEMMSupport dictionaryWithStringValuesFromDictionary:@{}]; - - XCTAssertEqual(resultDictionary.count, 0, @"The resulting dictionary should be empty."); -} - # pragma mark - Helpers - (NSString *)systemVersion { From 01d1a24b9e47dd0e30da8ef570d9d89047ec38d7 Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Wed, 8 Oct 2025 10:59:49 -0700 Subject: [PATCH 09/16] Updated comments in GIDEMMSupport.m --- GoogleSignIn/Sources/GIDEMMSupport.m | 8 -------- 1 file changed, 8 deletions(-) diff --git a/GoogleSignIn/Sources/GIDEMMSupport.m b/GoogleSignIn/Sources/GIDEMMSupport.m index c1c57a8d..a09a48ce 100644 --- a/GoogleSignIn/Sources/GIDEMMSupport.m +++ b/GoogleSignIn/Sources/GIDEMMSupport.m @@ -145,13 +145,10 @@ - (void)updateErrorForAuthSession:(GTMAuthSession *)authSession [NSMutableDictionary dictionaryWithCapacity:originalDictionary.count]; [originalDictionary enumerateKeysAndObjectsUsingBlock:^(NSString *key, id value, BOOL *stop) { - // Case 1: The value is already of `NSString` type. if ([value isKindOfClass:[NSString class]]) { stringifiedDictionary[key] = value; return; } - - // Case 2: The value is of `NSNumber` type (which includes `BOOL` type). if ([value isKindOfClass:[NSNumber class]]) { if (CFGetTypeID((__bridge CFTypeRef)value) == CFBooleanGetTypeID()) { stringifiedDictionary[key] = [value boolValue] ? @"true" : @"false"; @@ -160,11 +157,6 @@ - (void)updateErrorForAuthSession:(GTMAuthSession *)authSession } return; } - - // NOTE: Nested container objects (e.g., NSArray, NSDictionary) are intentionally ignored. - // The underlying AppAuth API expects a flat `NSDictionary` and is - // not designed for serialized, nested objects. A proper fix for nested objects - // would require a larger refactoring of the AppAuth and GTMAppAuth libraries. }]; return stringifiedDictionary; } From a1a3a9237915a0a3fa4498fb3e6ed6a2c09dade8 Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Thu, 9 Oct 2025 08:52:15 -0700 Subject: [PATCH 10/16] Updated tests. --- GoogleSignIn/Sources/GIDEMMSupport.m | 17 +--- GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m | 95 +++++++++++++-------- 2 files changed, 63 insertions(+), 49 deletions(-) diff --git a/GoogleSignIn/Sources/GIDEMMSupport.m b/GoogleSignIn/Sources/GIDEMMSupport.m index a09a48ce..b54e7907 100644 --- a/GoogleSignIn/Sources/GIDEMMSupport.m +++ b/GoogleSignIn/Sources/GIDEMMSupport.m @@ -58,13 +58,6 @@ typedef NS_ENUM(NSInteger, ErrorCode) { ErrorCodeAppVerificationRequired, }; -@interface GIDEMMSupport () - -+ (NSDictionary *) - dictionaryWithStringValuesFromDictionary:(NSDictionary *)originalDictionary; - -@end - @implementation GIDEMMSupport - (instancetype)init { @@ -116,17 +109,15 @@ + (NSDictionary *)parametersWithParameters:(NSDictionary *)parameters if (isPasscodeInfoRequired) { allParameters[kEMMPasscodeInfoParameterName] = [GIDMDMPasscodeState passcodeState].info; } - return allParameters; + return [GIDEMMSupport dictionaryWithStringValuesFromDictionary:allParameters]; } #pragma mark - GTMAuthSessionDelegate - (nullable NSDictionary *) - additionalTokenRefreshParametersForAuthSession:(GTMAuthSession *)authSession { - NSDictionary *additionalParameters = authSession.authState.lastTokenResponse.additionalParameters; - NSDictionary *updatedAdditionalParameters = - [GIDEMMSupport updatedEMMParametersWithParameters:additionalParameters]; - return [GIDEMMSupport dictionaryWithStringValuesFromDictionary:updatedAdditionalParameters]; +additionalTokenRefreshParametersForAuthSession:(GTMAuthSession *)authSession { + return [GIDEMMSupport updatedEMMParametersWithParameters: + authSession.authState.lastTokenResponse.additionalParameters]; } - (void)updateErrorForAuthSession:(GTMAuthSession *)authSession diff --git a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m index a7edbabb..ea405f3b 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m @@ -281,76 +281,89 @@ - (void)testHandleTokenFetchEMMError_errorIsNotEMM { # pragma mark - String Conversion Tests -- (void)testStringConversion_withAnyNumber_isConvertedToString { - NSDictionary *inputDictionary = @{ @"number_key": @12345 }; +- (void)testParametersWithParameters_withAnyNumber_isConvertedToString { + NSDictionary *inputParameters = @{ @"number_key": @12345 }; - NSDictionary *resultDictionary = [GIDEMMSupport - dictionaryWithStringValuesFromDictionary:inputDictionary]; + NSDictionary *stringifiedParameters = [GIDEMMSupport parametersWithParameters:inputParameters + emmSupport:@"1" + isPasscodeInfoRequired:NO]; - XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]], + XCTAssertTrue([stringifiedParameters[@"number_key"] isKindOfClass:[NSString class]], @"The value should be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"number_key"], @"12345", + XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"12345", @"The NSNumber should be converted to a string."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } -- (void)testStringConversion_withNumberOne_isConvertedToString { - NSDictionary *inputDictionary = @{ @"number_key": @1 }; - NSDictionary *resultDictionary = [GIDEMMSupport - dictionaryWithStringValuesFromDictionary:inputDictionary]; +- (void)testParametersWithParameters_withNumberOne_isConvertedToString { + NSDictionary *inputParameters = @{ @"number_key": @1 }; - XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]], + NSDictionary *stringifiedParameters = [GIDEMMSupport parametersWithParameters:inputParameters + emmSupport:@"1" + isPasscodeInfoRequired:NO]; + + XCTAssertTrue([stringifiedParameters[@"number_key"] isKindOfClass:[NSString class]], @"The value should be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"number_key"], @"1", + XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"1", @"The NSNumber should be converted to a string."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } -- (void)testStringConversion_withNumberZero_isConvertedToString { - NSDictionary *inputDictionary = @{ @"number_key": @0}; +- (void)testParametersWithParameters_withNumberZero_isConvertedToString { + NSDictionary *inputParameters = @{ @"number_key": @0}; - NSDictionary *resultDictionary = [GIDEMMSupport - dictionaryWithStringValuesFromDictionary:inputDictionary]; + NSDictionary *stringifiedParameters = [GIDEMMSupport parametersWithParameters:inputParameters + emmSupport:@"1" + isPasscodeInfoRequired:NO]; - XCTAssertTrue([resultDictionary[@"number_key"] isKindOfClass:[NSString class]], + XCTAssertTrue([stringifiedParameters[@"number_key"] isKindOfClass:[NSString class]], @"The value should be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"number_key"], @"0", + XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"0", @"The NSNumber should be converted to a string."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } -- (void)testStringConversion_withBooleanYes_isConvertedToTrueString { - NSDictionary *inputDictionary = @{ @"bool_key": @YES }; +- (void)testParametersWithParameters_withBooleanYes_isConvertedToTrueString { + NSDictionary *inputParameters = @{ @"bool_key": @YES }; - NSDictionary *resultDictionary = [GIDEMMSupport - dictionaryWithStringValuesFromDictionary:inputDictionary]; + NSDictionary *stringifiedParameters = [GIDEMMSupport parametersWithParameters:inputParameters + emmSupport:@"1" + isPasscodeInfoRequired:NO]; - XCTAssertTrue([resultDictionary[@"bool_key"] isKindOfClass:[NSString class]], + XCTAssertTrue([stringifiedParameters[@"bool_key"] isKindOfClass:[NSString class]], @"The value should be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"bool_key"], @"true", + XCTAssertEqualObjects(stringifiedParameters[@"bool_key"], @"true", @"The boolean YES should be converted to the string 'true'."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } -- (void)testStringConversion_withBooleanNo_isConvertedToFalseString { - NSDictionary *inputDictionary = @{ @"bool_key": @NO }; +- (void)testParametersWithParameters_withBooleanNo_isConvertedToFalseString { + NSDictionary *inputParameters = @{ @"bool_key": @NO }; - NSDictionary *resultDictionary = [GIDEMMSupport - dictionaryWithStringValuesFromDictionary:inputDictionary]; + NSDictionary *stringifiedParameters = [GIDEMMSupport parametersWithParameters:inputParameters + emmSupport:@"1" + isPasscodeInfoRequired:NO]; - XCTAssertTrue([resultDictionary[@"bool_key"] isKindOfClass:[NSString class]], + XCTAssertTrue([stringifiedParameters[@"bool_key"] isKindOfClass:[NSString class]], @"The value should be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"bool_key"], @"false", + XCTAssertEqualObjects(stringifiedParameters[@"bool_key"], @"false", @"The boolean NO should be converted to the string 'false'."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } -- (void)testStringConversion_withString_remainsUnchanged { - NSDictionary *inputDictionary = @{ @"string_key": @"hello" }; +- (void)testParametersWithParameters_withString_remainsUnchanged { + NSDictionary *inputParameters = @{ @"string_key": @"hello" }; - NSDictionary *resultDictionary = [GIDEMMSupport - dictionaryWithStringValuesFromDictionary:inputDictionary]; + NSDictionary *stringifiedParameters = [GIDEMMSupport parametersWithParameters:inputParameters + emmSupport:@"1" + isPasscodeInfoRequired:NO]; - XCTAssertTrue([resultDictionary[@"string_key"] isKindOfClass:[NSString class]], + XCTAssertTrue([stringifiedParameters[@"string_key"] isKindOfClass:[NSString class]], @"The value should still be an NSString."); - XCTAssertEqualObjects(resultDictionary[@"string_key"], @"hello", + XCTAssertEqualObjects(stringifiedParameters[@"string_key"], @"hello", @"The original string value should be preserved."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } # pragma mark - Helpers @@ -381,6 +394,16 @@ - (void)unswizzle { self.presentedViewController = nil; } +- (void)assertAllKeysAndValuesAreStringsInDictionary:(NSDictionary *)dictionary { + [dictionary enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + XCTAssertTrue([key isKindOfClass:[NSString class]], + @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); + XCTAssertTrue([obj isKindOfClass:[NSString class]], + @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", + [obj class], key); + }]; +} + @end NS_ASSUME_NONNULL_END From 3cfc450bf316632c9bc0a5d6ed1eb9a7e9854130 Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Thu, 9 Oct 2025 08:55:34 -0700 Subject: [PATCH 11/16] Updated tests. --- GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m | 5 ----- 1 file changed, 5 deletions(-) diff --git a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m index ea405f3b..f5b97362 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m @@ -59,11 +59,6 @@ static NSString *const kDeviceOSKey = @"device_os"; static NSString *const kEMMPasscodeInfoKey = @"emm_passcode_info"; -@interface GIDEMMSupport (Private) -+ (NSDictionary *) - dictionaryWithStringValuesFromDictionary:(NSDictionary *)originalDictionary; -@end - @interface GIDEMMSupportTest : XCTestCase // The view controller that has been presented, if any. @property(nonatomic, strong, nullable) UIViewController *presentedViewController; From 0d9df859426e3021bf98271978cba6b791774d00 Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:04:31 -0700 Subject: [PATCH 12/16] Updated tests. --- GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m | 24 ++++++--------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m index f5b97362..9925178b 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m @@ -283,11 +283,9 @@ - (void)testParametersWithParameters_withAnyNumber_isConvertedToString { emmSupport:@"1" isPasscodeInfoRequired:NO]; - XCTAssertTrue([stringifiedParameters[@"number_key"] isKindOfClass:[NSString class]], - @"The value should be an NSString."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"12345", @"The NSNumber should be converted to a string."); - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } @@ -298,11 +296,9 @@ - (void)testParametersWithParameters_withNumberOne_isConvertedToString { emmSupport:@"1" isPasscodeInfoRequired:NO]; - XCTAssertTrue([stringifiedParameters[@"number_key"] isKindOfClass:[NSString class]], - @"The value should be an NSString."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"1", @"The NSNumber should be converted to a string."); - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } - (void)testParametersWithParameters_withNumberZero_isConvertedToString { @@ -312,11 +308,9 @@ - (void)testParametersWithParameters_withNumberZero_isConvertedToString { emmSupport:@"1" isPasscodeInfoRequired:NO]; - XCTAssertTrue([stringifiedParameters[@"number_key"] isKindOfClass:[NSString class]], - @"The value should be an NSString."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"0", @"The NSNumber should be converted to a string."); - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } - (void)testParametersWithParameters_withBooleanYes_isConvertedToTrueString { @@ -326,11 +320,9 @@ - (void)testParametersWithParameters_withBooleanYes_isConvertedToTrueString { emmSupport:@"1" isPasscodeInfoRequired:NO]; - XCTAssertTrue([stringifiedParameters[@"bool_key"] isKindOfClass:[NSString class]], - @"The value should be an NSString."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"bool_key"], @"true", @"The boolean YES should be converted to the string 'true'."); - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } - (void)testParametersWithParameters_withBooleanNo_isConvertedToFalseString { @@ -340,11 +332,9 @@ - (void)testParametersWithParameters_withBooleanNo_isConvertedToFalseString { emmSupport:@"1" isPasscodeInfoRequired:NO]; - XCTAssertTrue([stringifiedParameters[@"bool_key"] isKindOfClass:[NSString class]], - @"The value should be an NSString."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"bool_key"], @"false", @"The boolean NO should be converted to the string 'false'."); - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } - (void)testParametersWithParameters_withString_remainsUnchanged { @@ -354,11 +344,9 @@ - (void)testParametersWithParameters_withString_remainsUnchanged { emmSupport:@"1" isPasscodeInfoRequired:NO]; - XCTAssertTrue([stringifiedParameters[@"string_key"] isKindOfClass:[NSString class]], - @"The value should still be an NSString."); + [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"string_key"], @"hello", @"The original string value should be preserved."); - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; } # pragma mark - Helpers From df1217be31a522b5aad51fd4c6126f016798851e Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Thu, 9 Oct 2025 09:48:16 -0700 Subject: [PATCH 13/16] Updated tests. --- GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m | 1 - 1 file changed, 1 deletion(-) diff --git a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m index 9925178b..fd48c70f 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m @@ -288,7 +288,6 @@ - (void)testParametersWithParameters_withAnyNumber_isConvertedToString { @"The NSNumber should be converted to a string."); } - - (void)testParametersWithParameters_withNumberOne_isConvertedToString { NSDictionary *inputParameters = @{ @"number_key": @1 }; From e42b30dc211b40225519ce9636de3e80e7fad880 Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Thu, 9 Oct 2025 11:45:58 -0700 Subject: [PATCH 14/16] Updated method return type in GIDEMMSupport --- GoogleSignIn/Sources/GIDEMMSupport.h | 9 +++++---- GoogleSignIn/Sources/GIDEMMSupport.m | 10 +++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/GoogleSignIn/Sources/GIDEMMSupport.h b/GoogleSignIn/Sources/GIDEMMSupport.h index f57a6af7..61f445a4 100644 --- a/GoogleSignIn/Sources/GIDEMMSupport.h +++ b/GoogleSignIn/Sources/GIDEMMSupport.h @@ -34,12 +34,13 @@ NS_ASSUME_NONNULL_BEGIN completion:(void (^)(NSError *_Nullable))completion; /// Gets a new set of URL parameters that contains updated EMM-related URL parameters if needed. -+ (NSDictionary *)updatedEMMParametersWithParameters:(NSDictionary *)parameters; ++ (NSDictionary *)updatedEMMParametersWithParameters: + (NSDictionary *)parameters; /// Gets a new set of URL parameters that also contains EMM-related URL parameters if needed. -+ (NSDictionary *)parametersWithParameters:(NSDictionary *)parameters - emmSupport:(nullable NSString *)emmSupport - isPasscodeInfoRequired:(BOOL)isPasscodeInfoRequired; ++ (NSDictionary *)parametersWithParameters:(NSDictionary *)parameters + emmSupport:(nullable NSString *)emmSupport + isPasscodeInfoRequired:(BOOL)isPasscodeInfoRequired; @end diff --git a/GoogleSignIn/Sources/GIDEMMSupport.m b/GoogleSignIn/Sources/GIDEMMSupport.m index b54e7907..0914d841 100644 --- a/GoogleSignIn/Sources/GIDEMMSupport.m +++ b/GoogleSignIn/Sources/GIDEMMSupport.m @@ -84,16 +84,16 @@ + (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 - emmSupport:(nullable NSString *)emmSupport - isPasscodeInfoRequired:(BOOL)isPasscodeInfoRequired { ++ (NSDictionary *)parametersWithParameters:(NSDictionary *)parameters + emmSupport:(nullable NSString *)emmSupport + isPasscodeInfoRequired:(BOOL)isPasscodeInfoRequired { if (!emmSupport) { return parameters; } From 910896402b19709850ca33c4fc0721f98ad50ff6 Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:12:25 -0700 Subject: [PATCH 15/16] Updated tests in GIDEMMSupportTest --- GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m | 58 +++++++++++++++------ 1 file changed, 42 insertions(+), 16 deletions(-) diff --git a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m index fd48c70f..08eb150f 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m @@ -283,9 +283,15 @@ - (void)testParametersWithParameters_withAnyNumber_isConvertedToString { emmSupport:@"1" isPasscodeInfoRequired:NO]; - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"12345", @"The NSNumber should be converted to a string."); + [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + XCTAssertTrue([key isKindOfClass:[NSString class]], + @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); + XCTAssertTrue([obj isKindOfClass:[NSString class]], + @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", + [obj class], key); + }]; } - (void)testParametersWithParameters_withNumberOne_isConvertedToString { @@ -295,9 +301,15 @@ - (void)testParametersWithParameters_withNumberOne_isConvertedToString { emmSupport:@"1" isPasscodeInfoRequired:NO]; - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"1", @"The NSNumber should be converted to a string."); + [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + XCTAssertTrue([key isKindOfClass:[NSString class]], + @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); + XCTAssertTrue([obj isKindOfClass:[NSString class]], + @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", + [obj class], key); + }]; } - (void)testParametersWithParameters_withNumberZero_isConvertedToString { @@ -307,9 +319,15 @@ - (void)testParametersWithParameters_withNumberZero_isConvertedToString { emmSupport:@"1" isPasscodeInfoRequired:NO]; - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"0", @"The NSNumber should be converted to a string."); + [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + XCTAssertTrue([key isKindOfClass:[NSString class]], + @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); + XCTAssertTrue([obj isKindOfClass:[NSString class]], + @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", + [obj class], key); + }]; } - (void)testParametersWithParameters_withBooleanYes_isConvertedToTrueString { @@ -319,9 +337,15 @@ - (void)testParametersWithParameters_withBooleanYes_isConvertedToTrueString { emmSupport:@"1" isPasscodeInfoRequired:NO]; - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"bool_key"], @"true", @"The boolean YES should be converted to the string 'true'."); + [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + XCTAssertTrue([key isKindOfClass:[NSString class]], + @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); + XCTAssertTrue([obj isKindOfClass:[NSString class]], + @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", + [obj class], key); + }]; } - (void)testParametersWithParameters_withBooleanNo_isConvertedToFalseString { @@ -331,9 +355,15 @@ - (void)testParametersWithParameters_withBooleanNo_isConvertedToFalseString { emmSupport:@"1" isPasscodeInfoRequired:NO]; - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"bool_key"], @"false", @"The boolean NO should be converted to the string 'false'."); + [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + XCTAssertTrue([key isKindOfClass:[NSString class]], + @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); + XCTAssertTrue([obj isKindOfClass:[NSString class]], + @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", + [obj class], key); + }]; } - (void)testParametersWithParameters_withString_remainsUnchanged { @@ -343,9 +373,15 @@ - (void)testParametersWithParameters_withString_remainsUnchanged { emmSupport:@"1" isPasscodeInfoRequired:NO]; - [self assertAllKeysAndValuesAreStringsInDictionary:stringifiedParameters]; XCTAssertEqualObjects(stringifiedParameters[@"string_key"], @"hello", @"The original string value should be preserved."); + [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { + XCTAssertTrue([key isKindOfClass:[NSString class]], + @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); + XCTAssertTrue([obj isKindOfClass:[NSString class]], + @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", + [obj class], key); + }]; } # pragma mark - Helpers @@ -376,16 +412,6 @@ - (void)unswizzle { self.presentedViewController = nil; } -- (void)assertAllKeysAndValuesAreStringsInDictionary:(NSDictionary *)dictionary { - [dictionary enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - XCTAssertTrue([key isKindOfClass:[NSString class]], - @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); - XCTAssertTrue([obj isKindOfClass:[NSString class]], - @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", - [obj class], key); - }]; -} - @end NS_ASSUME_NONNULL_END From cd45936f83d6123ea4a369124944a31bb0d55593 Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Thu, 16 Oct 2025 11:35:12 -0700 Subject: [PATCH 16/16] Updated tests in GIDEMMSupportTest --- GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m | 54 +++++---------------- 1 file changed, 12 insertions(+), 42 deletions(-) diff --git a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m index 08eb150f..9aece13b 100644 --- a/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m +++ b/GoogleSignIn/Tests/Unit/GIDEMMSupportTest.m @@ -285,13 +285,8 @@ - (void)testParametersWithParameters_withAnyNumber_isConvertedToString { XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"12345", @"The NSNumber should be converted to a string."); - [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - XCTAssertTrue([key isKindOfClass:[NSString class]], - @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); - XCTAssertTrue([obj isKindOfClass:[NSString class]], - @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", - [obj class], key); - }]; + XCTAssertTrue([stringifiedParameters[@"number_key"] isKindOfClass:[NSString class]], + @"The final value should be of a NSString type."); } - (void)testParametersWithParameters_withNumberOne_isConvertedToString { @@ -303,13 +298,8 @@ - (void)testParametersWithParameters_withNumberOne_isConvertedToString { XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"1", @"The NSNumber should be converted to a string."); - [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - XCTAssertTrue([key isKindOfClass:[NSString class]], - @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); - XCTAssertTrue([obj isKindOfClass:[NSString class]], - @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", - [obj class], key); - }]; + XCTAssertTrue([stringifiedParameters[@"number_key"] isKindOfClass:[NSString class]], + @"The final value should be of a NSString type."); } - (void)testParametersWithParameters_withNumberZero_isConvertedToString { @@ -321,13 +311,8 @@ - (void)testParametersWithParameters_withNumberZero_isConvertedToString { XCTAssertEqualObjects(stringifiedParameters[@"number_key"], @"0", @"The NSNumber should be converted to a string."); - [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - XCTAssertTrue([key isKindOfClass:[NSString class]], - @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); - XCTAssertTrue([obj isKindOfClass:[NSString class]], - @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", - [obj class], key); - }]; + XCTAssertTrue([stringifiedParameters[@"number_key"] isKindOfClass:[NSString class]], + @"The final value should be of a NSString type."); } - (void)testParametersWithParameters_withBooleanYes_isConvertedToTrueString { @@ -339,13 +324,8 @@ - (void)testParametersWithParameters_withBooleanYes_isConvertedToTrueString { XCTAssertEqualObjects(stringifiedParameters[@"bool_key"], @"true", @"The boolean YES should be converted to the string 'true'."); - [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - XCTAssertTrue([key isKindOfClass:[NSString class]], - @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); - XCTAssertTrue([obj isKindOfClass:[NSString class]], - @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", - [obj class], key); - }]; + XCTAssertTrue([stringifiedParameters[@"bool_key"] isKindOfClass:[NSString class]], + @"The final value should be of a NSString type."); } - (void)testParametersWithParameters_withBooleanNo_isConvertedToFalseString { @@ -357,13 +337,8 @@ - (void)testParametersWithParameters_withBooleanNo_isConvertedToFalseString { XCTAssertEqualObjects(stringifiedParameters[@"bool_key"], @"false", @"The boolean NO should be converted to the string 'false'."); - [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - XCTAssertTrue([key isKindOfClass:[NSString class]], - @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); - XCTAssertTrue([obj isKindOfClass:[NSString class]], - @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", - [obj class], key); - }]; + XCTAssertTrue([stringifiedParameters[@"bool_key"] isKindOfClass:[NSString class]], + @"The final value should be of a NSString type."); } - (void)testParametersWithParameters_withString_remainsUnchanged { @@ -375,13 +350,8 @@ - (void)testParametersWithParameters_withString_remainsUnchanged { XCTAssertEqualObjects(stringifiedParameters[@"string_key"], @"hello", @"The original string value should be preserved."); - [stringifiedParameters enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) { - XCTAssertTrue([key isKindOfClass:[NSString class]], - @"All keys must be NSStrings. Found a key of type '%@'.", [key class]); - XCTAssertTrue([obj isKindOfClass:[NSString class]], - @"All values must be NSStrings. Found a value of type '%@' for key '%@'.", - [obj class], key); - }]; + XCTAssertTrue([stringifiedParameters[@"string_key"] isKindOfClass:[NSString class]], + @"The final value should be of a NSString type."); } # pragma mark - Helpers