From 234580efcc08d80b1fcb26d64da03fe5868b7be3 Mon Sep 17 00:00:00 2001 From: AkshatGandhi <54901287+AkshatG6@users.noreply.github.com> Date: Fri, 16 Jan 2026 09:12:10 -0800 Subject: [PATCH 1/4] AMR claim implementation + unit tests (#573) --- GoogleSignIn/Sources/GIDClaim.m | 11 ++++++++++- GoogleSignIn/Sources/Public/GoogleSignIn/GIDClaim.h | 7 +++++++ GoogleSignIn/Tests/Unit/GIDClaimTest.m | 12 ++++++++++++ 3 files changed, 29 insertions(+), 1 deletion(-) diff --git a/GoogleSignIn/Sources/GIDClaim.m b/GoogleSignIn/Sources/GIDClaim.m index 13792cae..2179ec91 100644 --- a/GoogleSignIn/Sources/GIDClaim.m +++ b/GoogleSignIn/Sources/GIDClaim.m @@ -16,7 +16,8 @@ #import "GoogleSignIn/Sources/Public/GoogleSignIn/GIDClaim.h" -NSString * const kAuthTimeClaimName = @"auth_time"; +NSString *const kAuthTimeClaimName = @"auth_time"; +NSString *const kAMRClaimName = @"amr"; // Private interface to declare the internal initializer @interface GIDClaim () @@ -48,6 +49,14 @@ + (instancetype)essentialAuthTimeClaim { return [[self alloc] initWithName:kAuthTimeClaimName essential:YES]; } ++ (instancetype)AMRClaim { + return [[self alloc] initWithName:kAMRClaimName essential:NO]; +} + ++ (instancetype)essentialAMRClaim { + return [[self alloc] initWithName:kAMRClaimName essential:YES]; +} + #pragma mark - NSObject - (BOOL)isEqual:(id)object { diff --git a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDClaim.h b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDClaim.h index bc266930..8ff96a95 100644 --- a/GoogleSignIn/Sources/Public/GoogleSignIn/GIDClaim.h +++ b/GoogleSignIn/Sources/Public/GoogleSignIn/GIDClaim.h @@ -19,6 +19,7 @@ NS_ASSUME_NONNULL_BEGIN extern NSString *const kAuthTimeClaimName; +extern NSString *const kAMRClaimName; /** * An object representing a single OIDC claim to be requested for an ID token. @@ -42,6 +43,12 @@ extern NSString *const kAuthTimeClaimName; /// Creates an *essential* "auth_time" claim object. + (instancetype)essentialAuthTimeClaim; +/// Creates a *non-essential* (voluntary) "amr" claim object. ++ (instancetype)AMRClaim; + +/// Creates an *essential* "amr" claim object. ++ (instancetype)essentialAMRClaim; + @end NS_ASSUME_NONNULL_END diff --git a/GoogleSignIn/Tests/Unit/GIDClaimTest.m b/GoogleSignIn/Tests/Unit/GIDClaimTest.m index e014d885..bb955e8c 100644 --- a/GoogleSignIn/Tests/Unit/GIDClaimTest.m +++ b/GoogleSignIn/Tests/Unit/GIDClaimTest.m @@ -33,6 +33,18 @@ - (void)testEssentialAuthTimeClaim_PropertiesAreCorrect { XCTAssertTrue(claim.isEssential); } +- (void)testAMRClaim_PropertiesAreCorrect { + GIDClaim *claim = [GIDClaim AMRClaim]; + XCTAssertEqualObjects(claim.name, kAMRClaimName); + XCTAssertFalse(claim.isEssential); +} + +- (void)testEssentialAMRClaim_PropertiesAreCorrect { + GIDClaim *claim = [GIDClaim essentialAMRClaim]; + XCTAssertEqualObjects(claim.name, kAMRClaimName); + XCTAssertTrue(claim.isEssential); +} + - (void)testEquality_WithEqualClaims { GIDClaim *claim1 = [GIDClaim authTimeClaim]; GIDClaim *claim2 = [GIDClaim authTimeClaim]; From bd48ccb43ce85a908b15e927d60e99814cf9fd6f Mon Sep 17 00:00:00 2001 From: AkshatGandhi <54901287+AkshatG6@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:05:44 -0800 Subject: [PATCH 2/4] Add unit tests for AMR claim (#575) --- GoogleSignIn/Tests/Unit/GIDSignInTest.m | 114 ++++++++++++++++-- .../Tests/Unit/OIDTokenResponse+Testing.h | 13 +- .../Tests/Unit/OIDTokenResponse+Testing.m | 30 ++--- 3 files changed, 116 insertions(+), 41 deletions(-) diff --git a/GoogleSignIn/Tests/Unit/GIDSignInTest.m b/GoogleSignIn/Tests/Unit/GIDSignInTest.m index 13b4435e..ac245739 100644 --- a/GoogleSignIn/Tests/Unit/GIDSignInTest.m +++ b/GoogleSignIn/Tests/Unit/GIDSignInTest.m @@ -160,11 +160,18 @@ static NSString *const kGrantedScope = @"grantedScope"; static NSString *const kNewScope = @"newScope"; -static NSString *const kEssentialAuthTimeClaimsJsonString = +static NSString *const kEssentialAuthTimeClaimJsonString = @"{\"id_token\":{\"auth_time\":{\"essential\":true}}}"; -static NSString *const kNonEssentialAuthTimeClaimsJsonString = +static NSString *const kNonEssentialAuthTimeClaimJsonString = @"{\"id_token\":{\"auth_time\":{\"essential\":false}}}"; +static NSString *const kEssentialAMRClaimJsonString = + @"{\"id_token\":{\"amr\":{\"essential\":true}}}"; +static NSString *const kNonEssentialAMRClaimJsonString = + @"{\"id_token\":{\"amr\":{\"essential\":false}}}"; + +static NSString *const kMultipleClaimsJsonString = + @"{\"id_token\":{\"amr\":{\"essential\":false},\"auth_time\":{\"essential\":false}}}"; #if TARGET_OS_IOS || TARGET_OS_MACCATALYST // This category is used to allow the test to swizzle a private method. @@ -751,7 +758,7 @@ - (void)testOAuthLogin_AdditionalScopes { XCTAssertEqualObjects(_savedAuthorizationRequest.scope, expectedScopeString); } -- (void)testOAuthLogin_WithClaims_FormatsParametersCorrectly { +- (void)testOAuthLogin_WithAuthTimeClaim_FormatsParametersCorrectly { GIDClaim *authTimeClaim = [GIDClaim authTimeClaim]; GIDClaim *essentialAuthTimeClaim = [GIDClaim essentialAuthTimeClaim]; @@ -776,7 +783,7 @@ - (void)testOAuthLogin_WithClaims_FormatsParametersCorrectly { claims:[NSSet setWithObject:essentialAuthTimeClaim]]; XCTAssertEqualObjects(_savedAuthorizationRequest.additionalParameters[@"claims"], - kEssentialAuthTimeClaimsJsonString, + kEssentialAuthTimeClaimJsonString, @"Claims JSON should be correctly formatted"); [self OAuthLoginWithAddScopesFlow:NO @@ -795,12 +802,62 @@ - (void)testOAuthLogin_WithClaims_FormatsParametersCorrectly { claims:[NSSet setWithObject:authTimeClaim]]; XCTAssertEqualObjects(_savedAuthorizationRequest.additionalParameters[@"claims"], - kNonEssentialAuthTimeClaimsJsonString, + kNonEssentialAuthTimeClaimJsonString, + @"Claims JSON should be correctly formatted"); +} + +- (void)testOAuthLogin_WithAMRClaim_FormatsParametersCorrectly { + GIDClaim *AMRClaim = [GIDClaim AMRClaim]; + GIDClaim *essentialAMRClaim = [GIDClaim essentialAMRClaim]; + + OCMStub([_keychainStore saveAuthSession:OCMOCK_ANY error:OCMArg.anyObjectRef] + ).andDo(^(NSInvocation *invocation){ + self->_keychainSaved = self->_saveAuthorizationReturnValue; + }); + + [self OAuthLoginWithAddScopesFlow:NO + authError:nil + tokenError:nil + emmPasscodeInfoRequired:NO + claimsAsJSONRequired:NO + keychainError:NO + claimsError:NO + restoredSignIn:NO + oldAccessToken:NO + modalCancel:NO + useAdditionalScopes:NO + additionalScopes:nil + manualNonce:nil + claims:[NSSet setWithObject:essentialAMRClaim]]; + + XCTAssertEqualObjects(_savedAuthorizationRequest.additionalParameters[@"claims"], + kEssentialAMRClaimJsonString, + @"Claims JSON should be correctly formatted"); + + [self OAuthLoginWithAddScopesFlow:NO + authError:nil + tokenError:nil + emmPasscodeInfoRequired:NO + claimsAsJSONRequired:NO + keychainError:NO + claimsError:NO + restoredSignIn:NO + oldAccessToken:NO + modalCancel:NO + useAdditionalScopes:NO + additionalScopes:nil + manualNonce:nil + claims:[NSSet setWithObject:AMRClaim]]; + + XCTAssertEqualObjects(_savedAuthorizationRequest.additionalParameters[@"claims"], + kNonEssentialAMRClaimJsonString, @"Claims JSON should be correctly formatted"); } -- (void)testOAuthLogin_WithClaims_ReturnsIdTokenWithCorrectClaims { +- (void)testOAuthLogin_WithMultipleClaims_FormatsParametersCorrectly { GIDClaim *authTimeClaim = [GIDClaim authTimeClaim]; + GIDClaim *AMRClaim = [GIDClaim AMRClaim]; + NSSet *claims = [NSSet setWithArray:@[authTimeClaim, AMRClaim]]; OCMStub([_keychainStore saveAuthSession:OCMOCK_ANY error:OCMArg.anyObjectRef] ).andDo(^(NSInvocation *invocation){ @@ -820,7 +877,37 @@ - (void)testOAuthLogin_WithClaims_ReturnsIdTokenWithCorrectClaims { useAdditionalScopes:NO additionalScopes:nil manualNonce:nil - claims:[NSSet setWithObject:authTimeClaim]]; + claims:claims]; + + XCTAssertEqualObjects(_savedAuthorizationRequest.additionalParameters[@"claims"], + kMultipleClaimsJsonString, + @"Claims JSON should be correctly formatted"); +} + +- (void)testOAuthLogin_WithMultipleClaims_ReturnsIdTokenWithCorrectClaims { + GIDClaim *authTimeClaim = [GIDClaim authTimeClaim]; + GIDClaim *AMRClaim = [GIDClaim AMRClaim]; + NSSet *claims = [NSSet setWithArray:@[authTimeClaim, AMRClaim]]; + + OCMStub([_keychainStore saveAuthSession:OCMOCK_ANY error:OCMArg.anyObjectRef] + ).andDo(^(NSInvocation *invocation){ + self->_keychainSaved = self->_saveAuthorizationReturnValue; + }); + + [self OAuthLoginWithAddScopesFlow:NO + authError:nil + tokenError:nil + emmPasscodeInfoRequired:NO + claimsAsJSONRequired:NO + keychainError:NO + claimsError:NO + restoredSignIn:NO + oldAccessToken:NO + modalCancel:NO + useAdditionalScopes:NO + additionalScopes:nil + manualNonce:nil + claims:claims]; XCTAssertNotNil(_signIn.currentUser, @"The currentUser should not be nil after a successful sign-in."); NSString *idTokenString = _signIn.currentUser.idToken.tokenString; @@ -830,10 +917,13 @@ - (void)testOAuthLogin_WithClaims_ReturnsIdTokenWithCorrectClaims { NSData *payloadData = [[NSData alloc] initWithBase64EncodedString:components[1] options:NSDataBase64DecodingIgnoreUnknownCharacters]; - NSDictionary *claims = [NSJSONSerialization JSONObjectWithData:payloadData options:0 error:nil]; - XCTAssertEqualObjects(claims[@"auth_time"], + NSDictionary *receivedClaims = [NSJSONSerialization JSONObjectWithData:payloadData options:0 error:nil]; + XCTAssertEqualObjects(receivedClaims[@"auth_time"], kAuthTime, @"The 'auth_time' claim should be present and correct."); + XCTAssertEqualObjects(receivedClaims[@"amr"], + [OIDTokenResponse stubbedAMRValues], + @"The 'amr' claim should be present and correct."); } - (void)testAddScopes { @@ -963,7 +1053,7 @@ - (void)testAddScopes_WithPreviouslyRequestedClaims { NSArray *expectedScopes = @[kNewScope, kGrantedScope]; XCTAssertEqualObjects(grantedScopes, expectedScopes); XCTAssertEqualObjects(_savedAuthorizationRequest.additionalParameters[@"claims"], - kNonEssentialAuthTimeClaimsJsonString, + kNonEssentialAuthTimeClaimJsonString, @"Claims JSON should be correctly formatted"); [_user verify]; @@ -1688,7 +1778,7 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow nonce:nonce errorString:authError]; - NSString *idToken = claims ? [OIDTokenResponse fatIDTokenWithAuthTime] : [OIDTokenResponse fatIDToken]; + NSString *idToken = claims ? [OIDTokenResponse fatIDTokenWithClaims] : [OIDTokenResponse fatIDToken]; OIDTokenResponse *tokenResponse = [OIDTokenResponse testInstanceWithIDToken:idToken accessToken:restoredSignIn ? kAccessToken : nil @@ -1958,7 +2048,7 @@ - (void)OAuthLoginWithAddScopesFlow:(BOOL)addScopesFlow additionalParameters[@"emm_passcode_info_required"] = @"1"; } if (claimsAsJSONRequired) { - additionalParameters[@"claims"] = kNonEssentialAuthTimeClaimsJsonString; + additionalParameters[@"claims"] = kNonEssentialAuthTimeClaimJsonString; } return [additionalParameters copy]; diff --git a/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.h b/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.h index b8329c67..4ef08862 100644 --- a/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.h +++ b/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.h @@ -60,18 +60,13 @@ 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; ++ (NSString *)fatIDTokenWithClaims; + ++ (NSArray *)stubbedAMRValues; /** * @sub The subject of the ID token. @@ -81,6 +76,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; ++ (NSString *)idTokenWithSub:(NSString *)sub exp:(NSNumber *)exp fat:(BOOL)fat claims:(BOOL)claims; @end diff --git a/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.m b/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.m index bf2a5fa7..4dc8b0ae 100644 --- a/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.m +++ b/GoogleSignIn/Tests/Unit/OIDTokenResponse+Testing.m @@ -71,21 +71,6 @@ + (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), @@ -109,8 +94,12 @@ + (NSString *)fatIDToken { return [self idTokenWithSub:kUserID exp:@(kIDTokenExpires) fat:YES]; } -+ (NSString *)fatIDTokenWithAuthTime { - return [self idTokenWithSub:kUserID exp:@(kIDTokenExpires) fat:YES authTime:kAuthTime]; ++ (NSString *)fatIDTokenWithClaims { + return [self idTokenWithSub:kUserID exp:@(kIDTokenExpires) fat:YES claims:YES]; +} + ++ (NSArray *)stubbedAMRValues { + return @[ @"pwd", @"mfa", @"otp" ]; } + (NSString *)idTokenWithSub:(NSString *)sub exp:(NSNumber *)exp { @@ -120,13 +109,13 @@ + (NSString *)idTokenWithSub:(NSString *)sub exp:(NSNumber *)exp { + (NSString *)idTokenWithSub:(NSString *)sub exp:(NSNumber *)exp fat:(BOOL)fat { - return [self idTokenWithSub:kUserID exp:exp fat:fat authTime:nil]; + return [self idTokenWithSub:kUserID exp:exp fat:fat claims:NO]; } + (NSString *)idTokenWithSub:(NSString *)sub exp:(NSNumber *)exp fat:(BOOL)fat - authTime:(NSString *)authTime{ + claims:(BOOL)claims { NSError *error; NSDictionary *headerContents = @{ @"alg" : kAlg, @@ -156,9 +145,10 @@ + (NSString *)idTokenWithSub:(NSString *)sub kFatPictureURLKey : kFatPictureURL, }]; } - if (authTime) { + if (claims) { [payloadContents addEntriesFromDictionary:@{ @"auth_time": kAuthTime, + @"amr": [OIDTokenResponse stubbedAMRValues], }]; } NSData *payloadJson = [NSJSONSerialization dataWithJSONObject:payloadContents From 042c17533f634bfbf48c0c414d82d177e72796d4 Mon Sep 17 00:00:00 2001 From: AkshatGandhi <54901287+AkshatG6@users.noreply.github.com> Date: Wed, 8 Apr 2026 12:43:42 -0700 Subject: [PATCH 3/4] Gandhiakshat/sbc amr sample app (#597) --- .../project.pbxproj | 10 +++- .../Shared/Models/Claim.swift | 24 ++++++++ .../Services/GoogleSignInAuthenticator.swift | 2 +- .../ViewModels/AuthenticationViewModel.swift | 58 +++++++++++++------ .../iOS/UserProfileView.swift | 32 +++++++++- 5 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 Samples/Swift/DaysUntilBirthday/Shared/Models/Claim.swift diff --git a/Samples/Swift/DaysUntilBirthday/DaysUntilBirthday.xcodeproj/project.pbxproj b/Samples/Swift/DaysUntilBirthday/DaysUntilBirthday.xcodeproj/project.pbxproj index 4c12414e..7d947429 100644 --- a/Samples/Swift/DaysUntilBirthday/DaysUntilBirthday.xcodeproj/project.pbxproj +++ b/Samples/Swift/DaysUntilBirthday/DaysUntilBirthday.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 52; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -40,6 +40,9 @@ 73DB41932805FC3B0028B8D3 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7173A527F5110F00910319 /* UserProfileView.swift */; }; 73DB41952805FC5F0028B8D3 /* Birthday.swift in Sources */ = {isa = PBXBuildFile; fileRef = 739FCC47270E659A00C92042 /* Birthday.swift */; }; 73DB419628060A9A0028B8D3 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7345AD062703D9480020AFB1 /* Assets.xcassets */; }; + F8CE667E2F86AB020044EAFF /* Claim.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CE667D2F86AAFD0044EAFF /* Claim.swift */; }; + F8CE667F2F86AB020044EAFF /* Claim.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CE667D2F86AAFD0044EAFF /* Claim.swift */; }; + F8CE66802F86B6A30044EAFF /* Claim.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8CE667D2F86AAFD0044EAFF /* Claim.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -73,6 +76,7 @@ 739FCC45270E467600C92042 /* BirthdayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BirthdayView.swift; sourceTree = ""; }; 739FCC47270E659A00C92042 /* Birthday.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Birthday.swift; sourceTree = ""; }; 73DB417E2805F9850028B8D3 /* GoogleSignIn-iOS */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "GoogleSignIn-iOS"; path = ../../..; sourceTree = ""; }; + F8CE667D2F86AAFD0044EAFF /* Claim.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Claim.swift; sourceTree = ""; }; FE2F2ABC2800D9C1005EA17F /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; FE71738027ECFAF400910319 /* DaysUntilBirthday (macOS).app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "DaysUntilBirthday (macOS).app"; sourceTree = BUILT_PRODUCTS_DIR; }; FE71738927ECFAF600910319 /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; @@ -212,6 +216,7 @@ FE7173AD27F65B8500910319 /* Models */ = { isa = PBXGroup; children = ( + F8CE667D2F86AAFD0044EAFF /* Claim.swift */, 739FCC47270E659A00C92042 /* Birthday.swift */, ); path = Models; @@ -378,6 +383,7 @@ 739FCC46270E467600C92042 /* BirthdayView.swift in Sources */, 7345AD1B2703D9C30020AFB1 /* SignInView.swift in Sources */, 7345AD212703D9C30020AFB1 /* GoogleSignInAuthenticator.swift in Sources */, + F8CE667E2F86AB020044EAFF /* Claim.swift in Sources */, 7345AD232703D9C30020AFB1 /* UserProfileImageLoader.swift in Sources */, 7345AD1E2703D9C30020AFB1 /* UserProfileImageView.swift in Sources */, 736F49BC270E102C00580053 /* BirthdayViewModel.swift in Sources */, @@ -394,6 +400,7 @@ buildActionMask = 2147483647; files = ( 73508ED528134C7300ED7FB7 /* Credential.swift in Sources */, + F8CE66802F86B6A30044EAFF /* Claim.swift in Sources */, 73508EC82811BD9C00ED7FB7 /* DaysUntilBirthdayUITests_iOS.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -406,6 +413,7 @@ 73DB418B2805FBC40028B8D3 /* BirthdayLoader.swift in Sources */, 73DB418D2805FBD00028B8D3 /* AuthenticationViewModel.swift in Sources */, 73DB418F2805FBF50028B8D3 /* ContentView.swift in Sources */, + F8CE667F2F86AB020044EAFF /* Claim.swift in Sources */, 73DB418C2805FBC80028B8D3 /* UserProfileImageLoader.swift in Sources */, 73DB418E2805FBD40028B8D3 /* BirthdayViewModel.swift in Sources */, 73DB41952805FC5F0028B8D3 /* Birthday.swift in Sources */, diff --git a/Samples/Swift/DaysUntilBirthday/Shared/Models/Claim.swift b/Samples/Swift/DaysUntilBirthday/Shared/Models/Claim.swift new file mode 100644 index 00000000..f6164ca4 --- /dev/null +++ b/Samples/Swift/DaysUntilBirthday/Shared/Models/Claim.swift @@ -0,0 +1,24 @@ +/* + * Copyright 2026 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 Foundation + +/// A decoded representation of a single ID token claim. +struct Claim: Identifiable { + let key: String + let value: String + var id: String { key } +} diff --git a/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift b/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift index afa9ba49..48a0175f 100644 --- a/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift +++ b/Samples/Swift/DaysUntilBirthday/Shared/Services/GoogleSignInAuthenticator.swift @@ -20,7 +20,7 @@ import GoogleSignIn /// An observable class for authenticating via Google. final class GoogleSignInAuthenticator: ObservableObject { private var authViewModel: AuthenticationViewModel - private var claims: Set = Set([GIDClaim.authTime()]) + private let claims: Set = [GIDClaim.essentialAMR(), GIDClaim.authTime()] /// Creates an instance of this authenticator. /// - parameter authViewModel: The view model this authenticator will set logged in status on. diff --git a/Samples/Swift/DaysUntilBirthday/Shared/ViewModels/AuthenticationViewModel.swift b/Samples/Swift/DaysUntilBirthday/Shared/ViewModels/AuthenticationViewModel.swift index b528fc68..868a978d 100644 --- a/Samples/Swift/DaysUntilBirthday/Shared/ViewModels/AuthenticationViewModel.swift +++ b/Samples/Swift/DaysUntilBirthday/Shared/ViewModels/AuthenticationViewModel.swift @@ -26,15 +26,15 @@ final class AuthenticationViewModel: ObservableObject { 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? { + /// The user's `claims` as found in `idToken`. + /// - note: If the user is logged out, then this will default to empty. + var claims: [Claim] { switch state { case .signedIn(let user): - guard let idToken = user.idToken?.tokenString else { return nil } - return decodeAuthTime(fromJWT: idToken) + guard let idToken = user.idToken?.tokenString else { return [] } + return decodeClaims(fromJwt: idToken) case .signedOut: - return nil + return [] } } @@ -82,23 +82,25 @@ 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? { + /// Returns a collection of formatted claim keys and values decoded from a JWT. + func decodeClaims(fromJwt jwt: String) -> [Claim] { let segments = jwt.components(separatedBy: ".") - guard let parts = decodeJWTSegment(segments[1]), - let authTimeInterval = parts["auth_time"] as? TimeInterval else { - return nil + + guard segments.count > 1, + let payload = decodeJWTSegment(segments[1]) + else { + return [] } - return Date(timeIntervalSince1970: authTimeInterval) + + let claims: [Claim?] = [ + formatAuthTime(from: payload), + formatAmr(from: payload) + ] + + return claims.compactMap { $0 } } func decodeJWTSegment(_ segment: String) -> [String: Any]? { @@ -124,6 +126,26 @@ private extension AuthenticationViewModel { } return Data(base64Encoded: base64, options: .ignoreUnknownCharacters) } + + /// Returns the `auth_time` claim from the given JWT, if present. + func formatAuthTime(from payload: [String: Any]) -> Claim? { + guard let authTime = payload["auth_time"] as? TimeInterval + else { + return nil + } + let date = Date(timeIntervalSince1970: authTime) + let formattedDate = DateFormatter.localizedString(from: date, dateStyle: .medium, timeStyle: .medium) + return Claim(key: "auth_time", value: formattedDate) + } + + /// Returns the `amr` claim from the given JWT, if present. + private func formatAmr(from payload: [String: Any]) -> Claim? { + guard let amr = payload["amr"] as? [String] + else { + return nil + } + return Claim(key: "amr", value: amr.joined(separator: ", ")) + } } extension AuthenticationViewModel { diff --git a/Samples/Swift/DaysUntilBirthday/iOS/UserProfileView.swift b/Samples/Swift/DaysUntilBirthday/iOS/UserProfileView.swift index 256b777b..16392cc5 100644 --- a/Samples/Swift/DaysUntilBirthday/iOS/UserProfileView.swift +++ b/Samples/Swift/DaysUntilBirthday/iOS/UserProfileView.swift @@ -17,6 +17,27 @@ import SwiftUI import GoogleSignIn +/// A view that displays a list of ID token claims. +struct ClaimsListView: View { + let claims: [Claim] + var body: some View { + List(claims) { claim in + VStack(alignment: .leading, spacing: 4) { + Text(claim.key) + .font(.caption) + .foregroundColor(.secondary) + .fontWeight(.semibold) + + Text(claim.value) + .font(.body) + .lineLimit(4) + } + .padding(.vertical, 4) + } + .navigationTitle("ID Token Claims") + } +} + struct UserProfileView: View { @EnvironmentObject var authViewModel: AuthenticationViewModel @StateObject var birthdayViewModel = BirthdayViewModel() @@ -35,8 +56,15 @@ struct UserProfileView: View { Text(userProfile.name) .font(.headline) Text(userProfile.email) - if let authTimeString = authViewModel.formattedAuthTimeString { - Text("Last sign-in date: \(authTimeString)") + + if !authViewModel.claims.isEmpty { + NavigationLink(destination: ClaimsListView(claims: authViewModel.claims)) { + HStack { + Image(systemName: "list.bullet.rectangle.portrait") + Text("View ID Token Claims") + } + .foregroundColor(.blue) + } } } } From 3044684a1a00d54949b4944ab9ce66cdbfb4748d Mon Sep 17 00:00:00 2001 From: Akshat Gandhi <54901287+AkshatG6@users.noreply.github.com> Date: Mon, 4 May 2026 18:38:36 -0700 Subject: [PATCH 4/4] Merge main and resolve conflicts --- GoogleSignIn/Tests/Unit/OIDAuthState+Testing.m | 1 - 1 file changed, 1 deletion(-) diff --git a/GoogleSignIn/Tests/Unit/OIDAuthState+Testing.m b/GoogleSignIn/Tests/Unit/OIDAuthState+Testing.m index 98c317a6..c971b115 100644 --- a/GoogleSignIn/Tests/Unit/OIDAuthState+Testing.m +++ b/GoogleSignIn/Tests/Unit/OIDAuthState+Testing.m @@ -57,7 +57,6 @@ + (instancetype)testInstanceWithIDToken:(NSString *)idToken expiresIn:@(accessTokenExpiresIn) refreshToken:refreshToken refreshExpiresIn:@(refreshTokenExpiresIn) - authTime:nil tokenRequest:nil]; return [self testInstanceWithTokenResponse:newResponse]; }