Skip to content
Merged
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
5 changes: 3 additions & 2 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 2 additions & 1 deletion DevOidcToolkit.Documentation/docs/configuration.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
1 change: 1 addition & 0 deletions DevOidcToolkit.Documentation/docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ OpenID Connect identity provider for development and testing.

- [Tutorial](tutorial.md)
- [Configuration](configuration.md)
- [Runtime Management](runtime-management.md)
61 changes: 61 additions & 0 deletions DevOidcToolkit.Documentation/docs/runtime-management.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 44 additions & 9 deletions DevOidcToolkit/Pages/Clients.cshtml
Original file line number Diff line number Diff line change
@@ -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";
}

<a href="/">Return to homepage</a>

<h1>Clients</h1>

<h2>Create New Client</h2>

@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div>
@Model.SuccessMessage
</div>
}

@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div>
@Model.ErrorMessage
</div>
}

<form method="post">
<label for="clientId">Client ID</label>
<input type="text" id="clientId" name="Input.ClientId" required />
<span asp-validation-for="Input.ClientId"></span>

<label for="clientSecret">Client Secret</label>
<input type="text" id="clientSecret" name="Input.ClientSecret" required />
<span asp-validation-for="Input.ClientSecret"></span>

<label for="redirectUris">Redirect URIs (comma-separated)</label>
<input type="text" id="redirectUris" name="Input.RedirectUris" placeholder="e.g., http://localhost:3000/callback, https://example.com/callback" />
<span asp-validation-for="Input.RedirectUris"></span>

<label for="postLogoutRedirectUris">Post-Logout Redirect URIs (comma-separated)</label>
<input type="text" id="postLogoutRedirectUris" name="Input.PostLogoutRedirectUris" placeholder="e.g., http://localhost:3000, https://example.com" />
<span asp-validation-for="Input.PostLogoutRedirectUris"></span>

<button type="submit">Create Client</button>
</form>

<hr />

<h2>Existing Clients</h2>

@{
var clients = await DbContext.Set<OpenIddictEntityFrameworkCoreApplication>().ToListAsync();
var clients = Model.Clients ?? [];
}

@if (clients?.Any() == true)
{
<ul class="client-list">
<ul>
@foreach (var client in clients)
{
<li style="list-style: none;">
Expand Down
138 changes: 138 additions & 0 deletions DevOidcToolkit/Pages/Clients.cshtml.cs
Original file line number Diff line number Diff line change
@@ -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<OpenIddictEntityFrameworkCoreApplication> 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<OpenIddictEntityFrameworkCoreApplication>().ToListAsync();
}

public async Task<IActionResult> OnPostAsync()
{
if (!ModelState.IsValid || Input == null)
{
Clients = await _context.Set<OpenIddictEntityFrameworkCoreApplication>().ToListAsync();
return Page();
}

if (string.IsNullOrWhiteSpace(Input.ClientId))
{
ModelState.AddModelError("Input.ClientId", "Client ID is required");
Clients = await _context.Set<OpenIddictEntityFrameworkCoreApplication>().ToListAsync();
return Page();
}

if (string.IsNullOrWhiteSpace(Input.ClientSecret))
{
ModelState.AddModelError("Input.ClientSecret", "Client Secret is required");
Clients = await _context.Set<OpenIddictEntityFrameworkCoreApplication>().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<OpenIddictEntityFrameworkCoreApplication>().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<OpenIddictEntityFrameworkCoreApplication>().ToListAsync();
return Page();
}
}
41 changes: 40 additions & 1 deletion DevOidcToolkit/Pages/Users.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
@using Microsoft.AspNetCore.Identity
@using Microsoft.EntityFrameworkCore
@using System.Text.Json
@model DevOidcToolkit.Pages.UsersModel
@inject UserManager<DevOidcToolkitUser> UserManager
@{
Layout = "_Layout";
Expand All @@ -12,8 +13,46 @@

<h1>Users</h1>

<h2>Register New User</h2>

@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div>
@Model.SuccessMessage
</div>
}

@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div>
@Model.ErrorMessage
</div>
}

<form method="post">
<label for="email">Email</label>
<input type="email" id="email" name="Input.Email" required />
<span asp-validation-for="Input.Email"></span>

<label for="firstName">First Name</label>
<input type="text" id="firstName" name="Input.FirstName" required />
<span asp-validation-for="Input.FirstName"></span>

<label for="lastName">Last Name</label>
<input type="text" id="lastName" name="Input.LastName" required />
<span asp-validation-for="Input.LastName"></span>

<label for="roles">Roles (Optional, comma-separated)</label>
<input type="text" id="roles" name="Input.Roles" placeholder="e.g., admin, moderator, viewer" />
<span asp-validation-for="Input.Roles"></span>

<button type="submit">Create User</button>
</form>

<hr />

@{
var users = await UserManager.Users.ToListAsync();
var users = Model.Users ?? [];
}

@if (users?.Any() == true)
Expand Down
Loading