Skip to content

Commit 5026aef

Browse files
authored
Expose cookie extensions, consolidate cookie handling #39968 (#42119)
1 parent 9711c57 commit 5026aef

File tree

17 files changed

+308
-99
lines changed

17 files changed

+308
-99
lines changed

src/Http/Headers/src/CacheControlHeaderValue.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,7 @@ public static CacheControlHeaderValue Parse(StringSegment input)
502502
/// </summary>
503503
/// <param name="input">The value to parse.</param>
504504
/// <param name="parsedValue">The parsed value.</param>
505-
/// <returns><see langword="true"/> if input is a valid <see cref="SetCookieHeaderValue"/>, otherwise <see langword="false"/>.</returns>
505+
/// <returns><see langword="true"/> if input is a valid <see cref="CacheControlHeaderValue"/>, otherwise <see langword="false"/>.</returns>
506506
public static bool TryParse(StringSegment input, [NotNullWhen(true)] out CacheControlHeaderValue? parsedValue)
507507
{
508508
var index = 0;

src/Http/Headers/src/SetCookieHeaderValue.cs

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ private static readonly HttpHeaderParser<SetCookieHeaderValue> MultipleValuePars
4141

4242
private StringSegment _name;
4343
private StringSegment _value;
44+
private List<StringSegment>? _extensions;
4445

4546
private SetCookieHeaderValue()
4647
{
@@ -177,7 +178,10 @@ public StringSegment Value
177178
/// <summary>
178179
/// Gets a collection of additional values to append to the cookie.
179180
/// </summary>
180-
public IList<StringSegment> Extensions { get; } = new List<StringSegment>();
181+
public IList<StringSegment> Extensions
182+
{
183+
get => _extensions ??= new List<StringSegment>();
184+
}
181185

182186
// name="value"; expires=Sun, 06 Nov 1994 08:49:37 GMT; max-age=86400; domain=domain1; path=path1; secure; samesite={strict|lax|none}; httponly
183187
/// <inheritdoc />
@@ -236,9 +240,12 @@ public override string ToString()
236240
length += SeparatorToken.Length + HttpOnlyToken.Length;
237241
}
238242

239-
foreach (var extension in Extensions)
243+
if (_extensions?.Count > 0)
240244
{
241-
length += SeparatorToken.Length + extension.Length;
245+
foreach (var extension in _extensions)
246+
{
247+
length += SeparatorToken.Length + extension.Length;
248+
}
242249
}
243250

244251
return string.Create(length, (this, maxAge, sameSite), (span, tuple) =>
@@ -291,9 +298,12 @@ public override string ToString()
291298
AppendSegment(ref span, HttpOnlyToken, null);
292299
}
293300

294-
foreach (var extension in Extensions)
301+
if (_extensions?.Count > 0)
295302
{
296-
AppendSegment(ref span, extension, null);
303+
foreach (var extension in _extensions)
304+
{
305+
AppendSegment(ref span, extension, null);
306+
}
297307
}
298308
});
299309
}
@@ -373,9 +383,12 @@ public void AppendToStringBuilder(StringBuilder builder)
373383
AppendSegment(builder, HttpOnlyToken, null);
374384
}
375385

376-
foreach (var extension in Extensions)
386+
if (_extensions?.Count > 0)
377387
{
378-
AppendSegment(builder, extension, null);
388+
foreach (var extension in _extensions)
389+
{
390+
AppendSegment(builder, extension, null);
391+
}
379392
}
380393
}
381394

@@ -701,7 +714,7 @@ public override bool Equals(object? obj)
701714
&& Secure == other.Secure
702715
&& SameSite == other.SameSite
703716
&& HttpOnly == other.HttpOnly
704-
&& HeaderUtilities.AreEqualCollections(Extensions, other.Extensions, StringSegmentComparer.OrdinalIgnoreCase);
717+
&& HeaderUtilities.AreEqualCollections(_extensions, other._extensions, StringSegmentComparer.OrdinalIgnoreCase);
705718
}
706719

