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/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..76a5c59 100644 --- a/Sources/RxAuthSwift/OAuthManager.swift +++ b/Sources/RxAuthSwift/OAuthManager.swift @@ -1,3 +1,4 @@ +import AuthenticationServices import Foundation import Logging import Observation @@ -8,7 +9,44 @@ 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 + 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() { + errorMessage = nil + } + + /// Clears the current informational message + public func clearInfo() { + infoMessage = 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 + } + 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 @@ -66,21 +104,155 @@ 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") + scheduleAutomaticPasskeyUpgrade() + } 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 + } + } + + @discardableResult + public func signUp(username: String, password: String, name: String? = nil) async throws -> SignupResult { + isAuthenticating = true + errorMessage = nil + infoMessage = nil + defer { isAuthenticating = false } + + do { + let result = try await exchangeSignupForTokens(username: username, password: password, name: name) + 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)") + } + return result + } catch { + errorMessage = error.localizedDescription + throw error + } + } + + public func signUpWithPasskey(username: String, name: String? = nil) async throws { + isAuthenticating = true + errorMessage = nil + defer { isAuthenticating = false } + + do { + try await exchangePasskeyRegistrationForTokens(username: username, name: name) + logger.info("Native passkey signup completed successfully") + } catch { + errorMessage = error.localizedDescription + throw error } + } - logger.info("Starting OAuth authentication flow") + /// 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 } - let callbackURL = try await platformAuthenticate(url: authorizeURL, callbackScheme: callbackScheme) - try await handleCallback(url: callbackURL, codeVerifier: codeVerifier) + 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 { @@ -114,12 +286,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 +345,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 +368,520 @@ 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 -> SignupResult { + 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: " ") + )) + + switch try await sendSignupRequest(request) { + case .tokens(let tokenResponse): + try saveTokens(tokenResponse) + try await fetchUserInfo() + // 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) + } + } + + 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, + redirectURI: configuration.redirectURI, + 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, + sessionID: options.sessionID, + scope: configuration.scopes.joined(separator: " "), + 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, + redirectURI: configuration.redirectURI, + 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, + sessionID: options.sessionID, + scope: configuration.scopes.joined(separator: " "), + registration: registration + )) + + let tokenResponse = try await sendTokenRequest(verificationRequest) + try saveTokens(tokenResponse) + try await fetchUserInfo() + + authState = .authenticated + startTokenRefreshTimer() + #else + throw OAuthError.passkeyUnavailable + #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 @@ -230,6 +914,73 @@ 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)") + + 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 + // 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) + + guard let httpResponse = response as? HTTPURLResponse else { + throw OAuthError.tokenExchangeFailed("Invalid response") + } + + 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)") + + 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) + } + + 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 @@ -276,6 +1027,54 @@ 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" + } +} + +// 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? @@ -289,3 +1088,391 @@ private struct TokenResponse: Decodable { case tokenType = "token_type" } } + +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 + } +} + +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 sessionID: String? + let relyingPartyIdentifier: String? + let allowedCredentialIDs: [String] + + enum CodingKeys: String, CodingKey { + case challenge + case sessionID + case sessionId + case requestID + case requestId + case relyingPartyIdentifier + case relyingPartyId + 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) + 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) + ?? 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 if let credentials = try container.decodeIfPresent([AllowedCredential].self, forKey: .allowCredentials) { + allowedCredentialIDs = credentials.map(\.id) + } else { + allowedCredentialIDs = [] + } + } + + private struct AllowedCredential: Decodable { + let id: String + } +} + +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 + } +} + +private struct PasskeyRegistrationChallengeResponse: Decodable { + let challenge: String + let userID: String + let username: 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) + + 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) + + 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 sessionID: String? + let scope: String? + let credential: Credential + + enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case sessionID = "session_id" + case scope + case credential + } + + 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 sessionID: String? + let scope: String? + let credential: Credential + + enum CodingKeys: String, CodingKey { + case clientID = "client_id" + case sessionID = "session_id" + case scope + case credential + } + + 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? + } + } +} + +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) + self.init( + clientID: clientID, + 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, sessionID: String?, scope: String?, registration: PasskeyRegistration) { + let credentialID = Base64URL.encode(registration.credentialID) + self.init( + clientID: clientID, + 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) + ) + ) + ) + } +} +#endif + +private extension Optional where Wrapped == String { + var nilIfBlank: String? { + guard let value = self?.trimmingCharacters(in: .whitespacesAndNewlines), !value.isEmpty else { + return nil + } + 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 new file mode 100644 index 0000000..50c8292 --- /dev/null +++ b/Sources/RxAuthSwift/Platform/MacOSPasskeyAuthenticator.swift @@ -0,0 +1,394 @@ +#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? +} + +/// 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 15b1c33..450b6b7 100644 --- a/Sources/RxAuthSwift/RxAuthConfiguration.swift +++ b/Sources/RxAuthSwift/RxAuthConfiguration.swift @@ -9,6 +9,18 @@ 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 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 @@ -20,6 +32,18 @@ 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, + 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 @@ -29,6 +53,18 @@ 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.passkeyUpgradeChallengePath = passkeyUpgradeChallengePath + self.passkeyUpgradeVerificationPath = passkeyUpgradeVerificationPath + self.passkeyAccountCreationOptionsPath = passkeyAccountCreationOptionsPath + self.passkeyAccountCreationVerifyPath = passkeyAccountCreationVerifyPath + self.passkeyRelyingPartyIdentifier = passkeyRelyingPartyIdentifier + self.uiSchemaPath = uiSchemaPath self.keychainServiceName = keychainServiceName } @@ -44,7 +80,63 @@ 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 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.. 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..016eafb 100644 --- a/Sources/RxAuthSwiftUI/RxSignInAppearance.swift +++ b/Sources/RxAuthSwiftUI/RxSignInAppearance.swift @@ -1,27 +1,48 @@ 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 var showsAnimatedBackground: Bool 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 + secondaryColor: Color = .purple, + showsAnimatedBackground: Bool = true ) { self.icon = icon 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 + self.showsAnimatedBackground = showsAnimatedBackground } public enum SignInIcon: Sendable { diff --git a/Sources/RxAuthSwiftUI/RxSignInView.swift b/Sources/RxAuthSwiftUI/RxSignInView.swift index 9244d23..91a633e 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 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: String? + @Namespace private var glassNamespace + #endif private let appearance: RxSignInAppearance private let customHeader: Header? private let onAuthSuccess: (() -> Void)? @@ -41,53 +50,276 @@ public struct RxSignInView: View { public var body: some View { ZStack { + #if os(macOS) + if appearance.showsAnimatedBackground { + 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 - } - - Spacer() - - // Error banner - if let errorMessage = manager.errorMessage { - AuthErrorBanner(message: errorMessage) - .padding(.horizontal, 24) - .transition(.move(edge: .top).combined(with: .opacity)) - } - - // 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) + 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) } - .padding(.horizontal, 32) - .padding(.bottom, 48) + .frame(maxWidth: .infinity, minHeight: 580) + } + .onAppear { + withAnimation(.easeOut(duration: 0.5)) { + isAppearing = true + } + } + + // Error overlay + 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.errorMessage) + .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 { + 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 { + HStack(spacing: 12) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(.red) + + 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() + + 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) + } } + #endif @ViewBuilder private var defaultHeader: some View { @@ -111,6 +343,446 @@ 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) { + 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) + .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 currentSchema: AuthUISchema? { + mode == .signIn ? manager.signInSchema : manager.signUpSchema + } + + private var nativeCredentialForm: some View { + VStack(spacing: 18) { + modeTogglePill + + 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) + + 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 + } + } + .task(id: mode) { + if currentSchema == nil { + await manager.loadUISchema() + } + hasAttemptedSchemaLoad = true + } + } + + 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) + } + + @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) + } + } + } + + 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) + } + } + .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") + 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 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 func credentialField( + field: AuthUISchema.Field, + text: Binding + ) -> some View { + let isFocused = focusedField == field.key + return HStack(spacing: 12) { + 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(field.placeholder ?? field.label, text: text) + .textFieldStyle(.plain) + .font(.system(size: 14)) + .focused($focusedField, equals: field.key) + .submitLabel(.next) + .onSubmit { advanceFocus(from: field.key) } + .accessibilityIdentifier("\(field.key)-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-\(field.key)", in: glassNamespace) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isFocused) + } + + private func secureCredentialField( + field: AuthUISchema.Field, + text: Binding + ) -> some View { + let isFocused = focusedField == field.key + return HStack(spacing: 12) { + 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(field.placeholder ?? field.label, text: text) + .textFieldStyle(.plain) + .font(.system(size: 14)) + .focused($focusedField, equals: field.key) + .onSubmit { + if let schema = currentSchema { submitPrimary(schema: schema) } + } + .accessibilityIdentifier("\(field.key)-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-\(field.key)", in: glassNamespace) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isFocused) + } + + 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 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(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: identifier, password: password) + onAuthSuccess?() + case .signUp: + let result = try await manager.signUp(username: identifier, password: password, name: name) + switch result { + case .authenticated: + 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: + fieldValues["password"] = "" + fieldValues["name"] = "" + withAnimation(.spring(response: 0.4, dampingFraction: 0.85)) { + mode = .signIn + } + } + } + } catch { + onAuthFailed?(error) + } + } + } + + 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: identifier) + case .signUp: + try await manager.signUpWithPasskey(username: identifier, name: name) + } + onAuthSuccess?() + } catch { + onAuthFailed?(error) + } + } + } + + /// 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 +} + +private enum NativeAuthMode: Hashable { + case signIn + case signUp } // MARK: - Previews @@ -149,6 +821,66 @@ public struct RxSignInView: View { .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/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift b/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift index 17d7668..274c3d8 100644 --- a/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift +++ b/Tests/RxAuthSwiftTests/OAuthManagerTokenCleanupTests.swift @@ -315,4 +315,215 @@ 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 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(), + 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/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. 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/ContentView.swift b/test/test/ContentView.swift index 4099222..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( @@ -34,6 +38,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/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..247ccd7 100644 --- a/test/test/testApp.swift +++ b/test/test/testApp.swift @@ -16,7 +16,17 @@ struct testApp: App { let configuration = RxAuthConfiguration( issuer: "https://auth.rxlab.app", clientID: "client_8605760939b8494c8bfe29c77ae7ee7f", - redirectURI: "rxauth://callback" + redirectURI: "rxauth://callback", + 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", + 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" ) let resetAuth = ProcessInfo.processInfo.arguments.contains("--reset-auth") 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)