OAuth2 and OpenID Connect for microservices and public applications (Part 2)

David WORMS

By David WORMS

Nov 20, 2020

Using OAuth2 and OpenID Connect, it is important to understand how the authorization flow is taking place, who shall call the Authorization Server, how to store the tokens. Moreover, microservices and client applications, such as mobile and SPA (single page application) applications, raise a few questions as to which flow applies to modern OAuth2 architectures.

Part 1, OAuth2 and OpenID Connect, a gentle and working introduction focuses on integrating your first application with an OpenID Connect server (Dex) and experienced the Authorization Code Flow with an external provider. Oauth and OpenID Connect strategies are complicated and confusing, reading that part will some light.

Part 2, OAuth2 and OpenID Connect for microservices and public applications, provides a deep dive into the OpenID code flow by describing, explaining and illustrating each steps. Once completed, you will be able to apply them to your client and public applications (mobile, SPA, …) without the need of extra tools.

Flow description

In the previous part 1, we used the Dex server with its example application to log in with our GitHub account. Here is what happened. A client application requires the user to be authenticated. From the client application, the user is redirected to the Authorization Server. Remember, the Authorization Server is an OpenID Connect server which is also an OAuth2 server. Once authenticated, consent takes place. The user, named the Resource Owner, authorizes the application to consume resources on his behalf. The user is redirected to the application with a short live authorization code. The authorization code is a Nonce (No More than Once) code. It is exchanged for an access token from the client application.

Time now to deep dive into the Authorization Code Flow.

A few things before starting

Reading this article and reproducing its procedure implies reading part 1 or having a decent understanding of OAuth as well as an OpenID Connect server up and running.

To test the procedure below, you only need to have Dex up and running as presented in part 1. There is no need to have a client application started.

OpenID Connect servers, also know as OAuth servers, provide a configuration endpoint called the Well-known URI Discovery Mechanism and available under .well-known/openid-configuration. For Dex, the full URL is http://127.0.0.1:5556/dex/.well-known/openid-configuration on our local server. The result is this JSON document:

{
  "issuer": "http://127.0.0.1:5556/dex",
  "authorization_endpoint": "http://127.0.0.1:5556/dex/auth",
  "token_endpoint": "http://127.0.0.1:5556/dex/token",
  "jwks_uri": "http://127.0.0.1:5556/dex/keys",
  "userinfo_endpoint": "http://127.0.0.1:5556/dex/userinfo",
  "device_authorization_endpoint": "http://127.0.0.1:5556/dex/device/code",
  "grant_types_supported": [
    "authorization_code",
    "refresh_token",
    "urn:ietf:params:oauth:grant-type:device_code"
  ],
  "response_types_supported": [
    "code"
  ],
  "subject_types_supported": [
    "public"
  ],
  "id_token_signing_alg_values_supported": [
    "RS256"
  ],
  "code_challenge_methods_supported": [
    "S256",
    "plain"
  ],
  "scopes_supported": [
    "openid",
    "email",
    "groups",
    "profile",
    "offline_access"
  ],
  "token_endpoint_auth_methods_supported": [
    "client_secret_basic"
  ],
  "claims_supported": [
    "aud",
    "email",
    "email_verified",
    "exp",
    "iat",
    "iss",
    "locale",
    "name",
    "sub"
  ]
}

It contains the various endpoints exposed by Dex as well as multiple available and supported features.

A CLI application to test the flow

I have created a small Node.js CLI application published on GitHub. It reproduces manually each steps of the authentication process. To each step corresponds a Node.js module inside the “lib” folder and named with an incremental number.

You can run the overall application with the command npx openid-cli-usage. It prints:

NAME
  openid-cli-usage - OAuth2 and OpenID Connect (OIDC) usage using the Authorization Code Grant.

SYNOPSIS
  openid-cli-usage <command>

OPTIONS
  -h --help                 Display help information.