707720
/// <inheritdoc />
@@ -717,9 +730,12 @@ public override int GetHashCode()
717730
^ SameSite.GetHashCode()
718731
^ HttpOnly.GetHashCode();
719732

720-
foreach (var extension in Extensions)
733+
if (_extensions?.Count > 0)
721734
{
722-
hash ^= extension.GetHashCode();
735+
foreach (var extension in _extensions)
736+
{
737+
hash ^= extension.GetHashCode();
738+
}
723739
}
724740

725741
return hash;

src/Http/Http.Abstractions/src/CookieBuilder.cs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ namespace Microsoft.AspNetCore.Http;
1111
public class CookieBuilder
1212
{
1313
private string? _name;
14+
private List<string>? _extensions;
1415

1516
/// <summary>
1617
/// The name of the cookie.
@@ -77,6 +78,14 @@ public virtual string? Name
7778
/// </summary>
7879
public virtual bool IsEssential { get; set; }
7980

81+
/// <summary>
82+
/// Gets a collection of additional values to append to the cookie.
83+
/// </summary>
84+
public IList<string> Extensions
85+
{
86+
get => _extensions ??= new List<string>();
87+
}
88+
8089
/// <summary>
8190
/// Creates the cookie options from the given <paramref name="context"/>.
8291
/// </summary>
@@ -97,7 +106,7 @@ public virtual CookieOptions Build(HttpContext context, DateTimeOffset expiresFr
97106
throw new ArgumentNullException(nameof(context));
98107
}
99108

100-
return new CookieOptions
109+
var options = new CookieOptions
101110
{
102111
Path = Path ?? "/",
103112
SameSite = SameSite,
@@ -108,5 +117,14 @@ public virtual CookieOptions Build(HttpContext context, DateTimeOffset expiresFr
108117
Secure = SecurePolicy == CookieSecurePolicy.Always || (SecurePolicy == CookieSecurePolicy.SameAsRequest && context.Request.IsHttps),
109118
Expires = Expiration.HasValue ? expiresFrom.Add(Expiration.GetValueOrDefault()) : default(DateTimeOffset?)
110119
};
120+
121+
if (_extensions?.Count > 0)
122+
{
123+
foreach (var extension in _extensions)
124+
{
125+
options.Extensions.Add(extension);
126+
}
127+
}
128+
return options;
111129
}
112130
}

src/Http/Http.Abstractions/src/PublicAPI.Unshipped.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ Microsoft.AspNetCore.Builder.EndpointBuilder.ApplicationServices.get -> System.I
55
Microsoft.AspNetCore.Builder.EndpointBuilder.ApplicationServices.set -> void
66
Microsoft.AspNetCore.Http.AsParametersAttribute
77
Microsoft.AspNetCore.Http.AsParametersAttribute.AsParametersAttribute() -> void
8+
Microsoft.AspNetCore.Http.CookieBuilder.Extensions.get -> System.Collections.Generic.IList<string!>!
89
Microsoft.AspNetCore.Http.DefaultRouteHandlerInvocationContext
910
Microsoft.AspNetCore.Http.DefaultRouteHandlerInvocationContext.DefaultRouteHandlerInvocationContext(Microsoft.AspNetCore.Http.HttpContext! httpContext, params object![]! arguments) -> void
1011
Microsoft.AspNetCore.Http.EndpointMetadataCollection.Enumerator.Current.get -> object!

src/Http/Http.Abstractions/test/CookieBuilderTests.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Licensed to the .NET Foundation under one or more agreements.
1+
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

