diff --git a/src/OpenIddict.Abstractions/OpenIddictResources.resx b/src/OpenIddict.Abstractions/OpenIddictResources.resx index 755354b0..b8675df7 100644 --- a/src/OpenIddict.Abstractions/OpenIddictResources.resx +++ b/src/OpenIddict.Abstractions/OpenIddictResources.resx @@ -1116,6 +1116,9 @@ To register the OpenIddict core services, reference the 'OpenIddict.Core' packag The specified principal doesn't contain a valid claims-based identity. + + The device grant must be allowed when enabling the device endpoint. + The security token is missing. @@ -1467,6 +1470,9 @@ To register the OpenIddict core services, reference the 'OpenIddict.Core' packag Callback URLs must be valid absolute URLs. + + The client application is not allowed to use the device code flow. + The '{0}' parameter shouldn't be null or empty at this point. @@ -2029,6 +2035,12 @@ This may indicate that the hashed entry is corrupted or malformed. The authorization request was rejected because the application '{ClientId}' was not allowed to use the '{ResponseType}' response type. + + The device request was rejected because the application '{ClientId}' was not allowed to use the device code flow. + + + The device request was rejected because the application '{ClientId}' was not allowed to request the '{Scope}' scope. + Removes orphaned tokens and authorizations from the database. diff --git a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs b/src/OpenIddict.Server/OpenIddictServerConfiguration.cs index ff6d1a8e..ccad6ce8 100644 --- a/src/OpenIddict.Server/OpenIddictServerConfiguration.cs +++ b/src/OpenIddict.Server/OpenIddictServerConfiguration.cs @@ -110,6 +110,12 @@ namespace OpenIddict.Server throw new InvalidOperationException(SR.GetResourceString(SR.ID0080)); } + // Ensure the device grant is allowed when the device endpoint is enabled. + if (options.DeviceEndpointUris.Count > 0 && !options.GrantTypes.Contains(GrantTypes.DeviceCode)) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0287)); + } + // Ensure the grant types/response types configuration is consistent. foreach (var type in options.ResponseTypes) { diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs index 6bebe7a1..d5c78349 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Device.cs @@ -41,11 +41,13 @@ namespace OpenIddict.Server * Device request validation: */ ValidateClientIdParameter.Descriptor, + ValidateScopeParameter.Descriptor, ValidateScopes.Descriptor, ValidateClientId.Descriptor, ValidateClientType.Descriptor, ValidateClientSecret.Descriptor, ValidateEndpointPermissions.Descriptor, + ValidateGrantTypePermissions.Descriptor, ValidateScopePermissions.Descriptor, /* @@ -364,6 +366,43 @@ namespace OpenIddict.Server } } + /// + /// Contains the logic responsible of rejecting device requests that don't specify a valid scope parameter. + /// + public class ValidateScopeParameter : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidateClientIdParameter.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateDeviceRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + // Reject device requests that specify scope=offline_access if the refresh token flow is not enabled. + if (context.Request.HasScope(Scopes.OfflineAccess) && !context.Options.GrantTypes.Contains(GrantTypes.RefreshToken)) + { + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2035(Scopes.OfflineAccess)); + + return default; + } + + return default; + } + } + /// /// Contains the logic responsible of rejecting authorization requests that use unregistered scopes. /// Note: this handler partially works with the degraded mode but is not used when scope validation is disabled. @@ -392,7 +431,7 @@ namespace OpenIddict.Server new ValidateScopes(provider.GetService() ?? throw new InvalidOperationException(SR.GetResourceString(SR.ID0016))); }) - .SetOrder(ValidateClientIdParameter.Descriptor.Order + 1_000) + .SetOrder(ValidateScopeParameter.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -486,7 +525,7 @@ namespace OpenIddict.Server context.Reject( error: Errors.InvalidClient, - description: SR.GetResourceString(SR.ID2052)); + description: SR.FormatID2052(Parameters.ClientId)); return; } @@ -685,6 +724,75 @@ namespace OpenIddict.Server } } + /// + /// Contains the logic responsible of rejecting device requests made by unauthorized applications. + /// Note: this handler is not used when the degraded mode is enabled or when grant type permissions are disabled. + /// + public class ValidateGrantTypePermissions : IOpenIddictServerHandler + { + private readonly IOpenIddictApplicationManager _applicationManager; + + public ValidateGrantTypePermissions() => throw new InvalidOperationException(SR.GetResourceString(SR.ID0016)); + + public ValidateGrantTypePermissions(IOpenIddictApplicationManager applicationManager) + => _applicationManager = applicationManager; + + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() + .AddFilter() + .UseScopedHandler() + .SetOrder(ValidateEndpointPermissions.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public async ValueTask HandleAsync(ValidateDeviceRequestContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(!string.IsNullOrEmpty(context.ClientId), SR.FormatID4000(Parameters.ClientId)); + + var application = await _applicationManager.FindByClientIdAsync(context.ClientId); + if (application is null) + { + throw new InvalidOperationException(SR.GetResourceString(SR.ID0032)); + } + + // Reject the request if the application is not allowed to use the device code grant. + if (!await _applicationManager.HasPermissionAsync(application, Permissions.GrantTypes.DeviceCode)) + { + context.Logger.LogError(SR.GetResourceString(SR.ID6182), context.ClientId); + + context.Reject( + error: Errors.UnauthorizedClient, + description: SR.GetResourceString(SR.ID2121)); + + return; + } + + // Reject the request if the offline_access scope was request and + // if the application is not allowed to use the refresh token grant. + if (context.Request.HasScope(Scopes.OfflineAccess) && + !await _applicationManager.HasPermissionAsync(application, Permissions.GrantTypes.RefreshToken)) + { + context.Logger.LogError(SR.GetResourceString(SR.ID6183), context.ClientId, Scopes.OfflineAccess); + + context.Reject( + error: Errors.InvalidRequest, + description: SR.FormatID2065(Scopes.OfflineAccess)); + + return; + } + } + } + /// /// Contains the logic responsible of rejecting device requests made by applications /// that haven't been granted the appropriate grant type permission. @@ -708,7 +816,7 @@ namespace OpenIddict.Server .AddFilter() .AddFilter() .UseScopedHandler() - .SetOrder(ValidateEndpointPermissions.Descriptor.Order + 1_000) + .SetOrder(ValidateGrantTypePermissions.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Device.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Device.cs new file mode 100644 index 00000000..0262b309 --- /dev/null +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Device.cs @@ -0,0 +1,1330 @@ +/* + * Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0) + * See https://github.com/openiddict/openiddict-core for more information concerning + * the license and the contributors participating to this project. + */ + +using System; +using System.Collections.Immutable; +using System.Linq; +using System.Net.Http; +using System.Security.Claims; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; +using Moq; +using OpenIddict.Abstractions; +using Xunit; +using static OpenIddict.Abstractions.OpenIddictConstants; +using static OpenIddict.Server.OpenIddictServerEvents; +using SR = OpenIddict.Abstractions.OpenIddictResources; + +namespace OpenIddict.Server.IntegrationTests +{ + public abstract partial class OpenIddictServerIntegrationTests + { + [Theory] + [InlineData(nameof(HttpMethod.Delete))] + [InlineData(nameof(HttpMethod.Get))] + [InlineData(nameof(HttpMethod.Head))] + [InlineData(nameof(HttpMethod.Options))] + [InlineData(nameof(HttpMethod.Put))] + [InlineData(nameof(HttpMethod.Trace))] + public async Task ExtractDeviceRequest_UnexpectedMethodReturnsAnError(string method) + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.SendAsync(method, "/connect/device", new OpenIddictRequest()); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2084), response.ErrorDescription); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task ExtractDeviceRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest()); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task ExtractDeviceRequest_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest()); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task ExtractDeviceRequest_AllowsSkippingHandler() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest()); + + // Assert + Assert.Equal("Bob le Magnifique", (string?) response["name"]); + } + + [Fact] + public async Task ValidateDeviceRequest_MissingClientIdCausesAnError() + { + // Arrange + await using var server = await CreateServerAsync(options => options.EnableDegradedMode()); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = null + }); + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.FormatID2029(Parameters.ClientId), response.ErrorDescription); + } + + [Fact] + public async Task ValidateDeviceRequest_RequestWithOfflineAccessScopeIsRejectedWhenRefreshTokenFlowIsDisabled() + { + // Arrange + var application = new OpenIddictApplication(); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + })); + + options.EnableDegradedMode(); + + options.Configure(options => options.GrantTypes.Remove(GrantTypes.RefreshToken)); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam", + Scope = Scopes.OfflineAccess + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2035(Scopes.OfflineAccess), response.ErrorDescription); + } + + [Fact] + public async Task ValidateDeviceRequest_RequestIsRejectedWhenUnregisteredScopeIsSpecified() + { + // Arrange + var application = new OpenIddictApplication(); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + })); + + options.Services.AddSingleton(CreateScopeManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(scopes => scopes.Length == 1 && scopes[0] == "unregistered_scope"), + It.IsAny())) + .Returns(AsyncEnumerable.Empty()); + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam", + Scope = "unregistered_scope" + }); + + // Assert + Assert.Equal(Errors.InvalidScope, response.Error); + Assert.Equal(SR.FormatID2052(Parameters.Scope), response.ErrorDescription); + } + + [Fact] + public async Task ValidateDeviceRequest_RequestIsValidatedWhenScopeRegisteredInOptionsIsSpecified() + { + // Arrange + var application = new OpenIddictApplication(); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + })); + + options.EnableDegradedMode(); + options.RegisterScopes("registered_scope"); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity()); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam", + Scope = "registered_scope" + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.DeviceCode); + Assert.NotNull(response.UserCode); + } + + [Fact] + public async Task ValidateDeviceRequest_RequestIsValidatedWhenRegisteredScopeIsSpecified() + { + // Arrange + var application = new OpenIddictApplication(); + var scope = new OpenIddictScope(); + + var manager = CreateScopeManager(mock => + { + mock.Setup(manager => manager.FindByNamesAsync( + It.Is>(scopes => scopes.Length == 1 && scopes[0] == "scope_registered_in_database"), + It.IsAny())) + .Returns(new[] { scope }.ToAsyncEnumerable()); + + mock.Setup(manager => manager.GetNameAsync(scope, It.IsAny())) + .ReturnsAsync("scope_registered_in_database"); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + })); + + options.RegisterScopes("scope_registered_in_options"); + options.SetRevocationEndpointUris(Array.Empty()); + options.DisableAuthorizationStorage(); + options.DisableTokenStorage(); + options.DisableSlidingRefreshTokenExpiration(); + + options.Services.AddSingleton(manager); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity()); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam", + Scope = "scope_registered_in_database scope_registered_in_options" + }); + + // Assert + Assert.Null(response.Error); + Assert.Null(response.ErrorDescription); + Assert.Null(response.ErrorUri); + Assert.NotNull(response.DeviceCode); + Assert.NotNull(response.UserCode); + } + + [Fact] + public async Task ValidateDeviceRequest_RequestIsRejectedWhenClientCannotBeFound() + { + // Arrange + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(value: null); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }); + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.FormatID2052(Parameters.ClientId), response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + } + + [Fact] + public async Task ValidateDeviceRequest_ClientSecretCannotBeUsedByPublicClients() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw" + }); + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.FormatID2053(Parameters.ClientSecret), response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidateDeviceRequest_ClientSecretIsRequiredForNonPublicClients() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = null + }); + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.FormatID2054(Parameters.ClientSecret), response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidateDeviceRequest_RequestIsRejectedWhenClientCredentialsAreInvalid() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(false); + + mock.Setup(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw" + }); + + // Assert + Assert.Equal(Errors.InvalidClient, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2055), response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidateDeviceRequest_RequestIsRejectedWhenEndpointPermissionIsNotGranted() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Endpoints.Device, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + + options.Configure(options => options.IgnoreEndpointPermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }); + + // Assert + Assert.Equal(Errors.UnauthorizedClient, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2056), response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Endpoints.Device, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidateDeviceRequest_RequestIsRejectedWhenGrantTypePermissionIsNotGranted() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.GrantTypes.DeviceCode, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + + options.Configure(options => options.IgnoreGrantTypePermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }); + + // Assert + Assert.Equal(Errors.UnauthorizedClient, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2121), response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.GrantTypes.DeviceCode, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidateDeviceRequest_RequestWithOfflineAccessScopeIsRejectedWhenRefreshTokenPermissionIsNotGranted() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.GrantTypes.DeviceCode, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.GrantTypes.RefreshToken, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + + options.Configure(options => options.IgnoreGrantTypePermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam", + Scope = Scopes.OfflineAccess + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.FormatID2065(Scopes.OfflineAccess), response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.GrantTypes.RefreshToken, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task ValidateDeviceRequest_RequestIsRejectedWhenScopePermissionIsNotGranted() + { + // Arrange + var application = new OpenIddictApplication(); + + var manager = CreateApplicationManager(mock => + { + mock.Setup(manager => manager.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + mock.Setup(manager => manager.HasClientTypeAsync(application, ClientTypes.Public, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.Profile, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.Email, It.IsAny())) + .ReturnsAsync(false); + }); + + await using var server = await CreateServerAsync(options => + { + options.Services.AddSingleton(manager); + + options.RegisterScopes(Scopes.Email, Scopes.Profile); + options.Configure(options => options.IgnoreScopePermissions = false); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam", + Scope = "openid offline_access profile email" + }); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2051), response.ErrorDescription); + + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.OpenId, It.IsAny()), Times.Never()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.OfflineAccess, It.IsAny()), Times.Never()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.Profile, It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(manager => manager.HasPermissionAsync(application, + Permissions.Prefixes.Scope + Scopes.Email, It.IsAny()), Times.Once()); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task ValidateDeviceRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task ValidateDeviceRequest_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task ValidateDeviceRequest_AllowsSkippingHandler() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }); + + // Assert + Assert.Equal("Bob le Magnifique", (string?) response["name"]); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task HandleDeviceRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task HandleDeviceRequest_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task HandleDeviceRequest_AllowsSkippingHandler() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }); + + // Assert + Assert.Equal("Bob le Magnifique", (string?) response["name"]); + } + + [Fact] + public async Task ApplyDeviceResponse_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity()); + + return default; + })); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task ApplyDeviceResponse_ResponseContainsCustomParameters() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Principal = new ClaimsPrincipal(new ClaimsIdentity()); + + return default; + })); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Response["custom_parameter"] = "custom_value"; + context.Response["parameter_with_multiple_values"] = new[] + { + "custom_value_1", + "custom_value_2" + }; + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/device", new OpenIddictRequest + { + ClientId = "Fabrikam" + }); + + // Assert + Assert.Equal("custom_value", (string?) response["custom_parameter"]); + Assert.Equal(new[] { "custom_value_1", "custom_value_2" }, (string[]?) response["parameter_with_multiple_values"]); + } + + [Theory] + [InlineData(nameof(HttpMethod.Delete))] + [InlineData(nameof(HttpMethod.Head))] + [InlineData(nameof(HttpMethod.Options))] + [InlineData(nameof(HttpMethod.Put))] + [InlineData(nameof(HttpMethod.Trace))] + public async Task ExtractVerificationRequest_UnexpectedMethodReturnsAnError(string method) + { + // Arrange + await using var server = await CreateServerAsync(); + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.SendAsync(method, "/connect/verification", new OpenIddictRequest()); + + // Assert + Assert.Equal(Errors.InvalidRequest, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2084), response.ErrorDescription); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task ExtractVerificationRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/verification", new OpenIddictRequest()); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task ExtractVerificationRequest_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/connect/verification"); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task ExtractVerificationRequest_AllowsSkippingHandler() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.GetAsync("/connect/verification"); + + // Assert + Assert.Equal("Bob le Magnifique", (string?) response["name"]); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task ValidateVerificationRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/verification", new OpenIddictRequest()); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task ValidateVerificationRequest_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/verification", new OpenIddictRequest()); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task ValidateVerificationRequest_AllowsSkippingHandler() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/verification", new OpenIddictRequest()); + + // Assert + Assert.Equal("Bob le Magnifique", (string?) response["name"]); + } + + [Theory] + [InlineData("custom_error", null, null)] + [InlineData("custom_error", "custom_description", null)] + [InlineData("custom_error", "custom_description", "custom_uri")] + [InlineData(null, "custom_description", null)] + [InlineData(null, "custom_description", "custom_uri")] + [InlineData(null, null, "custom_uri")] + [InlineData(null, null, null)] + public async Task HandleVerificationRequest_AllowsRejectingRequest(string error, string description, string uri) + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(error, description, uri); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/verification", new OpenIddictRequest()); + + // Assert + Assert.Equal(error ?? Errors.InvalidRequest, response.Error); + Assert.Equal(description, response.ErrorDescription); + Assert.Equal(uri, response.ErrorUri); + } + + [Fact] + public async Task HandleVerificationRequest_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/verification", new OpenIddictRequest()); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task HandleVerificationRequest_AllowsSkippingHandler() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.SkipRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/verification", new OpenIddictRequest()); + + // Assert + Assert.Equal("Bob le Magnifique", (string?) response["name"]); + } + + [Fact] + public async Task ApplyVerificationResponse_AllowsHandlingResponse() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(); + + return default; + })); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Transaction.SetProperty("custom_response", new + { + name = "Bob le Bricoleur" + }); + + context.HandleRequest(); + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/verification", new OpenIddictRequest()); + + // Assert + Assert.Equal("Bob le Bricoleur", (string?) response["name"]); + } + + [Fact] + public async Task ApplyVerificationResponse_ResponseContainsCustomParameters() + { + // Arrange + await using var server = await CreateServerAsync(options => + { + options.EnableDegradedMode(); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Reject(); + + return default; + })); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => + { + context.Response["custom_parameter"] = "custom_value"; + + return default; + })); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/verification", new OpenIddictRequest()); + + // Assert + Assert.Equal("custom_value", (string?) response["custom_parameter"]); + } + } +} diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs index 2b8b38a0..b369edac 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Discovery.cs @@ -411,6 +411,8 @@ namespace OpenIddict.Server.IntegrationTests options.Configure(options => options.GrantTypes.Clear()); options.Configure(options => options.GrantTypes.Add(GrantTypes.Implicit)); options.Configure(options => options.ResponseTypes.Clear()); + options.Configure(options => options.DeviceEndpointUris.Clear()); + options.Configure(options => options.VerificationEndpointUris.Clear()); options.SetTokenEndpointUris(Array.Empty()); }); diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs index 9649a195..32e92cbf 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.cs @@ -3527,14 +3527,17 @@ namespace OpenIddict.Server.IntegrationTests options.SetAuthorizationEndpointUris("/connect/authorize") .SetConfigurationEndpointUris("/.well-known/openid-configuration") .SetCryptographyEndpointUris("/.well-known/jwks") + .SetDeviceEndpointUris("/connect/device") .SetIntrospectionEndpointUris("/connect/introspect") .SetLogoutEndpointUris("/connect/logout") .SetRevocationEndpointUris("/connect/revoke") .SetTokenEndpointUris("/connect/token") - .SetUserinfoEndpointUris("/connect/userinfo"); + .SetUserinfoEndpointUris("/connect/userinfo") + .SetVerificationEndpointUris("/connect/verification"); options.AllowAuthorizationCodeFlow() .AllowClientCredentialsFlow() + .AllowDeviceCodeFlow() .AllowHybridFlow() .AllowImplicitFlow() .AllowNoneFlow() @@ -3563,6 +3566,9 @@ namespace OpenIddict.Server.IntegrationTests options.AddEventHandler(builder => builder.UseInlineHandler(context => default)); + options.AddEventHandler(builder => + builder.UseInlineHandler(context => default)); + options.AddEventHandler(builder => builder.UseInlineHandler(context => default)); @@ -3574,6 +3580,15 @@ namespace OpenIddict.Server.IntegrationTests options.AddEventHandler(builder => builder.UseInlineHandler(context => default)); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => default)); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => default)); + + options.AddEventHandler(builder => + builder.UseInlineHandler(context => default)); }); }