COMMANDS
  redirect_url              OAuth2 and OIDC usage - step 1 - redirect URL generation.
  code_grant                OAuth2 and OIDC usage - step 2 - authorization code grant.
  refresh_token             OAuth2 and OIDC usage - step 3 - refresh token grant.
  user_info                 OAuth2 and OIDC usage - step 4 - user information.
  jwt_verify                OAuth2 and OIDC usage - step 5 - JWT verification.
  help                      Display help information

EXAMPLES
  openid-cli-usage --help   Show this message.
  openid-cli-usage help     Show this message.

Note, it implies that Node.js is installed on your system. Every Node.js installation come with the node, npm and npx commands. npx is a tool to execute node.js packages.

As you can see, they are 5 available commands, beside help, which are:

  • npx openid-cli-usage redirect_url: step 1 - URL generation
  • npx openid-cli-usage code_grant: step 2 - authorization code grant
  • npx openid-cli-usage refresh_token: step 3 - refresh token grant
  • npx openid-cli-usage user_info: step 4 - user info
  • npx openid-cli-usage jwt_verify: step 5 - JWT verify

To each command corresonds a Node.js module. For example, the redirect_url command is implemented inside the ./lib/1.redirect_url.coffee file. It can also be execute individually by cloning the repository and running npx coffee ./lib/1.redirect_url.coffee inside it. By the way, it is 2020 and I still enjoy writing CoffeeScript. It is clean, simple and expressive.

Each module is constructured with the same pattern. They define a [parameter configuration] which describes the CLI application. The configuration consist of descriptions, list of options, as well as handler functions.

The handler functions are were the work is happening and this is what has been imported as code illustration inside this article.

Step 1: Redirect URL generation

The user is in your client application. He is not yet authenticated. The client will redirect him to the Authorization Server (AS). For this, a special request must be build.

The redirect_url command, npx openid-cli-usage redirect_url --help, looks like:

NAME
  openid-cli-usage redirect_url - OAuth2 and OIDC usage - step 1 - generate URL

SYNOPSIS
  openid-cli-usage redirect_url [redirect_url options]

OPTIONS for redirect_url
     --authorization_endpoint Authorization endpoint. Required.
     --client_id              Client ID. Required.
  -h --help                   Display help information.
     --redirect_uri           Redirect URI Required.
     --scope                  No description yet for the scope option. Required.

OPTIONS for openid-cli-usage
  -h --help                   Display help information.

EXAMPLES
  openid-cli-usage redirect_url --help Show this message.

The 4 arguments are required.

The authorization_endpoint is the url of the AS server where the user should land. For Dex, it is located at ”http://127.0.0.1:5556/dex/auth”. You can query the OpenID configuration endpoint to discover its value:

curl -s http://127.0.0.1:5556/dex/.well-known/openid-configuration \
  | grep '"authorization_endpoint"'
  "authorization_endpoint": "http://127.0.0.1:5556/dex/auth",

The client_id is the one registered inside your AS server, Dex in our case.

The redirect_uri is a URL inside your client application, for example http://my.great.app/callback.

The scope is a list of valid scope OpenID Scope. The OpenID configuration endpoint return the following scope by default:

{
  "scopes_supported": [
    "openid",
    "email",
    "groups",
    "profile",
    "offline_access"
  ]
}

The openid scope is required. Add the email scope if you wish to obtain the email of the authenticated identity. The offline_access is also important as it will provide you with a refresh token. More on that later.

In the lib/1.redirect_url.coffee module, the handler function looks like:

crypto = require 'crypto'

base64URLEncode = (str) ->
  str.toString('base64')
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=/g, '');

sha256 = (buffer) ->
  crypto
    .createHash('sha256')
    .update(buffer)
    .digest()

