SignedChannel.MongoDb 10.2.1

dotnet add package SignedChannel.MongoDb --version 10.2.1
                    
NuGet\Install-Package SignedChannel.MongoDb -Version 10.2.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="SignedChannel.MongoDb" Version="10.2.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="SignedChannel.MongoDb" Version="10.2.1" />
                    
Directory.Packages.props
<PackageReference Include="SignedChannel.MongoDb" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add SignedChannel.MongoDb --version 10.2.1
                    
#r "nuget: SignedChannel.MongoDb, 10.2.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package SignedChannel.MongoDb@10.2.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=SignedChannel.MongoDb&version=10.2.1
                    
Install as a Cake Addin
#tool nuget:?package=SignedChannel.MongoDb&version=10.2.1
                    
Install as a Cake Tool

SignedChannel

A session-bound signed channel between a browser and an ASP.NET Core server:

  • Browser → server is signed HTTP. Every request carries a signature made with a key pair generated per session, so a request cannot be forged or replayed.
  • Server → browser is SignalR push, on a presence-tracked connection — so the server knows when a tab goes away, and can send your application's own events to the tabs that are there.
  • Business logic is actions, not controllers. You write classes implementing ISecureWebBrowserAction<TRequest, TResponse>; they are resolved by wire name through a namespace convention. No route table, no per-endpoint auth plumbing.
Package Registry What it is
SignedChannel NuGet The server half: handshake, dispatcher, presence hub, push, sign-in/out
SignedChannel.MongoDb NuGet MongoDB storage for sessions, instructions and tokens
signed-channel-client npm The browser half, framework-agnostic

All three version in lockstep from one git tag, so the client and server halves of the protocol cannot drift apart.

Getting started

Server (Program.cs):

builder.Services.BindSignedChannelConfiguration(builder.Configuration);
builder.Services.AddSignedChannel(options =>
{
    options.ActionAssembly  = typeof(Program).Assembly;   // where your actions live
    options.RootNamespace   = "MyApp";
    options.DevActionPrefix = "MyApp.Dev.";               // keep the trailing dot
});
builder.Services.AddSignalR();
builder.Services.AddSignedChannelMongoDb();   // sessions, instructions and tokens on MongoDB

app.MapSignedChannelHub();   // both must come before any SPA fallback route
app.MapSignedChannel();

ActionAssembly and RootNamespace have no sensible default and are validated at startup. Without AddSignedChannelMongoDb you must register your own IChannelSessionStore; the instruction and token stores then default to in-memory, which is fine for one instance and wrong for more than one.

Browser:

import { ChannelCore, CryptoCore, SessionApi, SessionStore, SignalRChannelConnection } from 'signed-channel-client';
import * as signalR from '@microsoft/signalr';   // or the global from signalr.min.js

const crypto = new CryptoCore();
const store = new SessionStore();
const connection = new SignalRChannelConnection({ signalR });
const channel = new ChannelCore(connection, new SessionApi(crypto, store), store, crypto);
channel.onSignedOut = () => location.assign('/sign-in');
channel.start('en');

