Identity Server - Kubernetes

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.

  1. Install Docker Desktop
  2. Enable Kubernetes in Docker Desktop Settings
  3. 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
    
  4. 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
      
  5. 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
    
  6. 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"]
    
  7. 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

  8. Apply to Kubernetes
    kubectl apply -f api.yml
    
  9. Continue #6, #7 and #8 for Client and IdentityProvider project
  10. Add Ingress Controller to route traffic Ingress Controller 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
    
  11. Apply to Kubernetes
       kubectl apply -f ingress.yml
    
  12. Add 3 domains in hosts
    • api.local 127.0.0.1
    • client.local 127.0.0.1
    • is4.local 127.0.0.1
  13. Browse https://client.local Client Home
  14. Navigate to Privacy, it will redirect to Identity Server to login (username / password: scott / Password123!) Identity Server Login
  15. After login successfully, it will redirect back to Privacy page Client Privacy
  16. Click on Call Api button
  17. API access authorized! should return

Identity Server - Dockerization - Part 2

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

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

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

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

  5. Update environment variables in docker-compose.override.yml
    client:
      environment:
        - IdentityServer:Authority=https://localhost:5000
        - IdentityServer:ContainerHost=https://is4
    
  6. 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

  7. 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
      
  8. 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"]
    
  9. 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.

  10. 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”
  11. Run docker-compose up --build
  12. Browse https://localhost:5002 Client Home
  13. Navigate to Privacy, it will redirect to Identity Server to login (username / password: scott / Password123!) Identity Server Login
  14. After login successfully, it will redirect back to Privacy page Client Privacy
  15. Click on Call Api button
  16. API access authorized! should return

Identity Server - Dockerization - Part 1

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.

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

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

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

  5. 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
    
  6. Run docker-compose up --build
  7. Unfortunately, it will throw exceptions. Please go to Identity Server - Dockerization (2)

Identity Server - EF to PostgreSQL

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.

  1. Prerequisites
    • .NET Core SDK 3.1
    • Visual Studio (2019 or 2022)
    • Docker Desktop
    • WSL2
  2. 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" />
    
  3. 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)));
    
  4. 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
    
  5. 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>();   
            });
    
  6. 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 Adminer Login Adminer Database View

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

  8. 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");
        ......
    }
    
  9. Run PostgreSQL
    docker-compose up --build
    
  10. F5 in Visual Studio, it will start 3 instances: Client, API and Identity Provider Client Home
  11. Navigate to Privacy, it will redirect to Identity Server to login (username / password: scott / Password123!) Identity Server Login
  12. After login successfully, it will redirect back to Privacy page Client Privacy
  13. Click on “Call Api” button to consume weather forecast API, which is restricted to authorized user

Identity Server - Example

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: Architecture

  1. View Privacy page - redirect to Identity Server login (usename: scott, password: Password123!)
  2. Issue access token and redirect back to Client
  3. Consume API with access token
  4. Validate token from Identity Server
  5. Respond results