Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Generating a token on behalf of a user #1241

Closed
Rohansi opened this issue Apr 7, 2021 · 14 comments
Closed

Generating a token on behalf of a user #1241

Rohansi opened this issue Apr 7, 2021 · 14 comments

Comments

@Rohansi
Copy link

Rohansi commented Apr 7, 2021

Is your feature request related to a problem?

I am trying to implement an IFTTT service and am using OpenIddict to handle authentication. Everything is working so far but they have an automated test suite for services which requires me to provide them with a valid auth token for a test user. Passing these automated tests is required for publishing services so this blocks me from publishing an IFTTT service that uses OpenIddict.

Describe the solution you'd like

I would like to be able to generate a token on behalf of a user/subject so that I can provide them to IFTTT's test system.

Additional context

IFTTT enforces that services restrict access to only IFTTT by validating the IFTTT-Service-Key authorization header so there should be no security concern here.

@kevinchalet
Copy link
Member

OpenIddict only generates tokens in the context of an OIDC request. For your scenario, you'll likely directly want to create the tokens using IdentityModel, the MSFT stack OpenIddict uses to generate tokens.

@Rohansi
Copy link
Author

Rohansi commented Apr 10, 2021

Thank you. I dug through the code and found out how to fill my needs here.

If anyone else needs to do this in the future, here's what I did. Ideally nobody should ever be using this, but unfortunately IFTTT requires it for their automated tests.

Get a reference to IOptionsMonitor<OpenIddictServerOptions> _oidcOptions via DI. That reference can then be used to get everything needed to generate an access token:

var options = _oidcOptions.CurrentValue;
var descriptor = new SecurityTokenDescriptor
{
    Claims = new Dictionary<string, object>
    {
        { "sub", "your user id" },
        { "scope", "your scopes" },
    },
    EncryptingCredentials = options.DisableAccessTokenEncryption
        ? null
        : options.EncryptionCredentials.First(),
    Expires = null, // recommended to set this
    IssuedAt = DateTime.UtcNow,
    Issuer = "https://contoso.com/", // 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);

@amansulaiman
Copy link

@Rohansi thanks for this, it saves me a lot of time to dig into the source code
Thanks for sharing this

@thirty7four2
Copy link

@Rohansi Thank you! This saved my day!

@gentledepp
Copy link

Thank you for this input!

We've had troubles implementing our maui essentials web authenticator backend.
There is a sample for this here but it only supports social logins (Github, Google, Microsoft, etc.)

There were basically 2 ways we could implement 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

  • need to call our own server within a request context opening the box of pandora of issues (e.g. server does not know its own FQDN, firewall, etc.) and it is less efficient
  • and the need for us to then enable the resource owner password credentials flow.

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);
        }

However, I am not sure how safe this is. :-| Any thoughts?

Note: Our server is installed on multiple sites (customers) with its own FQDN. But the app is installed via App Store/Google Play/Microsoft Store.
So we cannot know beforehand the FQDN of the server. Also, we cannot know how the social logins are configured. (Every customer might use its own Azure Entry Id!)

So we need a generic login page that can be controlled from the server and that - upon successful authentication - redirects to the uri the app requires for the Maui Essentials webauthenticator.

@kevinchalet
Copy link
Member

Both options are absolutely terrible (and the reason why I don't want to encourage users to "generate their own tokens" outside a standard flow).

The correct approach is to use a proper OIDC client library in your MAUI app - e.g OpenIddict: https://github.com/openiddict/openiddict-core/tree/dev/sandbox/OpenIddict.Sandbox.Maui.Client 🤣 - and use the OIDC code flow to communicate interactively with your OIDC server in a completely standard way. Everything else is just a hack, IMHO.

@gentledepp
Copy link

Well, could be, but having had a look at your sample, I have the following problems:

  1. The Issuer cannot be known at compile or releae-time. Because every customer may host the server under a totally different URL.
    Since your client implementation requires us to register/configure the issuer URL at application startup, we cannot use this approach.

In our app, the first thing you will see is a screen asking for the URL of the server you'd like to connect to (which then is the issuer)

  1. For the WebProviders we have the same issue:
    In this line you configure the available social login providers.
    However:
  • any customer can configure their own providers on their own servers.
  • and as our server is multi-tenant capable, on one URL you can have different webproviders (per tenant) configured.

So given this, how can we actually use the client library? :-|

And please don't be humble - you're the professional here 😅

@kevinchalet
Copy link
Member

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 😄

@gentledepp
Copy link

I am really greatful for all your insights!
But I am still not 100% sure how to poceed.

Here is what I am planning to do:

Participants:

  • Client
  • Server

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) ist not my favorite since dont 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 understand is how I could (see code above) configure the WebProviders.
  • And if i should really cache the OpenIddictClientRegistration. Because inbetween two login attempts, the server could be reconfigured to support another social login provider.
    ->So essentially, on every login attempt (i.e. when the user enter the url and hits "login"), we should re-retrieve all configuration from the server and update the local WebProviders configuration.

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?

@kevinchalet
Copy link
Member

@gentledepp looks like we're now a bit off-topic. If your company is sponsoring the project and you need assistance with your scenario, please open a dedicated thread and I'll add some thoughts there 😃

Cheers.

@gentledepp
Copy link

I am a personal supporter. Please let me know if my/our compensation is not enough.
Also: I could (if appreciated) create a sample for this - since PAAS is quite the norm today.

Created a new issue: #2192

@kevinchalet
Copy link
Member

I am a personal supporter. Please let me know if my/our compensation is not enough.

Support for professional projects - i.e non-hobby projects - is only offered to $25/month (and higher) sponsors, even if you're sponsoring OpenIddict using a personal GitHub account (otherwise, that would be a terribly easy way to abuse the system).

I encourage you to ask your employer to sponsor the project as there's no reason it should come out of your own pocket 😄

@gentledepp
Copy link

still trying to get this approved. I am fiddeling on my own in the meantime 😅

@kevinchalet
Copy link
Member

still trying to get this approved.

No worries 👍🏻

I am fiddeling on my own in the meantime 😅

AFAICT, GitHub has already taken your personal sponsorship cancellation into account.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

5 participants