diff --git a/src/modules/Elsa.Studio.Login/Extensions/StringExtensions.cs b/src/modules/Elsa.Studio.Login/Extensions/StringExtensions.cs
new file mode 100644
index 000000000..d064c22fc
--- /dev/null
+++ b/src/modules/Elsa.Studio.Login/Extensions/StringExtensions.cs
@@ -0,0 +1,29 @@
+namespace Elsa.Studio.Login.Extensions;
+
+///
+/// Provides a set of extension methods for .
+///
+internal static class StringExtensions
+{
+ ///
+ /// Ensures the string starts with the specified character when non-empty.
+ ///
+ public static string EnsureStartsWith(this string? value, string prefix)
+ {
+ if (string.IsNullOrEmpty(value))
+ return string.Empty;
+
+ return value.StartsWith(prefix) ? value : prefix + value;
+ }
+
+ ///
+ /// Ensures the string ends with the specified string when non-empty.
+ ///
+ public static string EnsureEndsWith(this string? value, string suffix)
+ {
+ if (string.IsNullOrEmpty(value))
+ return string.Empty;
+
+ return value.EndsWith(suffix) ? value : value + suffix;
+ }
+}
diff --git a/src/modules/Elsa.Studio.Login/Models/OpenIdConnectConfiguration.cs b/src/modules/Elsa.Studio.Login/Models/OpenIdConnectConfiguration.cs
index 509a0ad4f..741c14056 100644
--- a/src/modules/Elsa.Studio.Login/Models/OpenIdConnectConfiguration.cs
+++ b/src/modules/Elsa.Studio.Login/Models/OpenIdConnectConfiguration.cs
@@ -20,6 +20,14 @@ public class OpenIdConnectConfiguration
///
public required string EndSessionEndpoint { get; set; }
+ ///
+ /// A prefix to insert before /signin-oidc when constructing the redirect_uri for the authorization request.
+ /// Useful for sub-path deployments behind a reverse proxy, e.g. setting this to /workflow produces
+ /// https://myapp.com/workflow/signin-oidc. The value must start with /. When not set the redirect_uri
+ /// defaults to {origin}/signin-oidc.
+ ///
+ public string? RedirectUriPrefix { get; set; }
+
///
/// The client_id as which this application is registered with the authorization server
///
diff --git a/src/modules/Elsa.Studio.Login/Services/OpenIdConnectAuthorizationService.cs b/src/modules/Elsa.Studio.Login/Services/OpenIdConnectAuthorizationService.cs
index 1c09e3f84..579edaef4 100644
--- a/src/modules/Elsa.Studio.Login/Services/OpenIdConnectAuthorizationService.cs
+++ b/src/modules/Elsa.Studio.Login/Services/OpenIdConnectAuthorizationService.cs
@@ -1,4 +1,5 @@
-using Elsa.Studio.Login.Contracts;
+using Elsa.Studio.Login.Contracts;
+using Elsa.Studio.Login.Extensions;
using Elsa.Studio.Login.Models;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.WebUtilities;
@@ -21,7 +22,7 @@ public class OpenIdConnectAuthorizationService(IJwtAccessor jwtAccessor, IOption
public async Task RedirectToAuthorizationServer()
{
var config = configuration.Value;
- var redirectUri = new Uri(navigationManager.Uri).GetLeftPart(UriPartial.Authority) + "/signin-oidc";
+ var redirectUri = new Uri(navigationManager.Uri).GetLeftPart(UriPartial.Authority) + config.RedirectUriPrefix.EnsureStartsWith("/") + "/signin-oidc";
string url = config.AuthEndpoint + $"?client_id={WebUtility.UrlEncode(config.ClientId)}&redirect_uri={WebUtility.UrlEncode(redirectUri)}&response_type=code&scope={WebUtility.UrlEncode(String.Join(' ', config.Scopes))}";
if (config.UsePkce)
{
@@ -40,7 +41,7 @@ public async Task RedirectToAuthorizationServer()
public async Task ReceiveAuthorizationCode(string code, string? state, CancellationToken cancellationToken)
{
var config = configuration.Value;
- var redirectUri = new Uri(navigationManager.Uri).GetLeftPart(UriPartial.Authority) + "/signin-oidc";
+ var redirectUri = new Uri(navigationManager.Uri).GetLeftPart(UriPartial.Authority) + config.RedirectUriPrefix.EnsureStartsWith("/") + "/signin-oidc";
var formValues = new List>
{
@@ -50,7 +51,7 @@ public async Task ReceiveAuthorizationCode(string code, string? state, Cancellat
new("redirect_uri", redirectUri)
};
- if(!string.IsNullOrWhiteSpace(config.ClientSecret))
+ if (!string.IsNullOrWhiteSpace(config.ClientSecret))
{
formValues.Add(new KeyValuePair("client_secret", config.ClientSecret));
}
@@ -132,7 +133,7 @@ private static async Task ReadErrorSummaryAsync(HttpContent content, Can
if (summary.Length > MaxLoggedErrorLength)
summary = summary[..MaxLoggedErrorLength];
- return truncated ? $"{summary}…" : summary;
+ return truncated ? $"{summary}…" : summary;
}
private static async Task<(string Content, bool Truncated)> ReadContentSnippetAsync(HttpContent content, CancellationToken cancellationToken)
@@ -178,4 +179,5 @@ private static async Task ReadErrorSummaryAsync(HttpContent content, Can
return null;
}
+
}