handler = ({
  params: { authorization_endpoint, client_id, redirect_uri, scope }
  stdout
}) ->
  code_verifier = base64URLEncode(crypto.randomBytes(32))
  code_challenge = base64URLEncode(sha256(code_verifier))
  url = [
    "#{authorization_endpoint}?"
    "client_id=#{client_id}&"
    "scope=#{scope.join '%20'}&"
    "response_type=code&"
    "redirect_uri=#{redirect_uri}&"
    "code_challenge=#{code_challenge}&"
    "code_challenge_method=S256"
  ].join ''
  data =
    code_verifier: code_verifier
    url: url
  stdout.write JSON.stringify data, null, 2
  stdout.write '\n\n'

It prints the code verifier which are randomly generated bytes encoded with base64 and the URL to redirect the user to the AS. The base64URLEncode and sha256 function are easy to integrate on a public client with a browser or mobile environment.

The URL is simply built. There is no need to escape the argument beside the redirect_uri but it is not required in my case.

Be carefull, the redirect URI must match the one registered inside the OAuth server.

Running the command:

npx openid-cli-usage redirect_url \
  --authorization_endpoint http://127.0.0.1:5556/dex/auth \
  --client_id example-app \
  --redirect_uri http://127.0.0.1:5555/callback \
  --scope openid \
  --scope email \
  --scope offline_access

Prints:

{
  "code_verifier": "jVc-dP1YsCFp6px0XKFHBMtM5lwfp2inbb9xE8iv3y8-MPwGQFUCCGlu_Ejsd5tuECu3lU",
  "url": "http://127.0.0.1:5556/dex/auth?client_id=example-app&scope=openid%20email%20offline_access&response_type=code&redirect_uri=http://localhost:3002/auth/callback&code_challenge=I-bxiEvqV5NOveieEt2RWC1-pwknOg8UCa2FMi0Supg&code_challenge_method=S256"
}

Open your web browser and copy/paste the URL.

Step 2: Authorization Code Grant

You are now inside the OAuth server. Depending on your configuration and the registered connectors, you will be proposed with multiple login providers. Select one and complete the authentication process. You will be redirected to the client application. It doesn’t matter if the client application is not started. In fact, it is better. Once the login and consent steps are completed, you are sure to see the callback URL with the extra code and state query parameters before any redirect could make them disappear from the URL.

The code is named the Authorization Code. It is a temporary code returned by the Authorization Server to the client who will exchange it with an access token. It is a Nonce (No More than Once) and is very short-lived, commonly around 30 seconds.

Note, the access token is not returned directly in the callback URL. This flow is called the Implicit Flow and it shall no longer be used because it is insecure. Placing the token in a redirect URL creates a large surface of attack. For example, it leaves the tokens accessible inside the browser history. Also, all your browser plugins and half trusted dependencies hosted on half trusted CDN have access to it. Instead, it is recommended to use PKCE, a small extension to the Authorization Code Flow which only differs in that it doesn’t require the usage of a client secret.

Instead, the access token is obtained just after with an additional HTTP POST request. Combined with the code verifier, the authorization code validates a challenge. On success, the various tokens, including the access token, are returned.

Copy the code value. You can disregard state. It is just a way to pass information from the client’s original page back to the callback page.

We will use code to build a POST request and retrieve our tokens. Here is how the lib/2.code_grant.coffee module is implementing it:

qs = require 'qs'
axios = require 'axios'

handler = ({
  params: {
    client_id, client_secret, token_endpoint,
    redirect_uri, code_verifier, code
  }
  stdout
  stderr
}) ->
  try
    {data} = await axios.post token_endpoint,
      qs.stringify
        grant_type: 'authorization_code'
        client_id: "#{client_id}"
        redirect_uri: "#{redirect_uri}"
        client_secret: client_secret
        code_verifier: "#{code_verifier}"
        code: "#{code}"
    stdout.write JSON.stringify data, null, 2
    stdout.write '\n\n'
  catch err
    stderr.write JSON.stringify err.response.data, null, 2
    stdout.write '\n\n'

The axios package is an HTTP client. We use the qs package to build a query string that is sent as the POST body content.

To build our query string, we need the client id, the client secret, and the redirect_uri matching the one of the Dex configuration. The token endpoint can be obtained from the Well-known URI Discovery endpoint and equals to http://127.0.0.1:5556/dex/token in our case.

