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()
{