OAuth2 and OpenID Connect for microservices and public applications (Part 2)
By David WORMS
Nov 20, 2020
Never miss our publications about Open Source, big data and distributed systems, low frequency of one email every two months.
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 and NPM. 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
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 generationnpx openid-cli-usage code_grant
: step 2 - authorization code grantnpx openid-cli-usage refresh_token
: step 3 - refresh token grantnpx openid-cli-usage user_info
: step 4 - user infonpx 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 shell
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.