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) + } } } }