diff --git a/.nuke/build.schema.json b/.nuke/build.schema.json new file mode 100644 index 0000000..f3ff562 --- /dev/null +++ b/.nuke/build.schema.json @@ -0,0 +1,122 @@ +{ + "$schema": "http://json-schema.org/draft-04/schema#", + "title": "Build Schema", + "$ref": "#/definitions/build", + "definitions": { + "build": { + "type": "object", + "properties": { + "Configuration": { + "type": "string", + "description": "Configuration to build - Default is 'Debug' (local) or 'Release' (server)", + "enum": [ + "Debug", + "Release" + ] + }, + "Continue": { + "type": "boolean", + "description": "Indicates to continue a previously failed build attempt" + }, + "GitHubAccessToken": { + "type": "string", + "description": "GitHub access token used for creating a new or updating an existing release" + }, + "Help": { + "type": "boolean", + "description": "Shows the help text for this build assembly" + }, + "Host": { + "type": "string", + "description": "Host for execution. Default is 'automatic'", + "enum": [ + "AppVeyor", + "AzurePipelines", + "Bamboo", + "Bitbucket", + "Bitrise", + "GitHubActions", + "GitLab", + "Jenkins", + "Rider", + "SpaceAutomation", + "TeamCity", + "Terminal", + "TravisCI", + "VisualStudio", + "VSCode" + ] + }, + "NoLogo": { + "type": "boolean", + "description": "Disables displaying the NUKE logo" + }, + "NuGetApiKey": { + "type": "string", + "description": "NuGet API key used to pushing the Sdk NuGet package" + }, + "NuGetSource": { + "type": "string", + "description": "NuGet source used for pushing the Sdk NuGet package. Default is NuGet.org" + }, + "Partition": { + "type": "string", + "description": "Partition to use on CI" + }, + "Plan": { + "type": "boolean", + "description": "Shows the execution plan (HTML)" + }, + "Profile": { + "type": "array", + "description": "Defines the profiles to load", + "items": { + "type": "string" + } + }, + "Root": { + "type": "string", + "description": "Root directory during build execution" + }, + "Skip": { + "type": "array", + "description": "List of targets to be skipped. Empty list skips all dependencies", + "items": { + "type": "string", + "enum": [ + "Clean", + "Compile", + "Restore" + ] + } + }, + "Solution": { + "type": "string", + "description": "Path to a solution file that is automatically loaded" + }, + "Target": { + "type": "array", + "description": "List of targets to be invoked. Default is '{default_target}'", + "items": { + "type": "string", + "enum": [ + "Clean", + "Compile", + "Restore" + ] + } + }, + "Verbosity": { + "type": "string", + "description": "Logging verbosity during build execution. Default is 'Normal'", + "enum": [ + "Minimal", + "Normal", + "Quiet", + "Verbose" + ] + } + } + } + } +} \ No newline at end of file diff --git a/.nuke/parameters.json b/.nuke/parameters.json new file mode 100644 index 0000000..becac29 --- /dev/null +++ b/.nuke/parameters.json @@ -0,0 +1,4 @@ +{ + "$schema": "./build.schema.json", + "Solution": "Sknet.Openiddict.LiteDB.sln" +} \ No newline at end of file diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..30ed802 --- /dev/null +++ b/NOTICE @@ -0,0 +1,13 @@ +======================================================================= +Sknet.OpenIddict.LiteDB +Copyright (c) 2022 Steven Kuhn and contributors. All rights reserved. +======================================================================= + +This product is based on the OpenIddict (openiddict/openiddict-core) project developed by Kévin Chalet and its contributors at https://github.com/openiddict/openiddict-core. + +======================================================================= +OpenIddict +© Kévin Chalet. All rights reserved. +======================================================================= + +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. \ No newline at end of file diff --git a/Sknet.Openiddict.LiteDB.sln b/Sknet.Openiddict.LiteDB.sln new file mode 100644 index 0000000..8c02c46 --- /dev/null +++ b/Sknet.Openiddict.LiteDB.sln @@ -0,0 +1,53 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.3.32825.248 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{649E750E-7C5F-4D27-BE9A-CFEB4EA0C12B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{CB8DF41A-EFAE-4CDD-A8CB-558CF2F50E5F}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sknet.OpenIddict.LiteDB", "src\Sknet.OpenIddict.LiteDB\Sknet.OpenIddict.LiteDB.csproj", "{02AC77ED-78FA-46ED-91D9-B9FA403EF7ED}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Sknet.OpenIddict.LiteDB.Models", "src\Sknet.OpenIddict.LiteDB.Models\Sknet.OpenIddict.LiteDB.Models.csproj", "{198BFA54-A239-4149-9A02-0259761A2127}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "build", "build", "{217F1279-2978-4C25-B60F-0B65BA2E3B00}" +EndProject +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Build", "build\Build.csproj", "{B2746FD1-1277-4BFC-954A-84753B12A028}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Sknet.OpenIddict.LiteDB.Tests", "test\Sknet.OpenIddict.LiteDB.Tests\Sknet.OpenIddict.LiteDB.Tests.csproj", "{4D1B3900-4C81-4E9F-BB32-3FA7A8ECCF1D}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {02AC77ED-78FA-46ED-91D9-B9FA403EF7ED}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {02AC77ED-78FA-46ED-91D9-B9FA403EF7ED}.Debug|Any CPU.Build.0 = Debug|Any CPU + {02AC77ED-78FA-46ED-91D9-B9FA403EF7ED}.Release|Any CPU.ActiveCfg = Release|Any CPU + {02AC77ED-78FA-46ED-91D9-B9FA403EF7ED}.Release|Any CPU.Build.0 = Release|Any CPU + {198BFA54-A239-4149-9A02-0259761A2127}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {198BFA54-A239-4149-9A02-0259761A2127}.Debug|Any CPU.Build.0 = Debug|Any CPU + {198BFA54-A239-4149-9A02-0259761A2127}.Release|Any CPU.ActiveCfg = Release|Any CPU + {198BFA54-A239-4149-9A02-0259761A2127}.Release|Any CPU.Build.0 = Release|Any CPU + {B2746FD1-1277-4BFC-954A-84753B12A028}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B2746FD1-1277-4BFC-954A-84753B12A028}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D1B3900-4C81-4E9F-BB32-3FA7A8ECCF1D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4D1B3900-4C81-4E9F-BB32-3FA7A8ECCF1D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4D1B3900-4C81-4E9F-BB32-3FA7A8ECCF1D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4D1B3900-4C81-4E9F-BB32-3FA7A8ECCF1D}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {02AC77ED-78FA-46ED-91D9-B9FA403EF7ED} = {649E750E-7C5F-4D27-BE9A-CFEB4EA0C12B} + {198BFA54-A239-4149-9A02-0259761A2127} = {649E750E-7C5F-4D27-BE9A-CFEB4EA0C12B} + {B2746FD1-1277-4BFC-954A-84753B12A028} = {217F1279-2978-4C25-B60F-0B65BA2E3B00} + {4D1B3900-4C81-4E9F-BB32-3FA7A8ECCF1D} = {CB8DF41A-EFAE-4CDD-A8CB-558CF2F50E5F} + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {6ADAF88E-F6DC-44EF-957A-79D15C271767} + EndGlobalSection +EndGlobal diff --git a/Sknet.Openiddict.LiteDB.v3.ncrunchsolution b/Sknet.Openiddict.LiteDB.v3.ncrunchsolution new file mode 100644 index 0000000..10420ac --- /dev/null +++ b/Sknet.Openiddict.LiteDB.v3.ncrunchsolution @@ -0,0 +1,6 @@ + + + True + True + + \ No newline at end of file diff --git a/build.cmd b/build.cmd new file mode 100644 index 0000000..b08cc59 --- /dev/null +++ b/build.cmd @@ -0,0 +1,7 @@ +:; set -eo pipefail +:; SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) +:; ${SCRIPT_DIR}/build.sh "$@" +:; exit $? + +@ECHO OFF +powershell -ExecutionPolicy ByPass -NoProfile -File "%~dp0build.ps1" %* diff --git a/build.ps1 b/build.ps1 new file mode 100644 index 0000000..8833a53 --- /dev/null +++ b/build.ps1 @@ -0,0 +1,69 @@ +[CmdletBinding()] +Param( + [Parameter(Position=0,Mandatory=$false,ValueFromRemainingArguments=$true)] + [string[]]$BuildArguments +) + +Write-Output "PowerShell $($PSVersionTable.PSEdition) version $($PSVersionTable.PSVersion)" + +Set-StrictMode -Version 2.0; $ErrorActionPreference = "Stop"; $ConfirmPreference = "None"; trap { Write-Error $_ -ErrorAction Continue; exit 1 } +$PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent + +########################################################################### +# CONFIGURATION +########################################################################### + +$BuildProjectFile = "$PSScriptRoot\build\Build.csproj" +$TempDirectory = "$PSScriptRoot\\.nuke\temp" + +$DotNetGlobalFile = "$PSScriptRoot\\global.json" +$DotNetInstallUrl = "https://dot.net/v1/dotnet-install.ps1" +$DotNetChannel = "Current" + +$env:DOTNET_SKIP_FIRST_TIME_EXPERIENCE = 1 +$env:DOTNET_CLI_TELEMETRY_OPTOUT = 1 +$env:DOTNET_MULTILEVEL_LOOKUP = 0 + +########################################################################### +# EXECUTION +########################################################################### + +function ExecSafe([scriptblock] $cmd) { + & $cmd + if ($LASTEXITCODE) { exit $LASTEXITCODE } +} + +# If dotnet CLI is installed globally and it matches requested version, use for execution +if ($null -ne (Get-Command "dotnet" -ErrorAction SilentlyContinue) -and ` + $(dotnet --version) -and $LASTEXITCODE -eq 0) { + $env:DOTNET_EXE = (Get-Command "dotnet").Path +} +else { + # Download install script + $DotNetInstallFile = "$TempDirectory\dotnet-install.ps1" + New-Item -ItemType Directory -Path $TempDirectory -Force | Out-Null + [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 + (New-Object System.Net.WebClient).DownloadFile($DotNetInstallUrl, $DotNetInstallFile) + + # If global.json exists, load expected version + if (Test-Path $DotNetGlobalFile) { + $DotNetGlobal = $(Get-Content $DotNetGlobalFile | Out-String | ConvertFrom-Json) + if ($DotNetGlobal.PSObject.Properties["sdk"] -and $DotNetGlobal.sdk.PSObject.Properties["version"]) { + $DotNetVersion = $DotNetGlobal.sdk.version + } + } + + # Install by channel or version + $DotNetDirectory = "$TempDirectory\dotnet-win" + if (!(Test-Path variable:DotNetVersion)) { + ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Channel $DotNetChannel -NoPath } + } else { + ExecSafe { & powershell $DotNetInstallFile -InstallDir $DotNetDirectory -Version $DotNetVersion -NoPath } + } + $env:DOTNET_EXE = "$DotNetDirectory\dotnet.exe" +} + +Write-Output "Microsoft (R) .NET SDK version $(& $env:DOTNET_EXE --version)" + +ExecSafe { & $env:DOTNET_EXE build $BuildProjectFile /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet } +ExecSafe { & $env:DOTNET_EXE run --project $BuildProjectFile --no-build -- $BuildArguments } diff --git a/build.sh b/build.sh new file mode 100644 index 0000000..10c6ced --- /dev/null +++ b/build.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash + +bash --version 2>&1 | head -n 1 + +set -eo pipefail +SCRIPT_DIR=$(cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd) + +########################################################################### +# CONFIGURATION +########################################################################### + +BUILD_PROJECT_FILE="$SCRIPT_DIR/build/Build.csproj" +TEMP_DIRECTORY="$SCRIPT_DIR//.nuke/temp" + +DOTNET_GLOBAL_FILE="$SCRIPT_DIR//global.json" +DOTNET_INSTALL_URL="https://dot.net/v1/dotnet-install.sh" +DOTNET_CHANNEL="Current" + +export DOTNET_CLI_TELEMETRY_OPTOUT=1 +export DOTNET_SKIP_FIRST_TIME_EXPERIENCE=1 +export DOTNET_MULTILEVEL_LOOKUP=0 + +########################################################################### +# EXECUTION +########################################################################### + +function FirstJsonValue { + perl -nle 'print $1 if m{"'"$1"'": "([^"]+)",?}' <<< "${@:2}" +} + +# If dotnet CLI is installed globally and it matches requested version, use for execution +if [ -x "$(command -v dotnet)" ] && dotnet --version &>/dev/null; then + export DOTNET_EXE="$(command -v dotnet)" +else + # Download install script + DOTNET_INSTALL_FILE="$TEMP_DIRECTORY/dotnet-install.sh" + mkdir -p "$TEMP_DIRECTORY" + curl -Lsfo "$DOTNET_INSTALL_FILE" "$DOTNET_INSTALL_URL" + chmod +x "$DOTNET_INSTALL_FILE" + + # If global.json exists, load expected version + if [[ -f "$DOTNET_GLOBAL_FILE" ]]; then + DOTNET_VERSION=$(FirstJsonValue "version" "$(cat "$DOTNET_GLOBAL_FILE")") + if [[ "$DOTNET_VERSION" == "" ]]; then + unset DOTNET_VERSION + fi + fi + + # Install by channel or version + DOTNET_DIRECTORY="$TEMP_DIRECTORY/dotnet-unix" + if [[ -z ${DOTNET_VERSION+x} ]]; then + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --channel "$DOTNET_CHANNEL" --no-path + else + "$DOTNET_INSTALL_FILE" --install-dir "$DOTNET_DIRECTORY" --version "$DOTNET_VERSION" --no-path + fi + export DOTNET_EXE="$DOTNET_DIRECTORY/dotnet" +fi + +echo "Microsoft (R) .NET SDK version $("$DOTNET_EXE" --version)" + +"$DOTNET_EXE" build "$BUILD_PROJECT_FILE" /nodeReuse:false /p:UseSharedCompilation=false -nologo -clp:NoSummary --verbosity quiet +"$DOTNET_EXE" run --project "$BUILD_PROJECT_FILE" --no-build -- "$@" diff --git a/build/.editorconfig b/build/.editorconfig new file mode 100644 index 0000000..31e43dc --- /dev/null +++ b/build/.editorconfig @@ -0,0 +1,11 @@ +[*.cs] +dotnet_style_qualification_for_field = false:warning +dotnet_style_qualification_for_property = false:warning +dotnet_style_qualification_for_method = false:warning +dotnet_style_qualification_for_event = false:warning +dotnet_style_require_accessibility_modifiers = never:warning + +csharp_style_expression_bodied_methods = true:silent +csharp_style_expression_bodied_properties = true:warning +csharp_style_expression_bodied_indexers = true:warning +csharp_style_expression_bodied_accessors = true:warning diff --git a/build/Build.cs b/build/Build.cs new file mode 100644 index 0000000..07c635a --- /dev/null +++ b/build/Build.cs @@ -0,0 +1,89 @@ +using System; +using System.Linq; +using Newtonsoft.Json; +using Nuke.Common; +using Nuke.Common.CI; +class Build : NukeBuild +{ + /// Support plugins are available for: + /// - JetBrains ReSharper https://nuke.build/resharper + /// - JetBrains Rider https://nuke.build/rider + /// - Microsoft VisualStudio https://nuke.build/visualstudio + /// - Microsoft VSCode https://nuke.build/vscode + + public static int Main () => Execute(x => x.Compile); + + [Parameter("Configuration to build - Default is 'Debug' (local) or 'Release' (server)")] + readonly Configuration Configuration = IsLocalBuild ? Configuration.Debug : Configuration.Release; + + [Parameter("GitHub access token used for creating a new or updating an existing release.")] + readonly string GitHubAccessToken; + + [Parameter("NuGet source used for pushing the Sdk NuGet package. Default is NuGet.org.")] + readonly string NuGetSource = "https://api.nuget.org/v3/index.json"; + + [Parameter("NuGet API key used to pushing the Sdk NuGet package.")] + readonly string NuGetApiKey; + + [Solution] readonly Solution Solution; + [GitRepository] readonly GitRepository GitRepository; + [GitVersion(Framework = "net6.0", NoFetch = true)] readonly GitVersion GitVersion; + + static AbsolutePath SourceDirectory => RootDirectory / "src"; + static AbsolutePath TestsDirectory => RootDirectory / "test"; + static AbsolutePath ArtifactsDirectory => RootDirectory / "artifacts"; + + const string GitHubRepositoryName = "InRuleGitStorage"; + const string GitHubRepositoryOwner = "openiddict-litedb"; + + readonly string[] NuGetRestoreSources = new[] { + "https://api.nuget.org/v3/index.json" + }; + + protected override void OnBuildInitialized() + { + Log.Information($"GitVersion settings:\n{JsonConvert.SerializeObject(GitVersion, Formatting.Indented)}"); + } + + Target Clean => _ => _ + .Before(Restore) + .Executes(() => + { + Log.Debug("Deleting all bin/obj directories..."); + SourceDirectory.GlobDirectories("**/bin", "**/obj").ForEach(DeleteDirectory); + TestsDirectory.GlobDirectories("**/bin", "**/obj").ForEach(DeleteDirectory); + + Log.Debug("Cleaning artifacts directory..."); + EnsureCleanDirectory(ArtifactsDirectory); + + Log.Debug("Deleting test results directories..."); + TestsDirectory.GlobDirectories("**/TestResults").ForEach(DeleteDirectory); + }); + + Target Restore => _ => _ + .Executes(() => + { + Log.Debug("Restoring NuGet packages for solution..."); + DotNetRestore(s => s + .SetProjectFile(Solution) + .SetSources(NuGetRestoreSources)); + }); + + Target Compile => _ => _ + .DependsOn(Restore) + .Executes(() => + { + Log.Debug("Compiling solution..."); + DotNetBuild(s => s + .SetProjectFile(Solution) + .SetVersion(GitVersion.FullSemVer) + .SetAssemblyVersion(GitVersion.AssemblySemVer) + .SetFileVersion(GitVersion.AssemblySemFileVer) + .SetInformationalVersion(GitVersion.InformationalVersion) + .SetProperty("RepositoryBranch", GitVersion.BranchName) + .SetProperty("RepositoryCommit", GitVersion.Sha) + .SetConfiguration(Configuration) + .EnableNoRestore()); + }); + +} diff --git a/build/Build.csproj b/build/Build.csproj new file mode 100644 index 0000000..560b64f --- /dev/null +++ b/build/Build.csproj @@ -0,0 +1,21 @@ + + + + Exe + net6.0 + + CS0649;CS0169 + .. + .. + 1 + + + + + + + + + + + diff --git a/build/Build.csproj.DotSettings b/build/Build.csproj.DotSettings new file mode 100644 index 0000000..c8947fc --- /dev/null +++ b/build/Build.csproj.DotSettings @@ -0,0 +1,26 @@ + + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + DO_NOT_SHOW + Implicit + Implicit + ExpressionBody + 0 + NEXT_LINE + True + False + 120 + IF_OWNER_IS_SINGLE_LINE + WRAP_IF_LONG + False + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + <Policy Inspect="True" Prefix="" Suffix="" Style="AaBb" /> + True + True + True + True + True + True + True + True diff --git a/build/Configuration.cs b/build/Configuration.cs new file mode 100644 index 0000000..ad4e5ca --- /dev/null +++ b/build/Configuration.cs @@ -0,0 +1,11 @@ +[TypeConverter(typeof(TypeConverter))] +public class Configuration : Enumeration +{ + public static Configuration Debug = new Configuration { Value = nameof(Debug) }; + public static Configuration Release = new Configuration { Value = nameof(Release) }; + + public static implicit operator string(Configuration configuration) + { + return configuration.Value; + } +} diff --git a/build/Directory.Build.props b/build/Directory.Build.props new file mode 100644 index 0000000..e147d63 --- /dev/null +++ b/build/Directory.Build.props @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build/Directory.Build.targets b/build/Directory.Build.targets new file mode 100644 index 0000000..2532609 --- /dev/null +++ b/build/Directory.Build.targets @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/build/Usings.cs b/build/Usings.cs new file mode 100644 index 0000000..eb88c1b --- /dev/null +++ b/build/Usings.cs @@ -0,0 +1,26 @@ +global using System; +global using System.ComponentModel; +global using System.IO; +global using System.Linq; + +global using Newtonsoft.Json; +global using Serilog; +global using Nuke.Common; +global using Nuke.Common.CI; +global using Nuke.Common.Execution; +global using Nuke.Common.Git; +global using Nuke.Common.IO; +global using Nuke.Common.ProjectModel; +global using Nuke.Common.Tooling; +global using Nuke.Common.Tools.DotNet; +global using Nuke.Common.Tools.GitVersion; +global using Nuke.Common.Tools.MSBuild; +global using Nuke.Common.Tools.NuGet; +global using Nuke.Common.Utilities.Collections; + +global using static Nuke.Common.EnvironmentInfo; +global using static Nuke.Common.IO.CompressionTasks; +global using static Nuke.Common.IO.FileSystemTasks; +global using static Nuke.Common.IO.PathConstruction; +global using static Nuke.Common.Tools.DotNet.DotNetTasks; +global using static Nuke.Common.Tools.NuGet.NuGetTasks; \ No newline at end of file diff --git a/icon.png b/icon.png new file mode 100644 index 0000000..94fdf50 Binary files /dev/null and b/icon.png differ diff --git a/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBApplication.cs b/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBApplication.cs new file mode 100644 index 0000000..d61a228 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBApplication.cs @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB.Models; + +/// +/// Represents an OpenIddict application. +/// +[DebuggerDisplay("Id = {Id.ToString(),nq} ; ClientId = {ClientId,nq} ; Type = {Type,nq}")] +public class OpenIddictLiteDBApplication +{ + /// + /// Gets or sets the client identifier associated with the current application. + /// + [BsonField("client_id")] + public virtual string? ClientId { get; set; } + + /// + /// Gets or sets the client secret associated with the current application. + /// Note: depending on the application manager used to create this instance, + /// this property may be hashed or encrypted for security reasons. + /// + [BsonField("client_secret")] + public virtual string? ClientSecret { get; set; } + + /// + /// Gets or sets the concurrency token. + /// + [BsonField("concurrency_token")] + public virtual string? ConcurrencyToken { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the consent type associated with the current application. + /// + [BsonField("consent_type")] + public virtual string? ConsentType { get; set; } + + /// + /// Gets or sets the display name associated with the current application. + /// + [BsonField("display_name")] + public virtual string? DisplayName { get; set; } + + /// + /// Gets or sets the localized display names associated with the current application. + /// + [BsonField("display_names")] + public virtual IReadOnlyDictionary? DisplayNames { get; set; } + = ImmutableDictionary.Create(); + + /// + /// Gets or sets the unique identifier associated with the current application. + /// + [BsonId] + public virtual ObjectId Id { get; set; } = ObjectId.Empty; + + /// + /// Gets or sets the permissions associated with the current application. + /// + [BsonField("permissions")] + public virtual IReadOnlyList? Permissions { get; set; } = ImmutableList.Create(); + + /// + /// Gets or sets the logout callback URLs associated with the current application. + /// + [BsonField("post_logout_redirect_uris")] + public virtual IReadOnlyList? PostLogoutRedirectUris { get; set; } = ImmutableList.Create(); + + /// + /// Gets or sets the additional properties associated with the current application. + /// + [BsonField("properties")] + public virtual IReadOnlyDictionary? Properties { get; set; } + + /// + /// Gets or sets the callback URLs associated with the current application. + /// + [BsonField("redirect_uris")] + public virtual IReadOnlyList? RedirectUris { get; set; } = ImmutableList.Create(); + + /// + /// Gets or sets the requirements associated with the current application. + /// + [BsonField("requirements")] + public virtual IReadOnlyList? Requirements { get; set; } = ImmutableList.Create(); + + /// + /// Gets or sets the application type + /// associated with the current application. + /// + [BsonField("type")] + public virtual string? Type { get; set; } +} diff --git a/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBAuthorization.cs b/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBAuthorization.cs new file mode 100644 index 0000000..82c13d6 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBAuthorization.cs @@ -0,0 +1,77 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB.Models; + +/// +/// Represents an OpenIddict authorization. +/// +[DebuggerDisplay("Id = {Id.ToString(),nq} ; Subject = {Subject,nq} ; Type = {Type,nq} ; Status = {Status,nq}")] +public class OpenIddictLiteDBAuthorization +{ + /// + /// Gets or sets the identifier of the application associated with the current authorization. + /// + [BsonField("application_id")] + public virtual ObjectId ApplicationId { get; set; } = ObjectId.Empty; + + /// + /// Gets or sets the concurrency token. + /// + [BsonField("concurrency_token")] + public virtual string? ConcurrencyToken { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the UTC creation date of the current authorization. + /// + [BsonField("creation_date")] + public virtual DateTime? CreationDate { get; set; } + + /// + /// Gets or sets the unique identifier associated with the current authorization. + /// + [BsonId] + public virtual ObjectId Id { get; set; } = ObjectId.Empty; + + /// + /// Gets or sets the additional properties associated with the current authorization. + /// + [BsonField("properties")] + public virtual IReadOnlyDictionary? Properties { get; set; } + + /// + /// Gets or sets the scopes associated with the current authorization. + /// + [BsonField("scopes")] + public virtual IReadOnlyList? Scopes { get; set; } = ImmutableList.Create(); + + /// + /// Gets or sets the status of the current authorization. + /// + [BsonField("status")] + public virtual string? Status { get; set; } + + /// + /// Gets or sets the subject associated with the current authorization. + /// + [BsonField("subject")] + public virtual string? Subject { get; set; } + + /// + /// Gets or sets the type of the current authorization. + /// + [BsonField("type")] + public virtual string? Type { get; set; } +} diff --git a/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBScope.cs b/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBScope.cs new file mode 100644 index 0000000..8f76a80 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBScope.cs @@ -0,0 +1,79 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB.Models; + +/// +/// Represents an OpenIddict scope. +/// +[DebuggerDisplay("Id = {Id.ToString(),nq} ; Name = {Name,nq}")] +public class OpenIddictLiteDBScope +{ + /// + /// Gets or sets the concurrency token. + /// + [BsonField("concurrency_token")] + public virtual string? ConcurrencyToken { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the public description associated with the current scope. + /// + [BsonField("description")] + public virtual string? Description { get; set; } + + /// + /// Gets or sets the localized public descriptions associated with the current scope. + /// + [BsonField("descriptions")] + public virtual IReadOnlyDictionary? Descriptions { get; set; } + = ImmutableDictionary.Create(); + + /// + /// Gets or sets the display name associated with the current scope. + /// + [BsonField("display_name")] + public virtual string? DisplayName { get; set; } + + /// + /// Gets or sets the localized display names associated with the current scope. + /// + [BsonField("display_names")] + public virtual IReadOnlyDictionary? DisplayNames { get; set; } + = ImmutableDictionary.Create(); + + /// + /// Gets or sets the unique identifier associated with the current scope. + /// + [BsonId] + public virtual ObjectId Id { get; set; } = ObjectId.Empty; + + /// + /// Gets or sets the unique name associated with the current scope. + /// + [BsonField("name")] + public virtual string? Name { get; set; } + + /// + /// Gets or sets the additional properties associated with the current scope. + /// + [BsonField("properties")] + public virtual IReadOnlyDictionary? Properties { get; set; } + + /// + /// Gets or sets the resources associated with the current scope. + /// + [BsonField("resources")] + public virtual IReadOnlyList? Resources { get; set; } = ImmutableList.Create(); +} diff --git a/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBToken.cs b/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBToken.cs new file mode 100644 index 0000000..859b9e3 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB.Models/OpenIddictLiteDBToken.cs @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB.Models; + +/// +/// Represents an OpenIddict token. +/// +[DebuggerDisplay("Id = {Id.ToString(),nq} ; Subject = {Subject,nq} ; Type = {Type,nq} ; Status = {Status,nq}")] +public class OpenIddictLiteDBToken +{ + /// + /// Gets or sets the identifier of the application associated with the current token. + /// + [BsonField("application_id")] + public virtual ObjectId ApplicationId { get; set; } = ObjectId.Empty; + + /// + /// Gets or sets the identifier of the authorization associated with the current token. + /// + [BsonField("authorization_id")] + public virtual ObjectId AuthorizationId { get; set; } = ObjectId.Empty; + + /// + /// Gets or sets the concurrency token. + /// + [BsonField("concurrency_token")] + public virtual string? ConcurrencyToken { get; set; } = Guid.NewGuid().ToString(); + + /// + /// Gets or sets the UTC creation date of the current token. + /// + [BsonField("creation_date")] + public virtual DateTime? CreationDate { get; set; } + + /// + /// Gets or sets the UTC expiration date of the current token. + /// + [BsonField("expiration_date")] + public virtual DateTime? ExpirationDate { get; set; } + + /// + /// Gets or sets the unique identifier associated with the current token. + /// + [BsonId] + public virtual ObjectId Id { get; set; } = ObjectId.Empty; + + /// + /// Gets or sets the payload of the current token, if applicable. + /// Note: this property is only used for reference tokens + /// and may be encrypted for security reasons. + /// + [BsonField("payload")] + public virtual string? Payload { get; set; } + + /// + /// Gets or sets the additional properties associated with the current token. + /// + [BsonField("properties")] + public virtual IReadOnlyDictionary? Properties { get; set; } + + /// + /// Gets or sets the UTC redemption date of the current token. + /// + [BsonField("redemption_date")] + public virtual DateTime? RedemptionDate { get; set; } + + /// + /// Gets or sets the reference identifier associated + /// with the current token, if applicable. + /// Note: this property is only used for reference tokens + /// and may be hashed or encrypted for security reasons. + /// + [BsonField("reference_id")] + public virtual string? ReferenceId { get; set; } + + /// + /// Gets or sets the status of the current token. + /// + [BsonField("status")] + public virtual string? Status { get; set; } + + /// + /// Gets or sets the subject associated with the current token. + /// + [BsonField("subject")] + public virtual string? Subject { get; set; } + + /// + /// Gets or sets the type of the current token. + /// + [BsonField("type")] + public virtual string? Type { get; set; } +} \ No newline at end of file diff --git a/src/Sknet.OpenIddict.LiteDB.Models/Sknet.OpenIddict.LiteDB.Models.csproj b/src/Sknet.OpenIddict.LiteDB.Models/Sknet.OpenIddict.LiteDB.Models.csproj new file mode 100644 index 0000000..8c0f9ab --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB.Models/Sknet.OpenIddict.LiteDB.Models.csproj @@ -0,0 +1,46 @@ + + + + net461;netstandard2.0;net6.0 + enable + enable + 10.0 + + Steven Kuhn + Copyright (c) 2022 Steven Kuhn and contributors + Document-oriented entities for the OpenIddict LiteDB stores. + true + True + True + icon.png + MIT + https://github.com/stevenkuhn/openiddict-litedb + README.md + $(PackageTags);litedb;models + git + https://github.com/stevenkuhn/openiddict-litedb.git + snupkg + + + + + + + + + True + \ + + + True + \ + + + + + + + + + + diff --git a/src/Sknet.OpenIddict.LiteDB.Models/Usings.cs b/src/Sknet.OpenIddict.LiteDB.Models/Usings.cs new file mode 100644 index 0000000..954fe3d --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB.Models/Usings.cs @@ -0,0 +1,4 @@ +global using LiteDB; +global using System.Collections.Immutable; +global using System.Diagnostics; +global using System.Text.Json; \ No newline at end of file diff --git a/src/Sknet.OpenIddict.LiteDB/IOpenIddictLiteDBContext.cs b/src/Sknet.OpenIddict.LiteDB/IOpenIddictLiteDBContext.cs new file mode 100644 index 0000000..68a872f --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/IOpenIddictLiteDBContext.cs @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB; + +/// +/// Exposes the LiteDB database used by the OpenIddict stores. +/// +public interface IOpenIddictLiteDBContext +{ + /// + /// Gets the . + /// + /// + /// A that can be used to monitor the + /// asynchronous operation, whose result returns the LiteDB database. + /// + ValueTask GetDatabaseAsync(CancellationToken cancellationToken); +} diff --git a/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBBuilder.cs b/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBBuilder.cs new file mode 100644 index 0000000..14e08ca --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBBuilder.cs @@ -0,0 +1,189 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Exposes the necessary methods required to configure the OpenIddict LiteDB services. +/// +public class OpenIddictLiteDBBuilder +{ + /// + /// Initializes a new instance of . + /// + /// The services collection. + public OpenIddictLiteDBBuilder(IServiceCollection services) + => Services = services ?? throw new ArgumentNullException(nameof(services)); + + /// + /// Gets the services collection. + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public IServiceCollection Services { get; } + + /// + /// Amends the default OpenIddict LiteDB configuration. + /// + /// The delegate used to configure the OpenIddict options. + /// This extension can be safely called multiple times. + /// The . + public OpenIddictLiteDBBuilder Configure(Action configuration) + { + if (configuration is null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + Services.Configure(configuration); + + return this; + } + + /// + /// Configures OpenIddict to use the specified entity as the default application entity. + /// + /// The . + public OpenIddictLiteDBBuilder ReplaceDefaultApplicationEntity() + where TApplication : OpenIddictLiteDBApplication + { + Services.Configure(options => options.DefaultApplicationType = typeof(TApplication)); + + return this; + } + + /// + /// Configures OpenIddict to use the specified entity as the default authorization entity. + /// + /// The . + public OpenIddictLiteDBBuilder ReplaceDefaultAuthorizationEntity() + where TAuthorization : OpenIddictLiteDBAuthorization + { + Services.Configure(options => options.DefaultAuthorizationType = typeof(TAuthorization)); + + return this; + } + + /// + /// Configures OpenIddict to use the specified entity as the default scope entity. + /// + /// The . + public OpenIddictLiteDBBuilder ReplaceDefaultScopeEntity() + where TScope : OpenIddictLiteDBScope + { + Services.Configure(options => options.DefaultScopeType = typeof(TScope)); + + return this; + } + + /// + /// Configures OpenIddict to use the specified entity as the default token entity. + /// + /// The . + public OpenIddictLiteDBBuilder ReplaceDefaultTokenEntity() + where TToken : OpenIddictLiteDBToken + { + Services.Configure(options => options.DefaultTokenType = typeof(TToken)); + + return this; + } + + /// + /// Replaces the default applications collection name (by default, openiddict.applications). + /// + /// The collection name + /// The . + public OpenIddictLiteDBBuilder SetApplicationsCollectionName(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("The collection name cannot be null or empty.", nameof(name)); + } + + return Configure(options => options.ApplicationsCollectionName = name); + } + + /// + /// Replaces the default authorizations collection name (by default, openiddict.authorizations). + /// + /// The collection name + /// The . + public OpenIddictLiteDBBuilder SetAuthorizationsCollectionName(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("The collection name cannot be null or empty.", nameof(name)); + } + + return Configure(options => options.AuthorizationsCollectionName = name); + } + + /// + /// Replaces the default scopes collection name (by default, openiddict.scopes). + /// + /// The collection name + /// The . + public OpenIddictLiteDBBuilder SetScopesCollectionName(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("The collection name cannot be null or empty.", nameof(name)); + } + + return Configure(options => options.ScopesCollectionName = name); + } + + /// + /// Replaces the default tokens collection name (by default, openiddict.tokens). + /// + /// The collection name + /// The . + public OpenIddictLiteDBBuilder SetTokensCollectionName(string name) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("The collection name cannot be null or empty.", nameof(name)); + } + + return Configure(options => options.TokensCollectionName = name); + } + + /// + /// Configures the LiteDB stores to use the specified database + /// instead of retrieving it from the dependency injection container. + /// + /// The . + /// The . + public OpenIddictLiteDBBuilder UseDatabase(ILiteDatabase database) + { + if (database is null) + { + throw new ArgumentNullException(nameof(database)); + } + + return Configure(options => options.Database = database); + } + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override bool Equals(object? obj) => base.Equals(obj); + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override int GetHashCode() => base.GetHashCode(); + + /// + [EditorBrowsable(EditorBrowsableState.Never)] + public override string? ToString() => base.ToString(); +} \ No newline at end of file diff --git a/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBContext.cs b/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBContext.cs new file mode 100644 index 0000000..0f707bf --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBContext.cs @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB; + +/// +public class OpenIddictLiteDBContext : IOpenIddictLiteDBContext +{ + private readonly IOptionsMonitor _options; + private readonly IServiceProvider _provider; + + public OpenIddictLiteDBContext( + IOptionsMonitor options, + IServiceProvider provider) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + } + + /// + public ValueTask GetDatabaseAsync(CancellationToken cancellationToken) + { + if (cancellationToken.IsCancellationRequested) + { + return new(Task.FromCanceled(cancellationToken)); + } + + var database = _options.CurrentValue.Database; + if (database is null) + { + database = _provider.GetService(); + } + + if (database is null) + { + return new(Task.FromException( + new InvalidOperationException("No suitable LiteDB database service can be found.\r\nTo configure the OpenIddict LiteDB stores to use a specific database, use 'services.AddOpenIddict().AddCore().UseLiteDB().UseDatabase()' or register an 'ILiteDatabase' in the dependency injection container in 'ConfigureServices()'."))); + } + + return new(database); + } +} \ No newline at end of file diff --git a/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBExtensions.cs b/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBExtensions.cs new file mode 100644 index 0000000..05b6f5f --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBExtensions.cs @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Microsoft.Extensions.DependencyInjection; + + +/// +/// Exposes extensions allowing to register the OpenIddict LiteDB services. +/// +public static class OpenIddictLiteDBExtensions +{ + /// + /// Registers the LiteDB stores services in the DI container and + /// configures OpenIddict to use the LiteDB entities by default. + /// + /// The services builder used by OpenIddict to register new services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictLiteDBBuilder UseLiteDB(this OpenIddictCoreBuilder builder) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + // Note: Mongo uses simple binary comparison checks by default so the additional + // query filtering applied by the default OpenIddict managers can be safely disabled. + // builder.DisableAdditionalFiltering(); + + builder.SetDefaultApplicationEntity() + .SetDefaultAuthorizationEntity() + .SetDefaultScopeEntity() + .SetDefaultTokenEntity(); + + // Note: the LiteDB stores/resolvers don't depend on scoped/transient services and thus + // can be safely registered as singleton services and shared/reused across requests. + builder.ReplaceApplicationStoreResolver(ServiceLifetime.Singleton) + .ReplaceAuthorizationStoreResolver(ServiceLifetime.Singleton) + .ReplaceScopeStoreResolver(ServiceLifetime.Singleton) + .ReplaceTokenStoreResolver(ServiceLifetime.Singleton); + + builder.Services.TryAddSingleton(typeof(OpenIddictLiteDBApplicationStore<>)); + builder.Services.TryAddSingleton(typeof(OpenIddictLiteDBAuthorizationStore<>)); + builder.Services.TryAddSingleton(typeof(OpenIddictLiteDBScopeStore<>)); + builder.Services.TryAddSingleton(typeof(OpenIddictLiteDBTokenStore<>)); + + builder.Services.TryAddSingleton(); + + return new OpenIddictLiteDBBuilder(builder.Services); + } + + /// + /// Registers the LiteDB stores services in the DI container and + /// configures OpenIddict to use the LiteDB entities by default. + /// + /// The services builder used by OpenIddict to register new services. + /// The configuration delegate used to configure the LiteDB services. + /// This extension can be safely called multiple times. + /// The . + public static OpenIddictCoreBuilder UseLiteDB( + this OpenIddictCoreBuilder builder, Action configuration) + { + if (builder is null) + { + throw new ArgumentNullException(nameof(builder)); + } + + if (configuration is null) + { + throw new ArgumentNullException(nameof(configuration)); + } + + configuration(builder.UseLiteDB()); + + return builder; + } +} \ No newline at end of file diff --git a/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBOptions.cs b/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBOptions.cs new file mode 100644 index 0000000..1c2dbb3 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/OpenIddictLiteDBOptions.cs @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB; + +/// +/// Provides various settings needed to configure the OpenIddict LiteDB integration. +/// +public class OpenIddictLiteDBOptions +{ + /// + /// Gets or sets the name of the applications collection (by default, openiddict.applications). + /// + public string ApplicationsCollectionName { get; set; } = "openiddict.applications"; + + /// + /// Gets or sets the name of the authorizations collection (by default, openiddict.authorizations). + /// + public string AuthorizationsCollectionName { get; set; } = "openiddict.authorizations"; + + /// + /// Gets or sets the used by the OpenIddict stores. + /// If no value is explicitly set, the database is resolved from the DI container. + /// + public ILiteDatabase? Database { get; set; } + + /// + /// Gets or sets the name of the scopes collection (by default, openiddict.scopes). + /// + public string ScopesCollectionName { get; set; } = "openiddict.scopes"; + + /// + /// Gets or sets the name of the tokens collection (by default, openiddict.tokens). + /// + public string TokensCollectionName { get; set; } = "openiddict.tokens"; +} \ No newline at end of file diff --git a/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBApplicationStoreResolver.cs b/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBApplicationStoreResolver.cs new file mode 100644 index 0000000..33712d3 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBApplicationStoreResolver.cs @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB; + +/// +/// Exposes a method allowing to resolve an application store. +/// +public class OpenIddictLiteDBApplicationStoreResolver : IOpenIddictApplicationStoreResolver +{ + private readonly ConcurrentDictionary _cache = new ConcurrentDictionary(); + private readonly IServiceProvider _provider; + + public OpenIddictLiteDBApplicationStoreResolver(IServiceProvider provider) + => _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + + /// + /// Returns an application store compatible with the specified application type or throws an + /// if no store can be built using the specified type. + /// + /// The type of the Application entity. + /// An . + public IOpenIddictApplicationStore Get() where TApplication : class + { + var store = _provider.GetService>(); + if (store is not null) + { + return store; + } + + var type = _cache.GetOrAdd(typeof(TApplication), key => + { + if (!typeof(OpenIddictLiteDBApplication).IsAssignableFrom(key)) + { + throw new InvalidOperationException("The specified application type is not compatible with the LiteDB stores.\r\nWhen enabling the LiteDB stores, make sure you use the built-in 'OpenIddictLiteDBApplication' entity or a custom entity that inherits from the 'OpenIddictLiteDBApplication' entity."); + } + + return typeof(OpenIddictLiteDBApplicationStore<>).MakeGenericType(key); + }); + + return (IOpenIddictApplicationStore)_provider.GetRequiredService(type); + } +} \ No newline at end of file diff --git a/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBAuthorizationStoreResolver.cs b/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBAuthorizationStoreResolver.cs new file mode 100644 index 0000000..5f0938e --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBAuthorizationStoreResolver.cs @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB; + +/// +/// Exposes a method allowing to resolve an authorization store. +/// +public class OpenIddictLiteDBAuthorizationStoreResolver : IOpenIddictAuthorizationStoreResolver +{ + private readonly ConcurrentDictionary _cache = new ConcurrentDictionary(); + private readonly IServiceProvider _provider; + + public OpenIddictLiteDBAuthorizationStoreResolver(IServiceProvider provider) + => _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + + /// + /// Returns an authorization store compatible with the specified authorization type or throws an + /// if no store can be built using the specified type. + /// + /// The type of the Authorization entity. + /// An . + public IOpenIddictAuthorizationStore Get() where TAuthorization : class + { + var store = _provider.GetService>(); + if (store is not null) + { + return store; + } + + var type = _cache.GetOrAdd(typeof(TAuthorization), key => + { + if (!typeof(OpenIddictLiteDBAuthorization).IsAssignableFrom(key)) + { + throw new InvalidOperationException("The specified authorization type is not compatible with the LiteDB stores.\r\nWhen enabling the LiteDB stores, make sure you use the built-in 'OpenIddictLiteDBAuthorization' entity or a custom entity that inherits from the 'OpenIddictLiteDBAuthorization' entity."); + } + + return typeof(OpenIddictLiteDBAuthorizationStore<>).MakeGenericType(key); + }); + + return (IOpenIddictAuthorizationStore)_provider.GetRequiredService(type); + } +} \ No newline at end of file diff --git a/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBScopeStoreResolver.cs b/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBScopeStoreResolver.cs new file mode 100644 index 0000000..f97b749 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBScopeStoreResolver.cs @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB; + +/// +/// Exposes a method allowing to resolve a scope store. +/// +public class OpenIddictLiteDBScopeStoreResolver : IOpenIddictScopeStoreResolver +{ + private readonly ConcurrentDictionary _cache = new ConcurrentDictionary(); + private readonly IServiceProvider _provider; + + public OpenIddictLiteDBScopeStoreResolver(IServiceProvider provider) + => _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + + /// + /// Returns a scope store compatible with the specified scope type or throws an + /// if no store can be built using the specified type. + /// + /// The type of the Scope entity. + /// An . + public IOpenIddictScopeStore Get() where TScope : class + { + var store = _provider.GetService>(); + if (store is not null) + { + return store; + } + + var type = _cache.GetOrAdd(typeof(TScope), key => + { + if (!typeof(OpenIddictLiteDBScope).IsAssignableFrom(key)) + { + throw new InvalidOperationException("The specified scope type is not compatible with the LiteDB stores.\r\nWhen enabling the LiteDB stores, make sure you use the built-in 'OpenIddictLiteDBScope' entity or a custom entity that inherits from the 'OpenIddictLiteDBScope' entity."); + } + + return typeof(OpenIddictLiteDBScopeStore<>).MakeGenericType(key); + }); + + return (IOpenIddictScopeStore)_provider.GetRequiredService(type); + } +} \ No newline at end of file diff --git a/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBTokenStoreResolver.cs b/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBTokenStoreResolver.cs new file mode 100644 index 0000000..dd7a791 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/Resolvers/OpenIddictLiteDBTokenStoreResolver.cs @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB; + +/// +/// Exposes a method allowing to resolve a token store. +/// +public class OpenIddictLiteDBTokenStoreResolver : IOpenIddictTokenStoreResolver +{ + private readonly ConcurrentDictionary _cache = new ConcurrentDictionary(); + private readonly IServiceProvider _provider; + + public OpenIddictLiteDBTokenStoreResolver(IServiceProvider provider) + => _provider = provider ?? throw new ArgumentNullException(nameof(provider)); + + /// + /// Returns a token store compatible with the specified token type or throws an + /// if no store can be built using the specified type. + /// + /// The type of the Token entity. + /// An . + public IOpenIddictTokenStore Get() where TToken : class + { + var store = _provider.GetService>(); + if (store is not null) + { + return store; + } + + var type = _cache.GetOrAdd(typeof(TToken), key => + { + if (!typeof(OpenIddictLiteDBToken).IsAssignableFrom(key)) + { + throw new InvalidOperationException("The specified token type is not compatible with the LiteDB stores.\r\nWhen enabling the LiteDB stores, make sure you use the built-in 'OpenIddictLiteDBToken' entity or a custom entity that inherits from the 'OpenIddictLiteDBToken' entity."); + } + + return typeof(OpenIddictLiteDBTokenStore<>).MakeGenericType(key); + }); + + return (IOpenIddictTokenStore)_provider.GetRequiredService(type); + } +} \ No newline at end of file diff --git a/src/Sknet.OpenIddict.LiteDB/Sknet.OpenIddict.LiteDB.csproj b/src/Sknet.OpenIddict.LiteDB/Sknet.OpenIddict.LiteDB.csproj new file mode 100644 index 0000000..8f098f1 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/Sknet.OpenIddict.LiteDB.csproj @@ -0,0 +1,45 @@ + + + + net461;net472;net48;netstandard2.0;netstandard2.1;net6.0 + enable + enable + 10.0 + + Steven Kuhn + Copyright (c) 2022 Steven Kuhn and contributors + LiteDB stores for OpenIddict. + true + True + True + icon.png + MIT + https://github.com/stevenkuhn/openiddict-litedb + README.md + $(PackageTags);litedb + git + https://github.com/stevenkuhn/openiddict-litedb.git + snupkg + + + + + True + \ + + + True + \ + + + + + + + + + + + + + diff --git a/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBApplicationStore.cs b/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBApplicationStore.cs new file mode 100644 index 0000000..5c088d2 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBApplicationStore.cs @@ -0,0 +1,651 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB; + +/// +/// Provides methods allowing to manage the applications stored in a database. +/// +/// The type of the Application entity. +public class OpenIddictLiteDBApplicationStore : IOpenIddictApplicationStore + where TApplication : OpenIddictLiteDBApplication +{ + public OpenIddictLiteDBApplicationStore( + IOpenIddictLiteDBContext context, + IOptionsMonitor options) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Gets the database context associated with the current store. + /// + protected IOpenIddictLiteDBContext Context { get; } + + /// + /// Gets the options associated with the current store. + /// + protected IOptionsMonitor Options { get; } + + /// + public virtual async ValueTask CountAsync(CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + return collection.LongCount(); + } + + /// + public virtual async ValueTask CountAsync( + Func, IQueryable> query, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + return query(collection.FindAll().AsQueryable()).LongCount(); + } + + /// + public virtual async ValueTask CreateAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + collection.Insert(application); + } + + /// + public virtual async ValueTask DeleteAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + if (collection.DeleteMany(entity => + entity.Id == application.Id && + entity.ConcurrencyToken == application.ConcurrencyToken) == 0) + { + throw new OpenIddictExceptions.ConcurrencyException("The application was concurrently updated and cannot be persisted in its current state.\r\nReload the application from the database and retry the operation."); + } + + // Delete the authorizations associated with the application. + database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName) + .DeleteMany(authorization => authorization.ApplicationId == application.Id); + + // Delete the tokens associated with the application. + database.GetCollection(Options.CurrentValue.TokensCollectionName) + .DeleteMany(token => token.ApplicationId == application.Id); + } + + /// + public virtual async ValueTask FindByClientIdAsync(string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + return collection.Find(application => application.ClientId == identifier).FirstOrDefault(); + } + + /// + public virtual async ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + return collection.FindById(identifier); + } + + /// + public virtual IAsyncEnumerable FindByPostLogoutRedirectUriAsync( + string address, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(address)) + { + throw new ArgumentException("The address cannot be null or empty.", nameof(address)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + var applications = collection.Query() + .Where(entity => entity.PostLogoutRedirectUris.Contains(address)) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var application in applications) + { + yield return application; + } + } + } + + /// + public virtual IAsyncEnumerable FindByRedirectUriAsync( + string address, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(address)) + { + throw new ArgumentException("The address cannot be null or empty.", nameof(address)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + var applications = collection.Query() + .Where(entity => entity.RedirectUris.Contains(address)) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var application in applications) + { + yield return application; + } + } + } + + /// + public virtual async ValueTask GetAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + return query(collection.FindAll().AsQueryable(), state).FirstOrDefault(); + } + + /// + public virtual ValueTask GetClientIdAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + return new(application.ClientId); + } + + /// + public virtual ValueTask GetClientSecretAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + return new(application.ClientSecret); + } + + /// + public virtual ValueTask GetClientTypeAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + return new(application.Type); + } + + /// + public virtual ValueTask GetConsentTypeAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + return new(application.ConsentType); + } + + /// + public virtual ValueTask GetDisplayNameAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + return new(application.DisplayName); + } + + /// + public virtual ValueTask> GetDisplayNamesAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (application.DisplayNames is not { Count: > 0 }) + { + return new(ImmutableDictionary.Create()); + } + + return new(application.DisplayNames.ToImmutableDictionary( + pair => CultureInfo.GetCultureInfo(pair.Key), + pair => pair.Value)); + } + + /// + public virtual ValueTask GetIdAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + return new(application.Id.ToString()); + } + + /// + public virtual ValueTask> GetPermissionsAsync( + TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (application.Permissions is not { Count: > 0 }) + { + return new(ImmutableArray.Create()); + } + + return new(application.Permissions.ToImmutableArray()); + } + + /// + public virtual ValueTask> GetPostLogoutRedirectUrisAsync( + TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (application.PostLogoutRedirectUris is not { Count: > 0 }) + { + return new(ImmutableArray.Create()); + } + + return new(application.PostLogoutRedirectUris.ToImmutableArray()); + } + + /// + public virtual ValueTask> GetPropertiesAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (application.Properties is null || application.Properties.Count == 0) + { + return new(ImmutableDictionary.Create()); + } + + return new(application.Properties.ToImmutableDictionary()); + } + + /// + public virtual ValueTask> GetRedirectUrisAsync( + TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (application.RedirectUris is not { Count: > 0 }) + { + return new(ImmutableArray.Create()); + } + + return new(application.RedirectUris.ToImmutableArray()); + } + + /// + public virtual ValueTask> GetRequirementsAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (application.Requirements is not { Count: > 0 }) + { + return new(ImmutableArray.Create()); + } + + return new(application.Requirements.ToImmutableArray()); + } + + /// + public virtual ValueTask InstantiateAsync(CancellationToken cancellationToken) + { + try + { + return new(Activator.CreateInstance()); + } + + catch (MemberAccessException exception) + { + return new(Task.FromException( + new InvalidOperationException("An error occurred while trying to create a new application instance.\r\nMake sure that the application entity is not abstract and has a public parameterless constructor or create a custom application store that overrides 'InstantiateAsync()' to use a custom factory.", exception))); + } + } + + /// + public virtual async IAsyncEnumerable ListAsync( + int? count, int? offset, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + var applications = collection + .Find(Query.All(), offset ?? 0, count ?? int.MaxValue) + .ToAsyncEnumerable(); + + await foreach (var application in applications) + { + yield return application; + } + } + + /// + public virtual IAsyncEnumerable ListAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + var applications = query(collection.FindAll().AsQueryable(), state).ToAsyncEnumerable(); + + await foreach (var application in applications) + { + yield return application; + } + } + } + + /// + public virtual ValueTask SetClientIdAsync(TApplication application, + string? identifier, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + application.ClientId = identifier; + + return default; + } + + /// + public virtual ValueTask SetClientSecretAsync(TApplication application, + string? secret, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + application.ClientSecret = secret; + + return default; + } + + /// + public virtual ValueTask SetClientTypeAsync(TApplication application, + string? type, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + application.Type = type; + + return default; + } + + /// + public virtual ValueTask SetConsentTypeAsync(TApplication application, + string? type, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + application.ConsentType = type; + + return default; + } + + /// + public virtual ValueTask SetDisplayNameAsync(TApplication application, + string? name, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + application.DisplayName = name; + + return default; + } + + /// + public virtual ValueTask SetDisplayNamesAsync(TApplication application, + ImmutableDictionary names, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (names is not { Count: > 0 }) + { + application.DisplayNames = null; + + return default; + } + + application.DisplayNames = names.ToImmutableDictionary( + pair => pair.Key.Name, + pair => pair.Value); + + return default; + } + + /// + public virtual ValueTask SetPermissionsAsync(TApplication application, ImmutableArray permissions, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (permissions.IsDefaultOrEmpty) + { + application.Permissions = null; + + return default; + } + + application.Permissions = permissions.ToImmutableList(); + + return default; + } + + /// + public virtual ValueTask SetPostLogoutRedirectUrisAsync(TApplication application, + ImmutableArray addresses, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (addresses.IsDefaultOrEmpty) + { + application.PostLogoutRedirectUris = null; + + return default; + } + + application.PostLogoutRedirectUris = addresses.ToImmutableList(); + + return default; + } + + /// + public virtual ValueTask SetPropertiesAsync(TApplication application, + ImmutableDictionary properties, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (properties is not { Count: > 0 }) + { + application.Properties = null; + + return default; + } + + application.Properties = properties; + + return default; + } + + /// + public virtual ValueTask SetRedirectUrisAsync(TApplication application, + ImmutableArray addresses, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (addresses.IsDefaultOrEmpty) + { + application.RedirectUris = null; + + return default; + } + + application.RedirectUris = addresses.ToImmutableList(); + + return default; + } + + /// + public virtual ValueTask SetRequirementsAsync(TApplication application, + ImmutableArray requirements, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + if (requirements.IsDefaultOrEmpty) + { + application.Requirements = null; + + return default; + } + + application.Requirements = requirements.ToImmutableList(); + + return default; + } + + /// + public virtual async ValueTask UpdateAsync(TApplication application, CancellationToken cancellationToken) + { + if (application is null) + { + throw new ArgumentNullException(nameof(application)); + } + + // Generate a new concurrency token and attach it + // to the application before persisting the changes. + var timestamp = application.ConcurrencyToken; + application.ConcurrencyToken = Guid.NewGuid().ToString(); + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ApplicationsCollectionName); + + if (collection.Count(entity => entity.Id == application.Id && entity.ConcurrencyToken == timestamp) == 0) + { + throw new ConcurrencyException("The application was concurrently updated and cannot be persisted in its current state.\r\nReload the application from the database and retry the operation."); + } + + collection.Update(application); + } +} diff --git a/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBAuthorizationStore.cs b/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBAuthorizationStore.cs new file mode 100644 index 0000000..cee4548 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBAuthorizationStore.cs @@ -0,0 +1,718 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB; + +/// +/// Provides methods allowing to manage the authorizations stored in a database. +/// +/// The type of the Authorization entity. +public class OpenIddictLiteDBAuthorizationStore : IOpenIddictAuthorizationStore + where TAuthorization : OpenIddictLiteDBAuthorization +{ + public OpenIddictLiteDBAuthorizationStore( + IOpenIddictLiteDBContext context, + IOptionsMonitor options) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Gets the database context associated with the current store. + /// + protected IOpenIddictLiteDBContext Context { get; } + + /// + /// Gets the options associated with the current store. + /// + protected IOptionsMonitor Options { get; } + + /// + public virtual async ValueTask CountAsync(CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + return collection.LongCount(); + } + + /// + public virtual async ValueTask CountAsync( + Func, IQueryable> query, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + return query(collection.FindAll().AsQueryable()).LongCount(); + } + + /// + public virtual async ValueTask CreateAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + collection.Insert(authorization); + } + + /// + public virtual async ValueTask DeleteAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + if (collection.DeleteMany(entity => + entity.Id == authorization.Id && + entity.ConcurrencyToken == authorization.ConcurrencyToken) == 0) + { + throw new OpenIddictExceptions.ConcurrencyException("The authorization was concurrently updated and cannot be persisted in its current state.\r\nReload the authorization from the database and retry the operation."); + } + + // Delete the tokens associated with the authorization. + database.GetCollection(Options.CurrentValue.TokensCollectionName) + .DeleteMany(token => token.AuthorizationId == authorization.Id); + } + + /// + public virtual IAsyncEnumerable FindAsync( + string subject, string client, 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 identifier cannot be null or empty.", nameof(client)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + var authorizations = collection.Query() + .Where(entity => + entity.Subject == subject && + entity.ApplicationId == new ObjectId(client)) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var authorization in authorizations) + { + yield return authorization; + } + } + } + + /// + public virtual IAsyncEnumerable FindAsync( + string subject, string client, + string status, 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 identifier cannot be null or empty.", nameof(client)); + } + + if (string.IsNullOrEmpty(status)) + { + throw new ArgumentException("The status cannot be null or empty.", nameof(status)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + var authorizations = collection.Query() + .Where(entity => + entity.Subject == subject && + entity.ApplicationId == new ObjectId(client) && + entity.Status == status) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var authorization in authorizations) + { + yield return authorization; + } + } + } + + /// + public virtual IAsyncEnumerable FindAsync( + string subject, string client, + string status, string type, 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 identifier cannot be null or empty.", nameof(client)); + } + + if (string.IsNullOrEmpty(status)) + { + throw new ArgumentException("The status cannot be null or empty.", nameof(status)); + } + + if (string.IsNullOrEmpty(type)) + { + throw new ArgumentException("The type cannot be null or empty.", nameof(type)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + var authorizations = collection.Query() + .Where(entity => + entity.Subject == subject && + entity.ApplicationId == new ObjectId(client) && + entity.Status == status && + entity.Type == type) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var authorization in authorizations) + { + yield return authorization; + } + } + } + + /// + public virtual IAsyncEnumerable FindAsync( + string subject, string client, + string status, string type, + ImmutableArray 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 identifier cannot be null or empty.", nameof(client)); + } + + if (string.IsNullOrEmpty(status)) + { + throw new ArgumentException("The status cannot be null or empty.", nameof(status)); + } + + if (string.IsNullOrEmpty(type)) + { + throw new ArgumentException("The type cannot be null or empty.", nameof(type)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + var authorizations = collection.Query() + .Where(entity => + entity.Subject == subject && + entity.ApplicationId == new ObjectId(client) && + entity.Status == status && + entity.Type == type && + Enumerable.All(scopes, scope => entity.Scopes.Contains(scope))) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var authorization in authorizations) + { + yield return authorization; + } + } + } + + /// + public virtual IAsyncEnumerable FindByApplicationIdAsync( + string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + var authorizations = collection.Query() + .Where(entity => entity.ApplicationId == new ObjectId(identifier)) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var authorization in authorizations) + { + yield return authorization; + } + } + } + + /// + public virtual async ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + return collection.FindById(identifier); + } + + /// + public virtual IAsyncEnumerable FindBySubjectAsync( + string subject, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(subject)) + { + throw new ArgumentException("The subject cannot be null or empty.", nameof(subject)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + var authorizations = collection.Query() + .Where(entity => entity.Subject == subject) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var authorization in authorizations) + { + yield return authorization; + } + } + } + + /// + public virtual ValueTask GetApplicationIdAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + if (authorization.ApplicationId == ObjectId.Empty) + { + return new(result: null); + } + + return new(authorization.ApplicationId.ToString()); + } + + /// + public virtual async ValueTask GetAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + return query(collection.FindAll().AsQueryable(), state).FirstOrDefault(); + } + + /// + public virtual ValueTask GetCreationDateAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + if (authorization.CreationDate is null) + { + return new(result: null); + } + + return new(DateTime.SpecifyKind(authorization.CreationDate.Value, DateTimeKind.Utc)); + } + + /// + public virtual ValueTask GetIdAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + return new(authorization.Id.ToString()); + } + + /// + public virtual ValueTask> GetPropertiesAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + if (authorization.Properties is null || authorization.Properties.Count == 0) + { + return new(ImmutableDictionary.Create()); + } + + return new(authorization.Properties.ToImmutableDictionary()); + } + + /// + public virtual ValueTask> GetScopesAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + if (authorization.Scopes is not { Count: > 0 }) + { + return new(ImmutableArray.Create()); + } + + return new(authorization.Scopes.ToImmutableArray()); + } + + /// + public virtual ValueTask GetStatusAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + return new(authorization.Status); + } + + /// + public virtual ValueTask GetSubjectAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + return new(authorization.Subject); + } + + /// + public virtual ValueTask GetTypeAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + return new(authorization.Type); + } + + /// + public virtual ValueTask InstantiateAsync(CancellationToken cancellationToken) + { + try + { + return new(Activator.CreateInstance()); + } + + catch (MemberAccessException exception) + { + return new(Task.FromException( + new InvalidOperationException("An error occurred while trying to create a new authorization instance.\r\nMake sure that the authorization entity is not abstract and has a public parameterless constructor or create a custom authorization store that overrides 'InstantiateAsync()' to use a custom factory.", exception))); + } + } + + /// + public virtual async IAsyncEnumerable ListAsync( + int? count, int? offset, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + var authorizations = collection + .Find(Query.All(), offset ?? 0, count ?? int.MaxValue) + .ToAsyncEnumerable(); + + await foreach (var authorization in authorizations) + { + yield return authorization; + } + } + + /// + public virtual IAsyncEnumerable ListAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + var authorizations = query(collection.FindAll().AsQueryable(), state).ToAsyncEnumerable(); + + await foreach (var authorization in authorizations) + { + yield return authorization; + } + } + } + + /// + public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + + //var database = await Context.GetDatabaseAsync(cancellationToken); + //var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + // Note: directly deleting the resulting set of an aggregate query is not supported by MongoDb + // To work around this limitation, the authorization identifiers are stored in an intermediate + // list and delete requests are sent to remove the documents corresponding to these identifiers. + + //var identifiers = + // await (from authorization in collection.AsQueryable() + // join token in database.GetCollection(Options.CurrentValue.TokensCollectionName).AsQueryable() + // on authorization.Id equals token.AuthorizationId into tokens + // where authorization.CreationDate < threshold.UtcDateTime + // where authorization.Status != Statuses.Valid || + // (authorization.Type == AuthorizationTypes.AdHoc && !tokens.Any()) + // select authorization.Id).ToListAsync(cancellationToken); + + // Note: to avoid generating delete requests with very large filters, a buffer is used here and the + // maximum number of elements that can be removed by a single call to PruneAsync() is deliberately limited. + //foreach (var buffer in Buffer(identifiers.Take(1_000_000), 1_000)) + //{ + // await collection.DeleteManyAsync(authorization => buffer.Contains(authorization.Id), cancellationToken); + //} + + //static IEnumerable> Buffer(IEnumerable source, int count) + //{ + // List? buffer = null; + + // foreach (var element in source) + // { + // buffer ??= new List(capacity: 1); + // buffer.Add(element); + + // if (buffer.Count == count) + // { + // yield return buffer; + + // buffer = null; + // } + // } + + // if (buffer is not null) + // { + // yield return buffer; + // } + //} + } + + /// + public virtual ValueTask SetApplicationIdAsync(TAuthorization authorization, + string? identifier, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + if (!string.IsNullOrEmpty(identifier)) + { + authorization.ApplicationId = new ObjectId(identifier); + } + + else + { + authorization.ApplicationId = ObjectId.Empty; + } + + return default; + } + + /// + public virtual ValueTask SetCreationDateAsync(TAuthorization authorization, + DateTimeOffset? date, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + authorization.CreationDate = date?.UtcDateTime; + + return default; + } + + /// + public virtual ValueTask SetPropertiesAsync(TAuthorization authorization, + ImmutableDictionary properties, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + if (properties is not { Count: > 0 }) + { + authorization.Properties = null; + + return default; + } + + authorization.Properties = properties; + + return default; + } + + /// + public virtual ValueTask SetScopesAsync(TAuthorization authorization, + ImmutableArray scopes, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + if (scopes.IsDefaultOrEmpty) + { + authorization.Scopes = null; + + return default; + } + + authorization.Scopes = scopes.ToImmutableList(); + + return default; + } + + /// + public virtual ValueTask SetStatusAsync(TAuthorization authorization, string? status, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + authorization.Status = status; + + return default; + } + + /// + public virtual ValueTask SetSubjectAsync(TAuthorization authorization, string? subject, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + authorization.Subject = subject; + + return default; + } + + /// + public virtual ValueTask SetTypeAsync(TAuthorization authorization, string? type, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + authorization.Type = type; + + return default; + } + + /// + public virtual async ValueTask UpdateAsync(TAuthorization authorization, CancellationToken cancellationToken) + { + if (authorization is null) + { + throw new ArgumentNullException(nameof(authorization)); + } + + // Generate a new concurrency token and attach it + // to the authorization before persisting the changes. + var timestamp = authorization.ConcurrencyToken; + authorization.ConcurrencyToken = Guid.NewGuid().ToString(); + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName); + + if (collection.Count(entity => entity.Id == authorization.Id && entity.ConcurrencyToken == timestamp) == 0) + { + throw new ConcurrencyException("The authorization was concurrently updated and cannot be persisted in its current state.\r\nReload the authorization from the database and retry the operation."); + } + + collection.Update(authorization); + } +} + diff --git a/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBScopeStore.cs b/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBScopeStore.cs new file mode 100644 index 0000000..c86cd81 --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBScopeStore.cs @@ -0,0 +1,517 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB; + +/// +/// Provides methods allowing to manage the scopes stored in a database. +/// +/// The type of the Scope entity. +public class OpenIddictLiteDBScopeStore : IOpenIddictScopeStore + where TScope : OpenIddictLiteDBScope +{ + public OpenIddictLiteDBScopeStore( + IOpenIddictLiteDBContext context, + IOptionsMonitor options) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Gets the database context associated with the current store. + /// + protected IOpenIddictLiteDBContext Context { get; } + + /// + /// Gets the options associated with the current store. + /// + protected IOptionsMonitor Options { get; } + + /// + public virtual async ValueTask CountAsync(CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + return collection.LongCount(); + } + + /// + public virtual async ValueTask CountAsync( + Func, IQueryable> query, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + return query(collection.FindAll().AsQueryable()).LongCount(); + } + + /// + public virtual async ValueTask CreateAsync(TScope scope, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + collection.Insert(scope); + } + + /// + public virtual async ValueTask DeleteAsync(TScope scope, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + if (collection.DeleteMany(entity => + entity.Id == scope.Id && + entity.ConcurrencyToken == scope.ConcurrencyToken) == 0) + { + throw new OpenIddictExceptions.ConcurrencyException("The scope was concurrently updated and cannot be persisted in its current state.\r\nReload the scope from the database and retry the operation."); + } + } + + /// + public virtual async ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + return collection.FindById(identifier); + } + + /// + public virtual async ValueTask FindByNameAsync(string name, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(name)) + { + throw new ArgumentException("The scope name cannot be null or empty.", nameof(name)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + return collection.Query() + .Where(entity => entity.Name == name) + .FirstOrDefault(); + } + + /// + public virtual IAsyncEnumerable FindByNamesAsync(ImmutableArray names, CancellationToken cancellationToken) + { + if (names.Any(name => string.IsNullOrEmpty(name))) + { + throw new ArgumentException("Scope names cannot be null or empty.", nameof(names)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + var scopes = collection.Query() + .Where(entity => Enumerable.Contains(names, entity.Name)) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var scope in scopes) + { + yield return scope; + } + } + } + + /// + public virtual IAsyncEnumerable FindByResourceAsync(string resource, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(resource)) + { + throw new ArgumentException("The resource cannot be null or empty.", nameof(resource)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + var scopes = collection.Query() + .Where(entity => entity.Resources.Contains(resource)) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var scope in scopes) + { + yield return scope; + } + } + } + + /// + public virtual async ValueTask GetAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + return query(collection.FindAll().AsQueryable(), state).FirstOrDefault(); + } + + /// + public virtual ValueTask GetDescriptionAsync(TScope scope, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + return new(scope.Description); + } + + /// + public virtual ValueTask> GetDescriptionsAsync(TScope scope, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + if (scope.Descriptions is not { Count: > 0 }) + { + return new(ImmutableDictionary.Create()); + } + + return new(scope.Descriptions.ToImmutableDictionary( + pair => CultureInfo.GetCultureInfo(pair.Key), + pair => pair.Value)); + } + + /// + public virtual ValueTask GetDisplayNameAsync(TScope scope, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + return new(scope.DisplayName); + } + + /// + public virtual ValueTask> GetDisplayNamesAsync(TScope scope, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + if (scope.DisplayNames is not { Count: > 0 }) + { + return new(ImmutableDictionary.Create()); + } + + return new(scope.DisplayNames.ToImmutableDictionary( + pair => CultureInfo.GetCultureInfo(pair.Key), + pair => pair.Value)); + } + + /// + public virtual ValueTask GetIdAsync(TScope scope, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + return new(scope.Id.ToString()); + } + + /// + public virtual ValueTask GetNameAsync(TScope scope, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + return new(scope.Name); + } + + /// + public virtual ValueTask> GetPropertiesAsync(TScope scope, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + if (scope.Properties is null || scope.Properties.Count == 0) + { + return new(ImmutableDictionary.Create()); + } + + return new(scope.Properties.ToImmutableDictionary()); + } + + /// + public virtual ValueTask> GetResourcesAsync(TScope scope, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + if (scope.Resources is not { Count: > 0 }) + { + return new(ImmutableArray.Create()); + } + + return new(scope.Resources.ToImmutableArray()); + } + + /// + public virtual ValueTask InstantiateAsync(CancellationToken cancellationToken) + { + try + { + return new(Activator.CreateInstance()); + } + + catch (MemberAccessException exception) + { + return new(Task.FromException( + new InvalidOperationException("An error occurred while trying to create a new scope instance.\r\nMake sure that the scope entity is not abstract and has a public parameterless constructor or create a custom scope store that overrides 'InstantiateAsync()' to use a custom factory.", exception))); + } + } + + /// + public virtual async IAsyncEnumerable ListAsync( + int? count, int? offset, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + var scopes = collection + .Find(Query.All(), offset ?? 0, count ?? int.MaxValue) + .ToAsyncEnumerable(); + + await foreach (var scope in scopes) + { + yield return scope; + } + } + + /// + public virtual IAsyncEnumerable ListAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + var scopes = query(collection.FindAll().AsQueryable(), state).ToAsyncEnumerable(); + + await foreach (var scope in scopes) + { + yield return scope; + } + } + } + + /// + public virtual ValueTask SetDescriptionAsync(TScope scope, string? description, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + scope.Description = description; + + return default; + } + + /// + public virtual ValueTask SetDescriptionsAsync(TScope scope, + ImmutableDictionary descriptions, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + if (descriptions is not { Count: > 0 }) + { + scope.Descriptions = null; + + return default; + } + + scope.Descriptions = descriptions.ToImmutableDictionary( + pair => pair.Key.Name, + pair => pair.Value); + + return default; + } + + /// + public virtual ValueTask SetDisplayNamesAsync(TScope scope, + ImmutableDictionary names, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + if (names is not { Count: > 0 }) + { + scope.DisplayNames = null; + + return default; + } + + scope.DisplayNames = names.ToImmutableDictionary( + pair => pair.Key.Name, + pair => pair.Value); + + return default; + } + + /// + public virtual ValueTask SetDisplayNameAsync(TScope scope, string? name, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + scope.DisplayName = name; + + return default; + } + + /// + public virtual ValueTask SetNameAsync(TScope scope, string? name, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + scope.Name = name; + + return default; + } + + /// + public virtual ValueTask SetPropertiesAsync(TScope scope, + ImmutableDictionary properties, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + if (properties is not { Count: > 0 }) + { + scope.Properties = null; + + return default; + } + + scope.Properties = properties; + + return default; + } + + /// + public virtual ValueTask SetResourcesAsync(TScope scope, ImmutableArray resources, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + if (resources.IsDefaultOrEmpty) + { + scope.Resources = null; + + return default; + } + + scope.Resources = resources.ToImmutableList(); + + return default; + } + + /// + public virtual async ValueTask UpdateAsync(TScope scope, CancellationToken cancellationToken) + { + if (scope is null) + { + throw new ArgumentNullException(nameof(scope)); + } + + // Generate a new concurrency token and attach it + // to the scope before persisting the changes. + var timestamp = scope.ConcurrencyToken; + scope.ConcurrencyToken = Guid.NewGuid().ToString(); + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.ScopesCollectionName); + + if (collection.Count(entity => entity.Id == scope.Id && entity.ConcurrencyToken == timestamp) == 0) + { + throw new ConcurrencyException("The scope was concurrently updated and cannot be persisted in its current state.\r\nReload the scope from the database and retry the operation."); + } + + collection.Update(scope); + } +} diff --git a/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBTokenStore.cs b/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBTokenStore.cs new file mode 100644 index 0000000..5410c2c --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/Stores/OpenIddictLiteDBTokenStore.cs @@ -0,0 +1,809 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB; + +/// +/// Provides methods allowing to manage the tokens stored in a database. +/// +/// The type of the Token entity. +public class OpenIddictLiteDBTokenStore : IOpenIddictTokenStore + where TToken : OpenIddictLiteDBToken +{ + public OpenIddictLiteDBTokenStore( + IOpenIddictLiteDBContext context, + IOptionsMonitor options) + { + Context = context ?? throw new ArgumentNullException(nameof(context)); + Options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + /// Gets the database context associated with the current store. + /// + protected IOpenIddictLiteDBContext Context { get; } + + /// + /// Gets the options associated with the current store. + /// + protected IOptionsMonitor Options { get; } + + /// + public virtual async ValueTask CountAsync(CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + return collection.LongCount(); + } + + /// + public virtual async ValueTask CountAsync( + Func, IQueryable> query, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + return query(collection.FindAll().AsQueryable()).LongCount(); + } + + /// + public virtual async ValueTask CreateAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + collection.Insert(token); + } + + /// + public virtual async ValueTask DeleteAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + if (collection.DeleteMany(entity => + entity.Id == token.Id && + entity.ConcurrencyToken == token.ConcurrencyToken) == 0) + { + throw new ConcurrencyException("The token was concurrently updated and cannot be persisted in its current state.\r\nReload the token from the database and retry the operation."); + } + } + + /// + public virtual IAsyncEnumerable FindAsync(string subject, + string client, 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 identifier cannot be null or empty.", nameof(client)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + var tokens = collection.Query() + .Where(entity => + entity.ApplicationId == new ObjectId(client) && + entity.Subject == subject) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var token in tokens) + { + yield return token; + } + } + } + + /// + public virtual IAsyncEnumerable FindAsync( + string subject, string client, + string status, 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 identifier cannot be null or empty.", nameof(client)); + } + + if (string.IsNullOrEmpty(status)) + { + throw new ArgumentException("The status cannot be null or empty.", nameof(status)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + var tokens = collection.Query() + .Where(entity => + entity.ApplicationId == new ObjectId(client) && + entity.Subject == subject && + entity.Status == status) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var token in tokens) + { + yield return token; + } + } + } + + /// + public virtual IAsyncEnumerable FindAsync( + string subject, string client, + string status, string type, 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 identifier cannot be null or empty.", nameof(client)); + } + + if (string.IsNullOrEmpty(status)) + { + throw new ArgumentException("The status cannot be null or empty.", nameof(status)); + } + + if (string.IsNullOrEmpty(type)) + { + throw new ArgumentException("The type cannot be null or empty.", nameof(type)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + var tokens = collection.Query() + .Where(entity => + entity.ApplicationId == new ObjectId(client) && + entity.Subject == subject && + entity.Status == status && + entity.Type == type) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var token in tokens) + { + yield return token; + } + } + } + + /// + public virtual IAsyncEnumerable FindByApplicationIdAsync(string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + var tokens = collection.Query() + .Where(entity => entity.ApplicationId == new ObjectId(identifier)) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var token in tokens) + { + yield return token; + } + } + } + + /// + public virtual IAsyncEnumerable FindByAuthorizationIdAsync(string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + var tokens = collection.Query() + .Where(entity => entity.AuthorizationId == new ObjectId(identifier)) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var token in tokens) + { + yield return token; + } + } + } + + /// + public virtual async ValueTask FindByIdAsync(string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + return collection.FindById(identifier); + } + + /// + public virtual async ValueTask FindByReferenceIdAsync(string identifier, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(identifier)) + { + throw new ArgumentException("The identifier cannot be null or empty.", nameof(identifier)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + return collection.Query() + .Where(entity => entity.ReferenceId == identifier) + .FirstOrDefault(); + } + + /// + public virtual IAsyncEnumerable FindBySubjectAsync(string subject, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(subject)) + { + throw new ArgumentException("The subject cannot be null or empty.", nameof(subject)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + var tokens = collection.Query() + .Where(entity => entity.Subject == subject) + .ToEnumerable().ToAsyncEnumerable(); + + await foreach (var token in tokens) + { + yield return token; + } + } + } + + /// + public virtual ValueTask GetApplicationIdAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (token.ApplicationId == ObjectId.Empty) + { + return new(result: null); + } + + return new(token.ApplicationId.ToString()); + } + + /// + public virtual async ValueTask GetAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + return query(collection.FindAll().AsQueryable(), state).FirstOrDefault(); + } + + /// + public virtual ValueTask GetAuthorizationIdAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (token.AuthorizationId == ObjectId.Empty) + { + return new(result: null); + } + + return new(token.AuthorizationId.ToString()); + } + + /// + public virtual ValueTask GetCreationDateAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (token.CreationDate is null) + { + return new(result: null); + } + + return new(DateTime.SpecifyKind(token.CreationDate.Value, DateTimeKind.Utc)); + } + + /// + public virtual ValueTask GetExpirationDateAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (token.ExpirationDate is null) + { + return new(result: null); + } + + return new(DateTime.SpecifyKind(token.ExpirationDate.Value, DateTimeKind.Utc)); + } + + /// + public virtual ValueTask GetIdAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + return new(token.Id.ToString()); + } + + /// + public virtual ValueTask GetPayloadAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + return new(token.Payload); + } + + /// + public virtual ValueTask> GetPropertiesAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (token.Properties is null || token.Properties.Count == 0) + { + return new(ImmutableDictionary.Create()); + } + + return new(token.Properties.ToImmutableDictionary()); + } + + /// + public virtual ValueTask GetRedemptionDateAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (token.RedemptionDate is null) + { + return new(result: null); + } + + return new(DateTime.SpecifyKind(token.RedemptionDate.Value, DateTimeKind.Utc)); + } + + /// + public virtual ValueTask GetReferenceIdAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + return new(token.ReferenceId); + } + + /// + public virtual ValueTask GetStatusAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + return new(token.Status); + } + + /// + public virtual ValueTask GetSubjectAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + return new(token.Subject); + } + + /// + public virtual ValueTask GetTypeAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + return new(token.Type); + } + + /// + public virtual ValueTask InstantiateAsync(CancellationToken cancellationToken) + { + try + { + return new(Activator.CreateInstance()); + } + + catch (MemberAccessException exception) + { + return new(Task.FromException( + new InvalidOperationException("An error occurred while trying to create a new token instance.\r\nMake sure that the token entity is not abstract and has a public parameterless constructor or create a custom token store that overrides 'InstantiateAsync()' to use a custom factory.", exception))); + } + } + + /// + public virtual async IAsyncEnumerable ListAsync( + int? count, int? offset, [EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + var tokens = collection + .Find(Query.All(), offset ?? 0, count ?? int.MaxValue) + .ToAsyncEnumerable(); + + await foreach (var token in tokens) + { + yield return token; + } + } + + /// + public virtual IAsyncEnumerable ListAsync( + Func, TState, IQueryable> query, + TState state, CancellationToken cancellationToken) + { + if (query is null) + { + throw new ArgumentNullException(nameof(query)); + } + + return ExecuteAsync(cancellationToken); + + async IAsyncEnumerable ExecuteAsync([EnumeratorCancellation] CancellationToken cancellationToken) + { + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + var tokens = query(collection.FindAll().AsQueryable(), state).ToAsyncEnumerable(); + + await foreach (var token in tokens) + { + yield return token; + } + } + } + + /// + public virtual async ValueTask PruneAsync(DateTimeOffset threshold, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + + //var database = await Context.GetDatabaseAsync(cancellationToken); + //var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + //// Note: directly deleting the resulting set of an aggregate query is not supported by MongoDb. + //// To work around this limitation, the token identifiers are stored in an intermediate list + //// and delete requests are sent to remove the documents corresponding to these identifiers. + + //var identifiers = + // await (from token in collection.AsQueryable() + // join authorization in database.GetCollection(Options.CurrentValue.AuthorizationsCollectionName).AsQueryable() + // on token.AuthorizationId equals authorization.Id into authorizations + // where token.CreationDate < threshold.UtcDateTime + // where (token.Status != Statuses.Inactive && token.Status != Statuses.Valid) || + // token.ExpirationDate < DateTime.UtcNow || + // authorizations.Any(authorization => authorization.Status != Statuses.Valid) + // select token.Id).ToListAsync(cancellationToken); + + //// Note: to avoid generating delete requests with very large filters, a buffer is used here and the + //// maximum number of elements that can be removed by a single call to PruneAsync() is deliberately limited. + //foreach (var buffer in Buffer(identifiers.Take(1_000_000), 1_000)) + //{ + // await collection.DeleteManyAsync(token => buffer.Contains(token.Id), cancellationToken); + //} + + //static IEnumerable> Buffer(IEnumerable source, int count) + //{ + // List? buffer = null; + + // foreach (var element in source) + // { + // buffer ??= new List(capacity: 1); + // buffer.Add(element); + + // if (buffer.Count == count) + // { + // yield return buffer; + + // buffer = null; + // } + // } + + // if (buffer is not null) + // { + // yield return buffer; + // } + //} + } + + /// + public virtual ValueTask SetApplicationIdAsync(TToken token, string? identifier, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (!string.IsNullOrEmpty(identifier)) + { + token.ApplicationId = new ObjectId(identifier); + } + + else + { + token.ApplicationId = ObjectId.Empty; + } + + return default; + } + + /// + public virtual ValueTask SetAuthorizationIdAsync(TToken token, string? identifier, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (!string.IsNullOrEmpty(identifier)) + { + token.AuthorizationId = new ObjectId(identifier); + } + + else + { + token.AuthorizationId = ObjectId.Empty; + } + + return default; + } + + /// + public virtual ValueTask SetCreationDateAsync(TToken token, DateTimeOffset? date, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + token.CreationDate = date?.UtcDateTime; + + return default; + } + + /// + public virtual ValueTask SetExpirationDateAsync(TToken token, DateTimeOffset? date, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + token.ExpirationDate = date?.UtcDateTime; + + return default; + } + + /// + public virtual ValueTask SetPayloadAsync(TToken token, string? payload, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + token.Payload = payload; + + return default; + } + + /// + public virtual ValueTask SetPropertiesAsync(TToken token, + ImmutableDictionary properties, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + if (properties is not { Count: > 0 }) + { + token.Properties = null; + + return default; + } + + token.Properties = properties; + + return default; + } + + /// + public virtual ValueTask SetRedemptionDateAsync(TToken token, DateTimeOffset? date, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + token.RedemptionDate = date?.UtcDateTime; + + return default; + } + + /// + public virtual ValueTask SetReferenceIdAsync(TToken token, string? identifier, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + token.ReferenceId = identifier; + + return default; + } + + /// + public virtual ValueTask SetStatusAsync(TToken token, string? status, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + token.Status = status; + + return default; + } + + /// + public virtual ValueTask SetSubjectAsync(TToken token, string? subject, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + token.Subject = subject; + + return default; + } + + /// + public virtual ValueTask SetTypeAsync(TToken token, string? type, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + token.Type = type; + + return default; + } + + /// + public virtual async ValueTask UpdateAsync(TToken token, CancellationToken cancellationToken) + { + if (token is null) + { + throw new ArgumentNullException(nameof(token)); + } + + // Generate a new concurrency token and attach it + // to the token before persisting the changes. + var timestamp = token.ConcurrencyToken; + token.ConcurrencyToken = Guid.NewGuid().ToString(); + + var database = await Context.GetDatabaseAsync(cancellationToken); + var collection = database.GetCollection(Options.CurrentValue.TokensCollectionName); + + if (collection.Count(entity => entity.Id == token.Id && entity.ConcurrencyToken == timestamp) == 0) + { + throw new ConcurrencyException("The token was concurrently updated and cannot be persisted in its current state.\r\nReload the token from the database and retry the operation."); + } + + collection.Update(token); + } +} diff --git a/src/Sknet.OpenIddict.LiteDB/Usings.cs b/src/Sknet.OpenIddict.LiteDB/Usings.cs new file mode 100644 index 0000000..a241bde --- /dev/null +++ b/src/Sknet.OpenIddict.LiteDB/Usings.cs @@ -0,0 +1,15 @@ +global using LiteDB; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.DependencyInjection.Extensions; +global using Microsoft.Extensions.Options; +global using OpenIddict.Abstractions; +global using OpenIddict.Core; +global using Sknet.OpenIddict.LiteDB; +global using Sknet.OpenIddict.LiteDB.Models; +global using System.Collections.Concurrent; +global using System.Collections.Immutable; +global using System.ComponentModel; +global using System.Globalization; +global using System.Runtime.CompilerServices; +global using System.Text.Json; +global using static OpenIddict.Abstractions.OpenIddictExceptions; diff --git a/test/Sknet.OpenIddict.LiteDB.Tests/OpenIddictLiteDBBuilderTests.cs b/test/Sknet.OpenIddict.LiteDB.Tests/OpenIddictLiteDBBuilderTests.cs new file mode 100644 index 0000000..f76aea1 --- /dev/null +++ b/test/Sknet.OpenIddict.LiteDB.Tests/OpenIddictLiteDBBuilderTests.cs @@ -0,0 +1,281 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB.Tests; + +public class OpenIddictLiteDBBuilderTests +{ + [Fact] + public void Constructor_ThrowsAnExceptionForNullServices() + { + // Arrange + var services = (IServiceCollection)null!; + + // Act and assert + var exception = Assert.Throws(() => new OpenIddictLiteDBBuilder(services)); + + Assert.Equal("services", exception.ParamName); + } + + [Fact] + public void ReplaceDefaultApplicationEntity_EntityIsCorrectlySet() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.ReplaceDefaultApplicationEntity(); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal(typeof(CustomApplication), options.DefaultApplicationType); + } + + [Fact] + public void ReplaceDefaultAuthorizationEntity_EntityIsCorrectlySet() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.ReplaceDefaultAuthorizationEntity(); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal(typeof(CustomAuthorization), options.DefaultAuthorizationType); + } + + [Fact] + public void ReplaceDefaultScopeEntity_EntityIsCorrectlySet() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.ReplaceDefaultScopeEntity(); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal(typeof(CustomScope), options.DefaultScopeType); + } + + [Fact] + public void ReplaceDefaultTokenEntity_EntityIsCorrectlySet() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.ReplaceDefaultTokenEntity(); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal(typeof(CustomToken), options.DefaultTokenType); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void SetApplicationsCollectionName_ThrowsAnExceptionForNullOrEmptyCollectionName(string name) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetApplicationsCollectionName(name)); + + Assert.Equal("name", exception.ParamName); + Assert.StartsWith("The collection name cannot be null or empty.", exception.Message); + } + + [Fact] + public void SetApplicationsCollectionName_CollectionNameIsCorrectlySet() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetApplicationsCollectionName("custom_collection"); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal("custom_collection", options.ApplicationsCollectionName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void SetAuthorizationsCollectionName_ThrowsAnExceptionForNullOrEmptyCollectionName(string name) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetAuthorizationsCollectionName(name)); + + Assert.Equal("name", exception.ParamName); + Assert.StartsWith("The collection name cannot be null or empty.", exception.Message); + } + + [Fact] + public void SetAuthorizationsCollectionName_CollectionNameIsCorrectlySet() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetAuthorizationsCollectionName("custom_collection"); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal("custom_collection", options.AuthorizationsCollectionName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void SetScopesCollectionName_ThrowsAnExceptionForNullOrEmptyCollectionName(string name) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetScopesCollectionName(name)); + + Assert.Equal("name", exception.ParamName); + Assert.StartsWith("The collection name cannot be null or empty.", exception.Message); + } + + [Fact] + public void SetScopesCollectionName_CollectionNameIsCorrectlySet() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetScopesCollectionName("custom_collection"); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal("custom_collection", options.ScopesCollectionName); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void SetTokensCollectionName_ThrowsAnExceptionForNullOrEmptyCollectionName(string name) + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.SetTokensCollectionName(name)); + + Assert.Equal("name", exception.ParamName); + Assert.StartsWith("The collection name cannot be null or empty.", exception.Message); + } + + [Fact] + public void SetTokensCollectionName_CollectionNameIsCorrectlySet() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act + builder.SetTokensCollectionName("custom_collection"); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal("custom_collection", options.TokensCollectionName); + } + + [Fact] + public void UseDatabase_ThrowsAnExceptionForNullDatabase() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + + // Act and assert + var exception = Assert.Throws(delegate + { + return builder.UseDatabase(database: null!); + }); + + Assert.Equal("database", exception.ParamName); + } + + [Fact] + public void UseDatabase_SetsDatabaseInOptions() + { + // Arrange + var services = CreateServices(); + var builder = CreateBuilder(services); + var database = Mock.Of(); + + // Act + builder.UseDatabase(database); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal(database, options.Database); + } + + private static OpenIddictLiteDBBuilder CreateBuilder(IServiceCollection services) + => services.AddOpenIddict().AddCore().UseLiteDB(); + + private static IServiceCollection CreateServices() + { + var services = new ServiceCollection(); + services.AddOptions(); + + return services; + } + + public class CustomApplication : OpenIddictLiteDBApplication { } + public class CustomAuthorization : OpenIddictLiteDBAuthorization { } + public class CustomScope : OpenIddictLiteDBScope { } + public class CustomToken : OpenIddictLiteDBToken { } +} \ No newline at end of file diff --git a/test/Sknet.OpenIddict.LiteDB.Tests/OpenIddictLiteDBContextTests.cs b/test/Sknet.OpenIddict.LiteDB.Tests/OpenIddictLiteDBContextTests.cs new file mode 100644 index 0000000..a23b919 --- /dev/null +++ b/test/Sknet.OpenIddict.LiteDB.Tests/OpenIddictLiteDBContextTests.cs @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB.Tests; + +public class OpenIddictLiteDBContextTests +{ + [Fact] + public async Task GetDatabaseAsync_ThrowsAnExceptionForCanceledToken() + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + + var options = Mock.Of>(); + var token = new CancellationToken(canceled: true); + + var context = new OpenIddictLiteDBContext(options, provider); + + // Act and assert + var exception = await Assert.ThrowsAsync(async delegate + { + await context.GetDatabaseAsync(token); + }); + + Assert.Equal(token, exception.CancellationToken); + } + + [Fact] + public async Task GetDatabaseAsync_PrefersDatabaseRegisteredInOptionsToDatabaseRegisteredInDependencyInjectionContainer() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of()); + + var provider = services.BuildServiceProvider(); + + var database = Mock.Of(); + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictLiteDBOptions + { + Database = database + }); + + var context = new OpenIddictLiteDBContext(options, provider); + + // Act and assert + Assert.Same(database, await context.GetDatabaseAsync(CancellationToken.None)); + } + + [Fact] + public async Task GetDatabaseAsync_ThrowsAnExceptionWhenDatabaseCannotBeFound() + { + // Arrange + var services = new ServiceCollection(); + var provider = services.BuildServiceProvider(); + + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictLiteDBOptions + { + Database = null + }); + + var context = new OpenIddictLiteDBContext(options, provider); + + // Act and assert + var exception = await Assert.ThrowsAsync(async delegate + { + await context.GetDatabaseAsync(CancellationToken.None); + }); + + Assert.Equal("No suitable LiteDB database service can be found.\r\nTo configure the OpenIddict LiteDB stores to use a specific database, use 'services.AddOpenIddict().AddCore().UseLiteDB().UseDatabase()' or register an 'ILiteDatabase' in the dependency injection container in 'ConfigureServices()'.", exception.Message); + } + + [Fact] + public async Task GetDatabaseAsync_UsesDatabaseRegisteredInDependencyInjectionContainer() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of()); + + var database = Mock.Of(); + services.AddSingleton(database); + + var provider = services.BuildServiceProvider(); + + var options = Mock.Of>( + mock => mock.CurrentValue == new OpenIddictLiteDBOptions + { + Database = null + }); + + var context = new OpenIddictLiteDBContext(options, provider); + + // Act and assert + Assert.Same(database, await context.GetDatabaseAsync(CancellationToken.None)); + } +} \ No newline at end of file diff --git a/test/Sknet.OpenIddict.LiteDB.Tests/OpenIddictLiteDBExtensionsTests.cs b/test/Sknet.OpenIddict.LiteDB.Tests/OpenIddictLiteDBExtensionsTests.cs new file mode 100644 index 0000000..9131ff9 --- /dev/null +++ b/test/Sknet.OpenIddict.LiteDB.Tests/OpenIddictLiteDBExtensionsTests.cs @@ -0,0 +1,117 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB.Tests; + +public class OpenIddictLiteDBExtensionsTests +{ + [Fact] + public void UseLiteDB_ThrowsAnExceptionForNullBuilder() + { + // Arrange + var builder = (OpenIddictCoreBuilder)null!; + + // Act and assert + var exception = Assert.Throws(builder.UseLiteDB); + + Assert.Equal("builder", exception.ParamName); + } + + [Fact] + public void UseLiteDB_ThrowsAnExceptionForNullConfiguration() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenIddictCoreBuilder(services); + + // Act and assert + var exception = Assert.Throws(() => builder.UseLiteDB(configuration: null!)); + + Assert.Equal("configuration", exception.ParamName); + } + + [Fact] + public void UseLiteDB_RegistersDefaultEntities() + { + // Arrange + var services = new ServiceCollection().AddOptions(); + var builder = new OpenIddictCoreBuilder(services); + + // Act + builder.UseLiteDB(); + + // Assert + var provider = services.BuildServiceProvider(); + var options = provider.GetRequiredService>().CurrentValue; + + Assert.Equal(typeof(OpenIddictLiteDBApplication), options.DefaultApplicationType); + Assert.Equal(typeof(OpenIddictLiteDBAuthorization), options.DefaultAuthorizationType); + Assert.Equal(typeof(OpenIddictLiteDBScope), options.DefaultScopeType); + Assert.Equal(typeof(OpenIddictLiteDBToken), options.DefaultTokenType); + } + + [Theory] + [InlineData(typeof(IOpenIddictApplicationStoreResolver), typeof(OpenIddictLiteDBApplicationStoreResolver))] + [InlineData(typeof(IOpenIddictAuthorizationStoreResolver), typeof(OpenIddictLiteDBAuthorizationStoreResolver))] + [InlineData(typeof(IOpenIddictScopeStoreResolver), typeof(OpenIddictLiteDBScopeStoreResolver))] + [InlineData(typeof(IOpenIddictTokenStoreResolver), typeof(OpenIddictLiteDBTokenStoreResolver))] + public void UseLiteDB_RegistersLiteDBStoreResolvers(Type serviceType, Type implementationType) + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenIddictCoreBuilder(services); + + // Act + builder.UseLiteDB(); + + // Assert + Assert.Contains(services, service => service.ServiceType == serviceType && + service.ImplementationType == implementationType); + } + + [Theory] + [InlineData(typeof(OpenIddictLiteDBApplicationStore<>))] + [InlineData(typeof(OpenIddictLiteDBAuthorizationStore<>))] + [InlineData(typeof(OpenIddictLiteDBScopeStore<>))] + [InlineData(typeof(OpenIddictLiteDBTokenStore<>))] + public void UseLiteDB_RegistersLiteDBStore(Type type) + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenIddictCoreBuilder(services); + + // Act + builder.UseLiteDB(); + + // Assert + Assert.Contains(services, service => service.ServiceType == type && service.ImplementationType == type); + } + + [Fact] + public void UseLiteDB_RegistersLiteDBContext() + { + // Arrange + var services = new ServiceCollection(); + var builder = new OpenIddictCoreBuilder(services); + + // Act + builder.UseLiteDB(); + + // Assert + Assert.Contains(services, service => service.Lifetime == ServiceLifetime.Singleton && + service.ServiceType == typeof(IOpenIddictLiteDBContext) && + service.ImplementationType == typeof(OpenIddictLiteDBContext)); + } +} \ No newline at end of file diff --git a/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBApplicationStoreResolverTests.cs b/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBApplicationStoreResolverTests.cs new file mode 100644 index 0000000..10517f9 --- /dev/null +++ b/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBApplicationStoreResolverTests.cs @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB.Tests; + +public class OpenIddictLiteDBApplicationStoreResolverTests +{ + [Fact] + public void Get_ReturnsCustomStoreCorrespondingToTheSpecifiedTypeWhenAvailable() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of>()); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBApplicationStoreResolver(provider); + + // Act and assert + Assert.NotNull(resolver.Get()); + } + + [Fact] + public void Get_ThrowsAnExceptionForInvalidEntityType() + { + // Arrange + var services = new ServiceCollection(); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBApplicationStoreResolver(provider); + + // Act and assert + var exception = Assert.Throws(resolver.Get); + + Assert.Equal("The specified application type is not compatible with the LiteDB stores.\r\nWhen enabling the LiteDB stores, make sure you use the built-in 'OpenIddictLiteDBApplication' entity or a custom entity that inherits from the 'OpenIddictLiteDBApplication' entity.", exception.Message); + } + + [Fact] + public void Get_ReturnsDefaultStoreCorrespondingToTheSpecifiedTypeWhenAvailable() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of>()); + services.AddSingleton(CreateStore()); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBApplicationStoreResolver(provider); + + // Act and assert + Assert.NotNull(resolver.Get()); + } + + private static OpenIddictLiteDBApplicationStore CreateStore() + => new Mock>( + Mock.Of(), + Mock.Of>()).Object; + + public class CustomApplication { } + + public class MyApplication : OpenIddictLiteDBApplication { } +} diff --git a/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBAuthorizationStoreResolverTests.cs b/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBAuthorizationStoreResolverTests.cs new file mode 100644 index 0000000..d182976 --- /dev/null +++ b/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBAuthorizationStoreResolverTests.cs @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB.Tests; + +public class OpenIddictLiteDBAuthorizationStoreResolverTests +{ + [Fact] + public void Get_ReturnsCustomStoreCorrespondingToTheSpecifiedTypeWhenAvailable() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of>()); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBAuthorizationStoreResolver(provider); + + // Act and assert + Assert.NotNull(resolver.Get()); + } + + [Fact] + public void Get_ThrowsAnExceptionForInvalidEntityType() + { + // Arrange + var services = new ServiceCollection(); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBAuthorizationStoreResolver(provider); + + // Act and assert + var exception = Assert.Throws(resolver.Get); + + Assert.Equal("The specified authorization type is not compatible with the LiteDB stores.\r\nWhen enabling the LiteDB stores, make sure you use the built-in 'OpenIddictLiteDBAuthorization' entity or a custom entity that inherits from the 'OpenIddictLiteDBAuthorization' entity.", exception.Message); + } + + [Fact] + public void Get_ReturnsDefaultStoreCorrespondingToTheSpecifiedTypeWhenAvailable() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of>()); + services.AddSingleton(CreateStore()); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBAuthorizationStoreResolver(provider); + + // Act and assert + Assert.NotNull(resolver.Get()); + } + + private static OpenIddictLiteDBAuthorizationStore CreateStore() + => new Mock>( + Mock.Of(), + Mock.Of>()).Object; + + public class CustomAuthorization { } + + public class MyAuthorization : OpenIddictLiteDBAuthorization { } +} \ No newline at end of file diff --git a/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBScopeStoreResolverTests.cs b/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBScopeStoreResolverTests.cs new file mode 100644 index 0000000..892afd9 --- /dev/null +++ b/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBScopeStoreResolverTests.cs @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB.Tests; + +public class OpenIddictLiteDBScopeStoreResolverTests +{ + [Fact] + public void Get_ReturnsCustomStoreCorrespondingToTheSpecifiedTypeWhenAvailable() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of>()); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBScopeStoreResolver(provider); + + // Act and assert + Assert.NotNull(resolver.Get()); + } + + [Fact] + public void Get_ThrowsAnExceptionForInvalidEntityType() + { + // Arrange + var services = new ServiceCollection(); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBScopeStoreResolver(provider); + + // Act and assert + var exception = Assert.Throws(resolver.Get); + + Assert.Equal("The specified scope type is not compatible with the LiteDB stores.\r\nWhen enabling the LiteDB stores, make sure you use the built-in 'OpenIddictLiteDBScope' entity or a custom entity that inherits from the 'OpenIddictLiteDBScope' entity.", exception.Message); + } + + [Fact] + public void Get_ReturnsDefaultStoreCorrespondingToTheSpecifiedTypeWhenAvailable() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of>()); + services.AddSingleton(CreateStore()); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBScopeStoreResolver(provider); + + // Act and assert + Assert.NotNull(resolver.Get()); + } + + private static OpenIddictLiteDBScopeStore CreateStore() + => new Mock>( + Mock.Of(), + Mock.Of>()).Object; + + public class CustomScope { } + + public class MyScope : OpenIddictLiteDBScope { } +} \ No newline at end of file diff --git a/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBTokenStoreResolverTests.cs b/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBTokenStoreResolverTests.cs new file mode 100644 index 0000000..c420e1e --- /dev/null +++ b/test/Sknet.OpenIddict.LiteDB.Tests/Resolvers/OpenIddictLiteDBTokenStoreResolverTests.cs @@ -0,0 +1,72 @@ +/* + * Copyright (c) 2022 Steven Kuhn and contributors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +namespace Sknet.OpenIddict.LiteDB.Tests; + +public class OpenIddictLiteDBTokenStoreResolverTests +{ + [Fact] + public void Get_ReturnsCustomStoreCorrespondingToTheSpecifiedTypeWhenAvailable() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of>()); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBTokenStoreResolver(provider); + + // Act and assert + Assert.NotNull(resolver.Get()); + } + + [Fact] + public void Get_ThrowsAnExceptionForInvalidEntityType() + { + // Arrange + var services = new ServiceCollection(); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBTokenStoreResolver(provider); + + // Act and assert + var exception = Assert.Throws(resolver.Get); + + Assert.Equal("The specified token type is not compatible with the LiteDB stores.\r\nWhen enabling the LiteDB stores, make sure you use the built-in 'OpenIddictLiteDBToken' entity or a custom entity that inherits from the 'OpenIddictLiteDBToken' entity.", exception.Message); + } + + [Fact] + public void Get_ReturnsDefaultStoreCorrespondingToTheSpecifiedTypeWhenAvailable() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton(Mock.Of>()); + services.AddSingleton(CreateStore()); + + var provider = services.BuildServiceProvider(); + var resolver = new OpenIddictLiteDBTokenStoreResolver(provider); + + // Act and assert + Assert.NotNull(resolver.Get()); + } + + private static OpenIddictLiteDBTokenStore CreateStore() + => new Mock>( + Mock.Of(), + Mock.Of>()).Object; + + public class CustomToken { } + + public class MyToken : OpenIddictLiteDBToken { } +} \ No newline at end of file diff --git a/test/Sknet.OpenIddict.LiteDB.Tests/Sknet.OpenIddict.LiteDB.Tests.csproj b/test/Sknet.OpenIddict.LiteDB.Tests/Sknet.OpenIddict.LiteDB.Tests.csproj new file mode 100644 index 0000000..a0a75a7 --- /dev/null +++ b/test/Sknet.OpenIddict.LiteDB.Tests/Sknet.OpenIddict.LiteDB.Tests.csproj @@ -0,0 +1,31 @@ + + + + net462;net472;net48;netcoreapp3.1;net6.0 + enable + enable + 10.0 + + false + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + diff --git a/test/Sknet.OpenIddict.LiteDB.Tests/Usings.cs b/test/Sknet.OpenIddict.LiteDB.Tests/Usings.cs new file mode 100644 index 0000000..86ffacd --- /dev/null +++ b/test/Sknet.OpenIddict.LiteDB.Tests/Usings.cs @@ -0,0 +1,8 @@ +global using LiteDB; +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Options; +global using Moq; +global using OpenIddict.Abstractions; +global using OpenIddict.Core; +global using Sknet.OpenIddict.LiteDB.Models; +global using Xunit;