Explorar o código

Port DataProtection blob XmlRepository (#163)

Pavel Krymets %!s(int64=9) %!d(string=hai) anos
pai
achega
0e210dadea

+ 50 - 11
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}") = "samples", "samples", "{5A3A5DE3-49AD-431C-971D-B01B62D94AE2}"
 EndProject
-Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "sample", "sample", "{3A6C77DB-FD3D-4B20-A52B-34F7A7E1AED2}"
+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

+ 1 - 0
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": { }

+ 21 - 0
samples/AzureBlob/AzureBlob.xproj

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project ToolsVersion="14.0" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <PropertyGroup>
+    <VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0</VisualStudioVersion>
+    <VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
+  </PropertyGroup>
+
+  <Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" />
+  <PropertyGroup Label="Globals">
+    <ProjectGuid>b07435b3-cd81-4e3b-88a5-6384821e1c01</ProjectGuid>
+    <RootNamespace>AzureBlob</RootNamespace>
+    <BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
+    <OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
+    <TargetFrameworkVersion>v4.5.2</TargetFrameworkVersion>
+  </PropertyGroup>
+
+  <PropertyGroup>
+    <SchemaVersion>2.0</SchemaVersion>
+  </PropertyGroup>
+  <Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" />
+</Project>

+ 42 - 0
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<ILoggerFactory>();
+            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);
+        }
+    }
+}

+ 26 - 0
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"
+      ]
+    }
+  }
+}

+ 295 - 0
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
+{
+    /// <summary>
+    /// An <see cref="IXmlRepository"/> which is backed by Azure Blob Storage.
+    /// </summary>
+    /// <remarks>
+    /// Instances of this type are thread-safe.
+    /// </remarks>
+    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<ICloudBlob> _blobRefFactory;
+        private readonly Random _random;
+        private BlobData _cachedBlobData;
+
+        /// <summary>
+        /// Creates a new instance of the <see cref="AzureBlobXmlRepository"/>.
+        /// </summary>
+        /// <param name="blobRefFactory">A factory which can create <see cref="ICloudBlob"/>
+        /// instances. The factory must be thread-safe for invocation by multiple
+        /// concurrent threads, and each invocation must return a new object.</param>
+        public AzureBlobXmlRepository(Func<ICloudBlob> blobRefFactory)
+        {
+            if (blobRefFactory == null)
+            {
+                throw new ArgumentNullException(nameof(blobRefFactory));
+            }
+
+            _blobRefFactory = blobRefFactory;
+            _random = new Random();
+        }
+
+        public IReadOnlyCollection<XElement> 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<XElement>(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<IList<XElement>> 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:
+            //
+            // <root>
+            //   <child />
+            //   <child />
+            //   ...
+            // </root>
+            //
+            // 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<BlobData> 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;
+        }
+    }
+}

+ 171 - 0
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
+{
+    /// <summary>
+    /// Contains Azure-specific extension methods for modifying a
+    /// <see cref="IDataProtectionBuilder"/>.
+    /// </summary>
+    public static class AzureDataProtectionBuilderExtensions
+    {
+        /// <summary>
+        /// Configures the data protection system to persist keys to the specified path
+        /// in Azure Blob Storage.
+        /// </summary>
+        /// <param name="builder">The builder instance to modify.</param>
+        /// <param name="storageAccount">The <see cref="CloudStorageAccount"/> which
+        /// should be utilized.</param>
+        /// <param name="relativePath">A relative path where the key file should be
+        /// stored, generally specified as "/containerName/[subDir/]keys.xml".</param>
+        /// <returns>The value <paramref name="builder"/>.</returns>
+        /// <remarks>
+        /// The container referenced by <paramref name="relativePath"/> must already exist.
+        /// </remarks>
+        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));
+        }
+
+        /// <summary>
+        /// Configures the data protection system to persist keys to the specified path
+        /// in Azure Blob Storage.
+        /// </summary>
+        /// <param name="builder">The builder instance to modify.</param>
+        /// <param name="blobUri">The full URI where the key file should be stored.
+        /// The URI must contain the SAS token as a query string parameter.</param>
+        /// <returns>The value <paramref name="builder"/>.</returns>
+        /// <remarks>
+        /// The container referenced by <paramref name="blobUri"/> must already exist.
+        /// </remarks>
+        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));
+        }
+
+        /// <summary>
+        /// Configures the data protection system to persist keys to the specified path
+        /// in Azure Blob Storage.
+        /// </summary>
+        /// <param name="builder">The builder instance to modify.</param>
+        /// <param name="blobReference">The <see cref="CloudBlockBlob"/> where the
+        /// key file should be stored.</param>
+        /// <returns>The value <paramref name="builder"/>.</returns>
+        /// <remarks>
+        /// The container referenced by <paramref name="blobReference"/> must already exist.
+        /// </remarks>
+        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));
+        }
+
+        /// <summary>
+        /// Configures the data protection system to persist keys to the specified path
+        /// in Azure Blob Storage.
+        /// </summary>
+        /// <param name="builder">The builder instance to modify.</param>
+        /// <param name="container">The <see cref="CloudBlobContainer"/> in which the
+        /// key file should be stored.</param>
+        /// <param name="blobName">The name of the key file, generally specified
+        /// as "[subdir/]keys.xml"</param>
+        /// <returns>The value <paramref name="builder"/>.</returns>
+        /// <remarks>
+        /// The container referenced by <paramref name="container"/> must already exist.
+        /// </remarks>
+        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<CloudBlockBlob> blobRefFactory)
+        {
+            config.Services.AddSingleton<IXmlRepository>(services => new AzureBlobXmlRepository(blobRefFactory));
+            return config;
+        }
+    }
+}

