This is post 2 of 2 in a series titled Simple IDAM Integrations
- What's the 'minimal change' to use Entra External ID for public login with Sitecore?
- Integrating Ping Identity for public login
A while back I wrote a bit about how you might integrate Extra External ID with Sitecore to provide a very minimal IDAM integration for login. But in the follow-ups to the internal discussions that gave me the idea for that post, my project started talking about alternatives to Entra. So what might you be able to do if you chose Ping Identity instead?
The requirement for what the integration needed to do was the same as the things I wrote about in my previous post. Minimal change, to allow credential validation via the IDAM provider, but keeping the authorisation behaviour in Sitecore's model. But there was a request to look into Ping Identity as an alternative service provider.
The model I discussed last time was roughly:
And after looking at the documentation for Ping it's clear that exactly this "hosted login" model can work much the same way. The URLs requested change a bit, but the logic and behaviour stays pretty much the same.
That wouldn't make much of a blog post - but interestingly Ping offers an approach Entra doesn't seem to support: It allows you to deal with gathering credentials on your web page, but pass these to Ping's APIs for validation and token issuing.
That has some interesting potential for having the benefits of IDAM but being able to make the login page(s) look exactly the they way you want. That might be an improvement over the "you can customise our page a bit" approach of the hosted login model.
Having gone through a lot of documentation the process for authenticating a user via this model goes like this:
Initially you need to configure an "application" and a "flow" in Ping much as we did for Entra External ID. However here the documentation is fairly focused on doing this via APIs - rather than Entra's "just via the UI" model. In reality I suspect I'd not be automating this creation as it would be a bit of a "fire and forget" job. But these api-first approaches might be useful in some scenarios.
There are a few key things to set here, however. Your app's config needs:
Response Type
set to
Code
Grant Type
set to
Authorization Code
and
Client Credentials
Redirect URIs
set to match whatever you use in the code belowToken Endpoint Authentication Method
set to
Client Secret Post
And it also needs an Attribute Mapping set up to export the user's email in the id token we end up acquiring.
You need to attach a Policy to your app as well - here I'm using one which only wants the user's password. But you can create more complex ones.
You'll need to add your login form as a component in Sitecore. At its simplest this can be a View Rendering which posts data back to an API controller, so we don't need to worry about any complexity with Sitecore Controllers.
So the view might include:
<div> <h2>Ping Login Experiment</h2> <form action="/api/ping/login" method="post"> <label for="user">User: </label> <input type="text" name="user" placeholder="user" /> <label for="user">Pwd: </label> <input type="password" name="pwd" placeholder="password" /> <button type="submit">Go!</button> </form> </div>
And the controller needs a method to receive that post:
public class PingApiController : Controller { [HttpPost] public ActionResult Login(string user, string pwd) { // logic goes here } }
You need to take the client id (also referred to as the app id in the docs), the client secret and the environment id from either the Ping UI or their setup API calls, as they'll be needed later.
private string _environmentId = "<your value>"; private string _clientId = "<your value>"; private string _secret = "<your value>";
You'll also need to know the API endpoint for Ping's calls. And you'll need to have a URL for Ping to redirect to, like we did in the Entra version. But in this case while that URL is required by the endpoints your calling, we won't actually allow that redirect to be performed - all the work will be handled in back-end code.
private string _authPath = "https://auth.pingone.eu"; private string _redirect = "https://cm.pingexperiment.localhost/api/ping/redirect";
The documentation then describes two calls you make to get the relevant ID and URL for the login flow to follow. I'll reproduce that here, but I'm fairly sure these are constants of your App's setup so you might choose to make them config settings?
First up you need to fetch the ID for the login flow you've attached to your application:
private string GetFlowId() { var uri = $"{_authPath}/{_environmentId}/as/authorize?response_type=code&client_id={_clientId}&redirect_uri={_redirect}&scope=openid"; var client = WebRequest.CreateHttp(uri); client.AllowAutoRedirect = false; client.CookieContainer = _cookies; var response = client.GetResponse(); var loc = response.Headers["Location"]; return loc.Substring(loc.IndexOf('=') + 1); }
You can make a
GET
request to the
/as/authorize
endpoint to retrieve this ID. The URL requested here needs to include a collection of bits of data. You need to use the right API URL path, and the right environment ID so that Ping knows which bit of your estate you're talking to. You also need to pass the client id (or app id) to say what sort of login you're going via the app's configuration, and you need to specify an (unused) redirect that matches your app's configuration.
Sending that request needs two other settings. First, you have to tell it not to follow any redirects it receives. And second you need to make cookies persistent across all the requests we'll be making here, so that Ping can follow your session.
_cookies
here is just a field in the controller:
private CookieContainer _cookies = new CookieContainer();
And then when the call returns you can extract the
Location
header that gets sent back as part of the 302 response. Normally the browser would follow this redirect to show a login form, but we've told our client not to redirect, so we get a value that looks like:
https://apps.pingone.eu/9a25db85-0c77-4b4f-94fc-42acc9e23299/signon/?flowId=0a1471bb-a437-4955-bc65-dg8caa3c885b
The ID we need is the querystring param
flowId
- so the function can grab that off the end of the string and return it.
(As an aside, you'd have error handling code in all this stuff if you were doing it properly - but I'm ignoring that for clarity)
Then you can make a second call using that flow ID to get the URL that the flow would submit user credentials to:
private string GetFlowSubmissionUrl(string flowId) { var uri = $"{_authPath}/{_environmentId}/flows/{flowId}"; var client = WebRequest.CreateHttp(uri); client.CookieContainer = _cookies; var response = client.GetResponse(); using (var strm = response.GetResponseStream()) { using(var rdr = new StreamReader(strm)) { var data = rdr.ReadToEnd(); var json = JObject.Parse(data); var flowUrl = json["_links"]["usernamePassword.check"]["href"]; return flowUrl.ToString(); } } }
This call is similar in structure. It constructs a request to get data for the
flowId
we captured before, ensures we're keeping cookies and gets a response. It then reads out the body of the response - which is a blob of json which has a structure like this:
{ "_links" : { "usernamePassword.check" : { "href" : "https://auth.pingone.eu/9a25db85-0c77-4b4f-94fc-42acc9e23299/flows/0f5984c7-90ca-4554-8a1a-93c752d0507d" }, "self" : { "href" : "https://auth.pingone.eu/9a25db85-0c77-4b4f-94fc-42acc9e23299/flows/0f5984c7-90ca-4554-8a1a-93c752d0507d" } }, "id" : "015984c7-90ca-4554-8a1a-93c752d0507d", "resumeUrl" : "https://auth.pingone.eu/9a25db85-0c77-4b4f-94fc-42acc9e23299/as/resume?flowId=0f5984c7-90ca-4554-8a1a-93c752d0507d", "status" : "USERNAME_PASSWORD_REQUIRED", "createdAt" : "2024-11-06T14:49:26.669Z", "expiresAt" : "2024-11-06T15:04:26.732Z", "_embedded" : { "application" : { "name" : "Simple_Login_App" } } }
The important thing here is the
href
attribute of the
usernamePassword.check
element. You can see from the
status
field that the flow is waiting on a username and password to validate the user - so we need to post those bits of data to the url we're extracting here.
Posting that data is a little more compex, but looks like:
private string PostCredsForResumeUrl(string flowUrl, string user, string pwd) { var contentType = "application/vnd.pingidentity.usernamePassword.check+json"; var body = $"{{\"username\": \"{user}\",\"password\": \"{pwd}\"}}"; var bodyData = Encoding.ASCII.GetBytes(body); var client = WebRequest.CreateHttp(flowUrl); client.CookieContainer = _cookies; client.ContentType = contentType; client.Method = "POST"; client.ContentLength = bodyData.Length; using(var strm = client.GetRequestStream()) { strm.Write(bodyData, 0, bodyData.Length); } WebResponse response; try { response = client.GetResponse(); } catch(WebException ex) { // Invalid response return null; } using (var strm = response.GetResponseStream()) { using (var rdr = new StreamReader(strm)) { var data = rdr.ReadToEnd(); var json = JObject.Parse(data); return json["resumeUrl"].ToString(); } } }
This needs to take the user's credentials, format them into some JSON and post them to the URL from above. The response to that post will either be a blob of JSON for success or a HTTP error code. For simplicity the code here just assumes any error is a login error (rather than anything relating to networks or the like) and returns null. But if there's no error we get JSON like:
{ "_links": { "self": { "href": "https://auth.pingone.eu/9a25db85-0c77-4b4f-94fc-42acc9e23299/flows/0f6f75df-7aed-42c3-b6bf-c79562f60366" } }, "id": "0f6f75df-7aed-42c3-b6bf-c79562f60366", "session": { "id": "888427de-9fb2-4be3-92b5-14cad9f6f4f1" }, "resumeUrl": "https://auth.pingone.eu/9a25db85-0c77-4b4f-94fc-42acc9e23299/as/resume?flowId=0f6f75df-7aed-42c3-b6bf-c79562f60366", "status": "COMPLETED", "createdAt": "2024-11-06T15:26:46.196Z", "expiresAt": "2024-11-06T15:41:46.392Z", "_embedded": { "user": { "id": "3471e840-c1cf-4850-b163-3f311dcc4be3", "username": "DJD" }, "application": { "name": "Simple_Login_App" } } }
The important thing here is the "resume url" which is needed for the next step. But in more complex scenarios you might need to look at the
status
field. Here that's "completed" because this flow only needs username and password, so all its requirements have been met. But if you had a flow which needed 2FA then you might a status to say "now you need to do the 2nd factor" - which you'd have to respond to by posting the user's 2FA code data to the appropriate URL to get to the "completed" state or get an error if they did not match.
But the resume URL extracted is required to close out the login. Once all the factors are submitted this can be called to get a completion code which we need next:
public string ResumeToGetCode(string resumeUrl) { var client = WebRequest.CreateHttp(resumeUrl); client.CookieContainer = _cookies; client.AllowAutoRedirect = false; var response = client.GetResponse(); var redirectUrl = response.Headers["Location"]; var code = redirectUrl.Substring(redirectUrl.IndexOf('=') + 1); return code; }
This is repeating the pattern of making a
GET
request which ignores redirects and extracting the redirect URL returned from a 302 response. And the querystring value at the end of that URL is the code we need to extract.
Finally (!) then we need to translate that "yes, you did log in ok" code into a token in the same structure as we had before with Entra. I mentioned earlier adding the Attribute Mapping to include the email address we want for the login on the Sitecore side. That needs to be in place for the token to have the data we want.
The code looks like this:
private string GetToken(string code) { var uri = $"{_authPath}/{_environmentId}/as/token"; string formFields = $"grant_type=authorization_code&code={code}&client_id={_clientId}&client_secret={_secret}&redirect_uri={_redirect}&scope=openid+email"; var formData = System.Text.Encoding.ASCII.GetBytes(formFields); var client = WebRequest.CreateHttp(uri); client.ContentType = "application/x-www-form-urlencoded"; client.CookieContainer = _cookies; client.Method = "POST"; client.ContentLength = formData.Length; using (var stream = client.GetRequestStream()) { stream.Write(formData, 0, formData.Length); } var response = client.GetResponse(); using (var strm = response.GetResponseStream()) { using (var rdr = new StreamReader(strm)) { var data = rdr.ReadToEnd(); System.Diagnostics.Debug.WriteLine($"token: {data}"); var json = JObject.Parse(data); var id_token = json["id_token"]; return id_token.ToString(); } } }
This call isn't part of the login flow - so it involves
POST
ing a variety of things as part of the request. It needs to know we're authorising with a code. Then we need to pass the code, the client ID, the client secret and the (unused) redirect URL again. And we need to specify the scope of data we want to extract.
Now when you read the documentation for this call it suggests a slightly different approach. It doesn't add the client ID and secret to the posted data. Instead it suggests using these as a Basic Auth header instead. I spent a chunk of time trying to make that work, but always got a 400 response at best, and a 403 at worst as I fiddled with the data being sent.
But the docs also describe the above approach as an alternative. The changes I had to make in order for this to work were:
Token Endpoint Authentication Method
is set to
Client Secret Post
And the results of a valid call get you the same structure of token we saw in the previous post:
{ "access_token" : "eyJraWQiOiI5NGZiOWYzMC05YjcyLTExZWYtYjFmOS0zZjFkYTlkYmY5ZTAiLCJhbGciOiJSUzI1NiJ9.eyJjbGllbnRfaWQiOiI5ODQyN2U5YS1hNTYzLTQ4MTYtYmZhNi1jNzNmOTA2MDllYTgiLCJpc3MiOiJodHRwczovL2F1dGgucGluZ29uZS5ldS85NjI1ZGI4NS0wYzc3LTRiY2YtOTRmYy00MmE0YzllMjMyOTkvYXMiLCJqdGkiOiI1OTg0YjdkOC1iNjRmLTQyMGQtYWFiNC00MmY4YmZlMjIxZjUiLCJpYXQiOjE3MzA5OTA5ODAsImV4cCI6MTczMDk5NDU4MCwiYXVkIjpbImh0dHBzOi8vYXBpLnBpbmdvbmUuZXUiXSwic2NvcGUiOiJvcGVuaWQiLCJzdWIiOiIzNDcxZTg0MC1jMWNmLTQ4NTAtYjE2My0zZjMxMWRjYzRiZTMiLCJzaWQiOiI3MmJjOWMxMS1lZGZkLTRjNjUtOTUxZC0xNWYyMTljMDBhNDkiLCJlbnYiOiI5NjI1ZGI4NS0wYzc3LTRiY2YtOTRmYy00MmE0YzllMjMyOTkiLCJvcmciOiI5ZjFlMDEzZS0yMWQ0LTQ2YTEtODk3My0yOTQxZjA1MjQxNTIifQ.oQNlFArGpVFKwWYKYw2-2V0HxePAFYdul2O7cf5rLhstT0QF-YYkOpZr9A-gsTf1JzKxZloOmulSwu2QYGbvx4BceG78hUBGzsn7HnTQ8junsawPA7VBWfysmON7HRni5z_uEqKoM3oRESaoKISEZeiTc-P-mZ4_LIzFIntJJHkHGPz_bHpb8tGM_zFUuikWlD07qqAYqK593RVrUGzgmdbXgsava4jC2AW_hjSgSehj7i1SLS06IR-I1jqial-Bv6PXkgs8WUFpAKCOlK6SA75J7qUp91IAdOpNQbewuTfmqnEeyKwLfIv0dcrRZNP2gjIDLNnNCrh3QqdDdHulkQ", "token_type" : "Bearer", "expires_in" : 3600, "scope" : "openid", "id_token" : "eyJraWQiOiI5NGZiOWYzMC05YjcyLTExZWYtYjFmOS0zZjFkYTlkYmY5ZTAiLCJhbGciOiJSUzI1NiJ9.eyJpc3MiOiJodHRwczovL2F1dGgucGluZ29uZS5ldS85NjI1ZGI4NS0wYzc3LTRiY2YtOTRmYy00MmE0YzllMjMyOTkvYXMiLCJzdWIiOiIzNDcxZTg0MC1jMWNmLTQ4NTAtYjE2My0zZjMxMWRjYzRiZTMiLCJhdWQiOiI5ODQyN2U5YS1hNTYzLTQ4MTYtYmZhNi1jNzNmOTA2MDllYTgiLCJpYXQiOjE3MzA5OTA5ODAsImV4cCI6MTczMDk5NDU4MCwiYWNyIjoiU2ltcGxlX0xvZ2luX1BvbGljeSIsImFtciI6WyJwd2QiXSwiYXV0aF90aW1lIjoxNzMwOTkwOTc5LCJhdF9oYXNoIjoiR2tOUXRKa1NtZEtSZEJadVlHRmJTZyIsInNpZCI6IjcyYmM5YzExLWVkZmQtNGM2NS05NTFkLTE1ZjIxOWMwMGE0OSIsImVudiI6Ijk2MjVkYjg1LTBjNzctNGJjZi05NGZjLTQyYTRjOWUyMzI5OSIsIm9yZyI6IjlmMWUwMTNlLTIxZDQtNDZhMS04OTczLTI5NDFmMDUyNDE1MiIsInAxLnJlZ2lvbiI6IkVVIn0.SJ5PP-rzjtl5ZfGkLfOKeBEQAwmgnQ2sGzZSM3c2vIzPGpaHduE6gbogGhgEjVWM-RbxckYJmafVLfNTnAl3mziAk2PvktxuqXtmYoFixGsjjOAv9W2CcaLKuE3F4nDSxjvG5w--leqs4HhTrSvvYYgAlOF2zoHiHNNEfTDpUAoaYndgaezgCxjmIHYcud1u6E8LbU-V7nNolfqQf7v5Xxgmq51sIAaRbHNwJapxGRZC6jkLc3YWkN_aPK3bgSfzOkQpm-u4PyOIy2emLTjBS5F0BabZMCpR8YPjgzzP2ErdSMIQLEKwfbPWzbCPZx9I1KBj_LAcDR6MioxNIRYy5g" }
So the overall controller method can end up looking like this. It just calls all of the methods talked through above, and makes sure that the
resumeUrl
acquired when posting the user's credentials isn't null.
[HttpPost] public ActionResult Login(string user, string pwd) { var flowId = GetFlowId(); var flowUrl = GetFlowSubmissionUrl(flowId); var resumeUrl = PostCredsForResumeUrl(flowUrl, user, pwd); string token = null; if (!string.IsNullOrWhiteSpace(resumeUrl)) { var code = ResumeToGetCode(resumeUrl); token = GetToken(code); } return ProcessToken(token); }
Where
ProcessToken()
is the same code from the previous post to take the token text and extract the data to process the login. It doesn't try to log in if the
token
supplied here is null. So it does nothing if login has failed. But in the real world there'd be some better UI to handle error conditions here.
So that's an interesting alternative to using Entra's model.
↑ Back to top