Portal SSO Overview
This document describes high level SSO functionality of the Heretto Portal.
What is SSO?
Maintaining user identities, roles, and groups is a difficult job, and is often one that Service Providers (SPs) would often rather not do as it does not reflect their core competency. A popular solution is to delegate the responsibilities of managing user accounts and authentication to a third party entity Identity Provider (IDP). SSO, an acronym for Single Sign-On, is a family of authentication schemes which use IDPs to manage user accounts and authenticate users, and to transfer only relevant attributes back to the Service Providers. The name comes from the idea that you only have to sign-on in a single place, your IDP, and then various SPs simply contact the IDP to determine your identity in lieu of managing your personal account details and authentication themselves.
To take full advantage of SSO, The SP must be able to distinguish which of its resources are protected. In those instances, if a user is either not currently identified by the SP, or if the user has insufficient privileges to access the protected resource, then they are typically presented with a list of one or more partner IDPs by which they will be able to authenticate with, and hopefully thus gain access to the protected resource.
When user agents are redirected to the IDP, the link includes the information about which page they had originally requested (this is the state). From there, the IDP confirms the user's identity, and then redirects the user agent back to the SP with an assertion and the original state of the request. This response is then processed by the SP, after which, the SP will decide whether the user is authorized to access the requested resource at that point.
The following sequence diagram illustrates how SSO works, generally speaking, in relation to the portal:
What is OAuth 2.0?
This document provides a brief description of what types of problems that OAuth 2.0 solves.
Overview
OAuth 2.0 is the industry-standard protocol for authorization. OAuth 2.0 focuses on client developer simplicity while providing specific authorization flows for web applications, desktop applications, mobile phones, and living room devices.
What is the purpose of this?
OAuth 2.0 provides a mechanism by which different services on the Internet can inter-operate and share data and resources, without compromising a user's credentials. For instance, if you wanted one application, such as yoursite.com to be able to access your Gmail account's contact list, but didn't want yoursite.com to be able to log into your Gmail account and read your emails, or personal images, etc.
How does it do this?
The actual protocol is shown below. It is important to get an understanding of this because OpenID Connect is really just a specialization of OAuth.
OAuth Authorization Code Flow
The following shows the code flow of how authorizations are done in OAuth 2.0. The following diagram shows how OAuth could be used by a client in order to fetch "Profile Contacts" (address book) from accounts.google.com.
The diagram below assumes that the yoursite.com client needs to get "profile contacts" data from accounts.google.com
- First, the User Agent makes a request to the authorization server
- The IDP first checks to make sure that the user has been authenticated. There may be some back and forth with the user agent and the IDP to achieve authentication
- The IDP then checks with the resource owner (
accounts.google.com) to request consent to share "profile contact" data with the client. - The user agent is then redirected back to
yoursite.com/callbackwith an authorization code. - This authorization code is exchanged via a back channel for an access token. This access token is then used to get data directly from the resource owner (
accounts.google.com)
At the end of this flow, the user agent has a cookie for access token, that it will pass to google.com to get access to contact information. This access token will have an expiration date, and is only good for the scope it was intended for - for instance, it cannot be used to access the user's email messages, or photos, etc.
OpenID Connect Authentication Flow
The following diagram shows the standard flow for OpenID Connect.
- First, the User Agent makes a request to the authorization server
- The IDP first checks to make sure that the user has been authenticated. There may be some back and forth with the user agent and the IDP to achieve authentication
- The IDP then checks with the resource owner (
accounts.google.com) to request consent to share "profile contact" data with the client. - The user agent is then redirected back to
yoursite.com/callbackwith an authorization code. - This authorization code is exchanged via a back channel for an access token. This access token is then used to get data directly from the resource owner (
accounts.google.com) - Get the user info with the access token. This will be returned as JSON.
Understanding Portal Authentication
Authentication Strategy
At its core, the portal SSO authentication mechanism is a configurable, and very simple state machine design. Each stage of the state machine we call a step. At this time, we have support for the following protocols:
-
OAuth (not recommended)
-
OpenID Connect (recommended)
-
JWT
As well, we have examples of using authentication with the following OpenID Connect providers:
-
Azure
-
Google
Microsoft® Entra
-
Keycloak
-
Okta
The strategy configuration format is JSON, and each strategy is configured using the same general format:
-
A list of properties
-
Followed by a list of steps
Starting with the first step, each step, once configured, leads to the processing of the next step, and so forth. The end result of the processing is that the user has been authenticated, and that we have the following information about that user:
-
User Claims
-
Role Claims
-
Audience Claims
Let's consider how we'd configure Google for OpenID connect:
"google": {
"idp": "google",
"client_id": "client_id",
"client_secret": "client_secret",
"aud": "aud",
"iss": "accounts.google.com",
"grant_type": "authorization_code",
"response_type": "code",
"scope": "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
"hd": ["jorsek.com"],
"redirect_path": "/auth/google",
"auth_success_redirect": "/?state=${state}",
"auth_uri": "https://accounts.google.com/o/oauth2/auth?redirect_uri=${base_uri}${redirect_path}&client_id=${client_id}&response_type=${response_type}&scope=${scope}&state=${state}&access_type=offline&include_granted_scopes=true&prompt=consent",
"steps": [
{
"name": "token",
"uri": "https://oauth2.googleapis.com/token?code=${auth.code}&client_id=${client_id}&client_secret=${client_secret}&redirect_uri=${base_uri}${redirect_path}&grant_type=${grant_type}&state=${state}",
"method": "POST",
"config": {
"headers": {
"Content-Type": "application/x-www-form-urlencoded"
}
}
},
{
"name": "jwt",
"uri": "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=${token.id_token}"
},
{
"name": "refresh",
"uri": "https://oauth2.googleapis.com/token?refresh_token=${refresh_token}&grant_type=refresh_token&client_id=${client_id}&client_secret=${client_secret}",
"method": "POST",
"config": {
"headers": {
"Content-Type": "application/x-www-form-urlencoded"
}
}
}
]
},
As the Google example above is a full example of configuration, it is pretty complex, so we're going to break this down in chunks.
- idp
-
This field indicates which strategy you want to use. These are the current options.
idp: "okta" | "azure" | "google" | "keycloak" | "custom" | "jwt";Tip: If you want to implement a strategy which is not listed, you would use "custom", and then define the steps you want to follow for that implementation. - client_id
- This is the client id that you are given from your partner IDP. This is how they identify your SP and validate that you are passing in the correct secret
- client_secret
- This is the secret that your partner IDP has given you to sign and encrypt requests with
- aud
- aud is short for audience and is a standard field for JWTs. If you set this field, then it will check to make sure that one of the values specified in the return JWT under the field name "aud" will match the value you specify here. Remember: This is an optional field, but if you specify it, a match must happen, or users won't be validated on their return from the IDP to your Heretto Portal
- iss
- iss is short for issuer and is a standard field for JWTs. If you set this field, then it will check to make sure that one of the values specified in the return JWT under the field name "iss" will match the value you specify here. Remember: This is an optional field, but if you specify it, a match must happen, or users won't be validated on their return from the IDP to your Heretto Portal
- grant_type
- grant_type is a field that is used for token requests, and is specific to OIDC. At this point in time, we only Heretto Portal "authorization_code" as the value for this field. Note: your client will typically have to be configured to support one or more grant types at the IDP, so you will have to make sure that it is configured to accept "authorization_code" grant_types for it to successfully work with our Heretto Portal.
- response_type
- response_type is an OpenID Connect field. There are multiple types of flows that OpenID Connect supports. The most common ones, which support web browsers as User Agents, are typically Authorization Code Flow, Implicit Flow, and Hybrid Flow. At this time, Heretto Portal only supports Authorization Code Flow as it is considered to be more secure and doesn't expose the end token to the front-channel. Therefore, if you are using OpenID Connect, you must set this field, and it should be set to "code"
- scope
-
This field is required for OpenID Connect. scope value is not present, the behavior is entirely unspecified. Other scope values MAY be present. Scope values used that are not understood by an implementation SHOULD be ignored. This field tells the IDP what types of information you are requesting to learn about the user you are authenticating. This will then be used by the IDP to do tasks, such as inform the end user that their email address and personal information are to be shared, and to seek permission to do so.
For Google specifically, you can explore what options you have to include in the scope element. Refer to: OAuth 2.0 Scopes for Google APIs
- hd
-
hd is a list of domains, such as ["myPrimaryDomain.com", "mySecondaryDomain.com"]. This field is used as a way to limit the domains of the users who are permitted access to your site. If this field is set, then only users who are part of this domain, as determined by the following logic, will be permitted access:
const domainToTest = jwtObj.hd || jwtObj.email || jwtObj.username || jwtObj.subFrom looking at this code, you can see that the return jwt object is evaluated to see first if it has an hd field, if so, that is used, otherwise the email, the username, and the sub, in that order. if ["hotmail.com"] was set as hd, and the returned jwt didn't have an hd field, but the user's email was "john@hotmail.com", then it would be validated, however, if the user's email was "jane@yahoo.com", then it would not be validated.
- redirect_path
-
This is the path that the IDP will redirect users to after they have been authenticated by the IDP. For this to work correctly, there are always two parts of this, the first part is "/auth/" the second part is the name of the IDP in the idp field. This has to be configured correctly for the Heretto Portal to properly process the IDP response.
- auth_success_redirect
-
This is the path that the user agent will be redirected to after a successful authentication.
Remember: This configuration reflects a special path, /?state=${state} This path will automatically redirect to whatever path is indicated in the state variable. - auth_uri
-
This is the first path that a user will be redirected to to initiate the authentication process at the IDP level.
- steps
-
Each step is a step in the workflow where we build upon the previous steps in order to authenticate a user. For instance, the auth_uri is used to initiate a google OIDC sequence, but we must process the request that comes back to make an additional call to get the token back.
Any data returned by a previous step can be used in further template strings* with this name. for example, step2 may need information from step1 in the request uri and can be set as follows:* "https://some-idp.com/oauth2/v3/tokeninfo?id_token=${step1.id_token}".*Tip: A template string is simply a string that is processed to create a string from an expression. if the previous step was named step1, and a value came back with a key of foo=bar, then we could access that value in step 2 with ${step1.foo} which would evaluate to bar after it is processed.Warning: If the step name is "jwt", or matches the "jwt_key" defined in the IAuthStrategy object, we will automatically* validate it.The typescript interface for each step is as follows:
/** * @description the name of the flow step. any data returned by this step can be used in further template strings * with this name. for example, step2 may need information from step1 in the request uri and can be set as follows: * "https://some-idp.com/oauth2/v3/tokeninfo?id_token=${step1.id_token}". * If the step name is "jwt", or matches the "jwt_key" defined in the IAuthStrategy object, we will automatically * validate it. * @default step${index} * @example "step1", "step2" */ name: string, /** * @description If provided, the step will attempt to JWT decode the value defined. * Typically this would be used in lieu of an /introspect endpoint if the previous * step returns a value as one of its properties. * @default null * @example <caption>our default keylcloak config</caption> * * ```json [ { "name": "token", "uri": "${auth_endpoint}/token", "method": "POST", "params": { "redirect_uri": "${base_uri}${redirect_path}", "client_id": "${client_id}", "code": "${auth.code}", "grant_type": "authorization_code" }, "config": { "headers": { "Content-Type": "application/x-www-form-urlencoded" } } }, { "name": "jwt", "decode": "${token.id_token}" } ] ``` * */ decode?: "${token.id_token}" /** * @description the URI to make an api request * @example * "https://oauth2.googleapis.com/token?code=${auth.code}&client_id=${client_id}&client_secret=${client_secret}&redirect_uri=${base_uri}${redirect_path}&grant_type=${grant_type}&state=${state}", */ uri: string; /** * @description the method to use with the request uri * @default GET */ method?: "POST" | "GET" | "PUT", /** * @description any additional params to pass with the request. * NOTE: No options that are functions may be used here, only JSON. * @see https://github.com/axios/axios#request-config * @default {} */ params?: IJSONObject, /** * @description Axios request config object. * NOTE: No options that are functions may be used here, only JSON. * @see https://github.com/axios/axios#request-config * @default {} * @example * ```json * { "headers": { "Content-Type": "application/x-www-form-urlencoded"} } * ``` */ config?: IJSONObject, /** * @description maps the response to different object key/value pairs so that if the IDP returns with different * values in the response from what you would otherwise put as keys in the resulting object, you can map them here. * @default null * @example * ```json { "map": { "id_type": "typ", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "email", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/expiration": "exp" } } * ``` */ map: { [key: string]: string }
The typscript interface for an entire auth strategy is below:
/**
* @description Authentication Strategy configuration object. Most fields are es6-syntax templatable with all values
* defined on this object available to be used in other string fields and steps.
*/
export interface IAuthStrategy {
/**
* @description Identity Provider Type.
*/
idp: "okta" | "azure" | "google" | "keycloak" | "custom" | "jwt";
/**
* @description Identity Provider Client ID/ApplicationID for API requests.
*/
client_id?: string,
/**
* @description Identity Provider Client Secret for API requests.
*/
client_secret?: string,
/**
* @description Identity Provider Audience field to verify on JWT tokens.
*/
aud?: string,
/**
* @description Identity Provider Issuer field to verify on JWT tokens.
*/
iss?: string,
/**
* @description Identity Provider grant_type.
* @default "authorization_code"
*/
grant_type?: string,
/**
* @description Identity Provider response_type.
* @default "code"
*/
response_type?: string,
/**
* @description Identity Provider oauth scopes.
*/
scope?: "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email",
/**
* @description Identity Provider HD/domain field to validate against.
*/
hd?: string[],
/**
* @description JWT field to use in cookies and to match on the steps to find the correct JWT step.
* @default "jwt"
*/
jwt_key?: string,
/**
* @description Redirect path for incoming oauth redirect from the IDP containing the grant_type.
*/
redirect_path?: "/auth/google",
/**
* @description Redirect path for when authenitcation is fully successful.
* @param state Used to keep track of originally requested URL.
* @default "/?state=${state}"
*/
auth_success_redirect?: string,
/**
* @description URI to redirect to when the user needs authentication. any parameters returned on the incoming redirect back are available to the auth "steps" as "auth".
* For example, if the first step requires the "code", you can construct the first step uri with ${auth.code} as
* "https://some-idp/token?code=${auth.code}&client_id=${client_id}&client_secret=${client_secret}&redirect_uri=${base_uri}${redirect_path}&grant_type=${grant_type}&state=${state}",
* @example
*
* ```json
* {
* "auth_uri":"https://accounts.google.com/o/oauth2/auth?redirect_uri=${base_uri}${redirect_path}&client_id=${client_id}&response_type=${response_type}&scope=${scope}&state=${state}"
* }
* ```
*/
auth_uri?: string,
/**
* @description Authentication signing key.
* This can be created in Heretto and is a pre-shared key to create individualized tokens for each user.
*/
authSigningKey?: string;
/**
* @description Authenitcation steps. Run sequentially during authentication flow.
* @see IAuthStrategyStep
*/
steps?: IAuthStrategyStep[],
/**
* @description JWT key name for accessing the proper field to use the content/audiences for the logged in user.
* @default "content_audiences"
* @example
* If the incoming JWT is formed as:
* {"exp": 2147483647, "content_audiences": ["private"]}
* No additional configuration is required.
*
* If the incoming JWT is formed as:
* {"exp": 2147483647, "https://jorsek.com/content/audiences": ["private"]}
*
* the authStrategy object would need the following parameter defined:
* "authStrategy": {
* "audienceClaim": "https://jorsek.com/content/audiences"
* }
*
*/
audienceClaim?: string;
/**
* @description JWT key name for accessing the proper field to use the portal_role for the logged in user.
* @default "portal_role"
* @example
* If the incoming JWT is formed as:
* {"exp": 2147483647, "portal_role": "contributor"}
* No additional configuration is required.
*
* If the incoming JWT is formed as:
* {"exp": 2147483647, "https://jorsek.com/portal_role": "contributor"}
*
* the authStrategy object would need the following parameter defined:
* "authStrategy": {
* "roleClaim": "https://jorsek.com/portal_role"
* }
*/
roleClaim?: string;
/**
* @description JWT key name for accessing the proper field to use the ezd_username for the logged in user.
* @default email||username||sub||hd;
* @example
* If the incoming JWT is formed as:
* {"exp": 2147483647, "email": "contributor"}
* No additional configuration is required.
*
* If the incoming JWT is formed as:
* {"exp": 2147483647, "https://jorsek.com/ezd_username": "user@contoso.com"}
*
* the authStrategy object would need the following parameter defined:
* "authStrategy": {
* "userClaim": "https://jorsek.com/ezd_username"
* }
*/
userClaim?: string;
/**
* @description JWT expiration timing.
* @default "12h"
*
* Either "never" or expressed in seconds or a string describing a time span [zeit/ms](https://github.com/zeit/ms).
> Eg: `60`, `"2 days"`, `"10h"`, `"7d"`. A numeric value is interpreted as a seconds count. If you use a string be sure you provide the time units (days, hours, etc), otherwise milliseconds unit is used by default (`"120"` is equal to `"120ms"`).
*
* setting this value to "never" removes the exp requirement from forming a JWT. (not recommended)
*/
expiresIn?: string;
token?: object;
}
Important Concepts to Note
These are some important concepts that any implementations should be keenly aware of.
Overriding Parameters
It is possible to fully define a new strategy from scratch. Many times, however, you will simply want to override the settings that are provided for common OIDC providers. In that case, you use the same strategy name, and only provide keys/values for properties that you want to override. If you don't specify a property, it will just use the default configuration property that was provided in the mock_strategies file. If you do specify a key/value pair, that will override that key/value pair that was provided. This is a quick way to get started using an IDP such as google.
JWT IDP Special Handling
Please do NOT create an Authentication Strategy with the name of jwt. This is a reserved name and trying to use this name will create problems that will be difficult to debug.
jwt, you can and will OFTEN create a Step named jwt within your strategies. JWT Decoding and Resolution
The ultimate goal of authenticating users is to en up with a JSON object that represents the user's data. In some cases, these JWT are signed and encoded, and in other cases, they are not, or may even be represented as plain JSON objects. To make implementation as simple as possible, there is special behavior regarding any step that is either named jwt, or if the step name matches the name set on the optional configuration parameter: jwt_key.
In the Google OIDC example that was listed previously in this document, you will notice this step:
{
"name": "jwt",
"uri": "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token=${token.id_token}"
}
This endpoint that Google provides will return a plain JSON object of the token that was returned. As such, we don't have to take the additional step of decoding and validating a JWT. Using this service, however, is not recommended for production environments, and most of the time you will be doing the decoding yourself.
If we examine another sample of code, this time for Okta (another IDP), we can see a few steps. The first step, as was indicate earlier, is to redirect the user to the auth_uri. This is the first implicit step. The follow on steps are:
"steps": [
{
"name": "token",
"uri": "${okta_uri}/token?code=${auth.code}&client_id=${client_id}&client_secret=${client_secret}&redirect_uri=${base_uri}${redirect_path}&grant_type=${grant_type}&state=${state}",
"method": "POST"
},
{
"name": "jwt",
"decode": "${token.id_token}"
}
]
As in this case, if the decode parameter is set for a step, then the field that is referenced needs to point to a Base64 encoded JWT object. In turn, the result of that decoding will be set on the user's session, such as this:
req.session[jwt_key] = decode(jwt);
Refresh Token
It is often the case that an IDP will provide you with something called a refresh token. This token can kind of be thought to be a case number for this user's authentication, and it can be used to basically refresh the user with a new token without them having to do the extra work of verifying that they want the IDP to share specific details with the SP.
If a step is named refresh, and if upon validating a user's session it is determined that the access token has expired, then this step will be invoked with the refresh token that was assigned to the user
For instance, consider this configuration code block:
{
"name": "refresh",
"uri": "https://oauth2.googleapis.com/token?refresh_token=${refresh_token}&grant_type=refresh_token&client_id=${client_id}&client_secret=${client_secret}",
"method": "POST",
"config": {
"headers": {
"Content-Type": "application/x-www-form-urlencoded"
}
}
}
This step will NOT get called during the initial user authentication, but it will get called if there is a refresh token that came back as a field in the JWT that was returned from the IDP. For it to work properly, the IDP's initial token must have included a refresh token under the key refresh_token.