diff --git a/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs b/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs index a0c253ce4..fbafdd8e5 100644 --- a/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs +++ b/sandbox/OpenIddict.Sandbox.AspNet.Server/Startup.cs @@ -179,6 +179,7 @@ public void Configuration(IAppBuilder app) { await manager.CreateAsync(new OpenIddictApplicationDescriptor { + ApplicationType = ApplicationTypes.Web, ClientId = "mvc", ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", ConsentType = ConsentTypes.Explicit, @@ -215,6 +216,7 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor { await manager.CreateAsync(new OpenIddictApplicationDescriptor { + ApplicationType = ApplicationTypes.Native, ClientId = "postman", ConsentType = ConsentTypes.Systematic, DisplayName = "Postman", diff --git a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs index a7ddd7332..f09047649 100644 --- a/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs +++ b/sandbox/OpenIddict.Sandbox.AspNetCore.Server/Worker.cs @@ -30,6 +30,9 @@ static async Task RegisterApplicationsAsync(IServiceProvider provider) { await manager.CreateAsync(new OpenIddictApplicationDescriptor { + // Note: the application must be registered as a native application to force OpenIddict + // to apply a relaxed redirect_uri validation policy that allows specifying a random port. + ApplicationType = ApplicationTypes.Native, ClientId = "console", ConsentType = ConsentTypes.Systematic, DisplayName = "Console client application", @@ -39,17 +42,9 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor }, RedirectUris = { - new Uri("http://localhost:49152/callback/login/local"), - new Uri("http://localhost:49153/callback/login/local"), - new Uri("http://localhost:49154/callback/login/local"), - new Uri("http://localhost:49155/callback/login/local"), - new Uri("http://localhost:49156/callback/login/local"), - new Uri("http://localhost:49157/callback/login/local"), - new Uri("http://localhost:49158/callback/login/local"), - new Uri("http://localhost:49159/callback/login/local"), - new Uri("http://localhost:49160/callback/login/local"), - new Uri("http://localhost:49161/callback/login/local"), - new Uri("http://localhost:49162/callback/login/local") + // Note: the port must not be explicitly specified as it is selected + // dynamically at runtime by the OpenIddict client system integration. + new Uri("http://localhost/callback/login/local") }, Permissions = { @@ -76,6 +71,7 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor { await manager.CreateAsync(new OpenIddictApplicationDescriptor { + ApplicationType = ApplicationTypes.Web, ClientId = "mvc", ClientSecret = "901564A5-E7FE-42CB-B10D-61EF6A8F3654", ConsentType = ConsentTypes.Explicit, @@ -116,6 +112,7 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor { await manager.CreateAsync(new OpenIddictApplicationDescriptor { + ApplicationType = ApplicationTypes.Native, ClientId = "winforms", ConsentType = ConsentTypes.Systematic, DisplayName = "WinForms client application", @@ -150,6 +147,7 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor { await manager.CreateAsync(new OpenIddictApplicationDescriptor { + ApplicationType = ApplicationTypes.Native, ClientId = "wpf", ConsentType = ConsentTypes.Systematic, DisplayName = "WPF client application", @@ -209,6 +207,7 @@ await manager.CreateAsync(new OpenIddictApplicationDescriptor { await manager.CreateAsync(new OpenIddictApplicationDescriptor { + ApplicationType = ApplicationTypes.Native, ClientId = "postman", ConsentType = ConsentTypes.Systematic, DisplayName = "Postman", diff --git a/src/OpenIddict.Abstractions/Descriptors/OpenIddictApplicationDescriptor.cs b/src/OpenIddict.Abstractions/Descriptors/OpenIddictApplicationDescriptor.cs index 0399d0f07..76391bbae 100644 --- a/src/OpenIddict.Abstractions/Descriptors/OpenIddictApplicationDescriptor.cs +++ b/src/OpenIddict.Abstractions/Descriptors/OpenIddictApplicationDescriptor.cs @@ -1,4 +1,5 @@ -using System.Globalization; +using System.ComponentModel; +using System.Globalization; using System.Text.Json; namespace OpenIddict.Abstractions; @@ -8,6 +9,11 @@ namespace OpenIddict.Abstractions; /// public class OpenIddictApplicationDescriptor { + /// + /// Gets or sets the application type associated with the application. + /// + public string? ApplicationType { get; set; } + /// /// Gets or sets the client identifier associated with the application. /// @@ -20,6 +26,11 @@ public class OpenIddictApplicationDescriptor /// public string? ClientSecret { get; set; } + /// + /// Gets or sets the client type associated with the application. + /// + public string? ClientType { get; set; } + /// /// Gets or sets the consent type associated with the application. /// @@ -61,7 +72,13 @@ public class OpenIddictApplicationDescriptor public HashSet Requirements { get; } = new(StringComparer.Ordinal); /// - /// Gets or sets the application type associated with the application. + /// Gets or sets the client type associated with the application. /// - public string? Type { get; set; } + [EditorBrowsable(EditorBrowsableState.Never)] + [Obsolete($"This property was replaced by {nameof(ClientType)} and will be removed in a future version.", true)] + public string? Type + { + get => ClientType; + set => ClientType = value; + } } diff --git a/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs b/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs index 6e184cffe..e167d7d57 100644 --- a/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs +++ b/src/OpenIddict.Abstractions/Managers/IOpenIddictApplicationManager.cs @@ -130,6 +130,17 @@ IAsyncEnumerable FindByPostLogoutRedirectUriAsync( IAsyncEnumerable FindByRedirectUriAsync( [StringSyntax(StringSyntaxAttribute.Uri)] string uri, CancellationToken cancellationToken = default); + /// + /// Retrieves the application type associated with an application. + /// + /// The application. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the application type of the application (by default, "web"). + /// + ValueTask GetApplicationTypeAsync(object application, CancellationToken cancellationToken = default); + /// /// Executes the specified query and returns the first element. /// @@ -307,6 +318,15 @@ IAsyncEnumerable FindByRedirectUriAsync( /// ValueTask> GetRequirementsAsync(object application, CancellationToken cancellationToken = default); + /// + /// Determines whether a given application has the specified application type. + /// + /// The application. + /// The expected application type. + /// The that can be used to abort the operation. + /// if the application has the specified application type, otherwise. + ValueTask HasApplicationTypeAsync(object application, string type, CancellationToken cancellationToken = default); + /// /// Determines whether a given application has the specified client type. /// diff --git a/src/OpenIddict.Abstractions/OpenIddictConstants.cs b/src/OpenIddict.Abstractions/OpenIddictConstants.cs index 9e8ba71a8..44dcb378f 100644 --- a/src/OpenIddict.Abstractions/OpenIddictConstants.cs +++ b/src/OpenIddict.Abstractions/OpenIddictConstants.cs @@ -24,6 +24,12 @@ public static class Algorithms public const string RsaSsaPssSha512 = "PS512"; } + public static class ApplicationTypes + { + public const string Native = "native"; + public const string Web = "web"; + } + public static class AuthenticationMethodReferences { public const string Face = "face"; diff --git a/src/OpenIddict.Abstractions/Stores/IOpenIddictApplicationStore.cs b/src/OpenIddict.Abstractions/Stores/IOpenIddictApplicationStore.cs index a146329f9..94bbfcd5a 100644 --- a/src/OpenIddict.Abstractions/Stores/IOpenIddictApplicationStore.cs +++ b/src/OpenIddict.Abstractions/Stores/IOpenIddictApplicationStore.cs @@ -95,6 +95,17 @@ IAsyncEnumerable FindByPostLogoutRedirectUriAsync( IAsyncEnumerable FindByRedirectUriAsync( [StringSyntax(StringSyntaxAttribute.Uri)] string uri, CancellationToken cancellationToken); + /// + /// Retrieves the application type associated with an application. + /// + /// The application. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the application type of the application (by default, "web"). + /// + ValueTask GetApplicationTypeAsync(TApplication application, CancellationToken cancellationToken); + /// /// Executes the specified query and returns the first element. /// @@ -277,6 +288,15 @@ IAsyncEnumerable ListAsync( Func, TState, IQueryable> query, TState state, CancellationToken cancellationToken); + /// + /// Sets the application type associated with an application. + /// + /// The application. + /// The application type associated with the application. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + ValueTask SetApplicationTypeAsync(TApplication application, string? type, CancellationToken cancellationToken); + /// /// Sets the client identifier associated with an application. /// diff --git a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs index 3860e6a1e..b883bd513 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs @@ -407,6 +407,32 @@ async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] Cance } } + /// + /// Retrieves the application type associated with an application. + /// + /// The application. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the application type of the application (by default, "web"). + /// + public virtual async ValueTask GetApplicationTypeAsync( + TApplication application, CancellationToken cancellationToken = default) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + var type = await Store.GetApplicationTypeAsync(application, cancellationToken); + if (string.IsNullOrEmpty(type)) + { + return ApplicationTypes.Web; + } + + return type; + } + /// /// Executes the specified query and returns the first element. /// @@ -744,6 +770,29 @@ public virtual ValueTask> GetRequirementsAsync( return Store.GetRequirementsAsync(application, cancellationToken); } + /// + /// Determines whether a given application has the specified application type. + /// + /// The application. + /// The expected application type. + /// The that can be used to abort the operation. + /// if the application has the specified application type, otherwise. + public virtual async ValueTask HasApplicationTypeAsync( + TApplication application, string type, CancellationToken cancellationToken = default) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (string.IsNullOrEmpty(type)) + { + throw new ArgumentException(SR.GetResourceString(SR.ID0209), nameof(type)); + } + + return string.Equals(await GetApplicationTypeAsync(application, cancellationToken), type, StringComparison.OrdinalIgnoreCase); + } + /// /// Determines whether a given application has the specified client type. /// @@ -908,9 +957,10 @@ public virtual async ValueTask PopulateAsync(TApplication application, throw new ArgumentNullException(nameof(descriptor)); } + await Store.SetApplicationTypeAsync(application, descriptor.ApplicationType, cancellationToken); await Store.SetClientIdAsync(application, descriptor.ClientId, cancellationToken); await Store.SetClientSecretAsync(application, descriptor.ClientSecret, cancellationToken); - await Store.SetClientTypeAsync(application, descriptor.Type, cancellationToken); + await Store.SetClientTypeAsync(application, descriptor.ClientType, cancellationToken); await Store.SetConsentTypeAsync(application, descriptor.ConsentType, cancellationToken); await Store.SetDisplayNameAsync(application, descriptor.DisplayName, cancellationToken); await Store.SetDisplayNamesAsync(application, descriptor.DisplayNames.ToImmutableDictionary(), cancellationToken); @@ -946,11 +996,12 @@ public virtual async ValueTask PopulateAsync( throw new ArgumentNullException(nameof(application)); } + descriptor.ApplicationType = await Store.GetApplicationTypeAsync(application, cancellationToken); descriptor.ClientId = await Store.GetClientIdAsync(application, cancellationToken); descriptor.ClientSecret = await Store.GetClientSecretAsync(application, cancellationToken); + descriptor.ClientType = await Store.GetClientTypeAsync(application, cancellationToken); descriptor.ConsentType = await Store.GetConsentTypeAsync(application, cancellationToken); descriptor.DisplayName = await Store.GetDisplayNameAsync(application, cancellationToken); - descriptor.Type = await Store.GetClientTypeAsync(application, cancellationToken); descriptor.Permissions.Clear(); descriptor.Permissions.UnionWith(await Store.GetPermissionsAsync(application, cancellationToken)); descriptor.Requirements.Clear(); @@ -1316,11 +1367,45 @@ public virtual async ValueTask ValidatePostLogoutRedirectUriAsync(TApplica foreach (var candidate in await Store.GetPostLogoutRedirectUrisAsync(application, cancellationToken)) { - // Note: the post_logout_redirect_uri must be compared using case-sensitive "Simple String Comparison". + // Note: the post_logout_redirect_uri must be compared using case-sensitive "Simple String Comparison", + // unless the application was explicitly registered as a native application. In this case, a second + // pass using the relaxed comparison method is performed if the application is a native application. if (string.Equals(candidate, uri, StringComparison.Ordinal)) { return true; } + + if (await HasApplicationTypeAsync(application, ApplicationTypes.Native, cancellationToken) && + Uri.TryCreate(uri, UriKind.Absolute, out Uri? left) && + Uri.TryCreate(candidate, UriKind.Absolute, out Uri? right) && + // Only apply the relaxed comparison if the URI specified by the client uses a + // non-default port and if the value resolved from the database doesn't specify one. + !left.IsDefaultPort && right.IsDefaultPort && + // The relaxed policy only applies to loopback URIs. + left.IsLoopback && right.IsLoopback && + // The relaxed policy only applies to HTTP and HTTPS URIs. + // + // Note: the scheme case is deliberately ignored here as it is always + // normalized to a lowercase value by the Uri.TryCreate() API, which + // would prevent performing a case-sensitive comparison anyway. + ((string.Equals(left.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + string.Equals(right.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) || + (string.Equals(left.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + string.Equals(right.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) && + + string.Equals(left.UserInfo, right.UserInfo, StringComparison.Ordinal) && + + // Note: the host case is deliberately ignored here as it is always + // normalized to a lowercase value by the Uri.TryCreate() API, which + // would prevent performing a case-sensitive comparison anyway. + string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase) && + + string.Equals(left.AbsolutePath, right.AbsolutePath, StringComparison.Ordinal) && + string.Equals(left.Query, right.Query, StringComparison.Ordinal) && + string.Equals(left.Fragment, right.Fragment, StringComparison.Ordinal)) + { + return true; + } } Logger.LogInformation(SR.GetResourceString(SR.ID6202), uri, await GetClientIdAsync(application, cancellationToken)); @@ -1353,12 +1438,47 @@ public virtual async ValueTask ValidateRedirectUriAsync(TApplication appli foreach (var candidate in await Store.GetRedirectUrisAsync(application, cancellationToken)) { - // Note: the redirect_uri must be compared using case-sensitive "Simple String Comparison". + // Note: the redirect_uri must be compared using case-sensitive "Simple String Comparison", + // unless the application was explicitly registered as a native application. In this case, a second + // pass using the relaxed comparison method is performed if the application is a native application. + // // See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest for more information. if (string.Equals(candidate, uri, StringComparison.Ordinal)) { return true; } + + if (await HasApplicationTypeAsync(application, ApplicationTypes.Native, cancellationToken) && + Uri.TryCreate(uri, UriKind.Absolute, out Uri? left) && + Uri.TryCreate(candidate, UriKind.Absolute, out Uri? right) && + // Only apply the relaxed comparison if the URI specified by the client uses a + // non-default port and if the value resolved from the database doesn't specify one. + !left.IsDefaultPort && right.IsDefaultPort && + // The relaxed policy only applies to loopback URIs. + left.IsLoopback && right.IsLoopback && + // The relaxed policy only applies to HTTP and HTTPS URIs. + // + // Note: the scheme case is deliberately ignored here as it is always + // normalized to a lowercase value by the Uri.TryCreate() API, which + // would prevent performing a case-sensitive comparison anyway. + ((string.Equals(left.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + string.Equals(right.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase)) || + (string.Equals(left.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase) && + string.Equals(right.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) && + + string.Equals(left.UserInfo, right.UserInfo, StringComparison.Ordinal) && + + // Note: the host case is deliberately ignored here as it is always + // normalized to a lowercase value by the Uri.TryCreate() API, which + // would prevent performing a case-sensitive comparison anyway. + string.Equals(left.Host, right.Host, StringComparison.OrdinalIgnoreCase) && + + string.Equals(left.AbsolutePath, right.AbsolutePath, StringComparison.Ordinal) && + string.Equals(left.Query, right.Query, StringComparison.Ordinal) && + string.Equals(left.Fragment, right.Fragment, StringComparison.Ordinal)) + { + return true; + } } Logger.LogInformation(SR.GetResourceString(SR.ID6162), uri, await GetClientIdAsync(application, cancellationToken)); @@ -1579,6 +1699,10 @@ IAsyncEnumerable IOpenIddictApplicationManager.FindByPostLogoutRedirectU IAsyncEnumerable IOpenIddictApplicationManager.FindByRedirectUriAsync([StringSyntax(StringSyntaxAttribute.Uri)] string uri, CancellationToken cancellationToken) => FindByRedirectUriAsync(uri, cancellationToken); + /// + ValueTask IOpenIddictApplicationManager.GetApplicationTypeAsync(object application, CancellationToken cancellationToken) + => GetApplicationTypeAsync((TApplication) application, cancellationToken); + /// ValueTask IOpenIddictApplicationManager.GetAsync(Func, IQueryable> query, CancellationToken cancellationToken) where TResult : default => GetAsync(query, cancellationToken); @@ -1639,6 +1763,10 @@ ValueTask> IOpenIddictApplicationManager.GetRedirectUrisA ValueTask> IOpenIddictApplicationManager.GetRequirementsAsync(object application, CancellationToken cancellationToken) => GetRequirementsAsync((TApplication) application, cancellationToken); + /// + ValueTask IOpenIddictApplicationManager.HasApplicationTypeAsync(object application, string type, CancellationToken cancellationToken) + => HasApplicationTypeAsync((TApplication) application, type, cancellationToken); + /// ValueTask IOpenIddictApplicationManager.HasClientTypeAsync(object application, string type, CancellationToken cancellationToken) => HasClientTypeAsync((TApplication) application, type, cancellationToken); diff --git a/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkApplication.cs b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkApplication.cs index b6d401090..22bf0f049 100644 --- a/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkApplication.cs +++ b/src/OpenIddict.EntityFramework.Models/OpenIddictEntityFrameworkApplication.cs @@ -24,12 +24,17 @@ public OpenIddictEntityFrameworkApplication() /// /// Represents an OpenIddict application. /// -[DebuggerDisplay("Id = {Id.ToString(),nq} ; ClientId = {ClientId,nq} ; Type = {Type,nq}")] +[DebuggerDisplay("Id = {Id.ToString(),nq} ; ClientId = {ClientId,nq} ; ClientType = {ClientType,nq}")] public class OpenIddictEntityFrameworkApplication where TKey : notnull, IEquatable where TAuthorization : class where TToken : class { + /// + /// Gets or sets the application type associated with the current application. + /// + public virtual string? ApplicationType { get; set; } + /// /// Gets the list of the authorizations associated with this application. /// @@ -47,6 +52,11 @@ public class OpenIddictEntityFrameworkApplication /// public virtual string? ClientSecret { get; set; } + /// + /// Gets or sets the client type associated with the current application. + /// + public virtual string? ClientType { get; set; } + /// /// Gets or sets the concurrency token. /// @@ -114,9 +124,4 @@ public class OpenIddictEntityFrameworkApplication /// Gets the list of the tokens associated with this application. /// public virtual ICollection Tokens { get; } = new HashSet(); - - /// - /// Gets or sets the application type associated with the current application. - /// - public virtual string? Type { get; set; } } diff --git a/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkApplicationConfiguration.cs b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkApplicationConfiguration.cs index 66f32a1bf..04eafeab7 100644 --- a/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkApplicationConfiguration.cs +++ b/src/OpenIddict.EntityFramework/Configurations/OpenIddictEntityFrameworkApplicationConfiguration.cs @@ -34,6 +34,9 @@ public OpenIddictEntityFrameworkApplicationConfiguration() HasKey(application => application.Id); + Property(application => application.ApplicationType) + .HasMaxLength(50); + Property(application => application.ClientId) .HasMaxLength(100) .HasColumnAnnotation(IndexAnnotation.AnnotationName, new IndexAnnotation(new IndexAttribute @@ -41,6 +44,9 @@ public OpenIddictEntityFrameworkApplicationConfiguration() IsUnique = true })); + Property(application => application.ClientType) + .HasMaxLength(50); + Property(application => application.ConcurrencyToken) .HasMaxLength(50) .IsConcurrencyToken(); @@ -48,9 +54,6 @@ public OpenIddictEntityFrameworkApplicationConfiguration() Property(application => application.ConsentType) .HasMaxLength(50); - Property(application => application.Type) - .HasMaxLength(50); - HasMany(application => application.Authorizations) .WithOptional(authorization => authorization.Application!) .Map(association => diff --git a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkApplicationStore.cs b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkApplicationStore.cs index 35dda0d9a..d7d62f63d 100644 --- a/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkApplicationStore.cs +++ b/src/OpenIddict.EntityFramework/Stores/OpenIddictEntityFrameworkApplicationStore.cs @@ -304,6 +304,17 @@ async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] Cance } } + /// + public virtual ValueTask GetApplicationTypeAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + return new(application.ApplicationType); + } + /// public virtual async ValueTask GetAsync( Func, TState, IQueryable> query, @@ -347,7 +358,7 @@ async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] Cance throw new ArgumentNullException(nameof(application)); } - return new(application.Type); + return new(application.ClientType); } /// @@ -670,6 +681,20 @@ public virtual IAsyncEnumerable ListAsync( return query(Applications, state).AsAsyncEnumerable(cancellationToken); } + /// + public virtual ValueTask SetApplicationTypeAsync(TApplication application, + string? type, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + application.ApplicationType = type; + + return default; + } + /// public virtual ValueTask SetClientIdAsync(TApplication application, string? identifier, CancellationToken cancellationToken) { @@ -704,7 +729,7 @@ public virtual ValueTask SetClientTypeAsync(TApplication application, string? ty throw new ArgumentNullException(nameof(application)); } - application.Type = type; + application.ClientType = type; return default; } diff --git a/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreApplication.cs b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreApplication.cs index 985664297..23cae1555 100644 --- a/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreApplication.cs +++ b/src/OpenIddict.EntityFrameworkCore.Models/OpenIddictEntityFrameworkCoreApplication.cs @@ -32,12 +32,17 @@ public class OpenIddictEntityFrameworkCoreApplication : OpenIddictEntityFr /// /// Represents an OpenIddict application. /// -[DebuggerDisplay("Id = {Id.ToString(),nq} ; ClientId = {ClientId,nq} ; Type = {Type,nq}")] +[DebuggerDisplay("Id = {Id.ToString(),nq} ; ClientId = {ClientId,nq} ; ClientType = {ClientType,nq}")] public class OpenIddictEntityFrameworkCoreApplication where TKey : notnull, IEquatable where TAuthorization : class where TToken : class { + /// + /// Gets or sets the application type associated with the current application. + /// + public virtual string? ApplicationType { get; set; } + /// /// Gets the list of the authorizations associated with this application. /// @@ -55,6 +60,11 @@ public class OpenIddictEntityFrameworkCoreApplication public virtual string? ClientSecret { get; set; } + /// + /// Gets or sets the client type associated with the current application. + /// + public virtual string? ClientType { get; set; } + /// /// Gets or sets the concurrency token. /// @@ -122,9 +132,4 @@ public class OpenIddictEntityFrameworkCoreApplication public virtual ICollection Tokens { get; } = new HashSet(); - - /// - /// Gets or sets the application type associated with the current application. - /// - public virtual string? Type { get; set; } } diff --git a/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreApplicationConfiguration.cs b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreApplicationConfiguration.cs index 9600f4b1c..b19f94167 100644 --- a/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreApplicationConfiguration.cs +++ b/src/OpenIddict.EntityFrameworkCore/Configurations/OpenIddictEntityFrameworkCoreApplicationConfiguration.cs @@ -37,6 +37,9 @@ public void Configure(EntityTypeBuilder builder) builder.HasKey(application => application.Id); + builder.Property(application => application.ApplicationType) + .HasMaxLength(50); + // Warning: the non-generic overlord is deliberately used to work around // a breaking change introduced in Entity Framework Core 3.x (where a // generic entity type builder is now returned by the HasIndex() method). @@ -46,6 +49,9 @@ public void Configure(EntityTypeBuilder builder) builder.Property(application => application.ClientId) .HasMaxLength(100); + builder.Property(application => application.ClientType) + .HasMaxLength(50); + builder.Property(application => application.ConcurrencyToken) .HasMaxLength(50) .IsConcurrencyToken(); @@ -56,9 +62,6 @@ public void Configure(EntityTypeBuilder builder) builder.Property(application => application.Id) .ValueGeneratedOnAdd(); - builder.Property(application => application.Type) - .HasMaxLength(50); - builder.HasMany(application => application.Authorizations) .WithOne(authorization => authorization.Application!) .HasForeignKey(nameof(OpenIddictEntityFrameworkCoreAuthorization.Application) + diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs index 0ab032ed6..fb5a92a4d 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictEntityFrameworkCoreApplicationStore.cs @@ -346,6 +346,17 @@ async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] Cance } } + /// + public virtual ValueTask GetApplicationTypeAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + return new(application.ApplicationType); + } + /// public virtual async ValueTask GetAsync( Func, TState, IQueryable> query, @@ -389,7 +400,7 @@ async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] Cance throw new ArgumentNullException(nameof(application)); } - return new(application.Type); + return new(application.ClientType); } /// @@ -711,6 +722,20 @@ public virtual IAsyncEnumerable ListAsync( return query(Applications.AsTracking(), state).AsAsyncEnumerable(cancellationToken); } + /// + public virtual ValueTask SetApplicationTypeAsync(TApplication application, + string? type, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + application.ApplicationType = type; + + return default; + } + /// public virtual ValueTask SetClientIdAsync(TApplication application, string? identifier, CancellationToken cancellationToken) { @@ -745,7 +770,7 @@ public virtual ValueTask SetClientTypeAsync(TApplication application, string? ty throw new ArgumentNullException(nameof(application)); } - application.Type = type; + application.ClientType = type; return default; } diff --git a/src/OpenIddict.MongoDb.Models/OpenIddictMongoDbApplication.cs b/src/OpenIddict.MongoDb.Models/OpenIddictMongoDbApplication.cs index b31ab3098..30912e5ff 100644 --- a/src/OpenIddict.MongoDb.Models/OpenIddictMongoDbApplication.cs +++ b/src/OpenIddict.MongoDb.Models/OpenIddictMongoDbApplication.cs @@ -12,9 +12,15 @@ namespace OpenIddict.MongoDb.Models; /// /// Represents an OpenIddict application. /// -[DebuggerDisplay("Id = {Id.ToString(),nq} ; ClientId = {ClientId,nq} ; Type = {Type,nq}")] +[DebuggerDisplay("Id = {Id.ToString(),nq} ; ClientId = {ClientId,nq} ; ClientType = {ClientType,nq}")] public class OpenIddictMongoDbApplication { + /// + /// Gets or sets the application type associated with the current application. + /// + [BsonElement("application_type"), BsonIgnoreIfNull] + public virtual string? ApplicationType { get; set; } + /// /// Gets or sets the client identifier associated with the current application. /// @@ -29,6 +35,12 @@ public class OpenIddictMongoDbApplication [BsonElement("client_secret"), BsonIgnoreIfNull] public virtual string? ClientSecret { get; set; } + /// + /// Gets or sets the client type associated with the current application. + /// + [BsonElement("client_type"), BsonIgnoreIfNull] + public virtual string? ClientType { get; set; } + /// /// Gets or sets the concurrency token. /// @@ -89,11 +101,4 @@ public class OpenIddictMongoDbApplication /// [BsonElement("requirements"), BsonIgnoreIfNull] public virtual IReadOnlyList? Requirements { get; set; } = ImmutableList.Create(); - - /// - /// Gets or sets the application type - /// associated with the current application. - /// - [BsonElement("type"), BsonIgnoreIfNull] - public virtual string? Type { get; set; } } diff --git a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbApplicationStore.cs b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbApplicationStore.cs index c15941527..52eca6ddf 100644 --- a/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbApplicationStore.cs +++ b/src/OpenIddict.MongoDb/Stores/OpenIddictMongoDbApplicationStore.cs @@ -184,6 +184,17 @@ async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] Cance } } + /// + public virtual ValueTask GetApplicationTypeAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + return new(application.ApplicationType); + } + /// public virtual async ValueTask GetAsync( Func, TState, IQueryable> query, @@ -230,7 +241,7 @@ async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] Cance throw new ArgumentNullException(nameof(application)); } - return new(application.Type); + return new(application.ClientType); } /// @@ -439,6 +450,20 @@ async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] Cancellati } } + /// + public virtual ValueTask SetApplicationTypeAsync(TApplication application, + string? type, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + application.ApplicationType = type; + + return default; + } + /// public virtual ValueTask SetClientIdAsync(TApplication application, string? identifier, CancellationToken cancellationToken) @@ -476,7 +501,7 @@ public virtual ValueTask SetClientTypeAsync(TApplication application, throw new ArgumentNullException(nameof(application)); } - application.Type = type; + application.ClientType = type; return default; } diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs index f95839663..50d2b58d7 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Session.cs @@ -394,6 +394,10 @@ public ValidateClientId(IOpenIddictApplicationManager applicationManager) public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() + // Note: support for the client_id parameter was only added in the second draft of the + // https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout specification + // and is optional. As such, the client identifier is only validated if it was specified. + .AddFilter() .UseScopedHandler() .SetOrder(ValidatePostLogoutRedirectUriParameter.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) @@ -407,13 +411,7 @@ public async ValueTask HandleAsync(ValidateLogoutRequestContext context) throw new ArgumentNullException(nameof(context)); } - // Note: support for the client_id parameter was only added in the second draft of the - // https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout specification - // and is optional. As such, the client identifier is only validated if it was specified. - if (string.IsNullOrEmpty(context.ClientId)) - { - return; - } + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); var application = await _applicationManager.FindByClientIdAsync(context.ClientId); if (application is null) @@ -521,13 +519,51 @@ async ValueTask ValidatePostLogoutRedirectUriAsync([StringSyntax(StringSyn await foreach (var application in _applicationManager.FindByPostLogoutRedirectUriAsync(uri)) { - if (context.Options.IgnoreEndpointPermissions || - await _applicationManager.HasPermissionAsync(application, Permissions.Endpoints.Logout)) + if (!context.Options.IgnoreEndpointPermissions && + !await _applicationManager.HasPermissionAsync(application, Permissions.Endpoints.Logout)) + { + continue; + } + + if (await _applicationManager.ValidatePostLogoutRedirectUriAsync(application, uri)) { return true; } } + // If the specified URI is an HTTP/HTTPS URI, points to the local host and doesn't use the + // default port, make a second pass to determine whether a native application allowed to use + // a relaxed post_logout_redirect_uri comparison policy has the specified URI attached. + if (Uri.TryCreate(uri, UriKind.Absolute, out Uri? value) && + // Only apply the relaxed comparison if the URI specified by the client uses a non-default port. + !value.IsDefaultPort && + // The relaxed policy only applies to loopback URIs. + value.IsLoopback && + // The relaxed policy only applies to HTTP and HTTPS URIs. + // + // Note: the scheme case is deliberately ignored here as it is always + // normalized to a lowercase value by the Uri.TryCreate() API, which + // would prevent performing a case-sensitive comparison anyway. + (string.Equals(value.Scheme, Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) || + string.Equals(value.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase))) + { + await foreach (var application in _applicationManager.FindByPostLogoutRedirectUriAsync( + uri: new UriBuilder(value) { Port = -1 }.Uri.AbsoluteUri)) + { + if (!context.Options.IgnoreEndpointPermissions && + !await _applicationManager.HasPermissionAsync(application, Permissions.Endpoints.Logout)) + { + continue; + } + + if (await _applicationManager.HasApplicationTypeAsync(application, ApplicationTypes.Native) && + await _applicationManager.ValidatePostLogoutRedirectUriAsync(application, uri)) + { + return true; + } + } + } + return false; } } @@ -553,6 +589,13 @@ public ValidateEndpointPermissions(IOpenIddictApplicationManager applicationMana = OpenIddictServerHandlerDescriptor.CreateBuilder() .AddFilter() .AddFilter() + // Note: support for the client_id parameter was only added in the second draft of the + // https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout specification + // and is optional. As such, the client permissions are only validated if it was specified. + // + // Note: if only post_logout_redirect_uri was specified, client permissions are expected to be + // enforced by the ValidateClientPostLogoutRedirectUri handler when finding matching clients. + .AddFilter() .UseScopedHandler() .SetOrder(ValidateClientPostLogoutRedirectUri.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) @@ -566,15 +609,7 @@ public async ValueTask HandleAsync(ValidateLogoutRequestContext context) throw new ArgumentNullException(nameof(context)); } - // Note: support for the client_id parameter was only added in the second draft of the - // https://openid.net/specs/openid-connect-rpinitiated-1_0.html#RPLogout specification - // and is optional. As such, the client permissions are only validated if it was specified. - // If only post_logout_redirect_uri was specified, client permissions are expected to be - // enforced by the ValidateClientPostLogoutRedirectUri handler when finding matching clients. - if (string.IsNullOrEmpty(context.ClientId)) - { - return; - } + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); var application = await _applicationManager.FindByClientIdAsync(context.ClientId) ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); @@ -757,14 +792,28 @@ public async ValueTask HandleAsync(ValidateLogoutRequestContext context) async ValueTask ValidateAuthorizedParty(ClaimsPrincipal principal, [StringSyntax(StringSyntaxAttribute.Uri)] string uri) { - // To be considered valid, one of the clients matching the specified post_logout_redirect_uri - // must be listed either as an audience or as a presenter in the identity token hint. + // To be considered valid, the specified post_logout_redirect_uri must + // be considered valid for one of the listed audiences/presenters. - await foreach (var application in _applicationManager.FindByPostLogoutRedirectUriAsync(uri)) + var identifiers = new HashSet(StringComparer.Ordinal); + identifiers.UnionWith(principal.GetAudiences()); + identifiers.UnionWith(principal.GetPresenters()); + + foreach (var identifier in identifiers) { - var identifier = await _applicationManager.GetClientIdAsync(application); - if (!string.IsNullOrEmpty(identifier) && (principal.HasAudience(identifier) || - principal.HasPresenter(identifier))) + var application = await _applicationManager.FindByClientIdAsync(identifier); + if (application is null) + { + continue; + } + + if (!context.Options.IgnoreEndpointPermissions && + !await _applicationManager.HasPermissionAsync(application, Permissions.Endpoints.Logout)) + { + continue; + } + + if (await _applicationManager.ValidatePostLogoutRedirectUriAsync(application, uri)) { return true; } diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs index 6801373d7..232f4363c 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Session.cs @@ -364,6 +364,9 @@ public async Task ValidateLogoutRequest_RequestIsValidatedWhenMatchingApplicatio mock.Setup(manager => manager.HasPermissionAsync(applications[2], Permissions.Endpoints.Logout, It.IsAny())) .ReturnsAsync(false); + + mock.Setup(manager => manager.ValidatePostLogoutRedirectUriAsync(applications[1], "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); }); await using var server = await CreateServerAsync(options => @@ -561,19 +564,34 @@ public async Task ValidateLogoutRequest_IdentityTokenHintCausesAnErrorWhenExplic public async Task ValidateLogoutRequest_IdentityTokenHintCausesAnErrorWhenInferredCallerIsNotAuthorized() { // Arrange - var application = new OpenIddictApplication(); + var applications = new[] + { + new OpenIddictApplication(), + new OpenIddictApplication() + }; var manager = CreateApplicationManager(mock => { mock.Setup(manager => manager.FindByPostLogoutRedirectUriAsync("http://www.fabrikam.com/path", It.IsAny())) - .Returns(new[] { application }.ToAsyncEnumerable()); + .Returns(new[] { applications[0] }.ToAsyncEnumerable()); - mock.Setup(manager => manager.HasPermissionAsync(application, - Permissions.Endpoints.Logout, It.IsAny())) + mock.Setup(manager => manager.HasPermissionAsync(applications[0], Permissions.Endpoints.Logout, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.ValidatePostLogoutRedirectUriAsync(applications[0], "http://www.fabrikam.com/path", It.IsAny())) .ReturnsAsync(true); - mock.Setup(manager => manager.GetClientIdAsync(application, It.IsAny())) + mock.Setup(manager => manager.GetClientIdAsync(applications[0], It.IsAny())) .ReturnsAsync("Fabrikam"); + + mock.Setup(manager => manager.FindByClientIdAsync("Contoso", It.IsAny())) + .ReturnsAsync(applications[1]); + + mock.Setup(manager => manager.HasPermissionAsync(applications[1], Permissions.Endpoints.Logout, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.ValidatePostLogoutRedirectUriAsync(applications[1], "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(false); }); await using var server = await CreateServerAsync(options => @@ -615,7 +633,9 @@ public async Task ValidateLogoutRequest_IdentityTokenHintCausesAnErrorWhenInferr Assert.Equal(SR.GetResourceString(SR.ID2141), response.ErrorDescription); Assert.Equal(SR.FormatID8000(SR.ID2141), response.ErrorUri); - Mock.Get(manager).Verify(manager => manager.FindByPostLogoutRedirectUriAsync("http://www.fabrikam.com/path", It.IsAny()), Times.AtLeast(2)); + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Contoso", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.ValidatePostLogoutRedirectUriAsync(applications[0], "http://www.fabrikam.com/path", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.ValidatePostLogoutRedirectUriAsync(applications[1], "http://www.fabrikam.com/path", It.IsAny()), Times.Once()); } [Fact]