Make every cookie host-only: __Host- names, Secure, Path=/ #11
9 changed files with 181 additions and 23 deletions
|
|
@ -1,4 +1,5 @@
|
|||
using System.Security.Cryptography;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace Bifrost.Web.Features.ConceptVotes;
|
||||
|
|
@ -7,8 +8,8 @@ namespace Bifrost.Web.Features.ConceptVotes;
|
|||
// decides the voter key and class. The cookie holds a protected random id, never a count, class or account.
|
||||
public sealed class VoterKeyResolver(IDataProtectionProvider dataProtection)
|
||||
{
|
||||
public const string CookieName = "bifrost_voter";
|
||||
public const string CookiePath = "/mill";
|
||||
public const string CookieName = BifrostCookies.Voter;
|
||||
public const string CookiePath = BifrostCookies.Path;
|
||||
public static readonly TimeSpan CookieLifetime = TimeSpan.FromDays(365);
|
||||
|
||||
private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1";
|
||||
|
|
|
|||
|
|
@ -446,13 +446,13 @@ public sealed record HeimdallAccount(
|
|||
|
||||
/// <summary>
|
||||
/// The attempt cookies: a ring of <see cref="SlotNames"/> fixed names, so the Cookie header a browser sends to
|
||||
/// /auth/heimdall is bounded by construction (a worst-case attempt is about 2.4 KB; nginx's line limit is 8 KB).
|
||||
/// every path is bounded by construction (the __Host- prefix forces Path=/; a worst-case attempt is about 2.4 KB, nginx's line limit is 8 KB).
|
||||
/// A start takes a free slot or overwrites the one expiring first; an overwritten attempt finds no cookie and
|
||||
/// is refused. The attempt's nonce lives inside the protected value, never in the cookie name.
|
||||
/// </summary>
|
||||
public static class HeimdallAttemptCookie
|
||||
{
|
||||
public const string Prefix = "bifrost_heimdall_";
|
||||
public const string Prefix = BifrostCookies.HeimdallAttemptPrefix;
|
||||
public const int SlotCount = 2;
|
||||
|
||||
public static readonly TimeSpan Lifetime = TimeSpan.FromMinutes(10);
|
||||
|
|
@ -465,13 +465,14 @@ public static class HeimdallAttemptCookie
|
|||
HttpOnly = true,
|
||||
Secure = true,
|
||||
SameSite = SameSiteMode.Lax,
|
||||
Path = "/auth/heimdall",
|
||||
Path = BifrostCookies.Path,
|
||||
MaxAge = Lifetime
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// The options a deletion carries: only the attempt cookie's Path, which a cookie is matched on. It carries no
|
||||
/// Max-Age, which would take precedence over the expiry Delete writes and leave the emptied cookie alive.
|
||||
/// The options a deletion carries: the attempt cookie's Path, which a cookie is matched on, and Secure, without
|
||||
/// which a browser ignores a Set-Cookie for a __Host- name. It carries no Max-Age, which would take precedence
|
||||
/// over the expiry Delete writes and leave the emptied cookie alive.
|
||||
/// </summary>
|
||||
public static CookieOptions DeleteOptions() => new() { Path = "/auth/heimdall" };
|
||||
public static CookieOptions DeleteOptions() => new() { Path = BifrostCookies.Path, Secure = true };
|
||||
}
|
||||
|
|
|
|||
17
src/Bifrost.Web/Features/Shared/BifrostCookies.cs
Normal file
17
src/Bifrost.Web/Features/Shared/BifrostCookies.cs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
namespace Bifrost.Web.Features.Shared;
|
||||
|
||||
// Every cookie Bifrost sets carries the __Host- prefix: the browser then accepts it only with Secure, Path=/ and no
|
||||
// Domain, so no sibling subdomain of gamecult.org (the preview host is same-site) can plant or shadow one by setting
|
||||
// a Domain=gamecult.org cookie of the same name. The prefix forbids a narrower Path, so these all live at Path=/.
|
||||
public static class BifrostCookies
|
||||
{
|
||||
public const string HostPrefix = "__Host-";
|
||||
|
||||
public const string Session = HostPrefix + "bifrost_session";
|
||||
public const string OAuthCorrelation = HostPrefix + "bifrost_oauth_correlation";
|
||||
public const string Antiforgery = HostPrefix + "bifrost_antiforgery";
|
||||
public const string Voter = HostPrefix + "bifrost_voter";
|
||||
public const string HeimdallAttemptPrefix = HostPrefix + "bifrost_heimdall_";
|
||||
|
||||
public const string Path = "/";
|
||||
}
|
||||
|
|
@ -76,6 +76,13 @@ builder.Services
|
|||
.AddOptions<ConceptVoteOptions>()
|
||||
.BindConfiguration(ConceptVoteOptions.SectionName);
|
||||
|
||||
builder.Services.AddAntiforgery(options =>
|
||||
{
|
||||
options.Cookie.Name = BifrostCookies.Antiforgery;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
options.Cookie.Path = BifrostCookies.Path;
|
||||
});
|
||||
|
||||
builder.Services.AddRazorPages(options =>
|
||||
{
|
||||
options.Conventions.AuthorizePage("/Membership/Status");
|
||||
|
|
@ -167,6 +174,10 @@ var authenticationBuilder = builder.Services
|
|||
.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(options =>
|
||||
{
|
||||
options.Cookie.Name = BifrostCookies.Session;
|
||||
options.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
options.Cookie.Path = BifrostCookies.Path;
|
||||
options.Cookie.Domain = null;
|
||||
options.LoginPath = "/auth/sign-in";
|
||||
options.AccessDeniedPath = "/Membership/Status";
|
||||
options.SlidingExpiration = true;
|
||||
|
|
@ -183,6 +194,9 @@ if (configuredGitHubOAuthOptions.IsConfigured)
|
|||
options.TokenEndpoint = "https://github.com/login/oauth/access_token";
|
||||
options.UserInformationEndpoint = "https://api.github.com/user";
|
||||
options.SaveTokens = true;
|
||||
options.CorrelationCookie.Name = BifrostCookies.OAuthCorrelation;
|
||||
options.CorrelationCookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
options.CorrelationCookie.Path = BifrostCookies.Path;
|
||||
options.Scope.Add("read:user");
|
||||
options.Scope.Add("user:email");
|
||||
options.ClaimActions.MapJsonKey(ClaimTypes.NameIdentifier, "id");
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Bifrost.Web.Features.Shared;
|
||||
using System.Net;
|
||||
using System.Text.RegularExpressions;
|
||||
using Bifrost.Web.Data;
|
||||
|
|
@ -14,7 +15,7 @@ namespace Bifrost.Web.Tests;
|
|||
|
||||
public sealed partial class ConceptVotePageTests : IClassFixture<TestWebApplicationFactory>
|
||||
{
|
||||
private const string CookieName = "bifrost_voter";
|
||||
private const string CookieName = BifrostCookies.Voter;
|
||||
private const string Purpose = "Bifrost.ConceptVotes.DeviceToken.v1";
|
||||
|
||||
private readonly TestWebApplicationFactory _factory;
|
||||
|
|
@ -40,7 +41,7 @@ public sealed partial class ConceptVotePageTests : IClassFixture<TestWebApplicat
|
|||
Assert.Contains("httponly", attributes);
|
||||
Assert.Contains("secure", attributes);
|
||||
Assert.Contains("samesite=lax", attributes);
|
||||
Assert.Contains("path=/mill", attributes);
|
||||
Assert.Contains("path=/", attributes);
|
||||
Assert.Contains("max-age=31536000", attributes);
|
||||
|
||||
using (var page = await visitor.Get(collection, "alpha"))
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Bifrost.Web.Features.Shared;
|
||||
using System.Net;
|
||||
using Bifrost.Web.Data;
|
||||
using Bifrost.Web.Domain;
|
||||
|
|
@ -19,7 +20,7 @@ namespace Bifrost.Web.Tests;
|
|||
public sealed class ConceptVoteRateLimitTests : IClassFixture<ConceptVoteRateLimitTests.LimitedFactory>
|
||||
{
|
||||
private const string Site = "https://localhost";
|
||||
private const string CookieName = "bifrost_voter";
|
||||
private const string CookieName = BifrostCookies.Voter;
|
||||
|
||||
private readonly LimitedFactory _factory;
|
||||
|
||||
|
|
|
|||
100
tests/Bifrost.Web.Tests/CookieHardeningTests.cs
Normal file
100
tests/Bifrost.Web.Tests/CookieHardeningTests.cs
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
using Bifrost.Web.Features.ConceptVotes;
|
||||
using Bifrost.Web.Features.Heimdall;
|
||||
using Bifrost.Web.Features.Shared;
|
||||
using Bifrost.Web.Tests.Support;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authentication.OAuth;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Bifrost.Web.Tests;
|
||||
|
||||
// Every cookie is host-only: __Host- name, Secure, Path=/, no Domain. These pin the literals on purpose, so removing
|
||||
// the prefix, Secure or Path=/ from any of them fails here and not only in a test that reads the same constant.
|
||||
public sealed class CookieHardeningTests(CookieHardeningTests.OAuthConfiguredFactory factory)
|
||||
: IClassFixture<CookieHardeningTests.OAuthConfiguredFactory>
|
||||
{
|
||||
// Program reads GitHubOAuth at startup, before the base factory's in-memory settings land, so the GitHub scheme
|
||||
// is only registered when the settings arrive as host settings.
|
||||
public sealed class OAuthConfiguredFactory : TestWebApplicationFactory
|
||||
{
|
||||
protected override void ConfigureWebHost(Microsoft.AspNetCore.Hosting.IWebHostBuilder builder)
|
||||
{
|
||||
builder.UseSetting("GitHubOAuth:ClientId", "configured-for-tests");
|
||||
builder.UseSetting("GitHubOAuth:ClientSecret", "configured-for-tests");
|
||||
base.ConfigureWebHost(builder);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Cookie_names_carry_the_host_prefix_and_live_at_the_root()
|
||||
{
|
||||
Assert.Equal("__Host-bifrost_session", BifrostCookies.Session);
|
||||
Assert.Equal("__Host-bifrost_oauth_correlation", BifrostCookies.OAuthCorrelation);
|
||||
Assert.Equal("__Host-bifrost_antiforgery", BifrostCookies.Antiforgery);
|
||||
Assert.Equal("__Host-bifrost_voter", VoterKeyResolver.CookieName);
|
||||
Assert.Equal("__Host-bifrost_heimdall_", HeimdallAttemptCookie.Prefix);
|
||||
Assert.All(HeimdallAttemptCookie.SlotNames, x => Assert.StartsWith("__Host-", x, StringComparison.Ordinal));
|
||||
Assert.Equal("/", BifrostCookies.Path);
|
||||
Assert.Equal("/", VoterKeyResolver.CookiePath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Session_cookie_is_host_only()
|
||||
{
|
||||
var cookie = factory.Services.GetRequiredService<IOptionsMonitor<CookieAuthenticationOptions>>()
|
||||
.Get(CookieAuthenticationDefaults.AuthenticationScheme).Cookie;
|
||||
AssertHostOnly(cookie.Name, cookie.SecurePolicy, cookie.Path, cookie.Domain);
|
||||
Assert.True(cookie.HttpOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Oauth_correlation_cookie_is_host_only()
|
||||
{
|
||||
var cookie = factory.Services.GetRequiredService<IOptionsMonitor<OAuthOptions>>().Get("GitHub").CorrelationCookie;
|
||||
AssertHostOnly(cookie.Name, cookie.SecurePolicy, cookie.Path, cookie.Domain);
|
||||
Assert.True(cookie.HttpOnly);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Antiforgery_cookie_is_host_only()
|
||||
{
|
||||
var cookie = factory.Services.GetRequiredService<IOptions<AntiforgeryOptions>>().Value.Cookie;
|
||||
AssertHostOnly(cookie.Name, cookie.SecurePolicy, cookie.Path, cookie.Domain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Voter_and_attempt_cookies_set_secure_root_path_and_no_domain()
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
new VoterKeyResolver(new Microsoft.AspNetCore.DataProtection.EphemeralDataProtectionProvider()).Mint(context);
|
||||
context.Response.Cookies.Append("__Host-bifrost_heimdall_0", "v", HeimdallAttemptCookie.Options());
|
||||
var deletion = new DefaultHttpContext();
|
||||
deletion.Response.Cookies.Delete("__Host-bifrost_heimdall_0", HeimdallAttemptCookie.DeleteOptions());
|
||||
|
||||
var headers = context.Response.Headers.SetCookie.Concat(deletion.Response.Headers.SetCookie).ToArray();
|
||||
Assert.Equal(3, headers.Length);
|
||||
foreach (var header in headers)
|
||||
{
|
||||
var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray();
|
||||
Assert.StartsWith("__host-", attributes[0], StringComparison.Ordinal);
|
||||
Assert.Contains("path=/", attributes);
|
||||
Assert.DoesNotContain(attributes, x => x.StartsWith("domain", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
// A browser ignores a __Host- Set-Cookie that lacks Secure, a deletion included.
|
||||
Assert.All(headers, header => Assert.Contains("secure", header!.Split(';').Select(x => x.Trim().ToLowerInvariant())));
|
||||
Assert.All(headers.Take(2), header =>
|
||||
Assert.Contains("httponly", header!.Split(';').Select(x => x.Trim().ToLowerInvariant())));
|
||||
}
|
||||
|
||||
private static void AssertHostOnly(string? name, CookieSecurePolicy secure, string? path, string? domain)
|
||||
{
|
||||
Assert.StartsWith("__Host-", name, StringComparison.Ordinal);
|
||||
Assert.Equal(CookieSecurePolicy.Always, secure);
|
||||
Assert.Equal("/", path);
|
||||
Assert.Null(domain);
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,29 @@ public sealed class HeimdallCompletionTests
|
|||
private static void AssertTokenRefused(HeimdallHost host, string rule) =>
|
||||
AssertLogged(host, $"Heimdall token refused: {rule}.");
|
||||
|
||||
/// <summary>A __Host- cookie is only deleted by a Set-Cookie that is itself Secure at Path=/; a browser ignores any other.</summary>
|
||||
private static void AssertAttemptCookieCleared(string? header)
|
||||
{
|
||||
Assert.NotNull(header);
|
||||
var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray();
|
||||
Assert.Contains(attributes, x => x.StartsWith("expires=", StringComparison.Ordinal) && x.Contains("1970"));
|
||||
Assert.Contains("secure", attributes);
|
||||
Assert.Contains("path=/", attributes);
|
||||
Assert.DoesNotContain(attributes, x => x.StartsWith("domain", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task A_refused_completion_clears_the_attempt_cookie_with_a_set_cookie_the_browser_honours()
|
||||
{
|
||||
using var host = new HeimdallHost();
|
||||
var attempt = await host.StartAsync();
|
||||
|
||||
var refused = await host.CompleteAsync(attempt.Nonce, "unknown", attempt.Cookie);
|
||||
|
||||
AssertRefusedRedirect(refused);
|
||||
AssertAttemptCookieCleared(HeimdallHost.SetCookieHeader(refused.Response, attempt.CookieName!));
|
||||
}
|
||||
|
||||
private static void AssertRefusedRedirect(CompletionResult result)
|
||||
{
|
||||
Assert.Equal(HttpStatusCode.Redirect, result.Response.StatusCode);
|
||||
|
|
@ -81,8 +104,7 @@ public sealed class HeimdallCompletionTests
|
|||
Assert.Equal(MembershipStatus.Authenticated, account.Membership!.Status);
|
||||
Assert.Empty(account.Membership.RoleAssignments);
|
||||
var cleared = HeimdallHost.SetCookieHeader(done.Response, attempt.CookieName!);
|
||||
Assert.NotNull(cleared);
|
||||
Assert.Contains("1970", cleared);
|
||||
AssertAttemptCookieCleared(cleared);
|
||||
|
||||
var again = await host.CompleteAsync(attempt, code);
|
||||
|
||||
|
|
@ -526,7 +548,7 @@ public sealed class HeimdallCompletionTests
|
|||
Assert.Contains("httponly", attributes);
|
||||
Assert.Contains("secure", attributes);
|
||||
Assert.Contains("samesite=lax", attributes);
|
||||
Assert.Contains("path=/auth/heimdall", attributes);
|
||||
Assert.Contains("path=/", attributes);
|
||||
Assert.Contains("max-age=600", attributes);
|
||||
Assert.NotEqual(first.Cookie, second.Cookie);
|
||||
Assert.DoesNotContain(first.Nonce, first.Cookie!);
|
||||
|
|
@ -564,7 +586,7 @@ public sealed class HeimdallCompletionTests
|
|||
Assert.Contains("<noscript>", page);
|
||||
Assert.Contains("/?auth=heimdall-error", page);
|
||||
Assert.DoesNotContain("CANARY", page);
|
||||
Assert.Null(HeimdallHost.CookieValue(response, ".AspNetCore.Cookies"));
|
||||
Assert.Null(HeimdallHost.CookieValue(response, BifrostCookies.Session));
|
||||
}
|
||||
|
||||
[Theory]
|
||||
|
|
@ -696,7 +718,7 @@ public sealed class HeimdallCompletionTests
|
|||
"Heimdall completion has no attempt for this browser."),
|
||||
("no attempt field", await host.CompleteAsync(null, host.CodeFor(attempt, accountId), attempt.Cookie),
|
||||
"Heimdall completion has no attempt for this browser."),
|
||||
("malformed attempt", await host.CompleteAsync("short", "code", attempt.Cookie, "bifrost_heimdall_short"),
|
||||
("malformed attempt", await host.CompleteAsync("short", "code", attempt.Cookie, BifrostCookies.HeimdallAttemptPrefix + "short"),
|
||||
"Heimdall completion has no attempt for this browser."),
|
||||
("no code", await host.CompleteAsync(attempt.Nonce, null, attempt.Cookie), "Heimdall completion carried no code."),
|
||||
("blank code", await host.CompleteAsync(attempt.Nonce, " ", attempt.Cookie), "Heimdall completion carried no code."),
|
||||
|
|
@ -730,7 +752,7 @@ public sealed class HeimdallCompletionTests
|
|||
{
|
||||
AssertRefusedRedirect(result);
|
||||
Assert.Equal(string.Empty, await result.Response.Content.ReadAsStringAsync());
|
||||
Assert.Null(HeimdallHost.CookieValue(result.Response, ".AspNetCore.Cookies"));
|
||||
Assert.Null(HeimdallHost.CookieValue(result.Response, BifrostCookies.Session));
|
||||
AssertLogged(host, reason);
|
||||
Assert.True(result.Response.Headers.Location!.OriginalString == ErrorRedirect, name);
|
||||
}
|
||||
|
|
@ -1109,7 +1131,7 @@ public sealed class HeimdallCompletionTests
|
|||
responses.Add((await host.CompleteAsync(issued, host.CodeFor(issued, accountCanary, wrongSubject))).Response);
|
||||
responses.Add((await host.CompleteAsync(issued, host.CodeFor(issued, accountCanary, returnTo: returnToCanary))).Response);
|
||||
responses.Add((await host.CompleteAsync(issued.Nonce, codeCanary, cookieCanary)).Response);
|
||||
responses.Add((await host.CompleteAsync(attemptCanary, codeCanary, cookieCanary, "bifrost_heimdall_" + attemptCanary)).Response);
|
||||
responses.Add((await host.CompleteAsync(attemptCanary, codeCanary, cookieCanary, BifrostCookies.HeimdallAttemptPrefix + attemptCanary)).Response);
|
||||
responses.Add((await host.CompleteAsync(issued.Nonce, codeCanary, issued.Cookie)).Response);
|
||||
using (var client = host.NewClient())
|
||||
{
|
||||
|
|
@ -1188,7 +1210,7 @@ public sealed class HeimdallCompletionTests
|
|||
Assert.NotNull(header);
|
||||
Assert.StartsWith(name + "=;", header);
|
||||
var attributes = header!.Split(';').Select(x => x.Trim().ToLowerInvariant()).ToArray();
|
||||
Assert.Contains("path=/auth/heimdall", attributes);
|
||||
Assert.Contains("path=/", attributes);
|
||||
Assert.Contains(attributes, x => x.StartsWith("expires=", StringComparison.Ordinal) && x.Contains("1970"));
|
||||
Assert.DoesNotContain(attributes, x => x.StartsWith("max-age", StringComparison.Ordinal));
|
||||
}
|
||||
|
|
@ -1277,7 +1299,7 @@ public sealed class HeimdallCompletionTests
|
|||
foreach (var header in deletions)
|
||||
{
|
||||
var attributes = header.Split(';').Skip(1).Select(x => x.Trim().ToLowerInvariant()).ToArray();
|
||||
Assert.Contains("path=/auth/heimdall", attributes);
|
||||
Assert.Contains("path=/", attributes);
|
||||
Assert.DoesNotContain(attributes, x => x.StartsWith("max-age", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
|
|
@ -1346,7 +1368,7 @@ public sealed class HeimdallCompletionTests
|
|||
{
|
||||
host.Clock.Advance(TimeSpan.FromSeconds(1));
|
||||
var attempt = await host.StartAsync(
|
||||
returnUrl: "/App", cookieHeader: (jar.Count == 0 ? "" : CookieHeader(jar) + "; ") + ".AspNetCore.Cookies=session; x=1");
|
||||
returnUrl: "/App", cookieHeader: (jar.Count == 0 ? "" : CookieHeader(jar) + "; ") + BifrostCookies.Session + "=session; x=1");
|
||||
ApplySetCookies(jar, attempt.Response);
|
||||
started.Add(attempt);
|
||||
}
|
||||
|
|
@ -1393,7 +1415,7 @@ public sealed class HeimdallCompletionTests
|
|||
using var post = new HttpRequestMessage(HttpMethod.Post, action) { Content = new FormUrlEncodedContent(fields) };
|
||||
post.Headers.Add("Cookie", $"{attempt.CookieName}={attempt.Cookie}");
|
||||
using var response = await client.SendAsync(post);
|
||||
Assert.False(string.IsNullOrEmpty(HeimdallHost.CookieValue(response, ".AspNetCore.Cookies")));
|
||||
Assert.False(string.IsNullOrEmpty(HeimdallHost.CookieValue(response, BifrostCookies.Session)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
using Bifrost.Web.Features.Shared;
|
||||
using System.Net;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
|
|
@ -482,7 +483,7 @@ public sealed class HeimdallHost : TestWebApplicationFactory
|
|||
}
|
||||
|
||||
var response = await client.SendAsync(request);
|
||||
var session = CookieValue(response, ".AspNetCore.Cookies");
|
||||
var session = CookieValue(response, BifrostCookies.Session);
|
||||
return new CompletionResult(response, !string.IsNullOrEmpty(session));
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue