-
Notifications
You must be signed in to change notification settings - Fork 0
how it works authentication
IdentityServer4 is an OAuth2 and OpenIDConnect compliant solution and is added as middleware to the Identity Service
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();
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
andCustomerService.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
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
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
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
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
- Health Checks UI
- Mvc Frontend
- Web Backend-For-Frontend
- Address Service
- Address Worker
- Identity Service
- Order Service
- Order Worker
- Tyres Service