Skip to content

Instantly share code, notes, and snippets.

@synesthesia
Last active October 4, 2018 15:00
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save synesthesia/5b32cc3f43d414a2a41030571c36381e to your computer and use it in GitHub Desktop.
Save synesthesia/5b32cc3f43d414a2a41030571c36381e to your computer and use it in GitHub Desktop.
Enabling SSO for Discourse with IdentityServer3 - extended

Background

In our business we operate a number of customer-facing web services which use an IdentityServer3 identity provider as the single source of identity. We have customised our setup to allow two sources of federated identity, and to pull certain claims from our CRM. We have a new requirement to integrate a hosted instance of the excellent Discourse discussion forum, also using the same single source of identity. Discourse does not support OpenId Connect, rather its own particular form of SSO.

Using IdentityServer3 as SSO source for Discourse

John Korsnes wrote the core of this approach, documented in his Medium article and on Github. In his article he gives a good overview of how the Discourse SSO works, and explains his approach:

  • a custom endpoint on the IdentityServer, running in the same Owin context as the main IdP
  • configure Discourse to redirect a login to the custom endpoint
  • in the custom endpoint check if the user has a current authenticated session with IdentityServer
  • if they have, generate a Discourse SSO payload from the user properties, and return to Discourse
  • if they haven't, display a simple login form, and once they have authenticated, generate and return the Discourse SSO payload as before

Our modifications

From our perspective the only drawback of John's approach was that it only allowed for user authentication against the local IdentityServer accounts (username / password). Although that covers most of our customer accounts, we have extended our IdP with federated identity against our own company Office365 (Azure AD), and against Google, as some of our customers use Google Apps corporately. To extend John's approach we modified it so that instead of displaying a local login form we:

  • register an application in our IdentityServer as a proxy for Discourse
  • carry out an (almost) standard Authorization Code authentication process from our custom controller agaisnt the Identity Server.
  • the only difference is that because we are running inside the IdentityServer web piipeline we don't need to redeem the authorization code agaisnt the token endpoint, but can ignore any generated tokens and query the Owin Context in the same way John does.

Our version is shown in DiscourseController.cs

