This guide documents the CoreIdent codebase. It is intended to be the “one stop” developer reference for:
- Getting a host app running quickly
- Understanding how CoreIdent is structured
- Configuring keys, routes, clients, and scopes
- Using the OAuth/OIDC endpoints CoreIdent currently implements
- Persisting CoreIdent state with EF Core
- Writing unit/integration tests using the provided test infrastructure
- Run a minimal CoreIdent host and exercise key endpoints
- Configure signing keys, routes, clients, and scopes
- Enable persistence with EF Core
- Understand the implemented endpoint surface and how to map it
If you are looking for the high-level roadmap and design intent, start with:
docs/Project_Overview.mddocs/Technical_Plan.mddocs/DEVPLAN.md- Realms (future work): Realms — Draft realm-ready foundation for future multi-tenant / multi-issuer / per-realm keys+stores scenarios.
docs/Passkeys.mddocs/Aspire_Integration.md
CoreIdent currently provides a minimal, modular OAuth/OIDC foundation on .NET 10:
- JWT token issuance (
/auth/token)client_credentialsrefresh_tokenauthorization_code(with PKCE)password(implemented but deprecated and logs a warning)
- Token revocation (
/auth/revoke, RFC 7009) - Token introspection (
/auth/introspect, RFC 7662) - Discovery document (OIDC metadata)
- JWKS publishing (public keys only)
- Authorization endpoint (
/auth/authorize) + minimal consent UI (/auth/consent) for authorization code flow - Resource-owner convenience endpoints (
/auth/register,/auth/login,/auth/profile) for simple “first party app” workflows - Pluggable stores with in-memory defaults + EF Core implementations in
CoreIdent.Storage.EntityFrameworkCore - A testing package (
tests/CoreIdent.Testing) with fixtures and builders for integration tests
CoreIdent is not trying to be “everything at once” yet. The focus is secure defaults (asymmetric signing by default) and testable primitives.
CoreIdent targets full compliance with OAuth 2.1 (RFC 9725), which consolidates and strengthens OAuth 2.0 best practices. This means:
- PKCE required — All authorization code flows must include
code_challengeandcode_challenge_method=S256. This applies to both public and confidential clients. - No implicit grant — The
response_type=tokenflow is not supported. Use authorization code + PKCE instead. - No hybrid flow — OIDC hybrid response types (
code token,code id_token token) are not supported. - Exact redirect URI matching — Redirect URIs must exactly match a registered URI. No wildcard or partial matching.
- Refresh token rotation — Refresh tokens are single-use with automatic rotation and theft detection via family tracking.
- All clients (including server-side/confidential) must implement PKCE
- Use
response_type=codeexclusively - Register exact redirect URIs (no wildcards, no path prefixes)
- Handle refresh token rotation (each refresh returns a new token)
- JavaScript/SPA clients should use the authorization code flow with PKCE, not implicit
If migrating from a system that uses implicit flow or ROPC (password grant):
- Implicit flow → Use authorization code + PKCE. See the JS/TS Client Compatibility section.
- ROPC / Password grant → Use authorization code + PKCE, or install
CoreIdent.Legacy.PasswordGrantfor migration support. ROPC is deprecated in OAuth 2.1.
CoreIdent is designed so third parties can build “flavors” (membership systems, admin surfaces, hosted identity offerings, etc.) without forking CoreIdent.Core.
The primary rule is: prefer replacing behavior via DI + interfaces rather than editing CoreIdent code.
CoreIdent’s persistence is expressed as store interfaces. A third party can provide:
IUserStore(users + claim storage)IClientStore(OAuth clients)IScopeStore(scopes used by discovery + token issuance)IAuthorizationCodeStore(authorization codes)IRefreshTokenStore(refresh tokens, rotation, family revocation)IUserGrantStore(consent grants)ITokenRevocationStore(revoked access token tracking)IPasswordlessTokenStore(passwordless token/OTP storage)
Notes:
IScopeStoreis currently a read-only interface. If you need scope administration, own a write model (DB tables + admin API) and expose a read projection viaIScopeStore.- Store defaults are registered with
TryAdd*patterns, so registering your implementation before callingAddCoreIdent()takes precedence.
There are two main “claims seams”:
IUserStore.GetClaimsAsync(subjectId, ...)for user-owned claims (roles/groups/flags owned by your membership layer)ICustomClaimsProviderfor computed/derived claims added during token issuance
This enables membership and enterprise layers to inject authorization state into access tokens and ID tokens without modifying CoreIdent endpoints.
Token signing and JWKS publishing use ISigningKeyProvider.
Third parties can integrate external key systems by implementing ISigningKeyProvider (for example, loading from an HSM, Key Vault, KMS, or a per-tenant certificate store) and registering it via AddSigningKey(...).
For the resource-owner convenience endpoints, you can override response behavior via CoreIdentResourceOwnerOptions:
RegisterHandlerLoginHandlerProfileHandler
You can also avoid MapCoreIdentEndpoints() and map only the endpoints you want using the granular mapping extensions.
For production deployments, third parties commonly replace the default delivery providers:
- Implement
IEmailSenderto use an email API provider - Implement
ISmsProviderto use an SMS provider
Passwordless storage is similarly replaceable via IPasswordlessTokenStore.
If you already have a user database and credential validation, you can avoid forking by using CoreIdent.Adapters.DelegatedUserStore to delegate:
- user lookup (
FindUserByIdAsync,FindUserByUsernameAsync) - credential validation
- optional
GetClaimsAsyncfor membership/roles
This section is a placeholder for DEVPLAN 1.13.6.
- Embedded auth ("I just need auth in my app"): Use the resource-owner convenience endpoints (
/auth/register,/auth/login,/auth/profile) for first-party app workflows. - Membership/admin: For richer membership (profile fields, roles/groups, admin UI), prefer building on CoreIdent by plugging in your own stores and adding claims/profile behavior at the host layer.
Key extension points to expand on:
IUserStore(replace user persistence)ICustomClaimsProvider(add claims to tokens)CoreIdentResourceOwnerOptionshandlers (RegisterHandler,LoginHandler,ProfileHandler)- Custom endpoints in the host app (e.g.,
GET /me) keyed bysub
CoreIdent.Client is a lightweight OAuth 2.0 / OpenID Connect client library intended to work against:
- CoreIdent hosts
- Third-party OIDC providers (e.g. Keycloak)
It implements the Authorization Code + PKCE flow, refresh token handling, discovery document fetching, and (when available) UserInfo.
Create a client instance:
- Set
Authorityto the provider base URL - Set
ClientId - Use a loopback redirect URI for interactive flows (recommended)
CoreIdent.Client persists tokens via ISecureTokenStorage (in-memory by default).
Key CoreIdentClientOptions fields:
AuthorityClientIdClientSecret(optional; if set, client auth uses HTTP Basic)RedirectUriPostLogoutRedirectUriScopesTokenRefreshThreshold
Implementations included:
InMemoryTokenStorage(default)FileTokenStorage(encrypted using ASP.NET Core Data Protection)
If you want tokens to persist across process restarts, provide a configured IDataProtectionProvider to FileTokenStorage.
For interactive login, CoreIdent.Client uses an IBrowserLauncher abstraction.
The default implementation (SystemBrowserLauncher) requires:
RedirectUriuseshttporhttpsRedirectUrihost is loopback (localhost,127.0.0.1,::1)
If your app uses a non-loopback redirect URI (mobile deep link, custom scheme, embedded web view, etc.), provide a custom IBrowserLauncher.
For .NET MAUI apps, use CoreIdent.Client.Maui. It provides:
MauiSecureTokenStoragebacked bySecureStorageMauiBrowserLauncherbacked byWebAuthenticatorUseCoreIdentClient()forMauiAppBuilder
See the full MAUI setup and sample app walkthrough in docs/MAUI_Client_Guide.md.
CoreIdent.Client uses OIDC discovery (.well-known/openid-configuration) and follows the discovered endpoints.
Authorities with path bases are supported. Example:
- Keycloak realm authority:
https://kc.example/realms/myrealm
In this case discovery is fetched relative to that authority:
https://kc.example/realms/myrealm/.well-known/openid-configuration
When the discovery document includes jwks_uri, the client validates ID tokens using:
- Signature verification via JWKS
- Issuer validation (
iss) - Audience validation (
audcontainsClientId) - Lifetime validation (
exp)
For interactive login, the client additionally validates the ID token nonce.
UseDPoPenables client-side DPoP proof generation and sends DPoP headers on supported requests. Server-side DPoP validation/binding depends on the authorization server (CoreIdent server support is planned under Feature 3.6).- Front-channel logout is attempted only if the provider advertises
end_session_endpoint.
At a high level:
src/CoreIdent.Core/- Core models, options, store interfaces, in-memory stores, services, endpoint mapping extensions
src/CoreIdent.Storage.EntityFrameworkCore/- EF Core
DbContext, entity models, and EF Core implementations of stores
- EF Core
src/CoreIdent.Adapters.DelegatedUserStore/- Adapter package for plugging in an external user store (if used)
tests/CoreIdent.Core.Tests/- Unit + integration tests for the core package
tests/CoreIdent.Testing/- Shared test infrastructure:
WebApplicationFactory, fixture base class, builders, assertion extensions
- Shared test infrastructure:
tests/CoreIdent.TestHost/- A runnable host used by some tests and manual validation
src/CoreIdent.Cli/- CLI tool package (
dotnet coreident) for project scaffolding, key generation, and database migrations
- CLI tool package (
CoreIdent ships a .NET global tool for common development tasks:
dotnet tool install -g CoreIdent.CliCommands:
dotnet coreident init— Scaffoldappsettings.jsonand add package referencesdotnet coreident keys generate <rsa|ecdsa>— Generate signing key pairsdotnet coreident client add— Interactive client registration helperdotnet coreident migrate— Apply database schema (SQLite, SQL Server, PostgreSQL)
See docs/CLI_Reference.md for full documentation.
- .NET 10 SDK (required)
- Recommended:
- SQLite tooling (optional; used by tests/fixtures)
- OpenSSL (optional; useful for key generation)
This is the smallest “real” CoreIdent host app setup:
- configure issuer/audience
- configure a signing key (RSA or ECDSA for production)
- map CoreIdent endpoints
Key points:
AddCoreIdent()registers all core services and in-memory stores by default.AddSigningKey(...)registers anISigningKeyProviderused by token services and JWKS.MapCoreIdentEndpoints()maps all CoreIdent endpoints usingCoreIdentRouteOptions.
using CoreIdent.Core.Extensions;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddCoreIdent(o =>
{
o.Issuer = "https://issuer.example";
o.Audience = "https://resource.example";
});
// Production: prefer RSA/ECDSA.
// Example: load RSA from PEM file
builder.Services.AddSigningKey(o => o.UseRsa("/path/to/private-key.pem"));
var app = builder.Build();
app.MapCoreIdentEndpoints();
app.Run();CoreIdent supports OpenAPI document generation via the CoreIdent.OpenApi package (built on .NET 10's Microsoft.AspNetCore.OpenApi).
CoreIdent does not ship a built-in UI (no Swashbuckle / Swagger UI). Instead, the host app can optionally add a UI such as Scalar.
Add the package reference:
<PackageReference Include="CoreIdent.OpenApi" Version="1.0.0" />Register OpenAPI services:
using CoreIdent.OpenApi.Extensions;
builder.Services.AddCoreIdentOpenApi(options =>
{
options.DocumentTitle = "My API";
options.DocumentVersion = "v1";
options.OpenApiRoute = "/openapi/v1.json";
});using CoreIdent.OpenApi.Extensions;
app.MapCoreIdentOpenApi();This maps an endpoint equivalent to MapOpenApi(...) so that GET /openapi/v1.json returns the OpenAPI JSON document.
To serve an interactive API reference UI, install Scalar.AspNetCore in your host app and map it alongside the OpenAPI JSON:
using Scalar.AspNetCore;
app.MapCoreIdentOpenApi();
app.MapScalarApiReference();With defaults, CoreIdent maps:
- OpenID Connect discovery document:
GET <issuerPath>/.well-known/openid-configuration - JWKS:
GET <issuerPath>/.well-known/jwks.json - Authorization endpoint:
GET /auth/authorize - Consent UI:
GET/POST /auth/consent - Token endpoint:
POST /auth/token - Token management:
POST /auth/revokePOST /auth/introspect
- Resource owner endpoints:
GET/POST /auth/registerGET/POST /auth/loginGET /auth/profile
- Passwordless email magic link:
POST /auth/passwordless/email/startGET /auth/passwordless/email/verify
- Passwordless SMS OTP:
POST /auth/passwordless/sms/startPOST /auth/passwordless/sms/verify
- Passkeys (WebAuthn): see
docs/Passkeys.md(mapped viaapp.MapCoreIdentPasskeyEndpoints())
Note: discovery and JWKS are computed based on the
IssuerURL’s path (see “Routing” below). They are not hardcoded to root.
A runnable host exists at tests/CoreIdent.TestHost/Program.cs. It configures:
AddCoreIdent(...)AddSigningKey(o => o.UseSymmetric(...))(development/testing only)- EF Core
CoreIdentDbContext(SQLite) AddEntityFrameworkCoreStores()to use EF-backed stores- ASP.NET authentication with a test header auth handler
This is a convenient way to manually poke endpoints.
CoreIdent ships a template pack (CoreIdent.Templates) that contains starter host projects.
Install the templates:
dotnet new install CoreIdent.TemplatesAvailable templates:
coreident-api(C#)- Minimal host for CoreIdent endpoints.
- Parameters:
--useEfCore <true|false>(default:true)--usePasswordless <true|false>(default:true)
coreident-server(C#)- Full OAuth/OIDC server host (EF Core stores) with optional passkey endpoints.
- Parameters:
--usePasskeys <true|false>(default:true)--usePasswordless <true|false>(default:true)
coreident-api-fsharp(F#)- Minimal host for CoreIdent endpoints.
- Parameters:
--useEfCore <true|false>(default:true)--usePasswordless <true|false>(default:true)
Examples:
dotnet new coreident-api -n MyCoreIdentApi
dotnet new coreident-api -n MyCoreIdentApiNoEf --useEfCore false
dotnet new coreident-server -n MyCoreIdentServer
dotnet new coreident-server -n MyCoreIdentServerNoPasskeys --usePasskeys false
dotnet new coreident-api-fsharp -n MyCoreIdentApiFSharp
dotnet new coreident-api-fsharp -n MyCoreIdentApiFSharpNoEf --useEfCore falseEach template includes a sample appsettings.json with required CoreIdent configuration (issuer, audience, dev signing key) and, when applicable, a SQLite connection string.
Cross-Origin Resource Sharing (CORS) must be configured when JavaScript clients (SPAs, Blazor WASM) access CoreIdent endpoints from a different origin.
CoreIdent provides a convenience method that auto-configures CORS from registered client redirect URIs:
builder.Services.AddCoreIdentCors(); // Extracts origins from client redirect URIs
var app = builder.Build();
app.UseCoreIdentCors(); // Must be before MapCoreIdentEndpoints()
app.MapCoreIdentEndpoints();For full control, use ASP.NET Core's built-in CORS:
builder.Services.AddCors(options =>
{
options.AddPolicy("CoreIdentPolicy", policy =>
{
policy.WithOrigins("https://myapp.example.com")
.AllowAnyHeader()
.WithMethods("GET", "POST")
.AllowCredentials();
});
});
app.UseCors("CoreIdentPolicy");| Endpoint | When CORS is needed |
|---|---|
POST /auth/token |
Always (token requests from browser) |
GET /auth/userinfo |
Always (user info from SPA) |
POST /auth/revoke |
When revoking from browser |
GET /.well-known/openid-configuration |
Discovery from browser |
GET /.well-known/jwks.json |
Key fetching from browser |
Note: The authorization endpoint (
/auth/authorize) uses redirects, not CORS. CORS is not needed for redirect-based flows.
CoreIdent is a standard-compliant OAuth 2.1 / OpenID Connect server. Any OIDC-compliant JavaScript client library works with it. We recommend oidc-client-ts for most use cases.
import { UserManager } from 'oidc-client-ts';
const userManager = new UserManager({
authority: 'https://your-coreident-server.com',
client_id: 'your-spa-client-id',
redirect_uri: 'https://your-app.com/callback',
post_logout_redirect_uri: 'https://your-app.com/',
response_type: 'code',
scope: 'openid profile email',
automaticSilentRenew: true,
});
// Login
await userManager.signinRedirect();
// Handle callback
const user = await userManager.signinRedirectCallback();
console.log('Logged in:', user.profile.email);
// Get access token (auto-refreshed)
const user = await userManager.getUser();
const token = user?.access_token;
// Logout
await userManager.signoutRedirect();// Generate PKCE challenge
async function generatePKCE() {
const verifier = crypto.randomUUID() + crypto.randomUUID();
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
const challenge = btoa(String.fromCharCode(...new Uint8Array(hash)))
.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
return { verifier, challenge };
}
// Start login
const { verifier, challenge } = await generatePKCE();
const state = crypto.randomUUID();
sessionStorage.setItem('pkce_verifier', verifier);
sessionStorage.setItem('oauth_state', state);
const authUrl = new URL('https://your-coreident-server.com/auth/authorize');
authUrl.searchParams.set('client_id', 'your-spa-client-id');
authUrl.searchParams.set('redirect_uri', 'https://your-app.com/callback');
authUrl.searchParams.set('response_type', 'code');
authUrl.searchParams.set('scope', 'openid profile email');
authUrl.searchParams.set('state', state);
authUrl.searchParams.set('code_challenge', challenge);
authUrl.searchParams.set('code_challenge_method', 'S256');
window.location.href = authUrl.toString();Ensure CORS is configured for your JavaScript client's origin. See CORS Configuration.
Register your SPA as a public client (no client secret):
{
"clientId": "your-spa-client-id",
"clientType": "Public",
"redirectUris": ["https://your-app.com/callback"],
"postLogoutRedirectUris": ["https://your-app.com/"],
"allowedScopes": ["openid", "profile", "email"],
"defaultScopes": ["openid", "profile"],
"allowedGrantTypes": ["authorization_code"],
"requirePkce": true
}defaultScopes behaviour:
defaultScopes value |
When client omits scope parameter |
|---|---|
null (omitted) |
All allowedScopes are granted (backwards-compatible) |
[] (empty array) |
Request is rejected — scope is required |
["openid", "profile"] |
Only the listed scopes are granted |
When the client explicitly requests scopes, defaultScopes is ignored and the request is validated against allowedScopes as usual.
CoreIdentOptions includes:
Issuer(required)Audience(required)AccessTokenLifetime(default: 15 minutes)RefreshTokenLifetime(default: 7 days)
AddCoreIdent() registers a validator (CoreIdentOptionsValidator) and calls ValidateOnStart(). If required values are missing or invalid, the app will fail fast at startup.
- Many endpoints and services assume
IssuerandAudienceare non-null once validated. - The
ITokenService(JWT token creation) requires issuer/audience per call.
CoreIdentRouteOptions controls how endpoint routes are assembled.
Defaults:
BasePath = "/auth"- Relative paths under base:
AuthorizePath = "authorize"TokenPath = "token"RevocationPath = "revoke"IntrospectionPath = "introspect"ConsentPath = "consent"UserInfoPath = "userinfo"RegisterPath = "register"LoginPath = "login"ProfilePath = "profile"PasswordlessEmailStartPath = "passwordless/email/start"PasswordlessEmailVerifyPath = "passwordless/email/verify"PasswordlessSmsStartPath = "passwordless/sms/start"PasswordlessSmsVerifyPath = "passwordless/sms/verify"PasskeyRegisterOptionsPath = "passkey/register/options"PasskeyRegisterCompletePath = "passkey/register/complete"PasskeyAuthenticateOptionsPath = "passkey/authenticate/options"PasskeyAuthenticateCompletePath = "passkey/authenticate/complete"
- Root-ish helper:
UserProfilePath = "/me"(host-friendly convenience route; not currently mapped byMapCoreIdentEndpoints())
CombineWithBase(path):- If
pathstarts with/, it is treated as root-relative and returned normalized. - Otherwise it returns
BasePath + "/" + pathnormalized.
- If
If DiscoveryPath and JwksPath are not explicitly set:
GetDiscoveryPath(CoreIdentOptions)returns:"<issuerPath>/.well-known/openid-configuration"
GetJwksPath(CoreIdentOptions)returns:"<issuerPath>/.well-known/jwks.json"
Where issuerPath is the path component of Issuer:
- If
Issuer = "https://example.com"→ issuerPath is empty → discovery is/.well-known/openid-configuration - If
Issuer = "https://example.com/auth"→ issuerPath is/auth→ discovery is/auth/.well-known/openid-configuration
This makes it possible to host CoreIdent under a path while still generating correct issuer-relative discovery URLs.
builder.Services.AddCoreIdent(...) registers:
- Options:
CoreIdentOptionsCoreIdentRouteOptionsCoreIdentResourceOwnerOptionsCoreIdentAuthorizationCodeOptionsPasswordlessEmailOptionsPasswordlessSmsOptionsSmtpOptions
- Core services:
ITokenService→JwtTokenServiceIClientSecretHasher→DefaultClientSecretHasherIPasswordHasher→DefaultPasswordHasherICustomClaimsProvider→NullCustomClaimsProviderTimeProvider→TimeProvider.SystemIEmailSender→SmtpEmailSender
ISmsProvider→ConsoleSmsProviderPasswordlessEmailTemplateRenderer
- In-memory stores (defaults):
IClientStore→InMemoryClientStoreIScopeStore→InMemoryScopeStore(pre-seeded with standard OIDC scopes)IRefreshTokenStore→InMemoryRefreshTokenStoreIAuthorizationCodeStore→InMemoryAuthorizationCodeStoreIUserGrantStore→InMemoryUserGrantStoreITokenRevocationStore→InMemoryTokenRevocationStoreIUserStore→InMemoryUserStoreIPasswordlessTokenStore→InMemoryPasswordlessTokenStore
- Hosted service:
AuthorizationCodeCleanupHostedService
CoreIdent emits metrics using System.Diagnostics.Metrics.
By default, CoreIdent registers a no-op metrics sink.
To enable CoreIdent metrics emission:
builder.Services.AddCoreIdent(...);
builder.Services.AddCoreIdentMetrics();Counters:
coreident.client.authenticated— Number of client authentication attemptscoreident.token.issued— Number of tokens issuedcoreident.token.revoked— Number of tokens revoked
Histograms:
coreident.client.authentication.duration— Duration of client authentication (ms)coreident.token.issuance.duration— Duration of token issuance (ms)
coreident.client.authenticatedclient_type(public/confidential/unknown)success(true/false)
coreident.token.issuedtoken_type(access_token/refresh_token/id_token)grant_type(e.g.client_credentials,authorization_code,refresh_token,password)
coreident.token.revokedtoken_type(access_token/refresh_token)
You can configure filtering and sampling when enabling metrics:
builder.Services.AddCoreIdentMetrics(o =>
{
o.SampleRate = 1.0;
o.Filter = ctx => ctx.MetricName != "coreident.token.issued";
});.NET 10 already emits metrics for:
aspnetcore.authentication.*aspnetcore.identity.*
CoreIdent metrics are intended to complement these with OAuth/OIDC-specific counters.
All store and service registrations use TryAdd* patterns so you can override them by registering your own implementations before calling AddCoreIdent() (or by replacing registrations explicitly).
If you already have an existing user system (database + credential verification) and you want CoreIdent to delegate user lookup and credential validation, use CoreIdent.Adapters.DelegatedUserStore.
Registration:
using CoreIdent.Adapters.DelegatedUserStore.Extensions;
builder.Services.AddCoreIdentDelegatedUserStore(o =>
{
o.FindUserByIdAsync = (id, ct) => myUsers.FindByIdAsync(id, ct);
o.FindUserByUsernameAsync = (username, ct) => myUsers.FindByUsernameAsync(username, ct);
o.ValidateCredentialsAsync = (user, password, ct) => myUsers.ValidatePasswordAsync(user, password, ct);
// Optional: provide claims that will be emitted into tokens.
o.GetClaimsAsync = (subjectId, ct) => myUsers.GetClaimsAsync(subjectId, ct);
});Notes:
- The adapter replaces
IUserStoreandIPasswordHasherregistrations. - CoreIdent will not store password hashes when using this adapter (credential validation is delegated).
- You are responsible for:
- secure credential storage and verification (hashing, rate limiting, lockout, MFA, etc.)
- preventing credential leakage in logs and telemetry
- ensuring usernames are normalized consistently with your system
CoreIdent uses ISigningKeyProvider to:
- provide
SigningCredentialsfor token creation - provide validation keys for token verification / JWKS publishing
AddSigningKey(...) configures CoreIdentKeyOptions using CoreIdentKeyOptionsBuilder:
UseRsa(string keyPath)UseRsaPem(string pemString)UseEcdsa(string keyPath)UseSymmetric(string secret)(development/testing only)
- The RSA provider (
RsaSigningKeyProvider) supports:- PEM string
- PEM file
- X509 certificate (PFX)
- fallback: generate an ephemeral RSA key with a warning
- Key IDs (
kid) are computed from the SHA-256 hash of the public key.
- The ECDSA provider supports:
- PEM string
- PEM file
- X509 certificate
- fallback: generate ephemeral P-256 key (ES256) with a warning
SymmetricSigningKeyProviderlogs a warning at startup.- Symmetric keys are not published via JWKS.
- Secret must be at least 32 bytes.
The JWKS endpoint returns a JSON Web Key Set:
- RSA keys are output with
kty=RSA,n,e,kid,alg=RS256 - EC keys are output with
kty=EC,crv,x,y,kid,alg=ES256 - Symmetric keys are skipped (not published)
This is intentionally “safe by default” (no shared secrets exposed).
app.MapCoreIdentEndpoints() maps all current endpoints.
If you want a more controlled surface area, map individual endpoints:
MapCoreIdentOpenIdConfigurationEndpoint(coreOptions, routeOptions)MapCoreIdentDiscoveryEndpoints(jwksPath)MapCoreIdentAuthorizeEndpoint(authorizePath)MapCoreIdentConsentEndpoints(consentPath)MapCoreIdentTokenEndpoint(tokenPath)MapCoreIdentTokenManagementEndpoints(revokePath, introspectPath)MapCoreIdentResourceOwnerEndpoints(registerPath, loginPath, profilePath)MapCoreIdentUserInfoEndpoint(userInfoPath)MapCoreIdentPasswordlessEmailEndpoints(startPath, verifyPath)MapCoreIdentPasswordlessSmsEndpoints(startPath, verifyPath)
The discovery endpoint returns an OpenIdConfigurationDocument that includes:
issuerjwks_uritoken_endpointrevocation_endpointintrospection_endpointscopes_supported(pulled fromIScopeStore.GetAllAsync(), filtered byShowInDiscoveryDocument)id_token_signing_alg_values_supported(fromISigningKeyProvider.Algorithm)
grant_types_supportedis currently returned as an empty list in the implementation (even though token endpoint supports multiple grants). This is an implementation detail worth revisiting, but the guide documents current behavior.
The token endpoint expects:
Content-Type: application/x-www-form-urlencoded- Client authentication:
- Prefer
Authorization: Basic base64(client_id:client_secret) - Or form params
client_id,client_secret
- Prefer
client_credentialsrefresh_tokenauthorization_codepassword(deprecated; logs warning)
The endpoint returns TokenResponse:
access_token(JWT)token_type("Bearer")expires_in(seconds)refresh_token(when applicable)scope(space-delimited, when applicable)id_token(only forauthorization_codewhen scope includesopenid)
- If the request does not include
scope, CoreIdent grants the client’sAllowedScopes. - If the request includes
scope, CoreIdent intersects requested scopes with client’sAllowedScopes.
Refresh tokens are stored via IRefreshTokenStore as CoreIdentRefreshToken records.
Important security properties:
- Refresh token rotation is implemented:
- The old token is consumed (
ConsumedAtset). - A new token is issued.
- The old token is consumed (
- Theft detection is implemented:
- If a consumed refresh token is reused, CoreIdent revokes the entire token family (
FamilyId).
- If a consumed refresh token is reused, CoreIdent revokes the entire token family (
Refresh tokens are only minted in some flows when:
- client has
AllowOfflineAccess = true, and - granted scopes include
offline_access
CoreIdent implements:
- Authorization endpoint:
GET /auth/authorize - Consent endpoint (when client requires consent):
GET/POST /auth/consent - Token exchange:
POST /auth/tokenwithgrant_type=authorization_code
client_id(required)redirect_uri(required)response_type=code(required)state(required)- PKCE is required:
code_challenge(required)code_challenge_method=S256(required)
scope(optional)nonce(optional; used for id_token issuance)
The authorize endpoint uses ASP.NET authentication:
- If
HttpContext.User.Identity.IsAuthenticatedis false, it returnsResults.Challenge().
That means your host app must configure authentication (cookies, external provider, etc.) for interactive login.
If client.RequireConsent == true:
- CoreIdent checks
IUserGrantStore.HasUserGrantedConsentAsync(...). - If consent is missing, it redirects to
ConsentPath. - Consent UI is a minimal HTML form.
Authorization codes are stored via IAuthorizationCodeStore.
- Default is
InMemoryAuthorizationCodeStore. - Codes are single-use (
ConsumedAtis set on consume). - Cleanup runs periodically via
AuthorizationCodeCleanupHostedService.
CoreIdent includes non-OIDC convenience endpoints for quick “auth for my app” workflows:
GET/POST /auth/registerGET/POST /auth/loginGET /auth/profile
These endpoints return JSON when:
Accept: application/json, orContent-Type: application/json
Otherwise they return minimal HTML.
/auth/profile validates bearer tokens itself using:
ISigningKeyProvider.GetValidationKeysAsync()Microsoft.IdentityModel.JsonWebTokens.JsonWebTokenHandler.ValidateTokenAsync
It does not rely on ASP.NET JwtBearer middleware.
This makes it useful for quick starts, but if you’re building a larger system you’ll typically configure JWT authentication in the host instead.
You can override result handling using:
builder.Services.ConfigureResourceOwnerEndpoints(options =>
{
options.RegisterHandler = (http, user, ct) => Task.FromResult<IResult?>(Results.Redirect("/welcome"));
options.LoginHandler = (http, user, tokens, ct) => Task.FromResult<IResult?>(Results.Json(new { tokens.AccessToken }));
options.ProfileHandler = (http, user, claims, ct) => Task.FromResult<IResult?>(Results.Ok());
});Returning null falls back to CoreIdent defaults.
For endpoints that are not required to return OAuth 2.0 error objects (for example, the resource-owner and passwordless convenience endpoints), CoreIdent returns RFC 7807 Problem Details for JSON clients.
When you send Accept: application/json (or Content-Type: application/json where applicable), error responses are returned with:
Content-Type: application/problem+json- A standard Problem Details body containing:
type(currentlyabout:blank)titlestatusdetailinstance- extension members:
error_codecorrelation_idtrace_id
CoreIdent also emits an X-Correlation-Id response header for requests handled by CoreIdent endpoints. If the client provides an X-Correlation-Id request header, CoreIdent will reuse it; otherwise it will fall back to the current activity id / trace identifier.
- OAuth/OIDC endpoints such as
/auth/token,/auth/revoke, and/auth/introspectcontinue to return OAuth 2.0 style error objects (error,error_description) as required by their respective RFCs. - Structured logging uses a scope that includes
correlation_idandtrace_idfor easier log correlation. - Passwordless endpoints avoid logging raw email/phone values; values are masked/redacted.
CoreIdent provides a simple passwordless flow using email magic links:
POST /auth/passwordless/email/start— request a sign-in linkGET /auth/passwordless/email/verify?token=...— verify the token, create/find the user, and issue tokens
Request body (JSON):
{ "email": "user@example.com" }Behavior:
- Always returns
200 OK(does not leak whether a user exists) - Generates a secure random token and stores only a hash via
IPasswordlessTokenStore - Enforces per-email rate limiting (
PasswordlessEmailOptions.MaxAttemptsPerHour) - Sends an email using
IEmailSenderwith a link to the verify endpoint
Behavior:
- Validates and consumes the token (single-use)
- Creates the user if not found (
IUserStore) - Issues an access token + refresh token
- If
PasswordlessEmailOptions.SuccessRedirectUrlis set, redirects with tokens using the configuredTokenDeliverymode:
| Mode | Delivery | Notes |
|---|---|---|
QueryString (default) |
?access_token=...&... |
Simplest; tokens may appear in server logs and Referer headers |
Fragment |
#access_token=...&... |
More secure — fragments are never sent to the server, but require client-side JS to extract |
AuthorizationCode |
Planned | Server issues a short-lived code the client exchanges for tokens. See DEVPLAN.md |
Note: Fragment delivery is a token delivery mechanism for the passwordless email verify endpoint only. It is not the OAuth implicit grant (response_type=token). No OAuth authorization endpoint, client_id, or response_type is involved.
If the token is invalid, expired, or already consumed, it returns 400 Bad Request.
builder.Services.Configure<PasswordlessEmailOptions>(opts =>
{
opts.TokenLifetime = TimeSpan.FromMinutes(15);
opts.MaxAttemptsPerHour = 5;
opts.EmailSubject = "Sign in to {AppName}";
// Relative by default; combined with CoreIdentRouteOptions.BasePath
opts.VerifyEndpointUrl = "passwordless/email/verify";
// Optional redirect that receives tokens
opts.SuccessRedirectUrl = "https://client.example/signed-in";
// Token delivery mode: QueryString (default), Fragment, or AuthorizationCode (planned)
opts.TokenDelivery = TokenDeliveryMode.Fragment;
// Optional custom HTML template
// opts.EmailTemplatePath = "EmailTemplates/passwordless.html";
});CoreIdent defaults IEmailSender to an SMTP implementation. Configure it via SmtpOptions.
Example appsettings.json:
{
"SmtpOptions": {
"Host": "smtp.example.com",
"Port": 587,
"EnableTls": true,
"UserName": "smtp-user",
"Password": "smtp-password",
"FromAddress": "no-reply@example.com",
"FromDisplayName": "CoreIdent"
}
}And bind options:
builder.Services.Configure<SmtpOptions>(builder.Configuration.GetSection("SmtpOptions"));CoreIdent renders a simple HTML email containing a verify link.
- Default template is built into
PasswordlessEmailTemplateRenderer. - To provide your own template, set
PasswordlessEmailOptions.EmailTemplatePath.- If the path is relative, it is resolved relative to
IHostEnvironment.ContentRootPath.
- If the path is relative, it is resolved relative to
Available placeholders:
{AppName}—IHostEnvironment.ApplicationName{Email}— recipient email{VerifyUrl}— absolute verify URL
Recommendation:
- SMTP is great for demos and small self-hosted deployments.
- For production, many teams prefer provider email APIs (SendGrid, Postmark, Mailgun, AWS SES, Azure Communication Services) for deliverability and operational convenience.
CoreIdent is designed to be extended without forking:
- Implement
IEmailSenderin your host app or a separate package. - Register it in DI to override the default SMTP sender.
Example:
public sealed class MyProviderEmailSender : IEmailSender
{
public Task SendAsync(EmailMessage message, CancellationToken ct = default)
{
// Call your provider API here.
throw new NotImplementedException();
}
}
builder.Services.AddSingleton<IEmailSender, MyProviderEmailSender>();
builder.Services.AddCoreIdent(...);Because CoreIdent uses TryAdd* for defaults, registering IEmailSender before AddCoreIdent() will take precedence.
CoreIdent provides a simple passwordless flow using SMS one-time passcodes:
POST /auth/passwordless/sms/start— request an OTPPOST /auth/passwordless/sms/verify— verify the OTP, create/find the user, and issue tokens
Request body (JSON):
{ "phone_number": "+15551234567" }Optionally, you can provide a message_prefix that will be prepended to the OTP message:
{ "phone_number": "+15551234567", "message_prefix": "Your login code:" }For form posts, the corresponding field names are:
phone_numbermessage_prefix
Phone numbers must be in E.164 format (for example: +15551234567). CoreIdent applies minimal normalization before validation (trims whitespace, removes spaces/hyphens/parentheses, and converts a leading 00 prefix to +).
Behavior:
- Always returns
200 OK(does not leak whether a user exists) - If the phone number is missing or invalid, CoreIdent still returns
200 OKwith the same response message - Generates a 6-digit numeric OTP and stores only a hash via
IPasswordlessTokenStore - Enforces per-phone rate limiting (
PasswordlessSmsOptions.MaxAttemptsPerHour) - Sends an SMS using
ISmsProvider
Request body (JSON):
{ "phone_number": "+15551234567", "otp": "123456" }Behavior:
- Validates and consumes the OTP (single-use)
- Creates the user if not found (
IUserStore) - Issues an access token + refresh token
If the phone number is missing or invalid, or if the OTP is invalid, expired, or already consumed, it returns 400 Bad Request.
builder.Services.Configure<PasswordlessSmsOptions>(opts =>
{
opts.OtpLifetime = TimeSpan.FromMinutes(5);
opts.MaxAttemptsPerHour = 5;
});CoreIdent registers a default ConsoleSmsProvider for development.
To use a real provider, register your own ISmsProvider implementation:
builder.Services.AddSingleton<ISmsProvider, MySmsProvider>();CoreIdent provides built-in password reset functionality for apps that use password-based authentication.
- User requests reset:
POST /auth/account/recoverwith email address - CoreIdent generates a secure token, stores it hashed, and sends a reset link via
IEmailSender - User clicks link:
GET /auth/account/reset-password?token=xxxdisplays a reset form - User submits new password:
POST /auth/account/reset-passwordwith token and new password - CoreIdent validates the token, hashes the new password, and updates the user
builder.Services.AddCoreIdent(options => { ... })
.AddPasswordReset(reset =>
{
reset.TokenLifetime = TimeSpan.FromMinutes(30);
reset.MaxAttemptsPerHour = 3;
reset.EmailSubject = "Reset your password";
});- The recover endpoint always returns success regardless of whether the email exists (prevents email enumeration)
- Reset tokens are single-use and time-limited
- Rate limiting is applied per email address
- The reset form can be replaced by providing a custom handler
CoreIdent exposes a minimal OpenID Connect UserInfo endpoint:
GET /auth/userinfo
Behavior:
- Requires a valid bearer access token
- Requires that the access token includes the
openidscope - Returns a JSON object that always includes:
sub
CoreIdent returns claims based on granted scopes:
openidsub
profilename,family_name,given_name,middle_name,nickname,preferred_username,profile,picture,website,gender,birthdate,zoneinfo,locale,updated_at
emailemail,email_verified
addressaddress
phonephone_number,phone_number_verified
Claims are sourced from:
IUserStore.GetClaimsAsync(subjectId)ICustomClaimsProvider.GetIdTokenClaimsAsync(...)
Claims not granted by scope are omitted.
- Requires form content type
- Requires client authentication for confidential clients
- Returns
200 OKeven for invalid/unknown tokens (privacy semantics)
Revocation behavior:
- If token looks like a JWT (or
token_type_hint=access_token):- CoreIdent validates signature (without issuer/audience checks), extracts
jti, and stores revocation viaITokenRevocationStore.
- CoreIdent validates signature (without issuer/audience checks), extracts
- Otherwise (or
token_type_hint=refresh_token):- CoreIdent attempts refresh token revocation via
IRefreshTokenStore.RevokeAsync.
- CoreIdent attempts refresh token revocation via
Client ownership checks:
- If token belongs to a different client, CoreIdent returns
200 OKbut does not revoke.
JWTs are stateless; revocation only works if resource servers check revocation status.
CoreIdent provides middleware:
app.UseCoreIdentTokenRevocation()
This middleware checks:
- If the current request is authenticated
- If the principal contains a
jticlaim - If
ITokenRevocationStore.IsRevokedAsync(jti)is true → respond401 Unauthorized
app.UseAuthentication();
app.UseCoreIdentTokenRevocation();
app.UseAuthorization();Notes:
- You still need JWT validation (
AddAuthentication().AddJwtBearer(...)) in the resource server. - Revocation middleware adds the “online check” layer.
- Requires form content type
- Requires client authentication (resource server credentials)
- Returns:
{ "active": false }for unknown/invalid/expired/revoked tokens- RFC 7662-compatible payload for active tokens
Introspection supports:
- Access tokens (JWT): validates signature, checks expiry, checks revocation store
- Refresh tokens: checks store state (
IsRevoked,ConsumedAt,ExpiresAt)
CoreIdentClient is the central configuration for OAuth clients.
Notable fields:
ClientIdClientSecretHash(confidential clients)ClientType(PublicorConfidential)AllowedGrantTypesAllowedScopesRedirectUrisRequirePkceRequireConsentAllowOfflineAccess- Token lifetimes:
AccessTokenLifetimeSecondsRefreshTokenLifetimeSeconds
- Confidential clients must authenticate and must have a secret.
- Public clients should use PKCE and typically don’t use a client secret.
CoreIdent includes standard scope constants:
openidprofileemailaddressphoneoffline_access
Default in-memory scope store seeds these standard scopes.
The EF Core package provides:
CoreIdentDbContext- Entities:
ClientEntity,ScopeEntity,RefreshTokenEntity,AuthorizationCodeEntity,UserGrantEntity,UserEntity,RevokedToken
- Store implementations
When using EF-backed stores, use this order:
AddCoreIdent(...)AddDbContext<CoreIdentDbContext>(...)AddEntityFrameworkCoreStores()
Example:
builder.Services.AddCoreIdent(o =>
{
o.Issuer = "https://issuer.example";
o.Audience = "https://resource.example";
});
builder.Services.AddSigningKey(o => o.UseRsa("/path/to/private-key.pem"));
builder.Services.AddDbContext<CoreIdentDbContext>(options =>
options.UseSqlite(builder.Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddEntityFrameworkCoreStores();CoreIdent does not run migrations for you. Your host app owns migrations and schema management.
- Use
EnsureCreated()for quick dev prototypes - Use migrations for production
CoreIdent includes a dedicated testing package:
tests/CoreIdent.Testing
- Uses SQLite in-memory
- Registers EF Core stores via
AddEntityFrameworkCoreStores() - Seeds standard scopes via
StandardScopes.All
Provides:
Client(HttpClient)Services(IServiceProvider)- Helpers:
CreateUserAsync(...)CreateClientAsync(...)AuthenticateAsAsync(user)(sets test headers for the test auth scheme)
UserBuilder.WithEmail(...),.WithPassword(...),.WithClaim(...)
ClientBuilder.WithClientId(...),.WithSecret(...),.WithGrantTypes(...),.WithScopes(...),.WithRedirectUris(...),.RequireConsent(...),.AsPublicClient(), etc.
HttpResponseAssertionExtensions.ShouldBeSuccessful().ShouldBeSuccessfulWithContent<T>().ShouldBeUnauthorized().ShouldBeBadRequest(contains: ...)
There is also a JwtAssertionExtensions helper (see tests/CoreIdent.Testing/Extensions/JwtAssertionExtensions.cs).
- Register a confidential client in the store with:
AllowedGrantTypescontainingclient_credentialsAllowedScopescontaining your API scope (e.g.api)
- Call:
POST /auth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
grant_type=client_credentials&scope=api- Validate the returned
access_tokenusing your JWT validation stack + the published JWKS.
- Ensure client supports
refresh_tokenand (when minted from interactive/user flows) hasAllowOfflineAccess=true. - Exchange:
POST /auth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
grant_type=refresh_token&refresh_token=<handle>CoreIdent will rotate refresh tokens and detect reuse.
- Configure your host app authentication (cookies, external provider, etc.) so
/auth/authorizecan authenticate a user. - Configure client:
AllowedGrantTypesincludesauthorization_codeRedirectUrisincludes your redirect URIRequirePkce=true- Optionally
RequireConsent=true
- Start auth request:
GET /auth/authorize?
response_type=code&
client_id=...&
redirect_uri=https%3A%2F%2Fapp.example%2Fcallback&
scope=openid%20profile&
state=...&
code_challenge=...&
code_challenge_method=S256- If consent is required, CoreIdent redirects to
/auth/consent. - After consent + auth, CoreIdent redirects to your redirect URI with
codeandstate. - Exchange the code:
POST /auth/token
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
grant_type=authorization_code&code=...&redirect_uri=...&code_verifier=...If the granted scopes include openid, CoreIdent also returns an id_token.
-
Issuer/Audience not configured
CoreIdentOptionsvalidation fails on start.
-
Signing key not configured
- RSA/ECDSA providers will generate ephemeral keys with warnings.
- Symmetric provider requires a 32+ byte secret.
-
/auth/tokenreturns 400- Ensure
Content-Typeisapplication/x-www-form-urlencoded.
- Ensure
-
/auth/tokenreturns 401 invalid_client- Ensure the client exists and is enabled.
- For confidential clients, ensure the secret is correct.
-
/auth/authorizereturns challenge- Your host app hasn’t configured authentication, or the user is not signed in.
-
JWT revocation appears to “not work”
- Ensure the resource server uses
UseCoreIdentTokenRevocation()(or performs introspection).
- Ensure the resource server uses
- Use RSA (RS256) or ECDSA (ES256) in production.
- Keep access tokens short-lived.
- Prefer refresh token rotation and revoke on suspicion.
- Never publish symmetric signing secrets in JWKS (CoreIdent does not).
- Do not log secrets, tokens, or private keys.
- DI registration:
src/CoreIdent.Core/Extensions/ServiceCollectionExtensions.cs
- Endpoint aggregation:
src/CoreIdent.Core/Extensions/EndpointRouteBuilderExtensions.cs
- Token endpoint:
src/CoreIdent.Core/Endpoints/TokenEndpointExtensions.cs
- Revocation / introspection:
src/CoreIdent.Core/Endpoints/TokenManagementEndpointsExtensions.cs
- Discovery/JWKS:
src/CoreIdent.Core/Endpoints/DiscoveryEndpointsExtensions.cs
- Authorize/consent:
src/CoreIdent.Core/Endpoints/AuthorizationEndpointExtensions.cssrc/CoreIdent.Core/Endpoints/ConsentEndpointExtensions.cs
- Revocation enforcement middleware:
src/CoreIdent.Core/Middleware/TokenRevocationMiddleware.cs
- EF Core:
src/CoreIdent.Storage.EntityFrameworkCore/CoreIdentDbContext.cssrc/CoreIdent.Storage.EntityFrameworkCore/Extensions/ServiceCollectionExtensions.cs
- Test infrastructure:
tests/CoreIdent.Testing/Fixtures/CoreIdentTestFixture.cstests/CoreIdent.Testing/Fixtures/CoreIdentWebApplicationFactory.cs