diff --git a/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationHandler.cs b/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationHandler.cs index 1e21937fab..23f66b6c5b 100644 --- a/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationHandler.cs +++ b/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationHandler.cs @@ -37,8 +37,7 @@ namespace Microsoft.AspNet.Security.Cookies protected override async Task AuthenticateCoreAsync() { - IReadableStringCollection cookies = Request.Cookies; - string cookie = cookies[Options.CookieName]; + string cookie = Options.CookieManager.GetRequestCookie(Context, Options.CookieName); if (string.IsNullOrWhiteSpace(cookie)) { return null; @@ -148,7 +147,8 @@ namespace Microsoft.AspNet.Security.Cookies var model = new AuthenticationTicket(context.Identity, context.Properties); string cookieValue = Options.TicketDataFormat.Protect(model); - Response.Cookies.Append( + Options.CookieManager.AppendResponseCookie( + Context, Options.CookieName, cookieValue, cookieOptions); @@ -162,7 +162,8 @@ namespace Microsoft.AspNet.Security.Cookies Options.Notifications.ResponseSignOut(context); - Response.Cookies.Delete( + Options.CookieManager.DeleteCookie( + Context, Options.CookieName, cookieOptions); } @@ -180,7 +181,8 @@ namespace Microsoft.AspNet.Security.Cookies cookieOptions.Expires = _renewExpiresUtc.ToUniversalTime().DateTime; } - Response.Cookies.Append( + Options.CookieManager.AppendResponseCookie( + Context, Options.CookieName, cookieValue, cookieOptions); diff --git a/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationMiddleware.cs b/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationMiddleware.cs index 6f2a5dec04..1349ccadeb 100644 --- a/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationMiddleware.cs +++ b/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationMiddleware.cs @@ -5,6 +5,7 @@ using System; using Microsoft.AspNet.Builder; using Microsoft.AspNet.Http; +using Microsoft.AspNet.Security.Cookies.Infrastructure; using Microsoft.AspNet.Security.DataHandler; using Microsoft.AspNet.Security.DataProtection; using Microsoft.AspNet.Security.Infrastructure; @@ -33,6 +34,10 @@ namespace Microsoft.AspNet.Security.Cookies typeof(CookieAuthenticationMiddleware).FullName, options.AuthenticationType, "v1"); options.TicketDataFormat = new TicketDataFormat(dataProtector); } + if (Options.CookieManager == null) + { + Options.CookieManager = new ChunkingCookieManager(); + } _logger = loggerFactory.Create(typeof(CookieAuthenticationMiddleware).FullName); } diff --git a/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationOptions.cs b/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationOptions.cs index f04c51264b..0b6ef879e9 100644 --- a/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationOptions.cs +++ b/src/Microsoft.AspNet.Security.Cookies/CookieAuthenticationOptions.cs @@ -5,6 +5,7 @@ using System; using System.Diagnostics.CodeAnalysis; using Microsoft.AspNet.Http; +using Microsoft.AspNet.Security.Cookies.Infrastructure; using Microsoft.AspNet.Security.Infrastructure; namespace Microsoft.AspNet.Security.Cookies @@ -135,5 +136,12 @@ namespace Microsoft.AspNet.Security.Cookies /// used which calls DateTimeOffset.UtcNow. This is typically not replaced except for unit testing. /// public ISystemClock SystemClock { get; set; } + + /// + /// The component used to get cookies from the request or set them on the response. + /// + /// ChunkingCookieManager will be used by default. + /// + public ICookieManager CookieManager { get; set; } } } diff --git a/src/Microsoft.AspNet.Security.Cookies/Infrastructure/ChunkingCookieManager.cs b/src/Microsoft.AspNet.Security.Cookies/Infrastructure/ChunkingCookieManager.cs new file mode 100644 index 0000000000..d5e48c1983 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Cookies/Infrastructure/ChunkingCookieManager.cs @@ -0,0 +1,310 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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.Globalization; +using System.Linq; +using Microsoft.AspNet.Http; + +namespace Microsoft.AspNet.Security.Cookies.Infrastructure +{ + /// + /// This handles cookies that are limited by per cookie length. It breaks down long cookies for responses, and reassembles them + /// from requests. + /// + public class ChunkingCookieManager : ICookieManager + { + public ChunkingCookieManager() + { + ChunkSize = 4090; + ThrowForPartialCookies = true; + } + + /// + /// The maximum size of cookie to send back to the client. If a cookie exceeds this size it will be broken down into multiple + /// cookies. Set this value to null to disable this behavior. The default is 4090 characters, which is supported by all + /// common browsers. + /// + /// Note that browsers may also have limits on the total size of all cookies per domain, and on the number of cookies per domain. + /// + public int? ChunkSize { get; set; } + + /// + /// Throw if not all chunks of a cookie are available on a request for re-assembly. + /// + public bool ThrowForPartialCookies { get; set; } + + // Parse the "chunks:XX" to determine how many chunks there should be. + private static int ParseChunksCount(string value) + { + if (value != null && value.StartsWith("chunks:", StringComparison.Ordinal)) + { + string chunksCountString = value.Substring("chunks:".Length); + int chunksCount; + if (int.TryParse(chunksCountString, NumberStyles.None, CultureInfo.InvariantCulture, out chunksCount)) + { + return chunksCount; + } + } + return 0; + } + + /// + /// Get the reassembled cookie. Non chunked cookies are returned normally. + /// Cookies with missing chunks just have their "chunks:XX" header returned. + /// + /// + /// + /// The reassembled cookie, if any, or null. + public string GetRequestCookie(HttpContext context, string key) + { + if (context == null) + { + throw new ArgumentNullException("context"); + } + + IReadableStringCollection requestCookies = context.Request.Cookies; + string value = requestCookies[key]; + int chunksCount = ParseChunksCount(value); + if (chunksCount > 0) + { + bool quoted = false; + string[] chunks = new string[chunksCount]; + for (int chunkId = 1; chunkId <= chunksCount; chunkId++) + { + string chunk = requestCookies[key + "C" + chunkId.ToString(CultureInfo.InvariantCulture)]; + if (chunk == null) + { + if (ThrowForPartialCookies) + { + int totalSize = 0; + for (int i = 0; i < chunkId - 1; i++) + { + totalSize += chunks[i].Length; + } + throw new FormatException( + string.Format(CultureInfo.CurrentCulture, Resources.Exception_ImcompleteChunkedCookie, chunkId - 1, chunksCount, totalSize)); + } + // Missing chunk, abort by returning the original cookie value. It may have been a false positive? + return value; + } + if (IsQuoted(chunk)) + { + // Note: Since we assume these cookies were generated by our code, then we can assume that if one cookie has quotes then they all do. + quoted = true; + chunk = RemoveQuotes(chunk); + } + chunks[chunkId - 1] = chunk; + } + string merged = string.Join(string.Empty, chunks); + if (quoted) + { + merged = Quote(merged); + } + return merged; + } + return value; + } + + /// + /// Appends a new response cookie to the Set-Cookie header. If the cookie is larger than the given size limit + /// then it will be broken down into multiple cookies as follows: + /// Set-Cookie: CookieName=chunks:3; path=/ + /// Set-Cookie: CookieNameC1=Segment1; path=/ + /// Set-Cookie: CookieNameC2=Segment2; path=/ + /// Set-Cookie: CookieNameC3=Segment3; path=/ + /// + /// + /// + /// + /// + public void AppendResponseCookie(HttpContext context, string key, string value, CookieOptions options) + { + if (context == null) + { + throw new ArgumentNullException("context"); + } + if (options == null) + { + throw new ArgumentNullException("options"); + } + + bool domainHasValue = !string.IsNullOrEmpty(options.Domain); + bool pathHasValue = !string.IsNullOrEmpty(options.Path); + bool expiresHasValue = options.Expires.HasValue; + + string escapedKey = Uri.EscapeDataString(key); + string prefix = escapedKey + "="; + + string suffix = string.Concat( + !domainHasValue ? null : "; domain=", + !domainHasValue ? null : options.Domain, + !pathHasValue ? null : "; path=", + !pathHasValue ? null : options.Path, + !expiresHasValue ? null : "; expires=", + !expiresHasValue ? null : options.Expires.Value.ToString("ddd, dd-MMM-yyyy HH:mm:ss ", CultureInfo.InvariantCulture) + "GMT", + !options.Secure ? null : "; secure", + !options.HttpOnly ? null : "; HttpOnly"); + + value = value ?? string.Empty; + bool quoted = false; + if (IsQuoted(value)) + { + quoted = true; + value = RemoveQuotes(value); + } + string escapedValue = Uri.EscapeDataString(value); + + // Normal cookie + IHeaderDictionary responseHeaders = context.Response.Headers; + if (!ChunkSize.HasValue || ChunkSize.Value > prefix.Length + escapedValue.Length + suffix.Length + (quoted ? 2 : 0)) + { + string setCookieValue = string.Concat( + prefix, + quoted ? Quote(escapedValue) : escapedValue, + suffix); + responseHeaders.AppendValues(Constants.Headers.SetCookie, setCookieValue); + } + else if (ChunkSize.Value < prefix.Length + suffix.Length + (quoted ? 2 : 0) + 10) + { + // 10 is the minimum data we want to put in an individual cookie, including the cookie chunk identifier "CXX". + // No room for data, we can't chunk the options and name + throw new InvalidOperationException(Resources.Exception_CookieLimitTooSmall); + } + else + { + // Break the cookie down into multiple cookies. + // Key = CookieName, value = "Segment1Segment2Segment2" + // Set-Cookie: CookieName=chunks:3; path=/ + // Set-Cookie: CookieNameC1="Segment1"; path=/ + // Set-Cookie: CookieNameC2="Segment2"; path=/ + // Set-Cookie: CookieNameC3="Segment3"; path=/ + int dataSizePerCookie = ChunkSize.Value - prefix.Length - suffix.Length - (quoted ? 2 : 0) - 3; // Budget 3 chars for the chunkid. + int cookieChunkCount = (int)Math.Ceiling(escapedValue.Length * 1.0 / dataSizePerCookie); + + responseHeaders.AppendValues(Constants.Headers.SetCookie, prefix + "chunks:" + cookieChunkCount.ToString(CultureInfo.InvariantCulture) + suffix); + + string[] chunks = new string[cookieChunkCount]; + int offset = 0; + for (int chunkId = 1; chunkId <= cookieChunkCount; chunkId++) + { + int remainingLength = escapedValue.Length - offset; + int length = Math.Min(dataSizePerCookie, remainingLength); + string segment = escapedValue.Substring(offset, length); + offset += length; + + chunks[chunkId - 1] = string.Concat( + escapedKey, + "C", + chunkId.ToString(CultureInfo.InvariantCulture), + "=", + quoted ? "\"" : string.Empty, + segment, + quoted ? "\"" : string.Empty, + suffix); + } + responseHeaders.AppendValues(Constants.Headers.SetCookie, chunks); + } + } + + /// + /// Deletes the cookie with the given key by setting an expired state. If a matching chunked cookie exists on + /// the request, delete each chunk. + /// + /// + /// + /// + public void DeleteCookie(HttpContext context, string key, CookieOptions options) + { + if (context == null) + { + throw new ArgumentNullException("context"); + } + if (options == null) + { + throw new ArgumentNullException("options"); + } + + string escapedKey = Uri.EscapeDataString(key); + List keys = new List(); + keys.Add(escapedKey + "="); + + string requestCookie = context.Request.Cookies[key]; + int chunks = ParseChunksCount(requestCookie); + if (chunks > 0) + { + for (int i = 1; i <= chunks + 1; i++) + { + string subkey = escapedKey + "C" + i.ToString(CultureInfo.InvariantCulture); + keys.Add(subkey + "="); + } + } + + bool domainHasValue = !string.IsNullOrEmpty(options.Domain); + bool pathHasValue = !string.IsNullOrEmpty(options.Path); + + Func rejectPredicate; + Func predicate = value => keys.Any(k => value.StartsWith(k, StringComparison.OrdinalIgnoreCase)); + if (domainHasValue) + { + rejectPredicate = value => predicate(value) && value.IndexOf("domain=" + options.Domain, StringComparison.OrdinalIgnoreCase) != -1; + } + else if (pathHasValue) + { + rejectPredicate = value => predicate(value) && value.IndexOf("path=" + options.Path, StringComparison.OrdinalIgnoreCase) != -1; + } + else + { + rejectPredicate = value => predicate(value); + } + + IHeaderDictionary responseHeaders = context.Response.Headers; + IList existingValues = responseHeaders.GetValues(Constants.Headers.SetCookie); + if (existingValues != null) + { + responseHeaders.SetValues(Constants.Headers.SetCookie, existingValues.Where(value => !rejectPredicate(value)).ToArray()); + } + + AppendResponseCookie( + context, + key, + string.Empty, + new CookieOptions + { + Path = options.Path, + Domain = options.Domain, + Expires = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }); + + for (int i = 1; i <= chunks; i++) + { + AppendResponseCookie( + context, + key + "C" + i.ToString(CultureInfo.InvariantCulture), + string.Empty, + new CookieOptions + { + Path = options.Path, + Domain = options.Domain, + Expires = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc), + }); + } + } + + private static bool IsQuoted(string value) + { + return value.Length >= 2 && value[0] == '"' && value[value.Length - 1] == '"'; + } + + private static string RemoveQuotes(string value) + { + return value.Substring(1, value.Length - 2); + } + + private static string Quote(string value) + { + return '"' + value + '"'; + } + } +} diff --git a/src/Microsoft.AspNet.Security.Cookies/Infrastructure/Constants.cs b/src/Microsoft.AspNet.Security.Cookies/Infrastructure/Constants.cs new file mode 100644 index 0000000000..ef8db8e9e0 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Cookies/Infrastructure/Constants.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +namespace Microsoft.AspNet.Security.Cookies.Infrastructure +{ + internal static class Constants + { + internal static class Headers + { + internal const string SetCookie = "Set-Cookie"; + } + } +} diff --git a/src/Microsoft.AspNet.Security.Cookies/Infrastructure/ICookieManager.cs b/src/Microsoft.AspNet.Security.Cookies/Infrastructure/ICookieManager.cs new file mode 100644 index 0000000000..b523b1bad7 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Cookies/Infrastructure/ICookieManager.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using Microsoft.AspNet.Http; + +namespace Microsoft.AspNet.Security.Cookies.Infrastructure +{ + /// + /// This is used by the CookieAuthenticationMiddleware to process request and response cookies. + /// It is abstracted from the normal cookie APIs to allow for complex operations like chunking. + /// + public interface ICookieManager + { + /// + /// Retrieve a cookie of the given name from the request. + /// + /// + /// + /// + string GetRequestCookie(HttpContext context, string key); + + /// + /// Append the given cookie to the response. + /// + /// + /// + /// + /// + void AppendResponseCookie(HttpContext context, string key, string value, CookieOptions options); + + /// + /// Append a delete cookie to the response. + /// + /// + /// + /// + void DeleteCookie(HttpContext context, string key, CookieOptions options); + } +} diff --git a/src/Microsoft.AspNet.Security.Cookies/Resources.Designer.cs b/src/Microsoft.AspNet.Security.Cookies/Resources.Designer.cs new file mode 100644 index 0000000000..1a0258ba66 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Cookies/Resources.Designer.cs @@ -0,0 +1,85 @@ +//------------------------------------------------------------------------------ +// +// This code was generated by a tool. +// Runtime Version:4.0.30319.34003 +// +// Changes to this file may cause incorrect behavior and will be lost if +// the code is regenerated. +// +//------------------------------------------------------------------------------ + +namespace Microsoft.AspNet.Security.Cookies { + using System; + + + /// + /// A strongly-typed resource class, for looking up localized strings, etc. + /// + // This class was auto-generated by the StronglyTypedResourceBuilder + // class via a tool like ResGen or Visual Studio. + // To add or remove a member, edit your .ResX file then rerun ResGen + // with the /str option, or rebuild your VS project. + [global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "4.0.0.0")] + [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] + [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] + internal class Resources { + + private static global::System.Resources.ResourceManager resourceMan; + + private static global::System.Globalization.CultureInfo resourceCulture; + + [global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")] + internal Resources() { + } + + /// + /// Returns the cached ResourceManager instance used by this class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Resources.ResourceManager ResourceManager { + get { + if (object.ReferenceEquals(resourceMan, null)) { + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("Microsoft.AspNet.Security.Cookies.Resources", System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(Resources)).Assembly); + resourceMan = temp; + } + return resourceMan; + } + } + + /// + /// Overrides the current thread's CurrentUICulture property for all + /// resource lookups using this strongly typed resource class. + /// + [global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)] + internal static global::System.Globalization.CultureInfo Culture { + get { + return resourceCulture; + } + set { + resourceCulture = value; + } + } + + /// + /// Looks up a localized string similar to The cookie key and options are larger than ChunksSize, leaving no room for data.. + /// + internal static string Exception_CookieLimitTooSmall + { + get + { + return ResourceManager.GetString("Exception_CookieLimitTooSmall", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to The chunked cookie is incomplete. Only {0} of the expected {1} chunks were found, totaling {2} characters. A client size limit may have been exceeded.. + /// + internal static string Exception_ImcompleteChunkedCookie + { + get + { + return ResourceManager.GetString("Exception_ImcompleteChunkedCookie", resourceCulture); + } + } + } +} diff --git a/src/Microsoft.AspNet.Security.Cookies/Resources.resx b/src/Microsoft.AspNet.Security.Cookies/Resources.resx new file mode 100644 index 0000000000..71debecfa3 --- /dev/null +++ b/src/Microsoft.AspNet.Security.Cookies/Resources.resx @@ -0,0 +1,126 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + The cookie key and options are larger than ChunksSize, leaving no room for data. + + + The chunked cookie is incomplete. Only {0} of the expected {1} chunks were found, totaling {2} characters. A client size limit may have been exceeded. + + \ No newline at end of file diff --git a/test/Microsoft.AspNet.Security.Test/Cookies/Infrastructure/CookieChunkingTests.cs b/test/Microsoft.AspNet.Security.Test/Cookies/Infrastructure/CookieChunkingTests.cs new file mode 100644 index 0000000000..4df8763bd9 --- /dev/null +++ b/test/Microsoft.AspNet.Security.Test/Cookies/Infrastructure/CookieChunkingTests.cs @@ -0,0 +1,168 @@ +// Copyright (c) Microsoft Open Technologies, Inc. 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 Microsoft.AspNet.Http; +using Microsoft.AspNet.PipelineCore; +using Xunit; + +namespace Microsoft.AspNet.Security.Cookies.Infrastructure +{ + public class CookieChunkingTests + { + [Fact] + public void AppendLargeCookie_Appended() + { + HttpContext context = new DefaultHttpContext(); + + string testString = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + new ChunkingCookieManager() { ChunkSize = null }.AppendResponseCookie(context, "TestCookie", testString, new CookieOptions()); + IList values = context.Response.Headers.GetValues("Set-Cookie"); + Assert.Equal(1, values.Count); + Assert.Equal("TestCookie=" + testString + "; path=/", values[0]); + } + + [Fact] + public void AppendLargeCookieWithLimit_Chunked() + { + HttpContext context = new DefaultHttpContext(); + + string testString = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + new ChunkingCookieManager() { ChunkSize = 30 }.AppendResponseCookie(context, "TestCookie", testString, new CookieOptions()); + IList values = context.Response.Headers.GetValues("Set-Cookie"); + Assert.Equal(9, values.Count); + Assert.Equal(new[] + { + "TestCookie=chunks:8; path=/", + "TestCookieC1=abcdefgh; path=/", + "TestCookieC2=ijklmnop; path=/", + "TestCookieC3=qrstuvwx; path=/", + "TestCookieC4=yz012345; path=/", + "TestCookieC5=6789ABCD; path=/", + "TestCookieC6=EFGHIJKL; path=/", + "TestCookieC7=MNOPQRST; path=/", + "TestCookieC8=UVWXYZ; path=/", + }, values); + } + + [Fact] + public void AppendLargeQuotedCookieWithLimit_QuotedChunked() + { + HttpContext context = new DefaultHttpContext(); + + string testString = "\"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\""; + new ChunkingCookieManager() { ChunkSize = 32 }.AppendResponseCookie(context, "TestCookie", testString, new CookieOptions()); + IList values = context.Response.Headers.GetValues("Set-Cookie"); + Assert.Equal(9, values.Count); + Assert.Equal(new[] + { + "TestCookie=chunks:8; path=/", + "TestCookieC1=\"abcdefgh\"; path=/", + "TestCookieC2=\"ijklmnop\"; path=/", + "TestCookieC3=\"qrstuvwx\"; path=/", + "TestCookieC4=\"yz012345\"; path=/", + "TestCookieC5=\"6789ABCD\"; path=/", + "TestCookieC6=\"EFGHIJKL\"; path=/", + "TestCookieC7=\"MNOPQRST\"; path=/", + "TestCookieC8=\"UVWXYZ\"; path=/", + }, values); + } + + [Fact] + public void GetLargeChunkedCookie_Reassembled() + { + HttpContext context = new DefaultHttpContext(); + context.Request.Headers.AppendValues("Cookie", + "TestCookie=chunks:7", + "TestCookieC1=abcdefghi", + "TestCookieC2=jklmnopqr", + "TestCookieC3=stuvwxyz0", + "TestCookieC4=123456789", + "TestCookieC5=ABCDEFGHI", + "TestCookieC6=JKLMNOPQR", + "TestCookieC7=STUVWXYZ"); + + string result = new ChunkingCookieManager().GetRequestCookie(context, "TestCookie"); + string testString = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + Assert.Equal(testString, result); + } + + [Fact] + public void GetLargeChunkedCookieWithQuotes_Reassembled() + { + HttpContext context = new DefaultHttpContext(); + context.Request.Headers.AppendValues("Cookie", + "TestCookie=chunks:7", + "TestCookieC1=\"abcdefghi\"", + "TestCookieC2=\"jklmnopqr\"", + "TestCookieC3=\"stuvwxyz0\"", + "TestCookieC4=\"123456789\"", + "TestCookieC5=\"ABCDEFGHI\"", + "TestCookieC6=\"JKLMNOPQR\"", + "TestCookieC7=\"STUVWXYZ\""); + + string result = new ChunkingCookieManager().GetRequestCookie(context, "TestCookie"); + string testString = "\"abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ\""; + Assert.Equal(testString, result); + } + + [Fact] + public void GetLargeChunkedCookieWithMissingChunk_ThrowingEnabled_Throws() + { + HttpContext context = new DefaultHttpContext(); + context.Request.Headers.AppendValues("Cookie", + "TestCookie=chunks:7", + "TestCookieC1=abcdefghi", + // Missing chunk "TestCookieC2=jklmnopqr", + "TestCookieC3=stuvwxyz0", + "TestCookieC4=123456789", + "TestCookieC5=ABCDEFGHI", + "TestCookieC6=JKLMNOPQR", + "TestCookieC7=STUVWXYZ"); + + Assert.Throws(() => new ChunkingCookieManager().GetRequestCookie(context, "TestCookie")); + } + + [Fact] + public void GetLargeChunkedCookieWithMissingChunk_ThrowingDisabled_NotReassembled() + { + HttpContext context = new DefaultHttpContext(); + context.Request.Headers.AppendValues("Cookie", + "TestCookie=chunks:7", + "TestCookieC1=abcdefghi", + // Missing chunk "TestCookieC2=jklmnopqr", + "TestCookieC3=stuvwxyz0", + "TestCookieC4=123456789", + "TestCookieC5=ABCDEFGHI", + "TestCookieC6=JKLMNOPQR", + "TestCookieC7=STUVWXYZ"); + + string result = new ChunkingCookieManager() { ThrowForPartialCookies = false }.GetRequestCookie(context, "TestCookie"); + string testString = "chunks:7"; + Assert.Equal(testString, result); + } + + [Fact] + public void DeleteChunkedCookieWithOptions_AllDeleted() + { + HttpContext context = new DefaultHttpContext(); + context.Request.Headers.AppendValues("Cookie", "TestCookie=chunks:7"); + + new ChunkingCookieManager().DeleteCookie(context, "TestCookie", new CookieOptions() { Domain = "foo.com" }); + var cookies = context.Response.Headers.GetValues("Set-Cookie"); + Assert.Equal(8, cookies.Count); + Assert.Equal(new[] + { + "TestCookie=; domain=foo.com; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT", + "TestCookieC1=; domain=foo.com; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT", + "TestCookieC2=; domain=foo.com; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT", + "TestCookieC3=; domain=foo.com; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT", + "TestCookieC4=; domain=foo.com; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT", + "TestCookieC5=; domain=foo.com; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT", + "TestCookieC6=; domain=foo.com; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT", + "TestCookieC7=; domain=foo.com; path=/; expires=Thu, 01-Jan-1970 00:00:00 GMT", + }, cookies); + } + } +}