In this lab you will build a full-stack authenticated application using Keycloak as the identity provider, .NET Aspire for orchestration, a Minimal API backend protected with JWT Bearer tokens, and a React single-page application that lets users sign in via OpenID Connect and manage TechConf events. The lab now includes a checked-in Keycloak realm import so the starter and solution both use the same reproducible realm, clients, roles, and sample users.
Scaffold Level: Level 2 (50–60 % starter code provided)
- .NET 10 SDK installed
- Docker Desktop running (required for Keycloak and PostgreSQL)
- Node.js LTS installed (required for the React frontend)
- Basic knowledge of C#, React, and authentication concepts
- Orchestrate Keycloak, PostgreSQL, and a React frontend using .NET Aspire.
- Inspect and adapt an imported Keycloak realm, clients, and roles for an SPA.
- Protect ASP.NET Core Minimal API endpoints with JWT Bearer authentication and role-based authorization.
- Integrate a React application with Keycloak using OpenID Connect (
react-oidc-context). - Pass JWT access tokens from the React frontend to the API for authenticated requests.
- Manage the full authentication lifecycle: login, logout, and token refresh.
cd exercise/techconf-frontend
npm installFrom the AppHost directory, set the password once for the postgres resource:
cd exercise/TechConf.AppHost
aspire secret set Parameters:postgres-password DevOnlyPassword123!This stores the value in the AppHost user secrets. You do not need to run dotnet user-secrets separately unless you prefer managing the same secret that way.
dotnet runOpen the Aspire Dashboard (usually at https://localhost:15888) to monitor all services.
Once Keycloak is running (check the Aspire Dashboard), the techconf realm is already imported for you.
- Open the Keycloak resource URL from the Aspire Dashboard if you want to inspect the setup.
- The admin console still uses the bootstrap admin user in the
masterrealm with the credentials shown by Aspire. - The imported lab realm already includes:
- realm:
techconf - API client:
techconf-api - SPA client:
techconf-frontend - roles:
organizer,speaker,attendee - sample users:
admin@techconf.dev/Admin123!speaker@techconf.dev/Speaker123!attendee@techconf.dev/Attendee123!
- realm:
- The imported
techconf-frontendSPA client already allows the dynamic localhost ports that Aspire/Vite use. Its redirect URIs stay scoped to localhost, and its web origins use*so Keycloak accepts the SPA's random dev port for the token exchange.
If Keycloak state looks stale, imported users cannot log in, you see
client not found,invalid redirect_uri, or a token-endpoint CORS error in the browser, or the admin password in the dashboard no longer works, delete the persisted Keycloak volume and restart the AppHost:docker volume rm lab-keycloak-react-keycloakIf you ran an earlier version of the lab or the Day 3 Keycloak demo before this fix, you may also want to remove the old shared volume once:
docker volume rm techconf-keycloak
Open TechConf.AppHost/Program.cs and add PostgreSQL with a stable password, a named data volume, and a database:
var postgresPassword = builder.AddParameter("postgres-password", secret: true);
var postgres = builder.AddPostgres("postgres", password: postgresPassword)
.WithDataVolume("lab-keycloak-react-postgres")
.AddDatabase("eventdb");Set the secret once from the AppHost directory:
aspire secret set Parameters:postgres-password DevOnlyPassword123!If you already created the lab-keycloak-react-postgres volume with an older random password and PostgreSQL now shows password authentication failed, remove that volume once and start again:
docker volume rm lab-keycloak-react-postgresIn the same file, add Keycloak with a named persistent data volume, import the checked-in realm file, and wire up the API project:
const string keycloakVolumeName = "lab-keycloak-react-keycloak";
var keycloakRealmImportPath = Path.Combine(AppContext.BaseDirectory, "Realms");
var keycloak = builder.AddKeycloak("keycloak")
.WithDataVolume(keycloakVolumeName)
.WithRealmImport(keycloakRealmImportPath);
var api = builder.AddProject<Projects.TechConf_Api>("api")
.WithReference(postgres)
.WithReference(keycloak)
.WaitFor(postgres)
.WaitFor(keycloak);The realm JSON already exists in both exercise/TechConf.AppHost/Realms/ and solution/TechConf.AppHost/Realms/; in the exercise you still need to wire it into the AppHost code.
Open TechConf.Api/Program.cs and configure authentication and authorization:
var keycloakHttpEndpoint = builder.Configuration["KEYCLOAK_HTTP"]
?? builder.Configuration["services:keycloak:http:0"]
?? throw new InvalidOperationException("Missing Keycloak HTTP endpoint configuration.");
var keycloakAuthority = $"{keycloakHttpEndpoint.TrimEnd('/')}/realms/techconf";
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = keycloakAuthority;
options.Audience = "techconf-api";
options.MapInboundClaims = false;
options.RequireHttpsMetadata = false; // Development only
options.TokenValidationParameters.RoleClaimType = "roles";
});
builder.Services.AddAuthorizationBuilder()
.AddPolicy("Organizer", p => p.RequireRole("organizer"))
.AddPolicy("Speaker", p => p.RequireRole("speaker"))
.AddPolicy("Attendee", p => p.RequireRole("attendee"));Enable the middleware:
app.UseAuthentication();
app.UseAuthorization();Open TechConf.Api/Endpoints/EventEndpoints.cs and add .RequireAuthorization("Organizer") to the POST, PUT, and DELETE endpoints. The GET endpoints should remain publicly accessible.
- Configure the OIDC provider in
techconf-frontend/src/auth/AuthProvider.tsx:
const oidcConfig = {
authority: keycloakAuthority,
client_id: clientId,
redirect_uri: window.location.origin,
post_logout_redirect_uri: window.location.origin,
scope: "openid profile email",
onSigninCallback: () => {
window.history.replaceState({}, document.title, window.location.pathname);
},
};The frontend authority is injected from Aspire via the AppHost/Keycloak reference and the Vite config, so keycloakAuthority should resolve to the actual Keycloak URL instead of relying on http://localhost:8080. Clear the callback payload from the URL after sign-in so the SPA does not keep re-processing the code and state query parameters.
-
Wrap the app with
AuthProviderinsrc/main.tsx. -
Update the Navbar in
src/components/Navbar.tsxto show login/logout buttons using theuseAuth()hook.
-
Implement
createEventanddeleteEventinsrc/api/events.ts— include the JWT token in theAuthorization: Bearer <token>header. -
Update
EventList.tsxto pass the access token when creating or deleting events, and only show the create form and delete buttons when the user is authenticated. -
Add the React frontend to the Aspire AppHost:
var webfrontend = builder.AddViteApp("webfrontend", "../techconf-frontend")
.WithReference(api)
.WithReference(keycloak)
.WaitFor(api)
.WaitFor(keycloak);
api.PublishWithContainerFiles(webfrontend, "wwwroot");- Add CORS to the API in
Program.csso the frontend can call it:
builder.Services.AddCors(options =>
{
options.AddPolicy("AllowFrontend", policy =>
policy.AllowAnyOrigin()
.AllowAnyHeader()
.AllowAnyMethod());
});
// ... after building the app:
app.UseCors("AllowFrontend");1 Role-based UI — Parse the access token claims in the React app and only show admin actions (create/delete) to users with the organizer role.
2 User profile page — Add a /profile route that displays the authenticated user's Keycloak profile information (name, email, roles).
3 Realm customization — Extend the imported realm JSON with extra roles, users, or a second frontend client for another app.
4 ** compare with Cookies — Implement a version of the frontend that uses cookie-based authentication with Keycloak's keycloak-js adapter and compare the differences in setup, token handling, and API protection.
A fully working solution is available in the solution/ directory.