openiddict / openiddict-core

Flexible and versatile OAuth 2.0/OpenID Connect stack for .NET
https://openiddict.com/
Apache License 2.0
4.47k stars 527 forks source link

Dynamic OpenIddictClientRegistration so we do not have to hard-code the server #2192

Open gentledepp opened 1 month ago

gentledepp commented 1 month ago

Confirm you've already contributed to this project or that you sponsor it

Version

5.6.0

Question

We are implementing an app that connects to a .net 4.8 and/or .net 8 backend. We want to secure it using openiddict. However, there is a catch:

Note: This is a follow up based on this discussion

For this reason, we tried using the Maui.Essentials.WebAuthenticator approach since this just spins up some login page (which can totally be controlled on the server side) and just returns using a callback uri (deeplink into the app).

What exactly happens during the auth (which social providers are used - if any) is totally out of sight for the app, which makes this approach so elegany in our view.

However, to do so, we need a way to

Since Openiddict does not support creating tokens on-the-fly (usually only using the OWIN request pipeline) there would be two approaches for this:

1st approach: Redirect to /exchange/token

In our MobileAuthController, spin up an HttpClient and call the OpenIddict endpoint (/exchange/token) like so:


        [HttpPost]
        [Route("login")]
        public async Task<IHttpActionResult> Login([FromBody] LoginRequest request)
        {
            // Prepare the request to OpenIddict's token endpoint (/connect/token)
            using (var client = new HttpClient())
            {
                var tokenEndpoint = new Uri(Request.RequestUri,"/connect/token").ToString();

                var tokenRequest = new Dictionary<string, string>
                {
                    { "grant_type", "password" },
                    { "username", request.Username },
                    { "password", request.Password },
                    { "client_id", "the-client-id" },
                    { "client_secret", "the-client-secret" },
                    { "scope", "offline_access" },
                    {"tenancyName",request.TenancyName}
                };

                var content = new FormUrlEncodedContent(tokenRequest);

                // Send request to the token endpoint
                var response = await client.PostAsync(tokenEndpoint, content);

                if (!response.IsSuccessStatusCode)
                {
                    return BadRequest("Invalid login attempt.");
                }

                // Parse the response to extract access_token and refresh_token
                var responseString = await response.Content.ReadAsStringAsync();
                var tokenResponse = JsonConvert.DeserializeObject<TokenResponse>(responseString);

                // Build the query parameters to return back to the MAUI app
                var qs = new Dictionary<string, string>
                {
                    { "access_token", tokenResponse.AccessToken },
                    { "refresh_token", tokenResponse.RefreshToken },
                    { "expires_in", tokenResponse.ExpiresIn.ToString() },
                    { "token_type", tokenResponse.TokenType }
                };

                // Build the result URL (redirect back to MAUI app)
                var url = callbackScheme + "://#" + string.Join(
                    "&",
                    qs.Where(kvp => !string.IsNullOrEmpty(kvp.Value))
                      .Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"));

                // Redirect to the final URL
                return Redirect(url);
            }
        }

This has, however, the downside that we

2nd approach: Create the tokens on-the-fly

This is the approach you took and I honestly like it better:


        [HttpPost]
        [Route("login2")]
        public async Task<IHttpActionResult> Login2([FromBody] LoginRequest request)
        {
            // Step 1: Authenticate the user (replace with actual user authentication logic)
            // Note: We are using Aspnetboilerplate - thus, the loginmanager
            var loginResult = await _loginManager.LoginAsync(request.Username, request.Password, request.TenancyName);

            if (loginResult.Result != AbpLoginResultType.Success)
            {
                return Unauthorized();
            }

            // Step 2: Create the claims principal for the authenticated user
            var identity = new ClaimsIdentity(loginResult.Identity.Claims,
                authenticationType: OpenIddictServerOwinDefaults.AuthenticationType,
                nameType: AbpClaimTypes.UserName,
                roleType: OpenIddictConstants.Claims.Role);

            var claimsPrincipal = new ClaimsPrincipal(identity);
            claimsPrincipal.SetDestinations(static claim => OpenIddictUtil.GetDestinations(claim));

            var destins = claimsPrincipal.GetDestinations()
                .Where(k => k.Value.Contains(OpenIddictConstants.Destinations.AccessToken))
                .Select(k => k.Key).ToHashSet();

            var user = loginResult.User;

            OpenIddictServerOptions options = _options.CurrentValue;
            var descriptor = new SecurityTokenDescriptor
            {
                Claims = new Dictionary<string, object>
                {
                    { "sub", user.Id },
                    // { "scope", "your scopes" },
                }.Concat(claimsPrincipal.Claims.Where(c => destins.Contains(c.Type)).Select(c => new KeyValuePair<string, object>(c.Type,c.Value)))
                .GroupBy(kvp => kvp.Key).Select(g => g.First())
                .ToDictionary(k => k.Key, k => k.Value),
                EncryptingCredentials = options.DisableAccessTokenEncryption
                    ? null
                    : options.EncryptionCredentials.First(),
                Expires = DateTime.UtcNow.Add(options.AccessTokenLifetime??TimeSpan.FromHours(1)), // recommended to set this
                IssuedAt = DateTime.UtcNow,
                Issuer = options.Issuer?.ToString(), // the URL your auth server is hosted on, with trailing slash
                SigningCredentials = options.SigningCredentials.First(),
                TokenType = OpenIddictConstants.JsonWebTokenTypes.AccessToken,
            };
            var accessToken = options.JsonWebTokenHandler.CreateToken(descriptor);

            var descriptor2 = new SecurityTokenDescriptor
            {
                Claims = new Dictionary<string, object>
                {
                    { "sub", user.Id },
                    // { "scope", "your scopes" },
                },
                EncryptingCredentials = options.DisableAccessTokenEncryption
                    ? null
                    : options.EncryptionCredentials.First(),
                Expires = DateTime.UtcNow.Add(options.RefreshTokenLifetime??TimeSpan.FromDays(14)), // recommended to set this
                IssuedAt = DateTime.UtcNow,
                Issuer = options.Issuer?.ToString(), // the URL your auth server is hosted on, with trailing slash
                SigningCredentials = options.SigningCredentials.First(),
                TokenType = OpenIddictConstants.JsonWebTokenTypes.Private.RefreshToken
            };
            var refreshToken = options.JsonWebTokenHandler.CreateToken(descriptor2);

            // Step 6: Redirect to the MAUI app with the tokens
            var tokenResponse = new Dictionary<string, string>
            {
                { "access_token", accessToken },
                { "refresh_token", refreshToken ?? string.Empty },
                { "expires_in", ((int)TimeSpan.FromHours(1).TotalSeconds).ToString() },
                { "token_type", "Bearer" }
            };

            // Step 7: Minimalistic validation of the client and redirecturi
            var appMgr = _provider.GetRequiredService<IOpenIddictApplicationManager>();
            var app = (OpenIddictApplication)await appMgr.FindByClientIdAsync(request.ClientId);
            var validRedirectUri = app?.RedirectUris?.Contains(request.RedirectUri) ?? false;

            if (!validRedirectUri)
            {
                Logger.Warn($"requested redirectUri {request.RedirectUri} is not contained in the apps RedirectUris: {app?.RedirectUris}");
                return Unauthorized();
            }

            var fragment = $"#{string.Join("&", tokenResponse.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(kvp.Value)}"))}";
            var callbackUrl = new Uri(new Uri(request.RedirectUri),fragment);

            var logUrl = new Uri(new Uri(request.RedirectUri),$"#{string.Join("&", tokenResponse.Select(kvp => $"{WebUtility.UrlEncode(kvp.Key)}={WebUtility.UrlEncode(new string('*', kvp.Value.Length))}"))}");
            Logger.Debug($"Redirecting to {logUrl}");

            return Redirect(callbackUrl);
        }

