OpenId Connect Authentication with Authorisation Code Flow

We strongly recommend the use of standardized and validated client libraries from you application code. Security is very difficult to get correct and this article will just show a simplified minimal implementation of the authentication of a user with OpenId Connect (OIDC), more in the purpose to educate the basics than showing production ready code.

Initiating user authentication

The application will initiate a user authentication by re-directing the browser to the authorization endpoint of the Identity Provider (IdP, in this case Authway). The re-direct must be an OIDC authentication request message. Here is an example of the re-direct by the application:

HTTP/1.1 302 Found
Location: https://YOURINSTANCE.irmciam.se/connect/authorize?
          client_id=YOUR_CLIENT_ID
          &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcallback
          &response_type=code
          &scope=openid
          &state=YOUR_STATE

Explanation of the request parameters in the example:

  • client_id: The application (Client) identifier in Authway. This identifier is created when the application is registered in Authway.
  • redirect_uri: The callback URI where the application want to retrieve the authentication response. This URI is validated by Authway and it must be an exact match of a registered redirect URI.
  • response_type: Set to code to indicate that it is the authorisation code flow that should be used.
  • scope: Used to specify the requested authorisation in OAuth. openid indicates a request for OIDC authentication and a ID token. More scopes can be supplied to indicate what information should be included in the ID token. Read more about what claims are included in which scopes.
  • state: Value set by the application to keep state between the request and the callback.

There are more parameters that can be used.

At the Idp the user will be prompted to sign-in, if that is not already the case. After the Idp has successfully authenticated the user it will call the application redirect_uri with an authorization code (or an error code if Idp fails).

HTTP/1.1 302 Found
Location: https://client.example.org/callback?
          code=A_CODE_ISSUED_BY_IDP
          &state=YOUR_STATE

The application must validate the state parameter and if successful continue to exchange the code for the ID token.

Exchange code for ID token

The retrieved code is meaningless to the application and must be exchanged for an ID token by making a back-channel call to the Idp. By making this in a back-channel the client can be validated, so that the Idp is only revealing tokens to trusted applications, and the ID token is never exposed to the browser (which is more secure).

The code can be exchanged at the token endpoint:

POST /connect/token HTTP/1.1
Host: YOURINSTANCE.irmciam.se
Content-Type: application/x-www-form-urlencoded
Authorization: Basic czZCaGRSa3F0Mzo3RmpmcDBaQnIxS3REUmJuZlZkbUl3

grant_type=authorization_code
 &code=A_CODE_ISSUED_BY_IDP
 &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcallback

Client ID and secret can be passed in the Authorization header or as client_id and client_secret parameters in the request body. The form-encoded parameters should include a repeated redirect_uri and the retreved code from the response after the successful authentication of the user.

If the Idp handles the request successfully it will return a JSON object with the ID token, an access token and optionally a refresh token together with expiration information.

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: no-store
Pragma: no-cache

{
  "id_token": "A_JWT_TOKEN",
  "access_token": "AN_ACCESS_TOKEN",
  "token_type": "Bearer",
  "expires_in": 3600,
}

The ID token should be validated by the application before it is accepted.

The Authorisation Code Flow with PKCE

When using the Authorization Code Flow in public clients, such as single-page applications (SPAs) or mobile apps, it is strongly recommended to use PKCE (Proof Key for Code Exchange) to enhance security. PKCE mitigates the risk of an authorization code being intercepted and used by a malicious actor.

With PKCE, the client creates a code verifier and a code challenge before initiating the authorization request. These values are used to bind the authorization code to the specific client instance that requested it.

Here is an example of the re-direct by the application:

HTTP/1.1 302 Found
Location: https://YOURINSTANCE.irmciam.se/connect/authorize?
          client_id=YOUR_CLIENT_ID
          &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcallback
          &response_type=code
          &scope=openid
          &state=YOUR_STATE
          &code_challenge=YOUR_CODE_CHALLENGE
          &code_challenge_method=S256

