/*
* Licensed under the Apache License, Version 2.0 (http://www.apache.org/licenses/LICENSE-2.0)
* See https://github.com/openiddict/openiddict-core for more information concerning
* the license and the contributors participating to this project.
*/
using System;
using System.Collections.Immutable;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using CryptoHelper;
using JetBrains.Annotations;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using OpenIddict.Abstractions;
namespace OpenIddict.Core
{
///
/// Provides methods allowing to manage the applications stored in the store.
///
/// The type of the Application entity.
public class OpenIddictApplicationManager : IOpenIddictApplicationManager where TApplication : class
{
public OpenIddictApplicationManager(
[NotNull] IOpenIddictApplicationStoreResolver resolver,
[NotNull] ILogger> logger,
[NotNull] IOptions options)
{
Store = resolver.Get();
Logger = logger;
Options = options;
}
///
/// Gets the logger associated with the current manager.
///
protected ILogger Logger { get; }
///
/// Gets the options associated with the current manager.
///
protected IOptions Options { get; }
///
/// Gets the store associated with the current manager.
///
protected IOpenIddictApplicationStore Store { get; }
///
/// Determines the number of applications that exist in the database.
///
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the number of applications in the database.
///
public virtual Task CountAsync(CancellationToken cancellationToken = default)
=> Store.CountAsync(cancellationToken);
///
/// Determines the number of applications that match the specified query.
///
/// The result type.
/// The query to execute.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the number of applications that match the specified query.
///
public virtual Task CountAsync(
[NotNull] Func, IQueryable> query, CancellationToken cancellationToken = default)
{
if (query == null)
{
throw new ArgumentNullException(nameof(query));
}
return Store.CountAsync(query, cancellationToken);
}
///
/// Creates a new application.
///
/// The application to create.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation.
///
public virtual Task CreateAsync([NotNull] TApplication application, CancellationToken cancellationToken = default)
=> CreateAsync(application, /* secret: */ null, cancellationToken);
///
/// Creates a new application.
/// Note: the default implementation automatically hashes the client
/// secret before storing it in the database, for security reasons.
///
/// The application to create.
/// The client secret associated with the application, if applicable.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation.
///
public virtual async Task CreateAsync(
[NotNull] TApplication application,
[CanBeNull] string secret, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
if (!string.IsNullOrEmpty(await Store.GetClientSecretAsync(application, cancellationToken)))
{
throw new ArgumentException("The client secret hash cannot be directly set on the application entity.");
}
// If no client type was specified, assume it's a public application if no secret was provided.
var type = await Store.GetClientTypeAsync(application, cancellationToken);
if (string.IsNullOrEmpty(type))
{
await Store.SetClientTypeAsync(application, string.IsNullOrEmpty(secret) ?
OpenIddictConstants.ClientTypes.Public :
OpenIddictConstants.ClientTypes.Confidential, cancellationToken);
}
// If the client is not a public application, throw an
// exception as the client secret is required in this case.
if (string.IsNullOrEmpty(secret) && !await IsPublicAsync(application, cancellationToken))
{
throw new InvalidOperationException("A client secret must be provided when creating " +
"a confidential or hybrid application.");
}
// If a client secret was provided, obfuscate it.
if (!string.IsNullOrEmpty(secret))
{
secret = await ObfuscateClientSecretAsync(secret, cancellationToken);
await Store.SetClientSecretAsync(application, secret, cancellationToken);
}
var results = await ValidateAsync(application, cancellationToken);
if (results.Any(result => result != ValidationResult.Success))
{
throw new ValidationException(results.FirstOrDefault(result => result != ValidationResult.Success), null, application);
}
await Store.CreateAsync(application, cancellationToken);
}
///
/// Creates a new application based on the specified descriptor.
/// Note: the default implementation automatically hashes the client
/// secret before storing it in the database, for security reasons.
///
/// The application descriptor.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the unique identifier associated with the application.
///
public virtual async Task CreateAsync(
[NotNull] OpenIddictApplicationDescriptor descriptor, CancellationToken cancellationToken = default)
{
if (descriptor == null)
{
throw new ArgumentNullException(nameof(descriptor));
}
var application = await Store.InstantiateAsync(cancellationToken);
if (application == null)
{
throw new InvalidOperationException("An error occurred while trying to create a new application");
}
await PopulateAsync(application, descriptor, cancellationToken);
var secret = await Store.GetClientSecretAsync(application, cancellationToken);
if (!string.IsNullOrEmpty(secret))
{
await Store.SetClientSecretAsync(application, /* secret: */ null, cancellationToken);
await CreateAsync(application, secret, cancellationToken);
}
else
{
await CreateAsync(application, cancellationToken);
}
return application;
}
///
/// Removes an existing application.
///
/// The application to delete.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation.
///
public virtual Task DeleteAsync([NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
return Store.DeleteAsync(application, cancellationToken);
}
///
/// Retrieves an application using its unique identifier.
///
/// The unique identifier associated with the application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the client application corresponding to the identifier.
///
public virtual Task FindByIdAsync([NotNull] string identifier, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(identifier))
{
throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier));
}
return Store.FindByIdAsync(identifier, cancellationToken);
}
///
/// Retrieves an application using its client identifier.
///
/// The client identifier associated with the application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the client application corresponding to the identifier.
///
public virtual async Task FindByClientIdAsync([NotNull] string identifier, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(identifier))
{
throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier));
}
// SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default.
// To ensure a case-sensitive comparison is enforced independently of the database/table/query collation
// used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here.
var application = await Store.FindByClientIdAsync(identifier, cancellationToken);
if (application == null ||
!string.Equals(await Store.GetClientIdAsync(application, cancellationToken), identifier, StringComparison.Ordinal))
{
return null;
}
return application;
}
///
/// Retrieves all the applications associated with the specified post_logout_redirect_uri.
///
/// The post_logout_redirect_uri associated with the applications.
/// The that can be used to abort the operation.
///
/// 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 async Task> FindByPostLogoutRedirectUriAsync(
[NotNull] string address, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(address))
{
throw new ArgumentException("The address cannot be null or empty.", nameof(address));
}
// SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default.
// To ensure a case-sensitive comparison is enforced independently of the database/table/query collation
// used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here.
var applications = await Store.FindByPostLogoutRedirectUriAsync(address, cancellationToken);
if (applications.IsEmpty)
{
return ImmutableArray.Create();
}
var builder = ImmutableArray.CreateBuilder(applications.Length);
foreach (var application in applications)
{
foreach (var uri in await Store.GetPostLogoutRedirectUrisAsync(application, cancellationToken))
{
// Note: the post_logout_redirect_uri must be compared using case-sensitive "Simple String Comparison".
if (string.Equals(uri, address, StringComparison.Ordinal))
{
builder.Add(application);
}
}
}
return builder.Count == builder.Capacity ?
builder.MoveToImmutable() :
builder.ToImmutable();
}
///
/// Retrieves all the applications associated with the specified redirect_uri.
///
/// The redirect_uri associated with the applications.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation, whose result
/// returns the client applications corresponding to the specified redirect_uri.
///
public virtual async Task> FindByRedirectUriAsync(
[NotNull] string address, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(address))
{
throw new ArgumentException("The address cannot be null or empty.", nameof(address));
}
// SQL engines like Microsoft SQL Server or MySQL are known to use case-insensitive lookups by default.
// To ensure a case-sensitive comparison is enforced independently of the database/table/query collation
// used by the store, a second pass using string.Equals(StringComparison.Ordinal) is manually made here.
var applications = await Store.FindByRedirectUriAsync(address, cancellationToken);
if (applications.IsEmpty)
{
return ImmutableArray.Create();
}
var builder = ImmutableArray.CreateBuilder(applications.Length);
foreach (var application in applications)
{
foreach (var uri in await Store.GetRedirectUrisAsync(application, cancellationToken))
{
// Note: the post_logout_redirect_uri must be compared using case-sensitive "Simple String Comparison".
if (string.Equals(uri, address, StringComparison.Ordinal))
{
builder.Add(application);
}
}
}
return builder.Count == builder.Capacity ?
builder.MoveToImmutable() :
builder.ToImmutable();
}
///
/// Executes the specified query and returns the first element.
///
/// The result type.
/// The query to execute.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the first element returned when executing the query.
///
public virtual Task GetAsync(
[NotNull] Func, IQueryable> query, CancellationToken cancellationToken = default)
{
return GetAsync((applications, state) => state(applications), query, cancellationToken);
}
///
/// Executes the specified query and returns the first element.
///
/// The state type.
/// The result type.
/// The query to execute.
/// The optional state.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the first element returned when executing the query.
///
public virtual Task GetAsync(
[NotNull] Func, TState, IQueryable> query,
[CanBeNull] TState state, CancellationToken cancellationToken = default)
{
if (query == null)
{
throw new ArgumentNullException(nameof(query));
}
return Store.GetAsync(query, state, cancellationToken);
}
///
/// Retrieves the client identifier associated with an application.
///
/// The application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the client identifier associated with the application.
///
public virtual ValueTask GetClientIdAsync([NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
return Store.GetClientIdAsync(application, cancellationToken);
}
///
/// Retrieves the client type associated with an application.
///
/// The application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the client type of the application (by default, "public").
///
public virtual ValueTask GetClientTypeAsync(
[NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
async Task ResolveClientTypeAsync()
{
var type = await Store.GetClientTypeAsync(application, cancellationToken);
// Ensure the application type returned by the store is supported by the manager.
if (!string.Equals(type, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(type, OpenIddictConstants.ClientTypes.Hybrid, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(type, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase))
{
throw new InvalidOperationException("Only 'confidential', 'hybrid' or 'public' applications are " +
"supported by the default application manager.");
}
return type;
}
return new ValueTask(ResolveClientTypeAsync());
}
///
/// Retrieves the consent type associated with an application.
///
/// The application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the consent type of the application (by default, "explicit").
///
public virtual ValueTask GetConsentTypeAsync([NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
async Task ResolveConsentTypeAsync()
{
var type = await Store.GetConsentTypeAsync(application, cancellationToken);
if (string.IsNullOrEmpty(type))
{
return OpenIddictConstants.ConsentTypes.Explicit;
}
return type;
}
return new ValueTask(ResolveConsentTypeAsync());
}
///
/// Retrieves the display name associated with an application.
///
/// The application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the display name associated with the application.
///
public virtual ValueTask GetDisplayNameAsync([NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
return Store.GetDisplayNameAsync(application, cancellationToken);
}
///
/// Retrieves the unique identifier associated with an application.
///
/// The application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the unique identifier associated with the application.
///
public virtual ValueTask GetIdAsync([NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
return Store.GetIdAsync(application, cancellationToken);
}
///
/// Retrieves the permissions associated with an application.
///
/// The application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns all the permissions associated with the application.
///
public virtual ValueTask> GetPermissionsAsync(
[NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
return Store.GetPermissionsAsync(application, cancellationToken);
}
///
/// Retrieves the logout callback addresses associated with an application.
///
/// The application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns all the post_logout_redirect_uri associated with the application.
///
public virtual ValueTask> GetPostLogoutRedirectUrisAsync(
[NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
return Store.GetPostLogoutRedirectUrisAsync(application, cancellationToken);
}
///
/// Retrieves the callback addresses associated with an application.
///
/// The application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns all the redirect_uri associated with the application.
///
public virtual ValueTask> GetRedirectUrisAsync(
[NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
return Store.GetRedirectUrisAsync(application, cancellationToken);
}
///
/// Determines whether the specified permission has been granted to the application.
///
/// The application.
/// The permission.
/// The that can be used to abort the operation.
/// true if the application has been granted the specified permission, false otherwise.
public virtual async Task HasPermissionAsync(
[NotNull] TApplication application, [NotNull] string permission, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
if (string.IsNullOrEmpty(permission))
{
throw new ArgumentException("The permission name cannot be null or empty.", nameof(permission));
}
return (await GetPermissionsAsync(application, cancellationToken)).Contains(permission);
}
///
/// Determines whether an application is a confidential client.
///
/// The application.
/// The that can be used to abort the operation.
/// true if the application is a confidential client, false otherwise.
public async Task IsConfidentialAsync([NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
var type = await GetClientTypeAsync(application, cancellationToken);
if (string.IsNullOrEmpty(type))
{
return false;
}
return string.Equals(type, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase);
}
///
/// Determines whether an application is a hybrid client.
///
/// The application.
/// The that can be used to abort the operation.
/// true if the application is a hybrid client, false otherwise.
public async Task IsHybridAsync([NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
var type = await GetClientTypeAsync(application, cancellationToken);
if (string.IsNullOrEmpty(type))
{
return false;
}
return string.Equals(type, OpenIddictConstants.ClientTypes.Hybrid, StringComparison.OrdinalIgnoreCase);
}
///
/// Determines whether an application is a public client.
///
/// The application.
/// The that can be used to abort the operation.
/// true if the application is a public client, false otherwise.
public async Task IsPublicAsync([NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
// Assume client applications are public if their type is not explicitly set.
var type = await GetClientTypeAsync(application, cancellationToken);
if (string.IsNullOrEmpty(type))
{
return true;
}
return string.Equals(type, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase);
}
///
/// Executes the specified query and returns all the corresponding elements.
///
/// The number of results to return.
/// The number of results to skip.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns all the elements returned when executing the specified query.
///
public virtual Task> ListAsync(
[CanBeNull] int? count = null, [CanBeNull] int? offset = null, CancellationToken cancellationToken = default)
{
return Store.ListAsync(count, offset, cancellationToken);
}
///
/// Executes the specified query and returns all the corresponding elements.
///
/// The result type.
/// The query to execute.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns all the elements returned when executing the specified query.
///
public virtual Task> ListAsync(
[NotNull] Func, IQueryable> query, CancellationToken cancellationToken = default)
{
return ListAsync((applications, state) => state(applications), query, cancellationToken);
}
///
/// Executes the specified query and returns all the corresponding elements.
///
/// The state type.
/// The result type.
/// The query to execute.
/// The optional state.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns all the elements returned when executing the specified query.
///
public virtual Task> ListAsync(
[NotNull] Func, TState, IQueryable> query,
[CanBeNull] TState state, CancellationToken cancellationToken = default)
{
if (query == null)
{
throw new ArgumentNullException(nameof(query));
}
return Store.ListAsync(query, state, cancellationToken);
}
///
/// Populates the application using the specified descriptor.
///
/// The application.
/// The descriptor.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation.
///
public virtual async Task PopulateAsync([NotNull] TApplication application,
[NotNull] OpenIddictApplicationDescriptor descriptor, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
if (descriptor == null)
{
throw new ArgumentNullException(nameof(descriptor));
}
await Store.SetClientIdAsync(application, descriptor.ClientId, cancellationToken);
await Store.SetClientSecretAsync(application, descriptor.ClientSecret, cancellationToken);
await Store.SetClientTypeAsync(application, descriptor.Type, cancellationToken);
await Store.SetConsentTypeAsync(application, descriptor.ConsentType, cancellationToken);
await Store.SetDisplayNameAsync(application, descriptor.DisplayName, cancellationToken);
await Store.SetPermissionsAsync(application, ImmutableArray.CreateRange(descriptor.Permissions), cancellationToken);
await Store.SetPostLogoutRedirectUrisAsync(application, ImmutableArray.CreateRange(
descriptor.PostLogoutRedirectUris.Select(address => address.OriginalString)), cancellationToken);
await Store.SetRedirectUrisAsync(application, ImmutableArray.CreateRange(
descriptor.RedirectUris.Select(address => address.OriginalString)), cancellationToken);
}
///
/// Populates the specified descriptor using the properties exposed by the application.
///
/// The descriptor.
/// The application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation.
///
public virtual async Task PopulateAsync(
[NotNull] OpenIddictApplicationDescriptor descriptor,
[NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (descriptor == null)
{
throw new ArgumentNullException(nameof(descriptor));
}
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
descriptor.ClientId = await Store.GetClientIdAsync(application, cancellationToken);
descriptor.ClientSecret = await Store.GetClientSecretAsync(application, cancellationToken);
descriptor.ConsentType = await Store.GetConsentTypeAsync(application, cancellationToken);
descriptor.DisplayName = await Store.GetDisplayNameAsync(application, cancellationToken);
descriptor.Type = await Store.GetClientTypeAsync(application, cancellationToken);
descriptor.Permissions.Clear();
descriptor.Permissions.UnionWith(await Store.GetPermissionsAsync(application, cancellationToken));
descriptor.PostLogoutRedirectUris.Clear();
descriptor.RedirectUris.Clear();
foreach (var address in await Store.GetPostLogoutRedirectUrisAsync(application, cancellationToken))
{
// Ensure the address is not null or empty.
if (string.IsNullOrEmpty(address))
{
throw new ArgumentException("Callback URLs cannot be null or empty.");
}
// Ensure the address is a valid absolute URL.
if (!Uri.TryCreate(address, UriKind.Absolute, out Uri uri) || !uri.IsWellFormedOriginalString())
{
throw new ArgumentException("Callback URLs must be valid absolute URLs.");
}
descriptor.PostLogoutRedirectUris.Add(uri);
}
foreach (var address in await Store.GetRedirectUrisAsync(application, cancellationToken))
{
// Ensure the address is not null or empty.
if (string.IsNullOrEmpty(address))
{
throw new ArgumentException("Callback URLs cannot be null or empty.");
}
// Ensure the address is a valid absolute URL.
if (!Uri.TryCreate(address, UriKind.Absolute, out Uri uri) || !uri.IsWellFormedOriginalString())
{
throw new ArgumentException("Callback URLs must be valid absolute URLs.");
}
descriptor.RedirectUris.Add(uri);
}
}
///
/// Updates an existing application.
///
/// The application 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] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
var results = await ValidateAsync(application, cancellationToken);
if (results.Any(result => result != ValidationResult.Success))
{
throw new ValidationException(results.FirstOrDefault(result => result != ValidationResult.Success), null, application);
}
await Store.UpdateAsync(application, cancellationToken);
}
///
/// Updates an existing application and replaces the existing secret.
/// Note: the default implementation automatically hashes the client
/// secret before storing it in the database, for security reasons.
///
/// The application to update.
/// The client secret associated with the application.
/// 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] TApplication application,
[CanBeNull] string secret, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
if (string.IsNullOrEmpty(secret))
{
await Store.SetClientSecretAsync(application, null, cancellationToken);
}
else
{
secret = await ObfuscateClientSecretAsync(secret, cancellationToken);
await Store.SetClientSecretAsync(application, secret, cancellationToken);
}
var results = await ValidateAsync(application, cancellationToken);
if (results.Any(result => result != ValidationResult.Success))
{
throw new ValidationException(results.FirstOrDefault(result => result != ValidationResult.Success), null, application);
}
await UpdateAsync(application, cancellationToken);
}
///
/// Updates an existing application.
///
/// The application to update.
/// The descriptor used to update the application.
/// 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] TApplication application,
[NotNull] OpenIddictApplicationDescriptor descriptor, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
if (descriptor == null)
{
throw new ArgumentNullException(nameof(descriptor));
}
// Store the original client secret for later comparison.
var comparand = await Store.GetClientSecretAsync(application, cancellationToken);
await PopulateAsync(application, descriptor, cancellationToken);
// If the client secret was updated, re-obfuscate it before persisting the changes.
var secret = await Store.GetClientSecretAsync(application, cancellationToken);
if (!string.Equals(secret, comparand, StringComparison.Ordinal))
{
await UpdateAsync(application, secret, cancellationToken);
return;
}
await UpdateAsync(application, cancellationToken);
}
///
/// Validates the application to ensure it's in a consistent state.
///
/// The application.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns the validation error encountered when validating the application.
///
public virtual async Task> ValidateAsync(
[NotNull] TApplication application, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
var builder = ImmutableArray.CreateBuilder();
// Ensure the client_id is not null or empty and is not already used for a different application.
var identifier = await Store.GetClientIdAsync(application, cancellationToken);
if (string.IsNullOrEmpty(identifier))
{
builder.Add(new ValidationResult("The client identifier cannot be null or empty."));
}
else
{
// Note: depending on the database/table/query collation used by the store, an application
// whose client_id doesn't exactly match the specified value may be returned (e.g because
// the casing is different). To avoid issues when the client identifier is part of an index
// using the same collation, an error is added even if the two identifiers don't exactly match.
var other = await Store.FindByClientIdAsync(identifier, cancellationToken);
if (other != null && !string.Equals(
await Store.GetIdAsync(other, cancellationToken),
await Store.GetIdAsync(application, cancellationToken), StringComparison.Ordinal))
{
builder.Add(new ValidationResult("An application with the same client identifier already exists."));
}
}
var type = await Store.GetClientTypeAsync(application, cancellationToken);
if (string.IsNullOrEmpty(type))
{
builder.Add(new ValidationResult("The client type cannot be null or empty."));
}
else
{
// Ensure the application type is supported by the manager.
if (!string.Equals(type, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(type, OpenIddictConstants.ClientTypes.Hybrid, StringComparison.OrdinalIgnoreCase) &&
!string.Equals(type, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase))
{
builder.Add(new ValidationResult("Only 'confidential', 'hybrid' or 'public' applications are " +
"supported by the default application manager."));
}
// Ensure a client secret was specified if the client is a confidential application.
var secret = await Store.GetClientSecretAsync(application, cancellationToken);
if (string.IsNullOrEmpty(secret) &&
string.Equals(type, OpenIddictConstants.ClientTypes.Confidential, StringComparison.OrdinalIgnoreCase))
{
builder.Add(new ValidationResult("The client secret cannot be null or empty for a confidential application."));
}
// Ensure no client secret was specified if the client is a public application.
else if (!string.IsNullOrEmpty(secret) &&
string.Equals(type, OpenIddictConstants.ClientTypes.Public, StringComparison.OrdinalIgnoreCase))
{
builder.Add(new ValidationResult("A client secret cannot be associated with a public application."));
}
}
// When callback URLs are specified, ensure they are valid and spec-compliant.
// See https://tools.ietf.org/html/rfc6749#section-3.1 for more information.
foreach (var address in ImmutableArray.Create()
.AddRange(await Store.GetPostLogoutRedirectUrisAsync(application, cancellationToken))
.AddRange(await Store.GetRedirectUrisAsync(application, cancellationToken)))
{
// Ensure the address is not null or empty.
if (string.IsNullOrEmpty(address))
{
builder.Add(new ValidationResult("Callback URLs cannot be null or empty."));
break;
}
// Ensure the address is a valid absolute URL.
if (!Uri.TryCreate(address, UriKind.Absolute, out Uri uri) || !uri.IsWellFormedOriginalString())
{
builder.Add(new ValidationResult("Callback URLs must be valid absolute URLs."));
break;
}
// Ensure the address doesn't contain a fragment.
if (!string.IsNullOrEmpty(uri.Fragment))
{
builder.Add(new ValidationResult("Callback URLs cannot contain a fragment."));
break;
}
}
var permissions = await Store.GetPermissionsAsync(application, cancellationToken);
if (permissions.Contains(OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode))
{
if (!permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Authorization) &&
permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint)))
{
builder.Add(new ValidationResult(
"The authorization code flow permission requires adding the authorization endpoint permission."));
}
if (!permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Token) &&
permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint)))
{
builder.Add(new ValidationResult(
"The authorization code flow permission requires adding the token endpoint permission."));
}
}
if (permissions.Contains(OpenIddictConstants.Permissions.GrantTypes.ClientCredentials) &&
!permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Token) &&
permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint)))
{
builder.Add(new ValidationResult(
"The client credentials flow permission requires adding the token endpoint permission."));
}
if (permissions.Contains(OpenIddictConstants.Permissions.GrantTypes.Implicit) &&
!permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Authorization) &&
permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint)))
{
builder.Add(new ValidationResult(
"The implicit flow permission requires adding the authorization endpoint permission."));
}
if (permissions.Contains(OpenIddictConstants.Permissions.GrantTypes.Password) &&
!permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Token) &&
permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint)))
{
builder.Add(new ValidationResult(
"The password flow permission requires adding the token endpoint permission."));
}
if (permissions.Contains(OpenIddictConstants.Permissions.GrantTypes.RefreshToken) &&
!permissions.Contains(OpenIddictConstants.Permissions.Endpoints.Token) &&
permissions.Any(permission => permission.StartsWith(OpenIddictConstants.Permissions.Prefixes.Endpoint)))
{
builder.Add(new ValidationResult(
"The refresh token flow permission requires adding the token endpoint permission."));
}
return builder.Count == builder.Capacity ?
builder.MoveToImmutable() :
builder.ToImmutable();
}
///
/// Validates the client_secret associated with an application.
///
/// The application.
/// The secret that should be compared to the client_secret stored in the database.
/// 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 a boolean indicating whether the client secret was valid.
///
public virtual async Task ValidateClientSecretAsync(
[NotNull] TApplication application, string secret, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
if (await IsPublicAsync(application, cancellationToken))
{
Logger.LogWarning("Client authentication cannot be enforced for public applications.");
return false;
}
var value = await Store.GetClientSecretAsync(application, cancellationToken);
if (string.IsNullOrEmpty(value))
{
Logger.LogError("Client authentication failed for {Client} because " +
"no client secret was associated with the application.");
return false;
}
if (!await ValidateClientSecretAsync(secret, value, cancellationToken))
{
Logger.LogWarning("Client authentication failed for {Client}.",
await GetClientIdAsync(application, cancellationToken));
return false;
}
return true;
}
///
/// Validates the redirect_uri to ensure it's associated with an application.
///
/// The application.
/// The address that should be compared to one of the redirect_uri stored in the database.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns a boolean indicating whether the redirect_uri was valid.
///
public virtual async Task ValidateRedirectUriAsync(
[NotNull] TApplication application, [NotNull] string address, CancellationToken cancellationToken = default)
{
if (application == null)
{
throw new ArgumentNullException(nameof(application));
}
if (string.IsNullOrEmpty(address))
{
throw new ArgumentException("The address cannot be null or empty.", nameof(address));
}
foreach (var uri in await Store.GetRedirectUrisAsync(application, cancellationToken))
{
// Note: the redirect_uri must be compared using case-sensitive "Simple String Comparison".
// See http://openid.net/specs/openid-connect-core-1_0.html#AuthRequest for more information.
if (string.Equals(uri, address, StringComparison.Ordinal))
{
return true;
}
}
Logger.LogWarning("Client validation failed because '{RedirectUri}' was not a valid redirect_uri " +
"for {Client}.", address, await GetClientIdAsync(application, cancellationToken));
return false;
}
///
/// Obfuscates the specified client secret so it can be safely stored in a database.
/// By default, this method returns a complex hashed representation computed using PBKDF2.
///
/// The client secret.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation.
///
protected virtual Task ObfuscateClientSecretAsync([NotNull] string secret, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(secret))
{
throw new ArgumentException("The secret cannot be null or empty.", nameof(secret));
}
return Task.FromResult(Crypto.HashPassword(secret));
}
///
/// Validates the specified value to ensure it corresponds to the client secret.
/// Note: when overriding this method, using a time-constant comparer is strongly recommended.
///
/// The client secret to compare to the value stored in the database.
/// The value stored in the database, which is usually a hashed representation of the secret.
/// The that can be used to abort the operation.
///
/// A that can be used to monitor the asynchronous operation,
/// whose result returns a boolean indicating whether the specified value was valid.
///
protected virtual Task ValidateClientSecretAsync(
[NotNull] string secret, [NotNull] string comparand, CancellationToken cancellationToken = default)
{
if (string.IsNullOrEmpty(secret))
{
throw new ArgumentException("The secret cannot be null or empty.", nameof(secret));
}
if (string.IsNullOrEmpty(comparand))
{
throw new ArgumentException("The comparand cannot be null or empty.", nameof(comparand));
}
try
{
return Task.FromResult(Crypto.VerifyHashedPassword(comparand, secret));
}
catch (Exception exception)
{
Logger.LogWarning(0, exception, "An error occurred while trying to verify a client secret. " +
"This may indicate that the hashed entry is corrupted or malformed.");
return Task.FromResult(false);
}
}
Task IOpenIddictApplicationManager.CountAsync(CancellationToken cancellationToken)
=> CountAsync(cancellationToken);
Task IOpenIddictApplicationManager.CountAsync(Func, IQueryable> query, CancellationToken cancellationToken)
=> CountAsync(query, cancellationToken);
async Task