Skip to content

Commit 3192da4

Browse files
Protected Browser Storage (#23553)
* Migrated protected browser storage. * Added E2E tests. * Added safeguard against using ProtectedBrowserStorage in wasm. * Added TryGetValue. * Added Microsoft.AspNetCore.Components.Web.Extensions * Minor cleanup * Moved ProtectedBrowserStorage out of Web.JS. * Delete Microsoft.AspNetCore.Components.Web.Extensions.netcoreapp.cs * Updated ProjectReferences.props * Improvements and cleanup. * Update Microsoft.AspNetCore.Components.Web.Extensions.csproj * Added Web.Extensions to the VS solution.
1 parent 2440c05 commit 3192da4

22 files changed

+1163
-104
lines changed

AspNetCore.sln

Lines changed: 139 additions & 103 deletions
Large diffs are not rendered by default.

eng/ProjectReferences.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@
6363
<ProjectReferenceProvider Include="Microsoft.AspNetCore.SignalR.Specification.Tests" ProjectPath="$(RepoRoot)src\SignalR\server\Specification.Tests\src\Microsoft.AspNetCore.SignalR.Specification.Tests.csproj" />
6464
<ProjectReferenceProvider Include="Microsoft.AspNetCore.SignalR.StackExchangeRedis" ProjectPath="$(RepoRoot)src\SignalR\server\StackExchangeRedis\src\Microsoft.AspNetCore.SignalR.StackExchangeRedis.csproj" />
6565
<ProjectReferenceProvider Include="Ignitor" ProjectPath="$(RepoRoot)src\Components\Ignitor\src\Ignitor.csproj" />
66+
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.Web.Extensions" ProjectPath="$(RepoRoot)src\Components\Web.Extensions\src\Microsoft.AspNetCore.Components.Web.Extensions.csproj" />
6667
<ProjectReferenceProvider Include="Microsoft.Authentication.WebAssembly.Msal" ProjectPath="$(RepoRoot)src\Components\WebAssembly\Authentication.Msal\src\Microsoft.Authentication.WebAssembly.Msal.csproj" />
6768
<ProjectReferenceProvider Include="Microsoft.JSInterop.WebAssembly" ProjectPath="$(RepoRoot)src\Components\WebAssembly\JSInterop\src\Microsoft.JSInterop.WebAssembly.csproj" />
6869
<ProjectReferenceProvider Include="Microsoft.AspNetCore.Components.WebAssembly.Server" ProjectPath="$(RepoRoot)src\Components\WebAssembly\Server\src\Microsoft.AspNetCore.Components.WebAssembly.Server.csproj" />

src/Components/Components.slnf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,8 @@
100100
"src\\Components\\test\\testassets\\ComponentsApp.Server\\ComponentsApp.Server.csproj",
101101
"src\\Components\\benchmarkapps\\Wasm.Performance\\Driver\\Wasm.Performance.Driver.csproj",
102102
"src\\Components\\benchmarkapps\\Wasm.Performance\\TestApp\\Wasm.Performance.TestApp.csproj",
103+
"src\\Components\\Web.Extensions\\src\\Microsoft.AspNetCore.Components.Web.Extensions.csproj",
104+
"src\\Components\\Web.Extensions\\test\\Microsoft.AspNetCore.Components.Web.Extensions.Tests.csproj",
103105
"src\\Components\\WebAssembly\\Server\\test\\Microsoft.AspNetCore.Components.WebAssembly.Server.Tests.csproj",
104106
"src\\Components\\WebAssembly\\Authentication.Msal\\src\\Microsoft.Authentication.WebAssembly.Msal.csproj",
105107
"src\\Components\\WebAssembly\\DebugProxy\\src\\Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.csproj",

src/Components/Components/src/Properties/AssemblyInfo.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Server.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
88
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
99
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Web.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
10+
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Web.Extensions.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]

