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/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/GIDSignIn.m b/GoogleSignIn/Sources/GIDSignIn.m index 1c043735..ff4fcdae 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]; } @@ -313,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]; @@ -375,14 +437,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]; } @@ -397,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]; @@ -542,6 +660,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 +755,24 @@ - (void)signInWithOptions:(GIDSignInInternalOptions *)options { } }]; } else { + // 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; + } + } [self authenticateWithOptions:options]; } } @@ -765,6 +902,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/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/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..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 @@ -45,10 +46,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. @@ -221,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. @@ -294,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/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/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 { 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..55f239d0 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]; @@ -637,6 +644,7 @@ - (void)testOAuthLogin { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -654,6 +662,7 @@ - (void)testOAuthLogin_RestoredSignIn { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:YES oldAccessToken:NO @@ -671,6 +680,7 @@ - (void)testOAuthLogin_RestoredSignInOldAccessToken { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:YES oldAccessToken:YES @@ -690,13 +700,16 @@ - (void)testOAuthLogin_AdditionalScopes { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired: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); @@ -705,13 +718,16 @@ - (void)testOAuthLogin_AdditionalScopes { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired: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); @@ -720,18 +736,106 @@ - (void)testOAuthLogin_AdditionalScopes { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired: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 + tokenClaimsAsJSONRequired: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 + tokenClaimsAsJSONRequired: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 + 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, @"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( @@ -743,6 +847,7 @@ - (void)testAddScopes { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:YES oldAccessToken:NO @@ -752,7 +857,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 @@ -761,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 @@ -784,7 +891,7 @@ - (void)testAddScopes { [parsedScopes removeObject:@""]; grantedScopes = [parsedScopes copy]; } - + NSArray *expectedScopes = @[kNewScope, kGrantedScope]; XCTAssertEqualObjects(grantedScopes, expectedScopes); @@ -793,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 @@ -809,6 +986,7 @@ - (void)testOpenIDRealm { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -831,18 +1009,21 @@ - (void)testManualNonce { }); NSString* manualNonce = @"manual_nonce"; - + [self OAuthLoginWithAddScopesFlow:NO authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO + tokenClaimsError:NO restoredSignIn:NO oldAccessToken:NO modalCancel:NO useAdditionalScopes:NO additionalScopes:@[] - manualNonce:manualNonce]; + manualNonce:manualNonce + tokenClaims:nil]; XCTAssertEqualObjects(_savedAuthorizationRequest.nonce, manualNonce, @@ -862,6 +1043,7 @@ - (void)testOAuthLogin_LoginHint { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -887,6 +1069,7 @@ - (void)testOAuthLogin_HostedDomain { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -901,6 +1084,7 @@ - (void)testOAuthLogin_ConsentCanceled { authError:@"access_denied" tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -915,6 +1099,7 @@ - (void)testOAuthLogin_ModalCanceled { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -939,6 +1124,7 @@ - (void)testOAuthLogin_KeychainError { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:YES restoredSignIn:NO oldAccessToken:NO @@ -950,6 +1136,37 @@ - (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 + tokenClaimsAsJSONRequired: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); @@ -966,6 +1183,7 @@ - (void)testSignOut { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:YES oldAccessToken:NO @@ -1212,6 +1430,7 @@ - (void)testEmmSupportRequestParameters { authError:nil tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -1263,6 +1482,7 @@ - (void)testEmmPasscodeInfo { authError:nil tokenError:nil emmPasscodeInfoRequired:YES + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -1294,6 +1514,7 @@ - (void)testAuthEndpointEMMError { authError:callbackParams[@"error"] tokenError:nil emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -1331,6 +1552,7 @@ - (void)testTokenEndpointEMMError { authError:nil tokenError:emmError emmPasscodeInfoRequired:NO + tokenClaimsAsJSONRequired:NO keychainError:NO restoredSignIn:NO oldAccessToken:NO @@ -1339,7 +1561,7 @@ - (void)testTokenEndpointEMMError { NSError *handledError = [NSError errorWithDomain:kGIDSignInErrorDomain code:kGIDSignInErrorCodeEMM userInfo:emmError.userInfo]; - + completion(handledError); [self waitForExpectationsWithTimeout:1 handler:nil]; @@ -1415,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 @@ -1423,13 +1646,16 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow authError:authError tokenError:tokenError emmPasscodeInfoRequired:emmPasscodeInfoRequired + tokenClaimsAsJSONRequired:tokenClaimsAsJSONRequired 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. @@ -1437,13 +1663,16 @@ - (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 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]; @@ -1451,20 +1680,29 @@ - (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 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 @@ -1507,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 @@ -1533,10 +1772,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 +1877,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 +1899,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 +1938,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]); @@ -1700,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/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 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 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]; } 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]; 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)