.NET & C# Development · Lesson 37 of 92
OAuth 2.0 & OpenID Connect — Let Others Handle Auth
OAuth 2.0 Is Not Authentication
OAuth 2.0 is an authorization protocol — it lets an app request access to resources on behalf of a user. OpenID Connect (OIDC) is an identity layer on top of OAuth 2.0 that adds authentication: you get a signed id_token telling you who the user is.
Two flows matter in practice:
| Flow | Use When | |---|---| | Authorization Code + PKCE | User-facing apps (SPAs, mobile, web) | | Client Credentials | Server-to-server, no user involved |
Authorization Code Flow With PKCE
The browser never sees the client_secret. PKCE replaces it with a one-time code_verifier/code_challenge pair.
1. App generates code_verifier (random 43-128 char string)
2. App computes code_challenge = BASE64URL(SHA256(code_verifier))
3. Redirect user to:
https://auth.example.com/authorize
?response_type=code
&client_id=YOUR_CLIENT_ID
&redirect_uri=https://app.example.com/callback
&scope=openid profile email
&code_challenge=
&code_challenge_method=S256
4. User authenticates at the provider
5. Provider redirects back with ?code=AUTH_CODE
6. App POSTs to token endpoint with code + code_verifier
7. App receives access_token + id_token Client Credentials Flow
No user, no browser. Service A calls Service B using a token issued directly to the app.
// Fetching a token with HttpClient — no user context
public class TokenService
{
private readonly HttpClient _http;
private readonly IConfiguration _config;
public TokenService(HttpClient http, IConfiguration config)
{
_http = http;
_config = config;
}
public async Task<string> GetAccessTokenAsync()
{
var values = new Dictionary<string, string>
{
["grant_type"] = "client_credentials",
["client_id"] = _config["Auth:ClientId"]!,
["client_secret"] = _config["Auth:ClientSecret"]!,
["scope"] = "api://my-service/.default"
};
var response = await _http.PostAsync(
_config["Auth:TokenEndpoint"],
new FormUrlEncodedContent(values));
response.EnsureSuccessStatusCode();
var json = await response.Content.ReadFromJsonAsync<TokenResponse>();
return json!.AccessToken;
}
}
public record TokenResponse([property: JsonPropertyName("access_token")] string AccessToken);AddAuthentication + AddOpenIdConnect
Wire up OIDC in an ASP.NET Core web app (cookie + OIDC combo):
// Program.cs
builder.Services
.AddAuthentication(options =>
{
options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
.AddCookie(options =>
{
options.LoginPath = "/login";
options.LogoutPath = "/logout";
})
.AddOpenIdConnect(options =>
{
options.Authority = builder.Configuration["Auth:Authority"]; // e.g. https://login.microsoftonline.com/{tenantId}/v2.0
options.ClientId = builder.Configuration["Auth:ClientId"];
options.ClientSecret = builder.Configuration["Auth:ClientSecret"];
options.ResponseType = OpenIdConnectResponseType.Code; // Authorization Code flow
options.UsePkce = true;
options.Scope.Clear();
options.Scope.Add("openid");
options.Scope.Add("profile");
options.Scope.Add("email");
options.SaveTokens = true; // persists tokens in the auth cookie
options.GetClaimsFromUserInfoEndpoint = true;
options.TokenValidationParameters = new TokenValidationParameters
{
NameClaimType = "name",
RoleClaimType = "roles"
};
});
app.UseAuthentication();
app.UseAuthorization();Protecting an API With External JWTs
When your API is a resource server (not the OIDC client), it only needs to validate incoming Bearer tokens:
// Program.cs — API that accepts tokens from Azure AD
builder.Services
.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
// Authority publishes the JWKS (public keys) at /.well-known/openid-configuration
options.Authority = "https://login.microsoftonline.com/{tenantId}/v2.0";
options.Audience = "api://my-api-client-id";
options.TokenValidationParameters = new TokenValidationParameters
{
ValidateIssuer = true,
ValidateAudience = true,
ValidateLifetime = true,
ValidateIssuerSigningKey = true
};
options.Events = new JwtBearerEvents
{
OnAuthenticationFailed = ctx =>
{
// Log but don't leak exception detail to caller
var logger = ctx.HttpContext.RequestServices
.GetRequiredService<ILogger<Program>>();
logger.LogWarning("JWT validation failed: {Msg}", ctx.Exception.Message);
return Task.CompletedTask;
}
};
});The middleware downloads the JWKS from the authority on first request and caches it. Signature validation is automatic.
Scope vs Claim vs Role
| Concept | Lives In | Purpose |
|---|---|---|
| scope | access_token | What the token is allowed to do (e.g. read:orders) |
| claim | id_token / access_token | Facts about the user (e.g. email, sub, department) |
| role | access_token custom claim | Group membership for coarse authorization |
Check scopes in a policy:
builder.Services.AddAuthorization(options =>
{
options.AddPolicy("ReadOrders", policy =>
policy.RequireClaim("scp", "read:orders", "orders.read"));
options.AddPolicy("WriteOrders", policy =>
policy.RequireClaim("scp", "write:orders", "orders.write"));
});Using Google as the Identity Provider
// NuGet: Microsoft.AspNetCore.Authentication.Google
builder.Services
.AddAuthentication(...)
.AddCookie()
.AddGoogle(options =>
{
options.ClientId = builder.Configuration["Google:ClientId"]!;
options.ClientSecret = builder.Configuration["Google:ClientSecret"]!;
options.Scope.Add("email");
options.Scope.Add("profile");
// Map Google claims to standard .NET claims
options.ClaimActions.MapJsonKey(ClaimTypes.Email, "email");
options.ClaimActions.MapJsonKey(ClaimTypes.GivenName, "given_name");
});Validating Claims After Authentication
Use OnTokenValidated to enrich or reject the principal early:
options.Events = new OpenIdConnectEvents
{
OnTokenValidated = async ctx =>
{
var email = ctx.Principal!.FindFirstValue(ClaimTypes.Email);
// Only allow company domain
if (email is null || !email.EndsWith("@mycompany.com"))
{
ctx.Fail("Unauthorized domain.");
return;
}
// Optionally add claims from your own DB
var userService = ctx.HttpContext.RequestServices
.GetRequiredService<IUserService>();
var appClaims = await userService.GetClaimsAsync(email);
var appIdentity = new ClaimsIdentity(appClaims);
ctx.Principal!.AddIdentity(appIdentity);
}
};Getting the Access Token in a Controller
When SaveTokens = true, tokens are stored in the cookie:
[Authorize]
[HttpGet("call-downstream")]
public async Task<IActionResult> CallDownstream()
{
var accessToken = await HttpContext.GetTokenAsync("access_token");
_httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", accessToken);
var result = await _httpClient.GetStringAsync("https://api.downstream.com/data");
return Ok(result);
}Key Takeaways
- OAuth 2.0 = authorization; OIDC = authentication built on top of it
- Use Authorization Code + PKCE for any user-facing flow
- Use Client Credentials for service-to-service calls — no user involved
- Resource server APIs only need
AddJwtBearer— they don't do the OIDC dance - Validate the
audienceclaim — without it, a token for App A is accepted by App B - Use
OnTokenValidatedto enrich claims or gate on domain/org membership