Skip to content

how it works authentication

Shaylen Reddy edited this page Jun 15, 2024 · 3 revisions

Authentication

IdentityServer4

IdentityServer4 is an OAuth2 and OpenIDConnect compliant solution and is added as middleware to the Identity Service

How It's Added

It all starts in Program.cs

IdentityServer4 is added to the services collection

builder.Services.AddIdentityServer(options =>
{
    options.Events.RaiseErrorEvents = true;
    options.Events.RaiseInformationEvents = true;
    options.Events.RaiseFailureEvents = true;
    options.Events.RaiseSuccessEvents = true;

    options.EmitStaticAudienceClaim = true;

    options.Authentication.CookieSameSiteMode = SameSiteMode.None;
})
    .AddConfigurationStore(options =>
    {
        options.ConfigureDbContext = builder => 
            builder.UseSqlServer(
                connectionString, 
                options =>
                {
                    options.MigrationsAssembly(assemblyName);
                    options.EnableRetryOnFailure(maxRetryCount: 5);
                });
    })
    .AddOperationalStore(options =>
    {
        options.ConfigureDbContext = builder => 
            builder.UseSqlServer(
                connectionString, 
                options => 
                { 
                    options.MigrationsAssembly(assemblyName);
                    options.EnableRetryOnFailure(maxRetryCount: 5);
                });

        options.EnableTokenCleanup = true;
    })
    .AddAspNetIdentity<Customer>()
    .AddExtensionGrantValidator<TokenExchangeExtensionGrantValidator>()
    .AddSigningCredential(builder.GenerateSigningCredentialsFromConfiguration());

However, this is just the start

Here's some services that are added in addition to make up the entire solution for IdentityServer4

var connectionString = builder.Configuration["Database:ConnectionString"];
var assemblyName = typeof(Program).Assembly.GetName().Name;

builder.Services.AddDbContext<CustomerDbContext>(options =>
{
    options.UseSqlServer(
        connectionString, 
        options =>
        {
            options.MigrationsAssembly(assemblyName);
            options.EnableRetryOnFailure(maxRetryCount: 5);
        });
});

builder.Services.AddIdentity<Customer, IdentityRole<Guid>>()
    .AddEntityFrameworkStores<CustomerDbContext>()
    .AddRoles<IdentityRole<Guid>>()
    .AddDefaultTokenProviders();

builder.Services.AddScoped<AdminAccountSeeder>();
builder.Services.AddScoped<ConfigurationDataSeeder>();

Once the call to builder.Build() is made, its middleware is added to the request pipeline

app.UseIdentityServer();

How It's Configured

IdentityServer4 uses a Config file that holds all the models that'll be mapped to entities and added to it's configuration store

These have to seeded to the database which is why the ConfigurationDataSeeder service exists

Of course, I'm not going to add the entire Config file here, however, I will provide a sample of each and explain how it's all related

Here's a sample of an identity resource, this contains user claims that may become a part of the tokens issued by IdentityServer4

public static List<IdentityResource> IdentityResources => new()
{
    new IdentityResource(
        name: "role",
        displayName: "Your roles",
        userClaims: new List<string> { ClaimTypes.Role })
};

As I learnt the hard way, user claims are not in the access token by default 😂 and have to be catered for

Here's a sample of an api scope, this ties in with api resources, and when a client requests a certain api scope, the name of the api resource becomes an audience in the access token

public static List<ApiScope> ApiScopes => new()
{
    new ApiScope("SeelansTyresWebBff.fullaccess")
};

Here's the api resource that scope is tied to

public static List<ApiResource> ApiResources => new()
{
    new ApiResource(
        name: "SeelansTyresWebBff", 
        displayName: "Seelan's Tyres Mvc Bff", 
        userClaims: new[] { ClaimTypes.Role })
    {
        Scopes = { "SeelansTyresWebBff.fullaccess" }
    }
};

When a client requests SeelansTyresWebBff.fullaccess as a scope, SeelansTyresWebBff is the audience

Here's a sample of a client's configuration

public static List<Client> Clients => new()
{
    new Client
    {
        ClientId = Configuration!["Clients:SeelansTyresMvcClient:ClientId"],
        ClientName = "Seelan's Tyres Mvc Frontend",
        ClientSecrets = { new Secret(Configuration!["Clients:SeelansTyresMvcClient:ClientSecret"].Sha256()) },
        AllowedGrantTypes = GrantTypes.CodeAndClientCredentials,
        RedirectUris = { $"{Configuration!["Clients:SeelansTyresMvcClient:Url"]}/signin-oidc" },
        PostLogoutRedirectUris = { $"{Configuration!["Clients:SeelansTyresMvcClient:Url"]}/signout-callback-oidc" },
        AlwaysSendClientClaims = true,
        AllowOfflineAccess = true,
        AllowedScopes = 
        { 
            "openid", "profile", "role",
            "SeelansTyresWebBff.fullaccess",
            "CustomerService.createaccount",
            "CustomerService.retrievesinglebyemail",
            "CustomerService.resetpassword"
        }
    }
};

