signInWithApple
This commit is contained in:
@@ -429,6 +429,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = YK2DB9NT3S;
|
DEVELOPMENT_TEAM = YK2DB9NT3S;
|
||||||
@@ -466,6 +467,7 @@
|
|||||||
buildSettings = {
|
buildSettings = {
|
||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = LabWise/LabWise.entitlements;
|
||||||
CODE_SIGN_STYLE = Automatic;
|
CODE_SIGN_STYLE = Automatic;
|
||||||
CURRENT_PROJECT_VERSION = 1;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = YK2DB9NT3S;
|
DEVELOPMENT_TEAM = YK2DB9NT3S;
|
||||||
|
|||||||
10
LabWise/LabWise.entitlements
Normal file
10
LabWise/LabWise.entitlements
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>com.apple.developer.applesignin</key>
|
||||||
|
<array>
|
||||||
|
<string>Default</string>
|
||||||
|
</array>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -9,8 +9,14 @@ struct LoginView: View {
|
|||||||
@State private var password = ""
|
@State private var password = ""
|
||||||
@State private var isLoading = false
|
@State private var isLoading = false
|
||||||
@State private var isGoogleLoading = false
|
@State private var isGoogleLoading = false
|
||||||
|
@State private var isAppleLoading = false
|
||||||
@State private var errorMessage: String?
|
@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()
|
private let authClient = AuthClient()
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
@@ -86,8 +92,8 @@ struct LoginView: View {
|
|||||||
.background(Color(.brandPrimary))
|
.background(Color(.brandPrimary))
|
||||||
.foregroundStyle(.white)
|
.foregroundStyle(.white)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 10))
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
.disabled(email.isEmpty || password.isEmpty || isLoading || isGoogleLoading)
|
.disabled(email.isEmpty || password.isEmpty || isLoading || isGoogleLoading || isAppleLoading)
|
||||||
.opacity((email.isEmpty || password.isEmpty || isLoading || isGoogleLoading) ? 0.5 : 1)
|
.opacity((email.isEmpty || password.isEmpty || isLoading || isGoogleLoading || isAppleLoading) ? 0.5 : 1)
|
||||||
|
|
||||||
HStack {
|
HStack {
|
||||||
Rectangle()
|
Rectangle()
|
||||||
@@ -104,7 +110,24 @@ struct LoginView: View {
|
|||||||
GoogleSignInButton(isLoading: isGoogleLoading) {
|
GoogleSignInButton(isLoading: isGoogleLoading) {
|
||||||
Task { await signInWithGoogle() }
|
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)
|
.padding(24)
|
||||||
.background(Color(UIColor.secondarySystemGroupedBackground))
|
.background(Color(UIColor.secondarySystemGroupedBackground))
|
||||||
@@ -143,6 +166,58 @@ struct LoginView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func handleAppleCompletion(_ result: Result<ASAuthorization, Error>) 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
|
@MainActor
|
||||||
private func signInWithGoogle() async {
|
private func signInWithGoogle() async {
|
||||||
guard let window = UIApplication.shared.connectedScenes
|
guard let window = UIApplication.shared.connectedScenes
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ final class ProfileViewModel {
|
|||||||
var isDeleting = false
|
var isDeleting = false
|
||||||
|
|
||||||
// Editable fields
|
// Editable fields
|
||||||
|
var displayName = ""
|
||||||
var piFirstName = ""
|
var piFirstName = ""
|
||||||
var bldgCode = ""
|
var bldgCode = ""
|
||||||
var lab = ""
|
var lab = ""
|
||||||
@@ -36,6 +37,13 @@ final class ProfileViewModel {
|
|||||||
isSaving = true
|
isSaving = true
|
||||||
defer { isSaving = false }
|
defer { isSaving = false }
|
||||||
do {
|
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(
|
let body = UserProfileUpsertBody(
|
||||||
piFirstName: piFirstName,
|
piFirstName: piFirstName,
|
||||||
bldgCode: bldgCode,
|
bldgCode: bldgCode,
|
||||||
@@ -88,6 +96,11 @@ struct ProfileView: View {
|
|||||||
Form {
|
Form {
|
||||||
if let user = appState.currentUser {
|
if let user = appState.currentUser {
|
||||||
Section("Account") {
|
Section("Account") {
|
||||||
|
if viewModel.isEditing {
|
||||||
|
TextField("Name", text: $viewModel.displayName)
|
||||||
|
} else {
|
||||||
|
LabeledContent("Name", value: user.name ?? "—")
|
||||||
|
}
|
||||||
LabeledContent("Email", value: user.email)
|
LabeledContent("Email", value: user.email)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -149,6 +162,7 @@ struct ProfileView: View {
|
|||||||
.disabled(viewModel.isSaving)
|
.disabled(viewModel.isSaving)
|
||||||
} else {
|
} else {
|
||||||
Button("Edit") {
|
Button("Edit") {
|
||||||
|
viewModel.displayName = appState.currentUser?.name ?? ""
|
||||||
viewModel.isEditing = true
|
viewModel.isEditing = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,6 +171,7 @@ struct ProfileView: View {
|
|||||||
ToolbarItem(placement: .topBarLeading) {
|
ToolbarItem(placement: .topBarLeading) {
|
||||||
Button("Cancel") {
|
Button("Cancel") {
|
||||||
viewModel.isEditing = false
|
viewModel.isEditing = false
|
||||||
|
viewModel.displayName = appState.currentUser?.name ?? ""
|
||||||
if let p = viewModel.profile {
|
if let p = viewModel.profile {
|
||||||
viewModel.piFirstName = p.piFirstName
|
viewModel.piFirstName = p.piFirstName
|
||||||
viewModel.bldgCode = p.bldgCode
|
viewModel.bldgCode = p.bldgCode
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import AuthenticationServices
|
import AuthenticationServices
|
||||||
|
import CryptoKit
|
||||||
|
|
||||||
public struct SignInBody: Encodable, Sendable {
|
public struct SignInBody: Encodable, Sendable {
|
||||||
public let email: String
|
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)
|
/// Server returns { token, user } directly (or { error: { message } } on failure)
|
||||||
public struct SignInResponse: Decodable, Sendable {
|
public struct SignInResponse: Decodable, Sendable {
|
||||||
public let token: String?
|
public let token: String?
|
||||||
@@ -161,6 +191,108 @@ public final class AuthClient: Sendable {
|
|||||||
return try await fetchCurrentUser()
|
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
|
// MARK: - Cookie injection
|
||||||
|
|
||||||
private func injectSessionCookie(token: String) {
|
private func injectSessionCookie(token: String) {
|
||||||
@@ -200,6 +332,19 @@ public final class AuthClient: Sendable {
|
|||||||
return user
|
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
|
// MARK: - Delete Account
|
||||||
|
|
||||||
/// Permanently delete the user's account and all associated data.
|
/// Permanently delete the user's account and all associated data.
|
||||||
|
|||||||
Reference in New Issue
Block a user