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)