src/Components/ComponentsNoDeps.slnf

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616
"src\\Components\\Samples\\BlazorServerApp\\BlazorServerApp.csproj",
1717
"src\\Components\\Server\\src\\Microsoft.AspNetCore.Components.Server.csproj",
1818
"src\\Components\\Server\\test\\Microsoft.AspNetCore.Components.Server.Tests.csproj",
19+
"src\\Components\\Web.Extensions\\src\\Microsoft.AspNetCore.Components.Web.Extensions.csproj",
20+
"src\\Components\\Web.Extensions\\test\\Microsoft.AspNetCore.Components.Web.Extensions.Tests.csproj",
1921
"src\\Components\\WebAssembly\\Authentication.Msal\\src\\Microsoft.Authentication.WebAssembly.Msal.csproj",
2022
"src\\Components\\WebAssembly\\DebugProxy\\src\\Microsoft.AspNetCore.Components.WebAssembly.DebugProxy.csproj",
2123
"src\\Components\\WebAssembly\\DevServer\\src\\Microsoft.AspNetCore.Components.WebAssembly.DevServer.csproj",
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFramework>$(DefaultNetCoreTargetFramework)</TargetFramework>
5+
<Description>A collection of Blazor components for the web.</Description>
6+
<GenerateDocumentationFile>true</GenerateDocumentationFile>
7+
<RootNamespace>Microsoft.AspNetCore.Components</RootNamespace>
8+
<Nullable>enable</Nullable>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<Reference Include="Microsoft.AspNetCore.DataProtection" />
13+
<Reference Include="Microsoft.JSInterop" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<Compile Include="..\..\Shared\src\JsonSerializerOptionsProvider.cs" />
18+
</ItemGroup>
19+
20+
</Project>
Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Concurrent;
6+
using System.Runtime.InteropServices;
7+
using System.Text.Json;
8+
using System.Threading.Tasks;
9+
using Microsoft.AspNetCore.DataProtection;
10+
using Microsoft.JSInterop;
11+
12+
namespace Microsoft.AspNetCore.Components.Web.Extensions
13+
{
14+
/// <summary>
15+
/// Provides mechanisms for storing and retrieving data in the browser storage.
16+
/// </summary>
17+
public abstract class ProtectedBrowserStorage
18+
{
19+
private readonly string _storeName;
20+
private readonly IJSRuntime _jsRuntime;
21+
private readonly IDataProtectionProvider _dataProtectionProvider;
22+
private readonly ConcurrentDictionary<string, IDataProtector> _cachedDataProtectorsByPurpose
23+
= new ConcurrentDictionary<string, IDataProtector>(StringComparer.Ordinal);
24+
25+
/// <summary>
26+
/// Constructs an instance of <see cref="ProtectedBrowserStorage"/>.
27+
/// </summary>
28+
/// <param name="storeName">The name of the store in which the data should be stored.</param>
29+
/// <param name="jsRuntime">The <see cref="IJSRuntime"/>.</param>
30+
/// <param name="dataProtectionProvider">The <see cref="IDataProtectionProvider"/>.</param>
31+
protected ProtectedBrowserStorage(string storeName, IJSRuntime jsRuntime, IDataProtectionProvider dataProtectionProvider)
32+
{
33+
// Performing data protection on the client would give users a false sense of security, so we'll prevent this.
34+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Browser))
35+
{
36+
throw new PlatformNotSupportedException($"{GetType()} cannot be used when running in a browser.");
37+
}
38+
39+
if (string.IsNullOrEmpty(storeName))
40+
{
41+
throw new ArgumentException("The value cannot be null or empty", nameof(storeName));
42+
}
43+
44+
_storeName = storeName;
45+
_jsRuntime = jsRuntime ?? throw new ArgumentNullException(nameof(jsRuntime));
46+
_dataProtectionProvider = dataProtectionProvider ?? throw new ArgumentNullException(nameof(dataProtectionProvider));
47+
}
48+
49+
/// <summary>
50+
/// <para>
51+
/// Asynchronously stores the specified data.
52+
/// </para>
53+
/// <para>
54+
/// Since no data protection purpose is specified with this overload, the purpose is derived from
55+
/// <paramref name="key"/> and the store name. This is a good default purpose to use if the keys come from a
56+
/// fixed set known at compile-time.
57+
/// </para>
58+
/// </summary>
59+
/// <param name="key">A <see cref="string"/> value specifying the name of the storage slot to use.</param>
60+
/// <param name="value">A JSON-serializable value to be stored.</param>
61+
/// <returns>A <see cref="ValueTask"/> representing the completion of the operation.</returns>
62+
public ValueTask SetAsync(string key, object value)
63+
=> SetAsync(CreatePurposeFromKey(key), key, value);
64+
65+
/// <summary>
66+
/// Asynchronously stores the supplied data.
67+
/// </summary>
68+
/// <param name="purpose">
69+
/// A string that defines a scope for the data protection. The protected data can only
70+
/// be unprotected by code that specifies the same purpose.
71+
/// </param>
72+
/// <param name="key">A <see cref="string"/> value specifying the name of the storage slot to use.</param>
73+
/// <param name="value">A JSON-serializable value to be stored.</param>
74+
/// <returns>A <see cref="ValueTask"/> representing the completion of the operation.</returns>
75+
public ValueTask SetAsync(string purpose, string key, object value)
76+
{
77+
if (string.IsNullOrEmpty(purpose))
78+
{
79+
throw new ArgumentException("Cannot be null or empty", nameof(purpose));
80+
}
81+
82+
if (string.IsNullOrEmpty(key))
83+
{
84+
throw new ArgumentException("Cannot be null or empty", nameof(key));
85+
}
86+
87+
return SetProtectedJsonAsync(key, Protect(purpose, value));
88+
}
89+
90+
/// <summary>
91+
/// <para>
92+
/// Asynchronously retrieves the specified data.
93+
/// </para>
94+
/// <para>
95+
/// Since no data protection purpose is specified with this overload, the purpose is derived from
96+
/// <paramref name="key"/> and the store name. This is a good default purpose to use if the keys come from a
97+
/// fixed set known at compile-time.
98+
/// </para>
99+
/// </summary>
100+
/// <param name="key">A <see cref="string"/> value specifying the name of the storage slot to use.</param>
101+
/// <returns>A <see cref="ValueTask"/> representing the completion of the operation.</returns>
102+
public ValueTask<ProtectedBrowserStorageResult<TValue>> GetAsync<TValue>(string key)
103+
=> GetAsync<TValue>(CreatePurposeFromKey(key), key);
104+
105+
/// <summary>
106+
/// <para>
107+
/// Asynchronously retrieves the specified data.
108+
/// </para>
109+
/// </summary>
110+
/// <param name="purpose">
111+
/// A string that defines a scope for the data protection. The protected data can only
112+
/// be unprotected if the same purpose was previously specified when calling
113+
/// <see cref="SetAsync(string, string, object)"/>.
114+
/// </param>
115+
/// <param name="key">A <see cref="string"/> value specifying the name of the storage slot to use.</param>
116+
/// <returns>A <see cref="ValueTask"/> representing the completion of the operation.</returns>
117+
public async ValueTask<ProtectedBrowserStorageResult<TValue>> GetAsync<TValue>(string purpose, string key)
118+
{
119+
var protectedJson = await GetProtectedJsonAsync(key);
120+
121+
return protectedJson == null ?
122+
new ProtectedBrowserStorageResult<TValue>(false, default) :
123+
new ProtectedBrowserStorageResult<TValue>(true, Unprotect<TValue>(purpose, protectedJson));
124+
}
125+
126+
/// <summary>
127+
/// Asynchronously deletes any data stored for the specified key.
128+
/// </summary>
129+
/// <param name="key">
130+
/// A <see cref="string"/> value specifying the name of the storage slot whose value should be deleted.
131+
/// </param>
132+
/// <returns>A <see cref="ValueTask"/> representing the completion of the operation.</returns>
133+
public ValueTask DeleteAsync(string key)
134+
=> _jsRuntime.InvokeVoidAsync($"{_storeName}.removeItem", key);
135+
136+
private string Protect(string purpose, object value)
137+
{
138+
var json = JsonSerializer.Serialize(value, options: JsonSerializerOptionsProvider.Options);
139+
var protector = GetOrCreateCachedProtector(purpose);
140+
141+
return protector.Protect(json);
142+
}
143+
144+
private TValue Unprotect<TValue>(string purpose, string protectedJson)
145+
{
146+
var protector = GetOrCreateCachedProtector(purpose);
147+
var json = protector.Unprotect(protectedJson);
148+
149+
return JsonSerializer.Deserialize<TValue>(json, options: JsonSerializerOptionsProvider.Options)!;
150+
}
151+
152+
private ValueTask SetProtectedJsonAsync(string key, string protectedJson)
153+
=> _jsRuntime.InvokeVoidAsync($"{_storeName}.setItem", key, protectedJson);
154+
155+
private ValueTask<string> GetProtectedJsonAsync(string key)
156+
=> _jsRuntime.InvokeAsync<string>($"{_storeName}.getItem", key);
157+
158+
// IDataProtect isn't disposable, so we're fine holding these indefinitely.
159+
// Only a bounded number of them will be created, as the 'key' values should
160+
// come from a bounded set known at compile-time. There's no use case for
161+
// letting runtime data determine the 'key' values.
162+
private IDataProtector GetOrCreateCachedProtector(string purpose)
163+
=> _cachedDataProtectorsByPurpose.GetOrAdd(
164+
purpose,
165+
_dataProtectionProvider.CreateProtector);
166+
167+
private string CreatePurposeFromKey(string key)
168+
=> $"{GetType().FullName}:{_storeName}:{key}";
169+
}
170+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
namespace Microsoft.AspNetCore.Components.Web.Extensions
4+
{
5+
/// <summary>
6+
/// Contains the result of a protected browser storage operation.
7+
/// </summary>
8+
public readonly struct ProtectedBrowserStorageResult<T>
9+
{
10+
/// <summary>
11+
/// Gets whether the operation succeeded.
12+
/// </summary>
13+
public bool Success { get; }
14+
15+
/// <summary>
16+
/// Gets the result value of the operation.
17+
/// </summary>
18+
[MaybeNull]
19+
[AllowNull]
20+
public T Value { get; }
21+
22+
internal ProtectedBrowserStorageResult(bool success, [AllowNull] T value)
23+
{
24+
Success = success;
25+
Value = value;
26+
}
27+
}
28+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.Components.Web.Extensions;
5+
6+
namespace Microsoft.Extensions.DependencyInjection
7+
{
8+
/// <summary>
9+
/// Extension methods for registering Protected Browser Storage services.
10+
/// </summary>
11+
public static class ProtectedBrowserStorageServiceCollectionExtensions
12+
{
13+
/// <summary>
14+
/// Adds services for protected browser storage to the specified <see cref="IServiceCollection"/>.
15+
/// </summary>
16+
/// <param name="services">The <see cref="IServiceCollection"/>.</param>
17+
public static void AddProtectedBrowserStorage(this IServiceCollection services)
18+
{
19+
services.AddDataProtection();
20+
services.AddScoped<ProtectedLocalStorage>();
21+
services.AddScoped<ProtectedSessionStorage>();
22+
}
23+
}
24+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.AspNetCore.DataProtection;
5+
using Microsoft.JSInterop;
6+
7+
namespace Microsoft.AspNetCore.Components.Web.Extensions
8+
{
9+
/// <summary>
10+
/// Provides mechanisms for storing and retrieving data in the browser's
11+
/// 'localStorage' collection.
12+
///
13+
/// This data will be scoped to the current user's browser, shared across
14+
/// all tabs. The data will persist across browser restarts.
15+
///
16+
/// See: https://developer.mozilla.org/en-US/docs/Web/API/Window/localStorage
17+
/// </summary>
18+
public class ProtectedLocalStorage : ProtectedBrowserStorage
19+
{
20+
/// <summary>
21+
/// Constructs an instance of <see cref="ProtectedLocalStorage"/>.
22+
/// </summary>
23+
/// <param name="jsRuntime">The <see cref="IJSRuntime"/>.</param>
24+
/// <param name="dataProtectionProvider">The <see cref="IDataProtectionProvider"/>.</param>
25+
public ProtectedLocalStorage(IJSRuntime jsRuntime, IDataProtectionProvider dataProtectionProvider)
26+
: base("localStorage", jsRuntime, dataProtectionProvider)
27+
{
28+
}
29+
}
30+
}

0 commit comments

Comments
 (0)