diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 1daec23..5694956 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -13,8 +13,9 @@ MkDocs](https://squidfunk.github.io/mkdocs-material/). This documentation is bot
in to the application itself.
The application reads the configuration on start up and then populates an in-memory database with users and OpenID
-connect clients. This means there is no persistence between application restarts, as the in-memory database is
-wiped and a new one is used.
+connect clients. Additionally, users and clients can be created at runtime through the web interface at `/users` and
+`/clients` respectively. Note that there is no persistence between application restarts, as the in-memory database is
+wiped and a new one is used - any users or clients created at runtime will be lost upon restart.
The frontend is styled using basic styling, using the [Sakura CSS library](https://github.com/oxalorg/sakura).
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5f346b3..5833b95 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,6 +5,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
+- Add configurable user roles through `DevOidcToolkit__Users__INDEX__Roles__INDEX`
+- Add runtime user registration at `/users` page
+- Add runtime OIDC client creation at `/clients` page
+
## [0.4.0]
- Add configurable `Issuer` field to override the `iss` claim in tokens and the OIDC discovery document
diff --git a/DevOidcToolkit.Documentation/docs/configuration.md b/DevOidcToolkit.Documentation/docs/configuration.md
index 6a8d625..d996995 100644
--- a/DevOidcToolkit.Documentation/docs/configuration.md
+++ b/DevOidcToolkit.Documentation/docs/configuration.md
@@ -1,7 +1,8 @@
# Configuration
Dev OIDC Toolkit can be configured in two ways, either through environment variables, or through a JSON configuration
-file.
+file. Additionally, users and clients can be created and managed at runtime through the web interface - see
+[Runtime Management](runtime-management.md) for details.
## Environment variable configuration
diff --git a/DevOidcToolkit.Documentation/docs/index.md b/DevOidcToolkit.Documentation/docs/index.md
index a136c0f..5a7f7e4 100644
--- a/DevOidcToolkit.Documentation/docs/index.md
+++ b/DevOidcToolkit.Documentation/docs/index.md
@@ -5,3 +5,4 @@ OpenID Connect identity provider for development and testing.
- [Tutorial](tutorial.md)
- [Configuration](configuration.md)
+- [Runtime Management](runtime-management.md)
diff --git a/DevOidcToolkit.Documentation/docs/runtime-management.md b/DevOidcToolkit.Documentation/docs/runtime-management.md
new file mode 100644
index 0000000..0a297f7
--- /dev/null
+++ b/DevOidcToolkit.Documentation/docs/runtime-management.md
@@ -0,0 +1,61 @@
+# Runtime Management
+
+In addition to configuring users and clients through configuration files or environment variables, you can also create and manage them at runtime through the web interface.
+
+## Managing Users
+
+Navigate to `/users` to access the user management interface.
+
+### Creating Users
+
+1. Fill in the following fields:
+ - **Email**: The email address of the user
+ - **First Name**: The user's first name
+ - **Last Name**: The user's last name
+ - **Roles** (Optional): Comma-separated list of roles to assign to the user
+
+2. Click "Create User"
+
+Users created at runtime are immediately available for login. You can select them from the login dropdown to authenticate.
+
+### Assigning Roles
+
+You can assign one or more roles to a user:
+
+- **Select existing roles**: Enter role names that already exist in the system
+- **Create new roles**: Enter new role names that will be created automatically if they don't exist
+- **Multiple roles**: Separate multiple role names with commas, e.g., `admin, moderator, viewer`
+
+Roles are included in the OIDC tokens issued for users, allowing applications to check user permissions.
+
+## Managing Clients
+
+Navigate to `/clients` to access the client (OIDC application) management interface.
+
+### Creating Clients
+
+1. Fill in the following fields:
+ - **Client ID**: A unique identifier for the client
+ - **Client Secret**: A secret string shared between the client and the identity provider
+ - **Redirect URIs** (Optional): Comma-separated list of URIs where the user will be redirected after authentication
+ - **Post-Logout Redirect URIs** (Optional): Comma-separated list of URIs where the user will be redirected after logout
+
+2. Click "Create Client"
+
+Newly created clients are immediately available and can be used for OpenID Connect flows.
+
+### Configuring Redirect URIs
+
+Both redirect URIs and post-logout redirect URIs should be valid, complete URLs:
+
+```
+http://localhost:3000/callback, https://example.com/oauth/callback
+```
+
+URIs are validated on submission to ensure they are properly formatted.
+
+## Important Notes
+
+- **No Persistence**: Users and clients created at runtime exist only in the in-memory database. They will be lost when the application restarts.
+- **Configuration + Runtime**: You can use both configuration-based users/clients and runtime-created ones simultaneously.
+- **Role Management**: Roles created at runtime persist for the lifetime of the application and can be assigned to multiple users.
diff --git a/DevOidcToolkit/Pages/Clients.cshtml b/DevOidcToolkit/Pages/Clients.cshtml
index f741c0b..7397a3a 100644
--- a/DevOidcToolkit/Pages/Clients.cshtml
+++ b/DevOidcToolkit/Pages/Clients.cshtml
@@ -1,27 +1,62 @@
@page "/clients"
-@using OpenIddict.Abstractions
-@using OpenIddict.Core
-@using System.Linq
-@using Microsoft.EntityFrameworkCore
-@using OpenIddict.EntityFrameworkCore.Models
@using System.Text.Json
-@inject DevOidcToolkitContext DbContext
+@model DevOidcToolkit.Pages.ClientsModel
@{
Layout = "_Layout";
- ViewData["Title"] = "clients";
+ ViewData["Title"] = "Clients";
}
Return to homepage
Clients
+Create New Client
+
+@if (!string.IsNullOrEmpty(Model.SuccessMessage))
+{
+
+ @Model.SuccessMessage
+
+}
+
+@if (!string.IsNullOrEmpty(Model.ErrorMessage))
+{
+
+ @Model.ErrorMessage
+
+}
+
+
+
+
+
+Existing Clients
+
@{
- var clients = await DbContext.Set().ToListAsync();
+ var clients = Model.Clients ?? [];
}
@if (clients?.Any() == true)
{
-
+
@foreach (var client in clients)
{
-
diff --git a/DevOidcToolkit/Pages/Clients.cshtml.cs b/DevOidcToolkit/Pages/Clients.cshtml.cs
new file mode 100644
index 0000000..fdeb72c
--- /dev/null
+++ b/DevOidcToolkit/Pages/Clients.cshtml.cs
@@ -0,0 +1,138 @@
+using DevOidcToolkit.Infrastructure.Database;
+
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.EntityFrameworkCore;
+
+using OpenIddict.Abstractions;
+using OpenIddict.Core;
+using OpenIddict.EntityFrameworkCore.Models;
+
+namespace DevOidcToolkit.Pages;
+
+public class ClientsModel : PageModel
+{
+ private readonly IOpenIddictApplicationManager _applicationManager;
+ private readonly DevOidcToolkitContext _context;
+
+ public ClientsModel(IOpenIddictApplicationManager applicationManager, DevOidcToolkitContext context)
+ {
+ _applicationManager = applicationManager;
+ _context = context;
+ }
+
+ public List Clients { get; set; } = [];
+ public string? SuccessMessage { get; set; }
+ public string? ErrorMessage { get; set; }
+
+ [BindProperty]
+ public InputModel? Input { get; set; }
+
+ public class InputModel
+ {
+ public string ClientId { get; set; } = "";
+ public string ClientSecret { get; set; } = "";
+ public string RedirectUris { get; set; } = "";
+ public string PostLogoutRedirectUris { get; set; } = "";
+ }
+
+ public async Task OnGetAsync()
+ {
+ Clients = await _context.Set().ToListAsync();
+ }
+
+ public async Task OnPostAsync()
+ {
+ if (!ModelState.IsValid || Input == null)
+ {
+ Clients = await _context.Set().ToListAsync();
+ return Page();
+ }
+
+ if (string.IsNullOrWhiteSpace(Input.ClientId))
+ {
+ ModelState.AddModelError("Input.ClientId", "Client ID is required");
+ Clients = await _context.Set().ToListAsync();
+ return Page();
+ }
+
+ if (string.IsNullOrWhiteSpace(Input.ClientSecret))
+ {
+ ModelState.AddModelError("Input.ClientSecret", "Client Secret is required");
+ Clients = await _context.Set().ToListAsync();
+ return Page();
+ }
+
+ // Check if client already exists
+ var existingClient = await _applicationManager.FindByClientIdAsync(Input.ClientId);
+ if (existingClient != null)
+ {
+ ErrorMessage = $"Client with ID '{Input.ClientId}' already exists";
+ Clients = await _context.Set().ToListAsync();
+ return Page();
+ }
+
+ try
+ {
+ var clientApp = new OpenIddictApplicationDescriptor()
+ {
+ ClientId = Input.ClientId,
+ ClientSecret = Input.ClientSecret,
+ ConsentType = OpenIddictConstants.ConsentTypes.Explicit,
+ Permissions =
+ {
+ OpenIddictConstants.Permissions.Endpoints.Authorization,
+ OpenIddictConstants.Permissions.Endpoints.Token,
+ OpenIddictConstants.Permissions.Endpoints.EndSession,
+ OpenIddictConstants.Permissions.GrantTypes.AuthorizationCode,
+ OpenIddictConstants.Permissions.ResponseTypes.Code,
+ OpenIddictConstants.Permissions.Scopes.Profile,
+ OpenIddictConstants.Permissions.Scopes.Email
+ }
+ };
+
+ if (!string.IsNullOrWhiteSpace(Input.RedirectUris))
+ {
+ var redirectUris = Input.RedirectUris.Split(',').Select(uri => uri.Trim()).Where(uri => !string.IsNullOrWhiteSpace(uri));
+ foreach (var uri in redirectUris)
+ {
+ try
+ {
+ clientApp.RedirectUris.Add(new Uri(uri));
+ }
+ catch (UriFormatException)
+ {
+ throw new InvalidOperationException($"Invalid redirect URI: {uri}");
+ }
+ }
+ }
+
+ if (!string.IsNullOrWhiteSpace(Input.PostLogoutRedirectUris))
+ {
+ var postLogoutUris = Input.PostLogoutRedirectUris.Split(',').Select(uri => uri.Trim()).Where(uri => !string.IsNullOrWhiteSpace(uri));
+ foreach (var uri in postLogoutUris)
+ {
+ try
+ {
+ clientApp.PostLogoutRedirectUris.Add(new Uri(uri));
+ }
+ catch (UriFormatException)
+ {
+ throw new InvalidOperationException($"Invalid post-logout redirect URI: {uri}");
+ }
+ }
+ }
+
+ await _applicationManager.CreateAsync(clientApp);
+ SuccessMessage = $"Client '{Input.ClientId}' created successfully";
+ Input = new InputModel();
+ }
+ catch (Exception ex)
+ {
+ ErrorMessage = $"Failed to create client: {ex.Message}";
+ }
+
+ Clients = await _context.Set().ToListAsync();
+ return Page();
+ }
+}
\ No newline at end of file
diff --git a/DevOidcToolkit/Pages/Users.cshtml b/DevOidcToolkit/Pages/Users.cshtml
index fb40f60..d922fee 100644
--- a/DevOidcToolkit/Pages/Users.cshtml
+++ b/DevOidcToolkit/Pages/Users.cshtml
@@ -2,6 +2,7 @@
@using Microsoft.AspNetCore.Identity
@using Microsoft.EntityFrameworkCore
@using System.Text.Json
+@model DevOidcToolkit.Pages.UsersModel
@inject UserManager UserManager
@{
Layout = "_Layout";
@@ -12,8 +13,46 @@
Users
+Register New User
+
+@if (!string.IsNullOrEmpty(Model.SuccessMessage))
+{
+
+ @Model.SuccessMessage
+
+}
+
+@if (!string.IsNullOrEmpty(Model.ErrorMessage))
+{
+
+ @Model.ErrorMessage
+
+}
+
+
+
+
+
@{
- var users = await UserManager.Users.ToListAsync();
+ var users = Model.Users ?? [];
}
@if (users?.Any() == true)
diff --git a/DevOidcToolkit/Pages/Users.cshtml.cs b/DevOidcToolkit/Pages/Users.cshtml.cs
new file mode 100644
index 0000000..b16d928
--- /dev/null
+++ b/DevOidcToolkit/Pages/Users.cshtml.cs
@@ -0,0 +1,138 @@
+using DevOidcToolkit.Infrastructure.Database;
+
+using Microsoft.AspNetCore.Identity;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.AspNetCore.Mvc.RazorPages;
+using Microsoft.EntityFrameworkCore;
+
+namespace DevOidcToolkit.Pages;
+
+public class UsersModel : PageModel
+{
+ private readonly UserManager _userManager;
+ private readonly RoleManager _roleManager;
+
+ public UsersModel(UserManager userManager, RoleManager roleManager)
+ {
+ _userManager = userManager;
+ _roleManager = roleManager;
+ }
+
+ public List Users { get; set; } = [];
+ public string? SuccessMessage { get; set; }
+ public string? ErrorMessage { get; set; }
+
+ [BindProperty]
+ public InputModel? Input { get; set; }
+
+ public class InputModel
+ {
+ public string Email { get; set; } = "";
+ public string FirstName { get; set; } = "";
+ public string LastName { get; set; } = "";
+ public string? Roles { get; set; }
+ }
+
+ public async Task OnGetAsync()
+ {
+ Users = await _userManager.Users.ToListAsync();
+ }
+
+ public async Task OnPostAsync()
+ {
+ if (!ModelState.IsValid || Input == null)
+ {
+ Users = await _userManager.Users.ToListAsync();
+ return Page();
+ }
+
+ if (string.IsNullOrWhiteSpace(Input.Email))
+ {
+ ModelState.AddModelError("Input.Email", "Email is required");
+ Users = await _userManager.Users.ToListAsync();
+ return Page();
+ }
+
+ if (string.IsNullOrWhiteSpace(Input.FirstName))
+ {
+ ModelState.AddModelError("Input.FirstName", "First name is required");
+ Users = await _userManager.Users.ToListAsync();
+ return Page();
+ }
+
+ if (string.IsNullOrWhiteSpace(Input.LastName))
+ {
+ ModelState.AddModelError("Input.LastName", "Last name is required");
+ Users = await _userManager.Users.ToListAsync();
+ return Page();
+ }
+
+ var user = new DevOidcToolkitUser
+ {
+ Email = Input.Email,
+ UserName = Input.Email,
+ FirstName = Input.FirstName,
+ LastName = Input.LastName,
+ EmailConfirmed = true,
+ };
+
+ var result = await _userManager.CreateAsync(user);
+
+ if (result.Succeeded)
+ {
+ var rolesToAssign = new List();
+
+ if (!string.IsNullOrWhiteSpace(Input.Roles))
+ {
+ rolesToAssign = Input.Roles.Split(',').Select(r => r.Trim()).Where(r => !string.IsNullOrWhiteSpace(r)).ToList();
+ }
+
+ if (rolesToAssign.Any())
+ {
+ var failedRoles = new List();
+
+ foreach (var roleToAssign in rolesToAssign)
+ {
+ // Create role if it doesn't exist
+ if (!await _roleManager.RoleExistsAsync(roleToAssign))
+ {
+ var createRoleResult = await _roleManager.CreateAsync(new IdentityRole(roleToAssign));
+ if (!createRoleResult.Succeeded)
+ {
+ failedRoles.Add($"{roleToAssign} (creation failed)");
+ continue;
+ }
+ }
+
+ // Assign role to user
+ var roleResult = await _userManager.AddToRoleAsync(user, roleToAssign);
+ if (!roleResult.Succeeded)
+ {
+ failedRoles.Add($"{roleToAssign} (assignment failed)");
+ }
+ }
+
+ if (failedRoles.Any())
+ {
+ ErrorMessage = $"User created but failed with roles: {string.Join(", ", failedRoles)}";
+ }
+ else
+ {
+ SuccessMessage = $"User {Input.Email} created successfully with roles: {string.Join(", ", rolesToAssign)}";
+ }
+ }
+ else
+ {
+ SuccessMessage = $"User {Input.Email} created successfully";
+ }
+ Input = new InputModel();
+ }
+ else
+ {
+ ErrorMessage = string.Join(", ", result.Errors.Select(e => e.Description));
+ }
+
+ Users = await _userManager.Users.ToListAsync();
+ return Page();
+ }
+}
\ No newline at end of file
diff --git a/README.md b/README.md
index 0d3b47f..eaf93b3 100644
--- a/README.md
+++ b/README.md
@@ -31,8 +31,10 @@ identity provider such as Keycloak as part of your testing pipeline.
- Simple OpenID Connect identity provider
- Support for client credentials grant type
- Support for authorization code grant type
-- Create users through configuration
-- Create OpenID Connect clients through configuration
+- Create users through configuration or at runtime
+- Create OpenID Connect clients through configuration or at runtime
+- Assign multiple roles to users
+- Create new roles dynamically at runtime
- List configured users
- List configured clients
- Different levels of logging to help with debugging