Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
109 changes: 109 additions & 0 deletions BFF/v4/Websocket/Readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
# WebSocket BFF Sample

This sample demonstrates how WebSockets can be proxied through a Backend for Frontend (BFF) pattern using Duende BFF. The BFF exchanges authentication cookies for access tokens to secure WebSocket connections to backend services.

## Getting Started

To run this sample, start the AppHost project:

```bash
cd Websocket.AppHost
dotnet run
```

This will launch the .NET Aspire dashboard and orchestrate all the required services:
- **BFF** (https://localhost:7140) - The main entry point
- **GraphQL Server** (http://localhost:5095) - Backend service with WebSocket subscriptions
- **React Frontend** (http://localhost:5173) - Client application

Once running, navigate to the BFF URL (https://localhost:7140) to access the application.

You'll need to log in. This system uses the duende demo server: https://demo.duendesoftware.com/

You should be able to log in using:
user / password: bob/bob or alice/alice

Once logged in, it should automatically establish a websocket connection. Then:
1. Click 'Subscribe to books'. This sets up a graphql subscription
2. Click 'Add Sample book'. This shows that the sample book is reported back over the websocket connection.

After 75 seconds, you can observe (in the network tab), that the websocket connection is terminated and re-established.
This is because the token has expired.

## Project Structure

### Websocket.AppHost
A .NET Aspire AppHost project that orchestrates the entire solution. It configures and launches:
- The BFF service
- The GraphQL server
- The React frontend development server

### Websocket.Bff
The Backend for Frontend service built with Duende BFF that:
- Handles OIDC authentication with short-lived access tokens (75 seconds)
- Proxies WebSocket connections to the GraphQL server
- Exchanges authentication cookies for JWT access tokens
- Routes traffic between the frontend and backend services

Key features:
- Uses `interactive.confidential.short` client for demonstration of token expiration
- Proxies `/graphql` endpoint to the GraphQL server with user access tokens
- Serves the React frontend from `/`

### Websocket.GraphQLServer
A GraphQL server with WebSocket subscription support that:
- Validates JWT tokens from the BFF
- Provides GraphQL queries, mutations, and subscriptions
- Includes middleware for WebSocket authentication
- Demonstrates real-time data streaming over WebSockets

### Websocket.React
A React frontend application that:
- Connects to GraphQL subscriptions via WebSockets through the BFF
- Handles authentication state management
- Demonstrates automatic reconnection when tokens expire
- Provides a UI for testing GraphQL operations and subscriptions

### Websocket.Console
A console application for testing WebSocket connections outside of the browser environment.

## Goals and Architecture

This sample demonstrates several key concepts:

### WebSocket Proxying Through BFF
The BFF acts as a secure proxy for WebSocket connections, enabling:
- Centralized authentication and authorization
- Token management and refresh
- Secure communication between frontend and backend services

### Token Exchange and Lifecycle Management
The BFF exchanges authentication cookies for short-lived access tokens (75 seconds) that are used to authenticate WebSocket connections. This demonstrates:

1. **Cookie-to-Token Exchange**: The BFF converts browser cookies into JWT access tokens
2. **Short-lived Tokens**: Access tokens expire after 75 seconds to demonstrate token refresh scenarios
3. **Automatic Reconnection**: When tokens expire, the WebSocket connection is terminated
4. **Seamless Recovery**: The browser automatically attempts to reconnect, and the BFF issues a new token using the refresh token

### Connection Resilience
When an access token expires:
1. The GraphQL server detects the expired token and closes the WebSocket connection
2. The React client detects the connection loss
3. The client automatically attempts to reconnect
4. The BFF uses the refresh token to obtain a new access token
5. The WebSocket connection is re-established with the new token
6. Subscriptions resume seamlessly

This pattern ensures that users experience minimal disruption even with very short-lived access tokens, while maintaining strong security through regular token rotation.

## Authentication Flow

1. User authenticates with the BFF using OIDC
2. BFF stores authentication cookies and refresh tokens
3. Client initiates WebSocket connection through the BFF
4. BFF exchanges the authentication cookie for an access token
5. Access token is used to authenticate the WebSocket connection to the GraphQL server
6. When the token expires (after 75 seconds), the connection is closed
7. Client automatically reconnects, and the process repeats with a fresh token

This demonstrates how modern applications can maintain secure, long-lived connections while using short-lived tokens for enhanced security.
11 changes: 11 additions & 0 deletions BFF/v4/Websocket/Websocket.AppHost/AppHost.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// Copyright (c) Duende Software. All rights reserved.
// Licensed under the MIT License. See LICENSE in the project root for license information.

using Projects;

var builder = DistributedApplication.CreateBuilder(args);

builder.AddProject<Websocket_Bff>("bff");
builder.AddProject<Websocket_GraphQLServer>("graphql");
builder.AddNpmApp("frontend", "../Websocket.React", "dev");
builder.Build().Run();
29 changes: 29 additions & 0 deletions BFF/v4/Websocket/Websocket.AppHost/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "https://localhost:17097;http://localhost:15059",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21279",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22159"
}
},
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": true,
"applicationUrl": "http://localhost:15059",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development",
"DOTNET_ENVIRONMENT": "Development",
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19246",
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20270"
}
}
}
}
23 changes: 23 additions & 0 deletions BFF/v4/Websocket/Websocket.AppHost/Websocket.AppHost.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">