According to @kevinchalet both approaches are a hack. Nevertheless, he admits that OAuth does not support such a scenario out-of-the box.

Well, "dynamic" clients are not officially supported by the OpenIddict (at least not yet), but you have two viable approaches to solve that:

  • Attaching the client registration corresponding to the server configured by the user to the OpenIddict client options using an IConfigureOptions<OpenIddictClientOptions> service and reloading them automatically using an IOptionsChangeTokenSource<OpenIddictClientOptions> implementation that detects when the user configured the provider details. It's my favorite approach before it simply leverages the Microsoft.Extensions.Options stack for that.
  • Using a derived OpenIddictClientService that returns OpenIddictClientRegistration instances on-the-fly. E.g:
class MyClientService(IServiceProvider provider) : OpenIddictClientService(provider)
{
    private readonly IServiceProvider _provider = provider ?? throw new ArgumentNullException(nameof(provider));
    private readonly ConcurrentDictionary<string, OpenIddictClientRegistration> _registrations = new();

    public override ValueTask<OpenIddictClientRegistration> GetClientRegistrationByIdAsync(
        string identifier, CancellationToken cancellationToken = default)
    {
        var options = _provider.GetRequiredService<IOptionsMonitor<OpenIddictClientOptions>>();

        // If a static client registration using the specified identifier was added to the client options, always prefer it.
        var registration = options.CurrentValue.Registrations.Find(registration => string.Equals(
            registration.RegistrationId, identifier, StringComparison.Ordinal));

        if (registration is not null)
        {
            return ValueTask.FromResult(registration);
        }

        if (_registrations.TryGetValue(identifier, out registration))
        {
            return ValueTask.FromResult(registration);
        }

        throw new InvalidOperationException();
    }

