diff --git a/DataProtection.sln b/DataProtection.sln
index 462c11dab8..20808113c9 100644
--- a/DataProtection.sln
+++ b/DataProtection.sln
@@ -34,12 +34,24 @@ Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.DataPr
EndProject
Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.DataProtection.Redis", "src\Microsoft.AspNetCore.DataProtection.Redis\Microsoft.AspNetCore.DataProtection.Redis.xproj", "{0508ADB0-9D2E-4506-9AA3-C15D7BEAE7C9}"
EndProject
-Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Redis", "samples\Redis\Redis.xproj", "{24AAEC96-DF46-4F61-B2FF-3D5E056685D9}"
+Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.DataProtection.Azure.Blob", "src\Microsoft.AspNetCore.DataProtection.Azure.Blob\Microsoft.AspNetCore.DataProtection.Azure.Blob.xproj", "{CC799B57-81E2-4F45-8A32-0D5F49753C3F}"
EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{3A6C77DB-FD3D-4B20-A52B-34F7A7E1AED2}"
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{5A3A5DE3-49AD-431C-971D-B01B62D94AE2}"
+EndProject
+Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "AzureBlob", "samples\AzureBlob\AzureBlob.xproj", "{B07435B3-CD81-4E3B-88A5-6384821E1C01}"
EndProject
Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.DataProtection.Redis.Test", "test\Microsoft.AspNetCore.DataProtection.Redis.Test\Microsoft.AspNetCore.DataProtection.Redis.Test.xproj", "{ABCF00E5-5B2F-469C-90DC-908C5A04C08D}"
EndProject
+Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{E1D86B1B-41D8-43C9-97FD-C2BF65C414E2}"
+ ProjectSection(SolutionItems) = preProject
+ global.json = global.json
+ NuGet.config = NuGet.config
+ EndProjectSection
+EndProject
+Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Microsoft.AspNetCore.DataProtection.Azure.Blob.Test", "test\Microsoft.AspNetCore.DataProtection.Azure.Blob.Test\Microsoft.AspNetCore.DataProtection.Azure.Blob.Test.xproj", "{8C41240E-48F8-402F-9388-74CFE27F4D76}"
+EndProject
+Project("{8BB2217D-0F2D-49D1-97BC-3654ED321F3B}") = "Redis", "samples\Redis\Redis.xproj", "{24AAEC96-DF46-4F61-B2FF-3D5E056685D9}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -156,14 +168,22 @@ Global
{0508ADB0-9D2E-4506-9AA3-C15D7BEAE7C9}.Release|Any CPU.Build.0 = Release|Any CPU
{0508ADB0-9D2E-4506-9AA3-C15D7BEAE7C9}.Release|x86.ActiveCfg = Release|Any CPU
{0508ADB0-9D2E-4506-9AA3-C15D7BEAE7C9}.Release|x86.Build.0 = Release|Any CPU
- {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
- {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
- {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|x86.ActiveCfg = Debug|Any CPU
- {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|x86.Build.0 = Debug|Any CPU
- {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
- {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|Any CPU.Build.0 = Release|Any CPU
- {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|x86.ActiveCfg = Release|Any CPU
- {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|x86.Build.0 = Release|Any CPU
+ {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Debug|x86.Build.0 = Debug|Any CPU
+ {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Release|Any CPU.Build.0 = Release|Any CPU
+ {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Release|x86.ActiveCfg = Release|Any CPU
+ {CC799B57-81E2-4F45-8A32-0D5F49753C3F}.Release|x86.Build.0 = Release|Any CPU
+ {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Debug|x86.Build.0 = Debug|Any CPU
+ {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Release|Any CPU.Build.0 = Release|Any CPU
+ {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Release|x86.ActiveCfg = Release|Any CPU
+ {B07435B3-CD81-4E3B-88A5-6384821E1C01}.Release|x86.Build.0 = Release|Any CPU
{ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Debug|x86.ActiveCfg = Debug|Any CPU
@@ -172,6 +192,22 @@ Global
{ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Release|Any CPU.Build.0 = Release|Any CPU
{ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Release|x86.ActiveCfg = Release|Any CPU
{ABCF00E5-5B2F-469C-90DC-908C5A04C08D}.Release|x86.Build.0 = Release|Any CPU
+ {8C41240E-48F8-402F-9388-74CFE27F4D76}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {8C41240E-48F8-402F-9388-74CFE27F4D76}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {8C41240E-48F8-402F-9388-74CFE27F4D76}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {8C41240E-48F8-402F-9388-74CFE27F4D76}.Debug|x86.Build.0 = Debug|Any CPU
+ {8C41240E-48F8-402F-9388-74CFE27F4D76}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {8C41240E-48F8-402F-9388-74CFE27F4D76}.Release|Any CPU.Build.0 = Release|Any CPU
+ {8C41240E-48F8-402F-9388-74CFE27F4D76}.Release|x86.ActiveCfg = Release|Any CPU
+ {8C41240E-48F8-402F-9388-74CFE27F4D76}.Release|x86.Build.0 = Release|Any CPU
+ {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Debug|x86.Build.0 = Debug|Any CPU
+ {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|Any CPU.Build.0 = Release|Any CPU
+ {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|x86.ActiveCfg = Release|Any CPU
+ {24AAEC96-DF46-4F61-B2FF-3D5E056685D9}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -191,7 +227,10 @@ Global
{04AA8E60-A053-4D50-89FE-E76C3DF45200} = {60336AB3-948D-4D15-A5FB-F32A2B91E814}
{BF8681DB-C28B-441F-BD92-0DCFE9537A9F} = {5FCB2DA3-5395-47F5-BCEE-E0EA319448EA}
{0508ADB0-9D2E-4506-9AA3-C15D7BEAE7C9} = {5FCB2DA3-5395-47F5-BCEE-E0EA319448EA}
- {24AAEC96-DF46-4F61-B2FF-3D5E056685D9} = {3A6C77DB-FD3D-4B20-A52B-34F7A7E1AED2}
+ {CC799B57-81E2-4F45-8A32-0D5F49753C3F} = {5FCB2DA3-5395-47F5-BCEE-E0EA319448EA}
+ {B07435B3-CD81-4E3B-88A5-6384821E1C01} = {5A3A5DE3-49AD-431C-971D-B01B62D94AE2}
{ABCF00E5-5B2F-469C-90DC-908C5A04C08D} = {60336AB3-948D-4D15-A5FB-F32A2B91E814}
+ {8C41240E-48F8-402F-9388-74CFE27F4D76} = {60336AB3-948D-4D15-A5FB-F32A2B91E814}
+ {24AAEC96-DF46-4F61-B2FF-3D5E056685D9} = {5A3A5DE3-49AD-431C-971D-B01B62D94AE2}
EndGlobalSection
EndGlobal
diff --git a/NuGetPackageVerifier.json b/NuGetPackageVerifier.json
index af9e7d025d..96174b2f82 100644
--- a/NuGetPackageVerifier.json
+++ b/NuGetPackageVerifier.json
@@ -8,6 +8,7 @@
"Microsoft.AspNetCore.Cryptography.KeyDerivation": { },
"Microsoft.AspNetCore.DataProtection": { },
"Microsoft.AspNetCore.DataProtection.Abstractions": { },
+ "Microsoft.AspNetCore.DataProtection.Azure.Blob": { },
"Microsoft.AspNetCore.DataProtection.Extensions": { },
"Microsoft.AspNetCore.DataProtection.Redis": { },
"Microsoft.AspNetCore.DataProtection.SystemWeb": { }
diff --git a/samples/AzureBlob/AzureBlob.xproj b/samples/AzureBlob/AzureBlob.xproj
new file mode 100644
index 0000000000..52a7e78b7e
--- /dev/null
+++ b/samples/AzureBlob/AzureBlob.xproj
@@ -0,0 +1,21 @@
+
+
+
+ 14.0
+ $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)
+
+
+
+
+ b07435b3-cd81-4e3b-88a5-6384821e1c01
+ AzureBlob
+ .\obj
+ .\bin\
+ v4.5.2
+
+
+
+ 2.0
+
+
+
diff --git a/samples/AzureBlob/Program.cs b/samples/AzureBlob/Program.cs
new file mode 100644
index 0000000000..45a69fb10c
--- /dev/null
+++ b/samples/AzureBlob/Program.cs
@@ -0,0 +1,42 @@
+using System;
+using Microsoft.AspNetCore.DataProtection;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.WindowsAzure.Storage;
+using Microsoft.AspNetCore.DataProtection.Azure.Blob;
+
+namespace AzureBlob
+{
+ public class Program
+ {
+ public static void Main(string[] args)
+ {
+ var storageAccount = CloudStorageAccount.DevelopmentStorageAccount;
+ var client = storageAccount.CreateCloudBlobClient();
+ var container = client.GetContainerReference("key-container");
+
+ // The container must exist before calling the DataProtection APIs.
+ // The specific file within the container does not have to exist,
+ // as it will be created on-demand.
+
+ container.CreateIfNotExistsAsync().GetAwaiter().GetResult();
+
+ // Configure
+
+ var serviceCollection = new ServiceCollection();
+ serviceCollection.AddLogging();
+ serviceCollection.AddDataProtection()
+ .PersistKeysToAzureBlobStorage(container, "keys.xml");
+
+ var services = serviceCollection.BuildServiceProvider();
+ var loggerFactory = services.GetService();
+ loggerFactory.AddConsole(Microsoft.Extensions.Logging.LogLevel.Trace);
+
+ // Run a sample payload
+
+ var protector = services.GetDataProtector("sample-purpose");
+ var protectedData = protector.Protect("Hello world!");
+ Console.WriteLine(protectedData);
+ }
+ }
+}
diff --git a/samples/AzureBlob/project.json b/samples/AzureBlob/project.json
new file mode 100644
index 0000000000..2294f4df5f
--- /dev/null
+++ b/samples/AzureBlob/project.json
@@ -0,0 +1,26 @@
+{
+ "version": "1.0.0-*",
+ "buildOptions": {
+ "emitEntryPoint": true
+ },
+
+ "dependencies": {
+ "Microsoft.AspNetCore.DataProtection": "1.1.0-*",
+ "Microsoft.AspNetCore.DataProtection.Azure.Blob": "1.1.0-*",
+ "Microsoft.Extensions.DependencyInjection": "1.1.0-*",
+ "Microsoft.Extensions.Logging": "1.1.0-*",
+ "Microsoft.Extensions.Logging.Console": "1.1.0-*",
+ "Microsoft.NETCore.App": {
+ "type": "platform",
+ "version": "1.0.0"
+ }
+ },
+
+ "frameworks": {
+ "netcoreapp1.0": {
+ "imports": [
+ "portable-net45+win8+wp8+wpa81"
+ ]
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureBlobXmlRepository.cs b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureBlobXmlRepository.cs
new file mode 100644
index 0000000000..0020fdc693
--- /dev/null
+++ b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureBlobXmlRepository.cs
@@ -0,0 +1,295 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
+using System.IO;
+using System.Linq;
+using System.Runtime.ExceptionServices;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Linq;
+using Microsoft.AspNetCore.DataProtection.Repositories;
+using Microsoft.WindowsAzure.Storage;
+using Microsoft.WindowsAzure.Storage.Blob;
+
+namespace Microsoft.AspNetCore.DataProtection.Azure.Blob
+{
+ ///
+ /// An which is backed by Azure Blob Storage.
+ ///
+ ///
+ /// Instances of this type are thread-safe.
+ ///
+ public sealed class AzureBlobXmlRepository : IXmlRepository
+ {
+ private const int ConflictMaxRetries = 5;
+ private static readonly TimeSpan ConflictBackoffPeriod = TimeSpan.FromMilliseconds(200);
+
+ private static readonly XName RepositoryElementName = "repository";
+
+ private readonly Func _blobRefFactory;
+ private readonly Random _random;
+ private BlobData _cachedBlobData;
+
+ ///
+ /// Creates a new instance of the .
+ ///
+ /// A factory which can create
+ /// instances. The factory must be thread-safe for invocation by multiple
+ /// concurrent threads, and each invocation must return a new object.
+ public AzureBlobXmlRepository(Func blobRefFactory)
+ {
+ if (blobRefFactory == null)
+ {
+ throw new ArgumentNullException(nameof(blobRefFactory));
+ }
+
+ _blobRefFactory = blobRefFactory;
+ _random = new Random();
+ }
+
+ public IReadOnlyCollection GetAllElements()
+ {
+ var blobRef = CreateFreshBlobRef();
+
+ // Shunt the work onto a ThreadPool thread so that it's independent of any
+ // existing sync context or other potentially deadlock-causing items.
+
+ var elements = Task.Run(() => GetAllElementsAsync(blobRef)).GetAwaiter().GetResult();
+ return new ReadOnlyCollection(elements);
+ }
+
+ public void StoreElement(XElement element, string friendlyName)
+ {
+ if (element == null)
+ {
+ throw new ArgumentNullException(nameof(element));
+ }
+
+ var blobRef = CreateFreshBlobRef();
+
+ // Shunt the work onto a ThreadPool thread so that it's independent of any
+ // existing sync context or other potentially deadlock-causing items.
+
+ Task.Run(() => StoreElementAsync(blobRef, element)).GetAwaiter().GetResult();
+ }
+
+ private XDocument CreateDocumentFromBlob(byte[] blob)
+ {
+ using (var memoryStream = new MemoryStream(blob))
+ {
+ var xmlReaderSettings = new XmlReaderSettings()
+ {
+ DtdProcessing = DtdProcessing.Prohibit, IgnoreProcessingInstructions = true
+ };
+
+ using (var xmlReader = XmlReader.Create(memoryStream, xmlReaderSettings))
+ {
+ return XDocument.Load(xmlReader);
+ }
+ }
+ }
+
+ private ICloudBlob CreateFreshBlobRef()
+ {
+ // ICloudBlob instances aren't thread-safe, so we need to make sure we're working
+ // with a fresh instance that won't be mutated by another thread.
+
+ var blobRef = _blobRefFactory();
+ if (blobRef == null)
+ {
+ throw new InvalidOperationException("The ICloudBlob factory method returned null.");
+ }
+
+ return blobRef;
+ }
+
+ private async Task> GetAllElementsAsync(ICloudBlob blobRef)
+ {
+ var data = await GetLatestDataAsync(blobRef);
+
+ if (data == null)
+ {
+ // no data in blob storage
+ return new XElement[0];
+ }
+
+ // The document will look like this:
+ //
+ //
+ //
+ //
+ // ...
+ //
+ //
+ // We want to return the first-level child elements to our caller.
+
+ var doc = CreateDocumentFromBlob(data.BlobContents);
+ return doc.Root.Elements().ToList();
+ }
+
+ private async Task GetLatestDataAsync(ICloudBlob blobRef)
+ {
+ // Set the appropriate AccessCondition based on what we believe the latest
+ // file contents to be, then make the request.
+
+ var latestCachedData = Volatile.Read(ref _cachedBlobData); // local ref so field isn't mutated under our feet
+ var accessCondition = (latestCachedData != null)
+ ? AccessCondition.GenerateIfNoneMatchCondition(latestCachedData.ETag)
+ : null;
+
+ try
+ {
+ using (var memoryStream = new MemoryStream())
+ {
+ await blobRef.DownloadToStreamAsync(
+ target: memoryStream,
+ accessCondition: accessCondition,
+ options: null,
+ operationContext: null);
+
+ // At this point, our original cache either didn't exist or was outdated.
+ // We'll update it now and return the updated value;
+
+ latestCachedData = new BlobData()
+ {
+ BlobContents = memoryStream.ToArray(),
+ ETag = blobRef.Properties.ETag
+ };
+
+ }
+ Volatile.Write(ref _cachedBlobData, latestCachedData);
+ }
+ catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 304)
+ {
+ // 304 Not Modified
+ // Thrown when we already have the latest cached data.
+ // This isn't an error; we'll return our cached copy of the data.
+ }
+ catch (StorageException ex) when (ex.RequestInformation.HttpStatusCode == 404)
+ {
+ // 404 Not Found
+ // Thrown when no file exists in storage.
+ // This isn't an error; we'll delete our cached copy of data.
+
+ latestCachedData = null;
+ Volatile.Write(ref _cachedBlobData, latestCachedData);
+ }
+
+ return latestCachedData;
+ }
+
+ private int GetRandomizedBackoffPeriod()
+ {
+ // returns a TimeSpan in the range [0.8, 1.0) * ConflictBackoffPeriod
+ // not used for crypto purposes
+ var multiplier = 0.8 + (_random.NextDouble() * 0.2);
+ return (int) (multiplier * ConflictBackoffPeriod.Ticks);
+ }
+
+ private async Task StoreElementAsync(ICloudBlob blobRef, XElement element)
+ {
+ // holds the last error in case we need to rethrow it
+ ExceptionDispatchInfo lastError = null;
+
+ for (var i = 0; i < ConflictMaxRetries; i++)
+ {
+ if (i > 1)
+ {
+ // If multiple conflicts occurred, wait a small period of time before retrying
+ // the operation so that other writers can make forward progress.
+ await Task.Delay(GetRandomizedBackoffPeriod());
+ }
+
+ if (i > 0)
+ {
+ // If at least one conflict occurred, make sure we have an up-to-date
+ // view of the blob contents.
+ await GetLatestDataAsync(blobRef);
+ }
+
+ // Merge the new element into the document. If no document exists,
+ // create a new default document and inject this element into it.
+
+ var latestData = Volatile.Read(ref _cachedBlobData);
+ var doc = (latestData != null)
+ ? CreateDocumentFromBlob(latestData.BlobContents)
+ : new XDocument(new XElement(RepositoryElementName));
+ doc.Root.Add(element);
+
+ // Turn this document back into a byte[].
+
+ var serializedDoc = new MemoryStream();
+ doc.Save(serializedDoc, SaveOptions.DisableFormatting);
+
+ // Generate the appropriate precondition header based on whether or not
+ // we believe data already exists in storage.
+
+ AccessCondition accessCondition;
+ if (latestData != null)
+ {
+ accessCondition = AccessCondition.GenerateIfMatchCondition(blobRef.Properties.ETag);
+ }
+ else
+ {
+ accessCondition = AccessCondition.GenerateIfNotExistsCondition();
+ blobRef.Properties.ContentType = "application/xml; charset=utf-8"; // set content type on first write
+ }
+
+ try
+ {
+ // Send the request up to the server.
+
+ var serializedDocAsByteArray = serializedDoc.ToArray();
+
+ await blobRef.UploadFromByteArrayAsync(
+ buffer: serializedDocAsByteArray,
+ index: 0,
+ count: serializedDocAsByteArray.Length,
+ accessCondition: accessCondition,
+ options: null,
+ operationContext: null);
+
+ // If we got this far, success!
+ // We can update the cached view of the remote contents.
+
+ Volatile.Write(ref _cachedBlobData, new BlobData()
+ {
+ BlobContents = serializedDocAsByteArray,
+ ETag = blobRef.Properties.ETag // was updated by Upload routine
+ });
+
+ return;
+ }
+ catch (StorageException ex)
+ when (ex.RequestInformation.HttpStatusCode == 409 || ex.RequestInformation.HttpStatusCode == 412)
+ {
+ // 409 Conflict
+ // This error is rare but can be thrown in very special circumstances,
+ // such as if the blob in the process of being created. We treat it
+ // as equivalent to 412 for the purposes of retry logic.
+
+ // 412 Precondition Failed
+ // We'll get this error if another writer updated the repository and we
+ // have an outdated view of its contents. If this occurs, we'll just
+ // refresh our view of the remote contents and try again up to the max
+ // retry limit.
+
+ lastError = ExceptionDispatchInfo.Capture(ex);
+ }
+ }
+
+ // if we got this far, something went awry
+ lastError.Throw();
+ }
+
+ private sealed class BlobData
+ {
+ internal byte[] BlobContents;
+ internal string ETag;
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureDataProtectionBuilderExtensions.cs b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureDataProtectionBuilderExtensions.cs
new file mode 100644
index 0000000000..0c5ac7299c
--- /dev/null
+++ b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/AzureDataProtectionBuilderExtensions.cs
@@ -0,0 +1,171 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using Microsoft.AspNetCore.DataProtection.Repositories;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.WindowsAzure.Storage;
+using Microsoft.WindowsAzure.Storage.Auth;
+using Microsoft.WindowsAzure.Storage.Blob;
+
+namespace Microsoft.AspNetCore.DataProtection.Azure.Blob
+{
+ ///
+ /// Contains Azure-specific extension methods for modifying a
+ /// .
+ ///
+ public static class AzureDataProtectionBuilderExtensions
+ {
+ ///
+ /// Configures the data protection system to persist keys to the specified path
+ /// in Azure Blob Storage.
+ ///
+ /// The builder instance to modify.
+ /// The which
+ /// should be utilized.
+ /// A relative path where the key file should be
+ /// stored, generally specified as "/containerName/[subDir/]keys.xml".
+ /// The value .
+ ///
+ /// The container referenced by must already exist.
+ ///
+ public static IDataProtectionBuilder PersistKeysToAzureBlobStorage(this IDataProtectionBuilder builder, CloudStorageAccount storageAccount, string relativePath)
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (storageAccount == null)
+ {
+ throw new ArgumentNullException(nameof(storageAccount));
+ }
+ if (relativePath == null)
+ {
+ throw new ArgumentNullException(nameof(relativePath));
+ }
+
+ // Simply concatenate the root storage endpoint with the relative path,
+ // which includes the container name and blob name.
+
+ var uriBuilder = new UriBuilder(storageAccount.BlobEndpoint);
+ uriBuilder.Path = uriBuilder.Path.TrimEnd('/') + "/" + relativePath.TrimStart('/');
+
+ // We can create a CloudBlockBlob from the storage URI and the creds.
+
+ var blobAbsoluteUri = uriBuilder.Uri;
+ var credentials = storageAccount.Credentials;
+
+ return PersistKeystoAzureBlobStorageInternal(builder, () => new CloudBlockBlob(blobAbsoluteUri, credentials));
+ }
+
+ ///
+ /// Configures the data protection system to persist keys to the specified path
+ /// in Azure Blob Storage.
+ ///
+ /// The builder instance to modify.
+ /// The full URI where the key file should be stored.
+ /// The URI must contain the SAS token as a query string parameter.
+ /// The value .
+ ///
+ /// The container referenced by must already exist.
+ ///
+ public static IDataProtectionBuilder PersistKeysToAzureBlobStorage(this IDataProtectionBuilder builder, Uri blobUri)
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (blobUri == null)
+ {
+ throw new ArgumentNullException(nameof(blobUri));
+ }
+
+ var uriBuilder = new UriBuilder(blobUri);
+
+ // The SAS token is present in the query string.
+
+ if (string.IsNullOrEmpty(uriBuilder.Query))
+ {
+ throw new ArgumentException(
+ message: "URI does not have a SAS token in the query string.",
+ paramName: nameof(blobUri));
+ }
+
+ var credentials = new StorageCredentials(uriBuilder.Query);
+ uriBuilder.Query = null; // no longer needed
+ var blobAbsoluteUri = uriBuilder.Uri;
+
+ return PersistKeystoAzureBlobStorageInternal(builder, () => new CloudBlockBlob(blobAbsoluteUri, credentials));
+ }
+
+ ///
+ /// Configures the data protection system to persist keys to the specified path
+ /// in Azure Blob Storage.
+ ///
+ /// The builder instance to modify.
+ /// The where the
+ /// key file should be stored.
+ /// The value .
+ ///
+ /// The container referenced by must already exist.
+ ///
+ public static IDataProtectionBuilder PersistKeysToAzureBlobStorage(this IDataProtectionBuilder builder, CloudBlockBlob blobReference)
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (blobReference == null)
+ {
+ throw new ArgumentNullException(nameof(blobReference));
+ }
+
+ // We're basically just going to make a copy of this blob.
+ // Use (container, blobName) instead of (storageuri, creds) since the container
+ // is tied to an existing service client, which contains user-settable defaults
+ // like retry policy and secondary connection URIs.
+
+ var container = blobReference.Container;
+ var blobName = blobReference.Name;
+
+ return PersistKeystoAzureBlobStorageInternal(builder, () => container.GetBlockBlobReference(blobName));
+ }
+
+ ///
+ /// Configures the data protection system to persist keys to the specified path
+ /// in Azure Blob Storage.
+ ///
+ /// The builder instance to modify.
+ /// The in which the
+ /// key file should be stored.
+ /// The name of the key file, generally specified
+ /// as "[subdir/]keys.xml"
+ /// The value .
+ ///
+ /// The container referenced by must already exist.
+ ///
+ public static IDataProtectionBuilder PersistKeysToAzureBlobStorage(this IDataProtectionBuilder builder, CloudBlobContainer container, string blobName)
+ {
+ if (builder == null)
+ {
+ throw new ArgumentNullException(nameof(builder));
+ }
+ if (container == null)
+ {
+ throw new ArgumentNullException(nameof(container));
+ }
+ if (blobName == null)
+ {
+ throw new ArgumentNullException(nameof(blobName));
+ }
+ return PersistKeystoAzureBlobStorageInternal(builder, () => container.GetBlockBlobReference(blobName));
+ }
+
+ // important: the Func passed into this method must return a new instance with each call
+ private static IDataProtectionBuilder PersistKeystoAzureBlobStorageInternal(IDataProtectionBuilder config, Func blobRefFactory)
+ {
+ config.Services.AddSingleton(services => new AzureBlobXmlRepository(blobRefFactory));
+ return config;
+ }
+ }
+}
diff --git a/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Microsoft.AspNetCore.DataProtection.Azure.Blob.xproj b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Microsoft.AspNetCore.DataProtection.Azure.Blob.xproj
new file mode 100644
index 0000000000..10f72048c6
--- /dev/null
+++ b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Microsoft.AspNetCore.DataProtection.Azure.Blob.xproj
@@ -0,0 +1,19 @@
+
+
+
+ 14.0.25420
+ $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)
+
+
+
+ cc799b57-81e2-4f45-8a32-0d5f49753c3f
+ Microsoft.AspNetCore.DataProtection.Azure
+ .\obj
+ .\bin\
+
+
+
+ 2.0
+
+
+
\ No newline at end of file
diff --git a/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Properties/AssemblyInfo.cs
new file mode 100644
index 0000000000..8c1d02d738
--- /dev/null
+++ b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Properties/AssemblyInfo.cs
@@ -0,0 +1,12 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.Reflection;
+using System.Resources;
+using System.Runtime.CompilerServices;
+
+[assembly: AssemblyMetadata("Serviceable", "True")]
+[assembly: NeutralResourcesLanguage("en-US")]
+[assembly: AssemblyCompany("Microsoft Corporation.")]
+[assembly: AssemblyCopyright("© Microsoft Corporation. All rights reserved.")]
+[assembly: AssemblyProduct("Microsoft ASP.NET Core")]
diff --git a/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/project.json b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/project.json
new file mode 100644
index 0000000000..27d11056c3
--- /dev/null
+++ b/src/Microsoft.AspNetCore.DataProtection.Azure.Blob/project.json
@@ -0,0 +1,35 @@
+{
+ "version": "0.1.0-*",
+ "description": "Microsoft Azure Blob storrage support as key store.",
+ "packOptions": {
+ "repository": {
+ "type": "git",
+ "url": "git://github.com/aspnet/dataprotection"
+ },
+ "tags": [
+ "aspnetcore",
+ "dataprotection",
+ "azure",
+ "blob"
+ ]
+ },
+ "dependencies": {
+ "Microsoft.AspNetCore.DataProtection": "1.1.0-*",
+ "WindowsAzure.Storage": "7.0.2-preview"
+ },
+ "frameworks": {
+ "net451": {},
+ "netstandard1.5": {
+ "imports": "portable-net45+win8+wp8+wpa81"
+ }
+ },
+ "buildOptions": {
+ "allowUnsafe": true,
+ "warningsAsErrors": true,
+ "keyFile": "../../tools/Key.snk",
+ "nowarn": [
+ "CS1591"
+ ],
+ "xmlDoc": true
+ }
+}
\ No newline at end of file
diff --git a/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/AzureBlobXmlRepositoryTests.cs b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/AzureBlobXmlRepositoryTests.cs
new file mode 100644
index 0000000000..fefee2dad1
--- /dev/null
+++ b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/AzureBlobXmlRepositoryTests.cs
@@ -0,0 +1,112 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System.IO;
+using System.Linq;
+using System.Text;
+using System.Threading.Tasks;
+using System.Xml.Linq;
+using Microsoft.AspNetCore.DataProtection.Azure.Blob;
+using Microsoft.WindowsAzure.Storage;
+using Microsoft.WindowsAzure.Storage.Blob;
+using Moq;
+using Xunit;
+
+namespace Microsoft.AspNetCore.DataProtection.Azure.Test
+{
+ public class AzureBlobXmlRepositoryTests
+ {
+ [Fact]
+ public void StoreCreatesBlobWhenNotExist()
+ {
+ AccessCondition downloadCondition = null;
+ AccessCondition uploadCondition = null;
+ byte[] bytes = null;
+ BlobProperties properties = new BlobProperties();
+
+ var mock = new Mock();
+ mock.SetupGet(c => c.Properties).Returns(properties);
+ mock.Setup(c => c.UploadFromByteArrayAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(async (byte[] buffer, int index, int count, AccessCondition accessCondition, BlobRequestOptions options, OperationContext operationContext) =>
+ {
+ bytes = buffer.Skip(index).Take(count).ToArray();
+ uploadCondition = accessCondition;
+ await Task.Yield();
+ });
+
+ var repository = new AzureBlobXmlRepository(() => mock.Object);
+ repository.StoreElement(new XElement("Element"), null);
+
+ Assert.Null(downloadCondition);
+ Assert.Equal("*", uploadCondition.IfNoneMatchETag);
+ Assert.Equal("application/xml; charset=utf-8", properties.ContentType);
+ var element = "";
+
+ Assert.Equal(bytes, GetEnvelopedContent(element));
+ }
+
+ [Fact]
+ public void StoreUpdatesWhenExistsAndNewerExists()
+ {
+ AccessCondition downloadCondition = null;
+ byte[] bytes = null;
+ BlobProperties properties = new BlobProperties();
+
+ var mock = new Mock();
+ mock.SetupGet(c => c.Properties).Returns(properties);
+ mock.Setup(c => c.DownloadToStreamAsync(
+ It.IsAny(),
+ It.IsAny(),
+ null,
+ null))
+ .Returns(async (Stream target, AccessCondition condition, BlobRequestOptions options, OperationContext context) =>
+ {
+ var data = GetEnvelopedContent("");
+ await target.WriteAsync(data, 0, data.Length);
+ })
+ .Verifiable();
+
+ mock.Setup(c => c.UploadFromByteArrayAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.Is((AccessCondition cond) => cond.IfNoneMatchETag == "*"),
+ It.IsAny(),
+ It.IsAny()))
+ .Throws(new StorageException(new RequestResult { HttpStatusCode = 412 }, null, null))
+ .Verifiable();
+
+ mock.Setup(c => c.UploadFromByteArrayAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.Is((AccessCondition cond) => cond.IfNoneMatchETag != "*"),
+ It.IsAny(),
+ It.IsAny()))
+ .Returns(async (byte[] buffer, int index, int count, AccessCondition accessCondition, BlobRequestOptions options, OperationContext operationContext) =>
+ {
+ bytes = buffer.Skip(index).Take(count).ToArray();
+ await Task.Yield();
+ })
+ .Verifiable();
+
+ var repository = new AzureBlobXmlRepository(() => mock.Object);
+ repository.StoreElement(new XElement("Element2"), null);
+
+ mock.Verify();
+ Assert.Null(downloadCondition);
+ Assert.Equal(bytes, GetEnvelopedContent(""));
+ }
+
+ private static byte[] GetEnvelopedContent(string element)
+ {
+ return Encoding.UTF8.GetBytes($"{element}");
+ }
+ }
+}
diff --git a/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test.xproj b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test.xproj
new file mode 100644
index 0000000000..5f0d8f3acc
--- /dev/null
+++ b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test.xproj
@@ -0,0 +1,21 @@
+
+
+
+ 14.0.25420
+ $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)
+
+
+
+ 8c41240e-48f8-402f-9388-74cfe27f4d76
+ Microsoft.AspNetCore.DataProtection.Azure.Test
+ .\obj
+ .\bin\
+
+
+ 2.0
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/project.json b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/project.json
new file mode 100644
index 0000000000..4f8f44d11b
--- /dev/null
+++ b/test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/project.json
@@ -0,0 +1,38 @@
+{
+ "dependencies": {
+ "dotnet-test-xunit": "2.2.0-*",
+ "Microsoft.AspNetCore.DataProtection": "1.1.0-*",
+ "Microsoft.AspNetCore.DataProtection.Azure.Blob": "1.1.0-*",
+ "Microsoft.AspNetCore.Testing": "1.1.0-*",
+ "Microsoft.Extensions.DependencyInjection": "1.1.0-*",
+ "xunit": "2.2.0-*",
+ "Moq": "4.6.36-*"
+ },
+ "frameworks": {
+ "netcoreapp1.0": {
+ "dependencies": {
+ "Microsoft.NETCore.App": {
+ "version": "1.0.0-*",
+ "type": "platform"
+ },
+ "System.Diagnostics.Process": "4.1.0-*",
+ "System.Diagnostics.TraceSource": "4.0.0-*"
+ },
+ "imports": [
+ "dnxcore50",
+ "portable-net451+win8"
+ ]
+ },
+ "net451": {
+ "frameworkAssemblies": {
+ "System.Threading.Tasks": ""
+ }
+ }
+ },
+ "testRunner": "xunit",
+ "buildOptions": {
+ "allowUnsafe": true,
+ "warningsAsErrors": true,
+ "keyFile": "../../tools/Key.snk"
+ }
+}
\ No newline at end of file
diff --git a/test/Microsoft.AspNetCore.DataProtection.Redis.Test/Microsoft.AspNetCore.DataProtection.Redis.Test.xproj b/test/Microsoft.AspNetCore.DataProtection.Redis.Test/Microsoft.AspNetCore.DataProtection.Redis.Test.xproj
index 123ca898a3..723cb30927 100644
--- a/test/Microsoft.AspNetCore.DataProtection.Redis.Test/Microsoft.AspNetCore.DataProtection.Redis.Test.xproj
+++ b/test/Microsoft.AspNetCore.DataProtection.Redis.Test/Microsoft.AspNetCore.DataProtection.Redis.Test.xproj
@@ -11,9 +11,11 @@
.\obj
.\bin\
-
2.0
+
+
+
\ No newline at end of file