This is part 2 of a blog series on containerizing a Duende IdentityServer and a client application. In this post, we resolve communication challenges that arise when these applications run in separate Docker containers. You’ll learn how to fix back-channel issues, handle localhost conflicts, and establish proper networking between the client and IdentityServer.
This blog has been broken up into four separate posts:
- IdentityServer in Docker Containers: Adding Containers (Part 1)
- IdentityServer in Docker Containers: Networking (Part 2)
- IdentityServer in Docker Containers: HTTPS and SameSite (Part 3) (coming soon)
- IdentityServer in Docker Containers: Handle Logout (Part 4) (coming soon)
If you want to view the final solution, the code for each step and the final code can be found on GitHub here.
Fixing the IdentityServer Back-Channel (Attempt #2)
In the previous post, we learned we can’t use localhost from within the containers, so what should we do instead?
Docker Compose automatically creates a default network for all services defined in the docker-compose.yml file. This network allows containers to reach each other using their service names as DNS names.
In our setup, the client container should communicate with the identity container using http://identity:7000 rather than http://localhost:7000.
}).AddOpenIdConnect(options =>
{
options.Authority = "http://identity:7000";
...
};
Here, identity is the name of the IdentityService container as defined in our Docker Compose file.
But when we run this, we’ll still encounter an error. Why?
The issue lies in how Docker Compose handles container communication. Services within Docker Compose communicate using their service names and the internal container ports they expose, not the external ports.
This means that while external clients (such as a web browser or Postman) outside the Docker network should use port 7000 to reach the IdentityService, containers within Docker should use the internal port, which is 80.
To resolve this, we need to adjust the port in the code to use port 80 instead, like this:
}).AddOpenIdConnect(options =>
{
options.Authority = "http://identity:80";
...
};
This configuration change ensures that the client container can successfully communicate with the identity container using the correct internal port.
However, this still needs to be fixed!
We end up with the browser trying to reach http://identity/connect/authorize, but obviously, we can’t use this outside Docker. Identity is only a name that can be used within the Docker network.
We could resolve this by adding the Identity hostname to the operating system’s host file. However, I prefer a solution that doesn’t require modifying this file.
The Issue with Setting the Authority in IdentityServer
Setting the authority in the client to http://identity:80 might seem like the obvious solution, but it introduces a few complications!
In our current setup, the Authority configuration serves three distinct purposes:
- Retrieving Configuration Documents: It is used by the OpenID Connect authentication middleware in the client container to retrieve the /.well-known/openid-configuration and /.well-known/openid-configuration/jwks documents from IdentityServer.
- OpenID Connect Flows: The authority URL is crucial during various OpenID Connect flows, including authentication, token exchange, and sign-out.
- Browser Redirection: It also tells the browser where to redirect the user when authentication is required so that the user can authenticate.
The problem arises because the client container needs to use http://identity:80 to reach IdentityServer from within the Docker network. In contrast, the browser (which is outside the Docker network) needs to use http://localhost:7000 to reach IdentityServer.
This mismatch between the internal and external URLs complicates the situation.
Introducing the MetaDataAddress (Attempt #3)
To try to address this, we can introduce the MetadataAddress property in our configuration, as shown below:
.AddOpenIdConnect(options =>
{
options.Authority = "http://localhost:7000";
options.MetadataAddress = "http://identity:80";
...
};
By setting these two properties, we try to achieve the following:
- Authority: This is set to the externally accessible URL of IdentityServer (http://localhost:7000).
- MetadataAddress: This points to where the OpenID Connect authentication handler can reach IdentityServer internally (http://identity:80). This allows the client container to correctly obtain the needed configuration documents from IdentityServer.
With this configuration, we maintain compatibility for both internal container-to-container communication and external browser redirects.
The application still does not work, and so we get a new exception in the browser:
JsonException: IDX10805: Error deserializing json: '
...
How do we solve this?
Fixing the IdentityServer MetadataAddress (Attempt #4)
The MetadataAddress property in OpenID Connect should be set to the absolute URL of the metadata document, not just the base URL. This is an essential distinction because MetadataAddress specifies the exact endpoint from which the OpenID Connect middleware will retrieve the configuration.
The solution is to change the configuration to:
.AddOpenIdConnect(options =>
{
options.Authority = "http://localhost:7000";
options.MetadataAddress = "http://identity:80/.well-known/openid-configuration";
...
};
Now, retrieving the metadata from IdentityServer should work. However, we are back to the problem where the browser tries to reach http://identity/connect/authorize.
Observing the Back-Channel Communication
The client’s OpenID Connect authentication handler occasionally makes HTTP requests to the authorization server (IdentityServer), which is known as the back channel. By default, this communication could be more visible from the outside. However, implementing a custom backchannel handler to log these interactions can make it more transparent.
To implement this, we create a new delegating handler named BackChannelListener, implemented as follows:
public class BackChannelListener : DelegatingHandler
{
public BackChannelListener() : base(new HttpClientHandler())
{
}
protected async override Task SendAsync(HttpRequestMessage request,
CancellationToken token)
{
var sw = new Stopwatch();
sw.Start();
var result = await base.SendAsync(request, token);
sw.Stop();
Log.Logger.ForContext("SourceContext", "BackChannelListener")
.Information($"### BackChannel request to {request?.RequestUri?.AbsoluteUri} took {sw.ElapsedMilliseconds.ToString()} ms");
return result;
}
}
This implementation will measure the time it takes for each request to complete and log it using Serilog. This will give us insights into the back-channel communication between the client and IdentityServer.
Next, we configure the OpenID Connect handler to use this handler:
.AddOpenIdConnect(options =>
{
...
options.BackchannelHttpHandler = new BackChannelListener();
options.BackchannelTimeout = TimeSpan.FromSeconds(30);
});
With this setup, we should be able to see in the logs each time the client makes a request to IdentityServer over the back-channel, like the examples below:
### BackChannel request to http://identity/.well-known/openid-configuration took 362 ms
### BackChannel request to http://identity/.well-known/openid-configuration/jwks took 30 ms
These logs provide valuable visibility into internal HTTP communication, helping us better understand the performance and behavior of back-channel requests.
However, our application still can’t authenticate; let’s address that in the next attempt.
Understanding the Discovery Document Issue (Attempt #5)
If we run the application now, we still can’t authenticate successfully because the browser tries to access
http://identity/connect/authorize… instead of the expected URL.
To understand what’s happening, we can add a minor tweak to our BackChannelListener to log the back-channel responses from IdentityServer to the console. This will give us insight into what the client receives.
We add this code to the handler:
// HACK: log the response body; never run this in production
Log.Logger.ForContext("SourceContext", "BackChannelListener")
.Information("#####################################");
Log.Logger.ForContext("SourceContext", "BackChannelListener")
.Information(responseContent);
Log.Logger.ForContext("SourceContext", "BackChannelListener")
.Information("#####################################");
⚠️Important⚠️
The back-channel request and response contain sensitive information, such as tokens or configuration data. Therefore, you should only log this information during troubleshooting and never in a production environment.
With this in place, we can see that the client correctly retrieves the discovery document as expected when we attempt to authenticate. The log shows the following output:
{
"issuer":"http://identity",
"jwks_uri":"http://identity/.well-known/openid-configuration/jwks",
"authorization_endpoint":"http://identity/connect/authorize"
...
}
However, when we navigate to the discovery document directly from our browser at
http://localhost:7000/.well-known/openid-configuration, we get a different result:
{
"issuer":"http://localhost:7000",
"jwks_uri":"http://localhost:7000/.well-known/openid-configuration/jwks",
"authorization_endpoint":"http://localhost:7000/connect/authorize"
...
}
The issuer URL changes depending on where the request is made:
- Inside Docker: http://identity
- Outside Docker: http://localhost:7000
This discrepancy occurs because the identity provider’s issuer value is dynamically generated based on the origin of the incoming request URL.
Solving The Initial Authentication Problem (Attempt #6)
The main issue is that when we click the log-in button, the application receives information from IdentityServer indicating that the authorization endpoint is http://identity/connect/authorize. Naturally, it tries to redirect the browser to that URL for user authentication using the authorization code flow.
To address this in a development environment, we can add the following event handler to intercept the redirect and adjust the URL to the correct one:
.AddOpenIdConnect(options =>
{
...
options.Events.OnRedirectToIdentityProvider = context =>
{
context.ProtocolMessage.IssuerAddress =
"http://localhost:7000/connect/authorize";
return Task.CompletedTask;
};
});
Depending on your deployment setup, you may need to implement a different strategy in production. The key takeaway here is to understand the root cause of the issue.
Important!⚠️
Let me know if you have a better approach to solving this problem!
With this change in place, the application should now correctly redirect to IdentityServer and display the login screen as expected.
Great! Are we done?
Not quite yet! When we try to log in with the default bob/bob user, the form doesn’t work. ☹
What’s next
In the next post, we’ll address the login form issue and introduce HTTPS to secure communication between the host and the containers, enabling successful and secure authentications.