    public override ValueTask<OpenIddictClientRegistration> GetClientRegistrationByIssuerAsync(
        Uri uri, CancellationToken cancellationToken = default)
    {
        if (cancellationToken.IsCancellationRequested)
        {
            return ValueTask.FromCanceled<OpenIddictClientRegistration>(cancellationToken);
        }

        // If a static client registration using the specified issuer was added to the client options, always prefer it.
        var options = _provider.GetRequiredService<IOptionsMonitor<OpenIddictClientOptions>>();
        if (options.CurrentValue.Registrations.Find(registration => registration.Issuer == uri)
            is OpenIddictClientRegistration registration)
        {
            return ValueTask.FromResult(registration);
        }

        // Generate a stable client registration identifier based on the issuer URI.
        var identifier = Base64Url.EncodeToString(SHA256.HashData(Encoding.UTF8.GetBytes(uri.AbsoluteUri)));

        return ValueTask.FromResult(_registrations.GetOrAdd(identifier, _ =>
        {
            var registration = new OpenIddictClientRegistration
            {
                RegistrationId = identifier,

                Issuer = uri,
                ProviderName = "User-defined authorization server",
                ConfigurationEndpoint = new Uri(uri, ".well-known/openid-configuration"),

                ClientId = "console",

                PostLogoutRedirectUri = new Uri("callback/logout/local", UriKind.Relative),
                RedirectUri = new Uri("callback/login/local", UriKind.Relative),

                Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, "demo_api" }
            };

            // Note: unlike a static registration - for which the configuration manager is instantiated for you -
            // a dynamic registration requires attaching it manually. Caching the instance is strongly recommended.
            registration.ConfigurationManager = new ConfigurationManager<OpenIddictConfiguration>(
                registration.ConfigurationEndpoint.AbsoluteUri, new OpenIddictClientRetriever(this, registration))
            {
                AutomaticRefreshInterval = ConfigurationManager<OpenIddictConfiguration>.DefaultAutomaticRefreshInterval,
                RefreshInterval = ConfigurationManager<OpenIddictConfiguration>.DefaultRefreshInterval
            };

            return registration;
        }));
    }
}
services.Replace(ServiceDescriptor.Singleton<OpenIddictClientService, MyClientService>());
// Ask OpenIddict to initiate the authentication flow (typically, by starting the system browser).
var result = await _service.ChallengeInteractivelyAsync(new()
{
    CancellationToken = stoppingToken,
    Issuer = new Uri("https://yourprovider.com/", UriKind.Absolute)
});

AnsiConsole.MarkupLine("[cyan]Waiting for the user to approve the authorization demand.[/]");

// Wait for the user to complete the authorization process and authenticate the callback request,
// which allows resolving all the claims contained in the merged principal created by OpenIddict.
var response = await _service.AuthenticateInteractivelyAsync(new()
{
    CancellationToken = stoppingToken,
    Nonce = result.Nonce
});

Caution

It seems obvious, but using this approach means you're basically trusting any server configured by the user: it may be fine for a desktop or mobile app but may not be what you want for a web-app where you only want users to use pre-configured/trusted authorization servers: I know it's what you want, but I'm sure less careful users will end up blindly copying this snippet without necessarily realizing it 😄

In both cases, you'll need to configure the redirection and post-logout-redirection endpoints manually, since the addresses cannot be inferred from the configured registrations in that case:

options.SetRedirectionEndpointUris("callback/login/local")
       .SetPostLogoutRedirectionEndpointUris("callback/logout/local");

There are also other OAuth 2.0/OIDC libraries that offer different approaches (like IdentityModel/OidcClient) but no matter what OIDC library you decide to use, it will always be better than the non-standard flow recommended in the MAUI docs 😄

Based on these insights, we tried to follow along with this approach:

Participants:

