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