ASP.NET Core 10, Duende IdentityServer, OAuth 2.0, OpenID Connect
Table of Contents
- Application Security Architecture
- Authentication with OpenID Connect
- Securing the Authentication Process
- Working with Claims
- Authorization with OAuth2 and OpenID Connect
- Securing the API
- Authorization Policies and Access Control
- Token Expiration and Token Revocation
- Securing JavaScript Clients (BFF Pattern)
- Reference Tables
1. Application Security Architecture
Why a Centralized Identity Provider?
Delegating identity management to an Identity Provider (IdP) enables:
- Centralization: user registration, password policies, password reset, MFA in one place
- Security: modern hashing algorithms like Argon2 or bcrypt for passwords
- Reusability: single authentication for multiple applications (Single Sign-On)
⚠️ An application should never be responsible for verifying who a user is. That’s the Identity Provider’s responsibility.
Key Vocabulary
| Term | Definition |
|---|---|
| Identity Provider (IdP) | Service that authenticates users and provides identity proofs |
| Authorization Server | Server that issues access tokens (can be same as IdP) |
| Security Token Service (STS) | Generic term for a server issuing security tokens |
| Relying Party (RP) | Client that relies on the IdP for authentication |
| Duende IdentityServer | Open-source OAuth2/OIDC implementation for .NET |
2. Authentication with OpenID Connect
Core Concepts
OpenID Connect (OIDC) is a simple identity layer on top of OAuth 2. It extends and replaces OAuth 2 for scenarios involving users.
- Defines how a client can obtain an identity token and/or access token from an IdP
- Identity token = proof of user identity (JWT format)
- Access token = authorization to access a resource (API)
- Requires TLS (HTTPS) — mandatory
Confidential Clients vs Public Clients
| Confidential Client | Public Client | |
|---|---|---|
| Examples | ASP.NET Core MVC/API | Angular, React, Blazor WASM |
| Execution | Controlled server | Browser |
| Store secrets? | Yes | No |
| Client auth? | Yes | No |
OpenID Connect Endpoints
| Endpoint | Usage |
|---|---|
| Authorization Endpoint | Authentication request — redirects user to IdP |
| Token Endpoint | Exchange authorization code for tokens (back-channel) |
| UserInfo Endpoint | Retrieve additional claims with an access token |
| Discovery Document | /.well-known/openid-configuration — IdP metadata |
| End Session Endpoint | Logout at the IdP level |
| Token Revocation Endpoint | Token revocation (refresh tokens) |
Identity Token (JWT) Claims
| Claim | Meaning |
|---|---|
sub | Subject — unique user identifier at the IdP |
iss | Issuer — IdP URI |
aud | Audience — destination client ID |
iat | Issued At — emission timestamp (Unix) |
exp | Expiration — token invalid after this date |
auth_time | Time of original authentication |
amr | Authentication Methods References (e.g., pwd, otp, mfa) |
nonce | Protection against CSRF attacks |
3. Securing the Authentication Process
Authorization Code Flow
sequenceDiagram
participant User as User
participant Client as Client (MVC)
participant IDP as Identity Provider
User->>Client: Access to protected resource
Client->>User: Redirect to IDP (Authorization Endpoint)
User->>IDP: Provides credentials
IDP->>User: Redirect to Client with Authorization Code
Client->>IDP: POST Token Endpoint (code + client_id + client_secret)
IDP->>Client: Identity Token + Access Token (+ Refresh Token)
Client->>Client: Validates Identity Token, creates ClaimsIdentity
Client->>User: Access granted (Authentication Cookie)
Authorization Code Flow + PKCE
PKCE (Proof Key for Code Exchange) protects against code injection attacks.
sequenceDiagram
participant Client
participant IDP as Identity Provider
Client->>Client: Generate code_verifier (random string)
Client->>Client: Hash → code_challenge = SHA256(code_verifier)
Client->>IDP: Auth Request + code_challenge
IDP->>Client: Returns authorization_code
Client->>IDP: POST Token Endpoint<br/>code + code_verifier + client credentials
IDP->>IDP: Hash(code_verifier) == code_challenge?
IDP->>Client: Tokens (only if match ✓)
✅ PKCE is enabled by default in Microsoft’s ASP.NET Core OIDC middleware.
ASP.NET Core Client Configuration
// Program.cs — ASP.NET Core MVC Client
builder.Services.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(CookieAuthenticationDefaults.AuthenticationScheme, options =>
{
options.AccessDeniedPath = "/Authentication/AccessDenied";
})
.AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.Authority = "https://localhost:5001/";
options.ClientId = "imagegalleryclient";
options.ClientSecret = "secret";
options.ResponseType = "code";
options.SaveTokens = true;
options.GetClaimsFromUserInfoEndpoint = true;
options.MapInboundClaims = false;
options.Scope.Add("roles");
options.Scope.Add("country");
options.Scope.Add("offline_access");
options.Scope.Add("imagegalleryapi.write");
options.ClaimActions.Remove("aud");
options.ClaimActions.DeleteClaims("sid", "idp");
options.ClaimActions.MapJsonKey("role", "role");
options.ClaimActions.MapUniqueJsonKey("country", "country");
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "given_name",
RoleClaimType = "role"
};
});
Logout (Client + IDP)
[Authorize]
public async Task Logout()
{
await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
await HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme);
}
4. Working with Claims
UserInfo Endpoint
By default, the identity token only contains sub. Other claims are retrieved via the UserInfo Endpoint:
options.GetClaimsFromUserInfoEndpoint = true;
Claims Mapping
options.MapInboundClaims = false; // Disable WS-Security mapping
options.ClaimActions.Remove("aud"); // Re-add a filtered claim
options.ClaimActions.DeleteClaims("sid", "idp"); // Remove unnecessary claims
options.ClaimActions.MapJsonKey("role", "role");
options.ClaimActions.MapUniqueJsonKey("country", "country");
Role-based Authorization (RBAC)
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "given_name",
RoleClaimType = "role"
};
[Authorize(Roles = "PayingUser")]
public IActionResult AddImage() { ... }
5. Authorization with OAuth2 and OpenID Connect
OAuth2 Flows
| Flow | Status | Usage |
|---|---|---|
| Authorization Code + PKCE | Recommended | Confidential and Public clients |
| Client Credentials | Machine-to-machine | Background services, daemon apps |
| Hybrid Flow | Discouraged | Replaced by Authorization Code + PKCE |
| Implicit Flow | Obsolete | Do not use |
| Resource Owner Password Credentials | Legacy only | Avoid |
Access Token Claims (decoded example)
{
"sub": "b753...",
"iss": "https://localhost:5001",
"aud": ["imagegalleryapi"],
"client_id": "imagegalleryclient",
"scope": "openid imagegalleryapi.write profile country",
"role": "PayingUser",
"country": "be"
}
6. Securing the API
JWT Bearer Authentication
// Program.cs — API
builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
options.Authority = "https://localhost:5001";
options.Audience = "imagegalleryapi";
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "given_name",
RoleClaimType = "role",
ValidTypes = ["at+jwt"]
};
options.MapInboundClaims = false;
});
Passing the Access Token to the API
// Program.cs — Client
builder.Services.AddOpenIdConnectAccessTokenManagement();
builder.Services.AddHttpClient("APIClient", client =>
{
client.BaseAddress = new Uri("https://localhost:7075");
})
.AddUserAccessTokenHandler();
Using Claims in the API Controller
[HttpGet]
[Authorize]
public async Task<IActionResult> GetImages()
{
var ownerId = User.Claims
.FirstOrDefault(c => c.Type == "sub")?.Value;
var images = await _repository.GetImagesAsync(ownerId);
return Ok(images);
}
🔒 Never accept OwnerId from the request body. Always read it from validated token claims.
7. Authorization Policies and Access Control
RBAC vs ABAC
| RBAC | ABAC |
|---|---|
| Predefined roles | Policies based on attributes/claims |
| Simple but not extensible | Very flexible — recommended |
| Can lead to role explosion | A few policies cover many cases |
Creating a Shared Authorization Policy
public static class AuthorizationPolicies
{
public static AuthorizationPolicy CanAddImage()
{
return new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("country", "be")
.RequireRole("PayingUser")
.Build();
}
}
Registering and Applying Policies
builder.Services.AddAuthorizationBuilder()
.AddPolicy("UserCanAddImage", AuthorizationPolicies.CanAddImage())
.AddPolicy("ClientApplicationCanWrite", policyBuilder =>
{
policyBuilder.RequireClaim("scope", "imagegalleryapi.write");
})
.AddPolicy("MustOwnImage", policyBuilder =>
{
policyBuilder.RequireAuthenticatedUser();
policyBuilder.AddRequirements(new MustOwnImageRequirement());
});
[Authorize(Policy = "UserCanAddImage")]
public IActionResult AddImage() { ... }
Custom Requirements and Handlers
public class MustOwnImageRequirement : IAuthorizationRequirement { }
public class MustOwnImageHandler(
IHttpContextAccessor httpContextAccessor,
IGalleryRepository galleryRepository)
: AuthorizationHandler<MustOwnImageRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
MustOwnImageRequirement requirement)
{
var imageId = httpContextAccessor.HttpContext?
.GetRouteValue("id")?.ToString();
if (!Guid.TryParse(imageId, out Guid imageIdAsGuid))
{
context.Fail();
return;
}
var ownerId = context.User.Claims
.FirstOrDefault(c => c.Type == "sub")?.Value;
if (ownerId == null ||
!await galleryRepository.IsImageOwnerAsync(imageIdAsGuid, ownerId))
{
context.Fail();
return;
}
context.Succeed(requirement);
}
}
// Register in DI:
builder.Services.AddScoped<IAuthorizationHandler, MustOwnImageHandler>();
8. Token Expiration and Token Revocation
Token Lifetimes
| Token | Typical Lifetime | Note |
|---|---|---|
| Identity Token | 5 minutes | Used once to create ClaimsIdentity |
| Access Token | ~1 hour | Controlled by IdP |
| Refresh Token | Days / Weeks | Configured at IdP |
Refresh Token Flow
sequenceDiagram
participant Client
participant IDP as Identity Provider
Client->>Client: Access Token expired
Client->>IDP: POST /token<br/>grant_type=refresh_token<br/>refresh_token=xyz
IDP->>Client: New Access Token + New Refresh Token
Note over Client,IDP: User sees nothing — back-channel only
// Enable refresh tokens
options.Scope.Add("offline_access");
// Auto-refresh via Duende.AccessTokenManagement
builder.Services.AddOpenIdConnectAccessTokenManagement();
builder.Services.AddHttpClient("APIClient", ...)
.AddUserAccessTokenHandler();
Token Revocation on Logout
.AddCookie(options =>
{
options.Events.OnSigningOut = async e =>
{
await e.HttpContext.RevokeRefreshTokenAsync();
};
})
9. Securing JavaScript Clients (BFF Pattern)
The Problem with SPAs and Tokens
Tokens stored client-side in JavaScript are vulnerable:
- XSS: malicious code can read localStorage or sessionStorage
- No secure client secret: any secret in JavaScript is accessible to users
Backend-for-Frontend (BFF) Pattern
flowchart TB
subgraph Browser
SPA[JavaScript SPA]
end
subgraph "ASP.NET Core Host (BFF)"
OIDC[OIDC Middleware]
PROXY[Reverse Proxy\nDuende.BFF.Yarp]
SESSION[Session + Cookie\nHTTP-only]
end
subgraph Backend
API[Remote API]
IDP[Identity Provider]
end
SPA -->|Local request /bff/images| PROXY
PROXY -->|Proxied request + Bearer Token| API
OIDC <-->|OAuth2/OIDC Flow| IDP
SESSION -.->|HTTP-only encrypted Cookie| SPA
BFF Key Principles:
- OAuth2/OIDC authentication happens server-side (ASP.NET Core host)
- Tokens are never exposed to JavaScript
- JavaScript communicates with its host via local endpoints
- Host proxies requests to remote APIs, adding the Bearer token
BFF Configuration with Duende.BFF
builder.Services.AddBff()
.AddRemoteApis();
app.MapRemoteBffApiEndpoint(
"/bff/images",
"https://localhost:7075/api/images")
.RequireAccessToken();
10. Reference Tables
Architectural Decision Summary
| Scenario | Recommendation |
|---|---|
| Server-side web app (MVC) | Authorization Code + PKCE + Cookie |
| REST API | JWT Bearer Authentication |
| SPA (Angular, React) | BFF Pattern — Duende.BFF + YARP |
| Native mobile | Authorization Code + PKCE (no secret) |
| Service-to-service | Client Credentials Flow |
| Refresh without interaction | offline_access scope + Duende.AccessTokenManagement |
| Claims in access token | Configure ApiResource with claims to include |
| Fine-grained authorization | Authorization Policies + Custom Requirements/Handlers |
| Secure logout | SignOutAsync Cookie + OIDC + RevokeRefreshTokenAsync |
| Claims transformation | MapInboundClaims=false + ClaimActions |
⚠️ Never log tokens in production. Token logging in course examples is for educational purposes only.
Search Terms
asp.net · securing · core · oauth2 · openid · connect · authentication · security · c# · .net · development · token · authorization · claims · access · api · bff · flow · client · clients · configuration · identity · jwt · logout