diff --git a/AspNetCore.slnx b/AspNetCore.slnx index 0a3f8159a80d..9ffec5549017 100644 --- a/AspNetCore.slnx +++ b/AspNetCore.slnx @@ -809,6 +809,12 @@ + + + + + + diff --git a/docs/list-of-diagnostics.md b/docs/list-of-diagnostics.md index e6195634513e..39083ac0dbcf 100644 --- a/docs/list-of-diagnostics.md +++ b/docs/list-of-diagnostics.md @@ -35,6 +35,7 @@ | __`ASP0027`__ | Unnecessary public Program class declaration | | __`ASP0028`__ | Consider using ListenAnyIP() instead of Listen(IPAddress.Any) | | __`ASP0029`__ | Experimental warning for validations resolver APIs | +| __`ASP0030`__ | Experimental warning for Device Bound Sessions (DBSC) APIs | ### API (`API1000-API1003`) diff --git a/eng/ProjectReferences.props b/eng/ProjectReferences.props index 32e1fc9509c4..73ba757edeba 100644 --- a/eng/ProjectReferences.props +++ b/eng/ProjectReferences.props @@ -58,6 +58,7 @@ + diff --git a/eng/ShippingAssemblies.props b/eng/ShippingAssemblies.props index a432e88d2eaf..a47f24f92caa 100644 --- a/eng/ShippingAssemblies.props +++ b/eng/ShippingAssemblies.props @@ -122,6 +122,7 @@ + diff --git a/eng/TrimmableProjects.props b/eng/TrimmableProjects.props index 826193b51946..707ba374fe28 100644 --- a/eng/TrimmableProjects.props +++ b/eng/TrimmableProjects.props @@ -48,6 +48,7 @@ + diff --git a/src/Security/Authentication/Cookies/src/Microsoft.AspNetCore.Authentication.Cookies.csproj b/src/Security/Authentication/Cookies/src/Microsoft.AspNetCore.Authentication.Cookies.csproj index de1168e97d08..49ad14534115 100644 --- a/src/Security/Authentication/Cookies/src/Microsoft.AspNetCore.Authentication.Cookies.csproj +++ b/src/Security/Authentication/Cookies/src/Microsoft.AspNetCore.Authentication.Cookies.csproj @@ -11,6 +11,10 @@ true + + + + diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Dashboard.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Dashboard.cs new file mode 100644 index 000000000000..c6c765d87d59 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Dashboard.cs @@ -0,0 +1,315 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace DbscDebugServer; + +/// The dark-themed single-page debug dashboard served at /. +public static class Dashboard +{ + public const string Html = +""" + + + + + +DBSC Debug Console + + + +
+

DBSC Debug Console

+
Device Bound Session Credentials — live protocol inspector
+ +
+

Session

+
Not signed in
+
Session TTL
+
+ +
+

Sign in

+ +
+ + + + + +
+
TTL controls how long each short-lived bound cookie lasts before a device-key refresh is required.
+
+ +
+

Actions

+ + + + +
+ +
+

Decoded cookies

+
No DBSC cookies present.
+
+ +
+

Legend

+ register + refresh + api + auth + page +
+
+ +
+
+

Live exchange log