44
namespace Microsoft.AspNetCore.Http.Abstractions.Tests;
@@ -50,4 +50,26 @@ public void CookieBuilderPreservesDefaultPath()
5050
{
5151
Assert.Equal(new CookieOptions().Path, new CookieBuilder().Build(new DefaultHttpContext()).Path);
5252
}
53+
54+
[Fact]
55+
public void CookieBuilder_Extensions_Added()
56+
{
57+
var builder = new CookieBuilder();
58+
builder.Extensions.Add("simple");
59+
builder.Extensions.Add("key=value");
60+
61+
var options = builder.Build(new DefaultHttpContext());
62+
Assert.Equal(2, options.Extensions.Count);
63+
Assert.Contains("simple", options.Extensions);
64+
Assert.Contains("key=value", options.Extensions);
65+
66+
var cookie = options.CreateCookieHeader("name", "value");
67+
Assert.Equal("name", cookie.Name);
68+
Assert.Equal("value", cookie.Value);
69+
Assert.Equal(2, cookie.Extensions.Count);
70+
Assert.Contains("simple", cookie.Extensions);
71+
Assert.Contains("key=value", cookie.Extensions);
72+
73+
Assert.Equal("name=value; path=/; simple; key=value", cookie.ToString());
74+
}
5375
}

src/Http/Http.Features/src/CookieOptions.cs

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,17 @@
11
// Licensed to the .NET Foundation under one or more agreements.
22
// The .NET Foundation licenses this file to you under the MIT license.
33

4+
using Microsoft.Net.Http.Headers;
5+
46
namespace Microsoft.AspNetCore.Http;
57

68
/// <summary>
79
/// Options used to create a new cookie.
810
/// </summary>
911
public class CookieOptions
1012
{
13+
private List<string>? _extensions;
14+
1115
/// <summary>
1216
/// Creates a default cookie with a path of '/'.
1317
/// </summary>
@@ -16,6 +20,28 @@ public CookieOptions()
1620
Path = "/";
1721
}
1822

23+
/// <summary>
24+
/// Creates a copy of the given <see cref="CookieOptions"/>.
25+
/// </summary>
26+
public CookieOptions(CookieOptions options)
27+
{
28+
ArgumentNullException.ThrowIfNull(options);
29+
30+
Domain = options.Domain;
31+
Path = options.Path;
32+
Expires = options.Expires;
33+
Secure = options.Secure;
34+
SameSite = options.SameSite;
35+
HttpOnly = options.HttpOnly;
36+
MaxAge = options.MaxAge;
37+
IsEssential = options.IsEssential;
38+
39+
if (options._extensions?.Count > 0)
40+
{
41+
_extensions = new List<string>(options._extensions);
42+
}
43+
}
44+
1945
/// <summary>
2046
/// Gets or sets the domain to associate the cookie with.
2147
/// </summary>
@@ -63,4 +89,39 @@ public CookieOptions()
6389
/// consent policy checks may be bypassed. The default value is false.
6490
/// </summary>
6591
public bool IsEssential { get; set; }
92+
93+
/// <summary>
94+
/// Gets a collection of additional values to append to the cookie.
95+
/// </summary>
96+
public IList<string> Extensions
97+
{
98+
get => _extensions ??= new List<string>();
99+
}
100+
101+
/// <summary>
102+
/// Creates a <see cref="SetCookieHeaderValue"/> using the current options.
103+
/// </summary>
104+
public SetCookieHeaderValue CreateCookieHeader(string name, string value)
105+
{
106+
var cookie = new SetCookieHeaderValue(name, value)
107+
{
108+
Domain = Domain,
109+
Path = Path,
110+
Expires = Expires,
111+
Secure = Secure,
112+
HttpOnly = HttpOnly,
113+
MaxAge = MaxAge,
114+
SameSite = (Net.Http.Headers.SameSiteMode)SameSite,
115+
};
116+
117+
if (_extensions?.Count > 0)
118+
{
119+
foreach (var extension in _extensions)
120+
{
121+
cookie.Extensions.Add(extension);
122+
}
123+
}
124+
125+
return cookie;
126+
}
66127
}

