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"/>
-
+