keycloak-authorization-services-dotnet icon indicating copy to clipboard operation
keycloak-authorization-services-dotnet copied to clipboard

Not working as expected -or- I don't get it...

Open Neuroquila-n8fall opened this issue 2 years ago • 6 comments

I tried the samples but either I am expecting something completely different or it just doesn't work. Take the minimal API example: If I access an endpoint it just throws me a 401 but no auth redirect, no nothing. Shouldn't the middleware take care of that already?

The same is true for all other example projects. If I try to implement it myself I get the same result.

Neuroquila-n8fall avatar Feb 08 '23 15:02 Neuroquila-n8fall

It is the default behavior for Web API, the library is built to support the Web API in mind, so if you want to develop some MVC application with redirects and everything you need to override the default and add additional authentication schemas.

NikiforovAll avatar Feb 09 '23 08:02 NikiforovAll

Right.... I assumed that at some point. But then again when I run the most basic identity setup with keycloak the middleware takes care of redirects. When I try the examples (not the API one) I don't get redirected even though the controller setup suggests that something along those lines should happen.

I mean this is a great project and I learned a lot about keycloak specific things while browsing the source but I don't really get how to benefit from the effort you put in as a whole when using it as a package. Maybe if you could provide a small example on how to just integrate with keycloak and have the default identity behavior set up, that would be great since, again, I cannot see it working with the included examples as a custom implementation of a specific identity provider should behave.

The default and expected behavior should be that at any point when an endpoint of any sort is accessed, which is decorated with the [Authorize] attribute, the middleware should perform a redirect to the login page, when the user returns and does not have the correct permissions, a 401 should be returned.

Neuroquila-n8fall avatar Feb 11 '23 12:02 Neuroquila-n8fall

Yeah, it is something that we could do once I have time. I have an example of how to setup Keycloak + SPA-like application (Blazor WASM)

I guess you want to see an example of how to use and configure Keycloak + MVC

NikiforovAll avatar Feb 11 '23 19:02 NikiforovAll

oh i know that problem too well... take your time ;)

Neuroquila-n8fall avatar Feb 15 '23 14:02 Neuroquila-n8fall

Hi there,

I provide a free demo instance of keycloak: https://keycloak.nicedemo.de/ you can login with admin-admin. It would be great if you could show us an example there. Best thanks for your'e great work. If someone is interested, I created a nuxt 3 and vue 3 repo with keycloak authentification https://github.com/Kingside88/nuxt3-primevue-starter-auth

Kingside88 avatar Feb 17 '23 20:02 Kingside88

Just to let you know I stepped through everything and got everything working the way I want it.

What I ended up doing is:

serviceCollection.AddKeycloakAuthentication(configurationManager);
serviceCollection.AddAuthorization(options =>
{
//Profiles
}).AddKeycloakAuthorization(configurationManager);            

No surprises here. Now comes the very important bit when dealing with CORS and HTTPS redirection. First of all I am working with valid certificates in my environment so I can be sure that the production Version runs as good as the development stuff:

            //CORS Configuration
            serviceCollection.AddCors(options =>
                options.AddDefaultPolicy(
                    b => b
                        .SetIsOriginAllowedToAllowWildcardSubdomains()
                        .WithOrigins($"https://*.{corsDomain}", $"http://*.{corsDomain}")
                        .AllowAnyMethod()
                        .AllowCredentials()
                        .AllowAnyHeader()
                        .Build()
                ));

With that out of the way one little tricky bit has to be solved: The reverse proxy (traefik) is redirecting to HTTPS. Something that the whole pipeline isn't too happy about. This extension method simply switches over the scheme since it has to be HTTPS after the initial query:

    public static IApplicationBuilder UseHttpsEnforcement(this IApplicationBuilder app)
    {
        app.Use(async (ctx, next) =>
        {
            if (!ctx.Request.IsHttps)
            {
                ctx.Request.Scheme = "https";
            }

            await next();
        });

        ForwardedHeadersOptions forwardOptions = new ForwardedHeadersOptions
        {
            ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto,
            RequireHeaderSymmetry = false
        };

        forwardOptions.KnownNetworks.Clear();
        forwardOptions.KnownProxies.Clear();

        app.UseForwardedHeaders(forwardOptions);

        return app;
    }

These are the prerequisites I had to tackle in order to get the basics to work. Nothing this solution is aware of nor is designed to handle but I wanted to communicate the whole solution just in case.

Now to have the JWT stuff working as expected I had to write my own implementation as follows: (mind I'm using MassTransit to be able to scale everything up as the load goes up. Very important aspect in my scenario)

The final consumer class looks like this. First of all, the fields and DI stuff:

    private readonly IKeycloakUserClient _keycloakUserClient;
    private readonly IOptions<KeycloakInstallationOptions> _keycloakOptions;
    private readonly ILogger<AuthenticationConsumer> _logger;
    private readonly HttpClient _httpClient;

    public AuthenticationConsumer(IKeycloakUserClient keycloakUserClient, IOptions<KeycloakInstallationOptions> keycloakOptions, ILogger<AuthenticationConsumer> logger, HttpClient httpClient)
    {
        _keycloakUserClient = keycloakUserClient;
        _keycloakOptions = keycloakOptions;
        _logger = logger;
        _httpClient = httpClient;
    }

Here's the important bit: log in a user and retrieve the token:

    public async Task Consume(ConsumeContext<LoginCommand> context)
    {
        // Build the URL for the token endpoint
        var tokenEndpoint = $"{_keycloakOptions.Value.AuthServerUrl}/realms/{_keycloakOptions.Value.Realm}/protocol/openid-connect/token";

        var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint)
        {
            Content = new FormUrlEncodedContent(new Dictionary<string, string>
            {
                {"grant_type", "password"},
                {"client_id", _keycloakOptions.Value.Resource},
                {"client_secret", _keycloakOptions.Value.Credentials.Secret},
                {"username", context.Message.Username},
                {"password", context.Message.Password}
            })
        };

        request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_keycloakOptions.Value.Resource}:{_keycloakOptions.Value.Credentials.Secret}")));
        request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");


        // Send the token request to Keycloak
        var response = await _httpClient.SendAsync(request);

        // Parse the token response and retrieve the access token
        var tokenResponse = await response.Content.ReadAsStringAsync();
        if (string.IsNullOrWhiteSpace(tokenResponse))
        {
            throw new Exception("Authentication failed. Received an empty response from service.");
        }

        var token = JsonSerializer.Deserialize<TokenResponse>(tokenResponse);

        await context.RespondAsync(token ?? new TokenResponse());
    }

Wanna log out? here you go:

    public async Task Consume(ConsumeContext<LogoutCommand> context)
    {
        // Build the URL for the token revocation endpoint
        var revocationEndpoint = $"{_keycloakOptions.Value.AuthServerUrl}/realms/{_keycloakOptions.Value.Realm}/protocol/openid-connect/token/logout";

        var request = new HttpRequestMessage(HttpMethod.Post, revocationEndpoint);
        request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.Message.Token);
        request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");


        // Send the token revocation request to Keycloak
        await _httpClient.SendAsync(request);

        await context.RespondAsync(new LogoutResponse { Success = true });
    }

Or you wanna stay instead and refresh the token instead?

    public async Task Consume(ConsumeContext<RefreshTokenCommand> context)
    {
        // Build the URL for the token endpoint
        var tokenEndpoint = $"{_keycloakOptions.Value.AuthServerUrl}/realms/{_keycloakOptions.Value.Realm}/protocol/openid-connect/token";

        var request = new HttpRequestMessage(HttpMethod.Post, tokenEndpoint)
        {
            Content = new FormUrlEncodedContent(new Dictionary<string, string>
            {
                {"grant_type", "refresh_token"},
                {"client_id", _keycloakOptions.Value.Resource},
                {"client_secret", _keycloakOptions.Value.Credentials.Secret},
                {"refresh_token", context.Message.RefreshToken}
            })
        };

        request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Convert.ToBase64String(Encoding.ASCII.GetBytes($"{_keycloakOptions.Value.Resource}:{_keycloakOptions.Value.Credentials.Secret}")));
        request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded");

        // Send the token request to Keycloak
        var response = await _httpClient.SendAsync(request);

        // Parse the token response and retrieve the new access token
        var tokenResponse = await response.Content.ReadAsStringAsync();
        if (string.IsNullOrWhiteSpace(tokenResponse))
        {
            throw new Exception("Token refresh failed. Received an empty response from service.");
        }

        var token = JsonSerializer.Deserialize<TokenResponse>(tokenResponse);

        await context.RespondAsync(token ?? new TokenResponse());
    }