Adjust the parameters and run the following command:

npx openid-cli-usage code_grant \
  --client_id example-app \
  --client_secret ZXhhbXBsZS1hcHAtc2VjcmV0 \
  --token_endpoint http://127.0.0.1:5556/dex/token \
  --redirect_uri http://127.0.0.1:5555/callback \
  --code_verifier jVc-dP1YsCFp6px0XKFHBMtM5lwfp2inbb9xE8iv3y8 \
  --code ofupa4qe35oko4j7xzerrtyhp

It prints on success:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjgzYmI1ZTEyYmRlOTk3MWQ2ODgzMjU0MDA1NWI5ZjViN2NkZmIyYjYifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjU1NTYvZGV4Iiwic3ViIjoiQ2dVME5qZzVOaElHWjJsMGFIVmkiLCJhdWQiOiJ3ZWJ0ZWNoLWF1dGgiLCJleHAiOjE2MDU2ODkwODgsImlhdCI6MTYwNTYwMjY4OCwiYXRfaGFzaCI6ImJYLXpmSVlZZEtUaTE5Q1NNRmlGZkEiLCJlbWFpbCI6ImRhdmlkQGFkYWx0YXMuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWV9.bIJYsGTCYypH9NYmxgX-KWjSs3Et7bEFAEkFlJKHvcXfYWxCAVBp0KZD2xUMTVXRsRHCjgsioyxuFqShmLu0Nt9Et5jQs8XieuTJTt4EplYt2q2SXveDM1xCpXLfMSTf5qbJKvKCxOo-fXsZXxYirEqA2wMa-0rsFvj8jyJGANe6iF7fbMHnnSmwGknQmMA7wT2S9J_0s53ommtbdAWFE8f8KyqjpzOugp3DRArwQzrViPeBpWqgHT3zMIZG_m4-LAHt5zJtk4SpUZuTG_MYamSMzmK0JVxmhXZm-KjM2FnT9UqX73qc74iBBn27VB1SnUhBpdxKjeHmXdZQg31ROA",
  "token_type": "bearer",
  "expires_in": 86399,
  "refresh_token": "Chlhb2t4N20yaGVwcTJpMzY2Mm94cmhkdDJhEhl3aXp3MzJjYzdoajd6Nnhmd2V5amJ4czJo",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjgzYmI1ZTEyYmRlOTk3MWQ2ODgzMjU0MDA1NWI5ZjViN2NkZmIyYjYifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjU1NTYvZGV4Iiwic3ViIjoiQ2dVME5qZzVOaElHWjJsMGFIVmkiLCJhdWQiOiJ3ZWJ0ZWNoLWF1dGgiLCJleHAiOjE2MDU2ODkwODgsImlhdCI6MTYwNTYwMjY4OCwiYXRfaGFzaCI6ImhJTEhaaHJjNHdENlJZSzRLaVdMUlEiLCJlbWFpbCI6ImRhdmlkQGFkYWx0YXMuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWV9.CU8zht_oqzCQ3-b9q9H7R2NbSN4V--uTvNUqvUpVFxKUfAC1J9Kc4RQYtnU-N0kJP4ZO-a4OCN31dDj-3hin1Wj3G2qoNeTQB6p3zveUYca_eEVI5cP1jcj-jUa4QNz-CCraWIoQwPdnqUjHiWY3kg-thEONvR6QFhrRMcP-YkDpFmgyjYqNE1iWOuZbRPi6b1TzWmiCQG2ucevmDE8XFv845f3h7-qFnj2wmkaBJ9gxyRyn_-sD-qfYlYzK9MwUToM5lIX5TLfuN4p5QVVqFLIdEDyTG3hFlk5LSu2dzimCgddeWbN1MJnVdjRQWc5Gpvi3qkXqSeWwGHyAdrj_LQ"
}

And on error:

{
  "error": "invalid_request",
  "error_description": "Invalid or expired code parameter."
}

Public client application and PKCE