What's important for a client is the ClientId and ClientSecret

The GrantTypes indicates the OpenIDConnect flows that are allowed to be used by the client

In this case, its the authorization_code and client_credentials flows

The authorization_code flow is used when a user is involved in the authentication process

The client_credentials flow is often described as a machine-to-machine authentication process whereby a user is not present and can never be present

Cases where this happens:

  • When a customer wants to create an account [the CustomerService.createaccount scope]
  • When a customer wants to reset their password [the CustomerService.retrievesinglebyemail and CustomerService.resetpassword scopes]

How these scopes are used will be explained in another wiki page about custom authorization requirements and policies

The AllowOfflineAccess = true allows the client to request the offline_access scope for long-lived access

The Mvc Frontend Client

How OpenIDConnect is Configured

Here's the call to AddAuthentication on the service collection

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = CookieAuthenticationDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
})
    .AddCookie(CookieAuthenticationDefaults.AuthenticationScheme)
    .AddOpenIdConnect(OpenIdConnectDefaults.AuthenticationScheme, options =>
    {
        options.ClientId = builder.Configuration["ClientCredentials:ClientId"];
        options.ClientSecret = builder.Configuration["ClientCredentials:ClientSecret"];

        options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
        options.Authority = builder.Configuration["IdentityServer"];
        options.RequireHttpsMetadata = false;
        
        options.ResponseType = "code";
        options.SaveTokens = true;
        
        options.GetClaimsFromUserInfoEndpoint = true;

        options.Scope.Add("SeelansTyresWebBff.fullaccess");
        options.Scope.Add("offline_access");
        options.Scope.Add("role");

        options.ClaimActions.MapUniqueJsonKey(ClaimTypes.Role, ClaimTypes.Role);
    });

Whenever configuring for OpenIDConnect, cookie authentication must be the default scheme

The tokens received from the identity provider [IdentityServer4] is saved into an encrypted cookie that is passed with each request from the user's browser to the server for authentication

This is set with the following lines:

options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;

options.SaveTokens = true;

If a user isn't authenticated, the authentication middleware invokes the default challenge scheme, which is OpenIDConnect

This redirects the client to the identity provider to challenge the user for credentials

The authentication middleware retrieves the discovery document from the identity provider - and configures itself - at the /.well-known/openid-configuration endpoint which provides all the capabilities of the identity provider

Sequence Diagram

Here's an overly simplified sequence diagram to provide a visual aid for this explanation

sequenceDiagram
    actor user as User
    participant mvc as Mvc Frontend
    participant identityservice as Identity Service
    user ->> mvc: Clicks 'Login'
    mvc ->> mvc: Default challenge scheme is invoked
    mvc ->> identityservice: Redirected to the identity provider
    identityservice -->> user: Challenges for login credentials
    user ->> identityservice: Provides valid login credentials
    identityservice ->> identityservice: User session begins at the identity provider
    identityservice -->> mvc: Issues id_token, access_token and refresh_token
    mvc ->> mvc: Saves tokens in a cookie, creates a claims principal and logs in the user

Loading

There's much MUCH more going on under the hood to make it all work, and to be honest, this wiki page can become a mini-textbook if I explained it all in detail, and I'd definitely butcher some of the explanation too 😂

NOTE: Token samples are provided along with how jwt authentication works here

How Access Tokens Are Sent Downstream

The code to manage access tokens is in Program.cs

builder.Services.AddAccessTokenManagement();

builder.Services.AddHttpClient<IAddressService, AddressService>(client =>
{
    client.BaseAddress = new Uri(builder.Configuration["Services:AddressService"]!);
    client.DefaultRequestHeaders.Accept.Add(new(Application.Json));
})
    .AddUserAccessTokenHandler()
    .AddCommonResiliencyPolicies<AddressService>(builder.Services);

Usually, an access token has to be retrieved from the HttpContext using GetTokenAsync() and added to the outgoing request manually

The AddUserAccessTokenHandler() extension on the IHttpClientBuilder is a delegating handler that provides that functionality, as well as figure out when an access token is about to expire and performs a token refresh with the identity provider for a new access token

Clone this wiki locally