[API Proposal]: Device Bound Session Credentials (DBSC) for cookie authentication
- Dominant language
- C#
- Stars
- 38.4k
- Forks
- 10.9k
- Avg merge
- 2d 6h
- Merged PRs (30d)
- 290
Description
## Background and Motivation
Cookie-based sign-in today produces a bearer credential: whoever holds the cookie is the user. If the cookie is exfiltrated (infostealer malware, token theft), it can be replayed from any machine for the remaining lifetime of the session. [Device Bound Session Credentials (DBSC)](https://www.w3.org/TR/dbsc/) is a W3C spec that binds a browser session to a device-held private key: the browser proves possession of the key to obtain a short-lived cookie, so a stolen cookie stops working once it expires.
This proposal is the public surface for a new standalone, experimental package, `Microsoft.AspNetCore.Authentication.DeviceBoundSessions`, tracked by #66478 and implemented in #67388. It "[implements] the DBSC protocol (W3C spec) in the cookie authentication handler" using a "stateless two-cookie pattern": a long-lived cookie carrying the auth ticket plus the embedded public key (refresh-token equivalent) and a short-lived `.Dbsc.Session` cookie that is the device-bound credential (access-token equivalent), with registration and refresh endpoints handled by an `IAuthenticationRequestHandler`.
The design goal is that an app opts into DBSC by *wrapping an existing cookie scheme* — `AddDeviceBoundSession(sourceScheme)` — rather than rewriting its authentication setup. The primary target is ASP.NET Core Identity, whose application cookie (`IdentityConstants.ApplicationScheme`) is exactly the long-lived bearer credential DBSC is meant to protect. The handler derives the refresh/session/policy schemes, copies cookie settings from the source scheme, and routes authentication through a policy scheme, so app code and `[Authorize]` continue to work unchanged.
The API ships as a standalone NuGet package (not the shared framework) because it carries a third-party dependency (`Microsoft.IdentityModel.JsonWebTokens`) and `System.Formats.Cbor`, and the entire surface is gated behind the experimental diagnostic `ASP0031` ("Experimental warning for Device Bound Sessions (DBSC) APIs") and versioned with `ExperimentalVersionPrefix` as a 0.x preview package.
## Proposed API
Everything below is **new API in a new assembly/package**, `Microsoft.AspNetCore.Authentication.DeviceBoundSessions`. Nothing is added to or changed in an existing assembly, so there is no diff against a current surface. Every type is annotated `[Experimental("ASP0031", UrlFormat = "https://aka.ms/aspnet/analyzer/{0}")]`.
**New static class** `DeviceBoundSessionExtensions`, the DI entry point:
```csharp
namespace Microsoft.Extensions.DependencyInjection;
public static class DeviceBoundSessionExtensions
{
public static AuthenticationBuilder AddDeviceBoundSession(this AuthenticationBuilder builder, string sourceScheme);
public static AuthenticationBuilder AddDeviceBoundSession(this AuthenticationBuilder builder, string sourceScheme, Action configureOptions);
public static AuthenticationBuilder AddDeviceBoundSession(this AuthenticationBuilder builder, string authenticationScheme, string sourceScheme);
public static AuthenticationBuilder AddDeviceBoundSession(this AuthenticationBuilder builder, string authenticationScheme, string sourceScheme, Action configureOptions);
}
```
**New types** in the feature namespace — the defaults class, the `HttpContext` extension used to opt already-signed-in users into DBSC, the protocol handler, its options, and the options-side scope rule:
```csharp
namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
public static class DeviceBoundSessionDefaults
{
public const string AuthenticationScheme = "DeviceBoundSession";
public const string RegistrationPath = "/.well-known/dbsc/registration";
public const string RefreshPath = "/.well-known/dbsc/refresh";
}
public static class DeviceBoundSessionHttpContextExtensions
{
public static void WriteDeviceBoundSessionRegistration(this HttpContext context, string sourceScheme);
}
public class DeviceBoundSessionHandler : AuthenticationHandler, IAuthenticationRequestHandler
{
public DeviceBoundSessionHandler(
IOptionsMonitor options,
ILoggerFactory logger,
UrlEncoder encoder,
IDataProtectionProvider dataProtectionProvider,
IOptionsMonitor cookieOptionsMonitor);
public Task HandleRequestAsync();
protected override Task HandleAuthenticateAsync();
}
public class DeviceBoundSessionOptions : AuthenticationSchemeOptions
{
public DeviceBoundSessionOptions();
public string? RegistrationSourceScheme { get; set; }
public string? RefreshScheme { get; set; }
public string? SessionScheme { get; set; }
public PathString RegistrationPath { get; set; } // "/.well-known/dbsc/registration"
public PathString RefreshPath { get; set; } // "/.well-known/dbsc/refresh"
public TimeSpan ShortLivedCookieExpiration { get; set; } // 10 minutes
public TimeSpan ChallengeMaxAge { get; set; } // 5 minutes
public bool IncludeSite { get; set; } // false
public IList ScopeSpecifications { get; }
public IList AllowedRefreshInitiators { get; }
public override void Validate(string scheme);
}
public class DeviceBoundSessionScopeRule
{
public DeviceBoundSessionScopeRule();
public string Type { get; set; } // "include"
public string Domain { get; set; } // "*"
public string Path { get; set; } // "/"
}
```
**New types** modelling the W3C wire formats that are serialized to the browser during registration and refresh:
```csharp
namespace Microsoft.AspNetCore.Authentication.DeviceBoundSessions;
// W3C DBSC §9.6 "JSON Session Instruction Format"
public sealed class SessionInstruction
{
public SessionInstruction();
public string SessionIdentifier { get; set; }
public string? RefreshUrl { get; set; }
public bool Continue { get; set; } // true
public SessionScope? Scope { get; set; }
public List? Credentials { get; set; }
public List AllowedRefreshInitiators { get; set; }
}
// W3C DBSC §9.7 "JSON Session Scope Instruction Format"
public sealed class SessionScope
{
public SessionScope();
public string? Origin { get; set; }
public bool IncludeSite { get; set; }
public List ScopeSpecification { get; set; }
}
// W3C DBSC §9.8 "JSON Session Scope Rule Format"
public sealed class SessionScopeRule
{
public SessionScopeRule();
public string Type { get; set; }
public string Domain { get; set; }
public string Path { get; set; }
}
// W3C DBSC §9.9 "JSON Session Credentials Format"
public sealed class SessionCredential
{
public SessionCredential();
public string Type { get; } // always "cookie"
public required string Name { get; set; }
public string Attributes { get; set; } // ""
}
```
Implementation reference: PR #67388 (commit `eb04e4399c`), in particular [`src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt`](https://github.com/dotnet/aspnetcore/blob/main/src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt).
## Usage Examples
The main scenario is an ASP.NET Core Identity app protecting its application cookie. DBSC wraps the existing Identity scheme; nothing else about the app's authentication setup changes:
```csharp
#pragma warning disable ASP0031
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddDbContext(options =>
options.UseSqlServer(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddDefaultIdentity()
.AddEntityFrameworkStores();
// Bind the Identity application cookie to the device.
builder.Services.AddAuthentication()
.AddDeviceBoundSession(IdentityConstants.ApplicationScheme);
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapRazorPages();
app.Run();
```
Existing app code is unaffected — `[Authorize]`, `SignInManager`, and `User.Identity` keep working, because authentication is routed through the policy scheme that `AddDeviceBoundSession` registers:
```csharp
[Authorize]
public class IndexModel : PageModel
{
public void OnGet() => Name = User.Identity!.Name;
public string? Name { get; private set; }
}
```
Tuning the bound-cookie lifetime, challenge freshness, and session scope:
```csharp
builder.Services.AddAuthentication()
.AddDeviceBoundSession(IdentityConstants.ApplicationScheme, options =>
{
options.ShortLivedCookieExpiration = TimeSpan.FromMinutes(10);
options.ChallengeMaxAge = TimeSpan.FromMinutes(5);
options.RegistrationPath = "/.well-known/dbsc/registration";
options.RefreshPath = "/.well-known/dbsc/refresh";
options.IncludeSite = true;
options.AllowedRefreshInitiators.Add("app.contoso.com");
options.ScopeSpecifications.Add(new DeviceBoundSessionScopeRule
{
Type = "exclude",
Domain = "*",
Path = "/public",
});
});
```
Migrating users who are *already* signed in with an Identity cookie, without forcing a re-login. `WriteDeviceBoundSessionRegistration` emits the `Secure-Session-Registration` header for the current authenticated principal; DBSC-capable browsers start registration on the next response, and browsers that don't support DBSC ignore it:
```csharp
// 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();
});
```
## Alternative Designs
- **Implementing DBSC inside the existing cookie handler vs. a separate scheme.** An earlier iteration lived in `Microsoft.AspNetCore.Authentication.Cookies`; it was removed in favor of a dedicated handler project ("Remove v1 DBSC implementation from Cookies project", "Add DeviceBoundSessions handler project (v2 architecture)"). The current design instead composes existing building blocks: the DBSC handler owns only the protocol endpoints, while cookie handling is delegated to derived cookie schemes and a policy scheme selects between the bound session cookie and the source cookie.
- **Shared framework vs. standalone package.** The feature was explicitly changed to "ship as NuGet package, not shared framework", because it takes a dependency on `Microsoft.IdentityModel.JsonWebTokens` for JWT proof validation and on `System.Formats.Cbor`.
- **Stateful vs. stateless session storage.** The implementation uses a "stateless two-cookie pattern" with "stateless challenge generation via DataProtection", so no server-side session store is required. Server-side revocation via a shared `ITicketStore` is tracked separately in #67794.
- **Explicit scheme names vs. derived scheme names.** Rather than making an Identity app declare four schemes, `AddDeviceBoundSession` derives `{sourceScheme}.Dbsc.Refresh`, `{sourceScheme}.Dbsc.Session`, and `{sourceScheme}.Dbsc`, and post-configures the default scheme so "the app never has to know (or wire) the derived DBSC scheme names". `RegistrationSourceScheme` / `RefreshScheme` / `SessionScheme` remain on the options as an escape hatch.
- **Advertised endpoint URLs vs. local handler paths.** `RegistrationPath` / `RefreshPath` are deliberately restricted to nonempty, root-relative, application-local paths (no full URLs, network-path references, query strings, or fragments), even though "the DBSC protocol permits absolute and cross-origin same-site endpoint references". Splitting the advertised URL from the local handler path is tracked in #67850.
## Risks
- **Experimental surface.** Everything is annotated `[Experimental("ASP0031")]` and the package uses `ExperimentalVersionPrefix` (0.x), so consumers must suppress `ASP0031` to use it and the shape is expected to change. `ASP0031` is a new diagnostic ID reserved in `docs/list-of-diagnostics.md`.
- **New public API in a new package, so no breaking changes** to existing assemblies; the ref-assembly delta is additive and lives entirely in the new assembly.
- **Scheme-name collisions.** The derived scheme and cookie names are computed from the source scheme name (`{sourceScheme}.Dbsc.*`); an app that already registered a scheme with one of those names would conflict.
- **Security-sensitive defaults and deployment requirements.** The registration header "is only honored over HTTPS", the callable API requires an authenticated principal, and correct operation behind a proxy requires trusted forwarded scheme/host/prefix handling before authentication and no permissive credentialed CORS on the refresh endpoint. Mis-deployment weakens the binding the feature exists to provide.
- **Caller-owned idempotency for `WriteDeviceBoundSessionRegistration`.** "Emitting it repeatedly mints a new challenge each time, so the caller owns idempotency" — a naive call on every response would issue a fresh challenge per request.
- **`IncludeSite = true` has a topology constraint**: it "requires the registration endpoint to be served from the registrable-domain host because the scope origin is derived from the registration request origin". Registration from subdomains is tracked in #67827.
- **Wire-format DTOs are public and mutable.** `SessionInstruction`, `SessionScope`, `SessionScopeRule`, and `SessionCredential` mirror the W3C JSON shapes with `get; set;` properties and `List` members; if the spec evolves, these types evolve with it.
- **Public unsealed handler/options types** (`DeviceBoundSessionHandler`, `DeviceBoundSessionOptions`, `DeviceBoundSessionScopeRule`) are extensible by derivation, which constrains future changes once the experimental gate is removed.
---
Source justification (per .github/prompts/ApiReview.prompt.md)
**Background and Motivation**
- DBSC implemented in cookie auth with a two-cookie pattern: "Implements the DBSC protocol (W3C spec) in the cookie authentication handler. Uses a stateless two-cookie pattern: Long-lived cookie: auth ticket + embedded public key (refresh token equivalent) / Short-lived cookie (__dbsc suffix): device-bound credential (access token equivalent) / Registration/refresh middleware / ES256/RS256 JWT validation / Stateless challenge generation via DataProtection" (commit `eb04e4399c`).
- Wraps an existing cookie scheme: "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." (`DeviceBoundSessionExtensions.cs`).
- Identity as the target scheme: "The existing cookie authentication scheme to protect with DBSC (e.g., \"Identity.Application\")" (`DeviceBoundSessionExtensions.cs`) and "The source cookie authentication scheme that was passed to AddDeviceBoundSession (for example IdentityConstants.ApplicationScheme)" (`DeviceBoundSessionHttpContextExtensions.cs`).
- Policy-scheme routing: "Policy scheme routes auth: tries Session cookie first, falls back to source" (commit `eb04e4399c`).
- Standalone experimental package: "Experimental (ASP0031); ships as a 0.x preview package, matching the convention for experimental standalone packages." and ``, `` (`Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj`).
- Diagnostic reservation: "`__ASP0031__` | Experimental warning for Device Bound Sessions (DBSC) APIs" (`docs/list-of-diagnostics.md`).
- Package description: "ASP.NET Core middleware that enables Device Bound Session Credentials (DBSC) over an inner authentication scheme." (csproj).
**Proposed API**
- Entire surface transcribed from `src/Security/Authentication/DeviceBoundSessions/src/PublicAPI.Unshipped.txt`, e.g. "static Microsoft.Extensions.DependencyInjection.DeviceBoundSessionExtensions.AddDeviceBoundSession(this Microsoft.AspNetCore.Authentication.AuthenticationBuilder! builder, string! sourceScheme) -> Microsoft.AspNetCore.Authentication.AuthenticationBuilder!" and "const Microsoft.AspNetCore.Authentication.DeviceBoundSessions.DeviceBoundSessionDefaults.RegistrationPath = \"/.well-known/dbsc/registration\" -> string!".
- New assembly (no existing-surface diff): the API entries live in a newly added `PublicAPI.Unshipped.txt` for a new project, "src/Security/Authentication/DeviceBoundSessions/src/Microsoft.AspNetCore.Authentication.DeviceBoundSessions.csproj" (commit `eb04e4399c` file list).
- Experimental annotation on every type: "[Experimental(\"ASP0031\", UrlFormat = \"https://aka.ms/aspnet/analyzer/{0}\")]" (all public source files).
- Handler base/interface: "public class DeviceBoundSessionHandler : AuthenticationHandler, IAuthenticationRequestHandler" (`DeviceBoundSessionHandler.cs`).
- Defaults for options: "public TimeSpan ShortLivedCookieExpiration { get; set; } = TimeSpan.FromMinutes(10);", "public TimeSpan ChallengeMaxAge { get; set; } = TimeSpan.FromMinutes(5);" (`DeviceBoundSessionOptions.cs`).
- W3C section mapping: "Corresponds to the \"JSON Session Instruction Format\" defined in W3C Device Bound Session Credentials §9.6", "§9.7", "§9.8", "§9.9" (`SessionInstruction.cs`, `SessionScope.cs`, `SessionScopeRule.cs`, `SessionCredential.cs`).
- Credential shape: "public string Type { get; } = \"cookie\";", "public required string Name { get; set; }" (`SessionCredential.cs`).
**Usage Examples**
- Registration/migration middleware sample is taken verbatim from the `` block on `WriteDeviceBoundSessionRegistration` (`DeviceBoundSessionHttpContextExtensions.cs`), which itself uses `IdentityConstants.ApplicationScheme` and the `.AspNetCore.Identity.Application.Dbsc.Session` cookie name.
- Wrapping the Identity cookie scheme follows the documented parameter contract "The existing cookie authentication scheme to protect with DBSC (e.g., \"Identity.Application\")" (`DeviceBoundSessionExtensions.cs`); the sample app demonstrates the same shape against its own source scheme: "services.AddAuthentication(DbscNames.Source) .AddCookie(DbscNames.Source, ...) ... .AddDeviceBoundSession(...)" (`samples/DbscDebugServer/Program.cs`).
- Suppression pragma: "#pragma warning disable ASP0031 // Type is for evaluation purposes only and is subject to change or removal in future updates." (`samples/DbscDebugServer/Program.cs`).
- Unchanged app code: "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." (`samples/DbscDebugServer/Program.cs`).
**Alternative Designs**
- Handler split out of Cookies: "Remove v1 DBSC implementation from Cookies project" and "WIP: Add DeviceBoundSessions handler project (v2 architecture)" (commit history).
- Packaging decision: "Fix DBSC project to ship as NuGet package, not shared framework" (commit history).
- Stateless design: "Stateless challenge generation via DataProtection" (commit `eb04e4399c`); server-side revocation deferred per #67794 "DBSC: share source scheme ITicketStore (server-side revocation) with derived cookies".
- Derived scheme names: "var refreshScheme = $\"{sourceScheme}.Dbsc.Refresh\"; var sessionScheme = $\"{sourceScheme}.Dbsc.Session\"; var policyScheme = $\"{sourceScheme}.Dbsc\";" (`DeviceBoundSessionExtensions.cs`).
- Path restrictions: "Although the DBSC protocol permits absolute and cross-origin same-site endpoint references, they are outside this component's local request-handler model." (`DeviceBoundSessionOptions.cs`); follow-up #67850 "DBSC: separate browser-advertised endpoint URLs from local handler paths".
**Risks**
- HTTPS-only and idempotency: "Emitting it repeatedly mints a new challenge each time, so the caller owns idempotency" and "The header is only honored over HTTPS." (`DeviceBoundSessionHttpContextExtensions.cs`).
- Authenticated-principal requirement: "The Device Bound Session registration challenge must be bound to an authenticated principal." (`DeviceBoundSessionHttpContextExtensions.cs`).
- Proxy/CORS deployment guidance: "Production deployments behind a proxy must apply trusted forwarded scheme, host, and prefix before authentication... Do not enable permissive credentialed CORS on the refresh endpoint." (`samples/DbscDebugServer/Program.cs`).
- `IncludeSite` constraint: "With the current implementation, true requires the registration endpoint to be served from the registrable-domain host because the scope origin is derived from the registration request origin." (`DeviceBoundSessionOptions.cs`); follow-up #67827 "DBSC: support site scope registration from subdomains".
- Experimental versioning: "$(ExperimentalVersionPrefix)" (csproj).
/cc #66478 #67388
Contributor guide
Assessment
This issue has not been assessed yet.