diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs index e1b4935a..f092a272 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Exchange.cs @@ -1760,10 +1760,13 @@ namespace OpenIddict.Server return default; } - context.Response.Error = context.Error switch { - Errors.InvalidToken or Errors.ExpiredToken => Errors.InvalidGrant, + // Keep "expired_token" errors as-is if the request is a device code token request. + Errors.ExpiredToken when context.Request.IsDeviceCodeGrantType() => Errors.ExpiredToken, + + // Convert "invalid_token" errors to "invalid_grant". + Errors.InvalidToken => Errors.InvalidGrant, _ => context.Error // Otherwise, keep the error as-is. }; diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs index 7b6adea3..ec5e94dc 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.Protection.cs @@ -40,9 +40,9 @@ namespace OpenIddict.Server MapInternalClaims.Descriptor, RestoreReferenceTokenProperties.Descriptor, ValidatePrincipal.Descriptor, + ValidateExpirationDate.Descriptor, ValidateTokenEntry.Descriptor, ValidateAuthorizationEntry.Descriptor, - ValidateExpirationDate.Descriptor, /* * Token generation: @@ -180,8 +180,12 @@ namespace OpenIddict.Server } // If the type associated with the token entry doesn't match one of the expected types, return an error. - if (context.ValidTokenTypes.Count > 0 && - !await _tokenManager.HasTypeAsync(token, context.ValidTokenTypes.ToImmutableArray())) + if (!(context.ValidTokenTypes.Count switch + { + 0 => true, // If no specific token type is expected, accept all token types at this stage. + 1 => await _tokenManager.HasTypeAsync(token, context.ValidTokenTypes.ElementAt(0)), + _ => await _tokenManager.HasTypeAsync(token, context.ValidTokenTypes.ToImmutableArray()) + })) { context.Reject( error: Errors.InvalidToken, @@ -674,6 +678,69 @@ namespace OpenIddict.Server } } + /// + /// Contains the logic responsible of rejecting authentication demands that use an expired token. + /// + public class ValidateExpirationDate : IOpenIddictServerHandler + { + /// + /// Gets the default descriptor definition assigned to this handler. + /// + public static OpenIddictServerHandlerDescriptor Descriptor { get; } + = OpenIddictServerHandlerDescriptor.CreateBuilder() + .UseSingletonHandler() + .SetOrder(ValidatePrincipal.Descriptor.Order + 1_000) + .SetType(OpenIddictServerHandlerType.BuiltIn) + .Build(); + + /// + public ValueTask HandleAsync(ValidateTokenContext context) + { + if (context is null) + { + throw new ArgumentNullException(nameof(context)); + } + + Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); + + if (context.DisableLifetimeValidation) + { + return default; + } + + var date = context.Principal.GetExpirationDate(); + if (date.HasValue && date.Value < DateTimeOffset.UtcNow) + { + context.Reject( + error: context.Principal.GetTokenType() switch + { + TokenTypeHints.DeviceCode => Errors.ExpiredToken, + _ => Errors.InvalidToken + }, + description: context.Principal.GetTokenType() switch + { + TokenTypeHints.AuthorizationCode => SR.GetResourceString(SR.ID2016), + TokenTypeHints.DeviceCode => SR.GetResourceString(SR.ID2017), + TokenTypeHints.RefreshToken => SR.GetResourceString(SR.ID2018), + + _ => SR.GetResourceString(SR.ID2019) + }, + uri: context.Principal.GetTokenType() switch + { + TokenTypeHints.AuthorizationCode => SR.FormatID8000(SR.ID2016), + TokenTypeHints.DeviceCode => SR.FormatID8000(SR.ID2017), + TokenTypeHints.RefreshToken => SR.FormatID8000(SR.ID2018), + + _ => SR.FormatID8000(SR.ID2019) + }); + + return default; + } + + return default; + } + } + /// /// Contains the logic responsible of rejecting authentication demands that /// use a token whose entry is no longer valid (e.g was revoked). @@ -696,7 +763,7 @@ namespace OpenIddict.Server .AddFilter() .AddFilter() .UseScopedHandler() - .SetOrder(ValidatePrincipal.Descriptor.Order + 1_000) + .SetOrder(ValidateExpirationDate.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) .Build(); @@ -947,69 +1014,6 @@ namespace OpenIddict.Server } } - /// - /// Contains the logic responsible of rejecting authentication demands that use an expired token. - /// - public class ValidateExpirationDate : IOpenIddictServerHandler - { - /// - /// Gets the default descriptor definition assigned to this handler. - /// - public static OpenIddictServerHandlerDescriptor Descriptor { get; } - = OpenIddictServerHandlerDescriptor.CreateBuilder() - .UseSingletonHandler() - .SetOrder(ValidateTokenEntry.Descriptor.Order + 1_000) - .SetType(OpenIddictServerHandlerType.BuiltIn) - .Build(); - - /// - public ValueTask HandleAsync(ValidateTokenContext context) - { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - - if (context.DisableLifetimeValidation) - { - return default; - } - - var date = context.Principal.GetExpirationDate(); - if (date.HasValue && date.Value < DateTimeOffset.UtcNow) - { - context.Reject( - error: context.Principal.GetTokenType() switch - { - TokenTypeHints.DeviceCode => Errors.ExpiredToken, - _ => Errors.InvalidToken - }, - description: context.Principal.GetTokenType() switch - { - TokenTypeHints.AuthorizationCode => SR.GetResourceString(SR.ID2016), - TokenTypeHints.DeviceCode => SR.GetResourceString(SR.ID2017), - TokenTypeHints.RefreshToken => SR.GetResourceString(SR.ID2018), - - _ => SR.GetResourceString(SR.ID2019) - }, - uri: context.Principal.GetTokenType() switch - { - TokenTypeHints.AuthorizationCode => SR.FormatID8000(SR.ID2016), - TokenTypeHints.DeviceCode => SR.FormatID8000(SR.ID2017), - TokenTypeHints.RefreshToken => SR.FormatID8000(SR.ID2018), - - _ => SR.FormatID8000(SR.ID2019) - }); - - return default; - } - - return default; - } - } - /// /// Contains the logic responsible of resolving the signing and encryption credentials used to protect tokens. /// diff --git a/src/OpenIddict.Server/OpenIddictServerHandlers.cs b/src/OpenIddict.Server/OpenIddictServerHandlers.cs index 2b9fa38a..5e855cab 100644 --- a/src/OpenIddict.Server/OpenIddictServerHandlers.cs +++ b/src/OpenIddict.Server/OpenIddictServerHandlers.cs @@ -1790,6 +1790,7 @@ namespace OpenIddict.Server /// public static OpenIddictServerHandlerDescriptor Descriptor { get; } = OpenIddictServerHandlerDescriptor.CreateBuilder() + .AddFilter() .UseSingletonHandler() .SetOrder(PrepareAuthorizationCodePrincipal.Descriptor.Order + 1_000) .SetType(OpenIddictServerHandlerType.BuiltIn) @@ -1805,13 +1806,6 @@ namespace OpenIddict.Server Debug.Assert(context.Principal is { Identity: ClaimsIdentity }, SR.GetResourceString(SR.ID4006)); - // Note: a device code principal is produced when a device code is included in the response or when a - // device code entry is replaced when processing a sign-in response sent to the verification endpoint. - if (context.EndpointType != OpenIddictServerEndpointType.Verification && !context.GenerateDeviceCode) - { - return default; - } - // Create a new principal containing only the filtered claims. // Actors identities are also filtered (delegation scenarios). var principal = context.Principal.Clone(claim => @@ -2044,7 +2038,7 @@ namespace OpenIddict.Server principal.SetClaim(Claims.Nonce, context.EndpointType switch { OpenIddictServerEndpointType.Authorization => context.Request.Nonce, - OpenIddictServerEndpointType.Token => context.Principal.GetClaim(Claims.Private.Nonce), + OpenIddictServerEndpointType.Token => context.Principal.GetClaim(Claims.Private.Nonce), _ => null }); diff --git a/src/OpenIddict.Server/OpenIddictServerOptions.cs b/src/OpenIddict.Server/OpenIddictServerOptions.cs index 60582ee5..5a9e7196 100644 --- a/src/OpenIddict.Server/OpenIddictServerOptions.cs +++ b/src/OpenIddict.Server/OpenIddictServerOptions.cs @@ -181,36 +181,40 @@ namespace OpenIddict.Server /// /// Gets or sets the period of time authorization codes remain valid after being issued. The default value is 5 minutes. - /// While not recommended, this property can be set to null to issue codes that never expire. + /// While not recommended, this property can be set to to issue authorization codes that never expire. /// public TimeSpan? AuthorizationCodeLifetime { get; set; } = TimeSpan.FromMinutes(5); /// /// Gets or sets the period of time access tokens remain valid after being issued. The default value is 1 hour. /// The client application is expected to refresh or acquire a new access token after the token has expired. - /// While not recommended, this property can be set to null to issue access tokens that never expire. + /// While not recommended, this property can be set to to issue access tokens that never expire. /// public TimeSpan? AccessTokenLifetime { get; set; } = TimeSpan.FromHours(1); /// /// Gets or sets the period of time device codes remain valid after being issued. The default value is 10 minutes. /// The client application is expected to start a whole new authentication flow after the device code has expired. - /// While not recommended, this property can be set to null to issue codes that never expire. + /// While not recommended, this property can be set to to issue device codes that never expire. /// Note: the same value should be chosen for both and this property. /// + /// + /// The expiration date of a device code is automatically extended when the user approves the + /// authorization demand to give the client application enough time to redeem the device code. + /// public TimeSpan? DeviceCodeLifetime { get; set; } = TimeSpan.FromMinutes(10); /// /// Gets or sets the period of time identity tokens remain valid after being issued. The default value is 20 minutes. /// The client application is expected to refresh or acquire a new identity token after the token has expired. - /// While not recommended, this property can be set to null to issue identity tokens that never expire. + /// While not recommended, this property can be set to to issue identity tokens that never expire. /// public TimeSpan? IdentityTokenLifetime { get; set; } = TimeSpan.FromMinutes(20); /// /// Gets or sets the period of time refresh tokens remain valid after being issued. The default value is 14 days. /// The client application is expected to start a whole new authentication flow after the refresh token has expired. - /// While not recommended, this property can be set to null to issue refresh tokens that never expire. + /// While not recommended, this property can be set to to issue refresh tokens that never expire. /// public TimeSpan? RefreshTokenLifetime { get; set; } = TimeSpan.FromDays(14); @@ -223,7 +227,7 @@ namespace OpenIddict.Server /// /// Gets or sets the period of time user codes remain valid after being issued. The default value is 10 minutes. /// The client application is expected to start a whole new authentication flow after the user code has expired. - /// While not recommended, this property can be set to null to issue codes that never expire. + /// While not recommended, this property can be set to to issue user codes that never expire. /// Note: the same value should be chosen for both and this property. /// public TimeSpan? UserCodeLifetime { get; set; } = TimeSpan.FromMinutes(10); diff --git a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs index 62170f26..11039624 100644 --- a/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs +++ b/src/OpenIddict.Validation/OpenIddictValidationHandlers.Protection.cs @@ -153,8 +153,12 @@ namespace OpenIddict.Validation } // If the type associated with the token entry doesn't match one of the expected types, return an error. - if (context.ValidTokenTypes.Count > 0 && - !await _tokenManager.HasTypeAsync(token, context.ValidTokenTypes.ToImmutableArray())) + if (!(context.ValidTokenTypes.Count switch + { + 0 => true, // If no specific token type is expected, accept all token types at this stage. + 1 => await _tokenManager.HasTypeAsync(token, context.ValidTokenTypes.ElementAt(0)), + _ => await _tokenManager.HasTypeAsync(token, context.ValidTokenTypes.ToImmutableArray()) + })) { context.Reject( error: Errors.InvalidToken, diff --git a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs index bdc56f97..7c2d2b68 100644 --- a/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs +++ b/test/OpenIddict.Server.IntegrationTests/OpenIddictServerIntegrationTests.Exchange.cs @@ -474,6 +474,75 @@ namespace OpenIddict.Server.IntegrationTests Assert.Equal(SR.FormatID8000(SR.ID2018), response.ErrorUri); } + [Fact] + public async Task ValidateTokenRequest_ExpiredDeviceCodeCausesAnError() + { + // Arrange + var token = new OpenIddictToken(); + + var manager = CreateTokenManager(mock => + { + mock.Setup(manager => manager.FindByReferenceIdAsync("g43LaWCUrz2RaLILz2L1bg1bOpMSv1hGrH12IIkB9H4", It.IsAny())) + .ReturnsAsync(token); + + mock.Setup(manager => manager.HasTypeAsync(token, TokenTypeHints.DeviceCode, It.IsAny())) + .ReturnsAsync(true); + + mock.Setup(manager => manager.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("60FFF7EA-F98E-437B-937E-5073CC313103"); + + mock.Setup(manager => manager.GetPayloadAsync(token, It.IsAny())) + .ReturnsAsync("GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS"); + + mock.Setup(manager => manager.FindByIdAsync("60FFF7EA-F98E-437B-937E-5073CC313103", It.IsAny())) + .ReturnsAsync(token); + + mock.Setup(manager => manager.GetExpirationDateAsync(token, It.IsAny())) + .ReturnsAsync(DateTimeOffset.UtcNow - TimeSpan.FromDays(1)); + + mock.Setup(manager => manager.GetTypeAsync(token, It.IsAny())) + .ReturnsAsync(TokenTypeHints.DeviceCode); + }); + + await using var server = await CreateServerAsync(options => + { + options.AddEventHandler(builder => + { + builder.UseInlineHandler(context => + { + Assert.Equal("GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", context.Token); + Assert.Equal(new[] { TokenTypeHints.DeviceCode }, context.ValidTokenTypes); + + context.Principal = new ClaimsPrincipal(new ClaimsIdentity("Bearer")) + .SetClaim(Claims.Subject, "Bob le Bricoleur"); + + return default; + }); + + builder.SetOrder(ValidateIdentityModelToken.Descriptor.Order - 500); + }); + + options.Services.AddSingleton(manager); + }); + + await using var client = await server.CreateClientAsync(); + + // Act + var response = await client.PostAsync("/connect/token", new OpenIddictRequest + { + GrantType = GrantTypes.DeviceCode, + DeviceCode = "g43LaWCUrz2RaLILz2L1bg1bOpMSv1hGrH12IIkB9H4" + }); + + // Assert + Assert.Equal(Errors.ExpiredToken, response.Error); + Assert.Equal(SR.GetResourceString(SR.ID2017), response.ErrorDescription); + Assert.Equal(SR.FormatID8000(SR.ID2017), response.ErrorUri); + + Mock.Get(manager).Verify(manager => manager.FindByIdAsync("60FFF7EA-F98E-437B-937E-5073CC313103", It.IsAny()), Times.AtLeastOnce()); + Mock.Get(manager).Verify(manager => manager.HasStatusAsync(token, Statuses.Inactive, It.IsAny()), Times.Never()); + } + [Fact] public async Task ValidateTokenRequest_AuthorizationCodeCausesAnErrorWhenPresentersAreMissing() {