Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions AspNetCore.DataProtection.CustomStorage.slnx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
<Solution>
<Folder Name="/src/">
<File Path="Directory.Packages.props" />
<File Path="src\Directory.Build.props" />
<File Path="src\Directory.Build.targets" />
<Project Path="src\AspNetCore.DataProtection.CustomStorage\AspNetCore.DataProtection.CustomStorage.csproj" />
Expand Down
25 changes: 25 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>

<ItemGroup Label="Source">
<PackageVersion Include="Microsoft.AspNetCore.DataProtection" Version="10.0.10" />
<PackageVersion Include="System.Security.Cryptography.Xml" Version="10.0.10" />
<PackageVersion Include="Dapper" Version="2.1.79" />
<PackageVersion Include="Microsoft.Data.SqlClient" Version="7.0.2" />
<PackageVersion Include="Npgsql" Version="10.0.3" />
</ItemGroup>

<ItemGroup Label="Tests">
<PackageVersion Include="Bogus" Version="35.6.5" />
<PackageVersion Include="NSubstitute" Version="6.0.0" />
<PackageVersion Include="FluentAssertions" Version="8.10.0" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.8.1" />
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="Respawn" Version="7.0.0" />
<PackageVersion Include="Testcontainers.PostgreSql" Version="4.13.0" />
<PackageVersion Include="Testcontainers.MsSql" Version="4.13.0" />
</ItemGroup>
</Project>
6 changes: 6 additions & 0 deletions NuGet.Config
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<configuration>
<packageSources>
<clear />
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" protocolVersion="3" />
</packageSources>
<packageSourceMapping>
<packageSource key="nuget.org">
<package pattern="*" />
</packageSource>
</packageSourceMapping>
<packageRestore>
<add key="enabled" value="True" />
<add key="automatic" value="True" />
Expand Down
226 changes: 219 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,225 @@
[![GitHub](https://img.shields.io/github/license/giammin/AspNetCore.DataProtection.CustomStorage)](https://github.com/giammin/AspNetCore.DataProtection.CustomStorage/blob/main/LICENSE)

# AspNetCore.DataProtection.CustomStorage
Support for storing ASP.NET Core Data Protection keys using a custom storage

# AspNetCore.DataProtection.CustomStorage.Dapper
A storage implementation using Dapper
Persist [ASP.NET Core Data Protection](https://learn.microsoft.com/aspnet/core/security/data-protection/introduction) keys in a storage backend of your choice.

# AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer
Implementation of a Dapper custom storage in SQLServer
The base package lets you plug any backend into the Data Protection key ring by implementing a single, small interface. Ready-to-use SQL Server and PostgreSQL implementations (built on [Dapper](https://github.com/DapperLib/Dapper)) are provided as separate packages.

# AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL
Implementation of a Dapper custom storage in PostgreSQL
## Contents

- [Why](#why)
- [Packages](#packages)
- [Requirements](#requirements)
- [Installation](#installation)
- [Quick start](#quick-start)
- [SQL Server](#sql-server)
- [PostgreSQL](#postgresql)
- [Configuration](#configuration)
- [Database schema](#database-schema)
- [Custom storage backend](#custom-storage-backend)
- [How it works](#how-it-works)
- [Contributing](#contributing)
- [License](#license)

## Why

When an ASP.NET Core app runs on more than one node (containers, a web farm, autoscaling), every instance must share the same Data Protection keys; otherwise cookies, antiforgery tokens and anything else protected on one node cannot be read on another. This library keeps that key ring in a shared, durable store instead of the local filesystem.

## Packages

| Package | Description |
|---------|-------------|
| `AspNetCore.DataProtection.CustomStorage` | Core abstractions. Bring your own backend by implementing `IDataProtectionStorage`. |
| `AspNetCore.DataProtection.CustomStorage.Dapper` | Shared Dapper-based building blocks used by the database providers. |
| `AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer` | Ready-to-use SQL Server provider. |
| `AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL` | Ready-to-use PostgreSQL provider. |

The database provider packages transitively depend on `.Dapper` and the core package, so installing a provider is all you need for the SQL Server or PostgreSQL scenario.

## Requirements

- .NET 10.0 or later

## Installation

For SQL Server:

```bash
dotnet add package AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer
```

For PostgreSQL:

```bash
dotnet add package AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL
```

For a custom backend, install only the core package:

```bash
dotnet add package AspNetCore.DataProtection.CustomStorage
```

## Quick start

### SQL Server

```csharp
using AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDataProtection()
.PersistKeysWithDapperInSqlServer(
builder.Configuration.GetConnectionString("DataProtection")!);

var app = builder.Build();

// Creates the keys table if it does not exist yet
// (InitializeTable defaults to true; see Configuration below).
app.Services.UseDapperDataProtection();

app.Run();
```

### PostgreSQL

```csharp
using AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddDataProtection()
.PersistKeysWithDapperInPostgreSQL(
builder.Configuration.GetConnectionString("DataProtection")!);

var app = builder.Build();

app.Services.UseDapperDataProtection();

app.Run();
```

> **Note:** the PostgreSQL provider enables Dapper's snake_case column mapping globally
> (`Dapper.DefaultTypeMap.MatchNamesWithUnderscores = true`). Keep this in mind if the same
> application uses Dapper elsewhere.

## Configuration

Both providers accept an optional `Action<DapperDataProtectionConfig>` to override the defaults:

```csharp
builder.Services.AddDataProtection()
.PersistKeysWithDapperInSqlServer(connectionString, config =>
{
config.SchemaName = "security";
config.TableName = "DpKeys";
config.InitializeTable = false; // you manage the schema yourself
});
```

| Option | Type | Default (SQL Server) | Default (PostgreSQL) | Description |
|--------|------|----------------------|----------------------|-------------|
| `SchemaName` | `string` | `dbo` | `public` | Schema that contains the keys table. |
| `TableName` | `string` | `DataProtectionKeys` | `data_protection_keys` | Name of the keys table. |
| `InitializeTable` | `bool` | `true` | `true` | When `true`, `UseDapperDataProtection()` creates the table (and its unique index) if it is missing. |

`UseDapperDataProtection()` is an extension on `IServiceProvider`. Call it once at startup
(`app.Services.UseDapperDataProtection()`): it validates the service registrations and, when
`InitializeTable` is `true`, creates the table. If you set `InitializeTable = false` you are
responsible for creating the schema (see below) before the app stores keys.

## Database schema

When `InitializeTable` is enabled, the provider creates a table equivalent to the following.

SQL Server (`dbo.DataProtectionKeys` by default):

```sql
CREATE TABLE [dbo].[DataProtectionKeys](
[Id] [int] IDENTITY(1,1) NOT NULL,
[InsertDate] [datetime] NOT NULL DEFAULT getdate(),
[FriendlyName] [nvarchar](256) NULL,
[Xml] [nvarchar](max) NOT NULL,
CONSTRAINT [PK_DataProtectionKeys] PRIMARY KEY CLUSTERED ([Id] ASC)
);
CREATE UNIQUE NONCLUSTERED INDEX [IX_DataProtectionKeys_FriendlyName]
ON [dbo].[DataProtectionKeys]([FriendlyName] ASC)
WHERE [FriendlyName] IS NOT NULL;
```

PostgreSQL (`public.data_protection_keys` by default):

```sql
CREATE TABLE IF NOT EXISTS public.data_protection_keys (
id INTEGER GENERATED ALWAYS AS IDENTITY,
insert_date timestamp with time zone NOT NULL DEFAULT NOW(),
friendly_name character varying(256) NULL,
xml text NOT NULL,
CONSTRAINT pk_public_data_protection_keys PRIMARY KEY (id)
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_public_data_protection_keys_friendly_name
ON public.data_protection_keys (friendly_name ASC NULLS LAST);
```

The unique index on `FriendlyName` / `friendly_name` allows `NULL` values.

## Custom storage backend

Reference `AspNetCore.DataProtection.CustomStorage` and implement `IDataProtectionStorage`:

```csharp
using AspNetCore.DataProtection.CustomStorage;

public class MyDataProtectionStorage : IDataProtectionStorage
{
public IEnumerable<DataProtectionKey> GetAll()
{
// load every stored key from your backend
}

public void Insert(DataProtectionKey key)
{
// persist a new key; key.FriendlyName must be unique when it is not null
}
}
```

`DataProtectionKey` carries the two values the key ring needs:

- `FriendlyName` (`string?`) — a name that must be unique when it is not `null`.
- `Xml` (`string`, required) — the serialized key.

Register your implementation in DI and wire it up:

```csharp
builder.Services.AddScoped<MyDataProtectionStorage>();

builder.Services.AddDataProtection()
.PersistKeysToStorage<MyDataProtectionStorage>();
```

`PersistKeysToStorage<TStorage>()` resolves `TStorage` from a fresh dependency-injection scope
on each read/write, so you must register it yourself (any lifetime works). If an `Insert` throws,
it is wrapped in a `KeyInsertException`.

## How it works

`PersistKeysToStorage<TStorage>()` registers a `StorageWrapper<TStorage>` as the Data Protection
`IXmlRepository`. The wrapper adapts the framework's XML-element calls to the simpler
`IDataProtectionStorage` contract (`GetAll()` / `Insert()`), resolving `TStorage` from a scoped
service provider for every operation.

The database providers add a Dapper layer (`IDbDataProtectionStorage`) on top of that contract,
implementing the raw SQL for SQL Server and PostgreSQL, plus schema initialization
(`InitializeDb()`) and an async read path (`GetAllAsync()`).

## Contributing

Issues and pull requests are welcome at the
[GitHub repository](https://github.com/giammin/AspNetCore.DataProtection.CustomStorage).

## License

Licensed under the [Apache License 2.0](LICENSE).
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,11 @@
<Description>Support for storing AspNetCore data protection keys using Dapper in PostgreSQL.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Npgsql" Version="10.0.2" />
<PackageReference Include="Npgsql" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AspNetCore.DataProtection.CustomStorage.Dapper\AspNetCore.DataProtection.CustomStorage.Dapper.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Update="System.Security.Cryptography.Xml" Version="10.0.7" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,9 @@
<Description>Support for storing AspNetCore data protection keys using Dapper in SQLServer.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SqlClient" Version="7.0.0" />
<PackageReference Include="Microsoft.Data.SqlClient" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AspNetCore.DataProtection.CustomStorage.Dapper\AspNetCore.DataProtection.CustomStorage.Dapper.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Update="System.Security.Cryptography.Xml" Version="10.0.7" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,11 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Dapper" Version="2.1.72" />
<PackageReference Include="Dapper" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\AspNetCore.DataProtection.CustomStorage\AspNetCore.DataProtection.CustomStorage.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Update="System.Security.Cryptography.Xml" Version="10.0.7" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
using System;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="10.0.7" />
</ItemGroup>

<ItemGroup>
<PackageReference Update="System.Security.Cryptography.Xml" Version="10.0.7" />
<PackageReference Include="Microsoft.AspNetCore.DataProtection" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
using System;
using Microsoft.Extensions.Logging;

namespace AspNetCore.DataProtection.CustomStorage;
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
</PropertyGroup>

<ItemGroup>
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.6" />
<PackageReference Include="System.Security.Cryptography.Xml" />
<InternalsVisibleTo Include="AspNetCore.DataProtection.CustomStorage.Tests" />
<None Condition="Exists('$(ProjectDir)README.md')==false" Include="..\..\README.md" Pack="true" PackagePath="\"/>
</ItemGroup>
Expand Down
2 changes: 1 addition & 1 deletion src/Directory.Build.targets
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
<Project>
<PropertyGroup>

<versionBuild>3</versionBuild>
<versionBuild>4</versionBuild>
<versionRevision>0</versionRevision>
<buildNumber>$([System.DateTime]::Now.ToString(yyMM)).$([System.DateTime]::Now.ToString(dd))</buildNumber>
<FileVersion>$(VersionPrefix).$(buildNumber)$(versionBuild)$(versionRevision)</FileVersion>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
</ItemGroup>

<ItemGroup>
<PackageReference Include="Respawn" Version="7.0.0" />
<PackageReference Include="Testcontainers.PostgreSql" Version="4.11.0" />
<PackageReference Include="Testcontainers.MsSql" Version="4.11.0" />
<PackageReference Include="Respawn" />
<PackageReference Include="Testcontainers.PostgreSql" />
<PackageReference Include="Testcontainers.MsSql" />
</ItemGroup>


Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
using System;
using System.Threading.Tasks;
using AspNetCore.DataProtection.CustomStorage.Dapper;
using AspNetCore.DataProtection.CustomStorage.Dapper.PostgreSQL;
using AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Options;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@
using AspNetCore.DataProtection.CustomStorage.Dapper.SQLServer;
using FluentAssertions;
using Microsoft.Data.SqlClient;
using Npgsql;
using Xunit;

namespace AspNetCore.DataProtection.CustomStorage.Tests.SQLServer;
Expand Down
Loading
Loading