Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions Hemera/Auth/OAuthFlowManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,29 @@ final class OAuthFlowManager {
let state = UUID().uuidString

// Same-origin redirect URI — HA accepts without fetching client_id
var redirectComponents = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
redirectComponents.path = "/hemera_callback"
guard var redirectComponents = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
throw AuthError.invalidServerURL
}

/**
Preserve any base path (e.g. subpath-hosted HA behind a reverse proxy)
by appending endpoint paths instead of replacing the server URL's path.
*/
let rawBasePath = redirectComponents.path
let basePath = rawBasePath.hasSuffix("/") ? String(rawBasePath.dropLast()) : rawBasePath

redirectComponents.path = basePath + "/hemera_callback"
redirectComponents.queryItems = nil
redirectComponents.fragment = nil
let redirectURI = redirectComponents.url!.absoluteString
guard let redirectURL = redirectComponents.url else {
throw AuthError.invalidServerURL
}
let redirectURI = redirectURL.absoluteString

var authComponents = URLComponents(url: serverURL, resolvingAgainstBaseURL: false)!
authComponents.path = "/auth/authorize"
guard var authComponents = URLComponents(url: serverURL, resolvingAgainstBaseURL: false) else {
throw AuthError.invalidServerURL
}
authComponents.path = basePath + "/auth/authorize"
authComponents.queryItems = [
URLQueryItem(name: "client_id", value: clientId),
URLQueryItem(name: "redirect_uri", value: redirectURI),
Expand Down
12 changes: 10 additions & 2 deletions Hemera/UI/Onboarding/ManualEntrySheet.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,12 +71,20 @@ struct ManualEntrySheet: View {
}
}
.presentationDetents([.medium])
.onDisappear { viewModel.errorMessage = nil }
}

private func connectAndDismiss() {
if viewModel.connectManual() {
onDismiss()
/**
Keep the sheet open if the attempt failed synchronously (e.g. an
invalid URL that passes field validation but is rejected by OAuth
preparation) so the inline error stays visible instead of the sheet
dismissing over an unshown error.
*/
guard viewModel.connectManual(), viewModel.errorMessage == nil else {
return
}
onDismiss()
}
}

Expand Down
18 changes: 17 additions & 1 deletion Hemera/UI/Onboarding/ServerSelectionView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ struct ServerSelectionView: View {
} message: {
Text(Localization.unencryptedMessage)
}
.alert(
Localization.connectionFailedTitle,
isPresented: Binding(
get: { viewModel.errorMessage != nil && activeSheet == nil },
set: { if !$0 { viewModel.errorMessage = nil } }
)
) {
Button(Localization.ok, role: .cancel) { viewModel.errorMessage = nil }
} message: {
Text(viewModel.errorMessage ?? "")
}
.sheet(item: $activeSheet, onDismiss: {
if viewModel.authSession != nil {
viewModel.cancelAuth()
Expand Down Expand Up @@ -151,7 +162,10 @@ struct ServerSelectionView: View {
iconColor: .blue,
title: Localization.enterManually,
subtitle: Localization.enterManuallySubtitle,
action: { activeSheet = .manualEntry }
action: {
viewModel.prepareManualEntry()
activeSheet = .manualEntry
}
) {
DisclosureIndicator()
}
Expand Down Expand Up @@ -179,6 +193,8 @@ private extension ServerSelectionView {
private extension ServerSelectionView {
enum Localization {
static let title = String(localized: "Connect to Server", comment: "Navigation title for the server connection screen")
static let connectionFailedTitle = String(localized: "Couldn’t Connect", comment: "Alert title shown when connecting to a Home Assistant server fails during onboarding")
static let ok = String(localized: "OK", comment: "Button dismissing the connection-failure alert")
static let signIn = String(localized: "Sign In", comment: "Navigation title for the OAuth sign-in screen")
static let cancel = String(localized: "Cancel", comment: "Button to cancel the current action")

Expand Down
4 changes: 4 additions & 0 deletions Hemera/UI/Onboarding/ServerSelectionViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ final class ServerSelectionViewModel {
self.init(authManager: ServiceLocator.shared.authManager)
}

func prepareManualEntry() {
errorMessage = nil
}

func startDiscovery() {
discovery.startDiscovery()
}
Expand Down
50 changes: 50 additions & 0 deletions HemeraTests/Auth/OAuthFlowManagerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import Foundation
import Testing
@testable import Hemera

struct OAuthFlowManagerTests {

// MARK: - No Base Path

@Test
func prepare_withNoBasePath_buildsRootLevelURLs() throws {
let session = try OAuthFlowManager().prepare(serverURL: URL(string: "https://ha.example.com:8123")!)

#expect(session.redirectURI == "https://ha.example.com:8123/hemera_callback")

let authComponents = URLComponents(url: session.authorizeURL, resolvingAgainstBaseURL: false)!
#expect(authComponents.path == "/auth/authorize")
#expect(redirectURI(in: authComponents) == "https://ha.example.com:8123/hemera_callback")
}

// MARK: - Base Path

@Test
func prepare_withBasePath_preservesBasePath() throws {
let session = try OAuthFlowManager().prepare(serverURL: URL(string: "https://example.com/ha")!)

#expect(session.redirectURI == "https://example.com/ha/hemera_callback")

let authComponents = URLComponents(url: session.authorizeURL, resolvingAgainstBaseURL: false)!
#expect(authComponents.path == "/ha/auth/authorize")
#expect(redirectURI(in: authComponents) == "https://example.com/ha/hemera_callback")
}

@Test
func prepare_withTrailingSlash_doesNotDoubleSlash() throws {
let session = try OAuthFlowManager().prepare(serverURL: URL(string: "https://example.com/ha/")!)

#expect(session.redirectURI == "https://example.com/ha/hemera_callback")

let authComponents = URLComponents(url: session.authorizeURL, resolvingAgainstBaseURL: false)!
#expect(authComponents.path == "/ha/auth/authorize")
}
}

// MARK: - Helpers

private extension OAuthFlowManagerTests {
func redirectURI(in components: URLComponents) -> String? {
components.queryItems?.first { $0.name == "redirect_uri" }?.value
}
}
53 changes: 53 additions & 0 deletions HemeraTests/UI/Onboarding/ServerSelectionViewModelTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,59 @@ struct ServerSelectionViewModelTests {
#expect(viewModel.pendingHTTPURL == nil)
}

// MARK: - OAuth Callback Error Surfacing

@Test
func handleOAuthCallback_whenCallbackFails_setsErrorMessageAndDoesNotAuthenticate() async {
let authManager = MockAuthManager()
let viewModel = ServerSelectionViewModel(authManager: authManager)
let serverURL = URL(string: "https://ha.example.com:8123")!
let session = OAuthFlowManager.AuthSession(
authorizeURL: serverURL,
redirectURI: "https://ha.example.com:8123/hemera_callback",
state: "expected-state",
clientId: serverURL.absoluteString,
serverURL: serverURL
)
// Callback carries a mismatched state, so handleCallback throws before any token exchange.
let callbackURL = URL(string: "https://ha.example.com:8123/hemera_callback?state=wrong&code=abc")!

await viewModel.handleOAuthCallback(url: callbackURL, session: session)

#expect(viewModel.errorMessage != nil)
#expect(viewModel.isConnecting == false)
#expect(authManager.didAuthenticateCallCount == 0)
}

@Test
func connectManual_withCredentialsInURL_returnsTrueButSetsError() {
// The URL passes field validation but OAuth preparation rejects the
// embedded credentials, so connectManual returns true yet leaves an
// error and no session — the manual sheet relies on this to stay open.
let viewModel = makeViewModel()
viewModel.manualURL = "https://user:pass@ha.example.com"
let result = viewModel.connectManual()
#expect(result == true)
#expect(viewModel.errorMessage != nil)
#expect(viewModel.authSession == nil)
}

@Test
func prepareManualEntry_clearsStaleErrorMessage() {
let viewModel = makeViewModel()
viewModel.errorMessage = "stale error"
viewModel.prepareManualEntry()
#expect(viewModel.errorMessage == nil)
}

@Test
func startOAuth_clearsPreviousErrorMessage() {
let viewModel = makeViewModel()
viewModel.errorMessage = "stale error"
viewModel.startOAuth(url: URL(string: "https://ha.example.com:8123")!)
#expect(viewModel.errorMessage == nil)
}

// MARK: - Auth Cancellation

@Test
Expand Down
Loading