From a8747a4df05d7980be7b6492100d4c0f0013ff29 Mon Sep 17 00:00:00 2001 From: pulipakaa24 Date: Fri, 10 Apr 2026 01:44:02 -0500 Subject: [PATCH] signInWithApple --- LabWise.xcodeproj/project.pbxproj | 2 + LabWise/LabWise.entitlements | 10 ++ LabWise/LoginView.swift | 81 +++++++++- LabWise/ProfileView.swift | 15 ++ .../Sources/LabWiseKit/Auth/AuthClient.swift | 145 ++++++++++++++++++ 5 files changed, 250 insertions(+), 3 deletions(-) create mode 100644 LabWise/LabWise.entitlements diff --git a/LabWise.xcodeproj/project.pbxproj b/LabWise.xcodeproj/project.pbxproj index 58631e2..fd48f54 100644 --- a/LabWise.xcodeproj/project.pbxproj +++ b/LabWise.xcodeproj/project.pbxproj @@ -429,6 +429,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = YK2DB9NT3S; @@ -466,6 +467,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = YK2DB9NT3S; diff --git a/LabWise/LabWise.entitlements b/LabWise/LabWise.entitlements new file mode 100644 index 0000000..a812db5 --- /dev/null +++ b/LabWise/LabWise.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.developer.applesignin + + Default + + + diff --git a/LabWise/LoginView.swift b/LabWise/LoginView.swift index 6d82ef5..8c6e0a4 100644 --- a/LabWise/LoginView.swift +++ b/LabWise/LoginView.swift @@ -9,8 +9,14 @@ struct LoginView: View { @State private var password = "" @State private var isLoading = false @State private var isGoogleLoading = false + @State private var isAppleLoading = false @State private var errorMessage: String? + // Apple sign-in: we generate a fresh raw nonce each time the button is + // tapped, hash it, hand the hash to Apple, then forward the same hash + // to Better Auth so it can verify the JWT echoed back in `nonce`. + @State private var currentAppleRawNonce: String? + private let authClient = AuthClient() var body: some View { @@ -86,8 +92,8 @@ struct LoginView: View { .background(Color(.brandPrimary)) .foregroundStyle(.white) .clipShape(RoundedRectangle(cornerRadius: 10)) - .disabled(email.isEmpty || password.isEmpty || isLoading || isGoogleLoading) - .opacity((email.isEmpty || password.isEmpty || isLoading || isGoogleLoading) ? 0.5 : 1) + .disabled(email.isEmpty || password.isEmpty || isLoading || isGoogleLoading || isAppleLoading) + .opacity((email.isEmpty || password.isEmpty || isLoading || isGoogleLoading || isAppleLoading) ? 0.5 : 1) HStack { Rectangle() @@ -104,7 +110,24 @@ struct LoginView: View { GoogleSignInButton(isLoading: isGoogleLoading) { Task { await signInWithGoogle() } } - .disabled(isLoading || isGoogleLoading) + .disabled(isLoading || isGoogleLoading || isAppleLoading) + + SignInWithAppleButton( + onRequest: { request in + let raw = AuthClient.generateAppleNonceRaw() + currentAppleRawNonce = raw + request.requestedScopes = [.fullName, .email] + request.nonce = AuthClient.sha256Hex(raw) + }, + onCompletion: { result in + Task { await handleAppleCompletion(result) } + } + ) + .signInWithAppleButtonStyle(.black) + .frame(height: 44) + .clipShape(RoundedRectangle(cornerRadius: 10)) + .disabled(isLoading || isGoogleLoading || isAppleLoading) + .opacity(isAppleLoading ? 0.5 : 1) } .padding(24) .background(Color(UIColor.secondarySystemGroupedBackground)) @@ -143,6 +166,58 @@ struct LoginView: View { } } + @MainActor + private func handleAppleCompletion(_ result: Result) async { + isAppleLoading = true + errorMessage = nil + defer { + isAppleLoading = false + currentAppleRawNonce = nil + } + + switch result { + case .failure(let error): + if let asError = error as? ASAuthorizationError, asError.code == .canceled { + return // user cancelled — silent + } + errorMessage = "Apple sign-in failed. Please try again." + case .success(let authorization): + guard let credential = authorization.credential as? ASAuthorizationAppleIDCredential, + let identityTokenData = credential.identityToken, + let identityToken = String(data: identityTokenData, encoding: .utf8) else { + errorMessage = "Apple sign-in failed: missing identity token." + return + } + + // The hashed nonce we sent to Apple — Better Auth needs the same + // value to compare against the JWT's `nonce` claim. + let hashedNonce = currentAppleRawNonce.map(AuthClient.sha256Hex) + + // Apple only delivers fullName / email the very first time the + // user authorises this app. Forward whatever we got — Better + // Auth's getUserInfo will use these to populate the user record. + let firstName = credential.fullName?.givenName + let lastName = credential.fullName?.familyName + let email = credential.email + + do { + let user = try await authClient.signInWithApple( + idToken: identityToken, + hashedNonce: hashedNonce, + firstName: firstName, + lastName: lastName, + email: email + ) + appState.signedIn(user: user) + } catch APIError.httpError(_, let data) { + let msg = String(data: data, encoding: .utf8) ?? "Apple sign-in failed." + errorMessage = msg + } catch { + errorMessage = "Apple sign-in failed. Please check your connection." + } + } + } + @MainActor private func signInWithGoogle() async { guard let window = UIApplication.shared.connectedScenes diff --git a/LabWise/ProfileView.swift b/LabWise/ProfileView.swift index 4c11cf7..f5bd985 100644 --- a/LabWise/ProfileView.swift +++ b/LabWise/ProfileView.swift @@ -12,6 +12,7 @@ final class ProfileViewModel { var isDeleting = false // Editable fields + var displayName = "" var piFirstName = "" var bldgCode = "" var lab = "" @@ -36,6 +37,13 @@ final class ProfileViewModel { isSaving = true defer { isSaving = false } do { + let trimmedName = displayName.trimmingCharacters(in: .whitespaces) + if !trimmedName.isEmpty && trimmedName != AppState.shared.currentUser?.name { + let updatedUser = try await authClient.updateName(trimmedName) + await MainActor.run { + AppState.shared.currentUser = updatedUser + } + } let body = UserProfileUpsertBody( piFirstName: piFirstName, bldgCode: bldgCode, @@ -88,6 +96,11 @@ struct ProfileView: View { Form { if let user = appState.currentUser { Section("Account") { + if viewModel.isEditing { + TextField("Name", text: $viewModel.displayName) + } else { + LabeledContent("Name", value: user.name ?? "—") + } LabeledContent("Email", value: user.email) } } @@ -149,6 +162,7 @@ struct ProfileView: View { .disabled(viewModel.isSaving) } else { Button("Edit") { + viewModel.displayName = appState.currentUser?.name ?? "" viewModel.isEditing = true } } @@ -157,6 +171,7 @@ struct ProfileView: View { ToolbarItem(placement: .topBarLeading) { Button("Cancel") { viewModel.isEditing = false + viewModel.displayName = appState.currentUser?.name ?? "" if let p = viewModel.profile { viewModel.piFirstName = p.piFirstName viewModel.bldgCode = p.bldgCode diff --git a/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift b/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift index d39b32f..3d4b318 100644 --- a/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift +++ b/LabWiseKit/Sources/LabWiseKit/Auth/AuthClient.swift @@ -1,5 +1,6 @@ import Foundation import AuthenticationServices +import CryptoKit public struct SignInBody: Encodable, Sendable { public let email: String @@ -11,6 +12,35 @@ public struct SignInBody: Encodable, Sendable { } } +/// Mirrors Better Auth's POST /sign-in/social body for the id-token (native) flow. +public struct AppleSocialSignInBody: Encodable, Sendable { + public let provider: String + public let idToken: AppleIdTokenPayload + + public struct AppleIdTokenPayload: Encodable, Sendable { + public let token: String + public let nonce: String? + public let user: AppleUser? + } + + public struct AppleUser: Encodable, Sendable { + public let name: AppleName? + public let email: String? + } + + public struct AppleName: Encodable, Sendable { + public let firstName: String? + public let lastName: String? + } +} + +/// Better Auth's response from /sign-in/social when an idToken is supplied. +private struct SocialIdTokenResponse: Decodable, Sendable { + let token: String? + let user: AuthUser? + let redirect: Bool? +} + /// Server returns { token, user } directly (or { error: { message } } on failure) public struct SignInResponse: Decodable, Sendable { public let token: String? @@ -161,6 +191,108 @@ public final class AuthClient: Sendable { return try await fetchCurrentUser() } + // MARK: - Sign in with Apple + + /// Generates a cryptographically random string suitable for use as the + /// raw nonce in a Sign in with Apple request. Pass the SHA-256 hash of + /// this value to ASAuthorizationAppleIDRequest.nonce, and pass the same + /// hashed value to `signInWithApple(idToken:hashedNonce:)`. + public static func generateAppleNonceRaw(length: Int = 32) -> String { + let charset: [Character] = Array("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz-._") + var result = "" + var remaining = length + while remaining > 0 { + var randoms = [UInt8](repeating: 0, count: 16) + let status = SecRandomCopyBytes(kSecRandomDefault, randoms.count, &randoms) + precondition(status == errSecSuccess, "Unable to generate secure random bytes for Apple nonce") + for byte in randoms where remaining > 0 { + if byte < charset.count { + result.append(charset[Int(byte)]) + remaining -= 1 + } + } + } + return result + } + + /// SHA-256 hashes the raw nonce as a lowercase hex string. This is the + /// value that should be passed to ASAuthorizationAppleIDRequest.nonce + /// AND forwarded to Better Auth, since the JWT Apple returns echoes back + /// the hashed nonce. + public static func sha256Hex(_ input: String) -> String { + let digest = SHA256.hash(data: Data(input.utf8)) + return digest.map { String(format: "%02x", $0) }.joined() + } + + /// Completes Sign in with Apple using a native identity token. + /// + /// Flow: + /// 1. The caller obtains an `ASAuthorizationAppleIDCredential` via + /// `ASAuthorizationController` (typically through SwiftUI's + /// `SignInWithAppleButton`). + /// 2. The caller passes the credential's `identityToken` (decoded as a + /// UTF-8 string), the SHA-256-hashed nonce that was used in the + /// request, and the user's name (only available the very first time + /// Apple consents the user) to this method. + /// 3. We POST `{ provider: "apple", idToken: { token, nonce, user } }` + /// to Better Auth's `/api/auth/sign-in/social` endpoint. Better Auth + /// verifies the JWT against Apple's public keys (audience = our iOS + /// bundle identifier) and returns the session token. + /// 4. We inject the returned token into HTTPCookieStorage so subsequent + /// URLSession requests are authenticated, then return the user. + public func signInWithApple( + idToken: String, + hashedNonce: String?, + firstName: String? = nil, + lastName: String? = nil, + email: String? = nil + ) async throws -> AuthUser { + let userPayload: AppleSocialSignInBody.AppleUser? + if firstName != nil || lastName != nil || email != nil { + userPayload = .init( + name: (firstName != nil || lastName != nil) + ? .init(firstName: firstName, lastName: lastName) + : nil, + email: email + ) + } else { + userPayload = nil + } + + let body = AppleSocialSignInBody( + provider: "apple", + idToken: .init(token: idToken, nonce: hashedNonce, user: userPayload) + ) + let encoded = try JSONEncoder().encode(body) + + var req = URLRequest(url: URL(string: "\(baseURL)/api/auth/sign-in/social")!) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.setValue(baseURL, forHTTPHeaderField: "Origin") + req.httpBody = encoded + + let session = sharedURLSession() + let (data, response) = try await session.data(for: req) + + guard let http = response as? HTTPURLResponse else { + throw APIError.networkError(URLError(.badServerResponse)) + } + guard http.statusCode == 200 else { + throw APIError.httpError(statusCode: http.statusCode, data: data) + } + + let decoded = try JSONDecoder.api.decode(SocialIdTokenResponse.self, from: data) + guard let user = decoded.user else { + throw APIError.decodingError(DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Missing user in Apple sign-in response") + )) + } + if let token = decoded.token { + injectSessionCookie(token: token) + } + return user + } + // MARK: - Cookie injection private func injectSessionCookie(token: String) { @@ -200,6 +332,19 @@ public final class AuthClient: Sendable { return user } + // MARK: - Update Name + + private struct UpdateNameBody: Encodable, Sendable { + let name: String + } + + /// Update the authenticated user's display name. + public func updateName(_ name: String) async throws -> AuthUser { + let body = UpdateNameBody(name: name) + _ = try await api.postRaw("/api/auth/update-user", body: body) + return try await fetchCurrentUser() + } + // MARK: - Delete Account /// Permanently delete the user's account and all associated data.