signInWithApple
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user