I've succesfully used WinForms sample from IdentityModel.OidcClient v2 to invoke an API secured with IdentityServer4.
IS is configured with two external providers, Google and ADFS; implementation is based on IS4 quickstarts.
Authentication works fine, WinForms application receives a valid refresh token and is able to invoke a secured API, but I'm confused by the external login callback behavior.
After succesful login, the embedded browser closes and default browser is opened (Chrome in my laptop), and reaches the ExternalLoginCallback.
Then the WinForms gets the refresh token, but then chrome tab stays open and is redirected to the IS login page.
How can I prevent showing / close chrome browser window? Do I have to tweak the ExternalLogin action?
Update
Adding client code and lib/server info:
WinForm client with IdentityModel v 3.0.0 IdentityModel.OidcClient 2.4.0 asp.net mvc server with IdentityServer4 version 2.1.1 IdentityServer4.EntityFramework 2.1.1
Following WinForm client code:
public partial class SampleForm : Form
{
private OidcClient _oidcClient;
private HttpClient _apiClient;
public SampleForm()
{
InitializeComponent();
var options = new OidcClientOptions
{
Authority = "http://localhost:5000",
ClientId = "native.hybrid",
ClientSecret = "secret",
Scope = "openid email offline_access myscope myapi1 myapi2",
RedirectUri = "http://localhost/winforms.client",
ResponseMode = OidcClientOptions.AuthorizeResponseMode.FormPost,
Flow = OidcClientOptions.AuthenticationFlow.Hybrid,
Browser = new WinFormsEmbeddedBrowser()
};
_oidcClient = new OidcClient(options);
}
private async void LoginButton_Click(object sender, EventArgs e)
{
AccessTokenDisplay.Clear();
OtherDataDisplay.Clear();
var result = await _oidcClient.LoginAsync(new LoginRequest());
if (result.IsError)
{
MessageBox.Show(this, result.Error, "Login", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
else
{
AccessTokenDisplay.Text = result.AccessToken;
var sb = new StringBuilder(128);
foreach (var claim in result.User.Claims)
{
sb.AppendLine($"{claim.Type}: {claim.Value}");
}
if (!string.IsNullOrWhiteSpace(result.RefreshToken))
{
sb.AppendLine($"refresh token: {result.RefreshToken}");
}
OtherDataDisplay.Text = sb.ToString();
_apiClient = new HttpClient(result.RefreshTokenHandler);
_apiClient.BaseAddress = new Uri("http://localhost:5003/");
}
}
private async void LogoutButton_Click(object sender, EventArgs e)
{
//await _oidcClient.LogoutAsync(trySilent: Silent.Checked);
//AccessTokenDisplay.Clear();
//OtherDataDisplay.Clear();
}
private async void CallApiButton_Click(object sender, EventArgs e)
{
if (_apiClient == null)
{
return;
}
var result = await _apiClient.GetAsync("identity");
if (result.IsSuccessStatusCode)
{
OtherDataDisplay.Text = JArray.Parse(await result.Content.ReadAsStringAsync()).ToString();
}
else
{
OtherDataDisplay.Text = result.ReasonPhrase;
}
}
}
Update 2
ExternalLoginCallback code:
public async Task<IActionResult> ExternalLoginCallback()
{
// read external identity from the temporary cookie
var result = await HttpContext.AuthenticateAsync(IdentityServer4.IdentityServerConstants.ExternalCookieAuthenticationScheme);
if (result?.Succeeded != true)
{
_logger.LogError(result.Failure, "External athentication error.");
throw new Exception("External authentication error");
}
// retrieve claims of the external user
var externalUser = result.Principal;
var claims = externalUser.Claims.ToList();
....LOOKING FOR THE USER (OMITTED FOR BREVITY)....
var additionalClaims = new List<Claim>();
// if the external system sent a session id claim, copy it over
// so we can use it for single sign-out
var sid = claims.FirstOrDefault(x => x.Type == JwtClaimTypes.SessionId);
if (sid != null)
{
additionalClaims.Add(new Claim(JwtClaimTypes.SessionId, sid.Value));
}
// if the external provider issued an id_token, we'll keep it for signout
AuthenticationProperties props = null;
var id_token = result.Properties.GetTokenValue("id_token");
if (id_token != null)
{
props = new AuthenticationProperties();
props.StoreTokens(new[] { new AuthenticationToken { Name = "id_token", Value = id_token } });
}
// issue authentication cookie for user
await _events.RaiseAsync(new UserLoginSuccessEvent(provider, userId, user.Id.ToString(), user.Username));
await HttpContext.SignInAsync(user.Id.ToString(), user.Username, provider, props, additionalClaims.ToArray());
_logger.LogInformation("User {user} logged in with external provider.", userId);
// delete temporary cookie used during external authentication
await HttpContext.SignOutAsync(IdentityServer4.IdentityServerConstants.ExternalCookieAuthenticationScheme);
// validate return URL and redirect back to authorization endpoint or a local page
var returnUrl = result.Properties.Items["returnUrl"];
if (_interaction.IsValidReturnUrl(returnUrl) || Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
return Redirect("~/");
}
Client configuration on IdentityServer, serialized:
{
"Enabled": true,
"ClientId": "native.hybrid",
"ProtocolType": "oidc",
"RequireClientSecret": true,
"ClientName": "Application",
"LogoUri": null,
"RequireConsent": false,
"AllowRememberConsent": true,
"AllowedGrantTypes": [
"hybrid"
],
"RequirePkce": false,
"AllowPlainTextPkce": false,
"AllowAccessTokensViaBrowser": true,
"RedirectUris": [
"http://localhost/winforms.client"
],
"FrontChannelLogoutUri": null,
"FrontChannelLogoutSessionRequired": true,
"BackChannelLogoutUri": null,
"BackChannelLogoutSessionRequired": true,
"AllowOfflineAccess": true,
"AllowedScopes": [
"openid",
"email",
"profile",
"myscope",
"offline_access",
"myapi1",
"myapi2"
],
"AlwaysIncludeUserClaimsInIdToken": false,
"IdentityTokenLifetime": 300,
"AccessTokenLifetime": 3600,
"AuthorizationCodeLifetime": 300,
"AbsoluteRefreshTokenLifetime": 2592000,
"SlidingRefreshTokenLifetime": 1296000,
"ConsentLifetime": null,
"RefreshTokenUsage": 1,
"UpdateAccessTokenClaimsOnRefresh": false,
"RefreshTokenExpiration": 1,
"AccessTokenType": 0,
"EnableLocalLogin": true,
"IdentityProviderRestrictions": [
"Google",
"WsFederation"
],
"IncludeJwtId": false,
"Claims": [],
"AlwaysSendClientClaims": false,
"ClientClaimsPrefix": "client_",
"PairWiseSubjectSalt": null,
"Properties": {}
}
I could answer very long but in the end the quickstart code you used was the root cause of this issue. To be exact it's this code that's causing the issue:
// validate return URL and redirect back to authorization endpoint or a local page
var returnUrl = result.Properties.Items["returnUrl"];
if (_interaction.IsValidReturnUrl(returnUrl) || Url.IsLocalUrl(returnUrl))
{
return Redirect(returnUrl);
}
return Redirect("~/");
it should become this instead:
// retrieve return URL
var returnUrl = result.Properties.Items["returnUrl"] ?? "~/";
// check if external login is in the context of an OIDC request
var context = await _interaction.GetAuthorizationContextAsync(returnUrl);
if (context != null)
{
if (await _clientStore.IsPkceClientAsync(context.ClientId))
{
// if the client is PKCE then we assume it's native, so this change in how to
// return the response is for better UX for the end user.
return View("Redirect", new RedirectViewModel { RedirectUrl = returnUrl });
}
}
return Redirect(returnUrl);
This would also mean that you need an extension class method:
public static class Extensions
{
/// <summary>
/// Determines whether the client is configured to use PKCE.
/// </summary>
/// <param name="store">The store.</param>
/// <param name="clientId">The client identifier.</param>
/// <returns></returns>
public static async Task<bool> IsPkceClientAsync(this IClientStore store, string clientId)
{
if (!string.IsNullOrWhiteSpace(clientId))
{
var client = await store.FindEnabledClientByIdAsync(clientId);
return client?.RequirePkce == true;
}
return false;
}
}
The missing viewmodel:
public class RedirectViewModel
{
public string RedirectUrl { get; set; }
}
This missing javascript file with this content located in wwwroot/js/signin-redirect.js
window.location.href = document.querySelector("meta[http-equiv=refresh]").getAttribute("data-url");
and as last a new razor page Redirect.cshtml located in the Views/Shared
@model RedirectViewModel
<h1>You are now being returned to the application.</h1>
<p>Once complete, you may close this tab</p>
<meta http-equiv="refresh" content="0;[email protected]" data-url="@Model.RedirectUrl">
<script src="~/js/signin-redirect.js"></script>
This should do the trick or you can update your quickstart code. But it's not an issue in your own code.