<Sdk Name="Aspire.AppHost.Sdk" Version="9.3.1" />

<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<UserSecretsId>2610c521-e7e1-4027-a362-d33ec16a22a9</UserSecretsId>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.3.1" />
<PackageReference Include="Aspire.Hosting.NodeJs" Version="9.4.2" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\Websocket.Bff\Websocket.Bff.csproj" />
<ProjectReference Include="..\Websocket.GraphQLServer\Websocket.GraphQLServer.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
9 changes: 9 additions & 0 deletions BFF/v4/Websocket/Websocket.AppHost/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning",
"Aspire.Hosting.Dcp": "Warning"
}
}
}
62 changes: 62 additions & 0 deletions BFF/v4/Websocket/Websocket.Bff/BffProgram.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
using Duende.Bff;
using Duende.Bff.AccessTokenManagement;
using Duende.Bff.Yarp;
using Microsoft.IdentityModel.JsonWebTokens;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddBff(options => options.DisableAntiForgeryCheck = (c) => true)
.ConfigureOpenIdConnect(options =>
{
options.Authority = "https://demo.duendesoftware.com";
options.ClientId = "interactive.confidential.short"; // Access tokens are valid for 1 minute 15 seconds
options.ClientSecret = "secret";
options.ResponseType = "code";
options.ResponseMode = "query";

options.GetClaimsFromUserInfoEndpoint = true;
options.MapInboundClaims = false;
options.SaveTokens = true;

options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("api");
options.Scope.Add("offline_access");

options.TokenValidationParameters = new()
{
NameClaimType = "name",
RoleClaimType = "role"
};
})
.AddRemoteApis();


var app = builder.Build();

app.UseHttpsRedirection();


app.UseAuthentication();
app.UseRouting();
app.UseBff();

// adds authorization for local and remote API endpoints
//app.UseAuthorization();
//app.MapGet("/", () => "ok");

app.UseWebSockets();

app.MapRemoteBffApiEndpoint("/graphql", new Uri("http://localhost:5095/graphql"))
.WithAccessToken(RequiredTokenType.User)
.SkipAntiforgery();

//app.MapBffManagementEndpoints();

app.MapRemoteBffApiEndpoint("/", new Uri("http://localhost:5173"))
.WithAccessToken(RequiredTokenType.None)
.SkipAntiforgery();

app.Run();

23 changes: 23 additions & 0 deletions BFF/v4/Websocket/Websocket.Bff/Properties/launchSettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"$schema": "https://json.schemastore.org/launchsettings.json",
"profiles": {
"http": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "http://localhost:5197",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
},
"https": {
"commandName": "Project",
"dotnetRunMessages": true,
"launchBrowser": false,
"applicationUrl": "https://localhost:7140;http://localhost:5197",
"environmentVariables": {
"ASPNETCORE_ENVIRONMENT": "Development"
}
}
}
}
15 changes: 15 additions & 0 deletions BFF/v4/Websocket/Websocket.Bff/Websocket.Bff.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

<ItemGroup>
<PackageReference Include="Duende.BFF" Version="4.0.0-rc.1" />
<PackageReference Include="Duende.BFF.Yarp" Version="4.0.0-rc.1" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.8" />
</ItemGroup>

</Project>
6 changes: 6 additions & 0 deletions BFF/v4/Websocket/Websocket.Bff/Websocket.Bff.http
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
@Websocket.Bff_HostAddress = http://localhost:5197

GET {{Websocket.Bff_HostAddress}}/weatherforecast/
Accept: application/json

###
8 changes: 8 additions & 0 deletions BFF/v4/Websocket/Websocket.Bff/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}
9 changes: 9 additions & 0 deletions BFF/v4/Websocket/Websocket.Bff/appsettings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}
Loading
Loading