+ 19 - 0
src/Microsoft.AspNetCore.DataProtection.Azure.Blob/Microsoft.AspNetCore.DataProtection.Azure.Blob.xproj

@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project ToolsVersion="14.0.25420" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <PropertyGroup>
+    <VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0.25420</VisualStudioVersion>
+    <VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
+  </PropertyGroup>
+  <Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" />
+  <PropertyGroup Label="Globals">
+    <ProjectGuid>cc799b57-81e2-4f45-8a32-0d5f49753c3f</ProjectGuid>
+    <RootNamespace>Microsoft.AspNetCore.DataProtection.Azure</RootNamespace>
+    <BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
+    <OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
+  </PropertyGroup>
+
+  <PropertyGroup>
+    <SchemaVersion>2.0</SchemaVersion>
+  </PropertyGroup>
+  <Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" />
+</Project>

+ 12 - 0
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")]

+ 35 - 0
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
+  }
+}

+ 112 - 0
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<ICloudBlob>();
+            mock.SetupGet(c => c.Properties).Returns(properties);
+            mock.Setup(c => c.UploadFromByteArrayAsync(
+                    It.IsAny<byte[]>(),
+                    It.IsAny<int>(),
+                    It.IsAny<int>(),
+                    It.IsAny<AccessCondition>(),
+                    It.IsAny<BlobRequestOptions>(),
+                    It.IsAny<OperationContext>()))
+                .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 = "<Element />";
+
+            Assert.Equal(bytes, GetEnvelopedContent(element));
+        }
+
+        [Fact]
+        public void StoreUpdatesWhenExistsAndNewerExists()
+        {
+            AccessCondition downloadCondition = null;
+            byte[] bytes = null;
+            BlobProperties properties = new BlobProperties();
+
+            var mock = new Mock<ICloudBlob>();
+            mock.SetupGet(c => c.Properties).Returns(properties);
+            mock.Setup(c => c.DownloadToStreamAsync(
+                    It.IsAny<Stream>(),
+                    It.IsAny<AccessCondition>(),
+                    null,
+                    null))
+                .Returns(async (Stream target, AccessCondition condition, BlobRequestOptions options, OperationContext context) =>
+                {
+                    var data = GetEnvelopedContent("<Element1 />");
+                    await target.WriteAsync(data, 0, data.Length);
+                })
+                .Verifiable();
+
+            mock.Setup(c => c.UploadFromByteArrayAsync(
+                    It.IsAny<byte[]>(),
+                    It.IsAny<int>(),
+                    It.IsAny<int>(),
+                    It.Is((AccessCondition cond) => cond.IfNoneMatchETag == "*"),
+                    It.IsAny<BlobRequestOptions>(),
+                    It.IsAny<OperationContext>()))
+                .Throws(new StorageException(new RequestResult { HttpStatusCode = 412 }, null, null))
+                .Verifiable();
+
+            mock.Setup(c => c.UploadFromByteArrayAsync(
+                    It.IsAny<byte[]>(),
+                    It.IsAny<int>(),
+                    It.IsAny<int>(),
+                    It.Is((AccessCondition cond) => cond.IfNoneMatchETag != "*"),
+                    It.IsAny<BlobRequestOptions>(),
+                    It.IsAny<OperationContext>()))
+                .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("<Element1 /><Element2 />"));
+        }
+
+        private static byte[] GetEnvelopedContent(string element)
+        {
+            return Encoding.UTF8.GetBytes($"<?xml version=\"1.0\" encoding=\"utf-8\"?><repository>{element}</repository>");
+        }
+    }
+}

+ 21 - 0
test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test/Microsoft.AspNetCore.DataProtection.Azure.Blob.Test.xproj

@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<Project ToolsVersion="14.0.25420" DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
+  <PropertyGroup>
+    <VisualStudioVersion Condition="'$(VisualStudioVersion)' == ''">14.0.25420</VisualStudioVersion>
+    <VSToolsPath Condition="'$(VSToolsPath)' == ''">$(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion)</VSToolsPath>
+  </PropertyGroup>
+  <Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.Props" Condition="'$(VSToolsPath)' != ''" />
+  <PropertyGroup Label="Globals">
+    <ProjectGuid>8c41240e-48f8-402f-9388-74cfe27f4d76</ProjectGuid>
+    <RootNamespace>Microsoft.AspNetCore.DataProtection.Azure.Test</RootNamespace>
+    <BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
+    <OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
+  </PropertyGroup>
+  <PropertyGroup>
+    <SchemaVersion>2.0</SchemaVersion>
+  </PropertyGroup>
+  <ItemGroup>
+    <Service Include="{82a7f48d-3b50-4b1e-b82e-3ada8210c358}" />
+  </ItemGroup>
+  <Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" />
+</Project>

+ 38 - 0
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"
+  }
+}

+ 3 - 1
test/Microsoft.AspNetCore.DataProtection.Redis.Test/Microsoft.AspNetCore.DataProtection.Redis.Test.xproj

@@ -11,9 +11,11 @@
     <BaseIntermediateOutputPath Condition="'$(BaseIntermediateOutputPath)'=='' ">.\obj</BaseIntermediateOutputPath>
     <OutputPath Condition="'$(OutputPath)'=='' ">.\bin\</OutputPath>
   </PropertyGroup>
-
   <PropertyGroup>
     <SchemaVersion>2.0</SchemaVersion>
   </PropertyGroup>
+  <ItemGroup>
+    <Service Include="{82a7f48d-3b50-4b1e-b82e-3ada8210c358}" />
+  </ItemGroup>
   <Import Project="$(VSToolsPath)\DotNet\Microsoft.DotNet.targets" Condition="'$(VSToolsPath)' != ''" />
 </Project>