Sequence of Events:

  1. User sets server URI:

    • The user provides the server URI in the client.
  2. Client sends riddle:

    • The client generates a truly random number.
    • The client creates a hash by combining the random number and its secret.
    • The client sends the random number and the hash to the server as the riddle.
  3. Server verifies riddle:

    • The server receives the random number and hash from the client.
    • The server uses its secret to create its own hash by combining it with the received random number.
    • The server compares its hash with the client's hash.
  4. Hash match verification:

    • If the hash doesn't match, the server responds with an error message (indicating a potential attack).
    • If the hash matches, the server sends a response proving its trustworthiness. This response includes:
      • The server's matching hash.
      • Authentication configuration (e.g., social login providers like Twitter with clientId).
  5. Client verifies server hash:

    • The client verifies the hash returned by the server.
    • If the server's hash is invalid, the client shows an error: "Please enter valid server URI."
    • If the server's hash is valid, the client proceeds to:
      • Use the retrieved authentication configuration to create an OpenIddictClientRegistration.
      • Invoke the login procedure.

Using your first recommended approach (Microsoft.Extensions.Options) its not my favorite since don't have the feeling that I can see all the moving parts. I would not even know where to start.

The second approach seems more reasonable to me since I could inject my logic here:

class MyClientService(IServiceProvider provider) : OpenIddictClientService(provider)
{
    private readonly IServiceProvider _provider = provider ?? throw new ArgumentNullException(nameof(provider));
    private readonly ConcurrentDictionary<string, OpenIddictClientRegistration> _registrations = new();

    public override ValueTask<OpenIddictClientRegistration> GetClientRegistrationByIdAsync(
        string identifier, CancellationToken cancellationToken = default)
    {
        var options = _provider.GetRequiredService<IOptionsMonitor<OpenIddictClientOptions>>();

        // If a static client registration using the specified identifier was added to the client options, always prefer it.
        var registration = options.CurrentValue.Registrations.Find(registration => string.Equals(
            registration.RegistrationId, identifier, StringComparison.Ordinal));

        if (registration is not null)
        {
            return ValueTask.FromResult(registration);
        }

        if (_registrations.TryGetValue(identifier, out registration))
        {
            return ValueTask.FromResult(registration);
        }

        throw new InvalidOperationException();
    }

    public override ValueTask<OpenIddictClientRegistration> GetClientRegistrationByIssuerAsync(
        Uri uri, CancellationToken cancellationToken = default)
    {
        if (cancellationToken.IsCancellationRequested)
        {
            return ValueTask.FromCanceled<OpenIddictClientRegistration>(cancellationToken);
        }

        // If a static client registration using the specified issuer was added to the client options, always prefer it.
        var options = _provider.GetRequiredService<IOptionsMonitor<OpenIddictClientOptions>>();
        if (options.CurrentValue.Registrations.Find(registration => registration.Issuer == uri)
            is OpenIddictClientRegistration registration)
        {
            return ValueTask.FromResult(registration);
        }

       // Generate a stable client registration identifier based on the issuer URI.
       var identifier = Base64Url.EncodeToString(SHA256.HashData(Encoding.UTF8.GetBytes(uri.AbsoluteUri)));

+       // call the server and start the process described above:
+       // - verify the server 
+       // - get the configuration from the server
+       var serverConfiguration = await ValidateServerAndRetrieveConfiguration(uri);

+      // use that server configuration to configure social providers such as twitter, google, etc. 
+      ?? How to do that

+      // Also: how to update the configuration if it changes on the server?

        return ValueTask.FromResult(_registrations.GetOrAdd(identifier, _ =>
        {
            var registration = new OpenIddictClientRegistration
            {
                RegistrationId = identifier,

                Issuer = uri,
                ProviderName = "User-defined authorization server",
                ConfigurationEndpoint = new Uri(uri, ".well-known/openid-configuration"),

                ClientId = "console",

                PostLogoutRedirectUri = new Uri("callback/logout/local", UriKind.Relative),
                RedirectUri = new Uri("callback/login/local", UriKind.Relative),

                Scopes = { Scopes.Email, Scopes.Profile, Scopes.OfflineAccess, "demo_api" }
            };

            // Note: unlike a static registration - for which the configuration manager is instantiated for you -
            // a dynamic registration requires attaching it manually. Caching the instance is strongly recommended.
            registration.ConfigurationManager = new ConfigurationManager<OpenIddictConfiguration>(
                registration.ConfigurationEndpoint.AbsoluteUri, new OpenIddictClientRetriever(this, registration))
            {
                AutomaticRefreshInterval = ConfigurationManager<OpenIddictConfiguration>.DefaultAutomaticRefreshInterval,
                RefreshInterval = ConfigurationManager<OpenIddictConfiguration>.DefaultRefreshInterval
            };

            return registration;
        }));
    }
}

What I do not like at all, is giving all the potentially sensitive configration data required for the WebProviders to work to the client. :-| Or am I too paranoid for this?