+ 0 entries + + +
+
Waiting for traffic… sign in to begin.
+
+ + + + +"""; +} diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebug.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebug.cs new file mode 100644 index 000000000000..6a25e959a42a --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebug.cs @@ -0,0 +1,674 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Formats.Cbor; +using System.Globalization; +using System.Text; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace DbscDebugServer; + +/// Well-known scheme, cookie, and purpose names used by the debug tooling. +public static class DbscNames +{ + public const string Source = "Application"; + public const string Refresh = "Application.Dbsc.Refresh"; + public const string Session = "Application.Dbsc.Session"; + + public const string SourceCookie = ".AspNetCore.Application"; + public const string RefreshCookie = ".AspNetCore.Application.Dbsc.Refresh"; + public const string SessionCookie = ".AspNetCore.Application.Dbsc.Session"; + + // The challenge protector is domain-separated by flow: registration challenges and refresh + // challenges are protected under distinct purposes, so a challenge from one flow can never be + // decrypted (or confused) as the other. + public const string RegistrationChallengePurpose = "Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.Registration.v1"; + public const string RefreshChallengePurpose = "Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.Refresh.v1"; + + public static string? SchemeForCookie(string name) => name switch + { + SourceCookie => Source, + RefreshCookie => Refresh, + SessionCookie => Session, + _ => null, + }; +} + +/// +/// Mutable, process-wide debug state for the sample: the current session TTL and a ring +/// buffer of decoded HTTP exchanges that the dashboard polls. +/// +public sealed class DbscDebugState +{ + public const int MaxEntries = 400; + + private readonly object _lock = new(); + private readonly LinkedList _exchanges = new(); + private long _nextId; + private long _ttlTicks = TimeSpan.FromSeconds(300).Ticks; + + // Signaled whenever the log changes, so long-poll requests can wake without busy polling. + private volatile TaskCompletionSource _signal = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public TimeSpan SessionTtl + { + get => TimeSpan.FromTicks(Interlocked.Read(ref _ttlTicks)); + set => Interlocked.Exchange(ref _ttlTicks, value.Ticks); + } + + private DebugCookie? _latestRefreshCookie; + + /// + /// The most recently observed refresh cookie (decoded). The refresh cookie is path-scoped to + /// /.well-known/dbsc, so the dashboard's own /debug/state polls never receive it; the capture + /// middleware stashes the latest copy here from the DBSC traffic that does carry it. + /// + public DebugCookie? LatestRefreshCookie + { + get { lock (_lock) { return _latestRefreshCookie; } } + set { lock (_lock) { _latestRefreshCookie = value; } } + } + + public DebugExchange Add(DebugExchange exchange) + { + lock (_lock) + { + exchange.Id = ++_nextId; + _exchanges.AddLast(exchange); + while (_exchanges.Count > MaxEntries) + { + _exchanges.RemoveFirst(); + } + } + SignalChange(); + return exchange; + } + + public (long LastId, List Entries) Since(long sinceId) + { + lock (_lock) + { + var list = new List(); + foreach (var e in _exchanges) + { + if (e.Id > sinceId) + { + list.Add(e); + } + } + return (_nextId, list); + } + } + + /// + /// Long-poll wait: completes immediately if entries newer than + /// already exist, otherwise waits until the log changes or elapses. + /// + public async Task<(long LastId, List Entries)> WaitForChangesAsync( + long sinceId, TimeSpan timeout, CancellationToken cancellationToken) + { + var current = Since(sinceId); + if (current.Entries.Count > 0 || current.LastId < sinceId) + { + return current; + } + + var signal = _signal; + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(timeout); + try + { + await signal.Task.WaitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + // Timed out with no change — return the current (likely empty) snapshot. + } + + return Since(sinceId); + } + + public void ClearLog() + { + lock (_lock) + { + _exchanges.Clear(); + } + SignalChange(); + } + + private void SignalChange() + { + var previous = Interlocked.Exchange(ref _signal, new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + previous.TrySetResult(); + } +} + +// ===== Serializable models surfaced to the dashboard ===== + +public sealed class DebugExchange +{ + [JsonPropertyName("id")] public long Id { get; set; } + [JsonPropertyName("time")] public string Time { get; set; } = ""; + [JsonPropertyName("method")] public string Method { get; set; } = ""; + [JsonPropertyName("path")] public string Path { get; set; } = ""; + [JsonPropertyName("status")] public int Status { get; set; } + [JsonPropertyName("category")] public string Category { get; set; } = ""; + [JsonPropertyName("authenticated")] public bool Authenticated { get; set; } + [JsonPropertyName("durationMs")] public double DurationMs { get; set; } + [JsonPropertyName("requestCookies")] public List? RequestCookies { get; set; } + [JsonPropertyName("setCookies")] public List? SetCookies { get; set; } + [JsonPropertyName("proof")] public DebugJwt? Proof { get; set; } + [JsonPropertyName("registrationHeader")] public string? RegistrationHeader { get; set; } + [JsonPropertyName("challengeHeader")] public string? ChallengeHeader { get; set; } + [JsonPropertyName("decodedChallenge")] public DebugChallenge? DecodedChallenge { get; set; } + [JsonPropertyName("sessionConfig")] public JsonNode? SessionConfig { get; set; } +} + +public sealed class DebugCookie +{ + [JsonPropertyName("name")] public string Name { get; set; } = ""; + [JsonPropertyName("scheme")] public string? Scheme { get; set; } + [JsonPropertyName("valueLength")] public int ValueLength { get; set; } + [JsonPropertyName("attributes")] public string? Attributes { get; set; } + [JsonPropertyName("deleted")] public bool Deleted { get; set; } + [JsonPropertyName("decoded")] public DecodedTicket? Decoded { get; set; } +} + +public sealed class DecodedTicket +{ + [JsonPropertyName("principal")] public string? Principal { get; set; } + [JsonPropertyName("claims")] public List Claims { get; set; } = new(); + [JsonPropertyName("items")] public Dictionary Items { get; set; } = new(); + [JsonPropertyName("issuedUtc")] public string? IssuedUtc { get; set; } + [JsonPropertyName("expiresUtc")] public string? ExpiresUtc { get; set; } +} + +public sealed class DebugJwt +{ + [JsonPropertyName("header")] public JsonNode? Header { get; set; } + [JsonPropertyName("payload")] public JsonNode? Payload { get; set; } + [JsonPropertyName("signaturePresent")] public bool SignaturePresent { get; set; } + [JsonPropertyName("decodedJti")] public DebugChallenge? DecodedJti { get; set; } +} + +public sealed class DebugChallenge +{ + [JsonPropertyName("kind")] public string Kind { get; set; } = ""; + [JsonPropertyName("claimUid")] public string? ClaimUid { get; set; } + [JsonPropertyName("sessionId")] public string? SessionId { get; set; } + [JsonPropertyName("valid")] public bool Valid { get; set; } + [JsonPropertyName("error")] public string? Error { get; set; } +} + +/// +/// Decodes DBSC artifacts for the dashboard: proof JWTs (plain base64url JSON), Data-Protection +/// cookie tickets (via each scheme's ), +/// and the time-limited Data-Protection challenge payloads (CBOR). +/// +public static class DbscDecoder +{ + public static List? DecodeRequestCookies(HttpContext context) + { + var result = new List(); + foreach (var name in new[] { DbscNames.SourceCookie, DbscNames.RefreshCookie, DbscNames.SessionCookie }) + { + var value = ReadChunkedCookie(context.Request.Cookies, name); + if (value is null) + { + continue; + } + + var scheme = DbscNames.SchemeForCookie(name); + result.Add(new DebugCookie + { + Name = name, + Scheme = scheme, + ValueLength = value.Length, + Decoded = scheme is null ? null : TryDecodeTicket(context, scheme, value), + }); + } + return result.Count > 0 ? result : null; + } + + public static List? DecodeSetCookies(HttpContext context) + { + var raw = context.Response.Headers.SetCookie; + if (raw.Count == 0) + { + return null; + } + + // Reassemble chunked Set-Cookie values (name=chunks-N + nameC1..CN). + var parsed = new List<(string Name, string Value, string? Attrs)>(); + var byName = new Dictionary(StringComparer.Ordinal); + foreach (var line in raw) + { + if (string.IsNullOrEmpty(line)) + { + continue; + } + var segs = line.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + var first = segs[0]; + var eq = first.IndexOf('='); + if (eq <= 0) + { + continue; + } + var name = first[..eq]; + var value = first[(eq + 1)..]; + var attrs = segs.Length > 1 ? string.Join("; ", segs[1..]) : null; + parsed.Add((name, value, attrs)); + byName[name] = value; + } + + var result = new List(); + foreach (var (name, value, attrs) in parsed) + { + // Skip the chunk-part cookies themselves; they are folded into the base entry. + if (IsChunkPart(name)) + { + continue; + } + + var deleted = value.Length == 0 || (attrs?.Contains("expires=Thu, 01 Jan 1970", StringComparison.OrdinalIgnoreCase) ?? false); + var fullValue = value.StartsWith("chunks-", StringComparison.Ordinal) ? Reassemble(name, value, byName) : value; + var scheme = DbscNames.SchemeForCookie(name); + + result.Add(new DebugCookie + { + Name = name, + Scheme = scheme, + ValueLength = fullValue.Length, + Attributes = attrs, + Deleted = deleted, + Decoded = (deleted || scheme is null) ? null : TryDecodeTicket(context, scheme, fullValue), + }); + } + return result.Count > 0 ? result : null; + } + + /// Parses a JSON response body (the DBSC Session Instruction) for display, or null. + public static JsonNode? TryParseJsonBody(string? body) + { + if (string.IsNullOrWhiteSpace(body)) + { + return null; + } + try + { + return JsonNode.Parse(body); + } + catch + { + return null; + } + } + + public static DebugJwt? DecodeProof(HttpContext context) + { + var raw = context.Request.Headers["Secure-Session-Response"].ToString(); + if (string.IsNullOrEmpty(raw)) + { + return null; + } + + raw = raw.Trim().Trim('"'); + var parts = raw.Split('.'); + if (parts.Length is < 2 or > 3) + { + return null; + } + + var header = TryParseJsonSegment(parts[0]); + var payload = TryParseJsonSegment(parts[1]); + if (header is null || payload is null) + { + return null; + } + + var jwt = new DebugJwt + { + Header = header, + Payload = payload, + SignaturePresent = parts.Length == 3 && parts[2].Length > 0, + }; + + if (payload["jti"]?.GetValue() is { Length: > 0 } jti) + { + jwt.DecodedJti = DecodeChallenge(context, jti); + } + return jwt; + } + + public static DebugChallenge? DecodeChallengeHeader(HttpContext context, string? challengeHeader) + { + if (string.IsNullOrEmpty(challengeHeader)) + { + return null; + } + + // Format: "";id="" + var value = challengeHeader.TrimStart(); + if (value.StartsWith('"')) + { + var end = value.IndexOf('"', 1); + if (end > 1) + { + value = value[1..end]; + } + } + else + { + var semi = value.IndexOf(';'); + if (semi >= 0) + { + value = value[..semi]; + } + } + return DecodeChallenge(context, value.Trim()); + } + + public static DebugChallenge DecodeChallenge(HttpContext context, string challenge) + { + var dp = context.RequestServices.GetRequiredService(); + + byte[] raw; + try + { + raw = WebEncoders.Base64UrlDecode(challenge); + } + catch (Exception ex) + { + return new DebugChallenge { Valid = false, Error = DescribeException(ex) }; + } + + // The challenge is protected under one of two domain-separated purposes. Try each: the one + // that decrypts both validates the payload and identifies which flow issued it. + Exception? firstError = null; + foreach (var (kind, purpose) in new[] + { + ("refresh", DbscNames.RefreshChallengePurpose), + ("registration", DbscNames.RegistrationChallengePurpose), + }) + { + try + { + var protector = dp.CreateProtector(purpose).ToTimeLimitedDataProtector(); + var bytes = protector.Unprotect(raw); + + var reader = new CborReader(bytes, allowMultipleRootLevelValues: true); + var claimUid = reader.ReadTextString(); + string? sessionId = null; + if (reader.PeekState() != CborReaderState.Finished) + { + sessionId = reader.ReadTextString(); + } + + return new DebugChallenge + { + Kind = kind, + ClaimUid = claimUid, + SessionId = sessionId, + Valid = true, + }; + } + catch (Exception ex) + { + // Keep the first failure: when a challenge is the correct kind but expired, the + // matching purpose surfaces an informative "payload expired" message, whereas the + // other purpose only reports a generic key mismatch. + firstError ??= ex; + } + } + + return new DebugChallenge { Valid = false, Error = DescribeException(firstError!) }; + } + + /// + /// Builds a human-readable explanation for a decode failure. For the time-limited + /// data protector an expired payload surfaces as a CryptographicException whose + /// message says the payload expired; a key mismatch or tampering surfaces differently. + /// We include the type, message, and inner-exception chain so the dashboard can show why. + /// + private static string DescribeException(Exception ex) + { + var sb = new StringBuilder(); + var current = ex; + while (current is not null) + { + if (sb.Length > 0) + { + sb.Append(" -> "); + } + sb.Append(current.GetType().Name).Append(": ").Append(current.Message); + current = current.InnerException; + } + return sb.ToString(); + } + + private static DecodedTicket? TryDecodeTicket(HttpContext context, string scheme, string value) + { + try + { + var monitor = context.RequestServices.GetService>(); + var ticket = monitor?.Get(scheme).TicketDataFormat?.Unprotect(value); + if (ticket is null) + { + return null; + } + + return new DecodedTicket + { + Principal = ticket.Principal.Identity?.Name, + Claims = ticket.Principal.Claims.Select(c => $"{c.Type} = {c.Value}").ToList(), + Items = ticket.Properties.Items + .Where(kv => kv.Value is not null) + .ToDictionary(kv => kv.Key, kv => kv.Value!), + IssuedUtc = ticket.Properties.IssuedUtc?.ToString("O"), + ExpiresUtc = ticket.Properties.ExpiresUtc?.ToString("O"), + }; + } + catch + { + return null; + } + } + + private static JsonNode? TryParseJsonSegment(string segment) + { + try + { + return JsonNode.Parse(WebEncoders.Base64UrlDecode(segment)); + } + catch + { + return null; + } + } + + private static string? ReadChunkedCookie(IRequestCookieCollection cookies, string name) + { + if (!cookies.TryGetValue(name, out var value) || value is null) + { + return null; + } + if (value.StartsWith("chunks-", StringComparison.Ordinal) && int.TryParse(value.AsSpan(7), out var count)) + { + var sb = new StringBuilder(); + for (var i = 1; i <= count; i++) + { + if (!cookies.TryGetValue($"{name}C{i}", out var chunk) || chunk is null) + { + return value; + } + sb.Append(chunk); + } + return sb.ToString(); + } + return value; + } + + private static string Reassemble(string name, string value, Dictionary byName) + { + if (!int.TryParse(value.AsSpan(7), out var count)) + { + return value; + } + var sb = new StringBuilder(); + for (var i = 1; i <= count; i++) + { + if (!byName.TryGetValue($"{name}C{i}", out var chunk)) + { + return value; + } + sb.Append(chunk); + } + return sb.ToString(); + } + + private static bool IsChunkPart(string name) + { + var c = name.LastIndexOf('C'); + return c > 0 && c < name.Length - 1 && int.TryParse(name.AsSpan(c + 1), out _); + } +} + +/// +/// Records every HTTP exchange (decoded) into for the live dashboard log. +/// +public sealed class DebugCaptureMiddleware +{ + private readonly RequestDelegate _next; + private readonly DbscDebugState _state; + + public DebugCaptureMiddleware(RequestDelegate next, DbscDebugState state) + { + _next = next; + _state = state; + } + + public async Task InvokeAsync(HttpContext context) + { + var start = DateTimeOffset.UtcNow; + var requestCookies = DbscDecoder.DecodeRequestCookies(context); + var proof = DbscDecoder.DecodeProof(context); + + var path = context.Request.Path.Value ?? ""; + + // For the DBSC endpoints, buffer the response body so we can capture and decode the + // JSON Session Instruction the server returns on a 200. Other paths stream as usual. + var captureBody = IsDbscEndpoint(path); + Stream? originalBody = null; + MemoryStream? buffer = null; + if (captureBody) + { + originalBody = context.Response.Body; + buffer = new MemoryStream(); + context.Response.Body = buffer; + } + + string? responseBody = null; + try + { + await _next(context); + } + finally + { + if (captureBody && buffer is not null && originalBody is not null) + { + if (context.Response.ContentType?.Contains("json", StringComparison.OrdinalIgnoreCase) == true && buffer.Length > 0) + { + responseBody = Encoding.UTF8.GetString(buffer.GetBuffer(), 0, (int)buffer.Length); + } + buffer.Position = 0; + await buffer.CopyToAsync(originalBody); + context.Response.Body = originalBody; + } + + // Don't log the dashboard's own polling endpoints — it would be infinite noise. + if (!path.StartsWith("/debug/", StringComparison.Ordinal)) + { + var registrationHeader = context.Response.Headers["Secure-Session-Registration"].ToString(); + var challengeHeader = context.Response.Headers["Secure-Session-Challenge"].ToString(); + + var setCookies = DbscDecoder.DecodeSetCookies(context); + + // The refresh cookie is path-scoped to /.well-known/dbsc, so the dashboard's own + // /debug/state polls never carry it. Stash the latest decoded copy seen on real DBSC + // traffic (a fresh Set-Cookie from registration or a slide wins; a deletion clears it). + UpdateRefreshCookieStash(requestCookies, setCookies); + + var exchange = new DebugExchange + { + Time = start.ToLocalTime().ToString("HH:mm:ss.fff", CultureInfo.InvariantCulture), + Method = context.Request.Method, + Path = path, + Status = context.Response.StatusCode, + Category = Categorize(path), + Authenticated = context.User.Identity?.IsAuthenticated == true, + DurationMs = Math.Round((DateTimeOffset.UtcNow - start).TotalMilliseconds, 2), + RequestCookies = requestCookies, + SetCookies = setCookies, + Proof = proof, + RegistrationHeader = string.IsNullOrEmpty(registrationHeader) ? null : registrationHeader, + ChallengeHeader = string.IsNullOrEmpty(challengeHeader) ? null : challengeHeader, + DecodedChallenge = DbscDecoder.DecodeChallengeHeader(context, challengeHeader), + SessionConfig = DbscDecoder.TryParseJsonBody(responseBody), + }; + + _state.Add(exchange); + } + } + } + + private void UpdateRefreshCookieStash(List? requestCookies, List? setCookies) + { + // A fresh Set-Cookie (registration or sliding renewal) reflects the newest expiry; prefer it. + if (setCookies is not null) + { + foreach (var c in setCookies) + { + if (c.Name == DbscNames.RefreshCookie) + { + _state.LatestRefreshCookie = c.Deleted ? null : c; + return; + } + } + } + + // Otherwise fall back to the refresh cookie the browser sent (e.g. the first leg of a refresh). + if (requestCookies is not null) + { + foreach (var c in requestCookies) + { + if (c.Name == DbscNames.RefreshCookie) + { + _state.LatestRefreshCookie = c; + return; + } + } + } + } + + private static bool IsDbscEndpoint(string path) => + path is "/.well-known/dbsc/registration" or "/.well-known/dbsc/refresh"; + + private static string Categorize(string path) => path switch + { + "/.well-known/dbsc/registration" => "register", + "/.well-known/dbsc/refresh" => "refresh", + "/login" or "/signout" or "/clear" => "auth", + _ when path.StartsWith("/api/", StringComparison.Ordinal) => "api", + _ => "page", + }; +} diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebugServer.csproj b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebugServer.csproj new file mode 100644 index 000000000000..1443973d3fc3 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/DbscDebugServer.csproj @@ -0,0 +1,35 @@ + + + + $(DefaultNetCoreTargetFramework) + enable + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/HarLoggingMiddleware.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/HarLoggingMiddleware.cs new file mode 100644 index 000000000000..bdd69ed92e53 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/HarLoggingMiddleware.cs @@ -0,0 +1,523 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System.Diagnostics; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.Json.Serialization; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Http.Extensions; + +namespace DbscDebugServer; + +/// +/// Middleware that captures all HTTP requests/responses and writes them to a HAR file on disk. +/// This captures the full DBSC protocol flow including headers that Chrome DevTools may hide. +/// +public sealed class HarLoggingMiddleware +{ + private readonly RequestDelegate _next; + private readonly string _outputPath; + private readonly HarLog _harLog; + private readonly object _lock = new(); + private readonly JsonSerializerOptions _jsonOptions = new() + { + WriteIndented = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; + + public HarLoggingMiddleware(RequestDelegate next, string outputPath) + { + _next = next; + _outputPath = outputPath; + _harLog = new HarLog + { + Log = new HarLogContent + { + Version = "1.2", + Creator = new HarCreator { Name = "DbscSample", Version = "1.0" }, + Entries = [], + } + }; + } + + public async Task InvokeAsync(HttpContext context) + { + var startTime = DateTimeOffset.UtcNow; + var stopwatch = Stopwatch.StartNew(); + + // Capture request + var requestHeaders = CaptureHeaders(context.Request.Headers); + var requestBody = await CaptureRequestBodyAsync(context); + + // Buffer the response body + var originalBodyStream = context.Response.Body; + using var responseBodyStream = new MemoryStream(); + context.Response.Body = responseBodyStream; + + try + { + await _next(context); + } + finally + { + stopwatch.Stop(); + context.Response.Body = originalBodyStream; + + // Read captured response body + responseBodyStream.Seek(0, SeekOrigin.Begin); + var responseBodyBytes = responseBodyStream.ToArray(); + var responseBody = Encoding.UTF8.GetString(responseBodyBytes); + + // Write response body to client + if (responseBodyBytes.Length > 0) + { + await originalBodyStream.WriteAsync(responseBodyBytes); + } + + // Capture response headers (after response has been written) + var responseHeaders = CaptureHeaders(context.Response.Headers); + + var entry = new HarEntry + { + StartedDateTime = startTime.ToString("O"), + Time = stopwatch.Elapsed.TotalMilliseconds, + Request = new HarRequest + { + Method = context.Request.Method, + Url = context.Request.GetDisplayUrl(), + HttpVersion = context.Request.Protocol, + Headers = requestHeaders, + HeadersSize = -1, + BodySize = requestBody?.Length ?? 0, + PostData = requestBody is not null ? new HarPostData + { + MimeType = context.Request.ContentType ?? "", + Text = requestBody, + } : null, + }, + Response = new HarResponse + { + Status = context.Response.StatusCode, + StatusText = "", + HttpVersion = context.Request.Protocol, + Headers = responseHeaders, + HeadersSize = -1, + BodySize = responseBodyBytes.Length, + Content = new HarContent + { + Size = responseBodyBytes.Length, + MimeType = context.Response.ContentType ?? "", + Text = responseBody, + }, + }, + Timings = new HarTimings + { + Send = 0, + Wait = stopwatch.Elapsed.TotalMilliseconds, + Receive = 0, + }, + Dbsc = BuildDbscAnnotations(requestHeaders, responseHeaders, requestBody), + }; + + lock (_lock) + { + _harLog.Log.Entries.Add(entry); + var json = JsonSerializer.Serialize(_harLog, _jsonOptions); + File.WriteAllText(_outputPath, json); + } + } + } + + private static List CaptureHeaders(IHeaderDictionary headers) + { + var result = new List(); + foreach (var header in headers) + { + foreach (var value in header.Value) + { + result.Add(new HarHeader { Name = header.Key, Value = value ?? "" }); + } + } + return result; + } + + private static async Task CaptureRequestBodyAsync(HttpContext context) + { + if (context.Request.ContentLength is null or 0) + { + return null; + } + + context.Request.EnableBuffering(); + using var reader = new StreamReader(context.Request.Body, Encoding.UTF8, leaveOpen: true); + var body = await reader.ReadToEndAsync(); + context.Request.Body.Position = 0; + return body; + } + + // === DBSC decoding helpers === + // Adds a non-standard "_dbsc" object to each entry (HAR viewers ignore underscore-prefixed fields). + // Decodes the DBSC proof JWT (base64url JSON, no key needed), parses DBSC headers, and breaks down + // cookies. Auth cookie VALUES are ASP.NET Core Data Protection ciphertext, so only their names, + // sizes, and Set-Cookie attributes are surfaced (the plaintext requires the server keyring). + private static DbscAnnotations? BuildDbscAnnotations( + List requestHeaders, + List responseHeaders, + string? requestBody) + { + string? Find(List headers, string name) => + headers.FirstOrDefault(h => string.Equals(h.Name, name, StringComparison.OrdinalIgnoreCase))?.Value; + + var annotations = new DbscAnnotations(); + + // Request cookies (names + lengths; values are encrypted) + var cookieHeader = Find(requestHeaders, "cookie"); + if (!string.IsNullOrEmpty(cookieHeader)) + { + var cookies = new List(); + foreach (var pair in cookieHeader.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + var eq = pair.IndexOf('='); + if (eq <= 0) + { + continue; + } + cookies.Add(new DbscCookie + { + Name = pair[..eq], + ValueLength = pair.Length - eq - 1, + }); + } + annotations.RequestCookies = cookies.Count > 0 ? cookies : null; + } + + // Set-Cookie responses (name + attributes; values are encrypted) + var setCookies = responseHeaders + .Where(h => string.Equals(h.Name, "set-cookie", StringComparison.OrdinalIgnoreCase)) + .Select(h => ParseSetCookie(h.Value)) + .Where(c => c is not null) + .Select(c => c!) + .ToList(); + annotations.SetCookies = setCookies.Count > 0 ? setCookies : null; + + // DBSC proof JWT: carried in the Secure-Session-Response request header (DBSC v2), + // with the empty POST body fallback for robustness. + var proofRaw = Find(requestHeaders, "secure-session-response"); + if (string.IsNullOrEmpty(proofRaw) && requestBody is { Length: > 0 } && requestBody.Count(c => c == '.') == 2) + { + proofRaw = requestBody; + } + annotations.ProofJwt = TryDecodeJwt(proofRaw); + + // DBSC negotiation headers (raw structured-field values, plus the challenge id when present) + annotations.RegistrationHeader = Find(responseHeaders, "secure-session-registration"); + var challenge = Find(responseHeaders, "secure-session-challenge"); + if (!string.IsNullOrEmpty(challenge)) + { + annotations.ChallengeHeader = challenge; + var idIdx = challenge.IndexOf("id=", StringComparison.OrdinalIgnoreCase); + if (idIdx >= 0) + { + annotations.ChallengeSessionId = challenge[(idIdx + 3)..].Trim().Trim('"'); + } + } + annotations.SessionId = Find(requestHeaders, "sec-secure-session-id")?.Trim().Trim('"'); + + var hasContent = annotations.RequestCookies is not null + || annotations.SetCookies is not null + || annotations.ProofJwt is not null + || annotations.RegistrationHeader is not null + || annotations.ChallengeHeader is not null + || annotations.SessionId is not null; + + return hasContent ? annotations : null; + } + + private static DbscSetCookie? ParseSetCookie(string value) + { + if (string.IsNullOrEmpty(value)) + { + return null; + } + + var segments = value.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (segments.Length == 0) + { + return null; + } + + var first = segments[0]; + var eq = first.IndexOf('='); + if (eq <= 0) + { + return null; + } + + var attributes = segments.Length > 1 + ? string.Join("; ", segments[1..]) + : null; + + return new DbscSetCookie + { + Name = first[..eq], + ValueLength = first.Length - eq - 1, + Attributes = attributes, + }; + } + + private static DbscJwt? TryDecodeJwt(string? token) + { + if (string.IsNullOrWhiteSpace(token)) + { + return null; + } + + token = token.Trim().Trim('"'); + var parts = token.Split('.'); + if (parts.Length is < 2 or > 3) + { + return null; + } + + var headerBytes = TryBase64UrlDecode(parts[0]); + var payloadBytes = TryBase64UrlDecode(parts[1]); + if (headerBytes is null || payloadBytes is null) + { + return null; + } + + try + { + return new DbscJwt + { + Header = JsonNode.Parse(headerBytes), + Payload = JsonNode.Parse(payloadBytes), + SignaturePresent = parts.Length == 3 && parts[2].Length > 0, + }; + } + catch (JsonException) + { + return null; + } + } + + private static byte[]? TryBase64UrlDecode(string value) + { + var s = value.Replace('-', '+').Replace('_', '/'); + switch (s.Length % 4) + { + case 2: s += "=="; break; + case 3: s += "="; break; + case 1: return null; + } + try + { + return Convert.FromBase64String(s); + } + catch (FormatException) + { + return null; + } + } +} + +// HAR format models +public sealed class HarLog +{ + [JsonPropertyName("log")] + public HarLogContent Log { get; set; } = null!; +} + +public sealed class HarLogContent +{ + [JsonPropertyName("version")] + public string Version { get; set; } = "1.2"; + + [JsonPropertyName("creator")] + public HarCreator Creator { get; set; } = null!; + + [JsonPropertyName("entries")] + public List Entries { get; set; } = []; +} + +public sealed class HarCreator +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("version")] + public string Version { get; set; } = ""; +} + +public sealed class HarEntry +{ + [JsonPropertyName("startedDateTime")] + public string StartedDateTime { get; set; } = ""; + + [JsonPropertyName("time")] + public double Time { get; set; } + + [JsonPropertyName("request")] + public HarRequest Request { get; set; } = null!; + + [JsonPropertyName("response")] + public HarResponse Response { get; set; } = null!; + + [JsonPropertyName("timings")] + public HarTimings Timings { get; set; } = null!; + + [JsonPropertyName("_dbsc")] + public DbscAnnotations? Dbsc { get; set; } +} + +// DBSC decoding annotations (non-standard "_dbsc" HAR extension). +public sealed class DbscAnnotations +{ + [JsonPropertyName("sessionId")] + public string? SessionId { get; set; } + + [JsonPropertyName("proofJwt")] + public DbscJwt? ProofJwt { get; set; } + + [JsonPropertyName("registrationHeader")] + public string? RegistrationHeader { get; set; } + + [JsonPropertyName("challengeHeader")] + public string? ChallengeHeader { get; set; } + + [JsonPropertyName("challengeSessionId")] + public string? ChallengeSessionId { get; set; } + + [JsonPropertyName("requestCookies")] + public List? RequestCookies { get; set; } + + [JsonPropertyName("setCookies")] + public List? SetCookies { get; set; } +} + +public sealed class DbscJwt +{ + [JsonPropertyName("header")] + public JsonNode? Header { get; set; } + + [JsonPropertyName("payload")] + public JsonNode? Payload { get; set; } + + [JsonPropertyName("signaturePresent")] + public bool SignaturePresent { get; set; } +} + +public sealed class DbscCookie +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("valueLength")] + public int ValueLength { get; set; } +} + +public sealed class DbscSetCookie +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("valueLength")] + public int ValueLength { get; set; } + + [JsonPropertyName("attributes")] + public string? Attributes { get; set; } +} + +public sealed class HarRequest +{ + [JsonPropertyName("method")] + public string Method { get; set; } = ""; + + [JsonPropertyName("url")] + public string Url { get; set; } = ""; + + [JsonPropertyName("httpVersion")] + public string HttpVersion { get; set; } = ""; + + [JsonPropertyName("headers")] + public List Headers { get; set; } = []; + + [JsonPropertyName("headersSize")] + public int HeadersSize { get; set; } + + [JsonPropertyName("bodySize")] + public int BodySize { get; set; } + + [JsonPropertyName("postData")] + public HarPostData? PostData { get; set; } +} + +public sealed class HarResponse +{ + [JsonPropertyName("status")] + public int Status { get; set; } + + [JsonPropertyName("statusText")] + public string StatusText { get; set; } = ""; + + [JsonPropertyName("httpVersion")] + public string HttpVersion { get; set; } = ""; + + [JsonPropertyName("headers")] + public List Headers { get; set; } = []; + + [JsonPropertyName("headersSize")] + public int HeadersSize { get; set; } + + [JsonPropertyName("bodySize")] + public int BodySize { get; set; } + + [JsonPropertyName("content")] + public HarContent Content { get; set; } = null!; +} + +public sealed class HarHeader +{ + [JsonPropertyName("name")] + public string Name { get; set; } = ""; + + [JsonPropertyName("value")] + public string Value { get; set; } = ""; +} + +public sealed class HarPostData +{ + [JsonPropertyName("mimeType")] + public string MimeType { get; set; } = ""; + + [JsonPropertyName("text")] + public string Text { get; set; } = ""; +} + +public sealed class HarContent +{ + [JsonPropertyName("size")] + public int Size { get; set; } + + [JsonPropertyName("mimeType")] + public string MimeType { get; set; } = ""; + + [JsonPropertyName("text")] + public string? Text { get; set; } +} + +public sealed class HarTimings +{ + [JsonPropertyName("send")] + public double Send { get; set; } + + [JsonPropertyName("wait")] + public double Wait { get; set; } + + [JsonPropertyName("receive")] + public double Receive { get; set; } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs new file mode 100644 index 000000000000..a23d340c5625 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Program.cs @@ -0,0 +1,229 @@ +#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.DeviceBoundSessions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace DbscDebugServer; + +public static class Program +{ + public static Task Main(string[] args) + { + var host = new HostBuilder() + .ConfigureWebHost(webHostBuilder => + { + webHostBuilder + .UseKestrel(options => + { + options.ListenLocalhost(7299, listenOptions => + { + listenOptions.UseHttps(); + }); + }) + .UseStartup(); + }) + .ConfigureLogging(factory => + { + factory.AddConsole(); + factory.AddFilter("Default", LogLevel.Information); + factory.AddFilter("Microsoft.AspNetCore.Authentication", LogLevel.Debug); + }) + .Build(); + + return host.RunAsync(); + } +} + +public class Startup +{ + private readonly DbscDebugState _debug = new(); + + public void ConfigureServices(IServiceCollection services) + { + services.AddSingleton(_debug); + + // The app makes the source cookie its default scheme; AddDeviceBoundSession detects that and + // redirects the default authenticate scheme to its policy scheme, so the app never has to know + // (or wire) the derived DBSC scheme names. + services.AddAuthentication(DbscNames.Source) + // The source cookie scheme (long-lived sign-in cookie) + .AddCookie(DbscNames.Source, o => + { + o.Cookie.Name = DbscNames.SourceCookie; + o.LoginPath = "/login"; + o.ExpireTimeSpan = TimeSpan.FromDays(7); + }) + // The DBSC handler + refresh/session cookie schemes + policy scheme. + // The session TTL is read live from the debug state so it can be changed at runtime. + .AddDeviceBoundSession(DbscNames.Source, o => + { + o.ShortLivedCookieExpiration = _debug.SessionTtl; + + // Exclude the debug dashboard's own endpoints from the DBSC session scope so that + // dashboard polling (/debug/state, /debug/log) is NOT treated as in-scope traffic. + // This keeps the browser from enforcing the bound cookie or triggering refreshes for + // the inspector itself, cleanly separating debugging requests from real test requests. + o.ScopeSpecifications.Add(new DeviceBoundSessionScopeRule + { + Type = "exclude", + Domain = "*", + Path = "/debug", + }); + }); + + services.AddRouting(); + } + + public void Configure(IApplicationBuilder app) + { + // Write all HTTP traffic to a HAR file (raw, for external tooling) + var harPath = Path.Combine(AppContext.BaseDirectory, "dbsc-v2-traffic.har"); + Console.WriteLine($"[HAR] Writing traffic to: {harPath}"); + app.UseMiddleware(harPath); + + // Capture decoded exchanges for the live dashboard + app.UseMiddleware(); + + app.UseRouting(); + app.UseAuthentication(); + + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/", async context => + { + context.Response.ContentType = "text/html; charset=utf-8"; + await context.Response.WriteAsync(Dashboard.Html); + }); + + endpoints.MapGet("/login", context => + { + context.Response.Redirect("/"); + return Task.CompletedTask; + }); + + endpoints.MapPost("/login", async context => + { + var form = await context.Request.ReadFormAsync(); + var username = form["username"].ToString(); + if (string.IsNullOrEmpty(username)) + { + context.Response.Redirect("/"); + return; + } + + // Authorization gate (demo): only "alice" is allowed. Anyone else (e.g. "bob") + // fails login: we do NOT sign in (so no source cookie and no DBSC registration), + // and we redirect back to "/" so the browser stays on the dashboard, logged out. + if (!string.Equals(username, "alice", StringComparison.OrdinalIgnoreCase)) + { + context.Response.Redirect($"/?loginError={Uri.EscapeDataString(username)}"); + return; + } + + // Apply a runtime-configurable session TTL before sign-in triggers registration. + if (int.TryParse(form["ttl"], out var ttlSeconds) && ttlSeconds is > 0 and <= 86400) + { + _debug.SessionTtl = TimeSpan.FromSeconds(ttlSeconds); + // Force the DBSC options to be rebuilt so the new TTL is picked up. + context.RequestServices + .GetRequiredService>() + .Clear(); + } + + var identity = new ClaimsIdentity(DbscNames.Source); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, username)); + identity.AddClaim(new Claim(ClaimTypes.Name, username)); + + await context.SignInAsync( + DbscNames.Source, + new ClaimsPrincipal(identity), + new AuthenticationProperties { IsPersistent = true }); + + context.Response.Redirect("/"); + }); + + endpoints.MapGet("/api/time", async context => + { + if (context.User.Identity?.IsAuthenticated != true) + { + context.Response.StatusCode = 401; + await context.Response.WriteAsync("Unauthorized"); + return; + } + await context.Response.WriteAsync($"Server time {DateTime.UtcNow:HH:mm:ss.fff} | user {context.User.Identity!.Name}"); + }); + + endpoints.MapGet("/signout", async context => + { + await SignOutAllAsync(context); + context.Response.Redirect("/"); + }); + + // Full reset: delete every cookie AND clear the captured log to observe a fresh registration. + endpoints.MapGet("/clear", async context => + { + await SignOutAllAsync(context); + _debug.ClearLog(); + context.Response.Redirect("/"); + }); + + endpoints.MapPost("/debug/clearlog", context => + { + _debug.ClearLog(); + context.Response.StatusCode = 204; + return Task.CompletedTask; + }); + + endpoints.MapGet("/debug/state", async context => + { + var cookies = DbscDecoder.DecodeRequestCookies(context) ?? new List(); + + // The refresh cookie is path-scoped to /.well-known/dbsc and is never sent to this + // endpoint, so surface the latest copy the capture middleware stashed from DBSC traffic. + var refresh = _debug.LatestRefreshCookie; + if (refresh is not null && !cookies.Exists(c => c.Name == refresh.Name)) + { + cookies.Add(refresh); + } + + await context.Response.WriteAsJsonAsync(new + { + authenticated = context.User.Identity?.IsAuthenticated == true, + user = context.User.Identity?.Name, + ttlSeconds = _debug.SessionTtl.TotalSeconds, + cookies, + }); + }); + + endpoints.MapGet("/debug/log", async context => + { + long since = 0; + if (long.TryParse(context.Request.Query["since"], out var s)) + { + since = s; + } + // Long poll: hold the request open until the log changes or ~60s elapses. + var (lastId, entries) = await _debug.WaitForChangesAsync( + since, TimeSpan.FromSeconds(60), context.RequestAborted); + await context.Response.WriteAsJsonAsync(new { lastId, entries }); + }); + }); + } + + private static async Task SignOutAllAsync(HttpContext context) + { + // Signing out the source scheme now also clears the DBSC session and refresh cookies. + await context.SignOutAsync(DbscNames.Source); + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Properties/launchSettings.json b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Properties/launchSettings.json new file mode 100644 index 000000000000..49db3bb172ed --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/samples/DbscDebugServer/Properties/launchSettings.json @@ -0,0 +1,8 @@ +{ + "profiles": { + "DbscDebugServer": { + "commandName": "Project", + "applicationUrl": "https://localhost:7299" + } + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs new file mode 100644 index 000000000000..6b2151c549bb --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionChallengeProtector.cs @@ -0,0 +1,224 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Formats.Cbor; +using System.Security.Claims; +using System.Security.Cryptography; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +internal sealed class DeviceBoundSessionChallengeProtector +{ + private readonly ITimeLimitedDataProtector _registrationProtector; + private readonly ITimeLimitedDataProtector _refreshProtector; + private readonly ILogger _logger; + + public DeviceBoundSessionChallengeProtector(IDataProtectionProvider dataProtectionProvider, ILogger logger) + { + _logger = logger; + + // Registration and refresh challenges use distinct data-protection purposes so a challenge + // minted for one flow cannot be decrypted (let alone accepted) by the other. This makes + // cross-type challenge confusion cryptographically impossible, independent of payload shape. + _registrationProtector = dataProtectionProvider + .CreateProtector("Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.Registration.v1") + .ToTimeLimitedDataProtector(); + _refreshProtector = dataProtectionProvider + .CreateProtector("Microsoft.AspNetCore.Authentication.DeviceBoundSessions.Challenge.Refresh.v1") + .ToTimeLimitedDataProtector(); + } + + public string GenerateRegistrationChallenge(ClaimsPrincipal principal, TimeSpan lifetime) + { + var claimUid = ComputeClaimUid(principal); + var writer = new CborWriter(allowMultipleRootLevelValues: true); + writer.WriteTextString(claimUid); + return WebEncoders.Base64UrlEncode(_registrationProtector.Protect(writer.Encode(), lifetime)); + } + + public string GenerateRefreshChallenge(ClaimsPrincipal principal, string sessionId, TimeSpan lifetime) + { + var claimUid = ComputeClaimUid(principal); + var writer = new CborWriter(allowMultipleRootLevelValues: true); + writer.WriteTextString(claimUid); + writer.WriteTextString(sessionId); + return WebEncoders.Base64UrlEncode(_refreshProtector.Protect(writer.Encode(), lifetime)); + } + + public bool TryValidateRegistrationChallenge(string challenge, ClaimsPrincipal principal) + { + if (!TryUnprotect(_registrationProtector, challenge, out var payload)) + { + // Expired, tampered, or minted for a different flow/version — undecryptable. + _logger.RegistrationChallengeUndecryptable(); + return false; + } + + try + { + var reader = new CborReader(payload, allowMultipleRootLevelValues: true); + var storedClaimUid = reader.ReadTextString(); + if (reader.PeekState() != CborReaderState.Finished) + { + // Extra trailing data — not a registration challenge in the shape this version writes. + _logger.RegistrationChallengeMalformed(); + return false; + } + + if (!string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal)) + { + // Decrypted but bound to a different principal than the request. + _logger.RegistrationChallengePrincipalMismatch(); + return false; + } + + return true; + } + catch (Exception ex) when (ex is CborContentException or InvalidOperationException) + { + // Decrypted (so authentic) but not in the expected registration-challenge shape — e.g. a + // challenge minted by a different/older serialization version. Reading the wrong type throws + // InvalidOperationException; genuinely-malformed CBOR throws CborContentException. + _logger.RegistrationChallengeMalformed(); + return false; + } + } + + public bool TryValidateRefreshChallenge(string challenge, ClaimsPrincipal principal, string expectedSessionId) + { + if (!TryUnprotect(_refreshProtector, challenge, out var payload)) + { + // Expired, tampered, or minted for a different flow/version — undecryptable. + _logger.RefreshChallengeUndecryptable(expectedSessionId); + return false; + } + + try + { + var reader = new CborReader(payload, allowMultipleRootLevelValues: true); + var storedClaimUid = reader.ReadTextString(); + var storedSessionId = reader.ReadTextString(); + if (reader.PeekState() != CborReaderState.Finished) + { + // Extra trailing data — not a refresh challenge in the shape this version writes. + _logger.RefreshChallengeMalformed(expectedSessionId); + return false; + } + + if (!string.Equals(storedClaimUid, ComputeClaimUid(principal), StringComparison.Ordinal)) + { + // Decrypted but bound to a different principal than the request. + _logger.RefreshChallengePrincipalMismatch(expectedSessionId); + return false; + } + + if (!string.Equals(storedSessionId, expectedSessionId, StringComparison.Ordinal)) + { + // Decrypted but bound to a different session than the request. + _logger.RefreshChallengeSessionMismatch(expectedSessionId); + return false; + } + + return true; + } + catch (Exception ex) when (ex is CborContentException or InvalidOperationException) + { + // Decrypted (so authentic) but not in the expected refresh-challenge shape — e.g. a challenge + // minted by a different/older serialization version, or a registration challenge (one field) + // replayed as a refresh proof. Reading the wrong type or past the end throws + // InvalidOperationException; genuinely-malformed CBOR (practically unreachable after an + // authenticated decrypt) throws CborContentException. Either way the challenge is unusable. + _logger.RefreshChallengeMalformed(expectedSessionId); + return false; + } + } + + private static bool TryUnprotect(ITimeLimitedDataProtector protector, string challenge, out byte[] payload) + { + try + { + payload = protector.Unprotect(WebEncoders.Base64UrlDecode(challenge)); + return true; + } + catch + { + payload = []; + return false; + } + } + + internal static string ComputeClaimUid(ClaimsPrincipal principal) + { + foreach (var identity in principal.Identities) + { + if (!identity.IsAuthenticated) + { + continue; + } + + var subClaim = identity.FindFirst(c => string.Equals("sub", c.Type, StringComparison.Ordinal)); + if (subClaim is not null && !string.IsNullOrEmpty(subClaim.Value)) + { + return EncodeClaim(subClaim); + } + + var nameIdClaim = identity.FindFirst(c => string.Equals(ClaimTypes.NameIdentifier, c.Type, StringComparison.Ordinal)); + if (nameIdClaim is not null && !string.IsNullOrEmpty(nameIdClaim.Value)) + { + return EncodeClaim(nameIdClaim); + } + + var upnClaim = identity.FindFirst(c => string.Equals(ClaimTypes.Upn, c.Type, StringComparison.Ordinal)); + if (upnClaim is not null && !string.IsNullOrEmpty(upnClaim.Value)) + { + return EncodeClaim(upnClaim); + } + } + + return ComputeClaimHashFallback(principal); + } + + private static string EncodeClaim(Claim claim) + { + var writer = new CborWriter(allowMultipleRootLevelValues: true); + writer.WriteTextString(claim.Type); + writer.WriteTextString(claim.Value); + writer.WriteTextString(claim.Issuer); + return WebEncoders.Base64UrlEncode(writer.Encode()); + } + + private static string ComputeClaimHashFallback(ClaimsPrincipal principal) + { + var allClaims = new List(); + foreach (var identity in principal.Identities) + { + if (identity.IsAuthenticated) + { + allClaims.AddRange(identity.Claims); + } + } + + if (allClaims.Count == 0) + { + return string.Empty; + } + + allClaims.Sort((a, b) => string.Compare(a.Type, b.Type, StringComparison.Ordinal)); + + var writer = new CborWriter(allowMultipleRootLevelValues: true); + foreach (var claim in allClaims) + { + writer.WriteTextString(claim.Type); + writer.WriteTextString(claim.Value); + writer.WriteTextString(claim.Issuer); + } + + var encoded = writer.Encode(); + Span hashBytes = stackalloc byte[SHA256.HashSizeInBytes]; + SHA256.HashData(encoded, hashBytes); + return WebEncoders.Base64UrlEncode(hashBytes); + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConstants.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConstants.cs new file mode 100644 index 000000000000..cdca8d8ce3fa --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionConstants.cs @@ -0,0 +1,47 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Centralized DBSC wire-protocol constants (header names and supported signing algorithms) +/// shared across the registration-header writer, the protocol handler, and the proof validator. +/// +internal static class DeviceBoundSessionConstants +{ + /// + /// DBSC HTTP header names (the long Secure-Session-* / Sec-Secure-Session-Id forms). + /// + internal static class Headers + { + /// Response header that triggers registration and carries the challenge. + internal const string Registration = "Secure-Session-Registration"; + + /// Response header that demands a fresh proof (emitted only on 403). + internal const string Challenge = "Secure-Session-Challenge"; + + /// Request header that carries the device's proof-of-possession JWT. + internal const string Proof = "Secure-Session-Response"; + + /// Request header that identifies the session on refresh. + internal const string SessionId = "Sec-Secure-Session-Id"; + } + + /// + /// ECDSA P-256 / SHA-256 proof signing algorithm. + /// + internal const string Es256 = "ES256"; + + /// + /// RSASSA-PKCS1-v1_5 / SHA-256 proof signing algorithm. + /// + internal const string Rs256 = "RS256"; + + /// + /// The supported algorithms formatted as the structured-field inner-list advertised in the + /// header (e.g. (ES256 RS256)). This advertised set + /// MUST stay in lock-step with the algorithms the proof validator accepts (, + /// ). + /// + internal const string AdvertisedAlgorithms = "(" + Es256 + " " + Rs256 + ")"; +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs new file mode 100644 index 000000000000..9d02f4d57e64 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionCookieEvents.cs @@ -0,0 +1,127 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// A decorator used when a DBSC source cookie scheme sets +/// the cookie options' EventsType. It emits the DBSC registration header on +/// sign-in and forwards every event to the application's configured (DI-resolved) inner events. +/// +internal sealed class DeviceBoundSessionCookieEvents : CookieAuthenticationEvents +{ + private readonly string _dbscScheme; + private readonly CookieAuthenticationEvents _innerEvents; + private readonly Type? _innerEventsType; + + public DeviceBoundSessionCookieEvents(string dbscScheme, CookieAuthenticationEvents innerEvents, Type? innerEventsType) + { + _dbscScheme = dbscScheme; + _innerEvents = innerEvents; + _innerEventsType = innerEventsType; + } + + public override async Task ValidatePrincipal(CookieValidatePrincipalContext context) + { + await GetInnerEvents(context.HttpContext).ValidatePrincipal(context); + } + + public override async Task CheckSlidingExpiration(CookieSlidingExpirationContext context) + { + await GetInnerEvents(context.HttpContext).CheckSlidingExpiration(context); + } + + public override async Task SigningIn(CookieSigningInContext context) + { + DeviceBoundSessionRegistrationHeader.Emit(context.HttpContext, context.Principal, _dbscScheme); + await GetInnerEvents(context.HttpContext).SigningIn(context); + } + + public override async Task SignedIn(CookieSignedInContext context) + { + await GetInnerEvents(context.HttpContext).SignedIn(context); + } + + public override async Task SigningOut(CookieSigningOutContext context) + { + await ClearDerivedCookiesAsync(context.HttpContext, _dbscScheme); + await GetInnerEvents(context.HttpContext).SigningOut(context); + } + + public override async Task RedirectToLogout(RedirectContext context) + { + await GetInnerEvents(context.HttpContext).RedirectToLogout(context); + } + + public override async Task RedirectToLogin(RedirectContext context) + { + await GetInnerEvents(context.HttpContext).RedirectToLogin(context); + } + + public override async Task RedirectToReturnUrl(RedirectContext context) + { + await GetInnerEvents(context.HttpContext).RedirectToReturnUrl(context); + } + + public override async Task RedirectToAccessDenied(RedirectContext context) + { + await GetInnerEvents(context.HttpContext).RedirectToAccessDenied(context); + } + + private CookieAuthenticationEvents GetInnerEvents(HttpContext httpContext) + { + if (_innerEventsType is null) + { + return _innerEvents; + } + + return (CookieAuthenticationEvents)httpContext.RequestServices.GetRequiredService(_innerEventsType); + } + + /// + /// Marker set on during the DBSC registration cookie exchange, when + /// the handler signs out the source scheme to drop the long-lived cookie. It tells the source + /// scheme's sign-out hook that this is an exchange, not a user logout, so it leaves the + /// freshly-minted session and refresh cookies intact. + /// + internal const string RegistrationExchangeItemKey = "__DeviceBoundSession.RegistrationExchange"; + + /// + /// Clears the DBSC-derived session and refresh cookies when the source scheme signs out, so a plain + /// SignOutAsync(sourceScheme) fully logs the user out. The application never needs to know the + /// derived scheme names to clear them — just as it never needs them to create them. Shared with the + /// delegate-based sign-out wiring installed by . + /// + /// The current HTTP context. + /// The resolved DBSC handler scheme whose options name the derived schemes. + internal static async Task ClearDerivedCookiesAsync(HttpContext httpContext, string dbscScheme) + { + // During DBSC registration the handler signs out the source scheme to complete the cookie + // exchange. That is not a user logout, so leave the session/refresh cookies just minted intact. + if (httpContext.Items.ContainsKey(RegistrationExchangeItemKey)) + { + return; + } + + var options = httpContext.RequestServices + .GetRequiredService>() + .Get(dbscScheme); + + if (options.SessionScheme is not null) + { + await httpContext.SignOutAsync(options.SessionScheme); + } + + if (options.RefreshScheme is not null) + { + await httpContext.SignOutAsync(options.RefreshScheme); + } + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs new file mode 100644 index 000000000000..84d6fe93a77a --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionDefaults.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Default values for the Device Bound Session Credentials authentication scheme. +/// +[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +public static class DeviceBoundSessionDefaults +{ + /// + /// The default authentication scheme name. + /// + public const string AuthenticationScheme = "DeviceBoundSession"; + + /// + /// The default registration path. + /// + public const string RegistrationPath = "/.well-known/dbsc/registration"; + + /// + /// The default refresh path. + /// + public const string RefreshPath = "/.well-known/dbsc/refresh"; +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs new file mode 100644 index 000000000000..3c39d209e338 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionExtensions.cs @@ -0,0 +1,142 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Authentication.DeviceBoundSessions; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Options; +using DbscOptions = Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions; + +namespace Microsoft.Extensions.DependencyInjection; + +/// +/// Extension methods to configure Device Bound Session Credentials authentication. +/// +[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +public static class DeviceBoundSessionExtensions +{ + /// + /// Adds Device Bound Session Credentials (DBSC) authentication that wraps an existing cookie scheme. + /// This sets up: a policy scheme (default auth), a refresh cookie scheme (path-scoped stash), + /// a session cookie scheme (short-lived), and the DBSC protocol handler. + /// + /// The authentication builder. + /// The existing cookie authentication scheme to protect with DBSC (e.g., "Identity.Application"). + /// The authentication builder for chaining. + public static AuthenticationBuilder AddDeviceBoundSession( + this AuthenticationBuilder builder, + string sourceScheme) + => AddDeviceBoundSessionCore(builder, DeviceBoundSessionDefaults.AuthenticationScheme, sourceScheme, configureOptions: null); + + /// + /// Adds Device Bound Session Credentials (DBSC) authentication that wraps an existing cookie scheme. + /// + /// The authentication builder. + /// The existing cookie authentication scheme to protect with DBSC (e.g., "Identity.Application"). + /// Action to configure DBSC options. + /// The authentication builder for chaining. + public static AuthenticationBuilder AddDeviceBoundSession( + this AuthenticationBuilder builder, + string sourceScheme, + Action configureOptions) + => AddDeviceBoundSessionCore(builder, DeviceBoundSessionDefaults.AuthenticationScheme, sourceScheme, configureOptions); + + /// + /// Adds Device Bound Session Credentials (DBSC) authentication that wraps an existing cookie scheme. + /// + /// The authentication builder. + /// The DBSC authentication scheme name. + /// The existing cookie authentication scheme to protect with DBSC. + /// The authentication builder for chaining. + public static AuthenticationBuilder AddDeviceBoundSession( + this AuthenticationBuilder builder, + string authenticationScheme, + string sourceScheme) + => AddDeviceBoundSessionCore(builder, authenticationScheme, sourceScheme, configureOptions: null); + + /// + /// Adds Device Bound Session Credentials (DBSC) authentication that wraps an existing cookie scheme. + /// + /// The authentication builder. + /// The DBSC authentication scheme name. + /// The existing cookie authentication scheme to protect with DBSC. + /// Action to configure DBSC options. + /// The authentication builder for chaining. + public static AuthenticationBuilder AddDeviceBoundSession( + this AuthenticationBuilder builder, + string authenticationScheme, + string sourceScheme, + Action configureOptions) + => AddDeviceBoundSessionCore(builder, authenticationScheme, sourceScheme, configureOptions); + + private static AuthenticationBuilder AddDeviceBoundSessionCore( + AuthenticationBuilder builder, + string authenticationScheme, + string sourceScheme, + Action? configureOptions) + { + var refreshScheme = $"{sourceScheme}.Dbsc.Refresh"; + var sessionScheme = $"{sourceScheme}.Dbsc.Session"; + var policyScheme = $"{sourceScheme}.Dbsc"; + + // Add the refresh cookie scheme — settings will be copied from the source + // scheme via PostConfigureDeviceBoundSessionDerivedCookieOptions + builder.AddCookie(refreshScheme, o => + { + o.Cookie.Name = $".AspNetCore.{sourceScheme}.Dbsc.Refresh"; + o.Cookie.Path = "/.well-known/dbsc"; + }); + + // Add the session cookie scheme — settings copied from source, expiry overridden + builder.AddCookie(sessionScheme, o => + { + o.Cookie.Name = $".AspNetCore.{sourceScheme}.Dbsc.Session"; + }); + + // Add a policy scheme that tries the session cookie first, then falls back to the source scheme + builder.AddPolicyScheme(policyScheme, policyScheme, o => + { + o.ForwardDefaultSelector = context => + { + // Resolve the session scheme's configured cookie name at request time so an app that + // customizes CookieAuthenticationOptions.Cookie.Name is still matched. Otherwise the + // selector would miss the cookie and fall back to a source scheme that DBSC registration + // may have deleted, effectively logging the user out. + var sessionCookieName = context.RequestServices + .GetRequiredService>() + .Get(sessionScheme).Cookie.Name ?? $".AspNetCore.{sessionScheme}"; + if (context.Request.Cookies.ContainsKey(sessionCookieName)) + { + return sessionScheme; + } + return sourceScheme; + }; + }); + + // Register services + builder.Services.TryAddSingleton(); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureDeviceBoundSessionCookieOptions>()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureDeviceBoundSessionDerivedCookieOptions>()); + builder.Services.TryAddEnumerable(ServiceDescriptor.Singleton, PostConfigureDeviceBoundSessionAuthenticationOptions>()); + builder.Services.Configure(o => + { + o.Schemes[sourceScheme] = authenticationScheme; + o.RefreshSchemes[refreshScheme] = sourceScheme; + o.SessionSchemes[sessionScheme] = sourceScheme; + o.PolicySchemes[sourceScheme] = policyScheme; + }); + + // Add the DBSC protocol handler + builder.AddScheme(authenticationScheme, o => + { + o.RegistrationSourceScheme = sourceScheme; + o.RefreshScheme = refreshScheme; + o.SessionScheme = sessionScheme; + configureOptions?.Invoke(o); + }); + + return builder; + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs new file mode 100644 index 000000000000..2fa9227a2ff8 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHandler.cs @@ -0,0 +1,403 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using System.Text.Json; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Authentication handler that implements the Device Bound Session Credentials (DBSC) protocol. +/// Handles registration and refresh endpoints, delegating cookie management to separate cookie schemes. +/// +[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +public class DeviceBoundSessionHandler : AuthenticationHandler, IAuthenticationRequestHandler +{ + private readonly DeviceBoundSessionChallengeProtector _challengeProtector; + private readonly DeviceBoundSessionJwtValidator _jwtValidator; + private readonly IOptionsMonitor _cookieOptionsMonitor; + + /// + /// Initializes a new instance of . + /// + /// The monitor for instances. + /// The used to create loggers. + /// The used to encode URLs. + /// The used to protect and unprotect registration challenges. + /// The monitor for used to resolve the session cookie configuration. + public DeviceBoundSessionHandler( + IOptionsMonitor options, + ILoggerFactory logger, + UrlEncoder encoder, + IDataProtectionProvider dataProtectionProvider, + IOptionsMonitor cookieOptionsMonitor) + : base(options, logger, encoder) + { + _challengeProtector = new DeviceBoundSessionChallengeProtector(dataProtectionProvider, logger.CreateLogger()); + _jwtValidator = new DeviceBoundSessionJwtValidator(logger.CreateLogger()); + _cookieOptionsMonitor = cookieOptionsMonitor; + } + + /// + /// The handler does not authenticate normal requests — cookie handlers do that. + /// + /// A completed task whose result is always . + protected override Task HandleAuthenticateAsync() + => Task.FromResult(AuthenticateResult.NoResult()); + + /// + /// Handles DBSC registration and refresh requests if the path matches. + /// + /// + /// A task whose result is when the request matched a DBSC registration or + /// refresh endpoint and was handled (the pipeline should short-circuit); otherwise . + /// + public async Task HandleRequestAsync() + { + if (HttpMethods.IsPost(Request.Method)) + { + if (Request.Path.Equals(Options.RegistrationPath)) + { + await HandleRegistrationAsync(); + return true; + } + + if (Request.Path.Equals(Options.RefreshPath)) + { + await HandleRefreshAsync(); + return true; + } + } + + return false; + } + + private async Task HandleRegistrationAsync() + { + // Extract the JWT proof from the Secure-Session-Response header + var proofHeader = Request.Headers[DeviceBoundSessionConstants.Headers.Proof].ToString(); + if (string.IsNullOrEmpty(proofHeader)) + { + Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + proofHeader = proofHeader.Trim('"'); + + // Validate the JWT and extract the public key (registration: no existing key to check against) + var jwtResult = await _jwtValidator.ValidateAsync(proofHeader, publicKeyJwk: null, expectedChallenge: null); + if (jwtResult is null) + { + // The validator logs the specific reason (malformed / wrong typ / unsupported alg / bad signature). + Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + // Authenticate against the registration source scheme (the long-lived sign-in cookie) + var authResult = await Context.AuthenticateAsync(Options.RegistrationSourceScheme); + if (!authResult.Succeeded || authResult.Principal is null) + { + Logger.RegistrationNoSourceAuthentication(); + Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var principal = authResult.Principal; + var properties = authResult.Properties ?? new AuthenticationProperties(); + + if (jwtResult.Challenge is null) + { + Logger.RegistrationChallengeMissing(); + Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + if (!ValidateRegistrationChallenge(jwtResult.Challenge, principal)) + { + // The protector logs the specific reason (undecryptable / malformed / principal-mismatch). + Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + // Generate a session ID + var sessionId = GenerateSessionId(); + + // Store public key and session info in properties for the refresh cookie + var refreshProperties = new AuthenticationProperties(); + foreach (var item in properties.Items) + { + refreshProperties.Items[item.Key] = item.Value; + } + refreshProperties.Items["DbscPublicKeyJwk"] = jwtResult.PublicKeyJwk; + refreshProperties.Items["DbscSessionId"] = sessionId; + refreshProperties.Items["DbscAlgorithm"] = jwtResult.Algorithm; + // Mirror the source sign-in's persistence so enabling DBSC does not change session/persistence + // semantics: a session-only sign-in stays session-only, a persistent one stays persistent. + refreshProperties.IsPersistent = properties.IsPersistent; + + // Leave IssuedUtc/ExpiresUtc unset so the refresh cookie scheme starts a fresh ExpireTimeSpan + // window at registration (exactly like a normal sign-in) and then slides on each refresh when + // the source scheme uses sliding expiration. This mirrors the auth cookie the session replaces, + // so enabling DBSC does not regress an active user's session lifetime. + + // 1. Stamp the refresh cookie (path-scoped stash with ticket + public key) + await Context.SignInAsync(Options.RefreshScheme, principal, refreshProperties); + + // 2. Stamp the short-lived session cookie + var sessionProperties = new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.Add(Options.ShortLivedCookieExpiration), + IssuedUtc = DateTimeOffset.UtcNow, + }; + await Context.SignInAsync(Options.SessionScheme, principal, sessionProperties); + + // 3. Delete the long-lived source cookie (exchange complete). Flag this as the DBSC + // registration exchange so the source scheme's sign-out hook does not treat it as a user + // logout and clear the session and refresh cookies we just minted. + Context.Items[DeviceBoundSessionCookieEvents.RegistrationExchangeItemKey] = true; + await Context.SignOutAsync(Options.RegistrationSourceScheme); + + // Build and return session instructions JSON. + var instructions = BuildSessionInstruction(sessionId); + + Response.StatusCode = StatusCodes.Status200OK; + Response.ContentType = "application/json"; + + await JsonSerializer.SerializeAsync(Response.Body, instructions, DeviceBoundSessionJsonContext.Default.SessionInstruction, Context.RequestAborted); + } + + private async Task HandleRefreshAsync() + { + // Read session ID from header + var sessionIdHeader = Request.Headers[DeviceBoundSessionConstants.Headers.SessionId].ToString(); + if (string.IsNullOrEmpty(sessionIdHeader)) + { + Response.StatusCode = StatusCodes.Status400BadRequest; + return; + } + + sessionIdHeader = sessionIdHeader.Trim('"'); + + // Authenticate against the refresh scheme (path-scoped refresh cookie) + var authResult = await Context.AuthenticateAsync(Options.RefreshScheme); + if (!authResult.Succeeded || authResult.Principal is null) + { + Logger.RefreshNoCookie(sessionIdHeader); + Response.StatusCode = StatusCodes.Status401Unauthorized; + return; + } + + var properties = authResult.Properties; + + // Verify the session ID matches what's in the refresh cookie + if (properties is null || + !properties.Items.TryGetValue("DbscSessionId", out var storedSessionId) || + !string.Equals(storedSessionId, sessionIdHeader, StringComparison.Ordinal)) + { + Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + // Get the public key from the refresh cookie + if (!properties.Items.TryGetValue("DbscPublicKeyJwk", out var publicKeyJwk) || publicKeyJwk is null) + { + Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + // Check for the proof JWT + var proofHeader = Request.Headers[DeviceBoundSessionConstants.Headers.Proof].ToString(); + if (string.IsNullOrEmpty(proofHeader)) + { + // No proof yet — issue a challenge (first leg of refresh) + var challenge = GenerateRefreshChallenge(authResult.Principal, sessionIdHeader); + Response.StatusCode = StatusCodes.Status403Forbidden; + Response.Headers[DeviceBoundSessionConstants.Headers.Challenge] = $"\"{challenge}\";id=\"{sessionIdHeader}\""; + return; + } + + proofHeader = proofHeader.Trim('"'); + + // Validate the JWT proof against the public key from the refresh cookie + var jwtResult = await _jwtValidator.ValidateAsync(proofHeader, publicKeyJwk, expectedChallenge: null); + if (jwtResult is null) + { + // The validator logs the specific reason (malformed / wrong typ / unsupported alg / bad signature). + Response.StatusCode = StatusCodes.Status403Forbidden; + return; + } + + // Validate the challenge (jti). The protector logs the specific reason + // (undecryptable / malformed / principal- or session-mismatch); a missing jti is invalid. + var challengeValid = jwtResult.Challenge is not null + && ValidateRefreshChallenge(jwtResult.Challenge, authResult.Principal, sessionIdHeader); + + if (!challengeValid) + { + // Re-issue a fresh, correctly-bound challenge so the client can immediately + // retry (the DBSC 403 + Secure-Session-Challenge handshake). + // This is curative, not a futile retry: the signature and refresh cookie already validated and are + // unchanged on retry, so the stale/expired/version-skewed challenge was the only failing input and a + // fresh nonce fixes it. Persistent client/server faults can't recover but are bounded by the + // client's challenge quota, which ends in a clean re-login. + var retryChallenge = GenerateRefreshChallenge(authResult.Principal, sessionIdHeader); + Response.StatusCode = StatusCodes.Status403Forbidden; + Response.Headers[DeviceBoundSessionConstants.Headers.Challenge] = $"\"{retryChallenge}\";id=\"{sessionIdHeader}\""; + return; + } + + // Success — stamp a fresh short-lived session cookie + var sessionProperties = new AuthenticationProperties + { + IsPersistent = true, + ExpiresUtc = DateTimeOffset.UtcNow.Add(Options.ShortLivedCookieExpiration), + IssuedUtc = DateTimeOffset.UtcNow, + }; + await Context.SignInAsync(Options.SessionScheme, authResult.Principal, sessionProperties); + + // NOTE: Returning the Session Instruction (config JSON) on a refresh 200 is OPTIONAL per the + // DBSC spec — the browser already has the instructions from registration, and we verified the + // session keeps working when the refresh body is empty. We currently re-send it every time for + // simplicity. A future optimization could omit it on the common path and only send it when the + // instructions actually need to change — e.g. to update scope/credentials, narrow access, or + // force a logout/cleanup everywhere by returning updated (or session-ending) instructions. + var instructions = BuildSessionInstruction(sessionIdHeader); + + Response.StatusCode = StatusCodes.Status200OK; + Response.ContentType = "application/json"; + + await JsonSerializer.SerializeAsync(Response.Body, instructions, DeviceBoundSessionJsonContext.Default.SessionInstruction, Context.RequestAborted); + } + + private SessionInstruction BuildSessionInstruction(string sessionId) + { + var origin = $"{Request.Scheme}://{Request.Host}"; + + List? scopeRules = null; + if (Options.ScopeSpecifications.Count > 0) + { + scopeRules = Options.ScopeSpecifications.Select(r => new SessionScopeRule + { + Type = r.Type, + Domain = r.Domain, + Path = r.Path, + }).ToList(); + } + + // The credential cookie name and attributes are derived from the session scheme's cookie + // options so the session instruction stays in lock-step with the cookie we actually emit. + // A wrong name would silently break DBSC (the browser would bind to a cookie we never set), + // so we fail loudly rather than emit a guessed default that can never match. + var sessionCookieOptions = ResolveSessionCookieOptions(); + var sessionCookieName = sessionCookieOptions.Cookie.Name + ?? throw new InvalidOperationException( + $"The session cookie scheme '{Options.SessionScheme}' has no configured cookie name; cannot build a valid DBSC session instruction."); + + List? allowedRefreshInitiators = null; + if (Options.AllowedRefreshInitiators.Count > 0) + { + allowedRefreshInitiators = [.. Options.AllowedRefreshInitiators]; + } + + return new SessionInstruction + { + SessionIdentifier = sessionId, + RefreshUrl = Request.PathBase.Add(Options.RefreshPath).Value, + Scope = new SessionScope + { + Origin = origin, + IncludeSite = Options.IncludeSite, + ScopeSpecification = scopeRules, + }, + Credentials = new List + { + new SessionCredential + { + Name = sessionCookieName, + Attributes = BuildCredentialAttributes(sessionCookieOptions), + } + }, + AllowedRefreshInitiators = allowedRefreshInitiators, + }; + } + + private CookieAuthenticationOptions ResolveSessionCookieOptions() + { + if (Options.SessionScheme is null) + { + throw new InvalidOperationException( + $"{nameof(DeviceBoundSessionOptions.SessionScheme)} must be configured to build a DBSC session instruction."); + } + + return _cookieOptionsMonitor.Get(Options.SessionScheme); + } + + private string BuildCredentialAttributes(CookieAuthenticationOptions cookieOptions) + { + // Build the cookie exactly as the session cookie handler will, so the advertised attributes — + // including the path, which the request path base is applied to — match the cookie we emit. + var cookie = cookieOptions.Cookie.Build(Context); + + var attributes = new List(); + if (cookie.Secure) + { + attributes.Add("Secure"); + } + if (cookie.HttpOnly) + { + attributes.Add("HttpOnly"); + } + + switch (cookie.SameSite) + { + case SameSiteMode.None: + attributes.Add("SameSite=None"); + break; + case SameSiteMode.Strict: + attributes.Add("SameSite=Strict"); + break; + case SameSiteMode.Lax: + attributes.Add("SameSite=Lax"); + break; + } + + attributes.Add($"Path={(string.IsNullOrEmpty(cookie.Path) ? "/" : cookie.Path)}"); + + if (!string.IsNullOrEmpty(cookie.Domain)) + { + attributes.Add($"Domain={cookie.Domain}"); + } + + return string.Join("; ", attributes); + } + + private string GenerateRegistrationChallenge(ClaimsPrincipal principal) + => _challengeProtector.GenerateRegistrationChallenge(principal, Options.ChallengeMaxAge); + + private bool ValidateRegistrationChallenge(string challenge, ClaimsPrincipal principal) + => _challengeProtector.TryValidateRegistrationChallenge(challenge, principal); + + private string GenerateRefreshChallenge(ClaimsPrincipal principal, string sessionId) + => _challengeProtector.GenerateRefreshChallenge(principal, sessionId, Options.ChallengeMaxAge); + + private bool ValidateRefreshChallenge(string challenge, ClaimsPrincipal principal, string expectedSessionId) + => _challengeProtector.TryValidateRefreshChallenge(challenge, principal, expectedSessionId); + + private static string GenerateSessionId() + { + Span bytes = stackalloc byte[24]; + RandomNumberGenerator.Fill(bytes); + return WebEncoders.Base64UrlEncode(bytes); + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHttpContextExtensions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHttpContextExtensions.cs new file mode 100644 index 000000000000..9751b123c3b7 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionHttpContextExtensions.cs @@ -0,0 +1,103 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Extension methods on for Device Bound Session Credentials (DBSC). +/// +[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +public static class DeviceBoundSessionHttpContextExtensions +{ + /// + /// Writes the DBSC Secure-Session-Registration response header for the current + /// authenticated user (), advertising the supported signing + /// algorithms and a fresh, principal-bound registration challenge. + /// + /// + /// + /// Use this to opt an already signed-in user into a device bound session without forcing a + /// re-login — for example, to migrate existing sessions after deploying DBSC instead of waiting + /// for them to expire. Registration is not tied to the sign-in event: a DBSC-capable browser reacts + /// to this header on any authenticated response and starts registration, sending the proof to the + /// registration endpoint with the existing source cookie attached. Browsers that do not support DBSC + /// ignore the header, so it is safe to emit broadly. + /// + /// + /// Calling this does not itself create a session; it only advertises registration. Emitting it + /// repeatedly mints a new challenge each time, so the caller owns idempotency: gate the call so a + /// user is offered registration at most once (for example, with a durable marker cookie), and skip + /// clients that are already bound (detected by the presence of the bound session cookie). The header + /// is only honored over HTTPS. + /// + /// + /// The current . + /// + /// The source cookie authentication scheme that was passed to + /// AddDeviceBoundSession (for example IdentityConstants.ApplicationScheme). + /// + /// is . + /// is or empty. + /// + /// is not a registered Device Bound Session source scheme, or the + /// current user is not authenticated. + /// + /// + /// Migration middleware that offers DBSC registration to already-signed-in users once each. It skips + /// clients that already have a bound session (the bound session cookie is present) and uses a durable + /// marker cookie so the offer is not repeated on every request: + /// + /// // The DBSC bound session cookie follows .AspNetCore.{sourceScheme}.Dbsc.Session + /// const string boundCookie = ".AspNetCore.Identity.Application.Dbsc.Session"; + /// + /// app.Use(async (context, next) => + /// { + /// var alreadyBound = context.Request.Cookies.ContainsKey(boundCookie); + /// var alreadyOffered = context.Request.Cookies.ContainsKey("dbsc-offered"); + /// + /// if (context.User.Identity?.IsAuthenticated == true && !alreadyBound && !alreadyOffered) + /// { + /// context.WriteDeviceBoundSessionRegistration(IdentityConstants.ApplicationScheme); + /// + /// context.Response.Cookies.Append("dbsc-offered", "1", new CookieOptions + /// { + /// Path = "/", + /// Secure = true, + /// HttpOnly = true, + /// SameSite = SameSiteMode.Lax, + /// }); + /// } + /// + /// await next(); + /// }); + /// + /// + public static void WriteDeviceBoundSessionRegistration(this HttpContext context, string sourceScheme) + { + ArgumentNullException.ThrowIfNull(context); + ArgumentException.ThrowIfNullOrEmpty(sourceScheme); + + var principal = context.User; + if (principal.Identity?.IsAuthenticated != true) + { + throw new InvalidOperationException( + "The Device Bound Session registration challenge must be bound to an authenticated principal."); + } + + var sourceSchemes = context.RequestServices + .GetRequiredService>().Value; + if (!sourceSchemes.Schemes.TryGetValue(sourceScheme, out var dbscScheme)) + { + throw new InvalidOperationException( + $"'{sourceScheme}' is not registered as a Device Bound Session source scheme. " + + $"Call AddDeviceBoundSession(\"{sourceScheme}\") to register it first."); + } + + DeviceBoundSessionRegistrationHeader.Emit(context, principal, dbscScheme); + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs new file mode 100644 index 000000000000..9e897d3b41c1 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJsonContext.cs @@ -0,0 +1,17 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Source-generated JSON serialization context for DBSC configuration types. +/// +[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +[JsonSerializable(typeof(SessionInstruction))] +[JsonSourceGenerationOptions(DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)] +internal sealed partial class DeviceBoundSessionJsonContext : JsonSerializerContext +{ +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtResult.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtResult.cs new file mode 100644 index 000000000000..80508bc594d6 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtResult.cs @@ -0,0 +1,15 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Result of validating a DBSC proof JWT. +/// +internal sealed class DeviceBoundSessionJwtResult +{ + public required string Algorithm { get; init; } + public required string PublicKeyJwk { get; init; } + public string? Challenge { get; init; } + public string? Authorization { get; init; } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs new file mode 100644 index 000000000000..3aaf1210b966 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionJwtValidator.cs @@ -0,0 +1,128 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Text.Json; +using Microsoft.Extensions.Logging; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Validates DBSC proof JWTs (typ: "dbsc+jwt") signed with ES256 or RS256. +/// +internal sealed class DeviceBoundSessionJwtValidator +{ + private static readonly JsonWebTokenHandler _tokenHandler = new(); + private readonly ILogger _logger; + + public DeviceBoundSessionJwtValidator(ILogger logger) + { + _logger = logger; + } + + /// + /// Validates a DBSC proof JWT and extracts its claims. + /// + /// The raw JWT string. + /// The JWK JSON of the public key to validate against. If null, extracts from JWT header. + /// The expected challenge value (jti claim). + /// The parsed result, or null if validation fails. + public async Task ValidateAsync(string jwt, string? publicKeyJwk, string? expectedChallenge) + { + JsonWebToken token; + try + { + token = new JsonWebToken(jwt); + } + catch (ArgumentException) + { + _logger.ProofMalformed(); + return null; + } + + if (!token.TryGetHeaderValue("typ", out var tokenType) || + !string.Equals(tokenType, "dbsc+jwt", StringComparison.Ordinal)) + { + _logger.ProofWrongType(); + return null; + } + + if (!token.TryGetHeaderValue("alg", out var algorithm) || string.IsNullOrEmpty(algorithm)) + { + _logger.ProofMissingAlgorithm(); + return null; + } + + string? jwkJson = publicKeyJwk; + if (jwkJson is null) + { + if (!token.TryGetHeaderValue("jwk", out var jwkElement)) + { + _logger.ProofMissingKey(); + return null; + } + + jwkJson = jwkElement.GetRawText(); + } + + var securityKey = CreateSecurityKey(jwkJson, algorithm); + if (securityKey is null) + { + _logger.ProofUnsupportedKey(); + return null; + } + + var validationResult = await _tokenHandler.ValidateTokenAsync(jwt, new TokenValidationParameters + { + IssuerSigningKey = securityKey, + ValidateAudience = false, + ValidateIssuer = false, + ValidateIssuerSigningKey = true, + ValidateLifetime = false, + }); + + if (!validationResult.IsValid) + { + _logger.ProofSignatureInvalid(); + return null; + } + + token.TryGetPayloadValue("jti", out string? challenge); + if (expectedChallenge is not null && !string.Equals(challenge, expectedChallenge, StringComparison.Ordinal)) + { + _logger.ProofChallengeMismatch(); + return null; + } + + token.TryGetPayloadValue("authorization", out string? authorization); + + return new DeviceBoundSessionJwtResult + { + Algorithm = algorithm, + PublicKeyJwk = jwkJson, + Challenge = challenge, + Authorization = authorization, + }; + } + + private static SecurityKey? CreateSecurityKey(string jwkJson, string algorithm) + { + JsonWebKey jsonWebKey; + try + { + jsonWebKey = new JsonWebKey(jwkJson); + } + catch (ArgumentException) + { + return null; + } + + return algorithm switch + { + DeviceBoundSessionConstants.Es256 when string.Equals(jsonWebKey.Kty, "EC", StringComparison.Ordinal) && string.Equals(jsonWebKey.Crv, "P-256", StringComparison.Ordinal) => jsonWebKey, + DeviceBoundSessionConstants.Rs256 when string.Equals(jsonWebKey.Kty, "RSA", StringComparison.Ordinal) => jsonWebKey, + _ => null, + }; + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs new file mode 100644 index 000000000000..b5422b8c00c5 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionOptions.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using Microsoft.AspNetCore.Http; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Options for the Device Bound Session Credentials authentication handler. +/// +[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +public class DeviceBoundSessionOptions : AuthenticationSchemeOptions +{ + /// + /// Gets or sets the authentication scheme used for initial sign-in (the source of the long-lived cookie). + /// During registration, the handler authenticates against this scheme to read the user's ticket. + /// + public string? RegistrationSourceScheme { get; set; } + + /// + /// Gets or sets the authentication scheme for the refresh cookie (path-scoped stash). + /// During refresh, the handler authenticates against this scheme to retrieve the ticket + public key. + /// + public string? RefreshScheme { get; set; } + + /// + /// Gets or sets the authentication scheme used to stamp the short-lived session cookie. + /// Both registration and refresh call SignInAsync on this scheme. + /// + public string? SessionScheme { get; set; } + + /// + /// Gets or sets the path for the registration endpoint. + /// Defaults to /.well-known/dbsc/registration. + /// + public PathString RegistrationPath { get; set; } = DeviceBoundSessionDefaults.RegistrationPath; + + /// + /// Gets or sets the path for the refresh endpoint. + /// Defaults to /.well-known/dbsc/refresh. + /// + public PathString RefreshPath { get; set; } = DeviceBoundSessionDefaults.RefreshPath; + + /// + /// Gets or sets the expiration for the short-lived session cookie. + /// Defaults to 10 minutes. + /// + public TimeSpan ShortLivedCookieExpiration { get; set; } = TimeSpan.FromMinutes(10); + + /// + /// Gets or sets the maximum age for challenges before they are considered stale. + /// Defaults to 5 minutes. + /// + public TimeSpan ChallengeMaxAge { get; set; } = TimeSpan.FromMinutes(5); + + /// + /// Gets or sets whether to include the entire site in the session scope. + /// Defaults to false (origin-only scope). + /// + public bool IncludeSite { get; set; } + + /// + /// Gets the list of scope specifications for the session. + /// + public IList ScopeSpecifications { get; } = new List(); + + /// + /// Gets the list of allowed refresh initiator host patterns. + /// + public IList AllowedRefreshInitiators { get; } = new List(); +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionRegistrationHeader.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionRegistrationHeader.cs new file mode 100644 index 000000000000..7d9ef453fbdb --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionRegistrationHeader.cs @@ -0,0 +1,54 @@ +#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Security.Claims; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Writes the DBSC Secure-Session-Registration response header, advertising the supported +/// signing algorithms and a fresh registration challenge bound to a principal. Shared by the +/// cookie-events wiring paths (the inline OnSigningIn delegate installed by +/// and the +/// wrapper) and by the public +/// on-demand entry point. +/// +internal static class DeviceBoundSessionRegistrationHeader +{ + /// + /// Emits the Secure-Session-Registration header onto the current response. + /// + /// The current HTTP context. + /// The principal the challenge is bound to. When an empty principal is used. + /// The resolved DBSC handler scheme name whose options drive the header. + public static void Emit(HttpContext httpContext, ClaimsPrincipal? principal, string dbscScheme) + { + // Dependencies are resolved from the request scope rather than constructor-injected: this is a + // shared helper used by the inline OnSigningIn delegate (wired by the singleton + // IPostConfigureOptions), the DI-less DeviceBoundSessionCookieEvents wrapper, and the public + // HttpContext extension, none of which hold these dependencies. It runs per-request, so the + // live HttpContext.RequestServices is the only source all callers share. (Both IOptionsMonitor<> + // and the challenge protector are singletons, so request-scope resolution is for sharing, not + // lifetime; the genuinely per-request input is the principal.) + var dbscOptions = httpContext.RequestServices + .GetRequiredService>() + .Get(dbscScheme); + var challengeProtector = httpContext.RequestServices + .GetRequiredService(); + + var effectivePrincipal = principal ?? new ClaimsPrincipal(); + var challenge = challengeProtector.GenerateRegistrationChallenge(effectivePrincipal, dbscOptions.ChallengeMaxAge); + + // Advertise the registration endpoint relative to the application's path base so an app mounted + // under a non-root path base (e.g. "/foo") tells the browser to POST to "/foo/.well-known/dbsc/..." + // rather than the origin root. + var registrationPath = httpContext.Request.PathBase.Add(dbscOptions.RegistrationPath); + var headerValue = $"{DeviceBoundSessionConstants.AdvertisedAlgorithms};path=\"{registrationPath.Value}\";challenge=\"{challenge}\""; + httpContext.Response.Headers.Append(DeviceBoundSessionConstants.Headers.Registration, headerValue); + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRule.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRule.cs new file mode 100644 index 000000000000..f39a595f0f0a --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionScopeRule.cs @@ -0,0 +1,28 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Represents a scope rule for DBSC session configuration. +/// +[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +public class DeviceBoundSessionScopeRule +{ + /// + /// Gets or sets the type of scope rule ("include" or "exclude"). + /// + public string Type { get; set; } = "include"; + + /// + /// Gets or sets the domain pattern for this scope rule. + /// + public string Domain { get; set; } = "*"; + + /// + /// Gets or sets the path for this scope rule. + /// + public string Path { get; set; } = "/"; +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs new file mode 100644 index 000000000000..070ccee96d19 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/DeviceBoundSessionSourceSchemes.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +internal sealed class DeviceBoundSessionSourceSchemes +{ + /// Maps source cookie scheme → DBSC handler scheme. + public IDictionary Schemes { get; } = new Dictionary(StringComparer.Ordinal); + + /// Maps refresh cookie scheme → source cookie scheme. + public IDictionary RefreshSchemes { get; } = new Dictionary(StringComparer.Ordinal); + + /// Maps session cookie scheme → source cookie scheme. + public IDictionary SessionSchemes { get; } = new Dictionary(StringComparer.Ordinal); + + /// Maps source cookie scheme → DBSC policy scheme. + public IDictionary PolicySchemes { get; } = new Dictionary(StringComparer.Ordinal); +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/LoggingExtensions.cs b/src/Security/Authentication/DeviceBoundSessions/src/LoggingExtensions.cs new file mode 100644 index 000000000000..f3017c69e4f7 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/LoggingExtensions.cs @@ -0,0 +1,72 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +namespace Microsoft.Extensions.Logging; + +/// +/// Source-generated log messages for Device Bound Session Credentials. Messages intentionally avoid +/// logging sensitive material (challenge values, claim values); the opaque session identifier is used +/// only as a correlation key. +/// +internal static partial class DeviceBoundSessionsLoggingExtensions +{ + // Refresh challenge validation (DeviceBoundSessionChallengeProtector). + + [LoggerMessage(1, LogLevel.Debug, "DBSC refresh challenge for session {SessionId} could not be decrypted; it is expired, tampered, or was minted for a different flow or version.", EventName = "RefreshChallengeUndecryptable")] + public static partial void RefreshChallengeUndecryptable(this ILogger logger, string sessionId); + + [LoggerMessage(2, LogLevel.Debug, "DBSC refresh challenge for session {SessionId} decrypted but was not in the expected shape.", EventName = "RefreshChallengeMalformed")] + public static partial void RefreshChallengeMalformed(this ILogger logger, string sessionId); + + [LoggerMessage(3, LogLevel.Warning, "DBSC refresh challenge for session {SessionId} is bound to a different principal than the request; this should not happen for a browser-originated request and may indicate a client or server bug.", EventName = "RefreshChallengePrincipalMismatch")] + public static partial void RefreshChallengePrincipalMismatch(this ILogger logger, string sessionId); + + [LoggerMessage(4, LogLevel.Warning, "DBSC refresh challenge is bound to a different session than the request ({SessionId}); this should not happen for a browser-originated request and may indicate a client or server bug.", EventName = "RefreshChallengeSessionMismatch")] + public static partial void RefreshChallengeSessionMismatch(this ILogger logger, string sessionId); + + // Registration endpoint (DeviceBoundSessionHandler). + + [LoggerMessage(6, LogLevel.Warning, "DBSC registration: no valid authentication from the source scheme.", EventName = "RegistrationNoSourceAuthentication")] + public static partial void RegistrationNoSourceAuthentication(this ILogger logger); + + [LoggerMessage(7, LogLevel.Debug, "DBSC registration: the proof did not contain a challenge (jti).", EventName = "RegistrationChallengeMissing")] + public static partial void RegistrationChallengeMissing(this ILogger logger); + + [LoggerMessage(10, LogLevel.Debug, "DBSC registration challenge could not be decrypted; it is expired, tampered, or was minted for a different flow or version.", EventName = "RegistrationChallengeUndecryptable")] + public static partial void RegistrationChallengeUndecryptable(this ILogger logger); + + [LoggerMessage(11, LogLevel.Debug, "DBSC registration challenge decrypted but was not in the expected shape.", EventName = "RegistrationChallengeMalformed")] + public static partial void RegistrationChallengeMalformed(this ILogger logger); + + [LoggerMessage(12, LogLevel.Warning, "DBSC registration challenge is bound to a different principal than the request; this should not happen for a browser-originated request and may indicate a client or server bug.", EventName = "RegistrationChallengePrincipalMismatch")] + public static partial void RegistrationChallengePrincipalMismatch(this ILogger logger); + + // Refresh endpoint (DeviceBoundSessionHandler). + + [LoggerMessage(8, LogLevel.Warning, "DBSC refresh: no valid refresh cookie for session {SessionId}.", EventName = "RefreshNoCookie")] + public static partial void RefreshNoCookie(this ILogger logger, string sessionId); + + // Proof JWT validation (DeviceBoundSessionJwtValidator). All are routine rejections of a bad or + // unsupported proof and are logged at Debug. + + [LoggerMessage(13, LogLevel.Debug, "DBSC proof rejected: the token is not a well-formed JWT.", EventName = "ProofMalformed")] + public static partial void ProofMalformed(this ILogger logger); + + [LoggerMessage(14, LogLevel.Debug, "DBSC proof rejected: the 'typ' header is not 'dbsc+jwt'.", EventName = "ProofWrongType")] + public static partial void ProofWrongType(this ILogger logger); + + [LoggerMessage(15, LogLevel.Debug, "DBSC proof rejected: the 'alg' header is missing.", EventName = "ProofMissingAlgorithm")] + public static partial void ProofMissingAlgorithm(this ILogger logger); + + [LoggerMessage(16, LogLevel.Debug, "DBSC proof rejected: no 'jwk' header and no stored key to validate against.", EventName = "ProofMissingKey")] + public static partial void ProofMissingKey(this ILogger logger); + + [LoggerMessage(17, LogLevel.Debug, "DBSC proof rejected: could not build a signing key (malformed JWK, unsupported algorithm, or key-type mismatch).", EventName = "ProofUnsupportedKey")] + public static partial void ProofUnsupportedKey(this ILogger logger); + + [LoggerMessage(18, LogLevel.Debug, "DBSC proof rejected: signature validation failed.", EventName = "ProofSignatureInvalid")] + public static partial void ProofSignatureInvalid(this ILogger logger); + + [LoggerMessage(19, LogLevel.Debug, "DBSC proof rejected: the challenge (jti) did not match the expected value.", EventName = "ProofChallengeMismatch")] + public static partial void ProofChallengeMismatch(this ILogger logger); +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj new file mode 100644 index 000000000000..69ae6cff3ad0 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj @@ -0,0 +1,33 @@ + + + + ASP.NET Core middleware that enables Device Bound Session Credentials (DBSC) over an inner authentication scheme. + $(DefaultNetCoreTargetFramework) + + $(ExperimentalVersionPrefix) + true + aspnetcore;authentication;dbsc;security + true + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionAuthenticationOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionAuthenticationOptions.cs new file mode 100644 index 000000000000..cf729ab1be78 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionAuthenticationOptions.cs @@ -0,0 +1,40 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// If an application's default authenticate scheme resolves to a DBSC source scheme, redirects it to +/// the corresponding DBSC policy scheme. This keeps the user authenticated after registration swaps the +/// long-lived source cookie for the short-lived session cookie: the source cookie the app defaulted to +/// no longer exists, but the policy scheme reads the session cookie (falling back to the source cookie +/// before registration). Defaults the app pointed at any other scheme are left untouched, and the +/// sign-in/sign-out defaults are never changed. +/// +internal sealed class PostConfigureDeviceBoundSessionAuthenticationOptions : IPostConfigureOptions +{ + private readonly IOptions _sourceSchemes; + + public PostConfigureDeviceBoundSessionAuthenticationOptions(IOptions sourceSchemes) + { + _sourceSchemes = sourceSchemes; + } + + public void PostConfigure(string? name, AuthenticationOptions options) + { + // The effective authenticate scheme is DefaultAuthenticateScheme, falling back to DefaultScheme. + var effectiveAuthenticateScheme = options.DefaultAuthenticateScheme ?? options.DefaultScheme; + if (effectiveAuthenticateScheme is null) + { + return; + } + + // Only upgrade when the app's default authenticate scheme is a scheme we wrapped with DBSC. + if (_sourceSchemes.Value.PolicySchemes.TryGetValue(effectiveAuthenticateScheme, out var policyScheme)) + { + options.DefaultAuthenticateScheme = policyScheme; + } + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionCookieOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionCookieOptions.cs new file mode 100644 index 000000000000..f9c9c1c23d5f --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionCookieOptions.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Post-configures each cookie scheme that is a DBSC source scheme so that the +/// Secure-Session-Registration header is emitted during sign-in. For delegate-based events it +/// chains ; for EventsType scenarios it +/// wraps the events with . +/// +internal sealed class PostConfigureDeviceBoundSessionCookieOptions : IPostConfigureOptions +{ + private readonly IOptions _sourceSchemes; + + public PostConfigureDeviceBoundSessionCookieOptions(IOptions sourceSchemes) + { + _sourceSchemes = sourceSchemes; + } + + public void PostConfigure(string? name, CookieAuthenticationOptions options) + { + ArgumentNullException.ThrowIfNull(name); + + if (!_sourceSchemes.Value.Schemes.TryGetValue(name, out var dbscScheme)) + { + return; + } + + if (options.EventsType is null) + { + var priorSigningIn = options.Events.OnSigningIn; + options.Events.OnSigningIn = async context => + { + DeviceBoundSessionRegistrationHeader.Emit(context.HttpContext, context.Principal, dbscScheme); + await priorSigningIn(context); + }; + + var priorSigningOut = options.Events.OnSigningOut; + options.Events.OnSigningOut = async context => + { + await DeviceBoundSessionCookieEvents.ClearDerivedCookiesAsync(context.HttpContext, dbscScheme); + await priorSigningOut(context); + }; + return; + } + + options.Events = new DeviceBoundSessionCookieEvents(dbscScheme, options.Events, options.EventsType); + options.EventsType = null; + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs new file mode 100644 index 000000000000..54475f4b1177 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/PostConfigureDeviceBoundSessionDerivedCookieOptions.cs @@ -0,0 +1,117 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Copies cookie settings from the source scheme to the refresh and session cookie schemes +/// so they match HttpOnly, Secure, SameSite, Domain, and lifetime settings. +/// +internal sealed class PostConfigureDeviceBoundSessionDerivedCookieOptions : IPostConfigureOptions +{ + private readonly IOptions _sourceSchemes; + private readonly IServiceProvider _services; + + public PostConfigureDeviceBoundSessionDerivedCookieOptions( + IOptions sourceSchemes, + IServiceProvider services) + { + _sourceSchemes = sourceSchemes; + _services = services; + } + + public void PostConfigure(string? name, CookieAuthenticationOptions options) + { + ArgumentNullException.ThrowIfNull(name); + + var schemes = _sourceSchemes.Value; + + if (schemes.RefreshSchemes.TryGetValue(name, out var refreshSourceScheme)) + { + CopyFromSource(refreshSourceScheme, options); + // Scope the refresh cookie to the DBSC endpoints' directory, applied relative to the request + // path base so an app mounted under a non-root path base still receives the cookie at its + // advertised refresh endpoint. Lifetime and sliding behavior are inherited from the source + // scheme so the refresh cookie ages exactly like the auth cookie it replaces. + options.Cookie = CreatePathBaseScopedCookie(options.Cookie, ResolveRefreshCookiePath(refreshSourceScheme)); + } + else if (schemes.SessionSchemes.TryGetValue(name, out var sessionSourceScheme)) + { + CopyFromSource(sessionSourceScheme, options); + // Session cookie uses the source path (default /) but shorter expiry. + // ExpireTimeSpan is set at sign-in time by the handler via AuthenticationProperties. + options.SlidingExpiration = false; + } + } + + private string ResolveRefreshCookiePath(string sourceScheme) + { + const string fallback = "/.well-known/dbsc"; + + // Map source cookie scheme -> DBSC handler scheme so we can read its configured RefreshPath. + if (!_sourceSchemes.Value.Schemes.TryGetValue(sourceScheme, out var dbscScheme)) + { + return fallback; + } + + var refreshPath = _services.GetRequiredService>() + .Get(dbscScheme).RefreshPath.Value; + if (string.IsNullOrEmpty(refreshPath)) + { + return fallback; + } + + // Scope the cookie to the refresh endpoint's directory so it is sent to that endpoint. + var lastSlash = refreshPath.LastIndexOf('/'); + return lastSlash > 0 ? refreshPath[..lastSlash] : "/"; + } + + private void CopyFromSource(string sourceScheme, CookieAuthenticationOptions target) + { + var source = _services.GetRequiredService>().Get(sourceScheme); + + // Copy cookie builder settings (preserving any name/path already set) + target.Cookie.HttpOnly = source.Cookie.HttpOnly; + target.Cookie.SecurePolicy = source.Cookie.SecurePolicy; + target.Cookie.SameSite = source.Cookie.SameSite; + target.Cookie.Domain = source.Cookie.Domain; + target.Cookie.IsEssential = source.Cookie.IsEssential; + target.ExpireTimeSpan = source.ExpireTimeSpan; + target.SlidingExpiration = source.SlidingExpiration; + } + + // Rebuilds the cookie as a path-base-aware builder so the cookie path becomes + // "{request path base}{additionalPath}" at emit time, instead of a fixed absolute path. + private static CookieBuilder CreatePathBaseScopedCookie(CookieBuilder source, string additionalPath) + => new PathBaseScopedCookieBuilder(additionalPath) + { + Name = source.Name, + HttpOnly = source.HttpOnly, + SecurePolicy = source.SecurePolicy, + SameSite = source.SameSite, + Domain = source.Domain, + IsEssential = source.IsEssential, + MaxAge = source.MaxAge, + Expiration = source.Expiration, + // Path is intentionally left unset so the builder derives (path base + additional path) per request. + }; + + private sealed class PathBaseScopedCookieBuilder : RequestPathBaseCookieBuilder + { + private readonly string _additionalPath; + + public PathBaseScopedCookieBuilder(string additionalPath) + { + _additionalPath = additionalPath; + } + + protected override string AdditionalPath => _additionalPath; + } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Shipped.txt b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Shipped.txt new file mode 100644 index 000000000000..8b137891791f --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Shipped.txt @@ -0,0 +1 @@ + diff --git a/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt new file mode 100644 index 000000000000..75eae16ca148 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt @@ -0,0 +1,81 @@ +#nullable enable +const Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults.AuthenticationScheme = "DeviceBoundSession" -> string! +const Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults.RefreshPath = "/.well-known/dbsc/refresh" -> string! +const Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults.RegistrationPath = "/.well-known/dbsc/registration" -> string! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHttpContextExtensions +static Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHttpContextExtensions.WriteDeviceBoundSessionRegistration(this Microsoft.AspNetCore.Http.HttpContext! context, string! sourceScheme) -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.DeviceBoundSessionHandler(Microsoft.Extensions.Options.IOptionsMonitor! options, Microsoft.Extensions.Logging.ILoggerFactory! logger, System.Text.Encodings.Web.UrlEncoder! encoder, Microsoft.AspNetCore.DataProtection.IDataProtectionProvider! dataProtectionProvider, Microsoft.Extensions.Options.IOptionsMonitor! cookieOptionsMonitor) -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.HandleRequestAsync() -> System.Threading.Tasks.Task! +override Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionHandler.HandleAuthenticateAsync() -> System.Threading.Tasks.Task! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.AllowedRefreshInitiators.get -> System.Collections.Generic.IList! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.ChallengeMaxAge.get -> System.TimeSpan +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.ChallengeMaxAge.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.DeviceBoundSessionOptions() -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.IncludeSite.get -> bool +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.IncludeSite.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RefreshPath.get -> Microsoft.AspNetCore.Http.PathString +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RefreshPath.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RefreshScheme.get -> string? +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RefreshScheme.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RegistrationPath.get -> Microsoft.AspNetCore.Http.PathString +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RegistrationPath.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RegistrationSourceScheme.get -> string? +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.RegistrationSourceScheme.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.ScopeSpecifications.get -> System.Collections.Generic.IList! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.SessionScheme.get -> string? +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.SessionScheme.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.ShortLivedCookieExpiration.get -> System.TimeSpan +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionOptions.ShortLivedCookieExpiration.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.DeviceBoundSessionScopeRule() -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Domain.get -> string! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Domain.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Path.get -> string! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Path.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Type.get -> string! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionScopeRule.Type.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Attributes.get -> string! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Attributes.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Name.get -> string! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Name.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.SessionCredential() -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionCredential.Type.get -> string! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.AllowedRefreshInitiators.get -> System.Collections.Generic.List? +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.AllowedRefreshInitiators.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Continue.get -> bool +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Continue.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Credentials.get -> System.Collections.Generic.List? +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Credentials.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.RefreshUrl.get -> string? +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.RefreshUrl.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Scope.get -> Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope? +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.Scope.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.SessionIdentifier.get -> string! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.SessionIdentifier.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionInstruction.SessionInstruction() -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.IncludeSite.get -> bool +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.IncludeSite.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.Origin.get -> string? +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.Origin.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.ScopeSpecification.get -> System.Collections.Generic.List? +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.ScopeSpecification.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScope.SessionScope() -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Domain.get -> string! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Domain.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Path.get -> string! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Path.set -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.SessionScopeRule() -> void +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Type.get -> string! +Microsoft.AspNetCore.Authentication.DeviceBoundSessions.SessionScopeRule.Type.set -> void +Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions +static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! authenticationScheme, string! sourceScheme) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder! +static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! authenticationScheme, string! sourceScheme, System.Action! configureOptions) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder! +static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! sourceScheme) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder! +static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! sourceScheme, System.Action! configureOptions) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder! diff --git a/src/Security/Authentication/DeviceBoundSessions/src/SessionCredential.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionCredential.cs new file mode 100644 index 000000000000..52c410058750 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionCredential.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Represents a credential in the DBSC session instructions. Corresponds to the "JSON Session +/// Credential Format" defined in W3C Device Bound Session Credentials §9.9. +/// +[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +public sealed class SessionCredential +{ + /// + /// Gets the credential type (always "cookie"). + /// + [JsonPropertyName("type")] + public string Type { get; } = "cookie"; + + /// + /// Gets or sets the cookie name. + /// + [JsonPropertyName("name")] + public string Name { get; set; } = default!; + + /// + /// Gets or sets the cookie attributes string. + /// + [JsonPropertyName("attributes")] + public string Attributes { get; set; } = default!; +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs new file mode 100644 index 000000000000..6b6be401c88f --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionInstruction.cs @@ -0,0 +1,56 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Represents the DBSC JSON session instructions returned to the browser during registration +/// and (optionally) refresh. Corresponds to the "JSON Session Instruction Format" defined in +/// W3C Device Bound Session Credentials §9.6. +/// +[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +public sealed class SessionInstruction +{ + /// + /// Gets or sets the session identifier. + /// + [JsonPropertyName("session_identifier")] + public string SessionIdentifier { get; set; } = default!; + + /// + /// Gets or sets the refresh URL. + /// + [JsonPropertyName("refresh_url")] + public string? RefreshUrl { get; set; } + + /// + /// Gets or sets a value indicating whether the session should continue to apply. Registration and + /// refresh endpoints can set this to to terminate the session. Defaults to + /// . + /// + [JsonPropertyName("continue")] + public bool Continue { get; set; } = true; + + /// + /// Gets or sets the session scope. + /// + [JsonPropertyName("scope")] + public SessionScope? Scope { get; set; } + + /// + /// Gets or sets the session credentials. + /// + [JsonPropertyName("credentials")] + public List? Credentials { get; set; } + + /// + /// Gets or sets the list of out-of-scope hosts allowed to initiate DBSC refreshes. When + /// or empty the field is omitted and the browser uses its default (an empty + /// list). See W3C Device Bound Session Credentials §8.3. + /// + [JsonPropertyName("allowed_refresh_initiators")] + public List? AllowedRefreshInitiators { get; set; } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/SessionScope.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionScope.cs new file mode 100644 index 000000000000..c19adedaa05b --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionScope.cs @@ -0,0 +1,34 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Represents the scope of a DBSC session. Corresponds to the "JSON Session Scope Instruction +/// Format" defined in W3C Device Bound Session Credentials §9.7. +/// +[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +public sealed class SessionScope +{ + /// + /// Gets or sets the origin for the session scope. + /// + [JsonPropertyName("origin")] + public string? Origin { get; set; } + + /// + /// Gets or sets whether the session applies to the entire site. + /// + [JsonPropertyName("include_site")] + public bool IncludeSite { get; set; } + + /// + /// Gets or sets the scope specification rules. + /// + [JsonPropertyName("scope_specification")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public List? ScopeSpecification { get; set; } +} diff --git a/src/Security/Authentication/DeviceBoundSessions/src/SessionScopeRule.cs b/src/Security/Authentication/DeviceBoundSessions/src/SessionScopeRule.cs new file mode 100644 index 000000000000..918cbabaf131 --- /dev/null +++ b/src/Security/Authentication/DeviceBoundSessions/src/SessionScopeRule.cs @@ -0,0 +1,33 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Represents a scope specification rule in the DBSC session instructions. Corresponds to the +/// "JSON Session Scope Rule Format" defined in W3C Device Bound Session Credentials §9.8. +/// +[Experimental("ASP0030", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")] +public sealed class SessionScopeRule +{ + /// + /// Gets or sets the type ("include" or "exclude"). + /// + [JsonPropertyName("type")] + public string Type { get; set; } = default!; + + /// + /// Gets or sets the domain pattern. + /// + [JsonPropertyName("domain")] + public string Domain { get; set; } = default!; + + /// + /// Gets or sets the path. + /// + [JsonPropertyName("path")] + public string Path { get; set; } = default!; +} diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs b/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs new file mode 100644 index 000000000000..45676ec79d9d --- /dev/null +++ b/src/Security/Authentication/test/DeviceBoundSessions/DbscProofKey.cs @@ -0,0 +1,130 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Text; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.IdentityModel.JsonWebTokens; +using Microsoft.IdentityModel.Tokens; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +/// +/// Test helper that mints DBSC proof JWTs (typ=dbsc+jwt, jwk in header, jti in payload), +/// signed with an ephemeral ES256 or RS256 device key, mirroring what shipping Chrome produces. +/// +internal sealed class DbscProofKey +{ + private static readonly JsonWebTokenHandler s_handler = new(); + + private readonly SecurityKey _signingKey; + + private DbscProofKey(string algorithm, SecurityKey signingKey, string publicJwkJson) + { + Algorithm = algorithm; + _signingKey = signingKey; + PublicJwkJson = publicJwkJson; + } + + public string Algorithm { get; } + + /// The public key as a JWK JSON string, as the server stores it for refresh validation. + public string PublicJwkJson { get; } + + public static DbscProofKey CreateEs256() + { + var ecdsa = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var p = ecdsa.ExportParameters(includePrivateParameters: false); + var jwk = new Dictionary + { + ["kty"] = "EC", + ["crv"] = "P-256", + ["x"] = WebEncoders.Base64UrlEncode(p.Q.X!), + ["y"] = WebEncoders.Base64UrlEncode(p.Q.Y!), + }; + return new DbscProofKey("ES256", new ECDsaSecurityKey(ecdsa), System.Text.Json.JsonSerializer.Serialize(jwk)); + } + + public static DbscProofKey CreateRs256() + { + var rsa = RSA.Create(2048); + var p = rsa.ExportParameters(includePrivateParameters: false); + var jwk = new Dictionary + { + ["kty"] = "RSA", + ["n"] = WebEncoders.Base64UrlEncode(p.Modulus!), + ["e"] = WebEncoders.Base64UrlEncode(p.Exponent!), + }; + return new DbscProofKey("RS256", new RsaSecurityKey(rsa), System.Text.Json.JsonSerializer.Serialize(jwk)); + } + + /// Creates a signed DBSC proof JWT with the public key in the header (registration shape). + public string CreateProof(string jti, bool includeJwkHeader = true, string? authorization = null, DateTimeOffset? expires = null) + { + var headerClaims = new Dictionary { ["typ"] = "dbsc+jwt" }; + if (includeJwkHeader) + { + headerClaims["jwk"] = System.Text.Json.JsonSerializer.Deserialize(PublicJwkJson); + } + + var claims = new Dictionary { ["jti"] = jti }; + if (authorization is not null) + { + claims["authorization"] = authorization; + } + + var descriptor = new SecurityTokenDescriptor + { + Claims = claims, + SigningCredentials = new SigningCredentials(_signingKey, Algorithm), + AdditionalHeaderClaims = headerClaims, + }; + + if (expires is not null) + { + descriptor.Expires = expires.Value.UtcDateTime; + } + + return s_handler.CreateToken(descriptor); + } + + /// + /// Builds an unsigned (empty-signature) compact JWS with caller-controlled header fields, used to + /// exercise the validator's pre-signature gates (typ, alg, key selection) in isolation. + /// + public static string CreateUnsignedToken(string? typ, string? alg, string? jwkJson, string jti) + { + var header = new Dictionary(); + if (typ is not null) + { + header["typ"] = typ; + } + if (alg is not null) + { + header["alg"] = alg; + } + if (jwkJson is not null) + { + header["jwk"] = System.Text.Json.JsonSerializer.Deserialize(jwkJson); + } + + var payload = new Dictionary { ["jti"] = jti }; + + var headerSegment = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(header))); + var payloadSegment = WebEncoders.Base64UrlEncode(Encoding.UTF8.GetBytes(System.Text.Json.JsonSerializer.Serialize(payload))); + return $"{headerSegment}.{payloadSegment}."; + } + + /// Flips one byte of the signature segment to produce a tampered token. + public static string TamperSignature(string jwt) + { + var lastDot = jwt.LastIndexOf('.'); + var sig = jwt[(lastDot + 1)..]; + var flipped = sig[0] == 'A' ? 'B' : 'A'; + return string.Concat(jwt.AsSpan(0, lastDot + 1), flipped.ToString(), sig.AsSpan(1)); + } +} diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionAuthenticationOptionsTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionAuthenticationOptionsTests.cs new file mode 100644 index 000000000000..87152a536a04 --- /dev/null +++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionAuthenticationOptionsTests.cs @@ -0,0 +1,120 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +public class DeviceBoundSessionAuthenticationOptionsTests +{ + private const string SourceScheme = "Source"; + private const string PolicyScheme = "Source.Dbsc"; + + [Fact] + public void Upgrades_DefaultAuthenticateScheme_WhenItIsAWrappedSourceScheme() + { + var sut = CreateSut((SourceScheme, PolicyScheme)); + var options = new AuthenticationOptions { DefaultAuthenticateScheme = SourceScheme }; + + sut.PostConfigure(Options.DefaultName, options); + + Assert.Equal(PolicyScheme, options.DefaultAuthenticateScheme); + } + + [Fact] + public void Upgrades_EffectiveDefault_WhenOnlyDefaultSchemeIsWrappedSource() + { + var sut = CreateSut((SourceScheme, PolicyScheme)); + var options = new AuthenticationOptions { DefaultScheme = SourceScheme }; + + sut.PostConfigure(Options.DefaultName, options); + + // The authenticate default is redirected to the policy scheme... + Assert.Equal(PolicyScheme, options.DefaultAuthenticateScheme); + // ...but DefaultScheme (which sign-in/out/challenge fall back to) is left on the source scheme. + Assert.Equal(SourceScheme, options.DefaultScheme); + } + + [Fact] + public void ExplicitAuthenticateScheme_TakesPrecedence_OverDefaultScheme() + { + var sut = CreateSut((SourceScheme, PolicyScheme)); + var options = new AuthenticationOptions + { + DefaultScheme = SourceScheme, + DefaultAuthenticateScheme = "Other", + }; + + // The effective authenticate scheme is "Other", not the wrapped source, so nothing changes. + sut.PostConfigure(Options.DefaultName, options); + + Assert.Equal("Other", options.DefaultAuthenticateScheme); + } + + [Fact] + public void DoesNotChange_WhenDefaultIsUnrelatedScheme() + { + var sut = CreateSut((SourceScheme, PolicyScheme)); + var options = new AuthenticationOptions { DefaultAuthenticateScheme = "Other" }; + + sut.PostConfigure(Options.DefaultName, options); + + Assert.Equal("Other", options.DefaultAuthenticateScheme); + } + + [Fact] + public void DoesNotInventADefault_WhenNoneConfigured() + { + var sut = CreateSut((SourceScheme, PolicyScheme)); + var options = new AuthenticationOptions(); + + sut.PostConfigure(Options.DefaultName, options); + + Assert.Null(options.DefaultAuthenticateScheme); + Assert.Null(options.DefaultScheme); + } + + [Fact] + public void DoesNotChange_SignInOrSignOutDefaults() + { + var sut = CreateSut((SourceScheme, PolicyScheme)); + var options = new AuthenticationOptions + { + DefaultScheme = SourceScheme, + DefaultSignInScheme = SourceScheme, + DefaultSignOutScheme = SourceScheme, + }; + + sut.PostConfigure(Options.DefaultName, options); + + Assert.Equal(PolicyScheme, options.DefaultAuthenticateScheme); + Assert.Equal(SourceScheme, options.DefaultSignInScheme); + Assert.Equal(SourceScheme, options.DefaultSignOutScheme); + } + + [Fact] + public void Upgrades_ToMatchingPolicy_WithMultipleRegistrations() + { + var sut = CreateSut(("A", "A.Dbsc"), ("B", "B.Dbsc")); + var options = new AuthenticationOptions { DefaultAuthenticateScheme = "B" }; + + sut.PostConfigure(Options.DefaultName, options); + + Assert.Equal("B.Dbsc", options.DefaultAuthenticateScheme); + } + + private static PostConfigureDeviceBoundSessionAuthenticationOptions CreateSut( + params (string source, string policy)[] mappings) + { + var schemes = new DeviceBoundSessionSourceSchemes(); + foreach (var (source, policy) in mappings) + { + schemes.PolicySchemes[source] = policy; + } + + return new PostConfigureDeviceBoundSessionAuthenticationOptions(Options.Create(schemes)); + } +} diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionChallengeProtectorTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionChallengeProtectorTests.cs new file mode 100644 index 000000000000..c722384eb506 --- /dev/null +++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionChallengeProtectorTests.cs @@ -0,0 +1,165 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Security.Claims; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +public class DeviceBoundSessionChallengeProtectorTests +{ + private static readonly TimeSpan s_lifetime = TimeSpan.FromMinutes(5); + + private static DeviceBoundSessionChallengeProtector CreateProtector() + => new(new EphemeralDataProtectionProvider(), NullLogger.Instance); + + private static ClaimsPrincipal Principal(params (string Type, string Value)[] claims) + { + var identity = new ClaimsIdentity(authenticationType: "test"); + foreach (var (type, value) in claims) + { + identity.AddClaim(new Claim(type, value)); + } + return new ClaimsPrincipal(identity); + } + + [Fact] + public void RegistrationChallenge_RoundTrips_ForSamePrincipal() + { + var protector = CreateProtector(); + var principal = Principal(("sub", "alice")); + + var challenge = protector.GenerateRegistrationChallenge(principal, s_lifetime); + + Assert.True(protector.TryValidateRegistrationChallenge(challenge, principal)); + } + + [Fact] + public void RegistrationChallenge_Fails_ForDifferentPrincipal() + { + var protector = CreateProtector(); + var challenge = protector.GenerateRegistrationChallenge(Principal(("sub", "alice")), s_lifetime); + + Assert.False(protector.TryValidateRegistrationChallenge(challenge, Principal(("sub", "bob")))); + } + + [Fact] + public void RegistrationChallenge_Fails_WhenTampered() + { + var protector = CreateProtector(); + var principal = Principal(("sub", "alice")); + var challenge = protector.GenerateRegistrationChallenge(principal, s_lifetime); + + var tampered = challenge[0] == 'A' ? "B" + challenge[1..] : "A" + challenge[1..]; + + Assert.False(protector.TryValidateRegistrationChallenge(tampered, principal)); + } + + [Fact] + public void RefreshChallenge_RoundTrips_ForSamePrincipalAndSession() + { + var protector = CreateProtector(); + var principal = Principal(("sub", "alice")); + + var challenge = protector.GenerateRefreshChallenge(principal, "session-1", s_lifetime); + + Assert.True(protector.TryValidateRefreshChallenge(challenge, principal, "session-1")); + } + + [Fact] + public void RefreshChallenge_Fails_ForWrongSessionId() + { + var protector = CreateProtector(); + var principal = Principal(("sub", "alice")); + var challenge = protector.GenerateRefreshChallenge(principal, "session-1", s_lifetime); + + Assert.False(protector.TryValidateRefreshChallenge(challenge, principal, "session-2")); + } + + [Fact] + public void RefreshChallenge_Fails_ForDifferentPrincipal() + { + var protector = CreateProtector(); + var challenge = protector.GenerateRefreshChallenge(Principal(("sub", "alice")), "session-1", s_lifetime); + + Assert.False(protector.TryValidateRefreshChallenge(challenge, Principal(("sub", "bob")), "session-1")); + } + + [Fact] + public void RefreshChallenge_Fails_WhenExpired() + { + var protector = CreateProtector(); + var principal = Principal(("sub", "alice")); + // Negative lifetime → already expired when validated; the data protector can't unprotect it. + var challenge = protector.GenerateRefreshChallenge(principal, "session-1", TimeSpan.FromSeconds(-1)); + + Assert.False(protector.TryValidateRefreshChallenge(challenge, principal, "session-1")); + } + + [Fact] + public void RefreshChallenge_Fails_WhenTampered() + { + var protector = CreateProtector(); + var principal = Principal(("sub", "alice")); + var challenge = protector.GenerateRefreshChallenge(principal, "session-1", s_lifetime); + var tampered = challenge[0] == 'A' ? "B" + challenge[1..] : "A" + challenge[1..]; + + Assert.False(protector.TryValidateRefreshChallenge(tampered, principal, "session-1")); + } + + [Fact] + public void RefreshChallenge_Fails_ForRegistrationChallenge() + { + var protector = CreateProtector(); + var principal = Principal(("sub", "alice")); + // A registration challenge is encrypted under a different data-protection purpose, so the + // refresh validator cannot even decrypt it. Cross-type confusion is impossible. + var registrationChallenge = protector.GenerateRegistrationChallenge(principal, s_lifetime); + + Assert.False(protector.TryValidateRefreshChallenge(registrationChallenge, principal, "session-1")); + } + + [Fact] + public void TryValidateRegistrationChallenge_Fails_ForRefreshChallenge() + { + var protector = CreateProtector(); + var principal = Principal(("sub", "alice")); + // The reverse direction: a refresh challenge is encrypted under the refresh purpose, so + // registration validation cannot decrypt it and rejects it (it would otherwise read the + // shared claim-uid prefix and silently accept it). + var refreshChallenge = protector.GenerateRefreshChallenge(principal, "session-1", s_lifetime); + + Assert.False(protector.TryValidateRegistrationChallenge(refreshChallenge, principal)); + } + + [Fact] + public void ComputeClaimUid_PrefersSub_OverNameIdentifier() + { + var withSub = Principal(("sub", "the-id"), (ClaimTypes.NameIdentifier, "other")); + var onlySub = Principal(("sub", "the-id")); + + Assert.Equal(DeviceBoundSessionChallengeProtector.ComputeClaimUid(onlySub), + DeviceBoundSessionChallengeProtector.ComputeClaimUid(withSub)); + } + + [Fact] + public void ComputeClaimUid_UsesNameIdentifier_WhenNoSub() + { + var withNameId = Principal((ClaimTypes.NameIdentifier, "the-id"), (ClaimTypes.Upn, "other")); + var onlyNameId = Principal((ClaimTypes.NameIdentifier, "the-id")); + + Assert.Equal(DeviceBoundSessionChallengeProtector.ComputeClaimUid(onlyNameId), + DeviceBoundSessionChallengeProtector.ComputeClaimUid(withNameId)); + } + + [Fact] + public void ComputeClaimUid_UsesUpn_WhenNoSubOrNameIdentifier() + { + var onlyUpn = Principal((ClaimTypes.Upn, "alice@contoso.com")); + + Assert.False(string.IsNullOrEmpty(DeviceBoundSessionChallengeProtector.ComputeClaimUid(onlyUpn))); + } +} diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs new file mode 100644 index 000000000000..623581327a97 --- /dev/null +++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionCookieProtectionTests.cs @@ -0,0 +1,173 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable + +using System; +using System.Security.Claims; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +public class DeviceBoundSessionCookieProtectionTests +{ + private const string SourceScheme = "Source"; + private const string RefreshScheme = "Source.Dbsc.Refresh"; + private const string SessionScheme = "Source.Dbsc.Session"; + + [Fact] + public void PostConfigure_PreservesExistingTicketDataFormat_ForRefreshScheme() + { + var sut = CreateDerivedPostConfigure(refreshScheme: RefreshScheme); + var sentinel = CreateSentinelFormat(); + var options = new CookieAuthenticationOptions { TicketDataFormat = sentinel }; + // The DBSC extension assigns the cookie name (and refresh path) before post-configure runs. + options.Cookie.Name = ".AspNetCore." + RefreshScheme; + + sut.PostConfigure(RefreshScheme, options); + + // Protection is left untouched... + Assert.Same(sentinel, options.TicketDataFormat); + // ...but the post-configure still ran (copied source lifetime, applied refresh path scope, kept the name). + Assert.Equal(TimeSpan.FromHours(3), options.ExpireTimeSpan); + Assert.Equal("/.well-known/dbsc", options.Cookie.Build(new DefaultHttpContext()).Path); + Assert.Equal(".AspNetCore." + RefreshScheme, options.Cookie.Name); + } + + [Fact] + public void PostConfigure_PreservesExistingTicketDataFormat_ForSessionScheme() + { + var sut = CreateDerivedPostConfigure(sessionScheme: SessionScheme); + var sentinel = CreateSentinelFormat(); + var options = new CookieAuthenticationOptions { TicketDataFormat = sentinel }; + options.Cookie.Name = ".AspNetCore." + SessionScheme; + + sut.PostConfigure(SessionScheme, options); + + Assert.Same(sentinel, options.TicketDataFormat); + Assert.Equal(TimeSpan.FromHours(3), options.ExpireTimeSpan); + Assert.Equal(".AspNetCore." + SessionScheme, options.Cookie.Name); + } + + [Fact] + public void DerivedCookieSchemes_RemainDataProtected_AndSchemeKeyed() + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddAuthentication() + .AddCookie(SourceScheme) + .AddDeviceBoundSession(SourceScheme); + using var provider = services.BuildServiceProvider(); + + var monitor = provider.GetRequiredService>(); + var source = monitor.Get(SourceScheme); + var refresh = monitor.Get(RefreshScheme); + var session = monitor.Get(SessionScheme); + + // All three schemes end up with a data-protecting ticket format. + Assert.NotNull(source.TicketDataFormat); + Assert.NotNull(refresh.TicketDataFormat); + Assert.NotNull(session.TicketDataFormat); + + var ticket = new AuthenticationTicket(new ClaimsPrincipal(new ClaimsIdentity("test")), RefreshScheme); + var protectedByRefresh = refresh.TicketDataFormat.Protect(ticket); + + // The refresh format round-trips its own payload (real protection, not a no-op). + Assert.True(CanUnprotect(refresh.TicketDataFormat, protectedByRefresh)); + + // Protection is scheme-keyed: neither the session nor the source format can read the + // refresh scheme's payload, proving each derived scheme keeps its own protector and the + // source scheme's protection is independent. + Assert.False(CanUnprotect(session.TicketDataFormat, protectedByRefresh)); + Assert.False(CanUnprotect(source.TicketDataFormat, protectedByRefresh)); + } + + [Fact] + public void PostConfigure_RefreshScheme_InheritsSlidingExpiration_FromSource() + { + var sut = CreateDerivedPostConfigure(refreshScheme: RefreshScheme, sourceSlidingExpiration: true); + var options = new CookieAuthenticationOptions(); + options.Cookie.Name = ".AspNetCore." + RefreshScheme; + + sut.PostConfigure(RefreshScheme, options); + + // The refresh cookie ages like the auth cookie it replaces: sliding inherited, lifetime copied. + Assert.True(options.SlidingExpiration); + Assert.Equal(TimeSpan.FromHours(3), options.ExpireTimeSpan); + } + + [Fact] + public void PostConfigure_RefreshScheme_RespectsDisabledSlidingExpiration_FromSource() + { + var sut = CreateDerivedPostConfigure(refreshScheme: RefreshScheme, sourceSlidingExpiration: false); + var options = new CookieAuthenticationOptions(); + options.Cookie.Name = ".AspNetCore." + RefreshScheme; + + sut.PostConfigure(RefreshScheme, options); + + // When the source app opts out of sliding, the refresh cookie matches it. + Assert.False(options.SlidingExpiration); + } + + [Fact] + public void PostConfigure_SessionScheme_DisablesSlidingExpiration_EvenWhenSourceSlides() + { + var sut = CreateDerivedPostConfigure(sessionScheme: SessionScheme, sourceSlidingExpiration: true); + var options = new CookieAuthenticationOptions(); + options.Cookie.Name = ".AspNetCore." + SessionScheme; + + sut.PostConfigure(SessionScheme, options); + + // The short-lived session cookie is deliberately non-sliding regardless of the source. + Assert.False(options.SlidingExpiration); + } + + private static PostConfigureDeviceBoundSessionDerivedCookieOptions CreateDerivedPostConfigure( + string? refreshScheme = null, + string? sessionScheme = null, + bool sourceSlidingExpiration = true) + { + var sourceSchemes = new DeviceBoundSessionSourceSchemes(); + if (refreshScheme is not null) + { + sourceSchemes.RefreshSchemes[refreshScheme] = SourceScheme; + } + if (sessionScheme is not null) + { + sourceSchemes.SessionSchemes[sessionScheme] = SourceScheme; + } + + var services = new ServiceCollection(); + services.AddOptions(); + services.Configure(SourceScheme, o => + { + o.Cookie.HttpOnly = true; + o.ExpireTimeSpan = TimeSpan.FromHours(3); + o.SlidingExpiration = sourceSlidingExpiration; + }); + + return new PostConfigureDeviceBoundSessionDerivedCookieOptions( + Options.Create(sourceSchemes), + services.BuildServiceProvider()); + } + + private static TicketDataFormat CreateSentinelFormat() + => new(new EphemeralDataProtectionProvider().CreateProtector("sentinel")); + + private static bool CanUnprotect(ISecureDataFormat format, string value) + { + try + { + return format.Unprotect(value) is not null; + } + catch + { + return false; + } + } +} diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionInstructionTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionInstructionTests.cs new file mode 100644 index 000000000000..5463d64bbd24 --- /dev/null +++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionInstructionTests.cs @@ -0,0 +1,204 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable +#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +using System.Net.Http; +using System.Security.Claims; +using System.Text.Json; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +public class DeviceBoundSessionInstructionTests +{ + private const string SourceScheme = "Source"; + private const string SourceCookieName = ".AspNetCore.Source"; + private const string RegistrationPath = "/.well-known/dbsc/registration"; + + [Fact] + public void Continue_DefaultsToTrue_AndIsAlwaysSerialized() + { + var json = Serialize(new SessionInstruction { SessionIdentifier = "id" }); + + Assert.Contains("\"continue\":true", json); + } + + [Fact] + public void AllowedRefreshInitiators_AreSerialized_WhenSet() + { + var json = Serialize(new SessionInstruction + { + SessionIdentifier = "id", + AllowedRefreshInitiators = ["example.com", "*.example.com"], + }); + + Assert.Contains("\"allowed_refresh_initiators\":[\"example.com\",\"*.example.com\"]", json); + } + + [Fact] + public void AllowedRefreshInitiators_AreOmitted_WhenNull() + { + var json = Serialize(new SessionInstruction { SessionIdentifier = "id" }); + + Assert.DoesNotContain("allowed_refresh_initiators", json); + } + + [Fact] + public async Task Registration_FlowsAllowedRefreshInitiators_FromOptions() + { + using var host = await CreateHostAsync(o => + { + o.AllowedRefreshInitiators.Add("example.com"); + o.AllowedRefreshInitiators.Add("*.example.com"); + }); + var client = host.GetTestServer().CreateClient(); + + var body = await SignInRegisterAndReadBodyAsync(client); + + // Setting the option must actually flow into the session instruction JSON. + Assert.Contains("\"allowed_refresh_initiators\":[\"example.com\",\"*.example.com\"]", body); + Assert.Contains("\"continue\":true", body); + } + + [Fact] + public async Task Registration_OmitsAllowedRefreshInitiators_WhenNotConfigured() + { + using var host = await CreateHostAsync(); + var client = host.GetTestServer().CreateClient(); + + var body = await SignInRegisterAndReadBodyAsync(client); + + Assert.DoesNotContain("allowed_refresh_initiators", body); + } + + [Fact] + public async Task Registration_AdvertisesRegistrationPathAndRefreshUrl_WithPathBase() + { + using var host = await CreateHostAsync(pathBase: "/foo"); + var client = host.GetTestServer().CreateClient(); + + // The registration header advertised on sign-in must include the path base. + var signIn = await client.GetAsync("/foo/signin"); + signIn.EnsureSuccessStatusCode(); + var registrationHeader = Assert.Single( + signIn.Headers.GetValues(DeviceBoundSessionConstants.Headers.Registration)); + Assert.Contains("path=\"/foo/.well-known/dbsc/registration\"", registrationHeader); + + // Completing registration returns a refresh_url that also includes the path base. + var sourceCookie = Assert.Single(signIn.Headers.GetValues("Set-Cookie"), + v => v.StartsWith(SourceCookieName + "=", StringComparison.Ordinal)); + var challenge = ParseChallenge(signIn); + var proof = DbscProofKey.CreateEs256().CreateProof(challenge); + var register = new HttpRequestMessage(HttpMethod.Post, "/foo" + RegistrationPath); + register.Headers.TryAddWithoutValidation("Cookie", CookiePair(sourceCookie)); + register.Headers.TryAddWithoutValidation(DeviceBoundSessionConstants.Headers.Proof, proof); + var response = await client.SendAsync(register); + response.EnsureSuccessStatusCode(); + var body = await response.Content.ReadAsStringAsync(); + + Assert.Contains("\"refresh_url\":\"/foo/.well-known/dbsc/refresh\"", body); + + // The refresh cookie is path-scoped under the path base so the browser sends it to the refresh endpoint. + var refreshSetCookie = Assert.Single(response.Headers.GetValues("Set-Cookie"), + v => v.StartsWith(".AspNetCore.Source.Dbsc.Refresh=", StringComparison.Ordinal)); + Assert.Contains("path=/foo/.well-known/dbsc", refreshSetCookie); + + // The session cookie and the advertised credential attributes both carry the path base. + var sessionSetCookie = Assert.Single(response.Headers.GetValues("Set-Cookie"), + v => v.StartsWith(".AspNetCore.Source.Dbsc.Session=", StringComparison.Ordinal)); + Assert.Contains("path=/foo", sessionSetCookie); + Assert.Contains("Path=/foo\"", body); + } + + private static string Serialize(SessionInstruction instruction) + => JsonSerializer.Serialize(instruction, DeviceBoundSessionJsonContext.Default.SessionInstruction); + + private static async Task SignInRegisterAndReadBodyAsync(HttpClient client) + { + var signIn = await client.GetAsync("/signin"); + signIn.EnsureSuccessStatusCode(); + + var sourceCookie = Assert.Single(signIn.Headers.GetValues("Set-Cookie"), + v => v.StartsWith(SourceCookieName + "=", StringComparison.Ordinal)); + var challenge = ParseChallenge(signIn); + + var proof = DbscProofKey.CreateEs256().CreateProof(challenge); + var register = new HttpRequestMessage(HttpMethod.Post, RegistrationPath); + register.Headers.TryAddWithoutValidation("Cookie", CookiePair(sourceCookie)); + register.Headers.TryAddWithoutValidation(DeviceBoundSessionConstants.Headers.Proof, proof); + var response = await client.SendAsync(register); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsStringAsync(); + } + + private static async Task CreateHostAsync(Action? configureDbsc = null, string? pathBase = null) + { + var host = new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddDataProtection(); + var builder = services.AddAuthentication(SourceScheme) + .AddCookie(SourceScheme, o => o.Cookie.Name = SourceCookieName); + if (configureDbsc is null) + { + builder.AddDeviceBoundSession(SourceScheme); + } + else + { + builder.AddDeviceBoundSession(SourceScheme, configureDbsc); + } + }) + .Configure(app => + { + if (pathBase is not null) + { + app.UsePathBase(pathBase); + } + + app.UseRouting(); + app.UseAuthentication(); + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/signin", async context => + { + var identity = new ClaimsIdentity(SourceScheme); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "alice")); + await context.SignInAsync(SourceScheme, new ClaimsPrincipal(identity)); + }); + }); + }); + }) + .Build(); + + await host.StartAsync(); + return host; + } + + private static string CookiePair(string setCookie) + { + var semicolon = setCookie.IndexOf(';'); + return semicolon < 0 ? setCookie : setCookie[..semicolon]; + } + + private static string ParseChallenge(HttpResponseMessage response) + { + var header = Assert.Single(response.Headers.GetValues(DeviceBoundSessionConstants.Headers.Registration)); + var match = Regex.Match(header, "challenge=\"([^\"]+)\""); + Assert.True(match.Success, $"No challenge found in registration header: {header}"); + return match.Groups[1].Value; + } +} diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionJwtValidatorTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionJwtValidatorTests.cs new file mode 100644 index 000000000000..640053abd0be --- /dev/null +++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionJwtValidatorTests.cs @@ -0,0 +1,202 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +public class DeviceBoundSessionJwtValidatorTests +{ + private static readonly DeviceBoundSessionJwtValidator Validator = new(NullLogger.Instance); + + [Fact] + public async Task ValidateAsync_ValidEs256Proof_WithJwkHeader_Succeeds() + { + var key = DbscProofKey.CreateEs256(); + var proof = key.CreateProof("challenge-1"); + + var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null); + + Assert.NotNull(result); + Assert.Equal("ES256", result.Algorithm); + Assert.Equal("challenge-1", result.Challenge); + } + + [Fact] + public async Task ValidateAsync_ValidRs256Proof_WithJwkHeader_Succeeds() + { + var key = DbscProofKey.CreateRs256(); + var proof = key.CreateProof("challenge-2"); + + var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null); + + Assert.NotNull(result); + Assert.Equal("RS256", result.Algorithm); + Assert.Equal("challenge-2", result.Challenge); + } + + [Fact] + public async Task ValidateAsync_RefreshShape_UsesProvidedStoredKey_WhenJwkHeaderOmitted() + { + var key = DbscProofKey.CreateEs256(); + var proof = key.CreateProof("challenge-3", includeJwkHeader: false); + + var result = await Validator.ValidateAsync(proof, key.PublicJwkJson, expectedChallenge: null); + + Assert.NotNull(result); + Assert.Equal("challenge-3", result.Challenge); + } + + [Fact] + public async Task ValidateAsync_TamperedSignature_ReturnsNull() + { + var key = DbscProofKey.CreateEs256(); + var proof = DbscProofKey.TamperSignature(key.CreateProof("challenge-4")); + + var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null); + + Assert.Null(result); + } + + [Fact] + public async Task ValidateAsync_WrongStoredKey_ReturnsNull() + { + var signingKey = DbscProofKey.CreateEs256(); + var otherKey = DbscProofKey.CreateEs256(); + var proof = signingKey.CreateProof("challenge-5", includeJwkHeader: false); + + var result = await Validator.ValidateAsync(proof, otherKey.PublicJwkJson, expectedChallenge: null); + + Assert.Null(result); + } + + [Fact] + public async Task ValidateAsync_MissingJwkHeaderAndNoStoredKey_ReturnsNull() + { + var key = DbscProofKey.CreateEs256(); + var proof = key.CreateProof("challenge-6", includeJwkHeader: false); + + var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null); + + Assert.Null(result); + } + + [Fact] + public async Task ValidateAsync_MalformedToken_ReturnsNull() + { + var result = await Validator.ValidateAsync("not-a-jwt", publicKeyJwk: null, expectedChallenge: null); + + Assert.Null(result); + } + + [Fact] + public async Task ValidateAsync_ChallengeMismatch_WhenExpectedChallengeProvided_ReturnsNull() + { + var key = DbscProofKey.CreateEs256(); + var proof = key.CreateProof("actual-challenge"); + + var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: "different-challenge"); + + Assert.Null(result); + } + + [Fact] + public async Task ValidateAsync_WrongTyp_ReturnsNull() + { + var key = DbscProofKey.CreateEs256(); + var token = DbscProofKey.CreateUnsignedToken(typ: "JWT", alg: "ES256", jwkJson: key.PublicJwkJson, jti: "c"); + + var result = await Validator.ValidateAsync(token, publicKeyJwk: null, expectedChallenge: null); + + Assert.Null(result); + } + + [Fact] + public async Task ValidateAsync_MissingAlg_ReturnsNull() + { + var key = DbscProofKey.CreateEs256(); + var token = DbscProofKey.CreateUnsignedToken(typ: "dbsc+jwt", alg: null, jwkJson: key.PublicJwkJson, jti: "c"); + + var result = await Validator.ValidateAsync(token, publicKeyJwk: null, expectedChallenge: null); + + Assert.Null(result); + } + + [Theory] + [InlineData("none")] + [InlineData("HS256")] + [InlineData("ES384")] + [InlineData("RS384")] + public async Task ValidateAsync_UnsupportedAlg_ReturnsNull(string alg) + { + var key = DbscProofKey.CreateEs256(); + var token = DbscProofKey.CreateUnsignedToken(typ: "dbsc+jwt", alg: alg, jwkJson: key.PublicJwkJson, jti: "c"); + + var result = await Validator.ValidateAsync(token, publicKeyJwk: null, expectedChallenge: null); + + Assert.Null(result); + } + + [Fact] + public async Task ValidateAsync_AlgKeyTypeMismatch_ReturnsNull() + { + // Proof is signed ES256 but validated against an RSA stored key: the EC switch arm's + // kty guard rejects the mismatch before signature validation. + var ecKey = DbscProofKey.CreateEs256(); + var rsaKey = DbscProofKey.CreateRs256(); + var proof = ecKey.CreateProof("c", includeJwkHeader: false); + + var result = await Validator.ValidateAsync(proof, rsaKey.PublicJwkJson, expectedChallenge: null); + + Assert.Null(result); + } + + [Fact] + public async Task ValidateAsync_MalformedStoredJwk_ReturnsNull() + { + var key = DbscProofKey.CreateEs256(); + var proof = key.CreateProof("c", includeJwkHeader: false); + + var result = await Validator.ValidateAsync(proof, publicKeyJwk: "{ not valid json", expectedChallenge: null); + + Assert.Null(result); + } + + [Fact] + public async Task ValidateAsync_ExpectedChallengeMatches_Succeeds() + { + var key = DbscProofKey.CreateEs256(); + var proof = key.CreateProof("the-challenge"); + + var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: "the-challenge"); + + Assert.NotNull(result); + Assert.Equal("the-challenge", result.Challenge); + } + + [Fact] + public async Task ValidateAsync_ExtractsAuthorizationClaim() + { + var key = DbscProofKey.CreateEs256(); + var proof = key.CreateProof("c", authorization: "auth-token-value"); + + var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null); + + Assert.NotNull(result); + Assert.Equal("auth-token-value", result.Authorization); + } + + [Fact] + public async Task ValidateAsync_ExpiredProof_StillValidates_BecauseLifetimeNotChecked() + { + var key = DbscProofKey.CreateEs256(); + var proof = key.CreateProof("c", expires: DateTimeOffset.UtcNow.AddMinutes(-10)); + + var result = await Validator.ValidateAsync(proof, publicKeyJwk: null, expectedChallenge: null); + + Assert.NotNull(result); + } +} diff --git a/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionSignOutTests.cs b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionSignOutTests.cs new file mode 100644 index 000000000000..00355547363d --- /dev/null +++ b/src/Security/Authentication/test/DeviceBoundSessions/DeviceBoundSessionSignOutTests.cs @@ -0,0 +1,167 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +#nullable enable +#pragma warning disable ASP0030 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed. + +using System.Net; +using System.Net.Http; +using System.Security.Claims; +using System.Text.RegularExpressions; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.TestHost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit; + +namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions; + +public class DeviceBoundSessionSignOutTests +{ + private const string SourceScheme = "Source"; + private const string SourceCookieName = ".AspNetCore.Source"; + private const string SessionCookieName = ".AspNetCore.Source.Dbsc.Session"; + private const string RefreshCookieName = ".AspNetCore.Source.Dbsc.Refresh"; + private const string RegistrationPath = "/.well-known/dbsc/registration"; + + [Fact] + public async Task Registration_MintsDerivedCookies_WithoutClearingThem() + { + using var host = await CreateHostAsync(); + var client = host.GetTestServer().CreateClient(); + + var registration = await SignInAndRegisterAsync(client); + + Assert.Equal(HttpStatusCode.OK, registration.StatusCode); + + var session = GetSetCookie(registration, SessionCookieName); + var refresh = GetSetCookie(registration, RefreshCookieName); + + // Registration is a cookie exchange: it must SET the derived cookies, not delete them + // (regression guard — the source sign-out that drops the long-lived cookie must not wipe + // the session/refresh cookies just minted). + Assert.NotNull(session); + Assert.NotNull(refresh); + Assert.False(IsDeletion(session!)); + Assert.False(IsDeletion(refresh!)); + } + + [Fact] + public async Task SignOut_OfSourceScheme_ClearsDerivedSessionAndRefreshCookies() + { + using var host = await CreateHostAsync(); + var client = host.GetTestServer().CreateClient(); + + var registration = await SignInAndRegisterAsync(client); + registration.EnsureSuccessStatusCode(); + + var sessionPair = CookiePair(GetSetCookie(registration, SessionCookieName)!); + var refreshPair = CookiePair(GetSetCookie(registration, RefreshCookieName)!); + + var signOut = new HttpRequestMessage(HttpMethod.Get, "/signout"); + signOut.Headers.TryAddWithoutValidation("Cookie", $"{sessionPair}; {refreshPair}"); + var signOutResponse = await client.SendAsync(signOut); + + var sessionDeletion = GetSetCookie(signOutResponse, SessionCookieName); + var refreshDeletion = GetSetCookie(signOutResponse, RefreshCookieName); + + // Signing out only the source scheme must also clear the DBSC-derived cookies, so the + // application never needs to know the derived scheme names to log the user out. + Assert.NotNull(sessionDeletion); + Assert.NotNull(refreshDeletion); + Assert.True(IsDeletion(sessionDeletion!)); + Assert.True(IsDeletion(refreshDeletion!)); + } + + private static async Task SignInAndRegisterAsync(HttpClient client) + { + var signIn = await client.GetAsync("/signin"); + signIn.EnsureSuccessStatusCode(); + + var sourceCookie = GetSetCookie(signIn, SourceCookieName); + Assert.NotNull(sourceCookie); + var challenge = ParseChallenge(signIn); + + var proof = DbscProofKey.CreateEs256().CreateProof(challenge); + var register = new HttpRequestMessage(HttpMethod.Post, RegistrationPath); + register.Headers.TryAddWithoutValidation("Cookie", CookiePair(sourceCookie!)); + register.Headers.TryAddWithoutValidation(DeviceBoundSessionConstants.Headers.Proof, proof); + return await client.SendAsync(register); + } + + private static async Task CreateHostAsync() + { + var host = new HostBuilder() + .ConfigureWebHost(webBuilder => + { + webBuilder + .UseTestServer() + .ConfigureServices(services => + { + services.AddRouting(); + services.AddDataProtection(); + services.AddAuthentication(SourceScheme) + .AddCookie(SourceScheme, o => o.Cookie.Name = SourceCookieName) + .AddDeviceBoundSession(SourceScheme); + }) + .Configure(app => + { + app.UseRouting(); + app.UseAuthentication(); + app.UseEndpoints(endpoints => + { + endpoints.MapGet("/signin", async context => + { + var identity = new ClaimsIdentity(SourceScheme); + identity.AddClaim(new Claim(ClaimTypes.NameIdentifier, "alice")); + await context.SignInAsync(SourceScheme, new ClaimsPrincipal(identity)); + }); + + endpoints.MapGet("/signout", context => context.SignOutAsync(SourceScheme)); + }); + }); + }) + .Build(); + + await host.StartAsync(); + return host; + } + + private static string? GetSetCookie(HttpResponseMessage response, string cookieName) + { + if (!response.Headers.TryGetValues("Set-Cookie", out var values)) + { + return null; + } + + foreach (var value in values) + { + if (value.StartsWith(cookieName + "=", StringComparison.Ordinal)) + { + return value; + } + } + + return null; + } + + private static string CookiePair(string setCookie) + { + var semicolon = setCookie.IndexOf(';'); + return semicolon < 0 ? setCookie : setCookie[..semicolon]; + } + + private static bool IsDeletion(string setCookie) + => setCookie.Contains("expires=Thu, 01 Jan 1970", StringComparison.OrdinalIgnoreCase); + + private static string ParseChallenge(HttpResponseMessage response) + { + var header = Assert.Single(response.Headers.GetValues(DeviceBoundSessionConstants.Headers.Registration)); + var match = Regex.Match(header, "challenge=\"([^\"]+)\""); + Assert.True(match.Success, $"No challenge found in registration header: {header}"); + return match.Groups[1].Value; + } +} diff --git a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj index 6a36303bc0f1..2315e7af0322 100644 --- a/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj +++ b/src/Security/Authentication/test/Microsoft.AspNetCore.Authentication.Test.csproj @@ -45,6 +45,7 @@ +