.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.

C#
// 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):

C#
// 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:

C#
// 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:

C#
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

C#
// 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:

C#
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:

C#
[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 audience claim — without it, a token for App A is accepted by App B
  • Use OnTokenValidated to enrich claims or gate on domain/org membership