On start the channel registers a session, proves it owns the connection (an encrypted challenge only that session's key can read) and joins the session's group. All of that is built in; wire names under Channel. belong to the package, and an application action that would answer one fails startup.

Browser → server: actions

An action is a class in the right place. The wire name "Orders.Ship" resolves to MyApp.Orders.Actions.WebApp.Ship.ShipAction:

namespace MyApp.Orders.Actions.WebApp.Ship;

public sealed record ShipRequest : MessageWebBrowserRequestBase
{
    [JsonPropertyName("orderId")] public required string OrderId { get; init; }
}

public sealed class ShipAction : ISecureWebBrowserAction<ShipRequest, MessageWebBrowserResponseBase>
{
    private readonly OrderService _orders;

    // Constructor dependencies are injected — no DI registration needed.
    public ShipAction(OrderService orders) => _orders = orders;

    public WebBrowserActionsValidationResult ValidateFromWebBrowser(string sessionId, string? userId, string? latestDeviceId, ShipRequest? request) =>
        new() { IsValid = !string.IsNullOrWhiteSpace(request?.OrderId) };

    public Task<bool> HasAccess(HttpRequest httpRequest, string sessionId, string? userId, string? latestDeviceId, ShipRequest? request) =>
        Task.FromResult(userId is not null);   // signed in

    public async Task<MessageWebBrowserResponseBase> ProcessMessageFromWebBrowserAsync(
        HttpRequest httpRequest, string sessionId, string signalRConnectionId, string? userId, string? latestDeviceId,
        ShipRequest? request, string jobId, CancellationToken cancellationToken)
    {
        await _orders.ShipAsync(request!.OrderId);
        return new MessageWebBrowserResponseBase();
    }
}
const response = await channel.send('Orders.Ship', { orderId: 'o-42' });

send signs the request and adds the page context (currentUrl, referrerUrl). A rejected request (validation, access, unknown action) resolves to an empty object rather than throwing, so optional-chain the result.

Server → browser: custom events

Your application's events are called instructions: a name you choose and an optional payload (usually JSON). There is nothing to declare or register on either side — push a name from the server, listen for the same name in the browser.

1. Push from the server

Inject ChannelPushService and call PushInstructionAsync with the session to tell. Inside an action, the session and the calling tab's connection are parameters:

public sealed class ShipAction : ISecureWebBrowserAction<ShipRequest, MessageWebBrowserResponseBase>
{
    private readonly OrderService _orders;
    private readonly ChannelPushService _push;

    public ShipAction(OrderService orders, ChannelPushService push)
    {
        _orders = orders;
        _push = push;
    }

    // ValidateFromWebBrowser and HasAccess as above.

    public async Task<MessageWebBrowserResponseBase> ProcessMessageFromWebBrowserAsync(
        HttpRequest httpRequest, string sessionId, string signalRConnectionId, string? userId, string? latestDeviceId,
        ShipRequest? request, string jobId, CancellationToken cancellationToken)
    {
        var order = await _orders.ShipAsync(request!.OrderId);

        await _push.PushInstructionAsync(
            sessionId,
            "Orders.Shipped",                                    // your event name
            JsonSerializer.Serialize(new { order.Id, order.TrackingNumber }),
            validity: TimeSpan.FromMinutes(1),                   // how long the tabs have to collect it
            exceptConnectionId: signalRConnectionId);            // optional: skip the tab that asked

        return new MessageWebBrowserResponseBase();
    }
}

Anywhere else — a background job, a webhook, another user's action — works the same way, as long as you know the session id:

public sealed class CourierWebhook(ChannelPushService push, MySessionIndex sessions)
{
    public async Task OnDeliveredAsync(string userId, string orderId)
    {
        foreach (var sessionId in await sessions.LiveSessionsOfAsync(userId))
        {
            await push.PushInstructionAsync(sessionId, "Orders.Delivered",
                JsonSerializer.Serialize(new { orderId }), TimeSpan.FromMinutes(5));
        }
    }
}

The channel does not keep a user → sessions index: who should hear about what is your application's business. The natural place to record it is where you sign a session in (below), since that is the moment you know both ids.

2. Listen in the browser

const unsubscribe = channel.on('Orders.Shipped', (payload) => {
  const { id, trackingNumber } = JSON.parse(payload!);
  showToast(`Order ${id} shipped — ${trackingNumber}`);
});

// When the page or component goes away:
unsubscribe();

The handler receives the payload exactly as the server passed it — a string, or null when there was none. Any number of handlers may listen to one name; a name nobody listens to is ignored. In Angular, call on in a service or component and pass the returned function to DestroyRef.onDestroy.

How delivery works, and its limits

  • The instruction is stored on the server and only a short pointer is pushed over SignalR, encrypted to the session's key (RSA payloads are size-limited; this way the payload is not). The browser then downloads the instruction over the signed channel. Only the session it was addressed to can collect it.
  • It reaches the tabs that are connected and verified at that moment. Nothing is queued for tabs that open later, and an instruction nobody collected is discarded when validity runs out. For state that must not be missed, have the page re-read it (with an action) when it loads, and treat instructions as "something changed, look again".
  • Delivery is best-effort, like any push: keep payloads small and idempotent to apply, and do not rely on ordering between different instructions.
  • Names starting with Channel. are reserved for the protocol.

Signing a session in and out

The channel never decides who someone is. Authenticate however you like — passkeys, OIDC, a magic link — then tell it, through ChannelSessionService:

await channelSessions.SignInAsync(sessionId, yourOwnUserId);
await mySessionIndex.AddAsync(yourOwnUserId, sessionId);   // if you push to users later

That clears any earlier sign-out and pushes Channel.SignedIn to every tab of the session, where channel.onSignedIn fires and channel.isSignedIn becomes true. Server-initiated sign-out is SignOutAsync(sessionId); a tab signs itself out with channel.signOut(). Either way every tab of the session ends in channel.onSignedOut.

The user id is opaque: echoed to actions, used to decide whether a request counts as signed in, never interpreted. That is what lets a confidential OIDC client hand over the sub from its own callback and get an authenticated channel out of it.

Signed-in sessions expire after inactivity and at an absolute limit (SessionExpiry section of configuration). Every signed-in response carries the expiry, mirrored on the client as channel.sessionExpiresAtUtc / onExpiryChange; channel.keepAlive() extends it.

Optional hooks

Register any of these and the channel calls them; leave them out and it does not.

Interface Called for
IChannelSessionObserver a session registering — per-browser bookkeeping
IChannelActivityRecorder a completed action — your audit trail
ISessionDeviceResolver the device behind a signed-in session
IPresenceObserver connections opening, binding to a session, and closing
IConnectionChangeNotifier a signed-in session gaining or losing a tab
IChannelSignOutObserver a browser signing out — your own cookies, a companion device

Versioning

The major version follows the .NET version it targets (10.x is .NET 10), so it is not a breaking-change signal: a minor release can change the API, and the release notes say so. Both halves always release together under one version; pin the pair exactly (10.2.1, not ^10.2.0) and read the release notes before moving.

The code is extracted from a production implementation (uSignIn) rather than written fresh.

Licence

Apache-2.0. See LICENSE and NOTICE.

Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
10.2.1 53 10/25/2026
10.2.0 60 10/25/2026
10.1.2 93 10/20/2026
10.1.1 86 10/19/2026
10.1.0 92 10/10/2026
10.0.1 98 10/5/2026
10.0.0 128 8/9/2026
0.3.1 116 8/9/2026
0.3.0 124 8/9/2026