10 Feb 2022
This example is for local (dev) environment ONLY, not for production environment
In Kubernetes branch (based on Docker branch), there are 3 main changes:
- Ingress Controller will be responsible for routing the requests to the corresponding containers
- Service endpoint will become
[x].default.svc.cluster.local
e.g. identity-core-api-service.default.svc.cluster.local
- In-Memory Resources, Clients and Users will be used in Identity Server
Similar to dockerization, it is critical to figure out each individual DNS from all the Kubernetes Service, then generate self-signed certificate. Otherwise System.Security.Authentication.AuthenticationException: The remote certificate is invalid according to the validation procedure
will be thrown.
- Install Docker Desktop
- Enable Kubernetes in Docker Desktop Settings
- Instal NGINX Ingress Controll
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.1.1/deploy/static/provider/cloud/deploy.yaml
A few pods should start in the ingress-nginx namespace:
kubectl get pods --namespace=ingress-nginx
- Generate self-signed certificate
- Add configuration file e.g. “is4-container-cert.conf” to ./Certificates folder
[req]
distinguished_name = req_distinguished_name
req_extensions = req_ext
x509_extensions = v3_ca
[req_distinguished_name]
commonName = jaylin
commonName_default = localhost
commonName_max = 64
[req_ext]
subjectAltName = @alt_names
1.3.6.1.4.1.311.84.1.1=ASN1:UTF8String:Something
[v3_ca]
subjectAltName = @alt_names
basicConstraints = critical, CA:false
keyUsage = keyCertSign, cRLSign, digitalSignature,keyEncipherment
[alt_names]
# Local domains
DNS.1 = api.local
DNS.2 = client.local
DNS.3 = is4.local
# Kubernetes
DNS.4 = *.default.svc.cluster.local
# docker-compose
DNS.5 = localhost
DNS.6 = 127.0.0.1
DNS.7 = is4
DNS.8 = api
- Open bash command and navigate to Certificates folder
- Run openssl command to generate Linux certificate
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./is4-container-cert.key -out ./is4-container-cert.crt -config ./is4-container-cert.conf
- Run openssl command to generate Windows certificate
openssl pkcs12 -export -out ./is4-container-cert.pfx -inkey ./is4-container-cert.key -in ./is4-container-cert.crt
- Create TLS secret named
k8s-tls-secret
in Kubernetes
kubectl create secret tls k8s-tls-secret --key is4-container-cert.key --cert is4-container-cert.crt
To re-create the secret, it needs deleting first, then create again:
kubectl delete secret k8s-tls-secret
- Update Dockerfile to copy the pfx to app folder in container
FROM base AS final
......
WORKDIR /app
COPY --from=publish /app/publish .
COPY ["./Certificates/is4-container-cert.pfx", "./is4-container-cert.pfx"]
ENTRYPOINT ["dotnet", "API.dll"]
- Add api yml file
apiVersion: apps/v1
kind: Deployment
metadata:
name: identity-core-api
labels:
app: identity-core-api
spec:
replicas: 1
selector:
matchLabels:
app: identity-core-api
template:
metadata:
labels:
app: identity-core-api
spec:
containers:
- name: identity-core-api
image: api:latest
imagePullPolicy: IfNotPresent
env:
- name: ASPNETCORE_ENVIRONMENT
value: Development
- name: ASPNETCORE_URLS
value: https://+:443
- name: IdentityServer__Audience
value: api1
- name: IdentityServer__Authority
value: https://identity-core-is4-service.default.svc.cluster.local
- name: ASPNETCORE_Kestrel__Certificates__Default__Password
value: P@ssword!
- name: ASPNETCORE_Kestrel__Certificates__Default__Path
value: ./is4-container-cert.pfx
---
apiVersion: v1
kind: Service
metadata:
name: identity-core-api-service
spec:
type: ClusterIP
ports:
- port: 443
targetPort: 443
protocol: TCP
name: https
selector:
app: identity-core-api
Environment variables will be almost the same as docker-compose.override.yml, Kestrel Path comes from Dockerfile at #6
- Apply to Kubernetes
- Continue #6, #7 and #8 for Client and IdentityProvider project
- Add Ingress Controller to route traffic
In below yml file, a few things need highlighting:
- rules.host must exist in tls.hosts array
- tls.hosts.secretName comes from #5
- annotations.backend-protocol is used to enforce container protocal e.g. HTTPS
- annotations.proxy-buffer-size and annotations.proxy-buffer-number are used to resolve 502 Bad Gateway issue from NGINX when redirecting to /signin-oidc from Identity Server
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: identity-core-api-ingress
labels:
app: identity-core-api-ingress
annotations:
kubernetes.io/ingress.class: nginx
nginx.ingress.kubernetes.io/backend-protocol: "HTTPS"
nginx.ingress.kubernetes.io/proxy-buffer-size: "128k"
nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
nginx.ingress.kubernetes.io/use-regex: "true"
nginx.ingress.kubernetes.io/rewrite-target: /$1
spec:
tls:
- hosts:
- api.local
- client.local
- is4.local
secretName: k8s-tls-secret
rules:
- host: api.local
http:
paths:
- path: /(.*)
pathType: Prefix
backend:
service:
name: identity-core-api-service
port:
number: 443
- host: client.local
http:
paths:
- path: /(.*)
pathType: Prefix
backend:
service:
name: identity-core-client-service
port:
number: 443
- host: is4.local
http:
paths:
- path: /(.*)
pathType: Prefix
backend:
service:
name: identity-core-is4-service
port:
number: 443
- Apply to Kubernetes
kubectl apply -f ingress.yml
- Add 3 domains in hosts
- api.local 127.0.0.1
- client.local 127.0.0.1
- is4.local 127.0.0.1
- Browse https://client.local
- Navigate to Privacy, it will redirect to Identity Server to login (username / password: scott / Password123!)
- After login successfully, it will redirect back to Privacy page
- Click on Call Api button
- API access authorized! should return
09 Feb 2022
- Identity Server failure
Npgsql.NpgsqlException (0x80004005): Exception while connecting
is4_1 | ---> System.Net.Internals.SocketExceptionFactory+ExtendedSocketException (99): Cannot assign requested address [::1]:5432
NOTE: localhost:5432 is unresolvable in container, which can only resolve db service
- Add one more environment variable in docker-compose.override.yml to overwrite ConnectionStrings:DefaultConnection in appSettings.json
is4:
environment:
- ConnectionStrings:DefaultConnection=Server=db;Port=5432;Database=IS4Database;User Id=postgres;Password=P@ssword!
Identity Server container will start working😆
- Another exception will throw after navigating to Privacy page from Client app
Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
client_1 | An unhandled exception has occurred while executing the request.
client_1 | System.InvalidOperationException: IDX20803: Unable to obtain configuration from: '[PII is hidden. For more details, see https://aka.ms/IdentityModel/PII.]'.
client_1 | ---> System.IO.IOException: IDX20804: Unable to retrieve document from: '[PII is hidden. For more details, see https://aka.ms/IdentityModel/PII.]'.
client_1 | ---> System.Net.Http.HttpRequestException: Cannot assign requested address
client_1 | ---> System.Net.Sockets.SocketException (99): Cannot assign requested address
client_1 | at System.Net.Http.ConnectHelper.ConnectAsync(String host, Int32 port, CancellationToken cancellationToken)
client_1 | --- End of inner exception stack trace ---
NOTE: Client needs to communicate to Identity Provider’s well-known endpoint and validate access token, however, it couldn’t recognize localhost:5000 but is4
- Here is the most tricky step:
- In Client project, update MetadataAddress to load well-known configuration from is4 instead of localhost:5000
// Startup.cs:
public void ConfigureServices(IServiceCollection services)
{
......
var containerHost = Configuration.GetValue<string>("IdentityServer:ContainerHost");
services.AddAuthentication(options =>
{
options.DefaultScheme = "cookie";
options.DefaultChallengeScheme = "oidc";
})
.AddCookie("cookie")
.AddOpenIdConnect("oidc", options =>
{
......
// DEV ONLY
// Container Identity Server replace well-known endpoint
// e.g. Replace http://is4 to http://localhost:5000, as localhost is unknown
options.MetadataAddress = $"{containerHost}/.well-known/openid-configuration";
});
- In Client project, replace the redirect Url from is4 to localhost:5000
public void Configure(IApplicationBuilder app)
{
......
// DEV ONLY
// Container Identity Server Redirection
// e.g. Replace http://is4 to http://localhost:5000
app.Use(async (httpcontext, next) =>
{
await next();
if (httpcontext.Response.StatusCode == StatusCodes.Status302Found)
{
var containerHost = Configuration.GetValue<string>("IdentityServer:ContainerHost");
var authority = Configuration.GetValue<string>("IdentityServer:Authority");
if (!containerHost.Equals(authority, StringComparison.OrdinalIgnoreCase))
{
string location = httpcontext.Response.Headers[Microsoft.Net.Http.Headers.HeaderNames.Location];
httpcontext.Response.Headers[Microsoft.Net.Http.Headers.HeaderNames.Location] =
location.Replace(containerHost, authority);
}
}
});
......
}
NOTE: Without this step, browser will redirect to https://is4, which can’t be resolved by browser
- Update environment variables in docker-compose.override.yml
client:
environment:
- IdentityServer:Authority=https://localhost:5000
- IdentityServer:ContainerHost=https://is4
- Certificate validation failure for domain is4
Microsoft.AspNetCore.Diagnostics.DeveloperExceptionPageMiddleware[1]
An unhandled exception has occurred while executing the request.
System.InvalidOperationException: IDX20803: Unable to obtain configuration from: '[PII is hidden. For more details, see https://aka.ms/IdentityModel/PII.]'.
---> System.IO.IOException: IDX20804: Unable to retrieve document from: '[PII is hidden. For more details, see https://aka.ms/IdentityModel/PII.]'.
---> System.Net.Http.HttpRequestException: The SSL connection could not be established, see inner exception.
---> System.Security.Authentication.AuthenticationException: The remote certificate is invalid according to the validation procedure.
NOTE: As mentioned in Identity Server - Dockerization (1) #3, the certificate ONLY allows localhost, therefore the certificate is invalid for is4
- Genereate self-signed certificate
- Add configuration file e.g. “is4-container-cert.conf” to ./Certificates folder
[req]
distinguished_name = req_distinguished_name
req_extensions = req_ext
x509_extensions = v3_ca
[req_distinguished_name]
commonName = jaylin
commonName_default = localhost
commonName_max = 64
[req_ext]
subjectAltName = @alt_names
1.3.6.1.4.1.311.84.1.1=ASN1:UTF8String:Something
[v3_ca]
subjectAltName = @alt_names
basicConstraints = critical, CA:false
keyUsage = keyCertSign, cRLSign, digitalSignature,keyEncipherment
[alt_names]
DNS.1 = localhost
DNS.2 = 127.0.0.1
DNS.3 = is4
DNS.4 = api
- Open bash command and navigate to Certificates folder
- Run openssl command to generate Linux certificate
openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout ./is4-container-cert.key -out ./is4-container-cert.crt -config ./is4-container-cert.conf
- Run openssl command to generate Windows certificate
openssl pkcs12 -export -out ./is4-container-cert.pfx -inkey ./is4-container-cert.key -in ./is4-container-cert.crt
- Update Dockerfile for each project to install crt in container
FROM base AS final
COPY ["./Certificates/is4-container-cert.crt", "/usr/local/share/ca-certificates/is4-container-cert.crt"]
RUN chmod 644 /usr/local/share/ca-certificates/is4-container-cert.crt
RUN update-ca-certificates
WORKDIR /app
COPY --from=publish /app/publish .
ENTRYPOINT ["dotnet", "IdentityProvider.dll"]
- Update Kestrel environment variables and volumes in docker-compose.override.yml for api, client and is4 services
client:
environment:
- ASPNETCORE_Kestrel__Certificates__Default__Password=P@ssword!
- ASPNETCORE_Kestrel__Certificates__Default__Path=/https/is4-container-cert.pfx
volumes:
- .\Certificates:/https/
It will create a volume to map the Certificates folder, and Kestrel Password comes from #7.4 when generating pfx certificate.
- Install pfx certificate on Windows
- Right Click on is4-container-cert.pfx
- Install PFX
- Current User
- Fill Password
- Place all certificates in the following store “Trusted Root Certification Authorities”
- Run
docker-compose up --build
- Browse https://localhost:5002
- Navigate to Privacy, it will redirect to Identity Server to login (username / password: scott / Password123!)
- After login successfully, it will redirect back to Privacy page
- Click on Call Api button
- API access authorized! should return
09 Feb 2022
This example is for local (dev) environment, not for production environment
In Docker branch (based on PostgreSQL branch), all the components will be containerized, and the whole solution can be started using docker-compose.
The challenge of dockerizing this solution is issuing self-signed certificate, so that containers can talk with each other via HTTPS. PowerShell New-SelfSignedCertificate can issue certificate, however, Linux container won’t trust it. openssl (from WSL2) is used to generate certificate.
- Add Docker Compose for each project
- Right click on project in Visual Studio > Add > Container Orchestrator Support…
- It will generate Dockerfile for each project
- It will generate docker-compose.yml and docker-compose.override.yml at the root
- Merge PostgreSQL docker-compose into the main one
version: '3.4'
services:
client:
image: ${DOCKER_REGISTRY-}client
build:
context: .
dockerfile: Client/Dockerfile
api:
image: ${DOCKER_REGISTRY-}api
build:
context: .
dockerfile: API/Dockerfile
is4:
image: ${DOCKER_REGISTRY-}is4
build:
context: .
dockerfile: IdentityProvider/Dockerfile
depends_on:
- "db"
db:
image: postgres:latest
restart: always
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: P@ssword!
POSTGRES_DB: IS4Database
adminer:
image: adminer
restart: always
ports:
- 8080:8080
NOTE: Add depends_on in Identity Server (is4) service to provision after PostgreSQL service.
- Generate certificate to support HTTPS
dotnet dev-certs https -ep %USERPROFILE%\.aspnet\https\aspnetapp.pfx -p P@ssword!
dotnet dev-certs https --trust
NOTE: The certificate generated by dotnet dev-certs can ONLY support “localhost”
- Update docker-compose.override.yml to update environment variables and volumes
is4:
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_URLS=https://+:443;http://+:80
- ASPNETCORE_Kestrel__Certificates__Default__Password=P@ssword!
- ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx
volumes:
- ~/.aspnet/https:/https:ro
NOTE: Client and API will share the same certificate by using the same Kestrel environment variables and volumes
- Update docker-compose.override.yml to map ports
- Client - 5002:443
- API - 5001: 443
- Identity Server - 5000: 443
Identity Server part:
is4:
environment:
- ASPNETCORE_ENVIRONMENT=Development
- ASPNETCORE_URLS=https://+:443;http://+:80
- ASPNETCORE_Kestrel__Certificates__Default__Password=P@ssword!
- ASPNETCORE_Kestrel__Certificates__Default__Path=/https/aspnetapp.pfx
ports:
- 5000:443
volumes:
- ~/.aspnet/https:/https:ro
- Run
docker-compose up --build
- Unfortunately, it will throw exceptions. Please go to Identity Server - Dockerization (2)
08 Feb 2022
In PostgreSQL branch, containerized PostgreSQL will be used as persistent storage, it will auto populate Operational Store and Configuration Store by Entity Framework Code First.
- Prerequisites
- .NET Core SDK 3.1
- Visual Studio (2019 or 2022)
- Docker Desktop
- WSL2
- Replace EF nuget packages in IdentityProvider project
<PackageReference Include="Npgsql" Version="5.0.0" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="3.1.18" />
Make sure this EntityFrameworkCore package is included
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.1.18" />
- In ConfigureServices.cs, replace UseSqlServer to UseNpgsql
services.AddDbContext<ApplicationDbContext>(builder =>
builder.UseNpgsql(connectionString, sqlOptions => sqlOptions.MigrationsAssembly(migrationsAssembly))
);
// EF client, scope, and persisted grant stores
identityServerBuilder
.AddOperationalStore(options =>
options.ConfigureDbContext = builder =>
builder.UseNpgsql(connectionString, sqlOptions => sqlOptions.MigrationsAssembly(migrationsAssembly)))
.AddConfigurationStore(options =>
options.ConfigureDbContext = builder =>
builder.UseNpgsql(connectionString, sqlOptions => sqlOptions.MigrationsAssembly(migrationsAssembly)));
- Run commands in Package Manager Console to generate initial database context code
Add-Migration InitialApplicationDbMigration -c ApplicationDbContext -o Migrations/ApplicationDb
Add-Migration InitialPersistedGrantDbMigration -c PersistedGrantDbContext -o Migrations/PersistedGrantDb
Add-Migration InitialConfigurationDbMigration -c ConfigurationDbContext -o Migrations/ConfigurationDb
- Update Program.cs to load settings from appSettings.jason
public static IHostBuilder CreateHostBuilder(string[] args) =>
Host.CreateDefaultBuilder(args)
.ConfigureAppConfiguration((hostContext, configurationBuilder) =>
{
IHostEnvironment env = hostContext.HostingEnvironment;
configurationBuilder.SetBasePath(Directory.GetCurrentDirectory());
configurationBuilder
.AddJsonFile("appSettings.json", optional: false, reloadOnChange: true)
.AddJsonFile($"appSettings.{env.EnvironmentName}.json", optional: true, reloadOnChange: true);
configurationBuilder.AddEnvironmentVariables();
})
.ConfigureWebHostDefaults(webBuilder =>
{
webBuilder.UseStartup<Startup>();
});
- Add docker-compose to run PosgreSQL in docker container
version: '3.4'
services:
db:
image: postgres:latest
restart: always
ports:
- 5432:5432
environment:
POSTGRES_PASSWORD: P@ssword!
POSTGRES_DB: IS4Database
adminer:
image: adminer
restart: always
ports:
- 8080:8080
adminer is the tool to manage PostgreSQL database
- Add connection string in appSettings
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Port=5432;Database=IS4Database;User Id=postgres;Password=P@ssword!"
}
}
Password comes from #6 POSTGRES_PASSWORD
- Get DefaultConnection in Startup.cs
public IConfiguration Configuration { get; }
public Startup(IConfiguration configuration)
{
Configuration = configuration;
}
public void ConfigureServices(IServiceCollection services)
{
services.AddControllersWithViews();
// using local db (assumes Visual Studio has been installed)
var connectionString = Configuration.GetConnectionString("DefaultConnection");
......
}
- Run PostgreSQL
docker-compose up --build
- F5 in Visual Studio, it will start 3 instances: Client, API and Identity Provider
- Navigate to Privacy, it will redirect to Identity Server to login (username / password: scott / Password123!)
- After login successfully, it will redirect back to Privacy page
- Click on “Call Api” button to consume weather forecast API, which is restricted to authorized user
07 Feb 2022
It’s been nearly 2 years since I posted Music Store, a few things had happened:
- Microsoft released .NET Core 3.1, .NET 5 and then .NET 6
- Duende commercialized Identity Server, and released Duende Identity Server v5 and then v6
- Implicit grant flow is deprecated, code + PKCE is recommended
Instead of updating my Music Store, I decided to continue the free version of Identity Server (v4) and use Scott Brady’s Getting Started with IdentityServer 4 as an example. In his amazing blog, he has clearly described the OAuth 2.0, OpenID Connect, and the relationship between Clients, Resources, Scopes and Users.
I forked Scott’s repo to my Identity Server 4, the master branch is Duende Identity Server v5.2, the IdentityServer4 is the branch that built upon Identity Server 4. It is EF Code First, therefore the demo data will be populated into MS SQLServer Express automatically at the first initialization.
Here is the architecture of the example:
- View Privacy page - redirect to Identity Server login (usename: scott, password: Password123!)
- Issue access token and redirect back to Client
- Consume API with access token
- Validate token from Identity Server
- Respond results