Great, we now have an access token, an id token, and even a refresh token if the offline_access scope was originally provided. There is no technical reason why we couldn’t run this code in the browser. After all, it is just plain JavaScript.

There are however two caveats:

  • The secret ID must be present inside the browser which means that it is now shared between every user, authenticated or not. This is where PKCE comes to the rescue.
  • Running the post request from the browser will fail due to security reasons with a message such as Origin http://localhost:8080 is not allowed by Access-Control-Allow-Origin unless CORS is activated on the OpenID Connect server.

PKCE is defined by RFC 7636 and is an extension to the Authorization Code Flow. It is pronounced “pixy” in English. It replaces the Implicit Flow mentioned earlier which is much less secure and no longer recommended. It is not even available with Dex.

To activate PKCE in Dex, comment the client secret key and activate the public property of your client:

staticClients:
- id: example-app
  redirectURIs:
  - 'http://127.0.0.1:5555/callback'
  name: 'Example App'
  # secret: ZXhhbXBsZS1hcHAtc2VjcmV0
  public: true

To enable CORS in Dex, set the allowedOrigins to ['*'] in the web section.

web:
  http: 0.0.0.0:5556
  allowedOrigins: ['*']

Now restart the Dex server. Repeat steps 1 and 2, just omit the client_secret property:

npx openid-cli-usage code_grant \
  --client_id example-app \
  --token_endpoint http://127.0.0.1:5556/dex/token \
  --redirect_uri http://127.0.0.1:5555/callback \
  --code_verifier jVc-dP1YsCFp6px0XKFHBMtM5lwfp2inbb9xE8iv3y8 \
  --code ofupa4qe35oko4j7xzerrtyhp

Failing to active PKCE prints:

{
  "error": "invalid_client",
  "error_description": "Invalid client credentials."
}

There is no technical barrier anymore. OpenID is now working inside your public client front-end, whether it is a mobile app, a SPA app, or anything else.

The rest of the example assumes we are using PKCE.

Refresh token

The access token is the only token which shall be sent to the API when submitting authenticated requests. You shall never share the refresh and ID token.

The access token has a short period of validity. If you do not want your users to log in frequently, you must return the refresh token with the offline_access scope.

Requesting a new token is a matter of sending the right POST request.

axios = require 'axios'
qs = require 'qs'

handler = ({
  params: { client_id, client_secret, refresh_token, token_endpoint }
  stdout
  stderr
}) ->
  try
    {data} = await axios.post token_endpoint,
      qs.stringify
        grant_type: 'refresh_token'
        client_id: client_id
        client_secret: client_secret
        refresh_token: refresh_token
    stdout.write JSON.stringify data, null, 2
    stdout.write '\n\n'
  catch err
    stderr.write JSON.stringify err.response.data, null, 2
    stderr.write '\n\n'

It takes the client ID, the client secret unless using the PKCE flow and the user refresh token:

npx openid-cli-usage refresh_token \
  --client_id example-app \
  --token_endpoint http://127.0.0.1:5556/dex/token \
  --refresh_token Chlhb2t4N20yaGVwcTJpMzY2Mm94cmhkdDJhEhl3aXp3MzJjYzdoajd6Nnhmd2V5amJ4czJo

Like previously when using the Authorization Code, the result is:

