diff --git a/src/PostIt.Tests/LoginPageViewModelTests.cs b/src/PostIt.Tests/LoginPageViewModelTests.cs index abd0916e..c04618c2 100644 --- a/src/PostIt.Tests/LoginPageViewModelTests.cs +++ b/src/PostIt.Tests/LoginPageViewModelTests.cs @@ -101,4 +101,80 @@ public class LoginPageViewModelTests var vm = new LoginPageViewModel(settings); Assert.False(vm.ConfigMissing); } + + [Theory] + [InlineData("https://yavsc.example.com/", "https://yavsc.example.com/.well-known/openid-configuration")] + [InlineData("https://yavsc.example.com", "https://yavsc.example.com/.well-known/openid-configuration")] + [InlineData("https://yavsc.example.com/sub/", "https://yavsc.example.com/sub/.well-known/openid-configuration")] + public void DiscoveryUrl_is_externalurl_plus_well_known(string authority, string expected) + { + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings { Authority = authority } + }; + var vm = new LoginPageViewModel(settings); + Assert.Equal(expected, vm.DiscoveryUrl); + // ExternalUrl is the slash-normalised form of Authority. + Assert.Equal(expected[..expected.LastIndexOf("/.well-known/openid-configuration")], vm.ExternalUrl); + } + + [Fact] + public void DiscoveryUrl_is_empty_when_authority_is_unset() + { + var vm = new LoginPageViewModel(new PostIt.Settings()); + Assert.Equal(string.Empty, vm.DiscoveryUrl); + } + + [Fact] + public async Task LoginAsync_failure_message_includes_discovery_url() + { + // Arrange: settings point at an unreachable authority; the test + // browser throws synchronously to guarantee the catch branch runs. + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://does-not-exist.invalid/", + ClientId = "postit-tests" + }, + RedirectUri = "http://127.0.0.1:7890/", + Scopes = new[] { "openid" } + }; + + var vm = new LoginPageViewModel(settings, () => throw new InvalidOperationException("boom")); + + // Act + await vm.LoginAsync(); + + // Assert: the surfaced error mentions the canonical discovery URL, + // so it can be copy-pasted into a browser to diagnose reachability. + Assert.NotNull(vm.StatusMessage); + Assert.StartsWith("Error:", vm.StatusMessage); + Assert.Contains( + "https://does-not-exist.invalid/.well-known/openid-configuration", + vm.StatusMessage); + } + + [Fact] + public async Task LoginAsync_reports_discovery_url_when_no_browser_available() + { + var settings = new PostIt.Settings + { + Authentication = new AuthenticationSettings + { + Authority = "https://yavsc.example.com/", + ClientId = "postit-tests" + }, + RedirectUri = "http://127.0.0.1:7890/", + Scopes = new[] { "openid" } + }; + + var vm = new LoginPageViewModel(settings, () => null); + + await vm.LoginAsync(); + + Assert.Contains( + "https://yavsc.example.com/.well-known/openid-configuration", + vm.StatusMessage); + } } \ No newline at end of file diff --git a/src/PostIt/PostIt/Settings/Settings.cs b/src/PostIt/PostIt/Settings/Settings.cs index 1d8e59c2..7aac405f 100644 --- a/src/PostIt/PostIt/Settings/Settings.cs +++ b/src/PostIt/PostIt/Settings/Settings.cs @@ -59,11 +59,20 @@ public partial class Settings : ObservableObject /// (no client secret). The browser implementation should be supplied /// per-platform by the caller. /// + /// + /// is normalised by + /// trimming any trailing slash before being handed to OidcClient. + /// OidcClient derives the discovery URL from + /// Authority + "/.well-known/openid-configuration"; leaving a + /// trailing slash in place would produce a double-slash URL that some + /// servers reject with 404. + /// internal OidcClientOptions GetOidcClientOptions(IdentityModel.OidcClient.Browser.IBrowser? browser = null) { + var authority = Authentication.Authority?.TrimEnd('/') ?? string.Empty; var options = new OidcClientOptions { - Authority = Authentication.Authority, + Authority = authority, ClientId = Authentication.ClientId, RedirectUri = RedirectUri, Scope = string.Join(' ', this.Scopes), diff --git a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs index 6e9f0d06..91535763 100644 --- a/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs +++ b/src/PostIt/PostIt/ViewModels/LoginPageViewModel.cs @@ -31,6 +31,23 @@ public partial class LoginPageViewModel : ViewModelBase public bool HasRegisterUrl => !string.IsNullOrEmpty(RegisterUrl); public bool HasForgotPasswordUrl => !string.IsNullOrEmpty(ForgotPasswordUrl); + /// + /// Canonical authority with any trailing + /// slash removed. Used as the base for both the OIDC discovery URL and the + /// human-facing Account URLs (Register / Forgot password). Empty when the + /// authority is not configured. + /// + public string ExternalUrl => BuildExternalUrl(string.Empty); + + /// + /// OIDC discovery URL the client actually calls during login: + /// ExternalUrl + "/.well-known/openid-configuration". Surfaced in + /// on failure so the operator can copy it + /// verbatim and verify reachability from a browser. + /// + public string DiscoveryUrl => + string.IsNullOrEmpty(ExternalUrl) ? string.Empty : ExternalUrl + "/.well-known/openid-configuration"; + /// /// True when the settings file is missing or Authentication.Authority /// is empty. The LoginPage surfaces a banner in that case and disables @@ -112,12 +129,21 @@ public partial class LoginPageViewModel : ViewModelBase ? Platform.DefaultRedirectUri : Settings.RedirectUri; + // Surface the discovery URL the client is about to call, so a + // failure (DNS, TLS, 404) can be diagnosed by pasting the URL + // straight into a browser. OidcClient computes the discovery + // URL as `Authority + /.well-known/openid-configuration`; we + // normalise the trailing slash here so the printed URL is + // exactly what IdentityModel will fetch. + if (!string.IsNullOrEmpty(DiscoveryUrl)) + StatusMessage = $"Discovering {DiscoveryUrl}"; + var browser = BrowserFactoryOverride is not null ? BrowserFactoryOverride.Invoke() : Platform.CreateBrowser?.Invoke(); if (browser is null) { - StatusMessage = "No browser is available on this platform."; + StatusMessage = $"No browser is available on this platform. (discovery: {DiscoveryUrl})"; return; } @@ -126,7 +152,7 @@ public partial class LoginPageViewModel : ViewModelBase if (loginResult.IsError) { - StatusMessage = loginResult.Error; + StatusMessage = $"{loginResult.Error} (discovery: {DiscoveryUrl})"; return; } @@ -144,7 +170,8 @@ public partial class LoginPageViewModel : ViewModelBase catch (Exception ex) { this.IsBusy = false; - StatusMessage = "Error: "+ex.Message; + var suffix = !string.IsNullOrEmpty(DiscoveryUrl) ? $" (discovery: {DiscoveryUrl})" : string.Empty; + StatusMessage = $"Error: {ex.Message}{suffix}"; } } } diff --git a/src/PostIt/PostIt/Views/LoginPage.axaml b/src/PostIt/PostIt/Views/LoginPage.axaml index 24be3131..fe0f714b 100644 --- a/src/PostIt/PostIt/Views/LoginPage.axaml +++ b/src/PostIt/PostIt/Views/LoginPage.axaml @@ -54,7 +54,12 @@ IsEnabled="{Binding HasForgotPasswordUrl}" Click="OnForgotPasswordClick"/> - +