Explanation of the additional request parameters in the example:

  • code_challenge: A derived value from the code verifier. It is created by applying the SHA256 hash algorithm to the code verifier and then Base64URL-encoding the result. This value is sent in the initial authorization request.
  • code_challenge_method: Specifies the transformation method used to generate the code challenge. The recommended and most secure method is S256. The plain method (plain) is also defined but should only be used if the S256 method is not supported.

Exchange code for ID token with PKCE

When exchanging the code for the ID token must also be modified when PKCE is used. The client (application) must include the code_verifier that was used to generate the code_challange in the authorisation request.

The extended code that exchange the code at the token endpoint:

POST /connect/token HTTP/1.1
Host: YOURINSTANCE.irmciam.se
Content-Type: application/x-www-form-urlencoded

grant_type=authorization_code
 &code=A_CODE_ISSUED_BY_IDP
 &code_verifier=YOUR_CODE_VERIFIER
 &client_id=YOUR_CLIENT_ID
 &redirect_uri=https%3A%2F%2Fclient.example.org%2Fcallback

Note that the this request does not pass any Authorization header, because a public client can’t securely store a client secret, since their code and configuration can be accessed by the user. This is the reason why PKCE is used and strongly recommended for this type of clients, but can be used by confidential clients (such as traditional server-side web applications) too for improved security (together with the client secret).

Explanation of the additional parameter:

  • client_id: The client id (the application (Client) identifier in Authway) is still required and is passed in the body.
  • code_verifier: The original string generated by the client before the authorization request.
    Authway verifies this value by re-computing the code challenge and comparing it with the one sent earlier. If they match, the token exchange succeeds.

Creating the Code Verifier and Code Challenge

Please use a tested and documented client library instead of relying on this code, which should only be used for extended understanding of how the PKCE flow works.

Before initiating the authorization request, the application must generate a code verifier and derive a code challenge from it.

  • code_verifier: A high-entropy cryptographic random string (43–128 characters) consisting of letters, digits, and the characters -._~.

  • code_challenge: A value derived from the code_verifier, created by applying the SHA256 hash function and then Base64URL-encoding the result.

Here is an example of how to create these values in Node.js:

import crypto from "crypto";