{
  "access_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjAxNWMzYjNmYmE4YzkwNzVmYjhlMjcxOWZkMjNjOGU1YWE3Y2Q4MTQifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjU1NTYvZGV4Iiwic3ViIjoiQ2dVME5qZzVOaElHWjJsMGFIVmkiLCJhdWQiOiJleGFtcGxlLWFwcCIsImV4cCI6MTYwNTczNDQ2MiwiaWF0IjoxNjA1NjQ4MDYyLCJhdF9oYXNoIjoiam5fTV9SYndyMzU0NWRHakxRZWEyUSIsImVtYWlsIjoiZGF2aWRAYWRhbHRhcy5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0.PmtqD50l3oJd4n1qAtgVwT6yimdFo3qlS1x_y0J9ZKEcfwPsgqnJJ6A9wbUIewhfuH9BCDnqhE-y6ZfrNi9ZIWQTETKZEx4LTg7vq5MKaWOMMEu_r1yOYmdHKsuOBXQko1vBdEjPtpSi7vCp4HR_gVwDfIe1KwnMPZjjcvvkr_PYMVj2_2RPWARB6tVczDTkBjTXTDvFXynVSM5mCihr_68ksat_dS6i5M1L7LRZAgvTeHVj0LrOnfE1hcEGQ5wME5m3diTRJbff_Q_UEhapsurTwrR4RRbaOIb6x-Oys26Ix7fdBNWucnOxmeBRUs6yTSdf1SUZHwkQxwsQrNx3_A",
  "token_type": "bearer",
  "expires_in": 86399,
  "refresh_token": "ChlyZzZndnV6eDc2ZXVlZ2ZlazdibzZ4ZWluEhljcGJyYXZieGhlbXF0dWw0bXBsZmhoZ2I1",
  "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjAxNWMzYjNmYmE4YzkwNzVmYjhlMjcxOWZkMjNjOGU1YWE3Y2Q4MTQifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjU1NTYvZGV4Iiwic3ViIjoiQ2dVME5qZzVOaElHWjJsMGFIVmkiLCJhdWQiOiJleGFtcGxlLWFwcCIsImV4cCI6MTYwNTczNDQ2MiwiaWF0IjoxNjA1NjQ4MDYyLCJhdF9oYXNoIjoidUF1aDVpWG5lVklCWV9FblNNek8xZyIsImVtYWlsIjoiZGF2aWRAYWRhbHRhcy5jb20iLCJlbWFpbF92ZXJpZmllZCI6dHJ1ZX0.JIMHsA2Mf1IPbUfjuWasxA_Xpng4tow3u_jiE2qnK7yd05_XMEOQi0KsQzTdk0Dl4M2uaQkSULU4lCx-ao4VrRsiSHBQ30NnT7AceHHp9h08DIeAuplnF0Ss8W3UPThAbwiAxl6G2PDXq5CUNGPrJ9d7mV3JE9lDv0QWjjycvsaAfAcC2ckjgYeLBl_mxI2BfZCT9dt2X7uyrPSH9HI4-r9mmeACZ3ybAAn_0TZ_1La5L94HfiS8eKzeNWgwToN0I52H1j7qrIzX44gFdK_6Xm07Ah6mg2vJJxS8ciJP4qyOjiHLqMuXz60JNtM6hZpl44jY7EaQCVsNsWFJUkVSgg"
}

The refresh token is a Nonce (No More than Once). Attempting to reuse it will result in:

{
  "error": "invalid_request",
  "error_description": "Refresh token is invalid or has already been claimed by another client."
}

You must reuse the refresh token generated by the last call. Rotating refresh token on the public client ensures both the access and refresh token are only valid for a relatively short period time, thus reducing the surface of attack in case of corruption.

Bearer authentication and user information

With the presence of a valid access token, the user is logged in and each subsequent request will include the JWT giving him access to every resource permitted with the JWT.

For every HTTP call, the request must include a “header” named “Authorization” with a value starting with “Bearer” and followed by the token:

Authorization: Bearer <token>

It is called a bearer authentication in the sense that it means “give access to the bearer of this token”.

The OpenID Connect protocol enriches the OAuth protocol in multiple ways including the addition of an endpoint to retrieve user information. The user information endpoint can be obtained from the Well-known URI Discovery endpoint and equals to http://127.0.0.1:5556/dex/userinfo in our case.

The user_info command contacts the Authorization Server and returns the user information:

axios = require 'axios'

handler = ({
  params: { access_token, userinfo_endpoint }
  stdout
  stderr
}) ->
  try
    {data} = await axios.get "#{userinfo_endpoint}",
      headers: 'Authorization': "Bearer #{access_token}"
    stdout.write JSON.stringify data, null, 2
    stdout.write '\n\n'
  catch err
    stderr.write JSON.stringify err.response.data, null, 2
    stderr.write '\n\n'