Yes, all and good but what about redirecting to the login form and catch the resulting response? Here we have to tweak things a little bit. First of all you have to tell the middleware that we are expecting a cookie. I basically ended up rolling my own implementation. Later I found out that the keycloak package will do 90% of the heavy lifting. The only things you really have to care about is the "AddOpenIdConnect" method. Also you need to define the default authentication scheme or else the middleware gets confused and yells at you and asks where the default scheme is.

Also note that in this code there is an option class which reads the following configuration from the appsettings.config:

  "OpenId": {
    "CookieDomain": "your-cookie-domain.com"
  },

The keycloakConfig object is the very same KeycloakInstallationOptions class from within the package which reads the config from the Keycloak section fo your configuration file(s).

services.AddAuthentication(options =>
            {
                options.DefaultAuthenticateScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultSignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.DefaultChallengeScheme = OpenIdConnectDefaults.AuthenticationScheme;
            })
            .AddOpenIdConnect(options =>
            {
                options.SignInScheme = CookieAuthenticationDefaults.AuthenticationScheme;
                options.Authority = keycloakConfig?.AuthServerUrl;
                options.ClientId = keycloakConfig?.Resource;
                options.ClientSecret = keycloakConfig?.Credentials.Secret;
                options.MetadataAddress = keycloakConfig?.KeycloakUrlRealm + "/.well-known/openid-configuration";
                options.RequireHttpsMetadata = true;
                options.GetClaimsFromUserInfoEndpoint = true;
                options.ResponseType = OpenIdConnectResponseType.Code;
                options.Scope.Add("openid");
                options.Scope.Add("profile");
                options.Scope.Add("email");
                options.SaveTokens = true;

                options.TokenValidationParameters = new TokenValidationParameters
                {
                    NameClaimType = "name",
                    RoleClaimType = ClaimTypes.Role,
                    ValidateIssuer = true
                };
            })
            .AddCookie(cookie =>
            {
                cookie.Cookie.MaxAge = TimeSpan.FromMinutes(60);
                cookie.Cookie.SecurePolicy = CookieSecurePolicy.SameAsRequest;
                cookie.Cookie.SameSite = SameSiteMode.Lax;
                cookie.Cookie.Domain = openIdConfig?.CookieDomain;
                cookie.SlidingExpiration = true;
            });

Yes it's that easy since the middleware will handle the rest. You only need to decorate the controller with the [Authorize] attribute and the middleware will automatically forward to the login form and set things up when returning with a valid session. So you end up with something like this for example (if you really insist on having a "Login" endpoint, that is ;) )

        [Route("sign-in")]
        [Authorize]
        public async Task<IActionResult> Login()
        {
            return Ok();
        }

Logout (more or less standard approach)

        [Route("sign-out")]
        [Authorize]
        public async Task Logout([FromQuery] string? redirectUrl)
        {
            await HttpContext.SignOutAsync(CookieAuthenticationDefaults.AuthenticationScheme);
            if (string.IsNullOrEmpty(redirectUrl))
            {
                var prop = new AuthenticationProperties
                {
                    RedirectUri = redirectUrl
                };
                await HttpContext.SignOutAsync(OpenIdConnectDefaults.AuthenticationScheme, prop);
            }

        }

It's worthy to note that when using the JWT approach you will never get the login redirect, of course.

Since I realized that my frontend will be developed outside of dotnet core, I switched over to JWT tokens altogether. The frontend dev will have to make sure that the token is up-to-date and passed into the APIs accordingly.

What I'm missing from this package is now the possibility to set the auth options directly without rolling my own implementation. It's very focused on JWT which made my life very hard because I didn't realize what's going on under the cover until I found some time and analyzed the source code.

@NikiforovAll Consider this "issue" resolved ;) Close it or let it stick around a bit - your choice :) Maybe you can extract some of my findings for the purpose of making this package a little bit more easier to work with. Maybe I'll find some time to bake this into a PR. I only just realized you're from Ukraine. Hope you're safe!

Neuroquila-n8fall avatar Mar 14 '23 15:03 Neuroquila-n8fall