diff --git a/build/version.props b/build/version.props index ba6ff1a7..b0387258 100644 --- a/build/version.props +++ b/build/version.props @@ -2,7 +2,7 @@ 1.0.0 - beta2 + rc1 $(VersionSuffix)-$(BuildNumber) diff --git a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs index bcef21ac..54e3e821 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictApplicationManager.cs @@ -162,9 +162,9 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, whose result /// returns the client applications corresponding to the specified post_logout_redirect_uri. /// - public virtual Task FindByLogoutRedirectUri(string address, CancellationToken cancellationToken) + public virtual Task FindByLogoutRedirectUriAsync(string address, CancellationToken cancellationToken) { - return Store.FindByLogoutRedirectUri(address, cancellationToken); + return Store.FindByLogoutRedirectUriAsync(address, cancellationToken); } /// @@ -176,9 +176,9 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, whose result /// returns the client applications corresponding to the specified redirect_uri. /// - public virtual Task FindByRedirectUri(string address, CancellationToken cancellationToken) + public virtual Task FindByRedirectUriAsync(string address, CancellationToken cancellationToken) { - return Store.FindByRedirectUri(address, cancellationToken); + return Store.FindByRedirectUriAsync(address, cancellationToken); } /// @@ -527,7 +527,7 @@ namespace OpenIddict.Core { // Warning: SQL engines like Microsoft SQL Server are known to use case-insensitive lookups by default. // To ensure a case-sensitive comparison is used, string.Equals(Ordinal) is manually called here. - foreach (var application in await Store.FindByLogoutRedirectUri(address, cancellationToken)) + foreach (var application in await Store.FindByLogoutRedirectUriAsync(address, cancellationToken)) { // Note: the post_logout_redirect_uri must be compared using case-sensitive "Simple String Comparison". if (string.Equals(address, await Store.GetLogoutRedirectUriAsync(application, cancellationToken), StringComparison.Ordinal)) diff --git a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs index 19d62ec8..3e6be278 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictAuthorizationManager.cs @@ -5,6 +5,7 @@ */ using System; +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; @@ -42,9 +43,9 @@ namespace OpenIddict.Core /// The application to create. /// The that can be used to abort the operation. /// - /// A that can be used to monitor the asynchronous operation. + /// A that can be used to monitor the asynchronous operation, whose result returns the authorization. /// - public virtual Task CreateAsync([NotNull] TAuthorization authorization, CancellationToken cancellationToken) + public virtual Task CreateAsync([NotNull] TAuthorization authorization, CancellationToken cancellationToken) { if (authorization == null) { @@ -54,6 +55,38 @@ namespace OpenIddict.Core return Store.CreateAsync(authorization, cancellationToken); } + /// + /// Creates a new authorization. + /// + /// The subject associated with the authorization. + /// The client associated with the authorization. + /// The scopes associated with the authorization. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, whose result returns the authorization. + /// + public virtual Task CreateAsync( + [NotNull] string subject, [NotNull] string client, + [NotNull] IEnumerable scopes, CancellationToken cancellationToken) + { + if (scopes == null) + { + throw new ArgumentNullException(nameof(scopes)); + } + + if (string.IsNullOrEmpty(subject)) + { + throw new ArgumentException("The subject cannot be null or empty.", nameof(subject)); + } + + if (string.IsNullOrEmpty(client)) + { + throw new ArgumentException("The client cannot be null or empty.", nameof(subject)); + } + + return Store.CreateAsync(subject, client, scopes, cancellationToken); + } + /// /// Retrieves an authorization using its associated subject/client. /// @@ -102,6 +135,45 @@ namespace OpenIddict.Core return Store.GetIdAsync(authorization, cancellationToken); } + /// + /// Revokes an authorization. + /// + /// The authorization to revoke. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + public virtual async Task RevokeAsync([NotNull] TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization == null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + var status = await Store.GetStatusAsync(authorization, cancellationToken); + if (!string.Equals(status, OpenIddictConstants.Statuses.Revoked, StringComparison.OrdinalIgnoreCase)) + { + await Store.SetStatusAsync(authorization, OpenIddictConstants.Statuses.Revoked, cancellationToken); + await UpdateAsync(authorization, cancellationToken); + } + } + + /// + /// Updates an existing authorization. + /// + /// The authorization to update. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public virtual Task UpdateAsync([NotNull] TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization == null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + return Store.UpdateAsync(authorization, cancellationToken); + } + /// /// Validates the authorization to ensure it's in a consistent state. /// diff --git a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs index b1b95d1d..c1480e63 100644 --- a/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs +++ b/src/OpenIddict.Core/Managers/OpenIddictTokenManager.cs @@ -61,21 +61,50 @@ namespace OpenIddict.Core /// /// The token type. /// The subject associated with the token. + /// The date on which the token will start to be considered valid. + /// The date on which the token will no longer be considered valid. /// The that can be used to abort the operation. /// /// A that can be used to monitor the asynchronous operation, whose result returns the token. /// - public virtual async Task CreateAsync([NotNull] string type, [NotNull] string subject, CancellationToken cancellationToken) + public virtual Task CreateAsync( + [NotNull] string type, [NotNull] string subject, + [CanBeNull] DateTimeOffset? start, + [CanBeNull] DateTimeOffset? end, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(type)) { throw new ArgumentException("The token type cannot be null or empty.", nameof(type)); } - if (!string.Equals(type, OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, StringComparison.OrdinalIgnoreCase) && - !string.Equals(type, OpenIdConnectConstants.TokenTypeHints.RefreshToken, StringComparison.OrdinalIgnoreCase)) + if (string.IsNullOrEmpty(subject)) { - throw new ArgumentException("The specified token type is not supported by the default token manager."); + throw new ArgumentException("The subject cannot be null or empty."); + } + + return Store.CreateAsync(type, subject, start, end, cancellationToken); + } + + /// + /// Creates a new reference token, which is associated with a particular subject. + /// + /// The token type. + /// The subject associated with the token. + /// The hash of the crypto-secure random identifier associated with the token. + /// The ciphertext associated with the token. + /// The date on which the token will start to be considered valid. + /// The date on which the token will no longer be considered valid. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, whose result returns the token. + /// + public virtual Task CreateAsync( + [NotNull] string type, [NotNull] string subject, [NotNull] string hash, [NotNull] string ciphertext, + [CanBeNull] DateTimeOffset? start, [CanBeNull] DateTimeOffset? end, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(type)) + { + throw new ArgumentException("The token type cannot be null or empty.", nameof(type)); } if (string.IsNullOrEmpty(subject)) @@ -83,7 +112,40 @@ namespace OpenIddict.Core throw new ArgumentException("The subject cannot be null or empty."); } - return await Store.CreateAsync(type, subject, cancellationToken); + if (string.IsNullOrEmpty(ciphertext)) + { + throw new ArgumentException("The ciphertext cannot be null or empty.", nameof(ciphertext)); + } + + return Store.CreateAsync(type, subject, hash, ciphertext, start, end, cancellationToken); + } + + /// + /// Retrieves the list of tokens corresponding to the specified authorization identifier. + /// + /// The authorization identifier associated with the tokens. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the tokens corresponding to the specified authorization. + /// + public virtual Task FindByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken) + { + return Store.FindByAuthorizationIdAsync(identifier, cancellationToken); + } + + /// + /// Retrieves the list of tokens corresponding to the specified hash. + /// + /// The hashed crypto-secure random identifier associated with the tokens. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the tokens corresponding to the specified hash. + /// + public virtual Task FindByHashAsync(string hash, CancellationToken cancellationToken) + { + return Store.FindByHashAsync(hash, cancellationToken); } /// @@ -114,6 +176,63 @@ namespace OpenIddict.Core return Store.FindBySubjectAsync(subject, cancellationToken); } + /// + /// Retrieves the optional authorization identifier associated with a token. + /// + /// The token. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the authorization identifier associated with the token. + /// + public virtual Task GetAuthorizationIdAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + return Store.GetAuthorizationIdAsync(token, cancellationToken); + } + + /// + /// Retrieves the ciphertext associated with a token. + /// + /// The token. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the ciphertext associated with the specified token. + /// + public virtual Task GetCiphertextAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + return Store.GetCiphertextAsync(token, cancellationToken); + } + + /// + /// Retrieves the hashed identifier associated with a token. + /// + /// The token. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the hashed identifier associated with the specified token. + /// + public virtual Task GetHashAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + return Store.GetHashAsync(token, cancellationToken); + } + /// /// Retrieves the unique identifier associated with a token. /// @@ -133,20 +252,131 @@ namespace OpenIddict.Core return Store.GetIdAsync(token, cancellationToken); } + /// + /// Retrieves the status associated with a token. + /// + /// The token. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the status associated with the specified token. + /// + public virtual Task GetStatusAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + return Store.GetStatusAsync(token, cancellationToken); + } + + /// + /// Determines whether a given token has already been redemeed. + /// + /// The token. + /// The that can be used to abort the operation. + /// true if the token has already been redemeed, false otherwise. + public virtual async Task IsRedeemedAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + var status = await Store.GetStatusAsync(token, cancellationToken); + if (string.IsNullOrEmpty(status)) + { + return false; + } + + return string.Equals(status, OpenIddictConstants.Statuses.Redeemed, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines whether a given token has been revoked. + /// + /// The token. + /// The that can be used to abort the operation. + /// true if the token has been revoked, false otherwise. + public virtual async Task IsRevokedAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + var status = await Store.GetStatusAsync(token, cancellationToken); + if (string.IsNullOrEmpty(status)) + { + return false; + } + + return string.Equals(status, OpenIddictConstants.Statuses.Revoked, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Determines whether a given token is valid. + /// + /// The token. + /// The that can be used to abort the operation. + /// true if the token is valid, false otherwise. + public virtual async Task IsValidAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + var status = await Store.GetStatusAsync(token, cancellationToken); + if (string.IsNullOrEmpty(status)) + { + return false; + } + + return string.Equals(status, OpenIddictConstants.Statuses.Valid, StringComparison.OrdinalIgnoreCase); + } + + /// + /// Redeems a token. + /// + /// The token to redeem. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + public virtual async Task RedeemAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + var status = await Store.GetStatusAsync(token, cancellationToken); + if (!string.Equals(status, OpenIddictConstants.Statuses.Redeemed, StringComparison.OrdinalIgnoreCase)) + { + await Store.SetStatusAsync(token, OpenIddictConstants.Statuses.Redeemed, cancellationToken); + await UpdateAsync(token, cancellationToken); + } + } + /// /// Revokes a token. /// /// The token to revoke. /// The that can be used to abort the operation. /// A that can be used to monitor the asynchronous operation. - public virtual Task RevokeAsync([NotNull] TToken token, CancellationToken cancellationToken) + public virtual async Task RevokeAsync([NotNull] TToken token, CancellationToken cancellationToken) { if (token == null) { throw new ArgumentNullException(nameof(token)); } - return Store.RevokeAsync(token, cancellationToken); + var status = await Store.GetStatusAsync(token, cancellationToken); + if (!string.Equals(status, OpenIddictConstants.Statuses.Revoked, StringComparison.OrdinalIgnoreCase)) + { + await Store.SetStatusAsync(token, OpenIddictConstants.Statuses.Revoked, cancellationToken); + await UpdateAsync(token, cancellationToken); + } } /// diff --git a/src/OpenIddict.Core/OpenIddictConstants.cs b/src/OpenIddict.Core/OpenIddictConstants.cs index ec3f7d4c..97b901b8 100644 --- a/src/OpenIddict.Core/OpenIddictConstants.cs +++ b/src/OpenIddict.Core/OpenIddictConstants.cs @@ -39,5 +39,12 @@ namespace OpenIddict.Core { public const string Roles = "roles"; } + + public static class Statuses + { + public const string Redeemed = "redeemed"; + public const string Revoked = "revoked"; + public const string Valid = "valid"; + } } } diff --git a/src/OpenIddict.Core/Stores/IOpenIddictApplicationStore.cs b/src/OpenIddict.Core/Stores/IOpenIddictApplicationStore.cs index 972340bf..64455d66 100644 --- a/src/OpenIddict.Core/Stores/IOpenIddictApplicationStore.cs +++ b/src/OpenIddict.Core/Stores/IOpenIddictApplicationStore.cs @@ -68,7 +68,7 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, whose result /// returns the client applications corresponding to the specified post_logout_redirect_uri. /// - Task FindByLogoutRedirectUri(string address, CancellationToken cancellationToken); + Task FindByLogoutRedirectUriAsync(string address, CancellationToken cancellationToken); /// /// Retrieves all the applications associated with the specified redirect_uri. @@ -79,7 +79,7 @@ namespace OpenIddict.Core /// A that can be used to monitor the asynchronous operation, whose result /// returns the client applications corresponding to the specified redirect_uri. /// - Task FindByRedirectUri(string address, CancellationToken cancellationToken); + Task FindByRedirectUriAsync(string address, CancellationToken cancellationToken); /// /// Retrieves the client identifier associated with an application. diff --git a/src/OpenIddict.Core/Stores/IOpenIddictAuthorizationStore.cs b/src/OpenIddict.Core/Stores/IOpenIddictAuthorizationStore.cs index 5035e1a5..dbe35f14 100644 --- a/src/OpenIddict.Core/Stores/IOpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.Core/Stores/IOpenIddictAuthorizationStore.cs @@ -4,6 +4,7 @@ * the license and the contributors participating to this project. */ +using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; @@ -26,6 +27,20 @@ namespace OpenIddict.Core /// Task CreateAsync([NotNull] TAuthorization authorization, CancellationToken cancellationToken); + /// + /// Creates a new authorization. + /// + /// The subject associated with the authorization. + /// The client associated with the authorization. + /// The scopes associated with the authorization. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, whose result returns the authorization. + /// + Task CreateAsync( + [NotNull] string subject, [NotNull] string client, + [NotNull] IEnumerable scopes, CancellationToken cancellationToken); + /// /// Retrieves an authorization using its unique identifier. /// @@ -60,6 +75,17 @@ namespace OpenIddict.Core /// Task GetIdAsync([NotNull] TAuthorization authorization, CancellationToken cancellationToken); + /// + /// Retrieves the status associated with an authorization. + /// + /// The authorization. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the status associated with the specified authorization. + /// + Task GetStatusAsync([NotNull] TAuthorization authorization, CancellationToken cancellationToken); + /// /// Retrieves the subject associated with an authorization. /// @@ -70,5 +96,26 @@ namespace OpenIddict.Core /// whose result returns the subject associated with the specified authorization. /// Task GetSubjectAsync([NotNull] TAuthorization authorization, CancellationToken cancellationToken); + + /// + /// Sets the status associated with an authorization. + /// + /// The authorization. + /// The status associated with the authorization. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + Task SetStatusAsync([NotNull] TAuthorization authorization, [NotNull] string status, CancellationToken cancellationToken); + + /// + /// Updates an existing authorization. + /// + /// The authorization to update. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + Task UpdateAsync([NotNull] TAuthorization authorization, CancellationToken cancellationToken); } } \ No newline at end of file diff --git a/src/OpenIddict.Core/Stores/IOpenIddictTokenStore.cs b/src/OpenIddict.Core/Stores/IOpenIddictTokenStore.cs index 4456f5bc..ca9dc883 100644 --- a/src/OpenIddict.Core/Stores/IOpenIddictTokenStore.cs +++ b/src/OpenIddict.Core/Stores/IOpenIddictTokenStore.cs @@ -4,6 +4,7 @@ * the license and the contributors participating to this project. */ +using System; using System.Threading; using System.Threading.Tasks; using JetBrains.Annotations; @@ -31,11 +32,63 @@ namespace OpenIddict.Core /// /// The token type. /// The subject associated with the token. + /// The date on which the token will start to be considered valid. + /// The date on which the token will no longer be considered valid. /// The that can be used to abort the operation. /// /// A that can be used to monitor the asynchronous operation, whose result returns the token. /// - Task CreateAsync([NotNull] string type, [NotNull] string subject, CancellationToken cancellationToken); + Task CreateAsync( + [NotNull] string type, [NotNull] string subject, + [CanBeNull] DateTimeOffset? start, + [CanBeNull] DateTimeOffset? end, CancellationToken cancellationToken); + + /// + /// Creates a new reference token, which is associated with a particular subject. + /// + /// The token type. + /// The subject associated with the token. + /// The hash of the crypto-secure random identifier associated with the token. + /// The ciphertext associated with the token. + /// The date on which the token will start to be considered valid. + /// The date on which the token will no longer be considered valid. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, whose result returns the token. + /// + Task CreateAsync( + [NotNull] string type, [NotNull] string subject, [NotNull] string hash, [NotNull] string ciphertext, + [CanBeNull] DateTimeOffset? start, [CanBeNull] DateTimeOffset? end, CancellationToken cancellationToken); + + /// + /// Removes a token. + /// + /// The token to delete. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + Task DeleteAsync([NotNull] TToken token, CancellationToken cancellationToken); + + /// + /// Retrieves the list of tokens corresponding to the specified authorization identifier. + /// + /// The authorization identifier associated with the tokens. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the tokens corresponding to the specified authorization. + /// + Task FindByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken); + + /// + /// Retrieves the list of tokens corresponding to the specified hash. + /// + /// The hashed crypto-secure random identifier associated with the tokens. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the tokens corresponding to the specified hash. + /// + Task FindByHashAsync(string hash, CancellationToken cancellationToken); /// /// Retrieves an token using its unique identifier. @@ -59,6 +112,39 @@ namespace OpenIddict.Core /// Task FindBySubjectAsync(string subject, CancellationToken cancellationToken); + /// + /// Retrieves the optional authorization identifier associated with a token. + /// + /// The token. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the authorization identifier associated with the token. + /// + Task GetAuthorizationIdAsync([NotNull] TToken token, CancellationToken cancellationToken); + + /// + /// Retrieves the ciphertext associated with a token. + /// + /// The token. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the ciphertext associated with the specified token. + /// + Task GetCiphertextAsync([NotNull] TToken token, CancellationToken cancellationToken); + + /// + /// Retrieves the hashed identifier associated with a token. + /// + /// The token. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the hashed identifier associated with the specified token. + /// + Task GetHashAsync([NotNull] TToken token, CancellationToken cancellationToken); + /// /// Retrieves the unique identifier associated with a token. /// @@ -71,15 +157,15 @@ namespace OpenIddict.Core Task GetIdAsync([NotNull] TToken token, CancellationToken cancellationToken); /// - /// Retrieves the token type associated with a token. + /// Retrieves the status associated with a token. /// /// The token. /// The that can be used to abort the operation. /// /// A that can be used to monitor the asynchronous operation, - /// whose result returns the token type associated with the specified token. + /// whose result returns the status associated with the specified token. /// - Task GetTokenTypeAsync([NotNull] TToken token, CancellationToken cancellationToken); + Task GetStatusAsync([NotNull] TToken token, CancellationToken cancellationToken); /// /// Retrieves the subject associated with a token. @@ -93,12 +179,15 @@ namespace OpenIddict.Core Task GetSubjectAsync([NotNull] TToken token, CancellationToken cancellationToken); /// - /// Revokes a token. + /// Retrieves the token type associated with a token. /// - /// The token to revoke. + /// The token. /// The that can be used to abort the operation. - /// A that can be used to monitor the asynchronous operation. - Task RevokeAsync([NotNull] TToken token, CancellationToken cancellationToken); + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the token type associated with the specified token. + /// + Task GetTokenTypeAsync([NotNull] TToken token, CancellationToken cancellationToken); /// /// Sets the authorization associated with a token. @@ -122,6 +211,17 @@ namespace OpenIddict.Core /// Task SetClientAsync([NotNull] TToken token, [CanBeNull] string identifier, CancellationToken cancellationToken); + /// + /// Sets the status associated with a token. + /// + /// The token. + /// The status associated with the authorization. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + Task SetStatusAsync([NotNull] TToken token, [NotNull] string status, CancellationToken cancellationToken); + /// /// Updates an existing token. /// diff --git a/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs b/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs index 93674ea1..ca82c718 100644 --- a/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs +++ b/src/OpenIddict.EntityFrameworkCore/OpenIddictExtensions.cs @@ -264,6 +264,9 @@ namespace Microsoft.Extensions.DependencyInjection { entity.HasKey(token => token.Id); + entity.HasIndex(token => token.Hash) + .IsUnique(unique: true); + entity.ToTable("OpenIddictTokens"); }); diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs index e6aaa966..96d5e7ee 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictApplicationStore.cs @@ -164,7 +164,7 @@ namespace OpenIddict.EntityFrameworkCore /// A that can be used to monitor the asynchronous operation, whose result /// returns the client applications corresponding to the specified post_logout_redirect_uri. /// - public virtual Task FindByLogoutRedirectUri(string address, CancellationToken cancellationToken) + public virtual Task FindByLogoutRedirectUriAsync(string address, CancellationToken cancellationToken) { return Applications.Where(application => application.LogoutRedirectUri == address).ToArrayAsync(cancellationToken); } @@ -178,7 +178,7 @@ namespace OpenIddict.EntityFrameworkCore /// A that can be used to monitor the asynchronous operation, whose result /// returns the client applications corresponding to the specified redirect_uri. /// - public virtual Task FindByRedirectUri(string address, CancellationToken cancellationToken) + public virtual Task FindByRedirectUriAsync(string address, CancellationToken cancellationToken) { return Applications.Where(application => application.RedirectUri == address).ToArrayAsync(cancellationToken); } diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs index dd2da734..cd375020 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictAuthorizationStore.cs @@ -5,6 +5,7 @@ */ using System; +using System.Collections.Generic; using System.ComponentModel; using System.Linq; using System.Threading; @@ -104,6 +105,48 @@ namespace OpenIddict.EntityFrameworkCore return authorization; } + /// + /// Creates a new authorization. + /// + /// The subject associated with the authorization. + /// The client associated with the authorization. + /// The scopes associated with the authorization. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, whose result returns the authorization. + /// + public virtual async Task CreateAsync( + [NotNull] string subject, [NotNull] string client, + [NotNull] IEnumerable scopes, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(subject)) + { + throw new ArgumentException("The subject cannot be null or empty.", nameof(subject)); + } + + if (string.IsNullOrEmpty(client)) + { + throw new ArgumentException("The client cannot be null or empty.", nameof(subject)); + } + + var key = ConvertIdentifierFromString(client); + + var application = await Applications.SingleOrDefaultAsync(entity => entity.Id.Equals(key)); + if (application == null) + { + throw new InvalidOperationException("The application associated with the authorization cannot be found."); + } + + var authorization = new TAuthorization + { + Application = application, + Scope = string.Join(" ", scopes), + Subject = subject + }; + + return await CreateAsync(authorization, cancellationToken); + } + /// /// Retrieves an authorization using its associated subject/client. /// @@ -160,6 +203,20 @@ namespace OpenIddict.EntityFrameworkCore return Task.FromResult(ConvertIdentifierToString(authorization.Id)); } + /// + /// Retrieves the status associated with an authorization. + /// + /// The authorization. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the status associated with the specified authorization. + /// + public virtual Task GetStatusAsync([NotNull] TAuthorization authorization, CancellationToken cancellationToken) + { + return Task.FromResult(authorization.Status); + } + /// /// Retrieves the subject associated with an authorization. /// @@ -179,6 +236,44 @@ namespace OpenIddict.EntityFrameworkCore return Task.FromResult(authorization.Subject); } + /// + /// Sets the status associated with an authorization. + /// + /// The authorization. + /// The status associated with the authorization. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public virtual Task SetStatusAsync([NotNull] TAuthorization authorization, [NotNull] string status, CancellationToken cancellationToken) + { + authorization.Status = status; + + return Task.FromResult(0); + } + + /// + /// Updates an existing authorization. + /// + /// The authorization to update. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public virtual async Task UpdateAsync([NotNull] TAuthorization authorization, CancellationToken cancellationToken) + { + + Context.Attach(authorization); + Context.Update(authorization); + + try + { + await Context.SaveChangesAsync(cancellationToken); + } + + catch (DbUpdateConcurrencyException) { } + } + /// /// Converts the provided identifier to a strongly typed key object. /// diff --git a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs index b6b45092..3f116cbf 100644 --- a/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs +++ b/src/OpenIddict.EntityFrameworkCore/Stores/OpenIddictTokenStore.cs @@ -114,18 +114,129 @@ namespace OpenIddict.EntityFrameworkCore /// /// The token type. /// The subject associated with the token. + /// The date on which the token will start to be considered valid. + /// The date on which the token will no longer be considered valid. /// The that can be used to abort the operation. /// /// A that can be used to monitor the asynchronous operation, whose result returns the token. /// - public virtual Task CreateAsync([NotNull] string type, [NotNull] string subject, CancellationToken cancellationToken) + public virtual Task CreateAsync( + [NotNull] string type, [NotNull] string subject, + [CanBeNull] DateTimeOffset? start, + [CanBeNull] DateTimeOffset? end, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(type)) { throw new ArgumentException("The token type cannot be null or empty."); } - return CreateAsync(new TToken { Subject = subject, Type = type }, cancellationToken); + if (string.IsNullOrEmpty(subject)) + { + throw new ArgumentException("The subject cannot be null or empty."); + } + + var token = new TToken + { + End = end, + Start = start, + Subject = subject, + Type = type + }; + + return CreateAsync(token, cancellationToken); + } + + /// + /// Creates a new reference token, which is associated with a particular subject. + /// + /// The token type. + /// The subject associated with the token. + /// The hash of the crypto-secure random identifier associated with the token. + /// The ciphertext associated with the token. + /// The date on which the token will start to be considered valid. + /// The date on which the token will no longer be considered valid. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, whose result returns the token. + /// + public virtual Task CreateAsync( + [NotNull] string type, [NotNull] string subject, [NotNull] string hash, [NotNull] string ciphertext, + [CanBeNull] DateTimeOffset? start, [CanBeNull] DateTimeOffset? end, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(type)) + { + throw new ArgumentException("The token type cannot be null or empty."); + } + + if (string.IsNullOrEmpty(subject)) + { + throw new ArgumentException("The subject cannot be null or empty."); + } + + var token = new TToken + { + Ciphertext = ciphertext, + End = end, + Hash = hash, + Start = start, + Subject = subject, + Type = type + }; + + return CreateAsync(token, cancellationToken); + } + + /// + /// Removes a token. + /// + /// The token to delete. + /// The that can be used to abort the operation. + /// A that can be used to monitor the asynchronous operation. + public virtual async Task DeleteAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + Context.Remove(token); + + try + { + await Context.SaveChangesAsync(cancellationToken); + } + + catch (DbUpdateConcurrencyException) { } + } + + /// + /// Retrieves the list of tokens corresponding to the specified authorization identifier. + /// + /// The authorization identifier associated with the tokens. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the tokens corresponding to the specified authorization. + /// + public virtual Task FindByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken) + { + var key = ConvertIdentifierFromString(identifier); + + return Tokens.Where(token => token.Authorization.Id.Equals(key)).ToArrayAsync(cancellationToken); + } + + /// + /// Retrieves the list of tokens corresponding to the specified hash. + /// + /// The hashed crypto-secure random identifier associated with the tokens. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the tokens corresponding to the specified hash. + /// + public virtual Task FindByHashAsync(string hash, CancellationToken cancellationToken) + { + return Tokens.SingleOrDefaultAsync(token => token.Hash == hash, cancellationToken); } /// @@ -158,6 +269,67 @@ namespace OpenIddict.EntityFrameworkCore return Tokens.Where(token => token.Subject == subject).ToArrayAsync(); } + /// + /// Retrieves the optional authorization identifier associated with a token. + /// + /// The token. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the authorization identifier associated with the token. + /// + public virtual async Task GetAuthorizationIdAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + var key = await (from authorization in Authorizations + where authorization.Tokens.Any(entity => entity.Id.Equals(token.Id)) + select authorization.Id).SingleOrDefaultAsync(); + + return ConvertIdentifierToString(key); + } + + /// + /// Retrieves the ciphertext associated with a token. + /// + /// The token. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the ciphertext associated with the specified token. + /// + public virtual Task GetCiphertextAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + return Task.FromResult(token.Ciphertext); + } + + /// + /// Retrieves the hashed identifier associated with a token. + /// + /// The token. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the hashed identifier associated with the specified token. + /// + public virtual Task GetHashAsync([NotNull] TToken token, CancellationToken cancellationToken) + { + if (token == null) + { + throw new ArgumentNullException(nameof(token)); + } + + return Task.FromResult(token.Hash); + } + /// /// Retrieves the unique identifier associated with a token. /// @@ -178,22 +350,22 @@ namespace OpenIddict.EntityFrameworkCore } /// - /// Retrieves the token type associated with a token. + /// Retrieves the status associated with a token. /// /// The token. /// The that can be used to abort the operation. /// /// A that can be used to monitor the asynchronous operation, - /// whose result returns the token type associated with the specified token. + /// whose result returns the status associated with the specified token. /// - public virtual Task GetTokenTypeAsync([NotNull] TToken token, CancellationToken cancellationToken) + public virtual Task GetStatusAsync([NotNull] TToken token, CancellationToken cancellationToken) { if (token == null) { throw new ArgumentNullException(nameof(token)); } - return Task.FromResult(token.Type); + return Task.FromResult(token.Status); } /// @@ -216,26 +388,22 @@ namespace OpenIddict.EntityFrameworkCore } /// - /// Revokes a token. + /// Retrieves the token type associated with a token. /// - /// The token to revoke. + /// The token. /// The that can be used to abort the operation. - /// A that can be used to monitor the asynchronous operation. - public virtual async Task RevokeAsync([NotNull] TToken token, CancellationToken cancellationToken) + /// + /// A that can be used to monitor the asynchronous operation, + /// whose result returns the token type associated with the specified token. + /// + public virtual Task GetTokenTypeAsync([NotNull] TToken token, CancellationToken cancellationToken) { if (token == null) { throw new ArgumentNullException(nameof(token)); } - Context.Remove(token); - - try - { - await Context.SaveChangesAsync(cancellationToken); - } - - catch (DbUpdateConcurrencyException) { } + return Task.FromResult(token.Type); } /// @@ -324,6 +492,22 @@ namespace OpenIddict.EntityFrameworkCore } } + /// + /// Sets the status associated with a token. + /// + /// The token. + /// The status associated with the authorization. + /// The that can be used to abort the operation. + /// + /// A that can be used to monitor the asynchronous operation. + /// + public virtual Task SetStatusAsync([NotNull] TToken token, [NotNull] string status, CancellationToken cancellationToken) + { + token.Status = status; + + return Task.FromResult(0); + } + /// /// Updates an existing token. /// diff --git a/src/OpenIddict.Models/OpenIddictAuthorization.cs b/src/OpenIddict.Models/OpenIddictAuthorization.cs index 34a4be67..2da90c02 100644 --- a/src/OpenIddict.Models/OpenIddictAuthorization.cs +++ b/src/OpenIddict.Models/OpenIddictAuthorization.cs @@ -50,6 +50,11 @@ namespace OpenIddict.Models /// public virtual string Scope { get; set; } + /// + /// Gets or sets the status of the current authorization. + /// + public virtual string Status { get; set; } = "valid"; + /// /// Gets or sets the subject associated with the current authorization. /// diff --git a/src/OpenIddict.Models/OpenIddictToken.cs b/src/OpenIddict.Models/OpenIddictToken.cs index d4801084..f9926574 100644 --- a/src/OpenIddict.Models/OpenIddictToken.cs +++ b/src/OpenIddict.Models/OpenIddictToken.cs @@ -43,12 +43,43 @@ namespace OpenIddict.Models /// public virtual TAuthorization Authorization { get; set; } + /// + /// Gets or sets the encrypted payload + /// of the current token, if applicable. + /// This property is only used for reference tokens. + /// + public virtual string Ciphertext { get; set; } + + /// + /// Gets or sets the date on which the token + /// will no longer be considered valid. + /// + public virtual DateTimeOffset? End { get; set; } + + /// + /// Gets or sets the hashed identifier associated + /// with the current token, if applicable. + /// This property is only used for reference tokens. + /// + public virtual string Hash { get; set; } + /// /// Gets or sets the unique identifier /// associated with the current token. /// public virtual TKey Id { get; set; } + /// + /// Gets or sets the date on which the token + /// will start to be considered valid. + /// + public virtual DateTimeOffset? Start { get; set; } + + /// + /// Gets or sets the status of the current token. + /// + public virtual string Status { get; set; } = "valid"; + /// /// Gets or sets the subject associated with the current token. /// diff --git a/src/OpenIddict/OpenIddictExtensions.cs b/src/OpenIddict/OpenIddictExtensions.cs index 6ce4490f..55eec5dd 100644 --- a/src/OpenIddict/OpenIddictExtensions.cs +++ b/src/OpenIddict/OpenIddictExtensions.cs @@ -14,6 +14,7 @@ using System.Security.Cryptography.X509Certificates; using AspNet.Security.OpenIdConnect.Primitives; using AspNet.Security.OpenIdConnect.Server; using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Caching.Distributed; @@ -75,6 +76,55 @@ namespace Microsoft.AspNetCore.Builder } } + // If OpenIddict was configured to use reference tokens, replace the default access tokens/ + // authorization codes/refresh tokens formats using a specific data protector to ensure + // that encrypted tokens stored in the database cannot be treated as valid tokens if the + // reference tokens option is later turned off by the developer. + if (options.UseReferenceTokens) + { + // Note: a default data protection provider is always registered by + // the OpenID Connect server handler when none is explicitly set but + // this initializer is registered to be invoked before ASOS' initializer. + // To ensure the provider property is never null, it's manually set here. + if (options.DataProtectionProvider == null) + { + options.DataProtectionProvider = app.ApplicationServices.GetDataProtectionProvider(); + } + + if (options.AccessTokenFormat == null) + { + var protector = options.DataProtectionProvider.CreateProtector( + nameof(OpenIdConnectServerHandler), + nameof(options.AccessTokenFormat), + nameof(options.UseReferenceTokens), + options.AuthenticationScheme); + + options.AccessTokenFormat = new TicketDataFormat(protector); + } + + if (options.AuthorizationCodeFormat == null) + { + var protector = options.DataProtectionProvider.CreateProtector( + nameof(OpenIdConnectServerHandler), + nameof(options.AuthorizationCodeFormat), + nameof(options.UseReferenceTokens), + options.AuthenticationScheme); + + options.AuthorizationCodeFormat = new TicketDataFormat(protector); + } + + if (options.RefreshTokenFormat == null) + { + var protector = options.DataProtectionProvider.CreateProtector( + nameof(OpenIdConnectServerHandler), + nameof(options.RefreshTokenFormat), + nameof(options.UseReferenceTokens), + options.AuthenticationScheme); + + options.RefreshTokenFormat = new TicketDataFormat(protector); + } + } + // Ensure at least one flow has been enabled. if (options.GrantTypes.Count == 0) { @@ -106,6 +156,18 @@ namespace Microsoft.AspNetCore.Builder throw new InvalidOperationException("The revocation endpoint cannot be enabled when token revocation is disabled."); } + if (options.UseReferenceTokens && options.DisableTokenRevocation) + { + throw new InvalidOperationException( + "Reference tokens cannot be used when disabling token revocation."); + } + + if (options.UseReferenceTokens && options.AccessTokenHandler != null) + { + throw new InvalidOperationException( + "Reference tokens cannot be used when configuring JWT as the access token format."); + } + // Ensure at least one asymmetric signing certificate/key was registered if the implicit flow was enabled. if (!options.SigningCredentials.Any(credentials => credentials.Key is AsymmetricSecurityKey) && options.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.Implicit)) @@ -883,5 +945,26 @@ namespace Microsoft.AspNetCore.Builder }; }); } + + /// + /// Configures OpenIddict to use reference tokens, so that authorization codes, + /// access tokens and refresh tokens are stored as ciphertext in the database + /// (only an identifier is returned to the client application). Enabling this option + /// is useful to keep track of all the issued tokens, when storing a very large + /// number of claims in the authorization codes, access tokens and refresh tokens + /// or when immediate revocation of reference access tokens is desired. + /// Note: this option cannot be used when configuring JWT as the access token format. + /// + /// The services builder used by OpenIddict to register new services. + /// The . + public static OpenIddictBuilder UseReferenceTokens([NotNull] this OpenIddictBuilder builder) + { + if (builder == null) + { + throw new ArgumentNullException(nameof(builder)); + } + + return builder.Configure(options => options.UseReferenceTokens = true); + } } } \ No newline at end of file diff --git a/src/OpenIddict/OpenIddictOptions.cs b/src/OpenIddict/OpenIddictOptions.cs index 0960ae63..d8a9297d 100644 --- a/src/OpenIddict/OpenIddictOptions.cs +++ b/src/OpenIddict/OpenIddictOptions.cs @@ -6,6 +6,7 @@ using System; using System.Collections.Generic; +using System.Security.Cryptography; using AspNet.Security.OpenIdConnect.Server; using Microsoft.Extensions.Caching.Distributed; @@ -48,11 +49,28 @@ namespace OpenIddict /// public ISet GrantTypes { get; } = new HashSet(StringComparer.Ordinal); + /// + /// Gets or sets the random number generator used to generate crypto-secure identifiers. + /// + public RandomNumberGenerator RandomNumberGenerator { get; set; } = RandomNumberGenerator.Create(); + /// /// Gets or sets a boolean determining whether client identification is required. /// Enabling this option requires registering a client application and sending a /// valid client_id when communicating with the token and revocation endpoints. /// public bool RequireClientIdentification { get; set; } + + /// + /// Gets or sets a boolean indicating whether reference tokens should be used. + /// When set to true, authorization codes, access tokens and refresh tokens + /// are stored as ciphertext in the database and a crypto-secure random identifier + /// is returned to the client application. Enabling this option is useful + /// to keep track of all the issued tokens, when storing a very large number + /// of claims in the authorization codes, access tokens and refresh tokens + /// or when immediate revocation of reference access tokens is desired. + /// Note: this option cannot be used when configuring JWT as the access token format. + /// + public bool UseReferenceTokens { get; set; } } } diff --git a/src/OpenIddict/OpenIddictProvider.Authentication.cs b/src/OpenIddict/OpenIddictProvider.Authentication.cs index 66623dc1..ad1422ca 100644 --- a/src/OpenIddict/OpenIddictProvider.Authentication.cs +++ b/src/OpenIddict/OpenIddictProvider.Authentication.cs @@ -16,7 +16,6 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Bson; using Newtonsoft.Json.Linq; @@ -29,8 +28,9 @@ namespace OpenIddict { public override async Task ExtractAuthorizationRequest([NotNull] ExtractAuthorizationRequestContext context) { + var options = (OpenIddictOptions) context.Options; + var logger = context.HttpContext.RequestServices.GetRequiredService>>(); - var options = context.HttpContext.RequestServices.GetRequiredService>(); // Reject requests using the unsupported request parameter. if (!string.IsNullOrEmpty(context.Request.Request)) @@ -63,7 +63,7 @@ namespace OpenIddict if (!string.IsNullOrEmpty(context.Request.RequestId)) { // Return an error if request caching support was not enabled. - if (!options.Value.EnableRequestCaching) + if (!options.EnableRequestCaching) { logger.LogError("The authorization request was rejected because " + "request caching support was not enabled."); @@ -79,7 +79,7 @@ namespace OpenIddict // to avoid collisions with the other types of cached requests. var key = OpenIddictConstants.Environment.AuthorizationRequest + context.Request.RequestId; - var payload = await options.Value.Cache.GetAsync(key); + var payload = await options.Cache.GetAsync(key); if (payload == null) { logger.LogError("The authorization request was rejected because an unknown " + @@ -111,9 +111,10 @@ namespace OpenIddict public override async Task ValidateAuthorizationRequest([NotNull] ValidateAuthorizationRequestContext context) { + var options = (OpenIddictOptions) context.Options; + var applications = context.HttpContext.RequestServices.GetRequiredService>(); var logger = context.HttpContext.RequestServices.GetRequiredService>>(); - var options = context.HttpContext.RequestServices.GetRequiredService>(); // Note: the OpenID Connect server middleware supports authorization code, implicit, hybrid, // none and custom flows but OpenIddict uses a stricter policy rejecting unknown flows. @@ -132,7 +133,7 @@ namespace OpenIddict // Reject code flow authorization requests if the authorization code flow is not enabled. if (context.Request.IsAuthorizationCodeFlow() && - !options.Value.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.AuthorizationCode)) + !options.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.AuthorizationCode)) { logger.LogError("The authorization request was rejected because " + "the authorization code flow was not enabled."); @@ -145,7 +146,7 @@ namespace OpenIddict } // Reject implicit flow authorization requests if the implicit flow is not enabled. - if (context.Request.IsImplicitFlow() && !options.Value.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.Implicit)) + if (context.Request.IsImplicitFlow() && !options.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.Implicit)) { logger.LogError("The authorization request was rejected because the implicit flow was not enabled."); @@ -157,8 +158,8 @@ namespace OpenIddict } // Reject hybrid flow authorization requests if the authorization code or the implicit flows are not enabled. - if (context.Request.IsHybridFlow() && (!options.Value.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.AuthorizationCode) || - !options.Value.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.Implicit))) + if (context.Request.IsHybridFlow() && (!options.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.AuthorizationCode) || + !options.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.Implicit))) { logger.LogError("The authorization request was rejected because the " + "authorization code flow or the implicit flow was not enabled."); @@ -172,7 +173,7 @@ namespace OpenIddict // Reject authorization requests that specify scope=offline_access if the refresh token flow is not enabled. if (context.Request.HasScope(OpenIdConnectConstants.Scopes.OfflineAccess) && - !options.Value.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.RefreshToken)) + !options.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.RefreshToken)) { context.Reject( error: OpenIdConnectConstants.Errors.InvalidRequest, @@ -317,12 +318,12 @@ namespace OpenIddict public override async Task HandleAuthorizationRequest([NotNull] HandleAuthorizationRequestContext context) { - var options = context.HttpContext.RequestServices.GetRequiredService>(); + var options = (OpenIddictOptions) context.Options; // If no request_id parameter can be found in the current request, assume the OpenID Connect request // was not serialized yet and store the entire payload in the distributed cache to make it easier // to flow across requests and internal/external authentication/registration workflows. - if (options.Value.EnableRequestCaching && string.IsNullOrEmpty(context.Request.RequestId)) + if (options.EnableRequestCaching && string.IsNullOrEmpty(context.Request.RequestId)) { // Generate a request identifier. Note: using a crypto-secure // random number generator is not necessary in this case. @@ -342,7 +343,7 @@ namespace OpenIddict // to avoid collisions with the other types of cached requests. var key = OpenIddictConstants.Environment.AuthorizationRequest + context.Request.RequestId; - await options.Value.Cache.SetAsync(key, stream.ToArray(), new DistributedCacheEntryOptions + await options.Cache.SetAsync(key, stream.ToArray(), new DistributedCacheEntryOptions { AbsoluteExpiration = context.Options.SystemClock.UtcNow + TimeSpan.FromMinutes(30), SlidingExpiration = TimeSpan.FromMinutes(10) @@ -368,10 +369,10 @@ namespace OpenIddict public override async Task ApplyAuthorizationResponse([NotNull] ApplyAuthorizationResponseContext context) { - var options = context.HttpContext.RequestServices.GetRequiredService>(); + var options = (OpenIddictOptions) context.Options; // Remove the authorization request from the distributed cache. - if (options.Value.EnableRequestCaching && !string.IsNullOrEmpty(context.Request.RequestId)) + if (options.EnableRequestCaching && !string.IsNullOrEmpty(context.Request.RequestId)) { // Note: the cache key is always prefixed with a specific marker // to avoid collisions with the other types of cached requests. @@ -380,11 +381,11 @@ namespace OpenIddict // Note: the ApplyAuthorizationResponse event is called for both successful // and errored authorization responses but discrimination is not necessary here, // as the authorization request must be removed from the distributed cache in both cases. - await options.Value.Cache.RemoveAsync(key); + await options.Cache.RemoveAsync(key); } - if (!options.Value.ApplicationCanDisplayErrors && !string.IsNullOrEmpty(context.Error) && - string.IsNullOrEmpty(context.RedirectUri)) + if (!options.ApplicationCanDisplayErrors && !string.IsNullOrEmpty(context.Error) && + string.IsNullOrEmpty(context.RedirectUri)) { // Determine if the status code pages middleware has been enabled for this request. // If it was not registered or enabled, let the OpenID Connect server middleware render diff --git a/src/OpenIddict/OpenIddictProvider.Discovery.cs b/src/OpenIddict/OpenIddictProvider.Discovery.cs index 7e04869c..59a54852 100644 --- a/src/OpenIddict/OpenIddictProvider.Discovery.cs +++ b/src/OpenIddict/OpenIddictProvider.Discovery.cs @@ -9,8 +9,6 @@ using System.Threading.Tasks; using AspNet.Security.OpenIdConnect.Primitives; using AspNet.Security.OpenIdConnect.Server; using JetBrains.Annotations; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; using Newtonsoft.Json.Linq; using OpenIddict.Core; @@ -21,7 +19,7 @@ namespace OpenIddict { public override Task HandleConfigurationRequest([NotNull] HandleConfigurationRequestContext context) { - var options = context.HttpContext.RequestServices.GetRequiredService>(); + var options = (OpenIddictOptions) context.Options; // Note: though it's natively supported by the OpenID Connect server middleware, // OpenIddict disallows the use of the unsecure code_challenge_method=plain method, @@ -32,7 +30,7 @@ namespace OpenIddict // Note: the OpenID Connect server middleware automatically populates grant_types_supported // by determining whether the authorization and token endpoints are enabled or not but // OpenIddict uses a different approach and relies on a configurable "grants list". - context.GrantTypes.IntersectWith(options.Value.GrantTypes); + context.GrantTypes.IntersectWith(options.GrantTypes); // Note: the "openid" scope is automatically // added by the OpenID Connect server middleware. diff --git a/src/OpenIddict/OpenIddictProvider.Exchange.cs b/src/OpenIddict/OpenIddictProvider.Exchange.cs index ee95f42a..3a062cfb 100644 --- a/src/OpenIddict/OpenIddictProvider.Exchange.cs +++ b/src/OpenIddict/OpenIddictProvider.Exchange.cs @@ -12,7 +12,6 @@ using AspNet.Security.OpenIdConnect.Server; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using OpenIddict.Core; namespace OpenIddict @@ -22,12 +21,13 @@ namespace OpenIddict { public override async Task ValidateTokenRequest([NotNull] ValidateTokenRequestContext context) { + var options = (OpenIddictOptions) context.Options; + var applications = context.HttpContext.RequestServices.GetRequiredService>(); var logger = context.HttpContext.RequestServices.GetRequiredService>>(); - var options = context.HttpContext.RequestServices.GetRequiredService>(); // Reject token requests that don't specify a supported grant type. - if (!options.Value.GrantTypes.Contains(context.Request.GrantType)) + if (!options.GrantTypes.Contains(context.Request.GrantType)) { logger.LogError("The token request was rejected because the '{Grant}' " + "grant is not supported.", context.Request.GrantType); @@ -41,7 +41,7 @@ namespace OpenIddict // Reject token requests that specify scope=offline_access if the refresh token flow is not enabled. if (context.Request.HasScope(OpenIdConnectConstants.Scopes.OfflineAccess) && - !options.Value.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.RefreshToken)) + !options.GrantTypes.Contains(OpenIdConnectConstants.GrantTypes.RefreshToken)) { context.Reject( error: OpenIdConnectConstants.Errors.InvalidRequest, @@ -100,7 +100,7 @@ namespace OpenIddict if (string.IsNullOrEmpty(context.ClientId)) { // Reject the request if client identification is mandatory. - if (options.Value.RequireClientIdentification) + if (options.RequireClientIdentification) { context.Reject( error: OpenIdConnectConstants.Errors.InvalidRequest, @@ -200,60 +200,114 @@ namespace OpenIddict public override async Task HandleTokenRequest([NotNull] HandleTokenRequestContext context) { - var options = context.HttpContext.RequestServices.GetRequiredService>(); + var options = (OpenIddictOptions) context.Options; + + var authorizations = context.HttpContext.RequestServices.GetRequiredService>(); var logger = context.HttpContext.RequestServices.GetRequiredService>>(); var tokens = context.HttpContext.RequestServices.GetRequiredService>(); - if (!options.Value.DisableTokenRevocation && (context.Request.IsAuthorizationCodeGrantType() || - context.Request.IsRefreshTokenGrantType())) + if (options.DisableTokenRevocation || (!context.Request.IsAuthorizationCodeGrantType() && + !context.Request.IsRefreshTokenGrantType())) { - Debug.Assert(context.Ticket != null, "The authentication ticket shouldn't be null."); + // Invoke the rest of the pipeline to allow + // the user code to handle the token request. + context.SkipToNextMiddleware(); + + return; + } - // Extract the token identifier from the authentication ticket. - var identifier = context.Ticket.GetProperty(OpenIdConnectConstants.Properties.TokenId); - Debug.Assert(!string.IsNullOrEmpty(identifier), "The authentication ticket should contain a ticket identifier."); + Debug.Assert(context.Ticket != null, "The authentication ticket shouldn't be null."); - if (context.Request.IsAuthorizationCodeGrantType()) + // Extract the token identifier from the authentication ticket. + var identifier = context.Ticket.GetProperty(OpenIdConnectConstants.Properties.TokenId); + Debug.Assert(!string.IsNullOrEmpty(identifier), "The authentication ticket should contain a ticket identifier."); + + if (context.Request.IsAuthorizationCodeGrantType()) + { + // Retrieve the authorization code from the database and ensure it is still valid. + var token = await tokens.FindByIdAsync(identifier, context.HttpContext.RequestAborted); + if (token == null) { - // Retrieve the token from the database and ensure it is still valid. - var token = await tokens.FindByIdAsync(identifier, context.HttpContext.RequestAborted); - if (token == null) - { - logger.LogError("The token request was rejected because the authorization code was revoked."); + logger.LogError("The token request was rejected because the authorization code was no longer valid."); + + context.Reject( + error: OpenIdConnectConstants.Errors.InvalidGrant, + description: "The specified authorization code is no longer valid."); - context.Reject( - error: OpenIdConnectConstants.Errors.InvalidGrant, - description: "The authorization code is no longer valid."); + return; + } - return; + // If the authorization code is already marked as redeemed, this may indicate that the authorization + // code was compromised. In this case, revoke the authorization and all the associated tokens. + // See https://tools.ietf.org/html/rfc6749#section-10.5 for more information. + if (await tokens.IsRedeemedAsync(token, context.HttpContext.RequestAborted)) + { + var key = context.Ticket.GetProperty(OpenIddictConstants.Properties.AuthorizationId); + if (!string.IsNullOrEmpty(key)) + { + var authorization = await authorizations.FindByIdAsync(key, context.HttpContext.RequestAborted); + if (authorization != null) + { + logger.LogInformation("The authorization '{Identifier}' was automatically revoked.", key); + + await authorizations.RevokeAsync(authorization, context.HttpContext.RequestAborted); + } + + var items = await tokens.FindByAuthorizationIdAsync(key, context.HttpContext.RequestAborted); + for (var index = 0; index < items.Length; index++) + { + logger.LogInformation("The compromised token '{Identifier}' was automatically revoked.", + await tokens.GetIdAsync(items[index], context.HttpContext.RequestAborted)); + + await tokens.RevokeAsync(items[index], context.HttpContext.RequestAborted); + } } - // Revoke the authorization code to prevent token reuse. - await tokens.RevokeAsync(token, context.HttpContext.RequestAborted); + logger.LogError("The token request was rejected because the authorization code was already redeemed."); + + context.Reject( + error: OpenIdConnectConstants.Errors.InvalidGrant, + description: "The specified authorization code has already been redemeed."); + + return; + } + + else if (!await tokens.IsValidAsync(token, context.HttpContext.RequestAborted)) + { + logger.LogError("The token request was rejected because the authorization code was no longer valid."); + + context.Reject( + error: OpenIdConnectConstants.Errors.InvalidGrant, + description: "The specified authorization code is no longer valid."); + + return; } - else if (context.Request.IsRefreshTokenGrantType()) + // Mark the authorization code as redeemed to prevent token reuse. + await tokens.RedeemAsync(token, context.HttpContext.RequestAborted); + } + + else + { + // Retrieve the token from the database and ensure it is still valid. + var token = await tokens.FindByIdAsync(identifier, context.HttpContext.RequestAborted); + if (token == null || !await tokens.IsValidAsync(token, context.HttpContext.RequestAborted)) { - // Retrieve the token from the database and ensure it is still valid. - var token = await tokens.FindByIdAsync(identifier, context.HttpContext.RequestAborted); - if (token == null) - { - logger.LogError("The token request was rejected because the refresh token was revoked."); + logger.LogError("The token request was rejected because the refresh token was no longer valid."); - context.Reject( - error: OpenIdConnectConstants.Errors.InvalidGrant, - description: "The refresh token is no longer valid."); + context.Reject( + error: OpenIdConnectConstants.Errors.InvalidGrant, + description: "The specified refresh token is no longer valid."); - return; - } + return; + } - // When sliding expiration is enabled, immediately - // revoke the refresh token to prevent future reuse. - // See https://tools.ietf.org/html/rfc6749#section-6. - if (context.Options.UseSlidingExpiration) - { - await tokens.RevokeAsync(token, context.HttpContext.RequestAborted); - } + // When sliding expiration is enabled, immediately + // redeem the refresh token to prevent future reuse. + // See https://tools.ietf.org/html/rfc6749#section-6. + if (options.UseSlidingExpiration) + { + await tokens.RedeemAsync(token, context.HttpContext.RequestAborted); } } diff --git a/src/OpenIddict/OpenIddictProvider.Introspection.cs b/src/OpenIddict/OpenIddictProvider.Introspection.cs index 46472dca..2dbb6706 100644 --- a/src/OpenIddict/OpenIddictProvider.Introspection.cs +++ b/src/OpenIddict/OpenIddictProvider.Introspection.cs @@ -13,7 +13,6 @@ using AspNet.Security.OpenIdConnect.Server; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using OpenIddict.Core; namespace OpenIddict @@ -100,7 +99,8 @@ namespace OpenIddict public override async Task HandleIntrospectionRequest([NotNull] HandleIntrospectionRequestContext context) { - var options = context.HttpContext.RequestServices.GetRequiredService>(); + var options = (OpenIddictOptions) context.Options; + var logger = context.HttpContext.RequestServices.GetRequiredService>>(); var tokens = context.HttpContext.RequestServices.GetRequiredService>(); @@ -124,13 +124,18 @@ namespace OpenIddict return; } + if (options.DisableTokenRevocation) + { + return; + } + // When the received ticket is revocable, ensure it is still valid. - if (!options.Value.DisableTokenRevocation && (context.Ticket.IsAuthorizationCode() || context.Ticket.IsRefreshToken())) + if (options.UseReferenceTokens || context.Ticket.IsAuthorizationCode() || context.Ticket.IsRefreshToken()) { // Retrieve the token from the database using the unique identifier stored in the authentication ticket: // if the corresponding entry cannot be found, return Active = false to indicate that is is no longer valid. var token = await tokens.FindByIdAsync(identifier, context.HttpContext.RequestAborted); - if (token == null) + if (token == null || !await tokens.IsValidAsync(token, context.HttpContext.RequestAborted)) { logger.LogInformation("The token {Identifier} was declared as inactive because " + "it was revoked.", identifier); diff --git a/src/OpenIddict/OpenIddictProvider.Revocation.cs b/src/OpenIddict/OpenIddictProvider.Revocation.cs index afb4e3ae..0266c197 100644 --- a/src/OpenIddict/OpenIddictProvider.Revocation.cs +++ b/src/OpenIddict/OpenIddictProvider.Revocation.cs @@ -12,7 +12,6 @@ using AspNet.Security.OpenIdConnect.Server; using JetBrains.Annotations; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using OpenIddict.Core; namespace OpenIddict @@ -22,23 +21,36 @@ namespace OpenIddict { public override async Task ValidateRevocationRequest([NotNull] ValidateRevocationRequestContext context) { + var options = (OpenIddictOptions) context.Options; + var applications = context.HttpContext.RequestServices.GetRequiredService>(); var logger = context.HttpContext.RequestServices.GetRequiredService>>(); - var options = context.HttpContext.RequestServices.GetRequiredService>(); - Debug.Assert(!options.Value.DisableTokenRevocation, "Token revocation support shouldn't be disabled at this stage."); + Debug.Assert(!options.DisableTokenRevocation, "Token revocation support shouldn't be disabled at this stage."); // When token_type_hint is specified, reject the request if it doesn't correspond to a revocable token. - if (!string.IsNullOrEmpty(context.Request.TokenTypeHint) && - !string.Equals(context.Request.TokenTypeHint, OpenIdConnectConstants.TokenTypeHints.AuthorizationCode) && - !string.Equals(context.Request.TokenTypeHint, OpenIdConnectConstants.TokenTypeHints.RefreshToken)) + if (!string.IsNullOrEmpty(context.Request.TokenTypeHint)) { - context.Reject( - error: OpenIdConnectConstants.Errors.UnsupportedTokenType, - description: "Only authorization codes and refresh tokens can be revoked. When specifying a token_type_hint " + - "parameter, its value must be equal to 'authorization_code' or 'refresh_token'."); + if (string.Equals(context.Request.TokenTypeHint, OpenIdConnectConstants.TokenTypeHints.IdToken)) + { + context.Reject( + error: OpenIdConnectConstants.Errors.UnsupportedTokenType, + description: "Identity tokens cannot be revoked. When specifying a token_type_hint parameter, " + + "its value must be equal to 'access_token', 'authorization_code' or 'refresh_token'."); - return; + return; + } + + if (!options.UseReferenceTokens && + string.Equals(context.Request.TokenTypeHint, OpenIdConnectConstants.TokenTypeHints.AccessToken)) + { + context.Reject( + error: OpenIdConnectConstants.Errors.UnsupportedTokenType, + description: "Access tokens cannot be revoked. When specifying a token_type_hint parameter, " + + "its value must be equal to 'authorization_code' or 'refresh_token'."); + + return; + } } // Skip client authentication if the client identifier is missing or reject @@ -49,7 +61,7 @@ namespace OpenIddict if (string.IsNullOrEmpty(context.ClientId)) { // Reject the request if client identification is mandatory. - if (options.Value.RequireClientIdentification) + if (options.RequireClientIdentification) { logger.LogError("The revocation request was rejected becaused the " + "mandatory client_id parameter was missing or empty."); @@ -128,6 +140,8 @@ namespace OpenIddict public override async Task HandleRevocationRequest([NotNull] HandleRevocationRequestContext context) { + var options = (OpenIddictOptions) context.Options; + var logger = context.HttpContext.RequestServices.GetRequiredService>>(); var tokens = context.HttpContext.RequestServices.GetRequiredService>(); @@ -135,13 +149,25 @@ namespace OpenIddict // If the received token is not an authorization code or a refresh token, // return an error to indicate that the token cannot be revoked. - if (!context.Ticket.IsAuthorizationCode() && !context.Ticket.IsRefreshToken()) + if (context.Ticket.IsIdentityToken()) + { + logger.LogError("The revocation request was rejected because identity tokens are not revocable."); + + context.Reject( + error: OpenIdConnectConstants.Errors.UnsupportedTokenType, + description: "Identity tokens cannot be revoked."); + + return; + } + + // If the received token is an access token, return an error if reference tokens are not enabled. + if (!options.UseReferenceTokens && context.Ticket.IsAccessToken()) { - logger.LogError("The revocation request was rejected because the token was not revocable."); + logger.LogError("The revocation request was rejected because the access token was not revocable."); context.Reject( error: OpenIdConnectConstants.Errors.UnsupportedTokenType, - description: "Only authorization codes and refresh tokens can be revoked."); + description: "The specified access token cannot be revoked."); return; } @@ -153,7 +179,7 @@ namespace OpenIddict // Retrieve the token from the database. If the token cannot be found, // assume it is invalid and consider the revocation as successful. var token = await tokens.FindByIdAsync(identifier, context.HttpContext.RequestAborted); - if (token == null) + if (token == null || await tokens.IsRevokedAsync(token, context.HttpContext.RequestAborted)) { logger.LogInformation("The token '{Identifier}' was already revoked.", identifier); diff --git a/src/OpenIddict/OpenIddictProvider.Serialization.cs b/src/OpenIddict/OpenIddictProvider.Serialization.cs index da3bcd00..f072b3bb 100644 --- a/src/OpenIddict/OpenIddictProvider.Serialization.cs +++ b/src/OpenIddict/OpenIddictProvider.Serialization.cs @@ -6,13 +6,17 @@ using System; using System.Diagnostics; +using System.Security.Cryptography; using System.Threading.Tasks; using AspNet.Security.OpenIdConnect.Extensions; using AspNet.Security.OpenIdConnect.Primitives; using AspNet.Security.OpenIdConnect.Server; using JetBrains.Annotations; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.Tokens; using OpenIddict.Core; namespace OpenIddict @@ -20,112 +24,376 @@ namespace OpenIddict public partial class OpenIddictProvider : OpenIdConnectServerProvider where TApplication : class where TAuthorization : class where TScope : class where TToken : class { + public override async Task DeserializeAccessToken([NotNull] DeserializeAccessTokenContext context) + { + var options = (OpenIddictOptions) context.Options; + if (!options.UseReferenceTokens) + { + return; + } + + var ticket = await ReceiveTokenAsync( + context.AccessToken, options, context.HttpContext, + context.Request, context.DataFormat); + + // If a valid ticket was returned by ReceiveTokenAsync(), + // force the OpenID Connect server middleware to use it. + if (ticket != null) + { + context.Ticket = ticket; + context.HandleResponse(); + } + + // Otherwise, let the OpenID Connect server middleware + // deserialize the token using its default internal logic. + } + + public override async Task DeserializeAuthorizationCode([NotNull] DeserializeAuthorizationCodeContext context) + { + var options = (OpenIddictOptions) context.Options; + if (!options.UseReferenceTokens) + { + return; + } + + var ticket = await ReceiveTokenAsync( + context.AuthorizationCode, options, context.HttpContext, + context.Request, context.DataFormat); + + // If a valid ticket was returned by ReceiveTokenAsync(), + // force the OpenID Connect server middleware to use it. + if (ticket != null) + { + context.Ticket = ticket; + context.HandleResponse(); + } + + // Otherwise, let the OpenID Connect server middleware + // deserialize the token using its default internal logic. + } + + public override async Task DeserializeRefreshToken([NotNull] DeserializeRefreshTokenContext context) + { + var options = (OpenIddictOptions) context.Options; + if (!options.UseReferenceTokens) + { + return; + } + + var ticket = await ReceiveTokenAsync( + context.RefreshToken, options, context.HttpContext, + context.Request, context.DataFormat); + + // If a valid ticket was returned by ReceiveTokenAsync(), + // force the OpenID Connect server middleware to use it. + if (ticket != null) + { + context.Ticket = ticket; + context.HandleResponse(); + } + + // Otherwise, let the OpenID Connect server middleware + // deserialize the token using its default internal logic. + } + + public override async Task SerializeAccessToken([NotNull] SerializeAccessTokenContext context) + { + var token = await CreateTokenAsync( + OpenIdConnectConstants.TokenUsages.AccessToken, + context.Ticket, (OpenIddictOptions) context.Options, + context.HttpContext, context.Request, context.DataFormat); + + // If a reference token was returned by CreateTokenAsync(), + // force the OpenID Connect server middleware to use it. + if (!string.IsNullOrEmpty(token)) + { + context.AccessToken = token; + context.HandleResponse(); + } + + // Otherwise, let the OpenID Connect server middleware + // serialize the token using its default internal logic. + } + public override async Task SerializeAuthorizationCode([NotNull] SerializeAuthorizationCodeContext context) { - var applications = context.HttpContext.RequestServices.GetRequiredService>(); - var options = context.HttpContext.RequestServices.GetRequiredService>(); - var tokens = context.HttpContext.RequestServices.GetRequiredService>(); + var token = await CreateTokenAsync( + OpenIdConnectConstants.TokenUsages.AuthorizationCode, + context.Ticket, (OpenIddictOptions) context.Options, + context.HttpContext, context.Request, context.DataFormat); + + // If a reference token was returned by CreateTokenAsync(), + // force the OpenID Connect server middleware to use it. + if (!string.IsNullOrEmpty(token)) + { + context.AuthorizationCode = token; + context.HandleResponse(); + } - Debug.Assert(!string.IsNullOrEmpty(context.Request.ClientId), "The client identifier shouldn't be null or empty."); + // Otherwise, let the OpenID Connect server middleware + // serialize the token using its default internal logic. + } + + public override async Task SerializeRefreshToken([NotNull] SerializeRefreshTokenContext context) + { + var token = await CreateTokenAsync( + OpenIdConnectConstants.TokenUsages.RefreshToken, + context.Ticket, (OpenIddictOptions) context.Options, + context.HttpContext, context.Request, context.DataFormat); - if (!options.Value.DisableTokenRevocation) + // If a reference token was returned by CreateTokenAsync(), + // force the OpenID Connect server middleware to use it. + if (!string.IsNullOrEmpty(token)) { - // Resolve the subject from the authentication ticket. If it cannot be found, throw an exception. - var subject = context.Ticket.Principal.GetClaim(OpenIdConnectConstants.Claims.Subject); - if (string.IsNullOrEmpty(subject)) - { - throw new InvalidOperationException("The subject associated with the authentication ticket cannot be retrieved."); - } + context.RefreshToken = token; + context.HandleResponse(); + } + + // Otherwise, let the OpenID Connect server middleware + // serialize the token using its default internal logic. + } + + private async Task CreateTokenAsync( + [NotNull] string type, [NotNull] AuthenticationTicket ticket, + [NotNull] OpenIddictOptions options, [NotNull] HttpContext context, + [NotNull] OpenIdConnectRequest request, + [NotNull] ISecureDataFormat format) + { + var applications = context.RequestServices.GetRequiredService>(); + var authorizations = context.RequestServices.GetRequiredService>(); + var logger = context.RequestServices.GetRequiredService>>(); + var tokens = context.RequestServices.GetRequiredService>(); + + Debug.Assert(!(options.DisableTokenRevocation && options.UseReferenceTokens), + "Token revocation cannot be disabled when using reference tokens."); + + Debug.Assert(!string.Equals(type, OpenIdConnectConstants.TokenUsages.IdToken, StringComparison.OrdinalIgnoreCase), + "Identity tokens shouldn't be stored in the database."); + + if (options.DisableTokenRevocation) + { + return null; + } + + // Resolve the subject from the authentication ticket. If it cannot be found, throw an exception. + var subject = ticket.Principal.GetClaim(OpenIdConnectConstants.Claims.Subject); + if (string.IsNullOrEmpty(subject)) + { + throw new InvalidOperationException("The subject associated with the authentication ticket cannot be retrieved."); + } + + TToken token; + string result = null; + + // If reference tokens are enabled, create a new entry for + // authorization codes, refresh tokens and access tokens. + if (options.UseReferenceTokens) + { + // When the token is a reference token, remove the token identifier from the + // authentication ticket as it is restored when receiving and decrypting it. + ticket.RemoveProperty(OpenIdConnectConstants.Properties.TokenId); + + // Note: the data format is automatically replaced at startup time to ensure + // that encrypted tokens stored in the database cannot be considered as + // valid tokens if the developer decides to disable reference tokens support. + var ciphertext = format.Protect(ticket); - // If a null value was returned by CreateAsync, return immediately. - var token = await tokens.CreateAsync(OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, subject, context.HttpContext.RequestAborted); - if (token == null) + // Generate a new crypto-secure random identifier that will be + // substituted to the ciphertext returned by the data format. + var bytes = new byte[256 / 8]; + options.RandomNumberGenerator.GetBytes(bytes); + result = Base64UrlEncoder.Encode(bytes); + + // Compute the digest of the generated identifier and use + // it as the hashed identifier of the reference token. + // Doing that prevents token identifiers stolen from + // the database from being used as valid reference tokens. + string hash; + using (var algorithm = SHA256.Create()) { - return; + hash = Convert.ToBase64String(algorithm.ComputeHash(bytes)); } - // Throw an exception if the token identifier can't be resolved. - var identifier = await tokens.GetIdAsync(token, context.HttpContext.RequestAborted); - if (string.IsNullOrEmpty(identifier)) + token = await tokens.CreateAsync(type, subject, hash, ciphertext, + ticket.Properties.IssuedUtc, + ticket.Properties.ExpiresUtc, context.RequestAborted); + } + + // Otherwise, only create a token metadata entry for authorization codes and refresh tokens. + else if (string.Equals(type, OpenIdConnectConstants.TokenUsages.AuthorizationCode, StringComparison.OrdinalIgnoreCase) || + string.Equals(type, OpenIdConnectConstants.TokenUsages.RefreshToken, StringComparison.OrdinalIgnoreCase)) + { + token = await tokens.CreateAsync(type, subject, + ticket.Properties.IssuedUtc, + ticket.Properties.ExpiresUtc, context.RequestAborted); + } + + else + { + return null; + } + + // If a null value was returned by CreateAsync(), return immediately. + if (token == null) + { + return null; + } + + // Throw an exception if the token identifier can't be resolved. + var identifier = await tokens.GetIdAsync(token, context.RequestAborted); + if (string.IsNullOrEmpty(identifier)) + { + throw new InvalidOperationException("The unique key associated with a refresh token cannot be null or empty."); + } + + // Attach the key returned by the underlying store + // to the refresh token to override the default GUID + // generated by the OpenID Connect server middleware. + ticket.SetTokenId(identifier); + + // If the client application is known, associate it with the token. + if (!string.IsNullOrEmpty(request.ClientId)) + { + var application = await applications.FindByClientIdAsync(request.ClientId, context.RequestAborted); + if (application == null) { - throw new InvalidOperationException("The unique key associated with an authorization code cannot be null or empty."); + throw new InvalidOperationException("The client application cannot be retrieved from the database."); } - // Attach the key returned by the underlying store - // to the authorization code to override the default GUID - // generated by the OpenID Connect server middleware. - context.Ticket.SetProperty(OpenIdConnectConstants.Properties.TokenId, identifier); + var key = await applications.GetIdAsync(application, context.RequestAborted); + + await tokens.SetClientAsync(token, key, context.RequestAborted); + } + + // If an authorization identifier was specified, bind it to the token. + if (ticket.HasProperty(OpenIddictConstants.Properties.AuthorizationId)) + { + await tokens.SetAuthorizationAsync(token, + ticket.GetProperty(OpenIddictConstants.Properties.AuthorizationId), context.RequestAborted); + } - var application = await applications.FindByClientIdAsync(context.Request.ClientId, context.HttpContext.RequestAborted); + // Otherwise, create an ad-hoc authorization if the token is an authorization code. + else if (string.Equals(type, OpenIdConnectConstants.TokenUsages.AuthorizationCode, StringComparison.OrdinalIgnoreCase)) + { + Debug.Assert(!string.IsNullOrEmpty(request.ClientId), "The client identifier shouldn't be null."); + + var application = await applications.FindByClientIdAsync(request.ClientId, context.RequestAborted); if (application == null) { throw new InvalidOperationException("The client application cannot be retrieved from the database."); } - await tokens.SetClientAsync(token, await applications.GetIdAsync(application, context.HttpContext.RequestAborted), context.HttpContext.RequestAborted); + var authorization = await authorizations.CreateAsync(subject, + await applications.GetIdAsync(application, context.RequestAborted), request.GetScopes(), context.RequestAborted); - // If an authorization identifier was specified, bind it to the token. - var authorization = context.Ticket.GetProperty(OpenIddictConstants.Properties.AuthorizationId); - if (!string.IsNullOrEmpty(authorization)) + if (authorization != null) { - await tokens.SetAuthorizationAsync(token, authorization, context.HttpContext.RequestAborted); + var key = await authorizations.GetIdAsync(authorization, context.RequestAborted); + ticket.SetProperty(OpenIddictConstants.Properties.AuthorizationId, key); + + await tokens.SetAuthorizationAsync(token, key, context.RequestAborted); } } + + if (!string.IsNullOrEmpty(result)) + { + logger.LogTrace("A new reference token was successfully generated and persisted " + + "in the database: {Token} ; {Claims} ; {Properties}.", + result, ticket.Principal.Claims, ticket.Properties.Items); + } + + return result; } - public override async Task SerializeRefreshToken([NotNull] SerializeRefreshTokenContext context) + private async Task ReceiveTokenAsync( + [NotNull] string value, [NotNull] OpenIddictOptions options, + [NotNull] HttpContext context, [NotNull] OpenIdConnectRequest request, + [NotNull] ISecureDataFormat format) { - var applications = context.HttpContext.RequestServices.GetRequiredService>(); - var options = context.HttpContext.RequestServices.GetRequiredService>(); - var tokens = context.HttpContext.RequestServices.GetRequiredService>(); + var logger = context.RequestServices.GetRequiredService>>(); + var tokens = context.RequestServices.GetRequiredService>(); - if (!options.Value.DisableTokenRevocation) + if (!options.UseReferenceTokens) { - // Resolve the subject from the authentication ticket. If it cannot be found, throw an exception. - var subject = context.Ticket.Principal.GetClaim(OpenIdConnectConstants.Claims.Subject); - if (string.IsNullOrEmpty(subject)) - { - throw new InvalidOperationException("The subject associated with the authentication ticket cannot be retrieved."); - } + return null; + } - // If a null value was returned by CreateAsync, return immediately. - var token = await tokens.CreateAsync(OpenIdConnectConstants.TokenTypeHints.RefreshToken, subject, context.HttpContext.RequestAborted); - if (token == null) + string hash; + try + { + // Compute the digest of the received token and use it + // to retrieve the reference token from the database. + using (var algorithm = SHA256.Create()) { - return; + hash = Convert.ToBase64String(algorithm.ComputeHash(Base64UrlEncoder.DecodeBytes(value))); } + } - // Throw an exception if the token identifier can't be resolved. - var identifier = await tokens.GetIdAsync(token, context.HttpContext.RequestAborted); - if (string.IsNullOrEmpty(identifier)) - { - throw new InvalidOperationException("The unique key associated with a refresh token cannot be null or empty."); - } + // Swallow format-related exceptions to ensure badly formed + // or tampered tokens don't cause an exception at this stage. + catch + { + return null; + } - // Attach the key returned by the underlying store - // to the refresh token to override the default GUID - // generated by the OpenID Connect server middleware. - context.Ticket.SetProperty(OpenIdConnectConstants.Properties.TokenId, identifier); + // Retrieve the token entry from the database. If it + // cannot be found, assume the token is not valid. + var token = await tokens.FindByHashAsync(hash, context.RequestAborted); + if (token == null) + { + logger.LogInformation("The reference token corresponding to the '{Hash}' hashed " + + "identifier cannot be found in the database.", hash); - // If the client application is known, associate it with the token. - if (!string.IsNullOrEmpty(context.Request.ClientId)) - { - var application = await applications.FindByClientIdAsync(context.Request.ClientId, context.HttpContext.RequestAborted); - if (application == null) - { - throw new InvalidOperationException("The client application cannot be retrieved from the database."); - } + return null; + } - await tokens.SetClientAsync(token, await applications.GetIdAsync(application, context.HttpContext.RequestAborted), context.HttpContext.RequestAborted); - } + var identifier = await tokens.GetIdAsync(token, context.RequestAborted); + if (string.IsNullOrEmpty(identifier)) + { + logger.LogWarning("The identifier associated with the received token cannot be retrieved. " + + "This may indicate that the token entry is corrupted."); - // If an authorization identifier was specified, bind it to the token. - var authorization = context.Ticket.GetProperty(OpenIddictConstants.Properties.AuthorizationId); - if (!string.IsNullOrEmpty(authorization)) - { - await tokens.SetAuthorizationAsync(token, authorization, context.HttpContext.RequestAborted); - } + return null; + } + + // Extract the encrypted payload from the token. If it's null or empty, + // assume the token is not a reference token and consider it as invalid. + var ciphertext = await tokens.GetCiphertextAsync(token, context.RequestAborted); + if (string.IsNullOrEmpty(ciphertext)) + { + logger.LogWarning("The ciphertext associated with the token '{Identifier}' cannot be retrieved. " + + "This may indicate that the token is not a reference token.", identifier); + + return null; } + + var ticket = format.Unprotect(ciphertext); + if (ticket == null) + { + logger.LogWarning("The ciphertext associated with the token '{Identifier}' cannot be decrypted. " + + "This may indicate that the token entry is corrupted or tampered.", + await tokens.GetIdAsync(token, context.RequestAborted)); + + return null; + } + + // Restore the token identifier using the unique + // identifier attached with the database entry. + ticket.SetTokenId(identifier); + + // If the authorization identifier cannot be found in the ticket properties, + // try to restore it using the identifier associated with the database entry. + if (!ticket.HasProperty(OpenIddictConstants.Properties.AuthorizationId)) + { + ticket.SetProperty(OpenIddictConstants.Properties.AuthorizationId, + await tokens.GetAuthorizationIdAsync(token, context.RequestAborted)); + } + + logger.LogTrace("The reference token '{Identifier}' was successfully retrieved " + + "from the database and decrypted: {Claims} ; {Properties}.", + identifier, ticket.Principal.Claims, ticket.Properties.Items); + + return ticket; } } } \ No newline at end of file diff --git a/src/OpenIddict/OpenIddictProvider.Session.cs b/src/OpenIddict/OpenIddictProvider.Session.cs index 6eae960a..06adf6aa 100644 --- a/src/OpenIddict/OpenIddictProvider.Session.cs +++ b/src/OpenIddict/OpenIddictProvider.Session.cs @@ -15,7 +15,6 @@ using Microsoft.AspNetCore.WebUtilities; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; using Newtonsoft.Json; using Newtonsoft.Json.Bson; using Newtonsoft.Json.Linq; @@ -28,15 +27,16 @@ namespace OpenIddict { public override async Task ExtractLogoutRequest([NotNull] ExtractLogoutRequestContext context) { + var options = (OpenIddictOptions) context.Options; + var logger = context.HttpContext.RequestServices.GetRequiredService>>(); - var options = context.HttpContext.RequestServices.GetRequiredService>(); // If a request_id parameter can be found in the logout request, // restore the complete logout request from the distributed cache. if (!string.IsNullOrEmpty(context.Request.RequestId)) { // Return an error if request caching support was not enabled. - if (!options.Value.EnableRequestCaching) + if (!options.EnableRequestCaching) { logger.LogError("The logout request was rejected because " + "request caching support was not enabled."); @@ -52,7 +52,7 @@ namespace OpenIddict // to avoid collisions with the other types of cached requests. var key = OpenIddictConstants.Environment.LogoutRequest + context.Request.RequestId; - var payload = await options.Value.Cache.GetAsync(key); + var payload = await options.Cache.GetAsync(key); if (payload == null) { logger.LogError("The logout request was rejected because an unknown " + @@ -106,12 +106,12 @@ namespace OpenIddict public override async Task HandleLogoutRequest([NotNull] HandleLogoutRequestContext context) { - var options = context.HttpContext.RequestServices.GetRequiredService>(); + var options = (OpenIddictOptions) context.Options; // If no request_id parameter can be found in the current request, assume the OpenID Connect // request was not serialized yet and store the entire payload in the distributed cache // to make it easier to flow across requests and internal/external logout workflows. - if (options.Value.EnableRequestCaching && string.IsNullOrEmpty(context.Request.RequestId)) + if (options.EnableRequestCaching && string.IsNullOrEmpty(context.Request.RequestId)) { // Generate a request identifier. Note: using a crypto-secure // random number generator is not necessary in this case. @@ -131,7 +131,7 @@ namespace OpenIddict // to avoid collisions with the other types of cached requests. var key = OpenIddictConstants.Environment.LogoutRequest + context.Request.RequestId; - await options.Value.Cache.SetAsync(key, stream.ToArray(), new DistributedCacheEntryOptions + await options.Cache.SetAsync(key, stream.ToArray(), new DistributedCacheEntryOptions { AbsoluteExpiration = context.Options.SystemClock.UtcNow + TimeSpan.FromMinutes(30), SlidingExpiration = TimeSpan.FromMinutes(10) @@ -155,10 +155,10 @@ namespace OpenIddict public override async Task ApplyLogoutResponse([NotNull] ApplyLogoutResponseContext context) { - var options = context.HttpContext.RequestServices.GetRequiredService>(); + var options = (OpenIddictOptions) context.Options; // Remove the logout request from the distributed cache. - if (options.Value.EnableRequestCaching && !string.IsNullOrEmpty(context.Request.RequestId)) + if (options.EnableRequestCaching && !string.IsNullOrEmpty(context.Request.RequestId)) { // Note: the cache key is always prefixed with a specific marker // to avoid collisions with the other types of cached requests. @@ -167,11 +167,11 @@ namespace OpenIddict // Note: the ApplyLogoutResponse event is called for both successful // and errored logout responses but discrimination is not necessary here, // as the logout request must be removed from the distributed cache in both cases. - await options.Value.Cache.RemoveAsync(key); + await options.Cache.RemoveAsync(key); } - if (!options.Value.ApplicationCanDisplayErrors && !string.IsNullOrEmpty(context.Error) && - string.IsNullOrEmpty(context.PostLogoutRedirectUri)) + if (!options.ApplicationCanDisplayErrors && !string.IsNullOrEmpty(context.Error) && + string.IsNullOrEmpty(context.PostLogoutRedirectUri)) { // Determine if the status code pages middleware has been enabled for this request. // If it was not registered or enabled, let the OpenID Connect server middleware render diff --git a/test/OpenIddict.Tests/OpenIddictExtensionsTests.cs b/test/OpenIddict.Tests/OpenIddictExtensionsTests.cs index f949f52f..f8ea2a56 100644 --- a/test/OpenIddict.Tests/OpenIddictExtensionsTests.cs +++ b/test/OpenIddict.Tests/OpenIddictExtensionsTests.cs @@ -128,6 +128,48 @@ namespace OpenIddict.Tests Assert.Equal("The revocation endpoint cannot be enabled when token revocation is disabled.", exception.Message); } + [Fact] + public void UseOpenIddict_ThrowsAnExceptionWhenUsingReferenceTokensWithTokenRevocationDisabled() + { + // Arrange + var services = new ServiceCollection(); + services.AddDataProtection(); + + services.AddOpenIddict() + .EnableAuthorizationEndpoint("/connect/authorize") + .AllowImplicitFlow() + .DisableTokenRevocation() + .UseReferenceTokens(); + + var builder = new ApplicationBuilder(services.BuildServiceProvider()); + + // Act and assert + var exception = Assert.Throws(() => builder.UseOpenIddict()); + + Assert.Equal("Reference tokens cannot be used when disabling token revocation.", exception.Message); + } + + [Fact] + public void UseOpenIddict_ThrowsAnExceptionWhenUsingReferenceTokensIfAnAccessTokenHandlerIsSet() + { + // Arrange + var services = new ServiceCollection(); + services.AddDataProtection(); + + services.AddOpenIddict() + .EnableAuthorizationEndpoint("/connect/authorize") + .AllowImplicitFlow() + .UseReferenceTokens() + .UseJsonWebTokens(); + + var builder = new ApplicationBuilder(services.BuildServiceProvider()); + + // Act and assert + var exception = Assert.Throws(() => builder.UseOpenIddict()); + + Assert.Equal("Reference tokens cannot be used when configuring JWT as the access token format.", exception.Message); + } + [Fact] public void UseOpenIddict_ThrowsAnExceptionWhenNoSigningKeyIsRegisteredIfTheImplicitFlowIsEnabled() { @@ -764,7 +806,26 @@ namespace OpenIddict.Tests var options = provider.GetRequiredService>(); // Assert - Assert.IsType(typeof(JwtSecurityTokenHandler), options.Value.AccessTokenHandler); + Assert.IsType(options.Value.AccessTokenHandler); + } + + [Fact] + public void UseReferenceTokens_ReferenceTokensAreEnabled() + { + // Arrange + var services = new ServiceCollection(); + services.AddOptions(); + + var builder = new OpenIddictBuilder(services); + + // Act + builder.UseReferenceTokens(); + + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>(); + + // Assert + Assert.True(options.Value.UseReferenceTokens); } } } diff --git a/test/OpenIddict.Tests/OpenIddictProviderTests.Authentication.cs b/test/OpenIddict.Tests/OpenIddictProviderTests.Authentication.cs index 778616f5..8e3e2602 100644 --- a/test/OpenIddict.Tests/OpenIddictProviderTests.Authentication.cs +++ b/test/OpenIddict.Tests/OpenIddictProviderTests.Authentication.cs @@ -1,4 +1,5 @@ -using System.IO; +using System; +using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -558,7 +559,10 @@ namespace OpenIddict.Tests { var token = new OpenIddictToken(); - instance.Setup(mock => mock.CreateAsync(OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", It.IsAny())) + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", + It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(token); instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) diff --git a/test/OpenIddict.Tests/OpenIddictProviderTests.Exchange.cs b/test/OpenIddict.Tests/OpenIddictProviderTests.Exchange.cs index 35cc7264..0780e0f6 100644 --- a/test/OpenIddict.Tests/OpenIddictProviderTests.Exchange.cs +++ b/test/OpenIddict.Tests/OpenIddictProviderTests.Exchange.cs @@ -1,4 +1,5 @@ -using System.Security.Claims; +using System.Collections.Generic; +using System.Security.Claims; using System.Threading; using System.Threading.Tasks; using AspNet.Security.OpenIdConnect.Client; @@ -458,7 +459,7 @@ namespace OpenIddict.Tests } [Fact] - public async Task HandleTokenRequest_RequestIsRejectedWhenAuthorizationCodeIsExpired() + public async Task HandleTokenRequest_RequestIsRejectedWhenAuthorizationCodeIsUnknown() { // Arrange var ticket = new AuthenticationTicket( @@ -512,13 +513,235 @@ namespace OpenIddict.Tests // Assert Assert.Equal(OpenIdConnectConstants.Errors.InvalidGrant, response.Error); - Assert.Equal("The authorization code is no longer valid.", response.ErrorDescription); + Assert.Equal("The specified authorization code is no longer valid.", response.ErrorDescription); Mock.Get(manager).Verify(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); } [Fact] - public async Task HandleTokenRequest_RequestIsRejectedWhenRefreshTokenIsExpired() + public async Task HandleTokenRequest_RequestIsRejectedWhenAuthorizationCodeIsAlreadyRedeemed() + { + // Arrange + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(), + new AuthenticationProperties(), + OpenIdConnectServerDefaults.AuthenticationScheme); + + ticket.SetPresenters("Fabrikam"); + ticket.SetTokenId("3E228451-1555-46F7-A471-951EFBA23A56"); + ticket.SetTokenUsage(OpenIdConnectConstants.TokenUsages.AuthorizationCode); + + var format = new Mock>(); + + format.Setup(mock => mock.Unprotect("SplxlOBeZQQYbYS6WxSbIA")) + .Returns(ticket); + + var token = new OpenIddictToken(); + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.IsRedeemedAsync(token, It.IsAny())) + .ReturnsAsync(true); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateApplicationManager(instance => + { + var application = new OpenIddictApplication(); + + instance.Setup(mock => mock.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + instance.Setup(mock => mock.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(OpenIddictConstants.ClientTypes.Public); + })); + + builder.Services.AddSingleton(manager); + + builder.Configure(options => options.AuthorizationCodeFormat = format.Object); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(TokenEndpoint, new OpenIdConnectRequest + { + ClientId = "Fabrikam", + Code = "SplxlOBeZQQYbYS6WxSbIA", + GrantType = OpenIdConnectConstants.GrantTypes.AuthorizationCode, + RedirectUri = "http://www.fabrikam.com/path" + }); + + // Assert + Assert.Equal(OpenIdConnectConstants.Errors.InvalidGrant, response.Error); + Assert.Equal("The specified authorization code has already been redemeed.", response.ErrorDescription); + + Mock.Get(manager).Verify(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.IsRedeemedAsync(token, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task HandleTokenRequest_RevokesTokensWhenAuthorizationCodeIsAlreadyRedeemed() + { + // Arrange + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(), + new AuthenticationProperties(), + OpenIdConnectServerDefaults.AuthenticationScheme); + + ticket.SetPresenters("Fabrikam"); + ticket.SetTokenId("3E228451-1555-46F7-A471-951EFBA23A56"); + ticket.SetTokenUsage(OpenIdConnectConstants.TokenUsages.AuthorizationCode); + ticket.SetProperty(OpenIddictConstants.Properties.AuthorizationId, "18D15F73-BE2B-6867-DC01-B3C1E8AFDED0"); + + var format = new Mock>(); + + format.Setup(mock => mock.Unprotect("SplxlOBeZQQYbYS6WxSbIA")) + .Returns(ticket); + + var tokens = new[] + { + new OpenIddictToken(), + new OpenIddictToken(), + new OpenIddictToken() + }; + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny())) + .ReturnsAsync(tokens[0]); + + instance.Setup(mock => mock.IsRedeemedAsync(tokens[0], It.IsAny())) + .ReturnsAsync(true); + + instance.Setup(mock => mock.FindByAuthorizationIdAsync("18D15F73-BE2B-6867-DC01-B3C1E8AFDED0", It.IsAny())) + .ReturnsAsync(tokens); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateApplicationManager(instance => + { + var application = new OpenIddictApplication(); + + instance.Setup(mock => mock.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + instance.Setup(mock => mock.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(OpenIddictConstants.ClientTypes.Public); + })); + + builder.Services.AddSingleton(CreateAuthorizationManager(instance => + { + var authorization = new OpenIddictAuthorization(); + + instance.Setup(mock => mock.FindByIdAsync("18D15F73-BE2B-6867-DC01-B3C1E8AFDED0", It.IsAny())) + .ReturnsAsync(authorization); + })); + + builder.Services.AddSingleton(manager); + + builder.Configure(options => options.AuthorizationCodeFormat = format.Object); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(TokenEndpoint, new OpenIdConnectRequest + { + ClientId = "Fabrikam", + Code = "SplxlOBeZQQYbYS6WxSbIA", + GrantType = OpenIdConnectConstants.GrantTypes.AuthorizationCode, + RedirectUri = "http://www.fabrikam.com/path" + }); + + // Assert + Assert.Equal(OpenIdConnectConstants.Errors.InvalidGrant, response.Error); + Assert.Equal("The specified authorization code has already been redemeed.", response.ErrorDescription); + + Mock.Get(manager).Verify(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.IsRedeemedAsync(tokens[0], It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.RevokeAsync(tokens[0], It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.RevokeAsync(tokens[1], It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.RevokeAsync(tokens[2], It.IsAny()), Times.Once()); + } + + [Fact] + public async Task HandleTokenRequest_RequestIsRejectedWhenAuthorizationCodeIsInvalid() + { + // Arrange + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(), + new AuthenticationProperties(), + OpenIdConnectServerDefaults.AuthenticationScheme); + + ticket.SetPresenters("Fabrikam"); + ticket.SetTokenId("3E228451-1555-46F7-A471-951EFBA23A56"); + ticket.SetTokenUsage(OpenIdConnectConstants.TokenUsages.AuthorizationCode); + + var format = new Mock>(); + + format.Setup(mock => mock.Unprotect("SplxlOBeZQQYbYS6WxSbIA")) + .Returns(ticket); + + var token = new OpenIddictToken(); + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.IsRedeemedAsync(token, It.IsAny())) + .ReturnsAsync(false); + + instance.Setup(mock => mock.IsValidAsync(token, It.IsAny())) + .ReturnsAsync(false); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateApplicationManager(instance => + { + var application = new OpenIddictApplication(); + + instance.Setup(mock => mock.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + instance.Setup(mock => mock.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(OpenIddictConstants.ClientTypes.Public); + })); + + builder.Services.AddSingleton(manager); + + builder.Configure(options => options.AuthorizationCodeFormat = format.Object); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(TokenEndpoint, new OpenIdConnectRequest + { + ClientId = "Fabrikam", + Code = "SplxlOBeZQQYbYS6WxSbIA", + GrantType = OpenIdConnectConstants.GrantTypes.AuthorizationCode, + RedirectUri = "http://www.fabrikam.com/path" + }); + + // Assert + Assert.Equal(OpenIdConnectConstants.Errors.InvalidGrant, response.Error); + Assert.Equal("The specified authorization code is no longer valid.", response.ErrorDescription); + + Mock.Get(manager).Verify(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.IsRedeemedAsync(token, It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.IsValidAsync(token, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task HandleTokenRequest_RequestIsRejectedWhenRefreshTokenIsUnknown() { // Arrange var ticket = new AuthenticationTicket( @@ -569,13 +792,76 @@ namespace OpenIddict.Tests // Assert Assert.Equal(OpenIdConnectConstants.Errors.InvalidGrant, response.Error); - Assert.Equal("The refresh token is no longer valid.", response.ErrorDescription); + Assert.Equal("The specified refresh token is no longer valid.", response.ErrorDescription); Mock.Get(manager).Verify(mock => mock.FindByIdAsync("60FFF7EA-F98E-437B-937E-5073CC313103", It.IsAny()), Times.Once()); } [Fact] - public async Task HandleTokenRequest_AuthorizationCodeIsAutomaticallyRevoked() + public async Task HandleTokenRequest_RequestIsRejectedWhenRefreshTokenIsInvalid() + { + // Arrange + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(), + new AuthenticationProperties(), + OpenIdConnectServerDefaults.AuthenticationScheme); + + ticket.SetTokenId("60FFF7EA-F98E-437B-937E-5073CC313103"); + ticket.SetTokenUsage(OpenIdConnectConstants.TokenUsages.RefreshToken); + + var format = new Mock>(); + + format.Setup(mock => mock.Unprotect("8xLOxBtZp8")) + .Returns(ticket); + + var token = new OpenIddictToken(); + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.FindByIdAsync("60FFF7EA-F98E-437B-937E-5073CC313103", It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.IsValidAsync(token, It.IsAny())) + .ReturnsAsync(false); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateApplicationManager(instance => + { + var application = new OpenIddictApplication(); + + instance.Setup(mock => mock.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + instance.Setup(mock => mock.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(OpenIddictConstants.ClientTypes.Public); + })); + + builder.Services.AddSingleton(manager); + + builder.Configure(options => options.RefreshTokenFormat = format.Object); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(TokenEndpoint, new OpenIdConnectRequest + { + GrantType = OpenIdConnectConstants.GrantTypes.RefreshToken, + RefreshToken = "8xLOxBtZp8" + }); + + // Assert + Assert.Equal(OpenIdConnectConstants.Errors.InvalidGrant, response.Error); + Assert.Equal("The specified refresh token is no longer valid.", response.ErrorDescription); + + Mock.Get(manager).Verify(mock => mock.FindByIdAsync("60FFF7EA-F98E-437B-937E-5073CC313103", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.IsValidAsync(token, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task HandleTokenRequest_AuthorizationCodeIsAutomaticallyRedeemed() { // Arrange var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme); @@ -601,6 +887,9 @@ namespace OpenIddict.Tests { instance.Setup(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny())) .ReturnsAsync(token); + + instance.Setup(mock => mock.IsValidAsync(token, It.IsAny())) + .ReturnsAsync(true); }); var server = CreateAuthorizationServer(builder => @@ -634,11 +923,11 @@ namespace OpenIddict.Tests // Assert Mock.Get(manager).Verify(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); - Mock.Get(manager).Verify(mock => mock.RevokeAsync(token, It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.RedeemAsync(token, It.IsAny()), Times.Once()); } [Fact] - public async Task HandleTokenRequest_RefreshTokenIsAutomaticallyRevokedWhenSlidingExpirationIsEnabled() + public async Task HandleTokenRequest_RefreshTokenIsAutomaticallyRedeemedWhenSlidingExpirationIsEnabled() { // Arrange var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme); @@ -663,6 +952,12 @@ namespace OpenIddict.Tests { instance.Setup(mock => mock.FindByIdAsync("60FFF7EA-F98E-437B-937E-5073CC313103", It.IsAny())) .ReturnsAsync(token); + + instance.Setup(mock => mock.IsRedeemedAsync(token, It.IsAny())) + .ReturnsAsync(false); + + instance.Setup(mock => mock.IsValidAsync(token, It.IsAny())) + .ReturnsAsync(true); }); var server = CreateAuthorizationServer(builder => @@ -694,7 +989,7 @@ namespace OpenIddict.Tests // Assert Mock.Get(manager).Verify(mock => mock.FindByIdAsync("60FFF7EA-F98E-437B-937E-5073CC313103", It.IsAny()), Times.Once()); - Mock.Get(manager).Verify(mock => mock.RevokeAsync(token, It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.RedeemAsync(token, It.IsAny()), Times.Once()); } [Theory] @@ -739,6 +1034,9 @@ namespace OpenIddict.Tests { instance.Setup(mock => mock.FindByIdAsync("60FFF7EA-F98E-437B-937E-5073CC313103", It.IsAny())) .ReturnsAsync(token); + + instance.Setup(mock => mock.IsValidAsync(token, It.IsAny())) + .ReturnsAsync(true); }); var server = CreateAuthorizationServer(builder => diff --git a/test/OpenIddict.Tests/OpenIddictProviderTests.Introspection.cs b/test/OpenIddict.Tests/OpenIddictProviderTests.Introspection.cs index a5fbd4b0..fc8b4866 100644 --- a/test/OpenIddict.Tests/OpenIddictProviderTests.Introspection.cs +++ b/test/OpenIddict.Tests/OpenIddictProviderTests.Introspection.cs @@ -342,7 +342,145 @@ namespace OpenIddict.Tests } [Fact] - public async Task HandleIntrospectionRequest_RequestIsRejectedWhenAuthorizationCodeIsRevoked() + public async Task HandleIntrospectionRequest_RequestIsRejectedWhenAccessTokenIsUnknown() + { + // Arrange + var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme); + identity.AddClaim(OpenIdConnectConstants.Claims.Subject, "Bob le Bricoleur"); + + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(identity), + new AuthenticationProperties(), + OpenIdConnectServerDefaults.AuthenticationScheme); + + ticket.SetTokenId("3E228451-1555-46F7-A471-951EFBA23A56"); + ticket.SetTokenUsage(OpenIdConnectConstants.TokenUsages.AccessToken); + + var format = new Mock>(); + + format.Setup(mock => mock.Unprotect("2YotnFZFEjr1zCsicMWpAA")) + .Returns(ticket); + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny())) + .ReturnsAsync(value: null); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateApplicationManager(instance => + { + var application = new OpenIddictApplication(); + + instance.Setup(mock => mock.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + instance.Setup(mock => mock.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(OpenIddictConstants.ClientTypes.Confidential); + + instance.Setup(mock => mock.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny())) + .ReturnsAsync(true); + })); + + builder.Services.AddSingleton(manager); + + builder.Configure(options => options.AccessTokenFormat = format.Object); + + builder.UseReferenceTokens(); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(IntrospectionEndpoint, new OpenIdConnectRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + Token = "2YotnFZFEjr1zCsicMWpAA" + }); + + // Assert + Assert.Single(response.GetParameters()); + Assert.False((bool) response[OpenIdConnectConstants.Claims.Active]); + + Mock.Get(manager).Verify(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task HandleIntrospectionRequest_RequestIsRejectedWhenAccessTokenIsInvalid() + { + // Arrange + var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme); + identity.AddClaim(OpenIdConnectConstants.Claims.Subject, "Bob le Bricoleur"); + + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(identity), + new AuthenticationProperties(), + OpenIdConnectServerDefaults.AuthenticationScheme); + + ticket.SetTokenId("3E228451-1555-46F7-A471-951EFBA23A56"); + ticket.SetTokenUsage(OpenIdConnectConstants.TokenUsages.AccessToken); + + var format = new Mock>(); + + format.Setup(mock => mock.Unprotect("2YotnFZFEjr1zCsicMWpAA")) + .Returns(ticket); + + var token = new OpenIddictToken(); + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.IsValidAsync(token, It.IsAny())) + .ReturnsAsync(false); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateApplicationManager(instance => + { + var application = new OpenIddictApplication(); + + instance.Setup(mock => mock.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + instance.Setup(mock => mock.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(OpenIddictConstants.ClientTypes.Confidential); + + instance.Setup(mock => mock.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny())) + .ReturnsAsync(true); + })); + + builder.Services.AddSingleton(manager); + + builder.Configure(options => options.AccessTokenFormat = format.Object); + + builder.UseReferenceTokens(); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(IntrospectionEndpoint, new OpenIdConnectRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + Token = "2YotnFZFEjr1zCsicMWpAA" + }); + + // Assert + Assert.Single(response.GetParameters()); + Assert.False((bool) response[OpenIdConnectConstants.Claims.Active]); + + Mock.Get(manager).Verify(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.IsValidAsync(token, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task HandleIntrospectionRequest_RequestIsRejectedWhenAuthorizationCodeIsUnknown() { // Arrange var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme); @@ -406,7 +544,77 @@ namespace OpenIddict.Tests } [Fact] - public async Task HandleIntrospectionRequest_RequestIsRejectedWhenRefreshTokenIsRevoked() + public async Task HandleIntrospectionRequest_RequestIsRejectedWhenAuthorizationCodeIsInvalid() + { + // Arrange + var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme); + identity.AddClaim(OpenIdConnectConstants.Claims.Subject, "Bob le Bricoleur"); + + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(identity), + new AuthenticationProperties(), + OpenIdConnectServerDefaults.AuthenticationScheme); + + ticket.SetTokenId("3E228451-1555-46F7-A471-951EFBA23A56"); + ticket.SetTokenUsage(OpenIdConnectConstants.TokenUsages.AuthorizationCode); + + var format = new Mock>(); + + format.Setup(mock => mock.Unprotect("2YotnFZFEjr1zCsicMWpAA")) + .Returns(ticket); + + var token = new OpenIddictToken(); + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.IsValidAsync(token, It.IsAny())) + .ReturnsAsync(false); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateApplicationManager(instance => + { + var application = new OpenIddictApplication(); + + instance.Setup(mock => mock.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + instance.Setup(mock => mock.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(OpenIddictConstants.ClientTypes.Confidential); + + instance.Setup(mock => mock.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny())) + .ReturnsAsync(true); + })); + + builder.Services.AddSingleton(manager); + + builder.Configure(options => options.AuthorizationCodeFormat = format.Object); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(IntrospectionEndpoint, new OpenIdConnectRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + Token = "2YotnFZFEjr1zCsicMWpAA" + }); + + // Assert + Assert.Single(response.GetParameters()); + Assert.False((bool) response[OpenIdConnectConstants.Claims.Active]); + + Mock.Get(manager).Verify(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.IsValidAsync(token, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task HandleIntrospectionRequest_RequestIsRejectedWhenRefreshTokenIsUnknown() { // Arrange var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme); @@ -468,5 +676,75 @@ namespace OpenIddict.Tests Mock.Get(manager).Verify(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); } + + [Fact] + public async Task HandleIntrospectionRequest_RequestIsRejectedWhenRefreshTokenIsInvalid() + { + // Arrange + var identity = new ClaimsIdentity(OpenIdConnectServerDefaults.AuthenticationScheme); + identity.AddClaim(OpenIdConnectConstants.Claims.Subject, "Bob le Bricoleur"); + + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(identity), + new AuthenticationProperties(), + OpenIdConnectServerDefaults.AuthenticationScheme); + + ticket.SetTokenId("3E228451-1555-46F7-A471-951EFBA23A56"); + ticket.SetTokenUsage(OpenIdConnectConstants.TokenUsages.RefreshToken); + + var format = new Mock>(); + + format.Setup(mock => mock.Unprotect("2YotnFZFEjr1zCsicMWpAA")) + .Returns(ticket); + + var token = new OpenIddictToken(); + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.IsValidAsync(token, It.IsAny())) + .ReturnsAsync(false); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateApplicationManager(instance => + { + var application = new OpenIddictApplication(); + + instance.Setup(mock => mock.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + instance.Setup(mock => mock.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(OpenIddictConstants.ClientTypes.Confidential); + + instance.Setup(mock => mock.ValidateClientSecretAsync(application, "7Fjfp0ZBr1KtDRbnfVdmIw", It.IsAny())) + .ReturnsAsync(true); + })); + + builder.Services.AddSingleton(manager); + + builder.Configure(options => options.RefreshTokenFormat = format.Object); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(IntrospectionEndpoint, new OpenIdConnectRequest + { + ClientId = "Fabrikam", + ClientSecret = "7Fjfp0ZBr1KtDRbnfVdmIw", + Token = "2YotnFZFEjr1zCsicMWpAA" + }); + + // Assert + Assert.Single(response.GetParameters()); + Assert.False((bool) response[OpenIdConnectConstants.Claims.Active]); + + Mock.Get(manager).Verify(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.IsValidAsync(token, It.IsAny()), Times.Once()); + } } } diff --git a/test/OpenIddict.Tests/OpenIddictProviderTests.Revocation.cs b/test/OpenIddict.Tests/OpenIddictProviderTests.Revocation.cs index 944d8a1e..0f1cda33 100644 --- a/test/OpenIddict.Tests/OpenIddictProviderTests.Revocation.cs +++ b/test/OpenIddict.Tests/OpenIddictProviderTests.Revocation.cs @@ -1,6 +1,5 @@ using System; using System.IdentityModel.Tokens.Jwt; -using System.Linq; using System.Security.Claims; using System.Threading; using System.Threading.Tasks; @@ -22,10 +21,30 @@ namespace OpenIddict.Tests { public partial class OpenIddictProviderTests { - [Theory] - [InlineData(OpenIdConnectConstants.TokenTypeHints.AccessToken)] - [InlineData(OpenIdConnectConstants.TokenTypeHints.IdToken)] - public async Task ValidateRevocationRequest_UnknownTokenTokenHintIsRejected(string hint) + [Fact] + public async Task ValidateRevocationRequest_IdTokenTokenTokenHintIsRejected() + { + // Arrange + var server = CreateAuthorizationServer(); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(RevocationEndpoint, new OpenIdConnectRequest + { + Token = "SlAV32hkKG", + TokenTypeHint = OpenIdConnectConstants.TokenTypeHints.IdToken + }); + + // Assert + Assert.Equal(OpenIdConnectConstants.Errors.UnsupportedTokenType, response.Error); + Assert.Equal( + "Identity tokens cannot be revoked. When specifying a token_type_hint parameter, " + + "its value must be equal to 'access_token', 'authorization_code' or 'refresh_token'.", response.ErrorDescription); + } + + [Fact] + public async Task ValidateRevocationRequest_AccessTokenTokenTokenHintIsRejectedWhenReferenceTokensAreDisabled() { // Arrange var server = CreateAuthorizationServer(); @@ -36,13 +55,14 @@ namespace OpenIddict.Tests var response = await client.PostAsync(RevocationEndpoint, new OpenIdConnectRequest { Token = "SlAV32hkKG", - TokenTypeHint = hint + TokenTypeHint = OpenIdConnectConstants.TokenTypeHints.AccessToken }); // Assert Assert.Equal(OpenIdConnectConstants.Errors.UnsupportedTokenType, response.Error); - Assert.Equal("Only authorization codes and refresh tokens can be revoked. When specifying a token_type_hint " + - "parameter, its value must be equal to 'authorization_code' or 'refresh_token'.", response.ErrorDescription); + Assert.Equal( + "Access tokens cannot be revoked. When specifying a token_type_hint parameter, " + + "its value must be equal to 'authorization_code' or 'refresh_token'.", response.ErrorDescription); } [Fact] @@ -219,7 +239,7 @@ namespace OpenIddict.Tests } [Fact] - public async Task HandleRevocationRequest_RequestIsRejectedWhenTokenIsAnAccessToken() + public async Task HandleRevocationRequest_RequestIsRejectedWhenTokenIsAnAccessTokenIfReferenceTokensAreDisabled() { // Arrange var ticket = new AuthenticationTicket( @@ -250,13 +270,13 @@ namespace OpenIddict.Tests // Assert Assert.Equal(OpenIdConnectConstants.Errors.UnsupportedTokenType, response.Error); - Assert.Equal("Only authorization codes and refresh tokens can be revoked.", response.ErrorDescription); + Assert.Equal("The specified access token cannot be revoked.", response.ErrorDescription); format.Verify(mock => mock.Unprotect("SlAV32hkKG"), Times.Once()); } [Fact] - public async Task HandleRevocationRequest_RequestIsNotRejectedWhenTokenIsAnIdentityToken() + public async Task HandleRevocationRequest_RequestIsRejectedWhenTokenIsAnIdentityToken() { // Arrange var token = Mock.Of(mock => @@ -290,7 +310,7 @@ namespace OpenIddict.Tests // Assert Assert.Equal(OpenIdConnectConstants.Errors.UnsupportedTokenType, response.Error); - Assert.Equal("Only authorization codes and refresh tokens can be revoked.", response.ErrorDescription); + Assert.Equal("Identity tokens cannot be revoked.", response.ErrorDescription); handler.As() .Verify(mock => mock.CanReadToken("SlAV32hkKG"), Times.Once()); @@ -300,7 +320,7 @@ namespace OpenIddict.Tests } [Fact] - public async Task HandleRevocationRequest_TokenIsNotRevokedWhenItIsAlreadyInvalid() + public async Task HandleRevocationRequest_TokenIsNotRevokedWhenItIsUnknown() { // Arrange var ticket = new AuthenticationTicket( @@ -343,6 +363,55 @@ namespace OpenIddict.Tests Mock.Get(manager).Verify(mock => mock.RevokeAsync(It.IsAny(), It.IsAny()), Times.Never()); } + [Fact] + public async Task HandleRevocationRequest_TokenIsNotRevokedWhenItIsAlreadyRevoked() + { + // Arrange + var ticket = new AuthenticationTicket( + new ClaimsPrincipal(), + new AuthenticationProperties(), + OpenIdConnectServerDefaults.AuthenticationScheme); + + ticket.SetTokenId("3E228451-1555-46F7-A471-951EFBA23A56"); + + var format = new Mock>(); + + format.Setup(mock => mock.Unprotect("SlAV32hkKG")) + .Returns(ticket); + + var token = new OpenIddictToken(); + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.IsRevokedAsync(token, It.IsAny())) + .ReturnsAsync(true); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(manager); + + builder.Configure(options => options.RefreshTokenFormat = format.Object); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(RevocationEndpoint, new OpenIdConnectRequest + { + Token = "SlAV32hkKG" + }); + + // Assert + Assert.Empty(response.GetParameters()); + + Mock.Get(manager).Verify(mock => mock.FindByIdAsync("3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.RevokeAsync(It.IsAny(), It.IsAny()), Times.Never()); + } + [Fact] public async Task HandleRevocationRequest_TokenIsSuccessfullyRevoked() { diff --git a/test/OpenIddict.Tests/OpenIddictProviderTests.Serialization.cs b/test/OpenIddict.Tests/OpenIddictProviderTests.Serialization.cs index f4eb0ce0..6599cfa1 100644 --- a/test/OpenIddict.Tests/OpenIddictProviderTests.Serialization.cs +++ b/test/OpenIddict.Tests/OpenIddictProviderTests.Serialization.cs @@ -1,7 +1,11 @@ -using System.Threading; +using System; +using System.Collections; +using System.Collections.Generic; +using System.Threading; using System.Threading.Tasks; using AspNet.Security.OpenIdConnect.Client; using AspNet.Security.OpenIdConnect.Primitives; +using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.DependencyInjection; @@ -14,6 +18,209 @@ namespace OpenIddict.Tests { public partial class OpenIddictProviderTests { + [Fact] + public async Task SerializeAccessToken_AccessTokenIsNotPersistedWhenReferenceTokensAreDisabled() + { + // Arrange + var manager = CreateTokenManager(); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(manager); + + builder.Configure(options => options.RevocationEndpointPath = PathString.Empty); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(TokenEndpoint, new OpenIdConnectRequest + { + GrantType = OpenIdConnectConstants.GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w", + Scope = OpenIdConnectConstants.Scopes.OfflineAccess + }); + + // Assert + Assert.NotNull(response.AccessToken); + + Mock.Get(manager).Verify(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AccessToken, "Bob le Magnifique", + It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never()); + } + + [Fact] + public async Task SerializeAccessToken_ReferenceAccessTokenIsCorrectlyPersisted() + { + // Arrange + var token = new OpenIddictToken + { + End = new DateTimeOffset(2017, 01, 01, 00, 00, 00, TimeSpan.Zero), + Start = new DateTimeOffset(2017, 01, 02, 00, 00, 00, TimeSpan.Zero), + }; + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AccessToken, "Bob le Magnifique", + It.IsNotNull(), It.IsNotNull(), + token.Start, token.End, It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("3E228451-1555-46F7-A471-951EFBA23A56"); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(manager); + + builder.UseReferenceTokens(); + + builder.Configure(options => + { + options.SystemClock = Mock.Of(mock => mock.UtcNow == token.Start.Value); + options.AccessTokenLifetime = token.End.Value - token.Start.Value; + }); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(TokenEndpoint, new OpenIdConnectRequest + { + GrantType = OpenIdConnectConstants.GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w", + Scope = OpenIdConnectConstants.Scopes.OfflineAccess + }); + + // Assert + Assert.NotNull(response.AccessToken); + + Mock.Get(manager).Verify(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AccessToken, "Bob le Magnifique", + It.IsNotNull(), It.IsNotNull(), + token.Start, token.End, It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.GetIdAsync(token, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task SerializeAccessToken_ClientApplicationIsAutomaticallyAttached() + { + // Arrange + var token = new OpenIddictToken(); + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AccessToken, "Bob le Magnifique", + It.IsNotNull(), It.IsNotNull(), + It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("3E228451-1555-46F7-A471-951EFBA23A56"); + + instance.Setup(mock => mock.SetClientAsync(token, "3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny())) + .Returns(Task.FromResult(0)); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateApplicationManager(instance => + { + var application = new OpenIddictApplication(); + + instance.Setup(mock => mock.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + instance.Setup(mock => mock.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(OpenIddictConstants.ClientTypes.Public); + + instance.Setup(mock => mock.GetIdAsync(application, It.IsAny())) + .ReturnsAsync("3E228451-1555-46F7-A471-951EFBA23A56"); + })); + + builder.Services.AddSingleton(manager); + + builder.UseReferenceTokens(); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(TokenEndpoint, new OpenIdConnectRequest + { + ClientId = "Fabrikam", + GrantType = OpenIdConnectConstants.GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w", + Scope = OpenIdConnectConstants.Scopes.OfflineAccess + }); + + // Assert + Assert.NotNull(response.AccessToken); + + Mock.Get(manager).Verify(mock => mock.SetClientAsync(token, "3E228451-1555-46F7-A471-951EFBA23A56", It.IsAny()), Times.Once()); + } + + [Fact] + public async Task SerializeAccessToken_AuthorizationIsAutomaticallyAttached() + { + // Arrange + var token = new OpenIddictToken(); + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AccessToken, "Bob le Magnifique", + It.IsNotNull(), It.IsNotNull(), + It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("3E228451-1555-46F7-A471-951EFBA23A56"); + + instance.Setup(mock => mock.SetAuthorizationAsync(token, "1AF06AB2-A0FC-4E3D-86AF-E04DA8C7BE70", It.IsAny())) + .Returns(Task.FromResult(0)); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateAuthorizationManager(instance => + { + instance.Setup(mock => mock.FindByIdAsync("1AF06AB2-A0FC-4E3D-86AF-E04DA8C7BE70", It.IsAny())) + .ReturnsAsync(new OpenIddictAuthorization()); + })); + + builder.Services.AddSingleton(manager); + + builder.UseReferenceTokens(); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(TokenEndpoint, new OpenIdConnectRequest + { + GrantType = OpenIdConnectConstants.GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w", + Scope = OpenIdConnectConstants.Scopes.OfflineAccess, + ["attach-authorization"] = true + }); + + // Assert + Assert.NotNull(response.AccessToken); + + Mock.Get(manager).Verify(mock => mock.SetAuthorizationAsync(token, "1AF06AB2-A0FC-4E3D-86AF-E04DA8C7BE70", It.IsAny()), Times.Once()); + } + [Fact] public async Task SerializeAuthorizationCode_AuthorizationCodeIsNotPersistedWhenRevocationIsDisabled() { @@ -59,18 +266,27 @@ namespace OpenIddict.Tests // Assert Assert.NotNull(response.Code); - Mock.Get(manager).Verify(mock => mock.CreateAsync(OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", It.IsAny()), Times.Never()); + Mock.Get(manager).Verify(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", + It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never()); } [Fact] public async Task SerializeAuthorizationCode_AuthorizationCodeIsCorrectlyPersisted() { // Arrange - var token = new OpenIddictToken(); + var token = new OpenIddictToken + { + End = new DateTimeOffset(2017, 01, 02, 00, 00, 00, TimeSpan.Zero), + Start = new DateTimeOffset(2017, 01, 01, 00, 00, 00, TimeSpan.Zero), + }; var manager = CreateTokenManager(instance => { - instance.Setup(mock => mock.CreateAsync(OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", It.IsAny())) + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", + token.Start, token.End, It.IsAny())) .ReturnsAsync(token); instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) @@ -97,6 +313,12 @@ namespace OpenIddict.Tests })); builder.Services.AddSingleton(manager); + + builder.Configure(options => + { + options.SystemClock = Mock.Of(mock => mock.UtcNow == token.Start.Value); + options.AuthorizationCodeLifetime = token.End.Value - token.Start.Value; + }); }); var client = new OpenIdConnectClient(server.CreateClient()); @@ -112,7 +334,81 @@ namespace OpenIddict.Tests // Assert Assert.NotNull(response.Code); - Mock.Get(manager).Verify(mock => mock.CreateAsync(OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", + token.Start, token.End, It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.GetIdAsync(token, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task SerializeAuthorizationCode_ReferenceAuthorizationCodeIsCorrectlyPersisted() + { + // Arrange + var token = new OpenIddictToken + { + End = new DateTimeOffset(2017, 01, 02, 00, 00, 00, TimeSpan.Zero), + Start = new DateTimeOffset(2017, 01, 01, 00, 00, 00, TimeSpan.Zero), + }; + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", + It.IsNotNull(), It.IsNotNull(), + token.Start, token.End, It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("3E228451-1555-46F7-A471-951EFBA23A56"); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateApplicationManager(instance => + { + var application = new OpenIddictApplication(); + + instance.Setup(mock => mock.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + instance.Setup(mock => mock.HasRedirectUriAsync(application, It.IsAny())) + .ReturnsAsync(true); + + instance.Setup(mock => mock.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + instance.Setup(mock => mock.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(OpenIddictConstants.ClientTypes.Public); + })); + + builder.Services.AddSingleton(manager); + + builder.UseReferenceTokens(); + + builder.Configure(options => + { + options.SystemClock = Mock.Of(mock => mock.UtcNow == token.Start.Value); + options.AuthorizationCodeLifetime = token.End.Value - token.Start.Value; + }); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(AuthorizationEndpoint, new OpenIdConnectRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = OpenIdConnectConstants.ResponseTypes.Code + }); + + // Assert + Assert.NotNull(response.Code); + + Mock.Get(manager).Verify(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", + It.IsNotNull(), It.IsNotNull(), + token.Start, token.End, It.IsAny()), Times.Once()); Mock.Get(manager).Verify(mock => mock.GetIdAsync(token, It.IsAny()), Times.Once()); } @@ -124,7 +420,10 @@ namespace OpenIddict.Tests var manager = CreateTokenManager(instance => { - instance.Setup(mock => mock.CreateAsync(OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", It.IsAny())) + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", + It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(token); instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) @@ -183,7 +482,10 @@ namespace OpenIddict.Tests var manager = CreateTokenManager(instance => { - instance.Setup(mock => mock.CreateAsync(OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", It.IsAny())) + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", + It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(token); instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) @@ -235,6 +537,7 @@ namespace OpenIddict.Tests ClientId = "Fabrikam", RedirectUri = "http://www.fabrikam.com/path", ResponseType = OpenIdConnectConstants.ResponseTypes.Code, + ["attach-authorization"] = true }); // Assert @@ -243,6 +546,72 @@ namespace OpenIddict.Tests Mock.Get(manager).Verify(mock => mock.SetAuthorizationAsync(token, "1AF06AB2-A0FC-4E3D-86AF-E04DA8C7BE70", It.IsAny()), Times.Once()); } + [Fact] + public async Task SerializeAuthorizationCode_AdHocAuthorizationIsAutomaticallyCreated() + { + // Arrange + var token = new OpenIddictToken(); + + var manager = CreateAuthorizationManager(instance => + { + instance.Setup(mock => mock.FindByIdAsync("1AF06AB2-A0FC-4E3D-86AF-E04DA8C7BE70", It.IsAny())) + .ReturnsAsync(new OpenIddictAuthorization()); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(CreateApplicationManager(instance => + { + var application = new OpenIddictApplication(); + + instance.Setup(mock => mock.FindByClientIdAsync("Fabrikam", It.IsAny())) + .ReturnsAsync(application); + + instance.Setup(mock => mock.HasRedirectUriAsync(application, It.IsAny())) + .ReturnsAsync(true); + + instance.Setup(mock => mock.ValidateRedirectUriAsync(application, "http://www.fabrikam.com/path", It.IsAny())) + .ReturnsAsync(true); + + instance.Setup(mock => mock.GetClientTypeAsync(application, It.IsAny())) + .ReturnsAsync(OpenIddictConstants.ClientTypes.Public); + + instance.Setup(mock => mock.GetIdAsync(application, It.IsAny())) + .ReturnsAsync("3E228451-1555-46F7-A471-951EFBA23A56"); + })); + + builder.Services.AddSingleton(CreateTokenManager(instance => + { + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.AuthorizationCode, "Bob le Magnifique", + It.IsAny(), It.IsAny(), + It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("3E228451-1555-46F7-A471-951EFBA23A56"); + })); + + builder.Services.AddSingleton(manager); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(AuthorizationEndpoint, new OpenIdConnectRequest + { + ClientId = "Fabrikam", + RedirectUri = "http://www.fabrikam.com/path", + ResponseType = OpenIdConnectConstants.ResponseTypes.Code, + }); + + // Assert + Assert.NotNull(response.Code); + + Mock.Get(manager).Verify(mock => mock.CreateAsync("Bob le Magnifique", "3E228451-1555-46F7-A471-951EFBA23A56", + It.IsAny>(), It.IsAny()), Times.Once()); + } + [Fact] public async Task SerializeRefreshToken_RefreshTokenIsNotPersistedWhenRevocationIsDisabled() { @@ -272,18 +641,80 @@ namespace OpenIddict.Tests // Assert Assert.NotNull(response.RefreshToken); - Mock.Get(manager).Verify(mock => mock.CreateAsync(OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", It.IsAny()), Times.Never()); + Mock.Get(manager).Verify(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", + It.IsAny(), It.IsAny(), + It.IsAny()), Times.Never()); } [Fact] public async Task SerializeRefreshToken_RefreshTokenIsCorrectlyPersisted() { // Arrange - var token = new OpenIddictToken(); + var token = new OpenIddictToken + { + End = new DateTimeOffset(2017, 01, 02, 00, 00, 00, TimeSpan.Zero), + Start = new DateTimeOffset(2017, 01, 01, 00, 00, 00, TimeSpan.Zero) + }; + + var manager = CreateTokenManager(instance => + { + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", + token.Start, token.End, It.IsAny())) + .ReturnsAsync(token); + + instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) + .ReturnsAsync("3E228451-1555-46F7-A471-951EFBA23A56"); + }); + + var server = CreateAuthorizationServer(builder => + { + builder.Services.AddSingleton(manager); + + builder.Configure(options => + { + options.SystemClock = Mock.Of(mock => mock.UtcNow == token.Start.Value); + options.RefreshTokenLifetime = token.End.Value - token.Start.Value; + }); + }); + + var client = new OpenIdConnectClient(server.CreateClient()); + + // Act + var response = await client.PostAsync(TokenEndpoint, new OpenIdConnectRequest + { + GrantType = OpenIdConnectConstants.GrantTypes.Password, + Username = "johndoe", + Password = "A3ddj3w", + Scope = OpenIdConnectConstants.Scopes.OfflineAccess + }); + + // Assert + Assert.NotNull(response.RefreshToken); + + Mock.Get(manager).Verify(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", + token.Start, token.End, It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.GetIdAsync(token, It.IsAny()), Times.Once()); + } + + [Fact] + public async Task SerializeRefreshToken_ReferenceRefreshTokenIsCorrectlyPersisted() + { + // Arrange + var token = new OpenIddictToken + { + End = new DateTimeOffset(2017, 01, 02, 00, 00, 00, TimeSpan.Zero), + Start = new DateTimeOffset(2017, 01, 01, 00, 00, 00, TimeSpan.Zero), + }; var manager = CreateTokenManager(instance => { - instance.Setup(mock => mock.CreateAsync(OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", It.IsAny())) + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", + It.IsNotNull(), It.IsNotNull(), + token.Start, token.End, It.IsAny())) .ReturnsAsync(token); instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) @@ -293,6 +724,14 @@ namespace OpenIddict.Tests var server = CreateAuthorizationServer(builder => { builder.Services.AddSingleton(manager); + + builder.UseReferenceTokens(); + + builder.Configure(options => + { + options.SystemClock = Mock.Of(mock => mock.UtcNow == token.Start.Value); + options.RefreshTokenLifetime = token.End.Value - token.Start.Value; + }); }); var client = new OpenIdConnectClient(server.CreateClient()); @@ -309,7 +748,10 @@ namespace OpenIddict.Tests // Assert Assert.NotNull(response.RefreshToken); - Mock.Get(manager).Verify(mock => mock.CreateAsync(OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", It.IsAny()), Times.Once()); + Mock.Get(manager).Verify(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", + It.IsNotNull(), It.IsNotNull(), + token.Start, token.End, It.IsAny()), Times.Once()); Mock.Get(manager).Verify(mock => mock.GetIdAsync(token, It.IsAny()), Times.Once()); } @@ -321,7 +763,10 @@ namespace OpenIddict.Tests var manager = CreateTokenManager(instance => { - instance.Setup(mock => mock.CreateAsync(OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", It.IsAny())) + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", + It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(token); instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) @@ -376,7 +821,10 @@ namespace OpenIddict.Tests var manager = CreateTokenManager(instance => { - instance.Setup(mock => mock.CreateAsync(OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", It.IsAny())) + instance.Setup(mock => mock.CreateAsync( + OpenIdConnectConstants.TokenTypeHints.RefreshToken, "Bob le Magnifique", + It.IsAny(), It.IsAny(), + It.IsAny())) .ReturnsAsync(token); instance.Setup(mock => mock.GetIdAsync(token, It.IsAny())) @@ -405,7 +853,8 @@ namespace OpenIddict.Tests GrantType = OpenIdConnectConstants.GrantTypes.Password, Username = "johndoe", Password = "A3ddj3w", - Scope = OpenIdConnectConstants.Scopes.OfflineAccess + Scope = OpenIdConnectConstants.Scopes.OfflineAccess, + ["attach-authorization"] = true }); // Assert diff --git a/test/OpenIddict.Tests/OpenIddictProviderTests.cs b/test/OpenIddict.Tests/OpenIddictProviderTests.cs index eb93fafc..619b112d 100644 --- a/test/OpenIddict.Tests/OpenIddictProviderTests.cs +++ b/test/OpenIddict.Tests/OpenIddictProviderTests.cs @@ -47,43 +47,46 @@ namespace OpenIddict.Tests services.AddAuthentication(); services.AddOptions(); - var instance = services.AddOpenIddict() + // Replace the default OpenIddict managers. + services.AddSingleton(CreateApplicationManager()); + services.AddSingleton(CreateAuthorizationManager()); + services.AddSingleton(CreateTokenManager()); + + services.AddOpenIddict(options => + { // Disable the transport security requirement during testing. - .DisableHttpsRequirement() + options.DisableHttpsRequirement(); // Enable the tested endpoints. - .EnableAuthorizationEndpoint(AuthorizationEndpoint) - .EnableIntrospectionEndpoint(IntrospectionEndpoint) - .EnableLogoutEndpoint(LogoutEndpoint) - .EnableRevocationEndpoint(RevocationEndpoint) - .EnableTokenEndpoint(TokenEndpoint) - .EnableUserinfoEndpoint(UserinfoEndpoint) + options.EnableAuthorizationEndpoint(AuthorizationEndpoint) + .EnableIntrospectionEndpoint(IntrospectionEndpoint) + .EnableLogoutEndpoint(LogoutEndpoint) + .EnableRevocationEndpoint(RevocationEndpoint) + .EnableTokenEndpoint(TokenEndpoint) + .EnableUserinfoEndpoint(UserinfoEndpoint); // Enable the tested flows. - .AllowAuthorizationCodeFlow() - .AllowClientCredentialsFlow() - .AllowImplicitFlow() - .AllowPasswordFlow() - .AllowRefreshTokenFlow() + options.AllowAuthorizationCodeFlow() + .AllowClientCredentialsFlow() + .AllowImplicitFlow() + .AllowPasswordFlow() + .AllowRefreshTokenFlow(); // Register the X.509 certificate used to sign the identity tokens. - .AddSigningCertificate( + options.AddSigningCertificate( assembly: typeof(OpenIddictProviderTests).GetTypeInfo().Assembly, resource: "OpenIddict.Tests.Certificate.pfx", - password: "OpenIddict") + password: "OpenIddict"); // Note: overriding the default data protection provider is not necessary for the tests to pass, // but is useful to ensure unnecessary keys are not persisted in testing environments, which also // helps make the unit tests run faster, as no registry or disk access is required in this case. - .UseDataProtectionProvider(new EphemeralDataProtectionProvider()); + options.UseDataProtectionProvider(new EphemeralDataProtectionProvider()); - // Replace the default application/token managers. - services.AddSingleton(CreateApplicationManager()); - services.AddSingleton(CreateTokenManager()); - - // Run the configuration delegate - // registered by the unit tests. - configuration?.Invoke(instance); + // Run the configuration delegate + // registered by the unit tests. + configuration?.Invoke(options); + }); }); builder.Configure(app => @@ -145,7 +148,10 @@ namespace OpenIddict.Tests ticket.SetScopes(request.GetScopes()); - ticket.SetProperty(OpenIddictConstants.Properties.AuthorizationId, "1AF06AB2-A0FC-4E3D-86AF-E04DA8C7BE70"); + if (request.HasParameter("attach-authorization")) + { + ticket.SetProperty(OpenIddictConstants.Properties.AuthorizationId, "1AF06AB2-A0FC-4E3D-86AF-E04DA8C7BE70"); + } return context.Authentication.SignInAsync(ticket.AuthenticationScheme, ticket.Principal, ticket.Properties); }