It takes the user information endpoint and the access_token to succeed:

npx openid-cli-usage user_info \
  --access_token eyJhbGciOiJSUzI1NiIsImtpZCI6IjgzYmI1ZTEyYmRlOTk3MWQ2ODgzMjU0MDA1NWI5ZjViN2NkZmIyYjYifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjU1NTYvZGV4Iiwic3ViIjoiQ2dVME5qZzVOaElHWjJsMGFIVmkiLCJhdWQiOiJ3ZWJ0ZWNoLWF1dGgiLCJleHAiOjE2MDU2ODk4NDMsImlhdCI6MTYwNTYwMzQ0MywiYXRfaGFzaCI6IkNvcG92X01aOEo2Wmk2c0NwRTlPaHciLCJlbWFpbCI6ImRhdmlkQGFkYWx0YXMuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWV9.A5Mz37rKLw8PbdB_9DJ6YGqEydvTe53a1Z8TMaNWUoaYz9tgFiQW_6gIJBX8ivmqoFVS-9ydbaTTomr64ZL6LtFtSl50jigJ5nBxpZv4_SXkCF0EphjoOmAvTX5HhCep_ig0QGwUamKGVzo5EeSqEK9jpH3nb2Hlt9AKjn4aShsWdrwiHz2FLHFdLlUfzSG113yDCvyoTP7JWONanSveLhDvEY3zlAlwY9auDVZqnnJsRatGbzWu1-gpAM9bZD6DgzMLnYyIaLH1yHtSgXOd748rTk4vOcvHRitSew_oZoVpcX17V0D2Fmk87tMKMnEgKARdcv5MKPH5YWpsZIkNbQ \
  --userinfo_endpoint http://127.0.0.1:5556/dex/userinfo

Prints on success:

{
  "iss": "http://127.0.0.1:5556/dex",
  "sub": "CgU0Njg5NhIGZ2l0aHVi",
  "aud": "example-app",
  "exp": 1605689843,
  "iat": 1605603443,
  "at_hash": "Copov_MZ8J6Zi6sCpE9Ohw",
  "email": "david@adaltas.com",
  "email_verified": true
}

Prints on error something like:

{
  "error": "invalid_request",
  "error_description": "Refresh token is invalid or has already been claimed by another client."
}

JSON Web Token (JWT) validation

We have used bearer authentication to communicate with the Authorization Server but how to use it with our API?

Like with the retrieval of user information, the access token must be sent with every authenticated request as a bearer token. The API server gets the access token and checks the identity present inside.

The access and ID tokens are serialized as JSON Web Tokens (JWT). It is pronounced “jot” in English. A JWT is composed of 3 parts: the header, the payload, and the signature. To validate the data present inside, called the payload, the API server uses the signature also present inside and the keys exposed by the Authorization Server.

Note, the resource server is the OAuth 2.0 term for your API server. The resource server handles authenticated requests after the application has obtained an access token.

The OpenID Connect server exposes multiple public keys. Keys are store in the format JSON Web KEY (JWK). The endpoint exposing those keys in Dex is located at http://127.0.0.1:5556/dex/keys and is named jwks_uri because it is a JSON Web Key Set (JWKS, RFC 7517 section 5).

The verify function of the jsonwebtoken package expects a PEM encoded public key for RSA. The conversion from JWK to the RSA Pem is complex. In the jwks-rsa which we used, it starts with the getSigningKeys in JwksClient and moves to the rsaPublicKeyToPEM function of utils.

jwt = require 'jsonwebtoken'
jwksClient = require 'jwks-rsa'