using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Security;
using System.Security.Claims;
using System.Security.Cryptography;
using System.Text;
using System.Threading.Tasks;
using System.Web;
using System.Web.Mvc;
using BrockAllen.MembershipReboot.WebHost;
using Castle.Core.Logging;
using IdentityServer3.Core.Extensions;
using ZZZ.Identity.Services.Membership.Services;
using ZZZ.Identity.Infrastructure.Common;
using ZZZ.Identity.Services.Membership.Model;
namespace ZZZ.Identity.Controllers
{
/// <summary>
/// Provides custom SSO endpoint for Discourse
/// </summary>
/// <seealso cref="https://blogg.blank.no/enabling-sso-for-discourse-with-identityserver3-7da2aca64bab"/>
public class DiscourseController : Controller
{
private readonly CustomUserAccountService _userAccountService;
private readonly ILogger _logger;
private readonly string _secret = ConfigurationHelper.DiscourseSsoSecret;
public DiscourseController(CustomUserAccountService userAccountService, ILogger logger)
{
_userAccountService = userAccountService ?? throw new ArgumentNullException(nameof(userAccountService));
_logger = logger;
}
[Route("identity/discourse")]
[HttpGet]
public async Task<ActionResult> Index(string sso, string sig)
{
_logger.Info("Discourse proxy signin called");
if (!IsValid(sso, sig))
{
throw new SecurityException("sso sig not valid");
}
var ctx = Request.GetOwinContext();
var idsrvClaimsIdentity = await ctx.Environment.GetIdentityServerFullLoginAsync();
var isAuthenticated = idsrvClaimsIdentity != null;
if (isAuthenticated)
{
_logger.Info("Discourse proxy is logged in to IdSrv, returning user to Discourse");
// User authenticated, getting user, generating sso and redirecting back to Discourse
if (Guid.TryParse(idsrvClaimsIdentity.FindFirst(c => c.Type == "sub").Value, out var id))
{
var user = _userAccountService.GetByID(id);
var redirectUrl = CreateDiscourseRedirectUrl(user, sso);
return new RedirectResult(redirectUrl);
}
}
// Not authenticated, redirecting to make an authenticaiton request against IdentityServer
var urlhelper = Request.Url;
var cbBuilder = new UriBuilder(Request.GetApplicationUrl())
{
Port = urlhelper.Port,
Path = "identity/discourse/callback",
};
var builder = new UriBuilder(Request.GetApplicationUrl())
{
Port = urlhelper.Port,
Path = "identity/connect/authorize",
};
var query = HttpUtility.ParseQueryString(builder.Query);
query["client_id"] = "CLIENT_ID_OF_OUR_PROXY";
query["scope"] = "openid email";
query["response_type"] = "code";
query["redirect_uri"] = cbBuilder.ToString();
query["state"] = sso;
query["nonce"] = Guid.NewGuid().ToString();
builder.Query = query.ToString();
string url = builder.ToString();
_logger.Info("Discourse proxy is not logged in to IdSrv, redirecting to login");
return new RedirectResult(url);
}
/// <summary>
/// Receives authentication result from IdentityServer
/// </summary>
/// <param name="code"></param>
/// <param name="state"></param>
/// <returns></returns>
[Route("identity/discourse/callback")]
[HttpGet]
public async Task<ActionResult> Callback(string code, string state, string error)
{
if (!string.IsNullOrWhiteSpace(error))
{
TempData["error"] = error;
return View();
}
var sso = state;
// normally we would have to use the code to get a token to find out the user
// but as we are running in same pipeline as IdentityServer we can just use the
// OwinContext extensions
var ctx = Request.GetOwinContext();
var idsrvClaimsIdentity = await ctx.Environment.GetIdentityServerFullLoginAsync();
var isAuthenticated = idsrvClaimsIdentity != null;
if (isAuthenticated)
{
_logger.Info("Discourse proxy is logged in to IdSrv, returning user to Discourse");
// User authenticated, getting user, generating sso and redirecting back to Discourse
if (Guid.TryParse(idsrvClaimsIdentity.FindFirst(c => c.Type == "sub").Value, out var id))
{
var user = _userAccountService.GetByID(id);
var redirectUrl = CreateDiscourseRedirectUrl(user, sso);
return new RedirectResult(redirectUrl);
}
}
_logger.Fatal("Discourse proxy cannot resolve logged in user");
TempData["error"] = "Discourse proxy cannot resolve logged in user";
return View();
}
[Route("identity/discourse/logout")]
[HttpGet]
public ActionResult Logout()
{
Request.GetOwinContext().Authentication.SignOut();
return Redirect("/");
}
private string CreateDiscourseRedirectUrl(CustomUser user, string originalEncodedsso)
{
var urlParameters = ParseSSO(originalEncodedsso);
var nonce = urlParameters.Get("nonce");
var returnUrl = urlParameters.Get("return_sso_url");
ValidateKnownUrl(returnUrl);
var userClaims = _userAccountService.GetClaimsFromAccount(user).ToList();
var username = GetUserName(user, userClaims);
var email = userClaims.First(x => x.Type == "email").Value;
var ssoDictionary = new Dictionary<string, string>
{
{"nonce", nonce},
{"email", HttpUtility.UrlEncode(email)},
{"external_id", HttpUtility.UrlEncode(userClaims.First(x => x.Type == "sub" ).Value)},
{"username", HttpUtility.UrlEncode(username)},
{"name", HttpUtility.UrlEncode(userClaims.First(x => x.Type == "name" ).Value)}
};
// at this point could use other claims, for example:
// to determine if user should be Discourse moderator
// to add user to specific Discourse groups
var returnsso = CreateSSOQueryString(ssoDictionary);
var returnssoEncoded = Convert.ToBase64String(Encoding.UTF8.GetBytes(returnsso));
var returnSig = Hash(_secret, returnssoEncoded);
return $"{returnUrl}?sso={returnssoEncoded}&sig={returnSig}";
}
private string GetUserName(CustomUser user, List<Claim> userclaims)
{
var username = user.Username;
// fixup username for users from our own Azure AD
var issuer = userclaims.FirstOrDefault(x => x.Type == "iss");
if (issuer?.Value == "https://sts.windows.net/Our Azure AD tenant/")
{
//WARNING - magic number hardcoded tenant
var upn = userclaims.FirstOrDefault(x => x.Type == "upn");
username = upn?.Value;
}
else
{
//fixup username for Google login
var urn = userclaims.FirstOrDefault(x => x.Type == "urn:google:profile");
if (urn != null
&& userclaims.FirstOrDefault(x => x.Type == "email") != null)
{
var email = userclaims.FirstOrDefault(x => x.Type == "email").Value;
username = email.Replace("@", "_");
}
}
return username;
}
private List<string> ValidRedirectUris = new List<string>
{
"https://DISCOURSEHOST/session/sso_login"
};
private void ValidateKnownUrl(string returnUrl)
{
if (!ValidRedirectUris.Any(u => u.Equals(returnUrl)))
throw new ApplicationException("Bad Discourse redirect uri");
}
private string Hash(string secret, string encodedPayload)
{
var hasher = new HMACSHA256(Encoding.UTF8.GetBytes(secret));
var hash = hasher.ComputeHash(Encoding.UTF8.GetBytes(encodedPayload));
return BitConverter.ToString(hash).Replace("-", "").ToLowerInvariant();
}
private NameValueCollection ParseSSO(string encodedsso)
{
var queryString = Encoding.UTF8.GetString(Convert.FromBase64String(encodedsso));
return HttpUtility.ParseQueryString(queryString);
}
private string CreateSSOQueryString(Dictionary<string, string> dictionary)
{
return string.Join("&", dictionary.Select(x => $"{x.Key}={x.Value}"));
}
private bool IsValid(string encodedPayload, string signature)
{
return Hash(_secret, encodedPayload) == signature;
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment