Device Bound Session Credentials (DBSC) for cookie authentication (prototype)#67388
Device Bound Session Credentials (DBSC) for cookie authentication (prototype)#67388rokonec wants to merge 34 commits into
Conversation
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) Includes: - Registration/refresh middleware - ES256/RS256 JWT validation - Stateless challenge generation via DataProtection - Sample app with HTTP logging for testing - 16 unit/integration tests Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
New separate authentication handler project following the OAuth/OIDC delegation pattern. The DBSC handler manages the registration/refresh protocol and delegates cookie stamping to separate cookie authentication schemes. Architecture: - Policy scheme routes auth: tries Session cookie first, falls back to source - Registration handler reads source scheme, stamps Refresh + Session cookies - Refresh handler reads Refresh cookie (path-scoped), stamps fresh Session cookie - Source (long-lived) cookie deleted after registration Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sample app demonstrates the new DBSC architecture: - Source cookie scheme with DeviceBoundSessionCookieEvents - AddDeviceBoundSession() wires up refresh/session/policy schemes - Policy scheme fallback verified working Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
The v1 approach embedded DBSC directly in the cookie authentication handler. This has been replaced by the v2 architecture in the separate DeviceBoundSessions project which uses the OAuth/OIDC delegation pattern. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…tyModel JWT - Challenges now use ITimeLimitedDataProtector (5min expiry) and include the user's NameIdentifier claim for binding validation - Registration validates NameIdentifier from challenge matches the authenticated principal - Cookie sign-in header emission is now automatic via IPostConfigureOptions (no manual DeviceBoundSessionCookieEvents wiring in sample) - JWT validation uses Microsoft.IdentityModel.JsonWebTokens - Base64Url encoding uses WebEncoders from framework - Added DataProtection.Extensions and WebUtilities references Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Remove IsAspNetCoreApp and IsPackable=false (matches JwtBearer pattern) - Revert SharedFramework.External.props changes - Add Microsoft.IdentityModel.JsonWebTokens to Dependencies.props/Versions.props as a LatestPackageReference (same as OpenIdConnect, WsFederation) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Replace simple NameIdentifier with a stable user identifier that follows the same priority as antiforgery: sub > NameIdentifier > UPN > SHA256 hash of all claims. This ensures the challenge is bound to the user regardless of which identity claims are available. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Registration challenge: claimUid|nonce (no session ID, not known yet) - Refresh challenge: claimUid|nonce|sessionId (binds to specific session) - Use RandomNumberGenerator.Fill(Span<byte>) instead of GetBytes() - Use stackalloc for nonce/hash buffers - Remove the RegistrationSessionId constant (was a design smell) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Replace pipe-separated string with CBOR map encoding
Registration: { uid: claimUid }
Refresh: { uid: claimUid, sid: sessionId }
- Remove nonce (ITimeLimitedDataProtector already provides uniqueness
and replay protection via timestamp + MAC)
- Use byte[] overload of ITimeLimitedDataProtector.Protect/Unprotect
- Add System.Formats.Cbor reference
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Use CBOR definite-length array instead of map (simpler, smaller): Registration: [ claimUid ] Refresh: [ claimUid, sessionId ] - Use WebEncoders.Base64UrlEncode/Decode consistently (not manual replace) - ITimeLimitedDataProtector byte[] API is appropriate here (not hot path, ISpanDataProtector doesn't support time-limited) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Challenge payload is just consecutive CBOR text strings, no array framing: Registration: textstring(claimUid) Refresh: textstring(claimUid) textstring(sessionId) - Default ShortLivedCookieExpiration changed from 30s to 10 minutes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Switch from Microsoft.IdentityModel.JsonWebTokens (new entry) to Microsoft.IdentityModel.Protocols.OpenIdConnect (already in repo, transitively includes JsonWebTokens) - Remove added Dependencies.props and Versions.props entries - ComputeClaimUid now uses CBOR encoding for claim triples: - Known claims (sub/nid/upn): CBOR sequence of 3 text strings - Fallback: SHA256 of CBOR-encoded sorted claims (no intermediate strings) - No more string interpolation for claim UIDs Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Split DeviceBoundSessionConfiguration.cs into separate files per class - Split DeviceBoundSessionCookieEvents.cs into PostConfigure, EventsWrapper, SourceSchemes - Move DeviceBoundSessionJwtResult to own file - Remove unused SessionCookieSuffix and RefreshCookieSuffix from Defaults Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…rotector - PostConfigureDeviceBoundSessionDerivedCookieOptions copies HttpOnly, Secure, SameSite, Domain, ExpireTimeSpan from source cookie scheme to refresh and session cookie schemes - Refresh cookie overrides path to /.well-known/dbsc - DeviceBoundSessionChallengeProtector is now a non-static singleton that caches the ITimeLimitedDataProtector instance - Handler creates protector from injected IDataProtectionProvider - Removed TryAddSingleton for protector (handler creates its own) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Reflects: NuGet package model, CBOR challenges without nonce, ITimeLimitedDataProtector, antiforgery-style claim UID, cookie settings inheritance via IPostConfigureOptions, 10min default expiry, file structure, JWT validation via Microsoft.IdentityModel, singleton challenge protector. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…pand debug sample - emit Secure-Session-Challenge only on 403, not on success - rename JSON DTOs to SessionInstruction/Scope/ScopeRule/Credential - add DbscSampleV2 debug dashboard and HAR capture - add challenge protector and JWT validator tests 🔒 - Generated by Copilot
…ntal - add per-reason debug logging in challenge protector and JWT validator - separate DP purposes for registration/refresh; fix CBOR validation - gate public API as experimental (ASP0030); anchor refresh cookie lifetime - rename DbscSampleV2 sample to DbscDebugServer; add gap tests 🔒 - Generated by Copilot
- inherit SlidingExpiration from the source scheme; drop refresh-scheme override - stop anchoring refresh cookie lifetime at registration so it slides like the auth cookie - add tests for refresh sliding inheritance and short-lived cookie staying fixed - sample: surface the refresh cookie on the dashboard and decode challenges with split purposes 🔒 - Generated by Copilot
|
@copilot resolve the merge conflicts in this pull request |
Co-authored-by: rokonec <25249058+rokonec@users.noreply.github.com>
Co-authored-by: rokonec <25249058+rokonec@users.noreply.github.com>
CopyFromSource saved and restored target.Cookie.Name/Path around the attribute copy, but the copy never touches Name or Path, so the save-restore was a no-op. Remove it; callers set the refresh cookie path explicitly and the session scheme keeps its configured name. Addresses PR review feedback. 🤖 Generated by Copilot
Address PR review feedback on logout semantics and default-scheme ownership: - Sign-out of a DBSC source scheme now also clears the derived session and refresh cookies, so a plain SignOutAsync(sourceScheme) fully logs the user out. Applications never need to know the derived scheme names to clear them — just as they never need them to create them. Wired via a shared helper on DeviceBoundSessionCookieEvents, covering both the delegate-based and EventsType cookie-event configurations. - Guard the registration cookie exchange: the handler signs out the source scheme to drop the long-lived cookie during registration, which is not a user logout, so it no longer clears the session/refresh cookies it just minted. - Stop unconditionally overriding the application's default schemes. A new IPostConfigureOptions<AuthenticationOptions> upgrades the default authenticate scheme to the DBSC policy scheme only when it already resolves to a wrapped source scheme (otherwise the user would be logged out after registration deletes the source cookie). Sign-in/sign-out and unrelated defaults are left untouched. Add unit tests for the conditional default-scheme upgrade and end-to-end integration tests (sign-in, registration, sign-out) that verify the registration exchange keeps the derived cookies and sign-out clears them. 🤖 Generated by Copilot
…struction The JSON session instruction format (W3C DBSC §9.6) defines two root fields that were missing from SessionInstruction: - continue (boolean, default true) — lets registration/refresh terminate a session by returning false. Added and always emitted. - allowed_refresh_initiators (list of strings) — limits which out-of-scope hosts may initiate refreshes. The DeviceBoundSessionOptions setting of the same name was never flowed into the JSON; BuildSessionInstruction now populates it (omitted when empty). Add serialization and end-to-end tests confirming the option flows into the registration response and that continue is emitted. 🤖 Generated by Copilot
An application mounted under a non-root path base (e.g. "/foo") advertised
and scoped its DBSC endpoints at the origin root, so registration and refresh
broke. Thread the request path base through every path DBSC emits:
- Registration header path and the session instruction refresh_url now include
Request.PathBase.
- The refresh cookie is scoped with a RequestPathBaseCookieBuilder (AdditionalPath
= the DBSC endpoints directory) so its path becomes "{path base}/.well-known/dbsc"
per request, matching the advertised refresh endpoint.
- The credential attributes are derived from the built session cookie so the
advertised Path (and Secure) exactly match the cookie we emit; otherwise the
browser would treat the bound cookie as missing under a path base and loop.
Add an end-to-end test that registers under UsePathBase("/foo") and asserts the
registration header path, refresh_url, refresh/session cookie paths, and the
credential attributes path all carry the path base.
🤖 Generated by Copilot
| options.Events.OnSigningIn = async context => | ||
| { | ||
| DeviceBoundSessionRegistrationHeader.Emit(context.HttpContext, context.Principal, dbscScheme); | ||
| await priorSigningIn(context); |
There was a problem hiding this comment.
| await priorSigningIn(context); | |
| await priorSigningIn(context); | |
| DeviceBoundSessionRegistrationHeader.Emit(context.HttpContext, context.Principal, dbscScheme); |
I think we should let the user-registered OnSigningIn event modify the identity claim used for challenge binding before generating the Secure-Session-Registration header and add a regression test verifying this scenario works.
| DeviceBoundSessionRegistrationHeader.Emit(context.HttpContext, context.Principal, _dbscScheme); | ||
| await GetInnerEvents(context.HttpContext).SigningIn(context); |
There was a problem hiding this comment.
| DeviceBoundSessionRegistrationHeader.Emit(context.HttpContext, context.Principal, _dbscScheme); | |
| await GetInnerEvents(context.HttpContext).SigningIn(context); | |
| await GetInnerEvents(context.HttpContext).SigningIn(context); | |
| DeviceBoundSessionRegistrationHeader.Emit(context.HttpContext, context.Principal, _dbscScheme); |
| /// MUST stay in lock-step with the algorithms the proof validator accepts (<see cref="Es256"/>, | ||
| /// <see cref="Rs256"/>). | ||
| /// </summary> | ||
| internal const string AdvertisedAlgorithms = "(" + Es256 + " " + Rs256 + ")"; |
There was a problem hiding this comment.
It's going to be easier to read with interpolated string.
| internal const string AdvertisedAlgorithms = "(" + Es256 + " " + Rs256 + ")"; | |
| internal const string AdvertisedAlgorithms = $"({Es256} {Rs256})"; |
| /// Gets or sets the session scope. | ||
| /// </summary> | ||
| [JsonPropertyName("scope")] | ||
| public SessionScope? Scope { get; set; } |
There was a problem hiding this comment.
a JSON session scope describing the resources covered by the session. This key MUST be present, except when the value of the continue key is false.
Can we have the proper nullability annotations to indicate that this property is never null when Continue is true?
| /// Gets or sets the session credentials. | ||
| /// </summary> | ||
| [JsonPropertyName("credentials")] | ||
| public List<SessionCredential>? Credentials { get; set; } |
There was a problem hiding this comment.
a list of JSON session credentials describing the cookies protected by this session. This key MUST be present, except when the value of the continue key is false.
Same here about nullability annotations.
| /// list). See W3C Device Bound Session Credentials §8.3. | ||
| /// </summary> | ||
| [JsonPropertyName("allowed_refresh_initiators")] | ||
| public List<string>? AllowedRefreshInitiators { get; set; } |
There was a problem hiding this comment.
a list of strings describing which out-of-scope hosts are allowed to initiate DBSC refreshes. Out-of-scope is defined based on the scope’s origin and include_site values. See § 8.3 Identify if a request is allowed to refresh for details. This key is OPTIONAL; if not present, the default value will be an empty list.
Per the spec, I think this should default to an empty list.
| [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] | ||
| public List<SessionScopeRule>? ScopeSpecification { get; set; } |
There was a problem hiding this comment.
Should this default to empty list and not be nullable, similar to others I commented on?
https://w3c.github.io/webappsec-dbsc/#format-session-scope-instructions
a list of JSON session scope rules describing modifications to the default scope (the entire origin or site). This key is OPTIONAL; if not present, an empty list will be used.
Also I guess it shouldn't have JsonIgnore?
| /// Gets or sets the cookie name. | ||
| /// </summary> | ||
| [JsonPropertyName("name")] | ||
| public string Name { get; set; } = default!; |
There was a problem hiding this comment.
Should this be marked as required and remove the default!?
| /// Gets or sets the cookie attributes string. | ||
| /// </summary> | ||
| [JsonPropertyName("attributes")] | ||
| public string Attributes { get; set; } = default!; |
There was a problem hiding this comment.
a string containing the expected attributes of the protected cookie. See § 8.6 Identify if missing session credential for details on how this is used. This key is OPTIONAL; if not present, it will be the empty string.
Should this default to string.Empty and not default! (which is effectively null)
| if (Request.Path.Equals(Options.RegistrationPath)) | ||
| { | ||
| await HandleRegistrationAsync(); | ||
| return true; | ||
| } | ||
|
|
||
| if (Request.Path.Equals(Options.RefreshPath)) | ||
| { | ||
| await HandleRefreshAsync(); | ||
| return true; | ||
| } |
There was a problem hiding this comment.
That answers some of my previous questions, I missed this.
| { | ||
| if (HttpMethods.IsPost(Request.Method)) | ||
| { | ||
| if (Request.Path.Equals(Options.RegistrationPath)) |
There was a problem hiding this comment.
An sf-parameter whose key is "path", and whose value is an sf-string, conveying the path to the registration endpoint. This may be relative to the current URL, or it may be a full URL. Entries without this parameter will be ignored in § 8.10 Process session registration.
Are we correctly considering that this might be relative or full url?
Are we supposed to do anything if the user-provided RegistrationPath is a full url that is completely in a different host?
| { | ||
| if (HttpMethods.IsPost(Request.Method)) | ||
| { | ||
| if (Request.Path.Equals(Options.RegistrationPath)) |
There was a problem hiding this comment.
This is PathString equality right? I think by default it's case insensitive, but maybe for the purpose of this feature we will want to have case sensitive comparison? I'm not really sure, but I thought it's worth bringing this up.
| private async Task HandleRegistrationAsync() | ||
| { | ||
| // Extract the JWT proof from the Secure-Session-Response header | ||
| var proofHeader = Request.Headers[DeviceBoundSessionConstants.Headers.Proof].ToString(); |
There was a problem hiding this comment.
This is taking the full header as-is right? Should we try to move any parameters and ignore them?
https://w3c.github.io/webappsec-dbsc/#header-secure-session-response
This string MUST only contain the DBSC proof JWT. Any sf-parameters SHOULD be ignored.
Do we already have any common helpers for parsing headers properly?
| return; | ||
| } | ||
|
|
||
| proofHeader = proofHeader.Trim('"'); |
There was a problem hiding this comment.
Do we need to handle any escaping or decoding there?
Should we only trim exactly a single " at the end and exactly a single " at the start?
| /// Gets or sets whether to include the entire site in the session scope. | ||
| /// Defaults to <c>false</c> (origin-only scope). | ||
| /// </summary> | ||
| public bool IncludeSite { get; set; } |
There was a problem hiding this comment.
Are we not implementing this so far?
| if (!authResult.Succeeded || authResult.Principal is null) | ||
| { | ||
| Logger.RegistrationNoSourceAuthentication(); | ||
| Response.StatusCode = StatusCodes.Status401Unauthorized; |
There was a problem hiding this comment.
Is this a place where we should throw AuthenticationFailureException instead?
| 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"; | ||
| }); |
There was a problem hiding this comment.
Why does refreshScheme has Path while sessionScheme doesn't?
| public string? RefreshScheme { get; set; } | ||
|
|
||
| /// <summary> | ||
| /// Gets or sets the authentication scheme used to stamp the short-lived session cookie. | ||
| /// Both registration and refresh call <c>SignInAsync</c> on this scheme. | ||
| /// </summary> | ||
| public string? SessionScheme { get; set; } |
There was a problem hiding this comment.
Do we really need to differentiate the two schemes?
| sessionIdHeader = sessionIdHeader.Trim('"'); | ||
|
|
||
| // Authenticate against the refresh scheme (path-scoped refresh cookie) | ||
| var authResult = await Context.AuthenticateAsync(Options.RefreshScheme); |
There was a problem hiding this comment.
When this is called while the short-lived cookies have expired, what is the authentication result that we get?
| proofHeader = proofHeader.Trim('"'); | ||
|
|
||
| // Validate the JWT proof against the public key from the refresh cookie | ||
| var jwtResult = await _jwtValidator.ValidateAsync(proofHeader, publicKeyJwk, expectedChallenge: null); |
There was a problem hiding this comment.
Are we supposed to ensure that the signed proof is corresponding to a recent challenge that we issued?
It seems like expectedChallenge: null will be skipping that?
| 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, |
There was a problem hiding this comment.
Why do we need those checks for Kty and Crv?
It existed only for review and design purposes. Not needed anymore.
Device Bound Session Credentials (DBSC) for cookie authentication (prototype)
What this adds
A new experimental authentication component,
Microsoft.AspNetCore.Authentication.DeviceBoundSessions, that implements the server side of Device Bound Session Credentials. DBSC binds a browser session to a device-held private key: the short-lived session cookie is periodically refreshed only when the browser can produce a fresh signed proof-of-possession, so a stolen cookie alone is useless.It is designed as a drop-in hardening layer over an existing cookie auth scheme: you point it at a "source" sign-in scheme and it manages the registration handshake, a path-scoped refresh cookie, and a short-lived session cookie.
Shape of the API
All public types carry
[Experimental("ASP0030")]; consumers must explicitly opt in.How it works
/.well-known/dbsc/registration): validates the browser's signed proof JWT, binds it to the authenticated principal via a data-protected, time-limited challenge, then stamps a path-scoped refresh cookie (carrying the device public key + session id) and a short-lived session cookie./.well-known/dbsc/refresh): authenticates the refresh cookie, issues aSecure-Session-Challenge(403), validates the returned proof JWT signature against the bound key and the challenge binding, then re-mints the short-lived session cookie.ExpireTimeSpan/SlidingExpirationand slides exactly like the auth cookie it replaces (no session-lifetime regression when DBSC is enabled). The session cookie stays deliberately short-lived and non-sliding.LoggerMessageevents for every validation failure (challenge undecryptable/malformed/mismatch, proof malformed/wrong-typ/unsupported-alg/bad-signature), atDebug, never logging secrets.Components
src/Security/Authentication/DeviceBoundSessions/src/DbscDebugServersample with a live dashboard that decodes cookies, proofs, and challenges for inspectionTesting
src/Security/Authentication/test/DeviceBoundSessions/— 38 tests covering challenge round-trips and domain separation, JWT proof validation, data-protection of derived cookies, and refresh-cookie sliding inheritance.DbscDebugServersample.Open questions for review
ASP0030is reserved indocs/list-of-diagnostics.md; confirm allocation.attributesadvertised in the session instruction are currently hard-coded rather than derived from the session scheme's cookie builder./plans/dbsc-design.mdis residual planning document which shall be removed before merge, we keeping it for now for reviewersReferences
Specification
Status: Editor's Draft, 17 April 2026 — "may change at any moment." Lowest standardization maturity.
Browser implementation & documentation
chrome://flags/#enable-standard-device-bound-session-credentialsChromium source code
TODO
/plans/dbsc-design.md