handler = ({
  params: { jwks_uri, token }
  stdout
  stderr
}) ->
  try
    # Extract the header
    header = JSON.parse Buffer.from(
      token.split('.')[0], 'base64'
    ).toString('utf-8')
      # Match the kid from JWKS and get the public key
    {publicKey, rsaPublicKey} = await jwksClient
      jwksUri: "#{jwks_uri}"
    .getSigningKeyAsync header.kid
    key = publicKey or rsaPublicKey
    # Validate the payload
    payload = jwt.verify token, key
    stdout.write JSON.stringify payload, null, 2
    stdout.write '\n\n'
  catch err
    stderr.write err.message
    stderr.write '\n\n'

The whole process of checking the user identity is very fast. One of the main strengths of JWT is to be self-supported. It only needs to fetch the OAuth public key. Certificates are not generated frequently. When the public keys are cached or provided by another means, no network connection is involved. It also works offline in case the Resource Server does not have access to the Authorization Server.

To verify the validity of a token, use the jwt_verify command:

npx openid-cli-usage jwt_verify \
  --token eyJhbGciOiJSUzI1NiIsImtpZCI6IjgzYmI1ZTEyYmRlOTk3MWQ2ODgzMjU0MDA1NWI5ZjViN2NkZmIyYjYifQ.eyJpc3MiOiJodHRwOi8vMTI3LjAuMC4xOjU1NTYvZGV4Iiwic3ViIjoiQ2dVME5qZzVOaElHWjJsMGFIVmkiLCJhdWQiOiJ3ZWJ0ZWNoLWF1dGgiLCJleHAiOjE2MDU2ODk4NDMsImlhdCI6MTYwNTYwMzQ0MywiYXRfaGFzaCI6IkNvcG92X01aOEo2Wmk2c0NwRTlPaHciLCJlbWFpbCI6ImRhdmlkQGFkYWx0YXMuY29tIiwiZW1haWxfdmVyaWZpZWQiOnRydWV9.A5Mz37rKLw8PbdB_9DJ6YGqEydvTe53a1Z8TMaNWUoaYz9tgFiQW_6gIJBX8ivmqoFVS-9ydbaTTomr64ZL6LtFtSl50jigJ5nBxpZv4_SXkCF0EphjoOmAvTX5HhCep_ig0QGwUamKGVzo5EeSqEK9jpH3nb2Hlt9AKjn4aShsWdrwiHz2FLHFdLlUfzSG113yDCvyoTP7JWONanSveLhDvEY3zlAlwY9auDVZqnnJsRatGbzWu1-gpAM9bZD6DgzMLnYyIaLH1yHtSgXOd748rTk4vOcvHRitSew_oZoVpcX17V0D2Fmk87tMKMnEgKARdcv5MKPH5YWpsZIkNbQ \
  --jwks_uri http://127.0.0.1:5556/dex/keys

It prints on success:

{
  "iss": "http://127.0.0.1:5556/dex",
  "sub": "CgU0Njg5NhIGZ2l0aHVi",
  "aud": "example-app",
  "exp": 1605689843,
  "iat": 1605603443,
  "at_hash": "Copov_MZ8J6Zi6sCpE9Ohw",
  "email": "david@adaltas.com",
  "email_verified": true
}

And it prints on error:

invalid signature

Conclusion

What an interesting journey. It might seem complicated but for those of us familiar with Kerberos and GSSAPI, it is indeed much easier to grasp. While part 1 helps you understand what OAuth, OpenID, and OIDC are all about, part 2 gave you the power to reproduce the various step involved. The usage of HTTP, JSON, and familiar web technologies eases the understanding of the underlying protocol. In the end, there is nothing that a web developer cannot do himself, embedding the OAuth flow inside his mobile or React application for example.

Canada - Morocco - France

International locations

10 rue de la Kasbah
2393 Rabbat
Canada

We are a team of Open Source enthusiasts doing consulting in Big Data, Cloud, DevOps, Data Engineering, Data Science…

We provide our customers with accurate insights on how to leverage technologies to convert their use cases to projects in production, how to reduce their costs and increase the time to market.

If you enjoy reading our publications and have an interest in what we do, contact us and we will be thrilled to cooperate with you.