// Generate a high-entropy random string as the code_verifier
const codeVerifier = crypto.randomBytes(64)
  .toString("base64")
  .replace(/\+/g, "-")
  .replace(/\//g, "_")
  .replace(/=+$/, "");

// Create the SHA256 hash of the code_verifier
const hash = crypto.createHash("sha256").update(codeVerifier).digest();

// Base64URL-encode the hash to get the code_challenge
const codeChallenge = hash
  .toString("base64")
  .replace(/\+/g, "-")
  .replace(/\//g, "_")
  .replace(/=+$/, "");

console.log("Code Verifier:", codeVerifier);
console.log("Code Challenge:", codeChallenge);

The code_verifier must be securely stored by the application (for example, in the session or local storage) so that it can later be included in the token request.

Samples

Here are some samples:

Subsections of OpenId Connect Authentication with Authorisation Code Flow

Authorization Endpoint Parameters

Request parameters

Parameter Required Description
acr_values No Space seperated string with special requests from the client. See below.
client_id Yes The unique id of the requesting client.
code_challange No, but required for client configured for PKCE. The code challange for PKCE.
code_challenge_method No, but required when code_challange is used. “S256” indicates the challenge is hashed with SHA256 (recommended). “plain” indicates that the challenge is using plain text (not recommended).
login_hint No Hint of the username. Could be used if the client asks for username before redirecting.
login_hint_token No A non-standardized parameters to enable some extra single-sign-on scenarios. Previously this parameter was sso_token and it is still supported.
max_age No Specifies how much time (in seconds) that is allowed to been passed since user singed in. See Force re-authentication of user.
nonce No, but required when response type is id_token A random string value used to mitigate replay attacks by associating the client session with the ID token.
prompt No “none”, “login”, “consent”, “select_account” or “create”. Indicates how the client wants that Authway handles the request.
redirect_uri Yes The callback URL the client wants to re-direct to. Must be pre-registered.
request No Instead of providing all parameters as individual query string parameters, it is possible to provide a subset or all them as a JWT.
request_uri No URL of a pre-packaged JWT containing request parameters.
response_mode No “query”, “fragment” or “form_post”. Indicates how the response should be returned.
response_type Yes Defines which flow to use (code, id_token, token, or a combination).
scope Yes A space separated string of scopes that the client wants access to.
state Recommended A random value that will be passed back to the client. Can be used to keep track of a session or to prevent unsolicited flows.
ui_locales No End-User’s preferred languages, represented as a space-separated list of language tag values, ordered by preference. For instance, the value “sv-SE en” represents a preference for Swedish as spoken in Sweden, then English (without a region designation).

Note request and request_rui can’t be used to together.

acr_values parameters

The acr_values parameters are passed as “parameter:value” and if multiple parameters are passed they should be seperated with a space. For example:

tenant:priv idp:bankid
Parameter Description
idp The unique identifier of the sign-in method to use. See Control authentication method from the client.
tenant The unique identifier of an owner (tenant) that the user must belong to. See Only allow users from specific tenant
impersonate Trigger the flow to impersonate another user. See Impersonate a user.
mfa Require the user to be authenticated with MFA. See Require MFA.

Parameters for specific authentication methods

Swedish BankId

Parameter Required Description
bankid_user_visible_data No Text displayed to the user during the authentication. Passed on to BankId without modification.
bankid_user_visible_data_format No The format of the text in bankid_user_visible_data parameter and can be plaintext (default) or simpleMarkdownV1. Passed on to BankId without modification.
bankid_call_initiator No Indicates if the user or your organization initiated the phone call (so only handled during a CIBA request). Valid values are user (default) or RP. Passed on to BankId without modification.

Custom request parameters

It is possible for the client to pass additional parameters in the request. Thoose parameters will not be handled by the Authorization Server in any way, but they will be returned as is in the response.

If the client for example want to have a custom tag parameter in the response, it can be added to the request like this:

/connect/authorize?client_id=…&redirect_uri=mycallbackurl&response_type=..&scope=…&code_challenge=…&code_challenge_method=…&response_mode=…&nonce=…&state=…&tag=my_value

The response will then include the tag parameter unmodified (ordering of the parameters are not guaranteed):

mycallbackurl?code=…&scope=…&state=…&session_state=…&iss=…&tag=my_value

Default Claims Supported

This is the default claims supported by Authway, but custom support can be added for more/other claims in a customer instance. The scope that the claims belong to is also the default, but could differ for a specific customer.

Scope Claim Description
openid sub A unique value identifying the user.
openid tid A unique value identifying the tenant the user belongs to.
profile family_name The surname of the user. For example “Doe”.
profile given_name The given name of the user. For example “Joe”.
profile name The full name of the user (given_name + ’ ’ + family_name). For example “Joe Doe”.
profile preferred_username The user name used during sign-in, most commonly the e-mail address.
profile picture A URL with a user picture taken from Gravatar or the initials if the user does not have a Gravatar picture.
email email The email address of the user. Could exists twice if the user has another e-mail registered on the person.
email email_verified True if the email address have been verified and it is always the same e-mail address used as user name that are confirmed (or not).
phone phone_number The phone number of the user.
phone phone_number_verified True if the phone number have been verified; otherwise False.
org orgid The unique identifier for the organisation the user belongs to. Will be the same as tid for users belonging to the mother organisation, but could be different for users belonging to a subsidiary.
org orgin The organisation number identifying the organisation, if the value exists.
org company_name The name of the organisation.
roles role The name of the group the user belongs to. Zero, one or more claims of this type could exists depending on the number of groups the user belongs to.
perms perm The unique identifier of a permission that the user have. Zero, one or more claims of this type could exists depending on the number of permissions the user got.

Protocol claims

Claim Description
amr The authentication used when the user signed in. The value would be pwd for password or external for an external identity provider (idp). More than one amr claim can exists. For example if multi-factor authentication is used there will be an amr claim with the mfa value.
idp The identity provider used when amr is external or local if the user used a password to sign-in.
auth_time The time the user authenticated. Could be used to evaluate if a re-authentication is required.

There are more protocol claims, but those are the most commonly used claims by developers themselves.

Special claims

Impersonated users

If a user is impersonated there will be an act claim with a serialized json structure with standard claims from the original user performing the impersonation. For example:

"act": {
   "oid":"d5542f98-8a6f-6d2a-cda0-39fc52ae2b58",
   "sub":"295A0000-E969-E6E6-3826-08DB0DD1E036",
   "tid":"a27446b6-795e-4ccc-1da6-39fc52ae2b37"
}

If impersonation is used there will also be a second amr claim with the value imp (for impersonate).

Linked accounts

If linked account are used the sub claim will have the same value for all linked accounts (that is the definition of linked accounts) which means that it is not unique over all tenants anymore. In that case a oid claim (object id) is also issued with a true unique identifier over all tenants.

For linked accounts the a may_login claim is also issued for org scope, which contains a serialized JSON array with information about other organisations that the user could switch to. For example:

"may_login": {
   [{
      "oid":"d5542f98-8a6f-6d2a-cda0-39fc52ae2b58", 
      "tid":"ffffffff-ffff-ffff-ffff-ffffffffffff",
      "companyname": "Privatpersoner"
   }]
}

OpenId Connect Authentication with .NET

To log in a user according to this guide, you must have configured an application (client) as a web application (server).

To log in a user, you use the OpenID Connect support available in .NET. This guide will show how it’s done in .NET 10, but for the most part, the process is similar regardless of the framework version.

Add the NuGet package for OpenID Connect:

<ItemGroup>   
  <PackageReference Include="Duende.IdentityModel" Version="8.0.0" />
  <PackageReference Include="Microsoft.AspNetCore.Authentication.OpenIdConnect" Version="10.0.3" />
</ItemGroup>

Here is also support for Duende.IdentityModel, which has several useful classes when working with OpenID Connect and claims.

Configure OpenID Connect and Cookies so that the application uses the IdP to identify users (in Startup.ConfigureServices):

services.AddAuthentication(options =>
{
    options.DefaultScheme = "Cookies";
    options.DefaultChallengeScheme = "oidc";
})
.AddCookie("Cookies")
.AddOpenIdConnect("oidc", options =>
{
    options.Authority = "https://environment-company.irmciam.se/";

    options.ClientId = "Company.ApplicationId";
    options.ClientSecret = "supertopsecret";
    options.ResponseType = "code";
    options.SaveTokens = true;
    options.MapInboundClaims = false;
});

In the project template, authentication is not enabled by default, a line must also be added in Startup.Configure (before UseAuthorization):

app.UseAuthentication();
app.UseAuthorization();

To trigger the login, there needs to be one or more protected pages. This can be configured in many different ways and is well-documented by Microsoft. For this example, we make a simple modification to the HomeController by adding the Authorize attribute, which forces the user to log in immediately.

[Authorize]
public class HomeController : Controller

Now you can test the application, and assuming everything is configured in the IdP, you should land directly on the IdP’s login page. After the login is completed, you will be redirected back to the application.

With the above configuration, User.Identity.Name may not display anything. This is because, for compatibility reasons, Microsoft reads the wrong claim, and the code must specify which claim to use instead. Make the following change to the options for the OpenID Connect configuration (which also sets the correct role claim):

...
options.SaveTokens = true;

options.TokenValidationParameters = new Microsoft.IdentityModel.Tokens.TokenValidationParameters
{
    NameClaimType = JwtClaimTypes.Name, //JwtClaimTypes.PreferredUserName, eller vad du önskar
    RoleClaimType = JwtClaimTypes.Role
};

JwtClaimTypes is part of the IdentityModel package that we added to the project file in the first step above.

To see which claims and other relevant information are available, you can add the following to a Razor view:

    <h2>Claims</h2>

    <dl>
        @foreach (var claim in User.Claims)
        {
            <dt>@claim.Type</dt>
            <dd>@claim.Value</dd>
        }
    </dl>

    <h2>Properties</h2>

    <dl>
        @foreach (var prop in (await Context.AuthenticateAsync()).Properties.Items)
        {
            <dt>@prop.Key</dt>
            <dd>@prop.Value</dd>
        }
    </dl>

What you quickly notice on a page displaying the user’s claims is that there are only a few claims compared to those available in the IdP. To get more claims, additional configuration is needed. All this configuration is also done in the AddOpenIdConnect call from above.

To retrieve more claims, you need to specify that more claims should be fetched from the UserInfo endpoint:

options.GetClaimsFromUserInfoEndpoint = true;

In OpenID Connect, the client (the application) requests which scopes it wants. Each scope is associated with one or more claims (this configuration is in the IdP). Common standard scopes include:

  • openid (must be requested).
  • profile (contains profile information for the user, such as name, picture, web pages, etc.).
  • email (contains email and whether the email address is verified).
  • phone (contains phone number and whether the number is verified, but this is not used by the IdP today).
  • address (contains address information, but is not used by the IdP today).
  • org (contains organizational information for users belonging to an organization; this is a custom scope for the IdP).

Microsoft’s default configuration of OpenID Connect automatically adds openid and profile, but if you want to include the user’s email, you need to add that scope to the configuration:

//Begär de scopes (gruppering av claims) som önskas
//options.Scope.Add("openid"); //Default by Microsoft
//options.Scope.Add("profile"); //Default by Microsoft
options.Scope.Add("email");
options.Scope.Add("org"); //IRM Idp organisationsinformation

If you run the application with these settings, you will unfortunately still notice that several claims are missing. This is because Microsoft has a concept called ClaimActions, which means that only the claims for which there is a ClaimAction will be transferred to the ClaimsIdentity during login. Therefore, we need to add more actions:

options.ClaimActions.MapUniqueJsonKey(JwtClaimTypes.Picture, JwtClaimTypes.Picture);
options.ClaimActions.MapUniqueJsonKey(JwtClaimTypes.EmailVerified, JwtClaimTypes.EmailVerified);
options.ClaimActions.MapUniqueJsonKey(Constants.JwtClaimTypes.TenantId, Constants.JwtClaimTypes.TenantId); //IRM IdP tenant id
options.ClaimActions.MapUniqueJsonKey(Constants.JwtClaimTypes.OrganisationId, Constants.JwtClaimTypes.OrganisationId); // IRM IdP organization id (same as before if the person belongs to the parent company, but may be different)
options.ClaimActions.MapUniqueJsonKey(Constants.JwtClaimTypes.CompanyName, Constants.JwtClaimTypes.CompanyName);

Several of these claims are unique to the IdP, and for this, we have created our own Constants class in the project (also declared in IRM.dll):

public static class Constants
{
    public static class JwtClaimTypes
    {
        public static string TenantId = "tid";
        public static string OrganisationId = "orgin";
        public static string CompanyName = "companyname";
        public static string Permission = "perm";
    }
}

With this configuration, you will see many of the claims that the IdP can provide about a user. In IRM.AspNetCpre.Mvc, there is a convenient extension method to add scopes and typical claim actions so that the application can access the claims included in each scope:

options.AddScope(StandardScopes.OpenId); //Default added by Microsoft, but here, a mapping is also done for OwnerId/TenantId
options.AddScope(StandardScopes.Profile); //Default added by Microsoft, but here, mappings are also done for, for example, picture.
options.AddScope(StandardScopes.Email);
options.AddScope(CustomScopes.Roles);
options.AddScope(CustomScopes.Permission);
options.AddScope(CustomScopes.Organisation);

Since we have specified SaveTokens = true above, we can retrieve the access token to make calls to an API:

var accessToken = await HttpContext.GetTokenAsync("access_token");

Note that if you retrieve many scopes and map many claims, it can result in a large number of claims, leading to a large cookie. In general, it’s good to keep the cookie size down, so this is something to consider. In AddCookie, there is an option to configure a SessionStore, allowing you to choose to store the content on the server instead, for example, in a distributed cache.

In IRM.AspNetCore.Mvc, there is a SessionStore that uses MemoryCache and/or DistributedCache if configured. Just call AddCacheSessionStore after AddCookie:

.AddCookie("Cookies", options =>
{
    options.Cookie.HttpOnly = true;
    options.Cookie.SameSite = SameSiteMode.Lax;
    options.Cookie.IsEssential = true;
}).AddCacheSessionStore()

OpenId Connect Authentication with Python (Authlib)

To log in a user according to this guide, you must have configured an application (client) as a web application (server).

To log in a user, you can use the OpenID Connect support available in Authlib.

from authlib.integrations.flask_client import OAuth

oauth = OAuth(app)
oauth.register(
    name="authway",
    client_id="client-id-from-portal",
    client_secret="client-secret-from-portal",
    server_metadata_url="https://environment-company.irmciam.se/.well-known/openid-configuration",
    client_kwargs={
        "scope": "openid profile email org"
    }
)

@app.route("/login")
def login():
    redirect_uri = url_for("callback", _external=True)
    return oauth.authway.authorize_redirect(
        redirect_uri,
        code_challange_method="S256"
    )

@app.route("/callback")
def callback():
    token = oauth.authway.authorize_access_token()
    id_token = token["id_token"]
    access_token = token["access_token"]

    userinfo = oauth.authway.userinfo()
    # userinfo includes claim (values) that are part of requested scopes
    return f"Hello {userinfo['given_name']}"

OpenId Connect Authentication with Node.js (openid-client)

To log in a user according to this guide, you must have configured an application (client) as a web application (server).

To log in a user, you can use the OpenID Connect support available in openid-client.

import express from "express";
import { Issuer, generators } from "openid-client";

const app = express();

// -----------------------------
// Discover OIDC Provider
// -----------------------------
(async () => {
  const issuer = await Issuer.discover(
    "https://environment-company.irmciam.se/.well-known/openid-configuration"
  );

  client = new issuer.Client({
    client_id: "client-id-from-portal",
    client_secret: "client-secret-from-portal",
    token_endpoint_auth_method: "client_secret_basic",
  });

  console.log("OIDC client initialized");
})();



// -----------------------------
// Login Endpoint
// -----------------------------
app.get("/", async (req, res) => {
  const code_verifier = generators.codeVerifier();
  const code_challenge = generators.codeChallenge(code_verifier);
  const state = generators.state();

  req.session.code_verifier = code_verifier;
  req.session.state = state;

  const authUrl = client.authorizationUrl({
    scope: "openid profile email org",
    response_type: "code",
    redirect_uri: "http://localhost:3000/callback",
    state,
    code_challenge,
    code_challenge_method: "S256",
  });

  res.redirect(authUrl);
});

// -----------------------------
// Callback Endpoint
// -----------------------------
app.get("/callback", async (req, res) => {
  try {
    const params = client.callbackParams(req);

    const tokenSet = await client.callback(
      "http://localhost:3000/callback",
      params,
      {
        state: req.session.state,
        code_verifier: req.session.code_verifier,
      }
    );

    const claims = tokenSet.claims();

    res.json({
      access_token: tokenSet.access_token,
      id_token: tokenSet.id_token,
      user: claims,
    });

  } catch (err) {
    console.error(err);
    res.status(500).send("Authentication failed");
  }
});