src/Http/Http.Features/src/PublicAPI.Unshipped.txt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
#nullable enable
2+
Microsoft.AspNetCore.Http.CookieOptions.CookieOptions(Microsoft.AspNetCore.Http.CookieOptions! options) -> void
3+
Microsoft.AspNetCore.Http.CookieOptions.CreateCookieHeader(string! name, string! value) -> Microsoft.Net.Http.Headers.SetCookieHeaderValue!
4+
Microsoft.AspNetCore.Http.CookieOptions.Extensions.get -> System.Collections.Generic.IList<string!>!
25
Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature
36
Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature.AcceptAsync() -> System.Threading.Tasks.ValueTask<System.IO.Stream!>
47
Microsoft.AspNetCore.Http.Features.IHttpExtendedConnectFeature.IsExtendedConnect.get -> bool

src/Http/Http/src/Internal/ResponseCookies.cs

Lines changed: 6 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -68,22 +68,11 @@ public void Append(string key, string value, CookieOptions options)
6868
}
6969
}
7070

71-
var setCookieHeaderValue = new SetCookieHeaderValue(
71+
var cookie = options.CreateCookieHeader(
7272
_enableCookieNameEncoding ? Uri.EscapeDataString(key) : key,
73-
Uri.EscapeDataString(value))
74-
{
75-
Domain = options.Domain,
76-
Path = options.Path,
77-
Expires = options.Expires,
78-
MaxAge = options.MaxAge,
79-
Secure = options.Secure,
80-
SameSite = (Net.Http.Headers.SameSiteMode)options.SameSite,
81-
HttpOnly = options.HttpOnly
82-
};
83-
84-
var cookieValue = setCookieHeaderValue.ToString();
73+
Uri.EscapeDataString(value)).ToString();
8574

86-
Headers.SetCookie = StringValues.Concat(Headers.SetCookie, cookieValue);
75+
Headers.SetCookie = StringValues.Concat(Headers.SetCookie, cookie);
8776
}
8877

8978
/// <inheritdoc />
@@ -112,25 +101,14 @@ public void Append(ReadOnlySpan<KeyValuePair<string, string>> keyValuePairs, Coo
112101
}
113102
}
114103

115-
var setCookieHeaderValue = new SetCookieHeaderValue(string.Empty)
116-
{
117-
Domain = options.Domain,
118-
Path = options.Path,
119-
Expires = options.Expires,
120-
MaxAge = options.MaxAge,
121-
Secure = options.Secure,
122-
SameSite = (Net.Http.Headers.SameSiteMode)options.SameSite,
123-
HttpOnly = options.HttpOnly
124-
};
125-
126-
var cookierHeaderValue = setCookieHeaderValue.ToString()[1..];
104+
var cookieSuffix = options.CreateCookieHeader(string.Empty, string.Empty).ToString()[1..];
127105
var cookies = new string[keyValuePairs.Length];
128106
var position = 0;
129107

130108
foreach (var keyValuePair in keyValuePairs)
131109
{
132110
var key = _enableCookieNameEncoding ? Uri.EscapeDataString(keyValuePair.Key) : keyValuePair.Key;
133-
cookies[position] = string.Concat(key, "=", Uri.EscapeDataString(keyValuePair.Value), cookierHeaderValue);
111+
cookies[position] = string.Concat(key, "=", Uri.EscapeDataString(keyValuePair.Value), cookieSuffix);
134112
position++;
135113
}
136114

@@ -200,14 +178,9 @@ public void Delete(string key, CookieOptions options)
200178
Headers.SetCookie = new StringValues(newValues.ToArray());
201179
}
202180

203-
Append(key, string.Empty, new CookieOptions
181+
Append(key, string.Empty, new CookieOptions(options)
204182
{
205-
Path = options.Path,
206-
Domain = options.Domain,
207183
Expires = DateTimeOffset.UnixEpoch,
208-
Secure = options.Secure,
209-
HttpOnly = options.HttpOnly,
210-
SameSite = options.SameSite
211184
});
212185
}
213186

0 commit comments

Comments
 (0)