From 270625f4844d4fe141b881214843d8a77d08fe95 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 16:45:17 +0800 Subject: [PATCH 1/7] feat(auth): native macOS passkey + password sign-in/signup with token-exchange diagnostics Adds native macOS authentication flows (passkey sign-in/registration, username/password sign-in/signup) alongside the existing OAuth authorization_code + PKCE flow, expanded RxSignInView UI with appearance configuration, AASA-associated-domains entitlements for the test app, and Base64URL helpers. Token-exchange errors now log the HTTP response body and surface it in the thrown error to make 400/4xx failures from rxlab-auth diagnosable end-to-end. Co-Authored-By: Claude Opus 4.7 (1M context) --- Package.swift | 8 +- README.md | 31 + Sources/RxAuthSwift/Base64URL.swift | 23 + Sources/RxAuthSwift/OAuthError.swift | 9 + Sources/RxAuthSwift/OAuthManager.swift | 570 +++++++++++++++++- .../Platform/MacOSPasskeyAuthenticator.swift | 187 ++++++ Sources/RxAuthSwift/RxAuthConfiguration.swift | 50 ++ .../Components/PrimaryAuthButton.swift | 9 +- .../RxAuthSwiftUI/RxSignInAppearance.swift | 32 +- Sources/RxAuthSwiftUI/RxSignInView.swift | 484 ++++++++++++++- .../OAuthManagerTokenCleanupTests.swift | 176 ++++++ Tests/RxAuthSwiftTests/RxAuthSwiftTests.swift | 23 + test/test.xcodeproj/project.pbxproj | 8 +- test/test/test.entitlements | 11 + test/test/testApp.swift | 7 +- 15 files changed, 1557 insertions(+), 71 deletions(-) create mode 100644 Sources/RxAuthSwift/Base64URL.swift create mode 100644 Sources/RxAuthSwift/Platform/MacOSPasskeyAuthenticator.swift create mode 100644 test/test/test.entitlements diff --git a/Package.swift b/Package.swift index 174b64e..80d9861 100644 --- a/Package.swift +++ b/Package.swift @@ -6,8 +6,8 @@ import PackageDescription let package = Package( name: "RxAuthSwift", platforms: [ - .iOS(.v18), - .macOS(.v15), + .iOS(.v26), + .macOS(.v26), ], products: [ .library( @@ -20,13 +20,13 @@ let package = Package( ), ], dependencies: [ - .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0") + .package(url: "https://github.com/apple/swift-log.git", from: "1.6.0"), ], targets: [ .target( name: "RxAuthSwift", dependencies: [ - .product(name: "Logging", package: "swift-log") + .product(name: "Logging", package: "swift-log"), ] ), .target( diff --git a/README.md b/README.md index 0d8485a..9cde6a0 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,37 @@ let config = RxAuthConfiguration( ) ``` +For native macOS sign-in, `RxSignInView` renders username/password fields instead of launching a browser. Password sign-in posts an OAuth password-grant request directly to `nativePasswordTokenPath` or `tokenPath` when no native override is provided: + +```swift +let config = RxAuthConfiguration( + issuer: "https://auth.example.com", + clientID: "your-client-id", + redirectURI: "yourapp://callback", + nativePasswordTokenPath: "/api/oauth/token", + nativeSignupPath: "/api/oauth/signup" +) +``` + +The signup endpoint receives JSON with `client_id`, `username`, `password`, optional `name`, and `scope`, and should return the same token JSON as the OAuth token endpoint. + +Passkey sign-in and signup are enabled when the matching endpoint pairs are configured: + +```swift +let config = RxAuthConfiguration( + issuer: "https://auth.example.com", + clientID: "your-client-id", + redirectURI: "yourapp://callback", + passkeyChallengePath: "/api/passkeys/authentication/options", + passkeyVerificationPath: "/api/passkeys/authentication/verify", + passkeyRegistrationChallengePath: "/api/passkeys/registration/options", + passkeyRegistrationVerificationPath: "/api/passkeys/registration/verify", + passkeyRelyingPartyIdentifier: "auth.example.com" +) +``` + +The authentication challenge endpoint should return a base64url WebAuthn challenge, optional `requestID`, optional relying party identifier, and optional allowed credential IDs. The registration challenge endpoint should return a base64url challenge and user ID, plus optional `requestID`, username, and relying party identifier. Verification endpoints receive the platform passkey assertion or registration payload and return the same token JSON as the OAuth token endpoint. + ### 2. Create OAuthManager ```swift diff --git a/Sources/RxAuthSwift/Base64URL.swift b/Sources/RxAuthSwift/Base64URL.swift new file mode 100644 index 0000000..a410678 --- /dev/null +++ b/Sources/RxAuthSwift/Base64URL.swift @@ -0,0 +1,23 @@ +import Foundation + +enum Base64URL { + static func encode(_ data: Data) -> String { + data.base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + } + + static func decode(_ string: String) -> Data? { + var base64 = string + .replacingOccurrences(of: "-", with: "+") + .replacingOccurrences(of: "_", with: "/") + + let padding = base64.count % 4 + if padding > 0 { + base64.append(String(repeating: "=", count: 4 - padding)) + } + + return Data(base64Encoded: base64) + } +} diff --git a/Sources/RxAuthSwift/OAuthError.swift b/Sources/RxAuthSwift/OAuthError.swift index e8b51a7..54b0445 100644 --- a/Sources/RxAuthSwift/OAuthError.swift +++ b/Sources/RxAuthSwift/OAuthError.swift @@ -11,6 +11,9 @@ public enum OAuthError: LocalizedError, Sendable { case noRefreshToken case invalidCallbackURL case cancelled + case invalidCredentials + case invalidSignupDetails + case passkeyUnavailable public var errorDescription: String? { switch self { @@ -34,6 +37,12 @@ public enum OAuthError: LocalizedError, Sendable { return "Invalid callback URL received" case .cancelled: return "Authentication was cancelled" + case .invalidCredentials: + return "Enter a username and password" + case .invalidSignupDetails: + return "Enter a username and password to create an account" + case .passkeyUnavailable: + return "Passkey sign in is not configured for this app" } } } diff --git a/Sources/RxAuthSwift/OAuthManager.swift b/Sources/RxAuthSwift/OAuthManager.swift index 840f8c2..f9a14fe 100644 --- a/Sources/RxAuthSwift/OAuthManager.swift +++ b/Sources/RxAuthSwift/OAuthManager.swift @@ -10,6 +10,20 @@ public final class OAuthManager: Sendable { public private(set) var errorMessage: String? public private(set) var isAuthenticating = false + /// Clears the current error message + public func clearError() { + errorMessage = nil + } + public var supportsPasskeyAuthentication: Bool { + configuration.passkeyChallengeURL != nil && configuration.passkeyVerificationURL != nil + } + public var supportsNativeSignup: Bool { + configuration.nativeSignupURL != nil + } + public var supportsPasskeyRegistration: Bool { + configuration.passkeyRegistrationChallengeURL != nil && configuration.passkeyRegistrationVerificationURL != nil + } + private let configuration: RxAuthConfiguration private let tokenStorage: TokenStorageProtocol private let logger: Logger @@ -66,21 +80,82 @@ public final class OAuthManager: Sendable { errorMessage = nil defer { isAuthenticating = false } - let codeVerifier = PKCEHelper.generateCodeVerifier() - let codeChallenge = PKCEHelper.generateCodeChallenge(from: codeVerifier) + do { + let codeVerifier = PKCEHelper.generateCodeVerifier() + let codeChallenge = PKCEHelper.generateCodeChallenge(from: codeVerifier) - guard let authorizeURL = buildAuthorizationURL(codeChallenge: codeChallenge) else { - throw OAuthError.invalidConfiguration + guard let authorizeURL = buildAuthorizationURL(codeChallenge: codeChallenge) else { + throw OAuthError.invalidConfiguration + } + + guard let callbackScheme = configuration.redirectScheme else { + throw OAuthError.invalidConfiguration + } + + logger.info("Starting OAuth authentication flow") + + let callbackURL = try await platformAuthenticate(url: authorizeURL, callbackScheme: callbackScheme) + try await handleCallback(url: callbackURL, codeVerifier: codeVerifier) + } catch { + errorMessage = error.localizedDescription + throw error } + } - guard let callbackScheme = configuration.redirectScheme else { - throw OAuthError.invalidConfiguration + public func authenticate(username: String, password: String) async throws { + isAuthenticating = true + errorMessage = nil + defer { isAuthenticating = false } + + do { + try await exchangePasswordForTokens(username: username, password: password) + logger.info("Native username/password authentication completed successfully") + } catch { + errorMessage = error.localizedDescription + throw error + } + } + + public func authenticateWithPasskey(username: String? = nil) async throws { + isAuthenticating = true + errorMessage = nil + defer { isAuthenticating = false } + + do { + try await exchangePasskeyAssertionForTokens(username: username) + logger.info("Native passkey authentication completed successfully") + } catch { + errorMessage = error.localizedDescription + throw error + } + } + + public func signUp(username: String, password: String, name: String? = nil) async throws { + isAuthenticating = true + errorMessage = nil + defer { isAuthenticating = false } + + do { + try await exchangeSignupForTokens(username: username, password: password, name: name) + logger.info("Native username/password signup completed successfully") + } catch { + errorMessage = error.localizedDescription + throw error } + } - logger.info("Starting OAuth authentication flow") + public func signUpWithPasskey(username: String, name: String? = nil) async throws { + isAuthenticating = true + errorMessage = nil + defer { isAuthenticating = false } - let callbackURL = try await platformAuthenticate(url: authorizeURL, callbackScheme: callbackScheme) - try await handleCallback(url: callbackURL, codeVerifier: codeVerifier) + do { + try await exchangePasskeyRegistrationForTokens(username: username, name: name) + logger.info("Native passkey signup completed successfully") + } catch { + errorMessage = error.localizedDescription + throw error + } } public func logout() async { @@ -114,12 +189,11 @@ public final class OAuthManager: Sendable { request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - let body = [ - "grant_type=refresh_token", - "refresh_token=\(refreshToken)", - "client_id=\(configuration.clientID)", - ].joined(separator: "&") - request.httpBody = body.data(using: .utf8) + request.httpBody = formEncodedBody([ + "grant_type": "refresh_token", + "refresh_token": refreshToken, + "client_id": configuration.clientID, + ]) let (data, response) = try await URLSession.shared.data(for: request) @@ -174,14 +248,13 @@ public final class OAuthManager: Sendable { request.httpMethod = "POST" request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") - let body = [ - "grant_type=authorization_code", - "code=\(code)", - "redirect_uri=\(configuration.redirectURI)", - "client_id=\(configuration.clientID)", - "code_verifier=\(codeVerifier)", - ].joined(separator: "&") - request.httpBody = body.data(using: .utf8) + request.httpBody = formEncodedBody([ + "grant_type": "authorization_code", + "code": code, + "redirect_uri": configuration.redirectURI, + "client_id": configuration.clientID, + "code_verifier": codeVerifier, + ]) let (data, response) = try await URLSession.shared.data(for: request) @@ -198,6 +271,205 @@ public final class OAuthManager: Sendable { logger.info("Authentication completed successfully") } + private func exchangePasswordForTokens(username: String, password: String) async throws { + let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedUsername.isEmpty, !password.isEmpty else { + throw OAuthError.invalidCredentials + } + + guard let tokenURL = configuration.nativePasswordTokenURL else { + throw OAuthError.invalidURL + } + + var request = URLRequest(url: tokenURL) + request.httpMethod = "POST" + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + request.httpBody = formEncodedBody([ + "grant_type": "password", + "username": trimmedUsername, + "password": password, + "client_id": configuration.clientID, + "scope": configuration.scopes.joined(separator: " "), + ]) + + let tokenResponse = try await sendTokenRequest(request) + try saveTokens(tokenResponse) + try await fetchUserInfo() + + authState = .authenticated + startTokenRefreshTimer() + } + + private func exchangeSignupForTokens(username: String, password: String, name: String?) async throws { + let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedUsername.isEmpty, !password.isEmpty else { + throw OAuthError.invalidSignupDetails + } + + guard let signupURL = configuration.nativeSignupURL else { + throw OAuthError.invalidConfiguration + } + + var request = URLRequest(url: signupURL) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try JSONEncoder().encode(SignupRequest( + clientID: configuration.clientID, + username: trimmedUsername, + password: password, + name: name.nilIfBlank, + scope: configuration.scopes.joined(separator: " ") + )) + + let tokenResponse = try await sendTokenRequest(request) + try saveTokens(tokenResponse) + try await fetchUserInfo() + + authState = .authenticated + startTokenRefreshTimer() + } + + private func exchangePasskeyAssertionForTokens(username: String?) async throws { + guard supportsPasskeyAuthentication, + let challengeURL = configuration.passkeyChallengeURL, + let verificationURL = configuration.passkeyVerificationURL + else { + throw OAuthError.passkeyUnavailable + } + + var challengeRequest = URLRequest(url: challengeURL) + challengeRequest.httpMethod = "POST" + challengeRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + challengeRequest.httpBody = try JSONEncoder().encode(PasskeyChallengeRequest( + clientID: configuration.clientID, + username: username.nilIfBlank + )) + + let (challengeData, challengeResponse) = try await URLSession.shared.data(for: challengeRequest) + guard let httpChallengeResponse = challengeResponse as? HTTPURLResponse, + httpChallengeResponse.statusCode == 200 + else { + throw OAuthError.authenticationFailed("Passkey challenge request failed") + } + + let options = try JSONDecoder().decode(PasskeyChallengeResponse.self, from: challengeData) + guard let challenge = Base64URL.decode(options.challenge) else { + throw OAuthError.authenticationFailed("Invalid passkey challenge") + } + + let relyingPartyIdentifier = options.relyingPartyIdentifier + ?? configuration.passkeyRelyingPartyIdentifier + ?? URL(string: configuration.issuer)?.host + + guard let relyingPartyIdentifier else { + throw OAuthError.passkeyUnavailable + } + + let allowedCredentialIDs = options.allowedCredentialIDs.compactMap(Base64URL.decode) + + #if os(macOS) + let assertion = try await MacOSPasskeyAuthenticator().authenticate( + relyingPartyIdentifier: relyingPartyIdentifier, + challenge: challenge, + allowedCredentialIDs: allowedCredentialIDs + ) + + var verificationRequest = URLRequest(url: verificationURL) + verificationRequest.httpMethod = "POST" + verificationRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + verificationRequest.httpBody = try JSONEncoder().encode(PasskeyVerificationRequest( + clientID: configuration.clientID, + requestID: options.requestID, + username: username.nilIfBlank, + assertion: assertion + )) + + let tokenResponse = try await sendTokenRequest(verificationRequest) + try saveTokens(tokenResponse) + try await fetchUserInfo() + + authState = .authenticated + startTokenRefreshTimer() + #else + throw OAuthError.passkeyUnavailable + #endif + } + + private func exchangePasskeyRegistrationForTokens(username: String, name: String?) async throws { + let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedUsername.isEmpty else { + throw OAuthError.invalidSignupDetails + } + + guard supportsPasskeyRegistration, + let challengeURL = configuration.passkeyRegistrationChallengeURL, + let verificationURL = configuration.passkeyRegistrationVerificationURL + else { + throw OAuthError.passkeyUnavailable + } + + var challengeRequest = URLRequest(url: challengeURL) + challengeRequest.httpMethod = "POST" + challengeRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + challengeRequest.httpBody = try JSONEncoder().encode(PasskeyRegistrationChallengeRequest( + clientID: configuration.clientID, + username: trimmedUsername, + name: name.nilIfBlank + )) + + let (challengeData, challengeResponse) = try await URLSession.shared.data(for: challengeRequest) + guard let httpChallengeResponse = challengeResponse as? HTTPURLResponse, + httpChallengeResponse.statusCode == 200 + else { + throw OAuthError.authenticationFailed("Passkey registration challenge request failed") + } + + let options = try JSONDecoder().decode(PasskeyRegistrationChallengeResponse.self, from: challengeData) + guard let challenge = Base64URL.decode(options.challenge) else { + throw OAuthError.authenticationFailed("Invalid passkey registration challenge") + } + + let relyingPartyIdentifier = options.relyingPartyIdentifier + ?? configuration.passkeyRelyingPartyIdentifier + ?? URL(string: configuration.issuer)?.host + guard let relyingPartyIdentifier else { + throw OAuthError.passkeyUnavailable + } + + guard let userID = Base64URL.decode(options.userID) else { + throw OAuthError.authenticationFailed("Invalid passkey registration user ID") + } + + #if os(macOS) + let registration = try await MacOSPasskeyRegistrationAuthenticator().register( + relyingPartyIdentifier: relyingPartyIdentifier, + challenge: challenge, + name: options.username ?? trimmedUsername, + userID: userID + ) + + var verificationRequest = URLRequest(url: verificationURL) + verificationRequest.httpMethod = "POST" + verificationRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + verificationRequest.httpBody = try JSONEncoder().encode(PasskeyRegistrationVerificationRequest( + clientID: configuration.clientID, + requestID: options.requestID, + username: trimmedUsername, + name: name.nilIfBlank, + registration: registration + )) + + let tokenResponse = try await sendTokenRequest(verificationRequest) + try saveTokens(tokenResponse) + try await fetchUserInfo() + + authState = .authenticated + startTokenRefreshTimer() + #else + throw OAuthError.passkeyUnavailable + #endif + } + private func fetchUserInfo() async throws { guard let accessToken = tokenStorage.getAccessToken(), let userInfoURL = configuration.userInfoURL @@ -230,6 +502,35 @@ public final class OAuthManager: Sendable { } } + private func sendTokenRequest(_ request: URLRequest) async throws -> TokenResponse { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OAuthError.tokenExchangeFailed("Invalid response") + } + + guard httpResponse.statusCode == 200 else { + let bodyString = String(data: data, encoding: .utf8) ?? "" + let url = request.url?.absoluteString ?? "" + logger.error("Token request failed: \(request.httpMethod ?? "?") \(url) -> HTTP \(httpResponse.statusCode); body: \(bodyString)") + throw OAuthError.tokenExchangeFailed("HTTP \(httpResponse.statusCode): \(bodyString)") + } + + return try JSONDecoder().decode(TokenResponse.self, from: data) + } + + private func formEncodedBody(_ values: [String: String]) -> Data? { + let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~") + return values + .map { key, value in + let encodedKey = key.addingPercentEncoding(withAllowedCharacters: allowed) ?? key + let encodedValue = value.addingPercentEncoding(withAllowedCharacters: allowed) ?? value + return "\(encodedKey)=\(encodedValue)" + } + .joined(separator: "&") + .data(using: .utf8) + } + private func buildAuthorizationURL(codeChallenge: String) -> URL? { guard var components = URLComponents(string: configuration.issuer + configuration.authorizePath) else { return nil @@ -289,3 +590,226 @@ private struct TokenResponse: Decodable { case tokenType = "token_type" } } + +private struct PasskeyChallengeRequest: Encodable { + let clientID: String + let username: String? + + enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case username + } +} + +private struct SignupRequest: Encodable { + let clientID: String + let username: String + let password: String + let name: String? + let scope: String + + enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case username + case password + case name + case scope + } +} + +private struct PasskeyChallengeResponse: Decodable { + let challenge: String + let requestID: String? + let relyingPartyIdentifier: String? + let allowedCredentialIDs: [String] + + enum CodingKeys: String, CodingKey { + case challenge + case requestID + case requestId + case relyingPartyIdentifier + case relyingPartyId + case rpId + case allowedCredentialIDs + case allowedCredentials + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + challenge = try container.decode(String.self, forKey: .challenge) + requestID = try container.decodeIfPresent(String.self, forKey: .requestID) + ?? container.decodeIfPresent(String.self, forKey: .requestId) + relyingPartyIdentifier = try container.decodeIfPresent(String.self, forKey: .relyingPartyIdentifier) + ?? container.decodeIfPresent(String.self, forKey: .relyingPartyId) + ?? container.decodeIfPresent(String.self, forKey: .rpId) + + if let ids = try container.decodeIfPresent([String].self, forKey: .allowedCredentialIDs) { + allowedCredentialIDs = ids + } else if let credentials = try container.decodeIfPresent([AllowedCredential].self, forKey: .allowedCredentials) { + allowedCredentialIDs = credentials.map(\.id) + } else { + allowedCredentialIDs = [] + } + } + + private struct AllowedCredential: Decodable { + let id: String + } +} + +private struct PasskeyRegistrationChallengeRequest: Encodable { + let clientID: String + let username: String + let name: String? + + enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case username + case name + } +} + +private struct PasskeyRegistrationChallengeResponse: Decodable { + let challenge: String + let userID: String + let username: String? + let requestID: String? + let relyingPartyIdentifier: String? + + enum CodingKeys: String, CodingKey { + case challenge + case userID + case userId + case username + case userName + case requestID + case requestId + case relyingPartyIdentifier + case relyingPartyId + case rpId + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + challenge = try container.decode(String.self, forKey: .challenge) + userID = try container.decodeIfPresent(String.self, forKey: .userID) + ?? container.decode(String.self, forKey: .userId) + username = try container.decodeIfPresent(String.self, forKey: .username) + ?? container.decodeIfPresent(String.self, forKey: .userName) + requestID = try container.decodeIfPresent(String.self, forKey: .requestID) + ?? container.decodeIfPresent(String.self, forKey: .requestId) + relyingPartyIdentifier = try container.decodeIfPresent(String.self, forKey: .relyingPartyIdentifier) + ?? container.decodeIfPresent(String.self, forKey: .relyingPartyId) + ?? container.decodeIfPresent(String.self, forKey: .rpId) + } +} + +private struct PasskeyVerificationRequest: Encodable { + let clientID: String + let requestID: String? + let username: String? + let id: String + let rawID: String + let type: String + let response: Response + let clientExtensionResults: [String: String] + + enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case requestID = "request_id" + case username + case id + case rawID = "rawId" + case type + case response + case clientExtensionResults + } + + struct Response: Encodable { + let clientDataJSON: String + let authenticatorData: String + let signature: String + let userHandle: String + } +} + +private struct PasskeyRegistrationVerificationRequest: Encodable { + let clientID: String + let requestID: String? + let username: String + let name: String? + let id: String + let rawID: String + let type: String + let response: Response + let clientExtensionResults: [String: String] + + enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case requestID = "request_id" + case username + case name + case id + case rawID = "rawId" + case type + case response + case clientExtensionResults + } + + struct Response: Encodable { + let clientDataJSON: String + let attestationObject: String? + } +} + +#if os(macOS) +private extension PasskeyVerificationRequest { + init(clientID: String, requestID: String?, username: String?, assertion: PasskeyAssertion) { + let credentialID = Base64URL.encode(assertion.credentialID) + self.init( + clientID: clientID, + requestID: requestID, + username: username, + id: credentialID, + rawID: credentialID, + type: "public-key", + response: Response( + clientDataJSON: Base64URL.encode(assertion.rawClientDataJSON), + authenticatorData: Base64URL.encode(assertion.rawAuthenticatorData), + signature: Base64URL.encode(assertion.signature), + userHandle: Base64URL.encode(assertion.userID) + ), + clientExtensionResults: [:] + ) + } +} + +private extension PasskeyRegistrationVerificationRequest { + init(clientID: String, requestID: String?, username: String, name: String?, registration: PasskeyRegistration) { + let credentialID = Base64URL.encode(registration.credentialID) + self.init( + clientID: clientID, + requestID: requestID, + username: username, + name: name, + id: credentialID, + rawID: credentialID, + type: "public-key", + response: Response( + clientDataJSON: Base64URL.encode(registration.rawClientDataJSON), + attestationObject: registration.rawAttestationObject.map(Base64URL.encode) + ), + clientExtensionResults: [:] + ) + } +} +#endif + +private extension Optional where Wrapped == String { + var nilIfBlank: String? { + guard let value = self?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + return value + } +} diff --git a/Sources/RxAuthSwift/Platform/MacOSPasskeyAuthenticator.swift b/Sources/RxAuthSwift/Platform/MacOSPasskeyAuthenticator.swift new file mode 100644 index 0000000..6697264 --- /dev/null +++ b/Sources/RxAuthSwift/Platform/MacOSPasskeyAuthenticator.swift @@ -0,0 +1,187 @@ +#if canImport(AuthenticationServices) && os(macOS) +import AppKit +import AuthenticationServices +import Foundation + +@MainActor +final class MacOSPasskeyAuthenticator: NSObject { + private var continuation: CheckedContinuation? + private var retainedSelf: MacOSPasskeyAuthenticator? + + func authenticate( + relyingPartyIdentifier: String, + challenge: Data, + allowedCredentialIDs: [Data] = [] + ) async throws -> PasskeyAssertion { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + self.retainedSelf = self + + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: relyingPartyIdentifier + ) + let request = provider.createCredentialAssertionRequest(challenge: challenge) + request.allowedCredentials = allowedCredentialIDs.map { + ASAuthorizationPlatformPublicKeyCredentialDescriptor(credentialID: $0) + } + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + } + + private func complete(with result: Result) { + guard let continuation else { return } + self.continuation = nil + self.retainedSelf = nil + + switch result { + case .success(let assertion): + continuation.resume(returning: assertion) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} + +extension MacOSPasskeyAuthenticator: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialAssertion else { + complete(with: .failure(OAuthError.authenticationFailed("Unexpected passkey credential"))) + return + } + + complete(with: .success(PasskeyAssertion( + credentialID: credential.credentialID, + rawClientDataJSON: credential.rawClientDataJSON, + rawAuthenticatorData: credential.rawAuthenticatorData, + signature: credential.signature, + userID: credential.userID + ))) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + let nsError = error as NSError + if nsError.domain == ASAuthorizationError.errorDomain, + ASAuthorizationError.Code(rawValue: nsError.code) == .canceled { + complete(with: .failure(OAuthError.cancelled)) + } else { + complete(with: .failure(OAuthError.authenticationFailed(error.localizedDescription))) + } + } +} + +extension MacOSPasskeyAuthenticator: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + NSApplication.shared.keyWindow + ?? NSApplication.shared.mainWindow + ?? ASPresentationAnchor() + } +} + +struct PasskeyAssertion { + let credentialID: Data + let rawClientDataJSON: Data + let rawAuthenticatorData: Data + let signature: Data + let userID: Data +} + +@MainActor +final class MacOSPasskeyRegistrationAuthenticator: NSObject { + private var continuation: CheckedContinuation? + private var retainedSelf: MacOSPasskeyRegistrationAuthenticator? + + func register( + relyingPartyIdentifier: String, + challenge: Data, + name: String, + userID: Data + ) async throws -> PasskeyRegistration { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + self.retainedSelf = self + + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: relyingPartyIdentifier + ) + let request = provider.createCredentialRegistrationRequest( + challenge: challenge, + name: name, + userID: userID + ) + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + } + + private func complete(with result: Result) { + guard let continuation else { return } + self.continuation = nil + self.retainedSelf = nil + + switch result { + case .success(let registration): + continuation.resume(returning: registration) + case .failure(let error): + continuation.resume(throwing: error) + } + } +} + +extension MacOSPasskeyRegistrationAuthenticator: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration else { + complete(with: .failure(OAuthError.authenticationFailed("Unexpected passkey registration credential"))) + return + } + + complete(with: .success(PasskeyRegistration( + credentialID: credential.credentialID, + rawClientDataJSON: credential.rawClientDataJSON, + rawAttestationObject: credential.rawAttestationObject + ))) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + let nsError = error as NSError + if nsError.domain == ASAuthorizationError.errorDomain, + ASAuthorizationError.Code(rawValue: nsError.code) == .canceled { + complete(with: .failure(OAuthError.cancelled)) + } else { + complete(with: .failure(OAuthError.authenticationFailed(error.localizedDescription))) + } + } +} + +extension MacOSPasskeyRegistrationAuthenticator: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + NSApplication.shared.keyWindow + ?? NSApplication.shared.mainWindow + ?? ASPresentationAnchor() + } +} + +struct PasskeyRegistration { + let credentialID: Data + let rawClientDataJSON: Data + let rawAttestationObject: Data? +} +#endif diff --git a/Sources/RxAuthSwift/RxAuthConfiguration.swift b/Sources/RxAuthSwift/RxAuthConfiguration.swift index 15b1c33..a922864 100644 --- a/Sources/RxAuthSwift/RxAuthConfiguration.swift +++ b/Sources/RxAuthSwift/RxAuthConfiguration.swift @@ -9,6 +9,13 @@ public struct RxAuthConfiguration: Sendable { public let authorizePath: String public let tokenPath: String public let userInfoPath: String + public let nativePasswordTokenPath: String? + public let nativeSignupPath: String? + public let passkeyChallengePath: String? + public let passkeyVerificationPath: String? + public let passkeyRegistrationChallengePath: String? + public let passkeyRegistrationVerificationPath: String? + public let passkeyRelyingPartyIdentifier: String? public let keychainServiceName: String @@ -20,6 +27,13 @@ public struct RxAuthConfiguration: Sendable { authorizePath: String = "/api/oauth/authorize", tokenPath: String = "/api/oauth/token", userInfoPath: String = "/api/oauth/userinfo", + nativePasswordTokenPath: String? = nil, + nativeSignupPath: String? = "/api/oauth/signup", + passkeyChallengePath: String? = nil, + passkeyVerificationPath: String? = nil, + passkeyRegistrationChallengePath: String? = nil, + passkeyRegistrationVerificationPath: String? = nil, + passkeyRelyingPartyIdentifier: String? = nil, keychainServiceName: String = "com.rxlab.RxAuthSwift" ) { self.issuer = issuer @@ -29,6 +43,13 @@ public struct RxAuthConfiguration: Sendable { self.authorizePath = authorizePath self.tokenPath = tokenPath self.userInfoPath = userInfoPath + self.nativePasswordTokenPath = nativePasswordTokenPath + self.nativeSignupPath = nativeSignupPath + self.passkeyChallengePath = passkeyChallengePath + self.passkeyVerificationPath = passkeyVerificationPath + self.passkeyRegistrationChallengePath = passkeyRegistrationChallengePath + self.passkeyRegistrationVerificationPath = passkeyRegistrationVerificationPath + self.passkeyRelyingPartyIdentifier = passkeyRelyingPartyIdentifier self.keychainServiceName = keychainServiceName } @@ -44,6 +65,35 @@ public struct RxAuthConfiguration: Sendable { URL(string: issuer + userInfoPath) } + public var nativePasswordTokenURL: URL? { + URL(string: issuer + (nativePasswordTokenPath ?? tokenPath)) + } + + public var nativeSignupURL: URL? { + guard let nativeSignupPath else { return nil } + return URL(string: issuer + nativeSignupPath) + } + + public var passkeyChallengeURL: URL? { + guard let passkeyChallengePath else { return nil } + return URL(string: issuer + passkeyChallengePath) + } + + public var passkeyVerificationURL: URL? { + guard let passkeyVerificationPath else { return nil } + return URL(string: issuer + passkeyVerificationPath) + } + + public var passkeyRegistrationChallengeURL: URL? { + guard let passkeyRegistrationChallengePath else { return nil } + return URL(string: issuer + passkeyRegistrationChallengePath) + } + + public var passkeyRegistrationVerificationURL: URL? { + guard let passkeyRegistrationVerificationPath else { return nil } + return URL(string: issuer + passkeyRegistrationVerificationPath) + } + public var redirectScheme: String? { URL(string: redirectURI)?.scheme } diff --git a/Sources/RxAuthSwiftUI/Components/PrimaryAuthButton.swift b/Sources/RxAuthSwiftUI/Components/PrimaryAuthButton.swift index 071460a..54fb115 100644 --- a/Sources/RxAuthSwiftUI/Components/PrimaryAuthButton.swift +++ b/Sources/RxAuthSwiftUI/Components/PrimaryAuthButton.swift @@ -1,18 +1,21 @@ import SwiftUI public struct PrimaryAuthButton: View { - let title: String + let title: LocalizedStringKey + let loadingTitle: LocalizedStringKey let isLoading: Bool let accentColor: Color let action: () -> Void public init( - title: String = "Sign In", + title: LocalizedStringKey = "Sign In", + loadingTitle: LocalizedStringKey = "Signing In...", isLoading: Bool = false, accentColor: Color = .blue, action: @escaping () -> Void ) { self.title = title + self.loadingTitle = loadingTitle self.isLoading = isLoading self.accentColor = accentColor self.action = action @@ -34,7 +37,7 @@ public struct PrimaryAuthButton: View { } else { Image(systemName: "arrow.right.circle.fill") } - Text(isLoading ? "Signing In..." : title) + Text(isLoading ? loadingTitle : title) .fontWeight(.semibold) } .frame(maxWidth: .infinity) diff --git a/Sources/RxAuthSwiftUI/RxSignInAppearance.swift b/Sources/RxAuthSwiftUI/RxSignInAppearance.swift index b56921b..19c5d05 100644 --- a/Sources/RxAuthSwiftUI/RxSignInAppearance.swift +++ b/Sources/RxAuthSwiftUI/RxSignInAppearance.swift @@ -1,18 +1,30 @@ import SwiftUI -public struct RxSignInAppearance: Sendable { +public struct RxSignInAppearance: @unchecked Sendable { public var icon: SignInIcon - public var title: String - public var subtitle: String - public var signInButtonTitle: String + public var title: LocalizedStringKey + public var subtitle: LocalizedStringKey + public var signInButtonTitle: LocalizedStringKey + public var signUpButtonTitle: LocalizedStringKey + public var usernamePlaceholder: LocalizedStringKey + public var passwordPlaceholder: LocalizedStringKey + public var namePlaceholder: LocalizedStringKey + public var passkeyButtonTitle: LocalizedStringKey + public var passkeySignupButtonTitle: LocalizedStringKey public var accentColor: Color public var secondaryColor: Color public init( icon: SignInIcon = .systemImage("lock.shield.fill"), - title: String = "Welcome", - subtitle: String = "Sign in to continue", - signInButtonTitle: String = "Sign In", + title: LocalizedStringKey = "Welcome", + subtitle: LocalizedStringKey = "Sign in to continue", + signInButtonTitle: LocalizedStringKey = "Sign In", + signUpButtonTitle: LocalizedStringKey = "Create Account", + usernamePlaceholder: LocalizedStringKey = "Username", + passwordPlaceholder: LocalizedStringKey = "Password", + namePlaceholder: LocalizedStringKey = "Name", + passkeyButtonTitle: LocalizedStringKey = "Continue with Passkey", + passkeySignupButtonTitle: LocalizedStringKey = "Create Passkey Account", accentColor: Color = .blue, secondaryColor: Color = .purple ) { @@ -20,6 +32,12 @@ public struct RxSignInAppearance: Sendable { self.title = title self.subtitle = subtitle self.signInButtonTitle = signInButtonTitle + self.signUpButtonTitle = signUpButtonTitle + self.usernamePlaceholder = usernamePlaceholder + self.passwordPlaceholder = passwordPlaceholder + self.namePlaceholder = namePlaceholder + self.passkeyButtonTitle = passkeyButtonTitle + self.passkeySignupButtonTitle = passkeySignupButtonTitle self.accentColor = accentColor self.secondaryColor = secondaryColor } diff --git a/Sources/RxAuthSwiftUI/RxSignInView.swift b/Sources/RxAuthSwiftUI/RxSignInView.swift index 9244d23..713264a 100644 --- a/Sources/RxAuthSwiftUI/RxSignInView.swift +++ b/Sources/RxAuthSwiftUI/RxSignInView.swift @@ -3,6 +3,15 @@ import SwiftUI public struct RxSignInView: View { @Bindable private var manager: OAuthManager + @State private var mode: NativeAuthMode = .signIn + @State private var username = "" + @State private var password = "" + @State private var name = "" + @State private var isAppearing = false + #if os(macOS) + @FocusState private var focusedField: CredentialField? + @Namespace private var glassNamespace + #endif private let appearance: RxSignInAppearance private let customHeader: Header? private let onAuthSuccess: (() -> Void)? @@ -41,53 +50,142 @@ public struct RxSignInView: View { public var body: some View { ZStack { + #if os(macOS) AnimatedGradientBackground( accentColor: appearance.accentColor, secondaryColor: appearance.secondaryColor ) + macOSContent + #else + AnimatedGradientBackground( + accentColor: appearance.accentColor, + secondaryColor: appearance.secondaryColor + ) + iOSContent + #endif + } + .animation(.default, value: manager.errorMessage) + } - VStack(spacing: 32) { - Spacer() + #if os(macOS) + private var macOSContent: some View { + ZStack(alignment: .top) { + ScrollView { + VStack(spacing: 0) { + Spacer(minLength: 32) - // Header area - if let customHeader { - customHeader - } else { - defaultHeader + GlassEffectContainer(spacing: 20) { + VStack(spacing: 24) { + if let customHeader { + customHeader + .scaleEffect(isAppearing ? 1 : 0.9) + .opacity(isAppearing ? 1 : 0) + } else { + compactHeader + .scaleEffect(isAppearing ? 1 : 0.9) + .opacity(isAppearing ? 1 : 0) + } + + nativeCredentialForm + } + .padding(32) + .frame(minWidth: 360, maxWidth: 400, minHeight: 480, alignment: .top) + } + .padding(.horizontal, 32) + .offset(y: isAppearing ? 0 : 20) + .animation(.spring(response: 0.6, dampingFraction: 0.8), value: isAppearing) + + Spacer(minLength: 32) } + .frame(maxWidth: .infinity, minHeight: 580) + } + .onAppear { + withAnimation(.easeOut(duration: 0.5)) { + isAppearing = true + } + } - Spacer() + // Error overlay + if let errorMessage = manager.errorMessage { + errorOverlay(message: errorMessage) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + } + + private func errorOverlay(message: String) -> some View { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.red) - // Error banner - if let errorMessage = manager.errorMessage { - AuthErrorBanner(message: errorMessage) - .padding(.horizontal, 24) - .transition(.move(edge: .top).combined(with: .opacity)) + Text(message) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(2) + + Spacer() + + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + manager.clearError() } + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss error") + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: 400) + .glassEffect(.regular.tint(.red.opacity(0.3)), in: .rect(cornerRadius: 12)) + .glassEffectID("error", in: glassNamespace) + .padding(.top, 16) + .padding(.horizontal, 32) + } + #else + private var iOSContent: some View { + VStack(spacing: 32) { + Spacer() + if let customHeader { + customHeader + } else { + defaultHeader + } + Spacer() - // Sign-in buttons - VStack(spacing: 12) { - PrimaryAuthButton( - title: appearance.signInButtonTitle, - isLoading: manager.isAuthenticating, - accentColor: appearance.accentColor - ) { - Task { - do { - try await manager.authenticate() - onAuthSuccess?() - } catch { - onAuthFailed?(error) - } + if let errorMessage = manager.errorMessage { + AuthErrorBanner(message: errorMessage) + .padding(.horizontal, 24) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + VStack(spacing: 12) { + PrimaryAuthButton( + title: appearance.signInButtonTitle, + isLoading: manager.isAuthenticating, + accentColor: appearance.accentColor + ) { + Task { + do { + try await manager.authenticate() + onAuthSuccess?() + } catch { + onAuthFailed?(error) } } } - .padding(.horizontal, 32) - .padding(.bottom, 48) } + .padding(.horizontal, 32) + .padding(.bottom, 48) } - .animation(.default, value: manager.errorMessage) } + #endif @ViewBuilder private var defaultHeader: some View { @@ -111,7 +209,333 @@ public struct RxSignInView: View { .padding(.horizontal, 32) } } + + #if os(macOS) + private var compactHeader: some View { + VStack(spacing: 14) { + compactIcon + .glassEffectID("header-icon", in: glassNamespace) + + VStack(spacing: 6) { + Text(appearance.title) + .font(.title2.weight(.semibold)) + .foregroundStyle(.primary) + + Text(appearance.subtitle) + .font(.callout) + .foregroundStyle(.secondary) + } + .multilineTextAlignment(.center) + } + } + + @ViewBuilder + private var compactIcon: some View { + switch appearance.icon { + case .systemImage(let name): + Image(systemName: name) + .font(.system(size: 32, weight: .semibold)) + .foregroundStyle(appearance.accentColor) + .frame(width: 64, height: 64) + .glassEffect(.regular.tint(appearance.accentColor.opacity(0.3)), in: .rect(cornerRadius: 18)) + case .image(let image): + image + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + case .assetImage(let name, let bundle): + Image(name, bundle: bundle) + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + case .none: + EmptyView() + } + } + + private var nativeCredentialForm: some View { + VStack(spacing: 18) { + modeTogglePill + + GlassEffectContainer(spacing: 12) { + VStack(spacing: 12) { + if mode == .signUp { + credentialField( + systemImage: "person.text.rectangle", + placeholder: appearance.namePlaceholder, + text: $name, + field: .name, + accessibilityIdentifier: "name-field" + ) + .glassEffectTransition(.matchedGeometry) + .transition(.opacity.combined(with: .scale(scale: 0.95)).combined(with: .move(edge: .top))) + } + + credentialField( + systemImage: "person.crop.circle", + placeholder: appearance.usernamePlaceholder, + text: $username, + field: .username, + accessibilityIdentifier: "username-field" + ) + + secureCredentialField( + systemImage: "lock.fill", + placeholder: appearance.passwordPlaceholder, + text: $password + ) + } + } + .animation(.spring(response: 0.4, dampingFraction: 0.85), value: mode) + + GlassEffectContainer(spacing: 16) { + VStack(spacing: 14) { + primaryButton + .keyboardShortcut(.defaultAction) + + if showsPasskeyButton { + orDivider + + passkeyButton + .glassEffectTransition(.materialize) + } + } + } + .padding(.top, 6) + } + } + + private var modeTogglePill: some View { + HStack(spacing: 0) { + modeTab(.signIn, label: "Sign In") + modeTab(.signUp, label: "Sign Up") + } + .padding(4) + .glassEffect(in: .capsule) + .glassEffectID("mode-toggle-container", in: glassNamespace) + .accessibilityIdentifier("auth-mode-picker") + .disabled(manager.isAuthenticating) + } + + private func modeTab(_ value: NativeAuthMode, label: String) -> some View { + let isSelected = mode == value + return Button { + withAnimation(.spring(response: 0.4, dampingFraction: 0.8)) { + mode = value + } + } label: { + Text(label) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(isSelected ? Color.white : Color.secondary) + .frame(maxWidth: .infinity) + .padding(.vertical, 10) + .padding(.horizontal, 20) + .background { + if isSelected { + Capsule() + .fill(appearance.accentColor) + } + } + .contentShape(Capsule()) + } + .buttonStyle(.plain) + } + + private var primaryButton: some View { + Button { + submitPrimary() + } label: { + ZStack { + Text(mode == .signIn ? appearance.signInButtonTitle : appearance.signUpButtonTitle) + .font(.system(size: 14, weight: .semibold)) + .opacity(manager.isAuthenticating ? 0 : 1) + + if manager.isAuthenticating { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .glassEffect(.regular.tint(appearance.accentColor).interactive(), in: .rect(cornerRadius: 12)) + } + .buttonStyle(.plain) + .glassEffectID("primary-button", in: glassNamespace) + .disabled(manager.isAuthenticating) + .opacity(manager.isAuthenticating ? 0.7 : 1) + .accessibilityIdentifier("sign-in-button") + } + + private var orDivider: some View { + HStack(spacing: 10) { + Rectangle() + .fill(.white.opacity(0.10)) + .frame(height: 0.5) + Text("or") + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + .textCase(.uppercase) + Rectangle() + .fill(.white.opacity(0.10)) + .frame(height: 0.5) + } + } + + private var passkeyButton: some View { + Button { + submitPasskeyAction() + } label: { + HStack(spacing: 8) { + Image(systemName: "person.badge.key.fill") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(appearance.accentColor) + Text(mode == .signIn ? appearance.passkeyButtonTitle : appearance.passkeySignupButtonTitle) + .font(.system(size: 14, weight: .medium)) + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 12)) + } + .buttonStyle(.plain) + .glassEffectID("passkey-button", in: glassNamespace) + .disabled(manager.isAuthenticating) + .accessibilityIdentifier("passkey-sign-in-button") + } + + private func credentialField( + systemImage: String, + placeholder: LocalizedStringKey, + text: Binding, + field: CredentialField, + accessibilityIdentifier: String + ) -> some View { + let isFocused = focusedField == field + return HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(isFocused ? appearance.accentColor : Color.secondary) + .frame(width: 20) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isFocused) + .scaleEffect(isFocused ? 1.1 : 1.0) + + TextField(placeholder, text: text) + .textFieldStyle(.plain) + .font(.system(size: 14)) + .focused($focusedField, equals: field) + .submitLabel(.next) + .onSubmit(advanceFocus) + .accessibilityIdentifier(accessibilityIdentifier) + } + .padding(.horizontal, 14) + .frame(height: 44) + .glassEffect( + isFocused ? .regular.tint(appearance.accentColor.opacity(0.2)).interactive() : .regular.interactive(), + in: .rect(cornerRadius: 12) + ) + .glassEffectID("field-\(field)", in: glassNamespace) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isFocused) + } + + private func secureCredentialField( + systemImage: String, + placeholder: LocalizedStringKey, + text: Binding + ) -> some View { + let isFocused = focusedField == .password + return HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(isFocused ? appearance.accentColor : Color.secondary) + .frame(width: 20) + .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isFocused) + .scaleEffect(isFocused ? 1.1 : 1.0) + + SecureField(placeholder, text: text) + .textFieldStyle(.plain) + .font(.system(size: 14)) + .focused($focusedField, equals: .password) + .onSubmit(submitPrimary) + .accessibilityIdentifier("password-field") + } + .padding(.horizontal, 14) + .frame(height: 44) + .glassEffect( + isFocused ? .regular.tint(appearance.accentColor.opacity(0.2)).interactive() : .regular.interactive(), + in: .rect(cornerRadius: 12) + ) + .glassEffectID("field-password", in: glassNamespace) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isFocused) + } + + private func advanceFocus() { + switch focusedField { + case .name: + focusedField = .username + case .username: + focusedField = .password + case .password, .none: + submitPrimary() + } + } + + private var showsPasskeyButton: Bool { + switch mode { + case .signIn: + return manager.supportsPasskeyAuthentication + case .signUp: + return manager.supportsPasskeyRegistration + } + } + + private func submitPrimary() { + Task { + do { + switch mode { + case .signIn: + try await manager.authenticate(username: username, password: password) + case .signUp: + try await manager.signUp(username: username, password: password, name: name) + } + onAuthSuccess?() + } catch { + onAuthFailed?(error) + } + } + } + + private func submitPasskeyAction() { + Task { + do { + switch mode { + case .signIn: + try await manager.authenticateWithPasskey(username: username) + case .signUp: + try await manager.signUpWithPasskey(username: username, name: name) + } + onAuthSuccess?() + } catch { + onAuthFailed?(error) + } + } + } + #endif +} + +private enum NativeAuthMode: Hashable { + case signIn + case signUp +} + +#if os(macOS) +private enum CredentialField: Hashable { + case name + case username + case password } +#endif // MARK: - Previews diff --git a/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift b/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift index 17d7668..8dba555 100644 --- a/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift +++ b/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift @@ -315,4 +315,180 @@ struct OAuthManagerTokenCleanupTests { #expect(manager.authState == .authenticated) #expect(manager.currentUser?.id == "user-1") } + + // MARK: - Native Credential Tests + + @Test @MainActor func authenticateWithUsernamePasswordPostsDirectTokenRequest() async throws { + let requestBodies = LockedRequestBodies() + MockURLProtocol.requestHandler = { request in + if request.url?.path == "/api/oauth/token" { + requestBodies.append(requestBodyString(from: request)) + let tokenJSON = #"{"access_token":"native-access","refresh_token":"native-refresh","expires_in":3600,"token_type":"Bearer"}"# + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, tokenJSON.data(using: .utf8)!) + } + + let userInfoJSON = #"{"id":"user-1","name":"Native User","email":"native@example.com"}"# + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, userInfoJSON.data(using: .utf8)!) + } + + URLProtocol.registerClass(MockURLProtocol.self) + defer { URLProtocol.unregisterClass(MockURLProtocol.self) } + + let storage = InMemoryTokenStorage() + let manager = OAuthManager( + configuration: makeConfig(), + tokenStorage: storage + ) + + try await manager.authenticate( + username: "qa+user@example.com", + password: "p@ss word" + ) + + #expect(manager.authState == .authenticated) + #expect(manager.currentUser?.email == "native@example.com") + #expect(storage.getAccessToken() == "native-access") + #expect(storage.getRefreshToken() == "native-refresh") + + let body = requestBodies.values.first ?? "" + #expect(body.contains("grant_type=password")) + #expect(body.contains("username=qa%2Buser%40example.com")) + #expect(body.contains("password=p%40ss%20word")) + #expect(body.contains("client_id=test-client")) + } + + @Test @MainActor func authenticateWithUsernamePasswordRejectsBlankCredentials() async throws { + let manager = OAuthManager( + configuration: makeConfig(), + tokenStorage: InMemoryTokenStorage() + ) + + do { + try await manager.authenticate(username: " ", password: "") + Issue.record("Expected invalid credentials error") + } catch OAuthError.invalidCredentials { + #expect(manager.errorMessage == OAuthError.invalidCredentials.localizedDescription) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test @MainActor func signUpPostsNativeSignupRequest() async throws { + let requestBodies = LockedRequestBodies() + MockURLProtocol.requestHandler = { request in + if request.url?.path == "/api/oauth/signup" { + requestBodies.append(requestBodyString(from: request)) + let tokenJSON = #"{"access_token":"signup-access","refresh_token":"signup-refresh","expires_in":3600,"token_type":"Bearer"}"# + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, tokenJSON.data(using: .utf8)!) + } + + let userInfoJSON = #"{"id":"user-2","name":"Signup User","email":"signup@example.com"}"# + let response = HTTPURLResponse( + url: request.url!, + statusCode: 200, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, userInfoJSON.data(using: .utf8)!) + } + + URLProtocol.registerClass(MockURLProtocol.self) + defer { URLProtocol.unregisterClass(MockURLProtocol.self) } + + let storage = InMemoryTokenStorage() + let manager = OAuthManager( + configuration: makeConfig(), + tokenStorage: storage + ) + + try await manager.signUp( + username: "signup@example.com", + password: "create-password", + name: "Signup User" + ) + + #expect(manager.authState == .authenticated) + #expect(manager.currentUser?.id == "user-2") + #expect(storage.getAccessToken() == "signup-access") + + let body = requestBodies.values.first ?? "" + #expect(body.contains(#""client_id":"test-client""#)) + #expect(body.contains(#""username":"signup@example.com""#)) + #expect(body.contains(#""password":"create-password""#)) + #expect(body.contains(#""name":"Signup User""#)) + } + + @Test @MainActor func signUpRejectsBlankCredentials() async throws { + let manager = OAuthManager( + configuration: makeConfig(), + tokenStorage: InMemoryTokenStorage() + ) + + do { + try await manager.signUp(username: "", password: "", name: nil) + Issue.record("Expected invalid signup details error") + } catch OAuthError.invalidSignupDetails { + #expect(manager.errorMessage == OAuthError.invalidSignupDetails.localizedDescription) + } catch { + Issue.record("Unexpected error: \(error)") + } + } +} + +private final class LockedRequestBodies: @unchecked Sendable { + private let lock = NSLock() + private var _values: [String] = [] + + var values: [String] { + lock.lock() + defer { lock.unlock() } + return _values + } + + func append(_ value: String) { + lock.lock() + defer { lock.unlock() } + _values.append(value) + } +} + +private func requestBodyString(from request: URLRequest) -> String { + if let httpBody = request.httpBody { + return String(data: httpBody, encoding: .utf8) ?? "" + } + + guard let stream = request.httpBodyStream else { return "" } + stream.open() + defer { stream.close() } + + var data = Data() + let bufferSize = 1024 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { buffer.deallocate() } + + while stream.hasBytesAvailable { + let read = stream.read(buffer, maxLength: bufferSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + + return String(data: data, encoding: .utf8) ?? "" } diff --git a/Tests/RxAuthSwiftTests/RxAuthSwiftTests.swift b/Tests/RxAuthSwiftTests/RxAuthSwiftTests.swift index d66378e..f7b95b4 100644 --- a/Tests/RxAuthSwiftTests/RxAuthSwiftTests.swift +++ b/Tests/RxAuthSwiftTests/RxAuthSwiftTests.swift @@ -88,6 +88,29 @@ struct RxAuthConfigurationTests { #expect(config.userInfoPath == "/oauth/me") } + @Test func nativeCredentialEndpoints() { + let config = RxAuthConfiguration( + issuer: "https://auth.example.com", + clientID: "client-123", + redirectURI: "myapp://callback", + nativePasswordTokenPath: "/api/native/token", + nativeSignupPath: "/api/native/signup", + passkeyChallengePath: "/api/passkeys/options", + passkeyVerificationPath: "/api/passkeys/verify", + passkeyRegistrationChallengePath: "/api/passkeys/register/options", + passkeyRegistrationVerificationPath: "/api/passkeys/register/verify", + passkeyRelyingPartyIdentifier: "auth.example.com" + ) + + #expect(config.nativePasswordTokenURL?.absoluteString == "https://auth.example.com/api/native/token") + #expect(config.nativeSignupURL?.absoluteString == "https://auth.example.com/api/native/signup") + #expect(config.passkeyChallengeURL?.absoluteString == "https://auth.example.com/api/passkeys/options") + #expect(config.passkeyVerificationURL?.absoluteString == "https://auth.example.com/api/passkeys/verify") + #expect(config.passkeyRegistrationChallengeURL?.absoluteString == "https://auth.example.com/api/passkeys/register/options") + #expect(config.passkeyRegistrationVerificationURL?.absoluteString == "https://auth.example.com/api/passkeys/register/verify") + #expect(config.passkeyRelyingPartyIdentifier == "auth.example.com") + } + @Test func urlConstruction() { let config = RxAuthConfiguration( issuer: "https://auth.example.com", diff --git a/test/test.xcodeproj/project.pbxproj b/test/test.xcodeproj/project.pbxproj index e752fb7..5f73c48 100644 --- a/test/test.xcodeproj/project.pbxproj +++ b/test/test.xcodeproj/project.pbxproj @@ -159,7 +159,7 @@ mainGroup = DF8439352F3C847500461950; minimizedProjectReferenceProxies = 1; packageReferences = ( - DF84396A2F3C848E00461950 /* XCLocalSwiftPackageReference "../../RxAuthSwift" */, + DF84396A2F3C848E00461950 /* XCLocalSwiftPackageReference ".." */, ); preferredProjectObjectVersion = 77; productRefGroup = DF84393F2F3C847500461950 /* Products */; @@ -341,6 +341,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = test/test.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -386,6 +387,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = test/test.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; @@ -503,9 +505,9 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - DF84396A2F3C848E00461950 /* XCLocalSwiftPackageReference "../../RxAuthSwift" */ = { + DF84396A2F3C848E00461950 /* XCLocalSwiftPackageReference ".." */ = { isa = XCLocalSwiftPackageReference; - relativePath = ../../RxAuthSwift; + relativePath = ..; }; /* End XCLocalSwiftPackageReference section */ diff --git a/test/test/test.entitlements b/test/test/test.entitlements new file mode 100644 index 0000000..8a65872 --- /dev/null +++ b/test/test/test.entitlements @@ -0,0 +1,11 @@ + + + + + com.apple.developer.associated-domains + + webcredentials:rxlab.app + webcredentials:rxlab.app?mode=developer + + + diff --git a/test/test/testApp.swift b/test/test/testApp.swift index cbdca8c..9b55887 100644 --- a/test/test/testApp.swift +++ b/test/test/testApp.swift @@ -16,7 +16,12 @@ struct testApp: App { let configuration = RxAuthConfiguration( issuer: "https://auth.rxlab.app", clientID: "client_8605760939b8494c8bfe29c77ae7ee7f", - redirectURI: "rxauth://callback" + redirectURI: "rxauth://callback", + passkeyChallengePath: "/api/auth/passkey/authenticate/options", + passkeyVerificationPath: "/api/auth/passkey/authenticate/verify", + passkeyRegistrationChallengePath: "/api/auth/passkey/register/options", + passkeyRegistrationVerificationPath: "/api/auth/passkey/register/verify", + passkeyRelyingPartyIdentifier: "rxlab.app" ) let resetAuth = ProcessInfo.processInfo.arguments.contains("--reset-auth") From 80a8193400fcd810409a04caef22f8781ff6c66a Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 18:01:42 +0800 Subject: [PATCH 2/7] feat(passkey): wire native macOS passkey sign-in/sign-up to /api/oauth/passkey routes Switches the macOS test app to the new rxlab-auth native passkey OAuth endpoints (/api/oauth/passkey/authenticate/{options,verify} and /register/{options,verify}) that mint OAuth access/refresh tokens, replacing the browser-cookie-only /api/auth/passkey routes. Reshapes the passkey verify request bodies to nest the WebAuthn credential under a `credential` field and use `session_id` (snake_case) instead of `request_id`, matching the new server contract. Extends the challenge-response decoders to accept the new `sessionId` field, nested `user`/`rp` objects, and the `allowCredentials` (vs `allowedCredentials`) array name. Narrows the test app's requested scopes to ["openid"] to match the client row's allowedScopes (read:profile / read:email can be granted later via admin). Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/RxAuthSwift/OAuthManager.swift | 235 +++++++++++++++++-------- test/test/ContentView.swift | 10 ++ test/test/testApp.swift | 9 +- 3 files changed, 172 insertions(+), 82 deletions(-) diff --git a/Sources/RxAuthSwift/OAuthManager.swift b/Sources/RxAuthSwift/OAuthManager.swift index f9a14fe..6ee49e7 100644 --- a/Sources/RxAuthSwift/OAuthManager.swift +++ b/Sources/RxAuthSwift/OAuthManager.swift @@ -379,8 +379,8 @@ public final class OAuthManager: Sendable { verificationRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") verificationRequest.httpBody = try JSONEncoder().encode(PasskeyVerificationRequest( clientID: configuration.clientID, - requestID: options.requestID, - username: username.nilIfBlank, + sessionID: options.sessionID, + scope: configuration.scopes.joined(separator: " "), assertion: assertion )) @@ -453,9 +453,8 @@ public final class OAuthManager: Sendable { verificationRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") verificationRequest.httpBody = try JSONEncoder().encode(PasskeyRegistrationVerificationRequest( clientID: configuration.clientID, - requestID: options.requestID, - username: trimmedUsername, - name: name.nilIfBlank, + sessionID: options.sessionID, + scope: configuration.scopes.joined(separator: " "), registration: registration )) @@ -513,7 +512,11 @@ public final class OAuthManager: Sendable { let bodyString = String(data: data, encoding: .utf8) ?? "" let url = request.url?.absoluteString ?? "" logger.error("Token request failed: \(request.httpMethod ?? "?") \(url) -> HTTP \(httpResponse.statusCode); body: \(bodyString)") - throw OAuthError.tokenExchangeFailed("HTTP \(httpResponse.statusCode): \(bodyString)") + + if let parsed = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data) { + throw OAuthError.tokenExchangeFailed(parsed.userFacingMessage) + } + throw OAuthError.tokenExchangeFailed("HTTP \(httpResponse.statusCode)") } return try JSONDecoder().decode(TokenResponse.self, from: data) @@ -577,6 +580,26 @@ public final class OAuthManager: Sendable { // MARK: - Token Response +private struct OAuthErrorResponse: Decodable { + let error: String? + let errorDescription: String? + + enum CodingKeys: String, CodingKey { + case error + case errorDescription = "error_description" + } + + var userFacingMessage: String { + if let description = errorDescription, !description.isEmpty { + return description + } + if let error, !error.isEmpty { + return error.replacingOccurrences(of: "_", with: " ").capitalized + } + return "Authentication failed" + } +} + private struct TokenResponse: Decodable { let accessToken: String let refreshToken: String? @@ -619,12 +642,14 @@ private struct SignupRequest: Encodable { private struct PasskeyChallengeResponse: Decodable { let challenge: String - let requestID: String? + let sessionID: String? let relyingPartyIdentifier: String? let allowedCredentialIDs: [String] enum CodingKeys: String, CodingKey { case challenge + case sessionID + case sessionId case requestID case requestId case relyingPartyIdentifier @@ -632,12 +657,15 @@ private struct PasskeyChallengeResponse: Decodable { case rpId case allowedCredentialIDs case allowedCredentials + case allowCredentials } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) challenge = try container.decode(String.self, forKey: .challenge) - requestID = try container.decodeIfPresent(String.self, forKey: .requestID) + sessionID = try container.decodeIfPresent(String.self, forKey: .sessionID) + ?? container.decodeIfPresent(String.self, forKey: .sessionId) + ?? container.decodeIfPresent(String.self, forKey: .requestID) ?? container.decodeIfPresent(String.self, forKey: .requestId) relyingPartyIdentifier = try container.decodeIfPresent(String.self, forKey: .relyingPartyIdentifier) ?? container.decodeIfPresent(String.self, forKey: .relyingPartyId) @@ -647,6 +675,8 @@ private struct PasskeyChallengeResponse: Decodable { allowedCredentialIDs = ids } else if let credentials = try container.decodeIfPresent([AllowedCredential].self, forKey: .allowedCredentials) { allowedCredentialIDs = credentials.map(\.id) + } else if let credentials = try container.decodeIfPresent([AllowedCredential].self, forKey: .allowCredentials) { + allowedCredentialIDs = credentials.map(\.id) } else { allowedCredentialIDs = [] } @@ -673,133 +703,182 @@ private struct PasskeyRegistrationChallengeResponse: Decodable { let challenge: String let userID: String let username: String? - let requestID: String? + let sessionID: String? let relyingPartyIdentifier: String? enum CodingKeys: String, CodingKey { case challenge case userID case userId + case user case username case userName + case sessionID + case sessionId case requestID case requestId case relyingPartyIdentifier case relyingPartyId case rpId + case rp } init(from decoder: Decoder) throws { let container = try decoder.container(keyedBy: CodingKeys.self) challenge = try container.decode(String.self, forKey: .challenge) - userID = try container.decodeIfPresent(String.self, forKey: .userID) - ?? container.decode(String.self, forKey: .userId) - username = try container.decodeIfPresent(String.self, forKey: .username) - ?? container.decodeIfPresent(String.self, forKey: .userName) - requestID = try container.decodeIfPresent(String.self, forKey: .requestID) + + if let flatUserID = try container.decodeIfPresent(String.self, forKey: .userID) + ?? container.decodeIfPresent(String.self, forKey: .userId) + { + userID = flatUserID + username = try container.decodeIfPresent(String.self, forKey: .username) + ?? container.decodeIfPresent(String.self, forKey: .userName) + } else if let user = try container.decodeIfPresent(NestedUser.self, forKey: .user) { + userID = user.id + username = user.name ?? user.displayName + } else { + userID = try container.decode(String.self, forKey: .userID) + username = nil + } + + sessionID = try container.decodeIfPresent(String.self, forKey: .sessionID) + ?? container.decodeIfPresent(String.self, forKey: .sessionId) + ?? container.decodeIfPresent(String.self, forKey: .requestID) ?? container.decodeIfPresent(String.self, forKey: .requestId) - relyingPartyIdentifier = try container.decodeIfPresent(String.self, forKey: .relyingPartyIdentifier) + + if let rpID = try container.decodeIfPresent(String.self, forKey: .relyingPartyIdentifier) ?? container.decodeIfPresent(String.self, forKey: .relyingPartyId) ?? container.decodeIfPresent(String.self, forKey: .rpId) + { + relyingPartyIdentifier = rpID + } else if let rp = try container.decodeIfPresent(NestedRP.self, forKey: .rp) { + relyingPartyIdentifier = rp.id + } else { + relyingPartyIdentifier = nil + } + } + + private struct NestedUser: Decodable { + let id: String + let name: String? + let displayName: String? + } + + private struct NestedRP: Decodable { + let id: String } } private struct PasskeyVerificationRequest: Encodable { let clientID: String - let requestID: String? - let username: String? - let id: String - let rawID: String - let type: String - let response: Response - let clientExtensionResults: [String: String] + let sessionID: String? + let scope: String? + let credential: Credential enum CodingKeys: String, CodingKey { case clientID = "client_id" - case requestID = "request_id" - case username - case id - case rawID = "rawId" - case type - case response - case clientExtensionResults + case sessionID = "session_id" + case scope + case credential } - struct Response: Encodable { - let clientDataJSON: String - let authenticatorData: String - let signature: String - let userHandle: String + struct Credential: Encodable { + let id: String + let rawID: String + let type: String + let response: Response + let clientExtensionResults: [String: String] + + enum CodingKeys: String, CodingKey { + case id + case rawID = "rawId" + case type + case response + case clientExtensionResults + } + + struct Response: Encodable { + let clientDataJSON: String + let authenticatorData: String + let signature: String + let userHandle: String + } } } private struct PasskeyRegistrationVerificationRequest: Encodable { let clientID: String - let requestID: String? - let username: String - let name: String? - let id: String - let rawID: String - let type: String - let response: Response - let clientExtensionResults: [String: String] + let sessionID: String? + let scope: String? + let credential: Credential enum CodingKeys: String, CodingKey { case clientID = "client_id" - case requestID = "request_id" - case username - case name - case id - case rawID = "rawId" - case type - case response - case clientExtensionResults + case sessionID = "session_id" + case scope + case credential } - struct Response: Encodable { - let clientDataJSON: String - let attestationObject: String? + struct Credential: Encodable { + let id: String + let rawID: String + let type: String + let response: Response + + enum CodingKeys: String, CodingKey { + case id + case rawID = "rawId" + case type + case response + } + + struct Response: Encodable { + let clientDataJSON: String + let attestationObject: String? + } } } #if os(macOS) private extension PasskeyVerificationRequest { - init(clientID: String, requestID: String?, username: String?, assertion: PasskeyAssertion) { + init(clientID: String, sessionID: String?, scope: String?, assertion: PasskeyAssertion) { let credentialID = Base64URL.encode(assertion.credentialID) self.init( clientID: clientID, - requestID: requestID, - username: username, - id: credentialID, - rawID: credentialID, - type: "public-key", - response: Response( - clientDataJSON: Base64URL.encode(assertion.rawClientDataJSON), - authenticatorData: Base64URL.encode(assertion.rawAuthenticatorData), - signature: Base64URL.encode(assertion.signature), - userHandle: Base64URL.encode(assertion.userID) - ), - clientExtensionResults: [:] + sessionID: sessionID, + scope: scope.nilIfBlank, + credential: Credential( + id: credentialID, + rawID: credentialID, + type: "public-key", + response: Credential.Response( + clientDataJSON: Base64URL.encode(assertion.rawClientDataJSON), + authenticatorData: Base64URL.encode(assertion.rawAuthenticatorData), + signature: Base64URL.encode(assertion.signature), + userHandle: Base64URL.encode(assertion.userID) + ), + clientExtensionResults: [:] + ) ) } } private extension PasskeyRegistrationVerificationRequest { - init(clientID: String, requestID: String?, username: String, name: String?, registration: PasskeyRegistration) { + init(clientID: String, sessionID: String?, scope: String?, registration: PasskeyRegistration) { let credentialID = Base64URL.encode(registration.credentialID) self.init( clientID: clientID, - requestID: requestID, - username: username, - name: name, - id: credentialID, - rawID: credentialID, - type: "public-key", - response: Response( - clientDataJSON: Base64URL.encode(registration.rawClientDataJSON), - attestationObject: registration.rawAttestationObject.map(Base64URL.encode) - ), - clientExtensionResults: [:] + sessionID: sessionID, + scope: scope.nilIfBlank, + credential: Credential( + id: credentialID, + rawID: credentialID, + type: "public-key", + response: Credential.Response( + clientDataJSON: Base64URL.encode(registration.rawClientDataJSON), + attestationObject: registration.rawAttestationObject.map(Base64URL.encode) + ) + ) ) } } diff --git a/test/test/ContentView.swift b/test/test/ContentView.swift index 4099222..cb0832e 100644 --- a/test/test/ContentView.swift +++ b/test/test/ContentView.swift @@ -34,6 +34,16 @@ struct ContentView: View { print("Auth failed: \(error)") } ) + #if os(macOS) + .onChange(of: manager.errorMessage) { _, newValue in + if newValue != nil { + Task { + try? await Task.sleep(for: .seconds(5)) + manager.clearError() + } + } + } + #endif case .authenticated: authenticatedView } diff --git a/test/test/testApp.swift b/test/test/testApp.swift index 9b55887..2c14048 100644 --- a/test/test/testApp.swift +++ b/test/test/testApp.swift @@ -17,10 +17,11 @@ struct testApp: App { issuer: "https://auth.rxlab.app", clientID: "client_8605760939b8494c8bfe29c77ae7ee7f", redirectURI: "rxauth://callback", - passkeyChallengePath: "/api/auth/passkey/authenticate/options", - passkeyVerificationPath: "/api/auth/passkey/authenticate/verify", - passkeyRegistrationChallengePath: "/api/auth/passkey/register/options", - passkeyRegistrationVerificationPath: "/api/auth/passkey/register/verify", + scopes: ["openid"], + passkeyChallengePath: "/api/oauth/passkey/authenticate/options", + passkeyVerificationPath: "/api/oauth/passkey/authenticate/verify", + passkeyRegistrationChallengePath: "/api/oauth/passkey/register/options", + passkeyRegistrationVerificationPath: "/api/oauth/passkey/register/verify", passkeyRelyingPartyIdentifier: "rxlab.app" ) From 9ff0b996f595f7d2283c7eaa4f9d4df9629e50c7 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 18:31:25 +0800 Subject: [PATCH 3/7] feat(passkey): send redirect_uri in OPTIONS body so server can gate via admin allow-list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rxlab-auth's native passkey routes now gate on the requesting client's admin-configured redirect_uris allow-list instead of a signInPermission flag, matching the authorization_code flow's client-validation model. Adds redirect_uri (snake_case) to both PasskeyChallengeRequest and PasskeyRegistrationChallengeRequest encoders and passes configuration.redirectURI through at the two call sites. Verify request bodies are unchanged — the server recovers the validated redirect_uri from the challenge stored in Redis. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/RxAuthSwift/OAuthManager.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/Sources/RxAuthSwift/OAuthManager.swift b/Sources/RxAuthSwift/OAuthManager.swift index 6ee49e7..e95248c 100644 --- a/Sources/RxAuthSwift/OAuthManager.swift +++ b/Sources/RxAuthSwift/OAuthManager.swift @@ -342,6 +342,7 @@ public final class OAuthManager: Sendable { challengeRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") challengeRequest.httpBody = try JSONEncoder().encode(PasskeyChallengeRequest( clientID: configuration.clientID, + redirectURI: configuration.redirectURI, username: username.nilIfBlank )) @@ -413,6 +414,7 @@ public final class OAuthManager: Sendable { challengeRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") challengeRequest.httpBody = try JSONEncoder().encode(PasskeyRegistrationChallengeRequest( clientID: configuration.clientID, + redirectURI: configuration.redirectURI, username: trimmedUsername, name: name.nilIfBlank )) @@ -616,10 +618,12 @@ private struct TokenResponse: Decodable { private struct PasskeyChallengeRequest: Encodable { let clientID: String + let redirectURI: String let username: String? enum CodingKeys: String, CodingKey { case clientID = "client_id" + case redirectURI = "redirect_uri" case username } } @@ -689,11 +693,13 @@ private struct PasskeyChallengeResponse: Decodable { private struct PasskeyRegistrationChallengeRequest: Encodable { let clientID: String + let redirectURI: String let username: String let name: String? enum CodingKeys: String, CodingKey { case clientID = "client_id" + case redirectURI = "redirect_uri" case username case name } From f8d6703b6c6f814c60402becebadd9b424f16178 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 18:46:34 +0800 Subject: [PATCH 4/7] feat(signup): treat 201 email_verification_required as success, switch UI to sign-in with info banner The rxlab-auth /api/oauth/signup route returns HTTP 201 with {email_verification_required:true} when an account is created but the user must click an email link before tokens will be issued. Previously the client treated this non-200 response as a tokenExchangeFailed error. Adds a public SignupResult enum (.authenticated | .emailVerificationRequired(email)) returned from OAuthManager.signUp, plus a parallel infoMessage state with clearInfo() so the UI can surface non-error confirmations. The signup path now uses a dedicated sendSignupRequest that accepts any 2xx, prefers the pending- verification decoder, and falls back to TokenResponse for the E2E inline-tokens mode. RxSignInView's macOS form now switches mode back to .signIn on .emailVerificationRequired, clears the password/name fields, and renders a glass info-banner ("Check for a verification link, then sign in.") parallel to the existing error overlay. Co-Authored-By: Claude Opus 4.7 (1M context) --- Sources/RxAuthSwift/OAuthManager.swift | 97 +++++++++++++++++-- Sources/RxAuthSwiftUI/RxSignInView.swift | 54 ++++++++++- .../OAuthManagerTokenCleanupTests.swift | 35 +++++++ 3 files changed, 174 insertions(+), 12 deletions(-) diff --git a/Sources/RxAuthSwift/OAuthManager.swift b/Sources/RxAuthSwift/OAuthManager.swift index e95248c..384af2a 100644 --- a/Sources/RxAuthSwift/OAuthManager.swift +++ b/Sources/RxAuthSwift/OAuthManager.swift @@ -8,12 +8,18 @@ public final class OAuthManager: Sendable { public private(set) var authState: AuthenticationState = .unknown public private(set) var currentUser: User? public private(set) var errorMessage: String? + public private(set) var infoMessage: String? public private(set) var isAuthenticating = false /// Clears the current error message public func clearError() { errorMessage = nil } + + /// Clears the current informational message + public func clearInfo() { + infoMessage = nil + } public var supportsPasskeyAuthentication: Bool { configuration.passkeyChallengeURL != nil && configuration.passkeyVerificationURL != nil } @@ -130,14 +136,23 @@ public final class OAuthManager: Sendable { } } - public func signUp(username: String, password: String, name: String? = nil) async throws { + @discardableResult + public func signUp(username: String, password: String, name: String? = nil) async throws -> SignupResult { isAuthenticating = true errorMessage = nil + infoMessage = nil defer { isAuthenticating = false } do { - try await exchangeSignupForTokens(username: username, password: password, name: name) - logger.info("Native username/password signup completed successfully") + let result = try await exchangeSignupForTokens(username: username, password: password, name: name) + switch result { + case .authenticated: + logger.info("Native username/password signup completed successfully") + case .emailVerificationRequired(let email): + infoMessage = "Check \(email) for a verification link, then sign in." + logger.info("Signup created an unverified account; awaiting email verification: \(email)") + } + return result } catch { errorMessage = error.localizedDescription throw error @@ -300,7 +315,7 @@ public final class OAuthManager: Sendable { startTokenRefreshTimer() } - private func exchangeSignupForTokens(username: String, password: String, name: String?) async throws { + private func exchangeSignupForTokens(username: String, password: String, name: String?) async throws -> SignupResult { let trimmedUsername = username.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmedUsername.isEmpty, !password.isEmpty else { throw OAuthError.invalidSignupDetails @@ -321,12 +336,16 @@ public final class OAuthManager: Sendable { scope: configuration.scopes.joined(separator: " ") )) - let tokenResponse = try await sendTokenRequest(request) - try saveTokens(tokenResponse) - try await fetchUserInfo() - - authState = .authenticated - startTokenRefreshTimer() + switch try await sendSignupRequest(request) { + case .tokens(let tokenResponse): + try saveTokens(tokenResponse) + try await fetchUserInfo() + authState = .authenticated + startTokenRefreshTimer() + return .authenticated + case .verificationRequired(let email): + return .emailVerificationRequired(email: email ?? trimmedUsername) + } } private func exchangePasskeyAssertionForTokens(username: String?) async throws { @@ -503,6 +522,36 @@ public final class OAuthManager: Sendable { } } + private func sendSignupRequest(_ request: URLRequest) async throws -> SignupServerResponse { + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OAuthError.tokenExchangeFailed("Invalid response") + } + + let bodyString = String(data: data, encoding: .utf8) ?? "" + + guard (200...299).contains(httpResponse.statusCode) else { + let url = request.url?.absoluteString ?? "" + logger.error("Signup request failed: POST \(url) -> HTTP \(httpResponse.statusCode); body: \(bodyString)") + throw OAuthError.tokenExchangeFailed("HTTP \(httpResponse.statusCode): \(bodyString)") + } + + // Signup may return either an OAuth token payload (E2E mode) or a + // verification-required payload (default mode). Try the verification + // shape first because it omits `access_token`, so a TokenResponse + // decode would succeed for token payloads but fail for verification + // ones. + if let pending = try? JSONDecoder().decode(SignupPendingVerificationResponse.self, from: data), + pending.emailVerificationRequired == true + { + return .verificationRequired(email: pending.email) + } + + let tokenResponse = try JSONDecoder().decode(TokenResponse.self, from: data) + return .tokens(tokenResponse) + } + private func sendTokenRequest(_ request: URLRequest) async throws -> TokenResponse { let (data, response) = try await URLSession.shared.data(for: request) @@ -602,6 +651,34 @@ private struct OAuthErrorResponse: Decodable { } } +// MARK: - Signup Result + +public enum SignupResult: Sendable, Equatable { + /// Server returned tokens and the user is now signed in. + case authenticated + /// Account was created but the server requires email verification before + /// tokens will be issued. `email` is the address the verification link was + /// sent to (mirrors what the user typed when the server did not echo one). + case emailVerificationRequired(email: String) +} + +private enum SignupServerResponse { + case tokens(TokenResponse) + case verificationRequired(email: String?) +} + +private struct SignupPendingVerificationResponse: Decodable { + let userID: String? + let email: String? + let emailVerificationRequired: Bool? + + enum CodingKeys: String, CodingKey { + case userID = "user_id" + case email + case emailVerificationRequired = "email_verification_required" + } +} + private struct TokenResponse: Decodable { let accessToken: String let refreshToken: String? diff --git a/Sources/RxAuthSwiftUI/RxSignInView.swift b/Sources/RxAuthSwiftUI/RxSignInView.swift index 713264a..4709e27 100644 --- a/Sources/RxAuthSwiftUI/RxSignInView.swift +++ b/Sources/RxAuthSwiftUI/RxSignInView.swift @@ -109,8 +109,48 @@ public struct RxSignInView: View { if let errorMessage = manager.errorMessage { errorOverlay(message: errorMessage) .transition(.move(edge: .top).combined(with: .opacity)) + } else if let infoMessage = manager.infoMessage { + infoOverlay(message: infoMessage) + .transition(.move(edge: .top).combined(with: .opacity)) } } + .animation(.default, value: manager.infoMessage) + } + + private func infoOverlay(message: String) -> some View { + HStack(spacing: 12) { + Image(systemName: "envelope.badge.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(appearance.accentColor) + + Text(message) + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.primary) + .lineLimit(3) + + Spacer() + + Button { + withAnimation(.spring(response: 0.3, dampingFraction: 0.8)) { + manager.clearInfo() + } + } label: { + Image(systemName: "xmark") + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(.secondary) + .frame(width: 24, height: 24) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Dismiss message") + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .frame(maxWidth: 400) + .glassEffect(.regular.tint(appearance.accentColor.opacity(0.3)), in: .rect(cornerRadius: 12)) + .glassEffectID("info", in: glassNamespace) + .padding(.top, 16) + .padding(.horizontal, 32) } private func errorOverlay(message: String) -> some View { @@ -496,10 +536,20 @@ public struct RxSignInView: View { switch mode { case .signIn: try await manager.authenticate(username: username, password: password) + onAuthSuccess?() case .signUp: - try await manager.signUp(username: username, password: password, name: name) + let result = try await manager.signUp(username: username, password: password, name: name) + switch result { + case .authenticated: + onAuthSuccess?() + case .emailVerificationRequired: + password = "" + name = "" + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + mode = .signIn + } + } } - onAuthSuccess?() } catch { onAuthFailed?(error) } diff --git a/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift b/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift index 8dba555..274c3d8 100644 --- a/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift +++ b/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift @@ -436,6 +436,41 @@ struct OAuthManagerTokenCleanupTests { #expect(body.contains(#""name":"Signup User""#)) } + @Test @MainActor func signUpReturnsVerificationRequiredOn201() async throws { + MockURLProtocol.requestHandler = { request in + #expect(request.url?.path == "/api/oauth/signup") + let verificationJSON = #"{"user_id":"40ff1a29-39e5-4794-bc24-b26f95eaa38e","email":"qiwei@rxlab.app","email_verification_required":true}"# + let response = HTTPURLResponse( + url: request.url!, + statusCode: 201, + httpVersion: nil, + headerFields: ["Content-Type": "application/json"] + )! + return (response, verificationJSON.data(using: .utf8)!) + } + + URLProtocol.registerClass(MockURLProtocol.self) + defer { URLProtocol.unregisterClass(MockURLProtocol.self) } + + let storage = InMemoryTokenStorage() + let manager = OAuthManager( + configuration: makeConfig(), + tokenStorage: storage + ) + + let result = try await manager.signUp( + username: "qiwei@rxlab.app", + password: "create-password", + name: nil + ) + + #expect(result == .emailVerificationRequired(email: "qiwei@rxlab.app")) + #expect(manager.authState != .authenticated) + #expect(storage.getAccessToken() == nil) + #expect(manager.errorMessage == nil) + #expect(manager.infoMessage?.contains("qiwei@rxlab.app") == true) + } + @Test @MainActor func signUpRejectsBlankCredentials() async throws { let manager = OAuthManager( configuration: makeConfig(), From ed08aca089f67e6acd3e89e6ecbc3e8b88d0e63a Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 22:02:05 +0800 Subject: [PATCH 5/7] feat: add account creation api --- Sources/RxAuthSwift/OAuthManager.swift | 509 +++++++++++++++- .../Platform/MacOSPasskeyAuthenticator.swift | 207 +++++++ Sources/RxAuthSwift/RxAuthConfiguration.swift | 42 ++ Sources/RxAuthSwift/UISchema.swift | 104 ++++ .../RxAuthSwiftUI/RxSignInAppearance.swift | 5 +- Sources/RxAuthSwiftUI/RxSignInView.swift | 546 +++++++++++++----- test/test/ContentView.swift | 6 +- test/test/testApp.swift | 4 + 8 files changed, 1273 insertions(+), 150 deletions(-) create mode 100644 Sources/RxAuthSwift/UISchema.swift diff --git a/Sources/RxAuthSwift/OAuthManager.swift b/Sources/RxAuthSwift/OAuthManager.swift index 384af2a..76a5c59 100644 --- a/Sources/RxAuthSwift/OAuthManager.swift +++ b/Sources/RxAuthSwift/OAuthManager.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import Foundation import Logging import Observation @@ -10,6 +11,17 @@ public final class OAuthManager: Sendable { public private(set) var errorMessage: String? public private(set) var infoMessage: String? public private(set) var isAuthenticating = false + public private(set) var signInSchema: AuthUISchema? + public private(set) var signUpSchema: AuthUISchema? + public private(set) var isLoadingSchema = false + + /// True after a password sign-up completes successfully on a server that + /// supports passkey upgrade. The UI is expected to surface an "Add a + /// passkey" prompt, then call either `addPasskeyForCurrentUser()` or + /// `skipPasskeyUpgradeOffer()`. While this flag is set, `authState` is + /// intentionally held at `.unauthenticated` so the host app keeps the + /// sign-in surface mounted long enough for the prompt to render. + public private(set) var pendingPasskeyOffer = false /// Clears the current error message public func clearError() { @@ -29,6 +41,12 @@ public final class OAuthManager: Sendable { public var supportsPasskeyRegistration: Bool { configuration.passkeyRegistrationChallengeURL != nil && configuration.passkeyRegistrationVerificationURL != nil } + public var supportsPasskeyUpgrade: Bool { + configuration.passkeyUpgradeChallengeURL != nil && configuration.passkeyUpgradeVerificationURL != nil + } + public var supportsPasskeyAccountCreation: Bool { + configuration.passkeyAccountCreationOptionsURL != nil && configuration.passkeyAccountCreationVerifyURL != nil + } private let configuration: RxAuthConfiguration private let tokenStorage: TokenStorageProtocol @@ -116,6 +134,7 @@ public final class OAuthManager: Sendable { do { try await exchangePasswordForTokens(username: username, password: password) logger.info("Native username/password authentication completed successfully") + scheduleAutomaticPasskeyUpgrade() } catch { errorMessage = error.localizedDescription throw error @@ -148,6 +167,13 @@ public final class OAuthManager: Sendable { switch result { case .authenticated: logger.info("Native username/password signup completed successfully") + if supportsPasskeyUpgrade { + // Hold authState so the UI can show the passkey offer + // sheet on top of the still-mounted sign-in view. + pendingPasskeyOffer = true + } else { + finalizeSignupSession() + } case .emailVerificationRequired(let email): infoMessage = "Check \(email) for a verification link, then sign in." logger.info("Signup created an unverified account; awaiting email verification: \(email)") @@ -173,6 +199,62 @@ public final class OAuthManager: Sendable { } } + /// WWDC 2025 / iOS 26 / macOS 26: drives Apple's system-provided account + /// creation sheet via `ASAuthorizationAccountCreationProvider`. The user + /// never fills out a form — the OS pulls email/phone/name from iCloud, + /// confirms with biometrics, and produces a passkey. The server creates + /// the account from the returned contact identifier and issues tokens. + public func createAccountWithPasskey( + acceptedIdentifiers: [ASContactIdentifierRequest] = [.email], + shouldRequestName: Bool = true + ) async throws { + isAuthenticating = true + errorMessage = nil + defer { isAuthenticating = false } + + do { + try await exchangePasskeyAccountCreationForTokens( + acceptedIdentifiers: acceptedIdentifiers, + shouldRequestName: shouldRequestName + ) + logger.info("Native passkey account creation completed successfully") + } catch { + errorMessage = error.localizedDescription + throw error + } + } + + /// User dismissed the post-signup "add a passkey" offer. Finalises the + /// session — flips `authState` to `.authenticated` and starts the + /// refresh timer — without registering a passkey. + public func skipPasskeyUpgradeOffer() { + guard pendingPasskeyOffer else { return } + pendingPasskeyOffer = false + finalizeSignupSession() + } + + /// User accepted the post-signup "add a passkey" offer. Runs an + /// interactive passkey registration ceremony against the upgrade + /// endpoints (Bearer-authenticated with the just-issued access token), + /// then finalises the session. Throws if the ceremony or verification + /// fails — the caller may surface the error and let the user retry or + /// skip. + public func addPasskeyForCurrentUser() async throws { + isAuthenticating = true + errorMessage = nil + defer { isAuthenticating = false } + + do { + try await performInteractivePasskeyUpgrade() + pendingPasskeyOffer = false + finalizeSignupSession() + logger.info("Post-signup passkey registration succeeded") + } catch { + errorMessage = error.localizedDescription + throw error + } + } + public func logout() async { stopTokenRefreshTimer() @@ -340,8 +422,9 @@ public final class OAuthManager: Sendable { case .tokens(let tokenResponse): try saveTokens(tokenResponse) try await fetchUserInfo() - authState = .authenticated - startTokenRefreshTimer() + // Defer the `.authenticated` transition to the caller so the + // sign-up flow can interpose a passkey-offer step before the + // host app swaps away from the sign-in surface. return .authenticated case .verificationRequired(let email): return .emailVerificationRequired(email: email ?? trimmedUsername) @@ -490,6 +573,315 @@ public final class OAuthManager: Sendable { #endif } + // MARK: - System-Sheet Account Creation (WWDC iOS 26 / macOS 26) + + private func exchangePasskeyAccountCreationForTokens( + acceptedIdentifiers: [ASContactIdentifierRequest], + shouldRequestName: Bool + ) async throws { + guard supportsPasskeyAccountCreation, + let optionsURL = configuration.passkeyAccountCreationOptionsURL, + let verifyURL = configuration.passkeyAccountCreationVerifyURL + else { + throw OAuthError.passkeyUnavailable + } + + var optionsRequest = URLRequest(url: optionsURL) + optionsRequest.httpMethod = "POST" + optionsRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + optionsRequest.httpBody = try JSONEncoder().encode(PasskeyAccountCreationOptionsRequest( + clientID: configuration.clientID, + redirectURI: configuration.redirectURI + )) + + let (optionsData, optionsResponse) = try await URLSession.shared.data(for: optionsRequest) + guard let httpOptionsResponse = optionsResponse as? HTTPURLResponse, + (200...299).contains(httpOptionsResponse.statusCode) + else { + let status = (optionsResponse as? HTTPURLResponse)?.statusCode ?? -1 + let body = String(data: optionsData, encoding: .utf8) ?? "" + logger.error("Passkey account-creation options failed: HTTP \(status) \(body)") + throw OAuthError.authenticationFailed("Passkey account-creation challenge failed") + } + + // Same SimpleWebAuthn-spread JSON shape as the upgrade route; decode + // with the existing tolerant decoder, ignoring username/displayName + // (the OS sheet supplies those). + let options = try JSONDecoder().decode(PasskeyRegistrationChallengeResponse.self, from: optionsData) + guard let challenge = Base64URL.decode(options.challenge) else { + throw OAuthError.authenticationFailed("Invalid challenge") + } + guard let userID = Base64URL.decode(options.userID) else { + throw OAuthError.authenticationFailed("Invalid user ID") + } + let relyingPartyIdentifier = options.relyingPartyIdentifier + ?? configuration.passkeyRelyingPartyIdentifier + ?? URL(string: configuration.issuer)?.host + guard let relyingPartyIdentifier else { + throw OAuthError.passkeyUnavailable + } + + #if os(macOS) + let creation = try await MacOSPasskeyAccountCreationAuthenticator().createAccount( + relyingPartyIdentifier: relyingPartyIdentifier, + challenge: challenge, + userID: userID, + acceptedIdentifiers: acceptedIdentifiers, + shouldRequestName: shouldRequestName + ) + + var verifyRequest = URLRequest(url: verifyURL) + verifyRequest.httpMethod = "POST" + verifyRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + verifyRequest.httpBody = try JSONEncoder().encode(PasskeyAccountCreationVerifyRequest( + clientID: configuration.clientID, + sessionID: options.sessionID, + scope: configuration.scopes.joined(separator: " "), + contactIdentifier: creation.contactIdentifier.value, + contactIdentifierType: creation.contactIdentifier.serverTypeString, + name: Self.formattedName(creation.name), + creation: creation + )) + + let tokenResponse = try await sendTokenRequest(verifyRequest) + try saveTokens(tokenResponse) + try await fetchUserInfo() + + authState = .authenticated + startTokenRefreshTimer() + #else + throw OAuthError.passkeyUnavailable + #endif + } + + private static func formattedName(_ components: PersonNameComponents?) -> String? { + guard let components else { return nil } + let formatter = PersonNameComponentsFormatter() + formatter.style = .long + let formatted = formatter.string(from: components).trimmingCharacters(in: .whitespacesAndNewlines) + return formatted.isEmpty ? nil : formatted + } + + private func finalizeSignupSession() { + authState = .authenticated + startTokenRefreshTimer() + } + + /// Interactive variant of the upgrade ceremony used by the post-signup + /// offer sheet. Unlike `attemptAutomaticPasskeyUpgrade`, this surfaces + /// the system passkey UI (`MacOSPasskeyRegistrationAuthenticator`) and + /// propagates errors so the UI can react. + private func performInteractivePasskeyUpgrade() async throws { + #if os(macOS) + guard supportsPasskeyUpgrade, + let challengeURL = configuration.passkeyUpgradeChallengeURL, + let verificationURL = configuration.passkeyUpgradeVerificationURL + else { + throw OAuthError.passkeyUnavailable + } + guard let accessToken = tokenStorage.getAccessToken() else { + throw OAuthError.passkeyUnavailable + } + + var challengeRequest = URLRequest(url: challengeURL) + challengeRequest.httpMethod = "POST" + challengeRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + challengeRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + challengeRequest.httpBody = "{}".data(using: .utf8) + + let (challengeData, challengeResponse) = try await URLSession.shared.data(for: challengeRequest) + guard let httpChallengeResponse = challengeResponse as? HTTPURLResponse, + (200...299).contains(httpChallengeResponse.statusCode) + else { + let status = (challengeResponse as? HTTPURLResponse)?.statusCode ?? -1 + throw OAuthError.authenticationFailed("Passkey upgrade challenge failed (HTTP \(status))") + } + + let options = try JSONDecoder().decode(PasskeyRegistrationChallengeResponse.self, from: challengeData) + guard let challenge = Base64URL.decode(options.challenge) else { + throw OAuthError.authenticationFailed("Invalid passkey upgrade challenge") + } + guard let userID = Base64URL.decode(options.userID) else { + throw OAuthError.authenticationFailed("Invalid passkey upgrade user ID") + } + let relyingPartyIdentifier = options.relyingPartyIdentifier + ?? configuration.passkeyRelyingPartyIdentifier + ?? URL(string: configuration.issuer)?.host + guard let relyingPartyIdentifier else { + throw OAuthError.passkeyUnavailable + } + + let displayName = options.username ?? currentUser?.email ?? currentUser?.name ?? "Account" + + let registration = try await MacOSPasskeyRegistrationAuthenticator().register( + relyingPartyIdentifier: relyingPartyIdentifier, + challenge: challenge, + name: displayName, + userID: userID + ) + + var verificationRequest = URLRequest(url: verificationURL) + verificationRequest.httpMethod = "POST" + verificationRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + verificationRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + verificationRequest.httpBody = try JSONEncoder().encode(PasskeyUpgradeVerificationRequest( + sessionID: options.sessionID, + name: displayName, + registration: registration + )) + + let (verifyData, verifyResponse) = try await URLSession.shared.data(for: verificationRequest) + guard let httpVerifyResponse = verifyResponse as? HTTPURLResponse, + (200...299).contains(httpVerifyResponse.statusCode) + else { + let status = (verifyResponse as? HTTPURLResponse)?.statusCode ?? -1 + let body = String(data: verifyData, encoding: .utf8) ?? "" + throw OAuthError.authenticationFailed("Passkey upgrade verification failed (HTTP \(status)): \(body)") + } + #else + throw OAuthError.passkeyUnavailable + #endif + } + + // MARK: - Automatic Passkey Upgrade (WWDC iOS 18 / macOS 15) + + /// Fire-and-forget: after a successful password sign-in or sign-up, ask the + /// OS to silently provision a passkey for the just-authenticated account so + /// the next sign-in can be passwordless. Errors are swallowed by design; + /// the user never sees an upgrade-related prompt or error. + private func scheduleAutomaticPasskeyUpgrade() { + guard supportsPasskeyUpgrade else { return } + Task { [weak self] in + await self?.attemptAutomaticPasskeyUpgrade() + } + } + + private func attemptAutomaticPasskeyUpgrade() async { + #if os(macOS) + guard supportsPasskeyUpgrade, + let challengeURL = configuration.passkeyUpgradeChallengeURL, + let verificationURL = configuration.passkeyUpgradeVerificationURL + else { return } + + guard let accessToken = tokenStorage.getAccessToken() else { + logger.debug("Automatic passkey upgrade skipped: no access token") + return + } + + do { + var challengeRequest = URLRequest(url: challengeURL) + challengeRequest.httpMethod = "POST" + challengeRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + challengeRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + challengeRequest.httpBody = "{}".data(using: .utf8) + + let (challengeData, challengeResponse) = try await URLSession.shared.data(for: challengeRequest) + guard let httpChallengeResponse = challengeResponse as? HTTPURLResponse, + (200...299).contains(httpChallengeResponse.statusCode) + else { + let status = (challengeResponse as? HTTPURLResponse)?.statusCode ?? -1 + logger.info("Automatic passkey upgrade skipped: challenge HTTP \(status)") + return + } + + let options = try JSONDecoder().decode(PasskeyRegistrationChallengeResponse.self, from: challengeData) + guard let challenge = Base64URL.decode(options.challenge) else { + logger.info("Automatic passkey upgrade skipped: invalid challenge") + return + } + guard let userID = Base64URL.decode(options.userID) else { + logger.info("Automatic passkey upgrade skipped: invalid user ID") + return + } + let relyingPartyIdentifier = options.relyingPartyIdentifier + ?? configuration.passkeyRelyingPartyIdentifier + ?? URL(string: configuration.issuer)?.host + guard let relyingPartyIdentifier else { + logger.info("Automatic passkey upgrade skipped: missing RP identifier") + return + } + + let displayName = options.username ?? currentUser?.email ?? currentUser?.name ?? "Account" + + guard let registration = await MacOSPasskeyConditionalUpgradeAuthenticator().upgrade( + relyingPartyIdentifier: relyingPartyIdentifier, + challenge: challenge, + name: displayName, + userID: userID + ) else { + logger.info("Automatic passkey upgrade skipped: OS declined or no candidates") + return + } + + var verificationRequest = URLRequest(url: verificationURL) + verificationRequest.httpMethod = "POST" + verificationRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") + verificationRequest.setValue("Bearer \(accessToken)", forHTTPHeaderField: "Authorization") + verificationRequest.httpBody = try JSONEncoder().encode(PasskeyUpgradeVerificationRequest( + sessionID: options.sessionID, + name: displayName, + registration: registration + )) + + let (verifyData, verifyResponse) = try await URLSession.shared.data(for: verificationRequest) + guard let httpVerifyResponse = verifyResponse as? HTTPURLResponse, + (200...299).contains(httpVerifyResponse.statusCode) + else { + let status = (verifyResponse as? HTTPURLResponse)?.statusCode ?? -1 + let body = String(data: verifyData, encoding: .utf8) ?? "" + logger.info("Automatic passkey upgrade failed at verify: HTTP \(status) \(body)") + return + } + + logger.info("Automatic passkey upgrade succeeded") + } catch { + logger.info("Automatic passkey upgrade skipped: \(error.localizedDescription)") + } + #endif + } + + // MARK: - UI Schema + + /// Fetch the server-driven UI schema for both flows. Stores results on the + /// manager so SwiftUI views can render fields, validation, and supported + /// auth methods dynamically. + @discardableResult + public func loadUISchema() async -> (signIn: AuthUISchema?, signUp: AuthUISchema?) { + isLoadingSchema = true + defer { isLoadingSchema = false } + + async let signIn = fetchUISchema(flow: .signin) + async let signUp = fetchUISchema(flow: .signup) + + let resolved = await (signIn, signUp) + if let schema = resolved.0 { signInSchema = schema } + if let schema = resolved.1 { signUpSchema = schema } + return (resolved.0, resolved.1) + } + + public func fetchUISchema(flow: AuthUISchema.Flow) async -> AuthUISchema? { + guard let url = configuration.uiSchemaURL(flow: flow) else { return nil } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("application/json", forHTTPHeaderField: "Accept") + + do { + let (data, response) = try await URLSession.shared.data(for: request) + guard let httpResponse = response as? HTTPURLResponse, + (200...299).contains(httpResponse.statusCode) + else { + let status = (response as? HTTPURLResponse)?.statusCode ?? -1 + logger.warning("UI schema fetch (\(flow.rawValue)) returned HTTP \(status)") + return nil + } + return try JSONDecoder().decode(AuthUISchema.self, from: data) + } catch { + logger.warning("UI schema fetch (\(flow.rawValue)) failed: \(error.localizedDescription)") + return nil + } + } + private func fetchUserInfo() async throws { guard let accessToken = tokenStorage.getAccessToken(), let userInfoURL = configuration.userInfoURL @@ -534,7 +926,11 @@ public final class OAuthManager: Sendable { guard (200...299).contains(httpResponse.statusCode) else { let url = request.url?.absoluteString ?? "" logger.error("Signup request failed: POST \(url) -> HTTP \(httpResponse.statusCode); body: \(bodyString)") - throw OAuthError.tokenExchangeFailed("HTTP \(httpResponse.statusCode): \(bodyString)") + + if let parsed = try? JSONDecoder().decode(OAuthErrorResponse.self, from: data) { + throw OAuthError.tokenExchangeFailed(parsed.userFacingMessage) + } + throw OAuthError.tokenExchangeFailed("HTTP \(httpResponse.statusCode)") } // Signup may return either an OAuth token payload (E2E mode) or a @@ -559,7 +955,7 @@ public final class OAuthManager: Sendable { throw OAuthError.tokenExchangeFailed("Invalid response") } - guard httpResponse.statusCode == 200 else { + guard (200...299).contains(httpResponse.statusCode) else { let bodyString = String(data: data, encoding: .utf8) ?? "" let url = request.url?.absoluteString ?? "" logger.error("Token request failed: \(request.httpMethod ?? "?") \(url) -> HTTP \(httpResponse.statusCode); body: \(bodyString)") @@ -922,7 +1318,101 @@ private struct PasskeyRegistrationVerificationRequest: Encodable { } } +private struct PasskeyAccountCreationOptionsRequest: Encodable { + let clientID: String + let redirectURI: String + + enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case redirectURI = "redirect_uri" + } +} + +private struct PasskeyAccountCreationVerifyRequest: Encodable { + let clientID: String + let sessionID: String? + let scope: String? + let contactIdentifier: String + let contactIdentifierType: String + let name: String? + let credential: PasskeyRegistrationVerificationRequest.Credential + + enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case sessionID = "session_id" + case scope + case contactIdentifier = "contact_identifier" + case contactIdentifierType = "contact_identifier_type" + case name + case credential + } +} + +#if os(macOS) +private extension PasskeyAccountCreationVerifyRequest { + init( + clientID: String, + sessionID: String?, + scope: String?, + contactIdentifier: String, + contactIdentifierType: String, + name: String?, + creation: PasskeyAccountCreation + ) { + let credentialID = Base64URL.encode(creation.credentialID) + self.init( + clientID: clientID, + sessionID: sessionID, + scope: scope.nilIfBlank, + contactIdentifier: contactIdentifier, + contactIdentifierType: contactIdentifierType, + name: name.nilIfBlank, + credential: PasskeyRegistrationVerificationRequest.Credential( + id: credentialID, + rawID: credentialID, + type: "public-key", + response: PasskeyRegistrationVerificationRequest.Credential.Response( + clientDataJSON: Base64URL.encode(creation.rawClientDataJSON), + attestationObject: creation.rawAttestationObject.map(Base64URL.encode) + ) + ) + ) + } +} +#endif + +private struct PasskeyUpgradeVerificationRequest: Encodable { + let sessionID: String? + let name: String? + let credential: PasskeyRegistrationVerificationRequest.Credential + + enum CodingKeys: String, CodingKey { + case sessionID = "session_id" + case name + case credential + } +} + #if os(macOS) +private extension PasskeyUpgradeVerificationRequest { + init(sessionID: String?, name: String?, registration: PasskeyRegistration) { + let credentialID = Base64URL.encode(registration.credentialID) + self.init( + sessionID: sessionID, + name: name.nilIfBlank, + credential: PasskeyRegistrationVerificationRequest.Credential( + id: credentialID, + rawID: credentialID, + type: "public-key", + response: PasskeyRegistrationVerificationRequest.Credential.Response( + clientDataJSON: Base64URL.encode(registration.rawClientDataJSON), + attestationObject: registration.rawAttestationObject.map(Base64URL.encode) + ) + ) + ) + } +} + private extension PasskeyVerificationRequest { init(clientID: String, sessionID: String?, scope: String?, assertion: PasskeyAssertion) { let credentialID = Base64URL.encode(assertion.credentialID) @@ -975,3 +1465,14 @@ private extension Optional where Wrapped == String { return value } } + +#if DEBUG +public extension OAuthManager { + /// Preview-only: inject pre-baked schemas so SwiftUI previews can render + /// the native form without hitting the network. + func _previewInject(signIn: AuthUISchema?, signUp: AuthUISchema?) { + signInSchema = signIn + signUpSchema = signUp + } +} +#endif diff --git a/Sources/RxAuthSwift/Platform/MacOSPasskeyAuthenticator.swift b/Sources/RxAuthSwift/Platform/MacOSPasskeyAuthenticator.swift index 6697264..50c8292 100644 --- a/Sources/RxAuthSwift/Platform/MacOSPasskeyAuthenticator.swift +++ b/Sources/RxAuthSwift/Platform/MacOSPasskeyAuthenticator.swift @@ -184,4 +184,211 @@ struct PasskeyRegistration { let rawClientDataJSON: Data let rawAttestationObject: Data? } + +/// Performs Apple's "Automatic Passkey Upgrade" (WWDC iOS 18 / macOS 15): +/// silently creates a passkey for an already-authenticated account using +/// `ASAuthorizationPlatformPublicKeyCredentialRegistrationRequest` with +/// `requestStyle = .conditional`. The OS shows no UI; any failure (no +/// candidates, user denial, biometrics off, etc.) resolves to `nil` so the +/// caller can treat it as a no-op without disturbing the sign-up flow. +@MainActor +final class MacOSPasskeyConditionalUpgradeAuthenticator: NSObject { + private var continuation: CheckedContinuation? + private var retainedSelf: MacOSPasskeyConditionalUpgradeAuthenticator? + + func upgrade( + relyingPartyIdentifier: String, + challenge: Data, + name: String, + userID: Data + ) async -> PasskeyRegistration? { + await withCheckedContinuation { (continuation: CheckedContinuation) in + self.continuation = continuation + self.retainedSelf = self + + let provider = ASAuthorizationPlatformPublicKeyCredentialProvider( + relyingPartyIdentifier: relyingPartyIdentifier + ) + let request = provider.createCredentialRegistrationRequest( + challenge: challenge, + name: name, + userID: userID + ) + request.requestStyle = .conditional + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + } + + private func complete(with registration: PasskeyRegistration?) { + guard let continuation else { return } + self.continuation = nil + self.retainedSelf = nil + continuation.resume(returning: registration) + } +} + +extension MacOSPasskeyConditionalUpgradeAuthenticator: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard let credential = authorization.credential as? ASAuthorizationPlatformPublicKeyCredentialRegistration else { + complete(with: nil) + return + } + complete(with: PasskeyRegistration( + credentialID: credential.credentialID, + rawClientDataJSON: credential.rawClientDataJSON, + rawAttestationObject: credential.rawAttestationObject + )) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + complete(with: nil) + } +} + +extension MacOSPasskeyConditionalUpgradeAuthenticator: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + NSApplication.shared.keyWindow + ?? NSApplication.shared.mainWindow + ?? ASPresentationAnchor() + } +} + +/// Result of the WWDC 2025 `ASAuthorizationAccountCreationProvider` ceremony. +/// The system sheet collects the contact identifier (email or phone) and +/// optional name from the user's iCloud profile, then creates a passkey. +struct PasskeyAccountCreation { + enum ContactIdentifier { + case email(String) + case phoneNumber(String) + + var value: String { + switch self { + case .email(let v), .phoneNumber(let v): return v + } + } + + var serverTypeString: String { + switch self { + case .email: return "email" + case .phoneNumber: return "phone_number" + } + } + } + + let contactIdentifier: ContactIdentifier + let name: PersonNameComponents? + let credentialID: Data + let rawClientDataJSON: Data + let rawAttestationObject: Data? +} + +/// Drives Apple's iOS 26 / macOS 26 `ASAuthorizationAccountCreationProvider` +/// ceremony: the OS shows a native sheet that pulls email/phone/name from +/// iCloud, the user confirms with biometrics, and the system generates the +/// passkey. The caller never collects a username — it arrives back here. +@MainActor +final class MacOSPasskeyAccountCreationAuthenticator: NSObject { + private var continuation: CheckedContinuation? + private var retainedSelf: MacOSPasskeyAccountCreationAuthenticator? + + func createAccount( + relyingPartyIdentifier: String, + challenge: Data, + userID: Data, + acceptedIdentifiers: [ASContactIdentifierRequest] = [.email], + shouldRequestName: Bool = true + ) async throws -> PasskeyAccountCreation { + try await withCheckedThrowingContinuation { continuation in + self.continuation = continuation + self.retainedSelf = self + + let provider = ASAuthorizationAccountCreationProvider() + let request = provider.createPlatformPublicKeyCredentialRegistrationRequest( + acceptedContactIdentifiers: acceptedIdentifiers, + shouldRequestName: shouldRequestName, + relyingPartyIdentifier: relyingPartyIdentifier, + challenge: challenge, + userID: userID + ) + + let controller = ASAuthorizationController(authorizationRequests: [request]) + controller.delegate = self + controller.presentationContextProvider = self + controller.performRequests() + } + } + + private func complete(with result: Result) { + guard let continuation else { return } + self.continuation = nil + self.retainedSelf = nil + switch result { + case .success(let value): continuation.resume(returning: value) + case .failure(let error): continuation.resume(throwing: error) + } + } +} + +extension MacOSPasskeyAccountCreationAuthenticator: ASAuthorizationControllerDelegate { + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithAuthorization authorization: ASAuthorization + ) { + guard let credential = authorization.credential as? ASAuthorizationAccountCreationPlatformPublicKeyCredential else { + complete(with: .failure(OAuthError.authenticationFailed("Unexpected passkey account-creation credential"))) + return + } + + let identifier: PasskeyAccountCreation.ContactIdentifier + switch credential.contactIdentifier { + case .email(let email): + identifier = .email(email.value) + case .phoneNumber(let phone): + identifier = .phoneNumber(phone.value) + @unknown default: + complete(with: .failure(OAuthError.authenticationFailed("Unsupported contact identifier"))) + return + } + + let registration = credential.credentialRegistration + complete(with: .success(PasskeyAccountCreation( + contactIdentifier: identifier, + name: credential.name, + credentialID: registration.credentialID, + rawClientDataJSON: registration.rawClientDataJSON, + rawAttestationObject: registration.rawAttestationObject + ))) + } + + func authorizationController( + controller: ASAuthorizationController, + didCompleteWithError error: Error + ) { + let nsError = error as NSError + if nsError.domain == ASAuthorizationError.errorDomain, + ASAuthorizationError.Code(rawValue: nsError.code) == .canceled { + complete(with: .failure(OAuthError.cancelled)) + } else { + complete(with: .failure(OAuthError.authenticationFailed(error.localizedDescription))) + } + } +} + +extension MacOSPasskeyAccountCreationAuthenticator: ASAuthorizationControllerPresentationContextProviding { + func presentationAnchor(for controller: ASAuthorizationController) -> ASPresentationAnchor { + NSApplication.shared.keyWindow + ?? NSApplication.shared.mainWindow + ?? ASPresentationAnchor() + } +} #endif diff --git a/Sources/RxAuthSwift/RxAuthConfiguration.swift b/Sources/RxAuthSwift/RxAuthConfiguration.swift index a922864..450b6b7 100644 --- a/Sources/RxAuthSwift/RxAuthConfiguration.swift +++ b/Sources/RxAuthSwift/RxAuthConfiguration.swift @@ -15,7 +15,12 @@ public struct RxAuthConfiguration: Sendable { public let passkeyVerificationPath: String? public let passkeyRegistrationChallengePath: String? public let passkeyRegistrationVerificationPath: String? + public let passkeyUpgradeChallengePath: String? + public let passkeyUpgradeVerificationPath: String? + public let passkeyAccountCreationOptionsPath: String? + public let passkeyAccountCreationVerifyPath: String? public let passkeyRelyingPartyIdentifier: String? + public let uiSchemaPath: String? public let keychainServiceName: String @@ -33,7 +38,12 @@ public struct RxAuthConfiguration: Sendable { passkeyVerificationPath: String? = nil, passkeyRegistrationChallengePath: String? = nil, passkeyRegistrationVerificationPath: String? = nil, + passkeyUpgradeChallengePath: String? = nil, + passkeyUpgradeVerificationPath: String? = nil, + passkeyAccountCreationOptionsPath: String? = nil, + passkeyAccountCreationVerifyPath: String? = nil, passkeyRelyingPartyIdentifier: String? = nil, + uiSchemaPath: String? = "/api/auth/ui-schema", keychainServiceName: String = "com.rxlab.RxAuthSwift" ) { self.issuer = issuer @@ -49,7 +59,12 @@ public struct RxAuthConfiguration: Sendable { self.passkeyVerificationPath = passkeyVerificationPath self.passkeyRegistrationChallengePath = passkeyRegistrationChallengePath self.passkeyRegistrationVerificationPath = passkeyRegistrationVerificationPath + self.passkeyUpgradeChallengePath = passkeyUpgradeChallengePath + self.passkeyUpgradeVerificationPath = passkeyUpgradeVerificationPath + self.passkeyAccountCreationOptionsPath = passkeyAccountCreationOptionsPath + self.passkeyAccountCreationVerifyPath = passkeyAccountCreationVerifyPath self.passkeyRelyingPartyIdentifier = passkeyRelyingPartyIdentifier + self.uiSchemaPath = uiSchemaPath self.keychainServiceName = keychainServiceName } @@ -94,7 +109,34 @@ public struct RxAuthConfiguration: Sendable { return URL(string: issuer + passkeyRegistrationVerificationPath) } + public var passkeyUpgradeChallengeURL: URL? { + guard let passkeyUpgradeChallengePath else { return nil } + return URL(string: issuer + passkeyUpgradeChallengePath) + } + + public var passkeyUpgradeVerificationURL: URL? { + guard let passkeyUpgradeVerificationPath else { return nil } + return URL(string: issuer + passkeyUpgradeVerificationPath) + } + + public var passkeyAccountCreationOptionsURL: URL? { + guard let passkeyAccountCreationOptionsPath else { return nil } + return URL(string: issuer + passkeyAccountCreationOptionsPath) + } + + public var passkeyAccountCreationVerifyURL: URL? { + guard let passkeyAccountCreationVerifyPath else { return nil } + return URL(string: issuer + passkeyAccountCreationVerifyPath) + } + public var redirectScheme: String? { URL(string: redirectURI)?.scheme } + + public func uiSchemaURL(flow: AuthUISchema.Flow) -> URL? { + guard let uiSchemaPath else { return nil } + var components = URLComponents(string: issuer + uiSchemaPath + "/" + flow.rawValue) + components?.queryItems = [URLQueryItem(name: "client_id", value: clientID)] + return components?.url + } } diff --git a/Sources/RxAuthSwift/UISchema.swift b/Sources/RxAuthSwift/UISchema.swift new file mode 100644 index 0000000..e9d88ed --- /dev/null +++ b/Sources/RxAuthSwift/UISchema.swift @@ -0,0 +1,104 @@ +import Foundation + +/// Server-driven UI schema describing the sign-in or sign-up form. +/// +/// Fetched from `GET {issuer}/api/auth/ui-schema/{flow}?client_id=...`. +/// Lets the backend evolve labels, validation, and supported auth methods +/// without re-shipping the native client. +public struct AuthUISchema: Codable, Sendable, Equatable { + public enum Flow: String, Codable, Sendable, Equatable { + case signin + case signup + } + + public let flow: Flow + public let title: String + public let submitLabel: String + public let fields: [Field] + public let supportedMethods: [SupportedMethod] + public let links: [Link]? + + public struct Field: Codable, Sendable, Equatable, Identifiable { + public enum FieldType: String, Codable, Sendable, Equatable { + case text + case email + case password + case name + } + + public let key: String + public let label: String + public let placeholder: String? + public let type: FieldType + public let isPassword: Bool + public let required: Bool + public let autocomplete: String? + public let validation: Validation? + + public var id: String { key } + } + + public struct Validation: Codable, Sendable, Equatable { + public let minLength: Int? + public let maxLength: Int? + public let pattern: String? + public let patternMessage: String? + } + + public struct SupportedMethod: Codable, Sendable, Equatable, Identifiable { + public enum MethodID: String, Codable, Sendable, Equatable { + case password + case passkey + /// WWDC 2025 / iOS 26 / macOS 26 `ASAuthorizationAccountCreationProvider` + /// — the OS shows a native sheet that collects email/phone/name + /// from iCloud, no form fields needed in the app. Emit this method + /// only on the signup flow. + case passkeyAccountCreation = "passkey_account_creation" + } + + public let id: MethodID + public let label: String + public let primary: Bool + } + + public struct Link: Codable, Sendable, Equatable, Identifiable { + public let id: String + public let label: String + public let href: String + } +} + +extension AuthUISchema.Field { + /// Validate `value` against this field's rules. Returns a user-facing + /// error string when invalid, or nil when the value passes. + public func validate(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + + if required && trimmed.isEmpty { + return "\(label) is required" + } + + // Optional empty fields are valid. + if !required && trimmed.isEmpty { + return nil + } + + if let v = validation { + if let min = v.minLength, value.count < min { + return "\(label) must be at least \(min) characters" + } + if let max = v.maxLength, value.count > max { + return "\(label) must be at most \(max) characters" + } + if let pattern = v.pattern, + let regex = try? NSRegularExpression(pattern: pattern), + regex.firstMatch( + in: value, + range: NSRange(value.startIndex..: View { @Bindable private var manager: OAuthManager @State private var mode: NativeAuthMode = .signIn - @State private var username = "" - @State private var password = "" - @State private var name = "" + @State private var fieldValues: [String: String] = [:] + @State private var fieldErrors: [String: String] = [:] @State private var isAppearing = false + @State private var hasAttemptedSchemaLoad = false #if os(macOS) - @FocusState private var focusedField: CredentialField? + @FocusState private var focusedField: String? @Namespace private var glassNamespace #endif private let appearance: RxSignInAppearance @@ -51,10 +51,12 @@ public struct RxSignInView: View { public var body: some View { ZStack { #if os(macOS) - AnimatedGradientBackground( - accentColor: appearance.accentColor, - secondaryColor: appearance.secondaryColor - ) + if appearance.showsAnimatedBackground { + AnimatedGradientBackground( + accentColor: appearance.accentColor, + secondaryColor: appearance.secondaryColor + ) + } macOSContent #else AnimatedGradientBackground( @@ -115,6 +117,98 @@ public struct RxSignInView: View { } } .animation(.default, value: manager.infoMessage) + .sheet(isPresented: Binding( + get: { manager.pendingPasskeyOffer }, + set: { newValue in + if !newValue && manager.pendingPasskeyOffer { + manager.skipPasskeyUpgradeOffer() + onAuthSuccess?() + } + } + )) { + passkeyOfferSheet + } + } + + private var passkeyOfferSheet: some View { + VStack(spacing: 20) { + ZStack { + Circle() + .fill(appearance.accentColor.opacity(0.18)) + .frame(width: 72, height: 72) + Image(systemName: "person.badge.key.fill") + .font(.system(size: 32, weight: .semibold)) + .foregroundStyle(appearance.accentColor) + } + .padding(.top, 24) + + VStack(spacing: 8) { + Text("Add a passkey?") + .font(.title3.weight(.semibold)) + Text("Sign in next time with Touch ID or your iCloud Keychain — no password needed.") + .font(.callout) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 24) + + if let errorMessage = manager.errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + + VStack(spacing: 10) { + Button { + Task { + do { + try await manager.addPasskeyForCurrentUser() + onAuthSuccess?() + } catch { + onAuthFailed?(error) + } + } + } label: { + ZStack { + Text("Add Passkey") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.white) + .opacity(manager.isAuthenticating ? 0 : 1) + if manager.isAuthenticating { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + } + } + .frame(maxWidth: .infinity) + .frame(height: 44) + .background(appearance.accentColor, in: .rect(cornerRadius: 12)) + } + .buttonStyle(.plain) + .keyboardShortcut(.defaultAction) + .disabled(manager.isAuthenticating) + .accessibilityIdentifier("add-passkey-button") + + Button { + manager.skipPasskeyUpgradeOffer() + onAuthSuccess?() + } label: { + Text("Maybe later") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + .frame(height: 36) + } + .buttonStyle(.plain) + .disabled(manager.isAuthenticating) + .accessibilityIdentifier("skip-passkey-button") + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + .frame(width: 340) } private func infoOverlay(message: String) -> some View { @@ -257,9 +351,15 @@ public struct RxSignInView: View { .glassEffectID("header-icon", in: glassNamespace) VStack(spacing: 6) { - Text(appearance.title) - .font(.title2.weight(.semibold)) - .foregroundStyle(.primary) + if let schemaTitle = currentSchema?.title, !schemaTitle.isEmpty { + Text(schemaTitle) + .font(.title2.weight(.semibold)) + .foregroundStyle(.primary) + } else { + Text(appearance.title) + .font(.title2.weight(.semibold)) + .foregroundStyle(.primary) + } Text(appearance.subtitle) .font(.callout) @@ -295,58 +395,156 @@ public struct RxSignInView: View { } } + private var currentSchema: AuthUISchema? { + mode == .signIn ? manager.signInSchema : manager.signUpSchema + } + private var nativeCredentialForm: some View { VStack(spacing: 18) { modeTogglePill - GlassEffectContainer(spacing: 12) { - VStack(spacing: 12) { - if mode == .signUp { - credentialField( - systemImage: "person.text.rectangle", - placeholder: appearance.namePlaceholder, - text: $name, - field: .name, - accessibilityIdentifier: "name-field" - ) - .glassEffectTransition(.matchedGeometry) - .transition(.opacity.combined(with: .scale(scale: 0.95)).combined(with: .move(edge: .top))) + if let schema = currentSchema { + GlassEffectContainer(spacing: 12) { + VStack(spacing: 12) { + ForEach(schema.fields) { field in + renderField(field) + } } + } + .animation(.spring(response: 0.4, dampingFraction: 0.85), value: mode) - credentialField( - systemImage: "person.crop.circle", - placeholder: appearance.usernamePlaceholder, - text: $username, - field: .username, - accessibilityIdentifier: "username-field" - ) - - secureCredentialField( - systemImage: "lock.fill", - placeholder: appearance.passwordPlaceholder, - text: $password - ) + GlassEffectContainer(spacing: 16) { + VStack(spacing: 14) { + let primaryMethods = schema.supportedMethods.filter { $0.primary } + let secondaryMethods = schema.supportedMethods.filter { !$0.primary } + let orderedMethods = primaryMethods + secondaryMethods + + ForEach(Array(orderedMethods.enumerated()), id: \.element.id) { index, method in + if index == primaryMethods.count, !primaryMethods.isEmpty, !secondaryMethods.isEmpty { + orDivider + } + methodButton(method, schema: schema) + .glassEffectTransition(.materialize) + } + } } + .padding(.top, 6) + } else if manager.isLoadingSchema || !hasAttemptedSchemaLoad { + ProgressView() + .padding(.vertical, 40) + } else { + schemaErrorFallback } - .animation(.spring(response: 0.4, dampingFraction: 0.85), value: mode) + } + .task(id: mode) { + if currentSchema == nil { + await manager.loadUISchema() + } + hasAttemptedSchemaLoad = true + } + } - GlassEffectContainer(spacing: 16) { - VStack(spacing: 14) { - primaryButton - .keyboardShortcut(.defaultAction) + private var schemaErrorFallback: some View { + VStack(spacing: 8) { + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + Text("Couldn't load sign-in form") + .font(.callout) + .foregroundStyle(.secondary) + Button("Retry") { + Task { await manager.loadUISchema() } + } + .buttonStyle(.borderedProminent) + } + .padding(.vertical, 32) + } - if showsPasskeyButton { - orDivider + @ViewBuilder + private func renderField(_ field: AuthUISchema.Field) -> some View { + let binding = Binding( + get: { fieldValues[field.key] ?? "" }, + set: { newValue in + fieldValues[field.key] = newValue + fieldErrors[field.key] = nil + } + ) + VStack(alignment: .leading, spacing: 4) { + if field.isPassword { + secureCredentialField(field: field, text: binding) + } else { + credentialField(field: field, text: binding) + } + if let error = fieldErrors[field.key] { + Text(error) + .font(.caption) + .foregroundStyle(.red) + .padding(.leading, 14) + .transition(.opacity) + } + } + } - passkeyButton - .glassEffectTransition(.materialize) + private func methodButton(_ method: AuthUISchema.SupportedMethod, schema: AuthUISchema) -> some View { + Button { + switch method.id { + case .password: + submitPrimary(schema: schema) + case .passkey: + submitPasskeyAction() + case .passkeyAccountCreation: + submitPasskeyAccountCreationAction() + } + } label: { + ZStack { + HStack(spacing: 8) { + if method.id == .passkey || method.id == .passkeyAccountCreation { + Image(systemName: "person.badge.key.fill") + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(method.primary ? Color.white : appearance.accentColor) } + Text(method.label) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(method.primary ? Color.white : .primary) + } + .opacity(manager.isAuthenticating ? 0 : 1) + + if manager.isAuthenticating, isCurrentSubmitMethod(method) { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) } } - .padding(.top, 6) + .frame(maxWidth: .infinity) + .frame(height: 44) + .glassEffect( + method.primary + ? .regular.tint(appearance.accentColor).interactive() + : .regular.interactive(), + in: .rect(cornerRadius: 12) + ) + } + .buttonStyle(.plain) + .glassEffectID("method-\(method.id.rawValue)", in: glassNamespace) + .disabled(manager.isAuthenticating) + .opacity(manager.isAuthenticating ? 0.7 : 1) + .keyboardShortcut(method.primary ? .defaultAction : nil) + .accessibilityIdentifier(accessibilityIdentifier(for: method)) + } + + private func accessibilityIdentifier(for method: AuthUISchema.SupportedMethod) -> String { + switch method.id { + case .password: return "sign-in-button" + case .passkey: return "passkey-sign-in-button" + case .passkeyAccountCreation: return "passkey-account-creation-button" } } + private func isCurrentSubmitMethod(_ method: AuthUISchema.SupportedMethod) -> Bool { + // Best-effort indicator; primary method shows progress when authenticating. + method.primary + } + private var modeTogglePill: some View { HStack(spacing: 0) { modeTab(.signIn, label: "Sign In") @@ -383,32 +581,6 @@ public struct RxSignInView: View { .buttonStyle(.plain) } - private var primaryButton: some View { - Button { - submitPrimary() - } label: { - ZStack { - Text(mode == .signIn ? appearance.signInButtonTitle : appearance.signUpButtonTitle) - .font(.system(size: 14, weight: .semibold)) - .opacity(manager.isAuthenticating ? 0 : 1) - - if manager.isAuthenticating { - ProgressView() - .progressViewStyle(.circular) - .controlSize(.small) - } - } - .frame(maxWidth: .infinity) - .frame(height: 44) - .glassEffect(.regular.tint(appearance.accentColor).interactive(), in: .rect(cornerRadius: 12)) - } - .buttonStyle(.plain) - .glassEffectID("primary-button", in: glassNamespace) - .disabled(manager.isAuthenticating) - .opacity(manager.isAuthenticating ? 0.7 : 1) - .accessibilityIdentifier("sign-in-button") - } - private var orDivider: some View { HStack(spacing: 10) { Rectangle() @@ -424,50 +596,26 @@ public struct RxSignInView: View { } } - private var passkeyButton: some View { - Button { - submitPasskeyAction() - } label: { - HStack(spacing: 8) { - Image(systemName: "person.badge.key.fill") - .font(.system(size: 14, weight: .medium)) - .foregroundStyle(appearance.accentColor) - Text(mode == .signIn ? appearance.passkeyButtonTitle : appearance.passkeySignupButtonTitle) - .font(.system(size: 14, weight: .medium)) - } - .frame(maxWidth: .infinity) - .frame(height: 44) - .glassEffect(.regular.interactive(), in: .rect(cornerRadius: 12)) - } - .buttonStyle(.plain) - .glassEffectID("passkey-button", in: glassNamespace) - .disabled(manager.isAuthenticating) - .accessibilityIdentifier("passkey-sign-in-button") - } - private func credentialField( - systemImage: String, - placeholder: LocalizedStringKey, - text: Binding, - field: CredentialField, - accessibilityIdentifier: String + field: AuthUISchema.Field, + text: Binding ) -> some View { - let isFocused = focusedField == field + let isFocused = focusedField == field.key return HStack(spacing: 12) { - Image(systemName: systemImage) + Image(systemName: iconName(for: field)) .font(.system(size: 15, weight: .medium)) .foregroundStyle(isFocused ? appearance.accentColor : Color.secondary) .frame(width: 20) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isFocused) .scaleEffect(isFocused ? 1.1 : 1.0) - TextField(placeholder, text: text) + TextField(field.placeholder ?? field.label, text: text) .textFieldStyle(.plain) .font(.system(size: 14)) - .focused($focusedField, equals: field) + .focused($focusedField, equals: field.key) .submitLabel(.next) - .onSubmit(advanceFocus) - .accessibilityIdentifier(accessibilityIdentifier) + .onSubmit { advanceFocus(from: field.key) } + .accessibilityIdentifier("\(field.key)-field") } .padding(.horizontal, 14) .frame(height: 44) @@ -475,30 +623,31 @@ public struct RxSignInView: View { isFocused ? .regular.tint(appearance.accentColor.opacity(0.2)).interactive() : .regular.interactive(), in: .rect(cornerRadius: 12) ) - .glassEffectID("field-\(field)", in: glassNamespace) + .glassEffectID("field-\(field.key)", in: glassNamespace) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isFocused) } private func secureCredentialField( - systemImage: String, - placeholder: LocalizedStringKey, + field: AuthUISchema.Field, text: Binding ) -> some View { - let isFocused = focusedField == .password + let isFocused = focusedField == field.key return HStack(spacing: 12) { - Image(systemName: systemImage) + Image(systemName: iconName(for: field)) .font(.system(size: 15, weight: .medium)) .foregroundStyle(isFocused ? appearance.accentColor : Color.secondary) .frame(width: 20) .animation(.spring(response: 0.3, dampingFraction: 0.7), value: isFocused) .scaleEffect(isFocused ? 1.1 : 1.0) - SecureField(placeholder, text: text) + SecureField(field.placeholder ?? field.label, text: text) .textFieldStyle(.plain) .font(.system(size: 14)) - .focused($focusedField, equals: .password) - .onSubmit(submitPrimary) - .accessibilityIdentifier("password-field") + .focused($focusedField, equals: field.key) + .onSubmit { + if let schema = currentSchema { submitPrimary(schema: schema) } + } + .accessibilityIdentifier("\(field.key)-field") } .padding(.horizontal, 14) .frame(height: 44) @@ -506,45 +655,82 @@ public struct RxSignInView: View { isFocused ? .regular.tint(appearance.accentColor.opacity(0.2)).interactive() : .regular.interactive(), in: .rect(cornerRadius: 12) ) - .glassEffectID("field-password", in: glassNamespace) + .glassEffectID("field-\(field.key)", in: glassNamespace) .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isFocused) } - private func advanceFocus() { - switch focusedField { - case .name: - focusedField = .username - case .username: - focusedField = .password - case .password, .none: - submitPrimary() + private func iconName(for field: AuthUISchema.Field) -> String { + switch field.type { + case .email: return "envelope" + case .password: return "lock.fill" + case .name: return "person.text.rectangle" + case .text: return "person.crop.circle" + } + } + + private func advanceFocus(from key: String) { + guard let schema = currentSchema, + let idx = schema.fields.firstIndex(where: { $0.key == key }) + else { return } + let next = idx + 1 + if next < schema.fields.count { + focusedField = schema.fields[next].key + } else { + submitPrimary(schema: schema) } } - private var showsPasskeyButton: Bool { - switch mode { - case .signIn: - return manager.supportsPasskeyAuthentication - case .signUp: - return manager.supportsPasskeyRegistration + private func value(forKey key: String) -> String { + fieldValues[key] ?? "" + } + + private func primaryIdentifier(_ schema: AuthUISchema) -> String { + // Server-driven username/email is whichever non-password field comes first. + for field in schema.fields where !field.isPassword && field.type != .name { + return value(forKey: field.key) + } + return value(forKey: "email").isEmpty ? value(forKey: "username") : value(forKey: "email") + } + + private func validate(_ schema: AuthUISchema, requirePassword: Bool) -> Bool { + var errors: [String: String] = [:] + for field in schema.fields { + if !requirePassword && field.isPassword { continue } + if let error = field.validate(value(forKey: field.key)) { + errors[field.key] = error + } } + withAnimation(.easeInOut(duration: 0.2)) { + fieldErrors = errors + } + return errors.isEmpty } - private func submitPrimary() { + private func submitPrimary(schema: AuthUISchema) { + guard validate(schema, requirePassword: true) else { return } + let identifier = primaryIdentifier(schema) + let password = value(forKey: "password") + let name = value(forKey: "name") + Task { do { switch mode { case .signIn: - try await manager.authenticate(username: username, password: password) + try await manager.authenticate(username: identifier, password: password) onAuthSuccess?() case .signUp: - let result = try await manager.signUp(username: username, password: password, name: name) + let result = try await manager.signUp(username: identifier, password: password, name: name) switch result { case .authenticated: - onAuthSuccess?() + if !manager.pendingPasskeyOffer { + onAuthSuccess?() + } + // Otherwise the sheet bound to `manager.pendingPasskeyOffer` + // takes over; it will call `onAuthSuccess` once the user + // either adds a passkey or skips. case .emailVerificationRequired: - password = "" - name = "" + fieldValues["password"] = "" + fieldValues["name"] = "" withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { mode = .signIn } @@ -557,13 +743,18 @@ public struct RxSignInView: View { } private func submitPasskeyAction() { + guard let schema = currentSchema else { return } + guard validate(schema, requirePassword: false) else { return } + let identifier = primaryIdentifier(schema) + let name = value(forKey: "name") + Task { do { switch mode { case .signIn: - try await manager.authenticateWithPasskey(username: username) + try await manager.authenticateWithPasskey(username: identifier) case .signUp: - try await manager.signUpWithPasskey(username: username, name: name) + try await manager.signUpWithPasskey(username: identifier, name: name) } onAuthSuccess?() } catch { @@ -571,6 +762,21 @@ public struct RxSignInView: View { } } } + + /// System-sheet account creation (iOS 26 / macOS 26): no fields, no + /// validation — the OS sheet collects email/name from iCloud. Only + /// reachable when the server emits the `passkey_account_creation` + /// method in the signup schema. + private func submitPasskeyAccountCreationAction() { + Task { + do { + try await manager.createAccountWithPasskey() + onAuthSuccess?() + } catch { + onAuthFailed?(error) + } + } + } #endif } @@ -579,14 +785,6 @@ private enum NativeAuthMode: Hashable { case signUp } -#if os(macOS) -private enum CredentialField: Hashable { - case name - case username - case password -} -#endif - // MARK: - Previews #Preview("Default Appearance") { @@ -623,6 +821,66 @@ private enum CredentialField: Hashable { .preferredColorScheme(.dark) } +#Preview("No Animated Background") { + RxSignInView( + manager: OAuthManager( + configuration: RxAuthConfiguration( + issuer: "https://auth.example.com", + clientID: "preview-client", + redirectURI: "myapp://callback" + ) + ), + appearance: RxSignInAppearance( + accentColor: .blue, + secondaryColor: .purple, + showsAnimatedBackground: false + ) + ) + .preferredColorScheme(.dark) +} + +#if DEBUG +private struct GroupedMethodsPreview: View { + @State private var manager: OAuthManager = { + let manager = OAuthManager( + configuration: RxAuthConfiguration( + issuer: "https://auth.example.com", + clientID: "preview-client", + redirectURI: "myapp://callback" + ) + ) + let json = #""" + { + "flow": "signin", + "title": "Welcome back", + "submitLabel": "Sign In", + "fields": [ + { "key": "email", "label": "Email", "placeholder": "you@example.com", "type": "email", "isPassword": false, "required": true, "autocomplete": "email" }, + { "key": "password", "label": "Password", "placeholder": "••••••••", "type": "password", "isPassword": true, "required": true, "autocomplete": "current-password" } + ], + "supportedMethods": [ + { "id": "password", "label": "Sign In", "primary": true }, + { "id": "passkey", "label": "Sign in with Passkey", "primary": false }, + { "id": "passkey_account_creation", "label": "Use iCloud Keychain", "primary": false } + ] + } + """# + let signIn = try! JSONDecoder().decode(AuthUISchema.self, from: Data(json.utf8)) + manager._previewInject(signIn: signIn, signUp: nil) + return manager + }() + + var body: some View { + RxSignInView(manager: manager) + } +} + +#Preview("Grouped Methods (1 primary + 2 secondary)") { + GroupedMethodsPreview() + .preferredColorScheme(.dark) +} +#endif + #Preview("Custom Header") { RxSignInView( manager: OAuthManager( diff --git a/test/test/ContentView.swift b/test/test/ContentView.swift index cb0832e..5c45292 100644 --- a/test/test/ContentView.swift +++ b/test/test/ContentView.swift @@ -18,7 +18,11 @@ struct ContentView: View { case .unknown: ProgressView("Loading...") .task { - await manager.checkExistingAuth() + async let auth: Void = manager.checkExistingAuth() + async let schema: Void = { + _ = await manager.loadUISchema() + }() + _ = await (auth, schema) } case .unauthenticated: RxSignInView( diff --git a/test/test/testApp.swift b/test/test/testApp.swift index 2c14048..247ccd7 100644 --- a/test/test/testApp.swift +++ b/test/test/testApp.swift @@ -22,6 +22,10 @@ struct testApp: App { passkeyVerificationPath: "/api/oauth/passkey/authenticate/verify", passkeyRegistrationChallengePath: "/api/oauth/passkey/register/options", passkeyRegistrationVerificationPath: "/api/oauth/passkey/register/verify", + passkeyUpgradeChallengePath: "/api/oauth/passkey/upgrade/options", + passkeyUpgradeVerificationPath: "/api/oauth/passkey/upgrade/verify", + passkeyAccountCreationOptionsPath: "/api/oauth/passkey/account-creation/options", + passkeyAccountCreationVerifyPath: "/api/oauth/passkey/account-creation/verify", passkeyRelyingPartyIdentifier: "rxlab.app" ) From a5931c021311cbb3641fa2f993648e228d858d43 Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 22:52:52 +0800 Subject: [PATCH 6/7] ci: move swift test to self-hosted and strip entitlements for ad-hoc signing The package now targets .macOS(.v26) / .iOS(.v26), so xctest bundles built on the macos-latest runner link against macOS 26 and fail to load on the runner's older OS ("built for macOS 26.0 which is newer than running OS"). Drop swift test there and run it on the self-hosted macOS 26 runner. The test app now declares com.apple.developer.associated-domains, which ad-hoc signing cannot grant; launchd rejects the binary with Runningboard error 5. Pass CODE_SIGN_ENTITLEMENTS="" on the xcodebuild test commands so CI builds without the entitlement. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/ci.yml | 8 ++++++-- scripts/build-macos.sh | 8 ++++---- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 41c4659..50a2f76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -8,14 +8,14 @@ on: jobs: build-macos: - name: Build & Test (macOS) + name: Build (macOS) runs-on: macos-latest steps: - uses: actions/checkout@v6 - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable - - name: Build & Test macOS + - name: Build macOS run: bash scripts/build-macos.sh test-app: @@ -26,6 +26,8 @@ jobs: - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: latest-stable + - name: Run swift test + run: swift test - name: Write .env file env: ENV_B64: ${{ secrets.ENV_B64 }} @@ -38,6 +40,7 @@ jobs: -testPlan TestPlan \ -destination 'platform=macOS' \ CODE_SIGN_IDENTITY=- \ + CODE_SIGN_ENTITLEMENTS="" \ DEVELOPMENT_TEAM="" test-app-ios: @@ -77,6 +80,7 @@ jobs: -testPlan TestPlan \ -destination 'platform=iOS Simulator,id=${{ steps.sim.outputs.sim_id }}' \ CODE_SIGN_IDENTITY=- \ + CODE_SIGN_ENTITLEMENTS="" \ DEVELOPMENT_TEAM="" build-ios: diff --git a/scripts/build-macos.sh b/scripts/build-macos.sh index 5f01916..d6cffc6 100755 --- a/scripts/build-macos.sh +++ b/scripts/build-macos.sh @@ -6,7 +6,7 @@ swift build echo "macOS build succeeded." -echo "Running tests..." -swift test - -echo "All tests passed." +# Note: `swift test` is intentionally skipped here. The package targets +# .macOS(.v26), so xctest bundles are linked for macOS 26, which the +# GitHub-hosted `macos-latest` runner cannot load (its OS is older than +# the deployment target). Unit tests run on the self-hosted runner job. From 87f6c748fa8a765267651c9e45ddeb4537c4de4c Mon Sep 17 00:00:00 2001 From: sirily11 <32106111+sirily11@users.noreply.github.com> Date: Tue, 26 May 2026 22:57:46 +0800 Subject: [PATCH 7/7] test(macos): use stable accessibility identifiers for credential fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The native macOS sign-in form's SecureField placeholder is now driven by the server schema (defaults to "••••••••"), so the helper's lookup by "Enter your password" no longer matches. Use the accessibility identifiers that RxSignInView already sets ("email-field" / "password-field"). Co-Authored-By: Claude Opus 4.7 (1M context) --- test/testUITests/helper/signIn.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/testUITests/helper/signIn.swift b/test/testUITests/helper/signIn.swift index 6858fdc..d636289 100644 --- a/test/testUITests/helper/signIn.swift +++ b/test/testUITests/helper/signIn.swift @@ -66,11 +66,11 @@ extension XCUIApplication { emailField.typeText("\n") // Press Enter to move to next field #elseif os(macOS) - let emailField = textFields["you@example.com"].firstMatch + let emailField = textFields["email-field"].firstMatch let emailFieldExists = emailField.waitForExistence(timeout: 30) XCTAssertTrue(emailFieldExists, "Failed to sign in and reach dashboard") - let passwordField = self/*@START_MENU_TOKEN@*/ .secureTextFields["Enter your password"].firstMatch/*[[".groups",".secureTextFields[\"Password\"].firstMatch",".secureTextFields[\"Enter your password\"].firstMatch",".secureTextFields",".containing(.group, identifier: nil).firstMatch",".firstMatch"],[[[-1,2],[-1,1],[-1,3,2],[-1,0,1]],[[-1,2],[-1,1]],[[-1,5],[-1,4]]],[0]]@END_MENU_TOKEN@*/ + let passwordField = secureTextFields["password-field"].firstMatch emailField.click() emailField.typeText(testEmail)