Confirm you've already contributed to this project or that you sponsor it
[X] I confirm I'm a sponsor or a contributor
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:
This server can be hosted by clients them selves (on-prem) so we cannot hard-code the issuer URL
The server runs in the cloud as PAAS, thus, supporting multi-tenancy. Every tenant should be able to configure its own social providers (tenant-id is passed as cookie)
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
show a login page (with potentially social providers - just as in the typcal asp.net core identity samples)
and if the user logs-in successfully, redirect to some registered URI (e.g. xamarinessentials://) providing the access_token and the refresh_token as URL fragments.
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
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);
}
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;
}));
}
}
// 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:
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:
Client
Server
Sequence of Events:
User sets server URI:
The user provides the server URI in the client.
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.
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.
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).
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 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?
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 anHttpClient
and call the OpenIddict endpoint (/exchange/token) like so: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:
According to @kevinchalet both approaches are a hack. Nevertheless, he admits that OAuth does not support such a scenario out-of-the box.
Based on these insights, we tried to follow along with this approach:
Participants:
Sequence of Events:
User sets server URI:
Client sends riddle:
Server verifies riddle:
Hash match verification:
Client verifies server hash:
OpenIddictClientRegistration
.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:
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?