A lightweight, OAuth 2.1 / OpenID Connect authorization server for .NET 11, with modern client authentication (
private_key_jwt, mTLS, DPoP), interactive authorization, pluggable persistence, and an accompanying admin console.
- Overview
- What's implemented
- Security posture
- Solution layout
- Endpoints
- Quick start
- Configuration (
IdentityServerOptions) - Registering clients & resources
- Grant types & flows
- Client authentication methods
- Persistence
- Customization (profile, signing, stores)
- Admin console
- Testing
- Known limitations
- License
EasilyNET.IdentityServer is a layered OAuth 2.1 / OIDC server built around the OAuth 2.1 draft defaults:
- authorization code with mandatory PKCE (
S256only by default) - refresh-token rotation with family replay revocation
- client credentials and the device authorization grant (RFC 8628)
- OpenID Connect discovery, JWKS,
userinfo, and RP-Initiated / Back-Channel logout - dynamic client registration (RFC 7591)
- modern client authentication:
private_key_jwt(RFC 7523), mutual TLS (RFC 8705), and DPoP (RFC 9449) - RFC 9068 JWT access tokens (
typ: at+jwt, space-delimitedscope)
The repository also ships:
- a Razor Pages admin console (EF Core + SQLite) for managing clients, resources, scopes, identity resources, persisted grants, and the interactive authorization screens (login / consent / device verification)
- unit and integration tests for protocol behavior, security rules, and core services
- a Chinese translation of the OAuth 2.1 draft under
docs/
Status: The
Hostproject is a development sample wired with in-memory stores and a deliberately simple "logged-in user" convention (see Known limitations). The protocol engine, client authentication, token service, and persistence layers are production-shaped; swap the in-memory stores for EF Core or MongoDB and provide a realIProfileService/ user-session integration for production.
| Grant | Notes |
|---|---|
authorization_code |
PKCE required; S256 only unless plain is explicitly enabled per-server and per-client |
refresh_token |
Rotation + sliding/absolute lifetime; reuse revokes the whole token family |
client_credentials |
Confidential clients only |
urn:ietf:params:oauth:grant-type:device_code |
RFC 8628 with slow_down / authorization_pending polling |
Implicit, password, and hybrid flows are not supported (per OAuth 2.1).
client_secret_basic, client_secret_post, private_key_jwt, tls_client_auth, self_signed_tls_client_auth, and none (public clients).
Discovery document, JWKS, userinfo (GET/POST), id_token with nonce and real auth_time, RP-Initiated Logout and Back-Channel Logout endpoints, and a scope→claims IProfileService abstraction.
The implementation enforces the OAuth 2.1 / OIDC security model:
- PKCE mandatory,
S256only by default; acode_verifierwithout a matchingcode_challenge(and vice-versa) is rejected. - Exact
redirect_urimatching (RFC 8252 loopback variable-port is the only exception).client_idandredirect_uriare validated before any error is redirected, so an unregistered/mismatchedredirect_uriis never used as an open-redirect target (RFC 6749 §4.1.2.1). - Refresh-token rotation with family replay revocation and absolute-lifetime caps.
- Authorization-code single use: replay revokes the access/refresh tokens already issued from that code; consumption is atomic with optimistic concurrency.
- Client secrets hashed with PBKDF2 (SHA-256, ≥100k iterations, per-secret random salt), verified in constant time; legacy SHA-256 hashes still validate for backward compatibility.
- RFC 9068 access tokens:
typ: at+jwtheader, space-delimitedscopeclaim,aud/iss/exp/iat/jti/client_id/sub. - DPoP (RFC 9449): proof
typ/htm/htu/iat/jti/athvalidation, asymmetric-alg allow-list, key-thumbprint (jkt) binding, and a per-(jkt,jti) replay cache. - mTLS (RFC 8705): certificate-bound clients via subject-DN and/or SHA-256 (
x5t#S256) thumbprint, with certificate validity-period checks; discovery advertisestls_client_certificate_bound_access_tokens. private_key_jwt(RFC 7523): audience pinned to the called endpoint,sub == client_id, requiredjti, and replay protection retained for the assertion's full lifetime.- Token introspection / revocation enforce client ownership (a client cannot introspect or revoke another client's token).
- Resource errors follow RFC 6750 (
WWW-Authenticate: Bearer/DPoP …); access tokens in the query string are rejected. - Rate limiting, audit logging, forwarded client-certificate handling, and security response headers (CSP,
X-Frame-Options: DENY,nosniff, etc.).
Apps │ Host (OAuth/OIDC server) │ Admin.UI (Razor Pages admin + interaction UI)
Core │ Token, authorization, client-auth, DPoP, mTLS, DCR, audit, rate limiting
Abstractions │ Domain models, store/service interfaces, options
Persistence │ EF Core (Sqlite / SqlServer / MySQL / PostgreSQL) │ MongoDB
| Project | Purpose |
|---|---|
src/EasilyNET.IdentityServer.Abstractions |
Domain models, store/service contracts, IdentityServerOptions |
src/EasilyNET.IdentityServer.Core |
Token issuance/validation, client auth, authorization, DPoP, mTLS, dynamic registration, auditing, rate limiting |
src/EasilyNET.IdentityServer.DataAccess.Abstractions |
Persistence contracts (IIdentityServerDbContext) |
src/EasilyNET.IdentityServer.DataAccess.EFCore |
EF Core entities, DbContext, and store implementations (IDbContextFactory-based) |
src/EasilyNET.IdentityServer.DataAccess.EFCore.{Sqlite,SqlServer,MySQL,PostgreSQL} |
Provider wiring |
src/EasilyNET.IdentityServer.DataAccess.MongoDB |
MongoDB store implementations |
src/EasilyNET.IdentityServer.Host |
The OAuth/OIDC server app with in-memory development stores |
src/EasilyNET.IdentityServer.Admin.UI |
Razor Pages admin console (clients, resources, grants, device) and interactive authorization screens |
tests/EasilyNET.IdentityServer.IntegrationTests |
End-to-end protocol tests (57) |
tests/EasilyNET.IdentityServer.Core.Tests |
Unit tests for core services (30) |
| Endpoint | Purpose |
|---|---|
GET /.well-known/openid-configuration |
Discovery metadata |
GET /.well-known/jwks |
JSON Web Key Set |
GET /connect/authorize |
Authorization endpoint |
GET /connect/authorize/context/{requestId} |
Retrieve interaction context (login / select-account / consent) |
GET /connect/authorize/interaction/page/{requestId} |
Stable same-origin entry that redirects to the UI |
POST /connect/authorize/interaction |
Continue login / account-selection / consent |
POST /connect/token |
Token endpoint (all grants) |
GET/POST /connect/userinfo |
OIDC UserInfo |
POST /connect/introspect |
Token introspection (RFC 7662) |
POST /connect/revocation |
Token revocation (RFC 7009) |
POST /connect/register |
Dynamic client registration (RFC 7591) |
POST /connect/device_authorization |
Device authorization (RFC 8628) |
POST /connect/device_verify |
Device user-code verification (requires an authenticated user) |
GET/POST /connect/logout |
RP-Initiated Logout |
POST /connect/backchannel-logout |
Back-Channel Logout (verified logout_token) |
POST /connect/verify |
Resource-server-facing access-token validation (bearer or DPoP) |
GET /health |
Health check |
- .NET 11 SDK (preview)
dotnet build EasilyNET.IdentityServer.slnx -v minimal
dotnet test EasilyNET.IdentityServer.slnx -v minimal # 30 unit + 57 integration testscd src/EasilyNET.IdentityServer.Host
dotnet runThe development profile listens on https://localhost:7020 and http://localhost:5093; the issuer is https://localhost:7020.
curl -k -X POST https://localhost:7020/connect/token \
-d grant_type=client_credentials \
-d client_id=console -d client_secret=secret \
-d scope=api1# 1. Build a PKCE pair, then open the authorize URL (the dev host accepts subject_id for the demo "logged-in" user):
GET https://localhost:7020/connect/authorize?response_type=code&client_id=spa
&redirect_uri=http://localhost:3000/callback&scope=openid%20profile%20api1
&code_challenge=<S256>&code_challenge_method=S256&subject_id=alice
# 2. Exchange the returned code:
curl -k -X POST https://localhost:7020/connect/token \
-d grant_type=authorization_code -d client_id=spa \
-d code=<code> -d redirect_uri=http://localhost:3000/callback \
-d code_verifier=<verifier>| Client ID | Type | Purpose |
|---|---|---|
console |
confidential | client credentials (secret / secret) |
mvc |
confidential | authorization code + refresh token |
spa |
public | PKCE sign-in |
interactive |
public | consent-focused interaction demo |
restricted-github |
public | identity-provider restrictions |
prompt-restricted |
public | restricted prompt values |
device |
public | device authorization grant |
Configure the server through AddIdentityServer:
builder.Services.AddIdentityServer(options =>
{
options.Issuer = "https://localhost:7020"; // must exactly match the public URL
options.AccessTokenLifetime = 3600; // seconds
options.RefreshTokenLifetime = 86400; // seconds (sliding)
options.AbsoluteRefreshTokenLifetime = 2592000; // 30 days (hard cap)
options.EnableAbsoluteRefreshTokenLifetime = true;
options.AuthorizationCodeLifetime = 300;
options.DeviceCodeLifetime = 300;
options.RequirePkce = true; // OAuth 2.1
options.AllowPlainTextPkce = false; // S256 only
options.RequireConsent = true;
// Modern client authentication
options.EnablePrivateKeyJwtClientAuthentication = true;
options.EnableMutualTlsClientAuthentication = true;
options.EnableDpop = true;
options.DpopProofLifetimeSeconds = 300;
// Dynamic client registration
options.EnableDynamicClientRegistration = true;
options.RequireInitialAccessTokenForDynamicClientRegistration = false;
options.DynamicClientRegistrationInitialAccessToken = null;
// Signing-algorithm allow-lists (asymmetric only)
options.AllowedAccessTokenSigningAlgorithms = ["RS256", "RS384", "RS512"];
options.AllowedIdentityTokenSigningAlgorithms = ["RS256", "RS384", "RS512"];
options.AllowedClientAssertionSigningAlgorithms = ["RS256", "RS384", "RS512"];
options.AllowedDpopSigningAlgorithms = ["RS256", "RS384", "RS512"];
});Security note: keep the signing-algorithm allow-lists asymmetric (RS/ES/PS). Adding
HS*would expose the JWT validators to key-confusion attacks. For production, also require an initial access token for dynamic registration (or front it with rate limiting).
Clients are Client records served by an IClientStore. Secrets are stored hashed via SecretHasher.HashSecret:
new Client
{
ClientId = "mvc",
ClientName = "MVC Client",
ClientType = ClientType.Confidential,
AllowedGrantTypes = [GrantType.AuthorizationCode, GrantType.RefreshToken],
RedirectUris = ["https://app.example.com/signin-oidc"],
PostLogoutRedirectUris = ["https://app.example.com/signout-callback-oidc"],
AllowedScopes = ["openid", "profile", "email", "api1"],
ClientSecrets = [new() { Value = SecretHasher.HashSecret("secret") }],
RequirePkce = true,
RequireClientSecret = true,
RequireConsent = false,
// private_key_jwt: set TokenEndpointAuthMethod = "private_key_jwt" + Jwks / JwksUri
// mTLS: set TokenEndpointAuthMethod = "tls_client_auth" + TlsClientAuthSubjectDn / TlsClientAuthThumbprint
// DPoP: set RequireDpopProof = true
};Resources (ApiResource, ApiScope, IdentityResource) are served by an IResourceStore. The openid identity resource is always advertised in scopes_supported.
GET /connect/authorize validates the request, then either returns an interaction contract (HTTP 401/403/409 with an interaction_required body describing whether login, account selection, or consent is needed) or, once the user is established and consent satisfied, issues a code and redirects with code, state, and the RFC 9207 iss. The token exchange enforces PKCE, redirect_uri match, and single-use semantics.
Confidential clients receive a rotated refresh token on each use; presenting a consumed token revokes the entire token family. Requested scope on refresh must be a subset of the originally granted scope. Sliding and absolute lifetimes are enforced.
Confidential clients only; scopes are validated against AllowedScopes.
POST /connect/device_authorization returns device_code, user_code, verification_uri(_complete), expires_in, and interval. The token endpoint returns authorization_pending / slow_down until the user approves at /connect/device_verify. Approval derives the subject from the authenticated user context — never from the request body.
| Method | How to configure |
|---|---|
client_secret_basic / client_secret_post |
RequireClientSecret = true + hashed ClientSecrets |
private_key_jwt |
TokenEndpointAuthMethod = "private_key_jwt" + Jwks (inline JWK set) or JwksUri |
tls_client_auth |
TokenEndpointAuthMethod = "tls_client_auth" + TlsClientAuthSubjectDn and/or TlsClientAuthThumbprint (40-hex SHA-1 or 64-hex SHA-256) |
self_signed_tls_client_auth |
TokenEndpointAuthMethod = "self_signed_tls_client_auth" + registered cert binding |
none |
public clients (ClientType.Public or RequireClientSecret = false) |
The client certificate for mTLS is read from the TLS connection or a forwarded header (X-ARR-ClientCert etc.) via the client-certificate-forwarding middleware. DPoP can be combined with any method by setting RequireDpopProof = true.
The development Host uses in-memory stores (registered as singletons). For real deployments, swap in one of the persistence packages:
// e.g. SQLite
builder.Services.AddIdentityServerSqlite("Data Source=identityserver.db");
// also available: AddIdentityServerSqlServer / AddIdentityServerMySql / AddIdentityServerPostgreSqlEF stores resolve a short-lived DbContext per operation through IDbContextFactory<IdentityServerDbContext>, so they are safe to consume from the singleton core services (no captive DbContext / cross-request thread-safety hazard). Read paths use AsNoTracking, bulk cleanup uses ExecuteDelete/ExecuteUpdate, grant consumption uses optimistic concurrency, and hot lookup columns are indexed.
builder.Services.AddIdentityServerMongoDB(connectionString, databaseName: "IdentityServer");- User claims — implement
IProfileService(GetProfileDataAsync/IsActiveAsync). The sampleInMemoryProfileServicereturns scope-filtered claims for the demo users; UserInfo and the id_token draw from this service rather than fabricating claims. - Signing keys — the default registration uses
PersistentSigningService(DB-backed, 30-day rotation, cached active key, JWKS exposure). For ephemeral dev keys, registerDefaultSigningService. - Stores — implement
IClientStore,IResourceStore,IPersistedGrantStore,IDeviceFlowStore,IUserConsentStore,ISigningKeyStore,IAuditLogStore, or use the provided EF Core / MongoDB implementations. - Account candidates — implement
IAuthorizationAccountServiceto drive theselect_accountinteraction.
EasilyNET.IdentityServer.Admin.UI is a Razor Pages app with direct EF Core (SQLite) access. On first run it creates IdentityServer.db automatically.
cd src/EasilyNET.IdentityServer.Admin.UI
dotnet run # https://localhost:49207 / http://localhost:49208It provides CRUD for clients (/Clients), API resources (/Resources), API scopes (/Scopes), identity resources (/IdentityResources), and persisted grants (/Grants, with expired-grant cleanup), plus the interactive authorization screens: login (/Account/Login, demo admin/admin), consent (/Consent), and device verification (/Device). The console is built on Razor Pages + Bootstrap 5 (no Node/JS build step); management pages require an authenticated admin session (cookie authentication).
dotnet test EasilyNET.IdentityServer.slnx -v minimal- Integration tests (57) — discovery/JWKS, client credentials, authorization code + PKCE, refresh rotation & family replay, device flow, interaction (login / select-account / consent), introspection, revocation, resource verification, dynamic registration,
private_key_jwt, mTLS, DPoP, and rate limiting. - Unit tests (30) —
SecretHasher(PBKDF2 + legacy compatibility) andRateLimitService.
The Host is a development sample, not a turnkey production deployment:
- It has no real server-side login session. The authorization and device-verification endpoints establish the "authenticated user" from the principal, then fall back to an
X-Subject-Idheader /subject_idquery parameter for the demo. Replace this with your real authentication/session integration before deploying. InMemoryProfileServicereturns sample claims; provide a realIProfileServiceover your user store.- The logout endpoints fail closed: RP-Initiated Logout only redirects to a registered
PostLogoutRedirectUrisentry, and Back-Channel Logout requires a fully verifiedlogout_token. Because the dev host has no session cookies, there is nothing to clear beyond the token grants it revokes. - In-memory replay/rate-limit caches are per-process; for horizontal scale-out, back them with a shared store (e.g. Redis) or use sticky routing.
- Dynamic client registration is open by default in the sample; enable
RequireInitialAccessTokenForDynamicClientRegistrationfor production.
MIT License. See LICENSE.