Script for Executing the OAuth2 Authorization Code Flow with PKCE in ForgeRock Access Management (AM)
Introduction
I often meet customers who want to quickly understand how the OAuth2 Authorization Code grant type works, how Proof Key for Code Exchange (PKCE) works and how they can execute the flows programatically to understand how it all hangs together.
This blog provides a sample script to execute the OAuth2 Authorization Code grant flow along with support for PKCE using cURL.
What is the OAuth2 Authorization Code Grant Flow
The Authorization Code grant is a two-step interactive process used when the client, for example, a Java application running on a server, requires access to protected resources. See the following RFC for more.
The following diagram demonstrates the Authorization Code grant flow:
To keep this blog concise the exact steps are documented here.
What is the OAuth2 Authorization Code Grant Flow with PKCE
The flow is similar to the regular Authorization Code grant type, but the client must generate a code that will be part of the communication between the client and the authorization server (see the following RFC for more). This code mitigates against interception attacks performed by malicious users on the authorization code itself. It should be used by mobile or a JavaScript applications requiring access to protected resources.
The PKCE flow adds three parameters on top of those used for the Authorization code grant:
- code_verifier (form parameter). Contains a random string that correlates the authorization request to the token request.
- code_challenge (query parameter). Contains a string derived from the code verifier that is sent in the authorization request and that needs to be verified later with the code verifier.
- code_challenge_method (query parameter). Contains the method used to derive the code challenge.
The following diagram demonstrates the Authorization Code grant with PKCE flow:
To keep this blog concise the exact steps are documented here.
Environment Setup
In order to execute the script the following pre-requisites need to be met:
- A user must exist in the Identity repository — for this example I am using the out of the box demo user
- An OAuth2 Provider Service in the target realm needs to be setup. From the AM Admin UI goto > Realm > Services > Hit Add A Service > Select OAuth2 Provider from the drop down > add
openid
andprofile
as Supported Scopes and hit Create. It should look something like this:
- An OAuth2 client need to be configured. From the AM Admin UI goto > Realm >Applications > Add Client > Set the Client ID to client, Client Secret to SecureP455word!, Redirection URIs to https://httpbin.org/anything, Scope(s) to
openid
andprofile
. Example below:
- Within the OAuth2 client’s configuration click the Advanced tab and add Refresh Token to the Grant Types parameter. By default refresh tokens are not generated unless this step is executed.
- Note - If a public client is used no Client Secret is required to acquire tokens, however calls to introspect endpoint will fail as a require a client secret. To simulate this a second confidential client needs to be configured with the
client_credentials
grant type defined, a scope ofam-introspect-all-tokens
set and Token Endpoint Authentication Method set toclient_secret_post
, to mimic an application introspecting access tokens generated from Public clients.
OAuth2 Script Execution Steps
The script is located here, full extract at the bottom of this blog. Essentially it works like this:
There are 11 functions:
jqCheck — The script relies on the jq JSON processor. This function checks for its presence.
authN — Function to authenticate a user and extract their SSO tokenId.
gen_PKCEMaterial — If the PKCE flow is invoked, this function generates the Challenge and Verifier.
getAuthCode — Generates the authorization code. If the PKCE flow is invoked, the code_challenge and challenge_method (SHA256) parameters are added to the POST body. NOTE: this example does not require user consent, for customer facing environment, the system should seek consent and other challenge methods are supported.
getTokens — Function to generate the access, refresh and OIDC tokens from the authorization code
hitTokenInfo — Function to call the ../tokeninfo endpoint with the access token
hitIntrospectAccessToken Access — Function to call the ../introspect endpoint with the access token
hitIntrospectAccessToken Refresh — Function to call the ../introspect endpoint with the refresh token
hitUserInfo — Function to call the ../userinfo endpoint with the access token
validateSession — Validate SSO Session
endSession — Terminate SSO session
To execute run:
./oauth2_test.sh with either the non-pkce or pkce flag. For example: ./oauth2_test.sh non-pkce
To force the client to support PKCE set the Code Verifier Parameter Required parameter in the OAuth2 Provider > Advanced tab to All requests. Note various different modes of operation are supported for PKCE (not just All Requests), see here for more.
Sample output:
*********************
Authenticating demo user to generate SSO token
SSO Token: RHu9Rst-JvBNI2ueKffoYhQ2US0.*AAJTSQACMDIAAlNLABx1U2o2VnhjMEpOSVNad25Pc1VLdGhjSzdzNzQ9AAR0eXBlAANDVFMAAlMxAAIwMw..**********************
Validating SSO token: zrdG3VhHmsXuvMfvn.....{
"valid": true,
"sessionUid": "927e79ed-dcdb-4cb5-9814-d2e6b1496563-593936",
"uid": "demo",
"realm": "/alpha"
}
*********************
Generating PKCE Verifier
Verifier is: 9flDxvKL2RyjzY5dOMngEwgv8ks9lcuKsbPCaqDBm3mFVCuhuN
Challenge is: yzk8WwovZqXsvePxQ9rh7tUnVn86hb7l90_ivdIRzs8*********************
Getting auth code
Auth code is: BZq6cwIEa8X2RbViTUzQUIDiH68*********************
Getting access and refresh tokens
using auth code BZq6cwIEa8X2RbViTUzQUIDiH68
{
“access_token”: “WiG0SvVOakf5uVPt5scjU_9vuvc”,
“refresh_token”: “_L6WwjMn-YrnC10Ruo7zTNd2JiU”,
“scope”: “openid profile”,
“id_token”: “eyJ0eXAiOiJKV1QiLCJraWQiOiJ3VTNpZklJYUxPVUFSZVJCL0ZHNmVNMVAxUU09IiwiYWxnIjoiUlMyNTYifQ.eyJhdF9oYXNoIjoieVhkRkNMeTUtaVZGSHJuRHBTcndDQSIsInN1YiI6ImRlbW8iLCJhdWRpdFRyYWNraW5nSWQiOiI0ZDdmODRhNy05ZGU4LTQwMTUtODdkMi1hY2U2YWVlNjQyNTktMTgxMyIsImlzcyI6Imh0dHA6Ly9vcGVuYW0udGVzdC5jb206OTQ5Ni9vcGVuYW0vb2F1dGgyIiwidG9rZW5OYW1lIjoiaWRfdG9rZW4iLCJhdWQiOiJjbGllbnQiLCJjX2hhc2giOiIwZmxtVVl6bGxlQnczeURpX0xaQW1nIiwiYWNyIjoiMCIsIm9yZy5mb3JnZXJvY2sub3BlbmlkY29ubmVjdC5vcHMiOiIxTmdQYk5LOURkWU9HVmhDLXlvMG8xaVdneE0iLCJhenAiOiJjbGllbnQiLCJhdXRoX3RpbWUiOjE1Njg4OTkwNjksInJlYWxtIjoiLyIsImV4cCI6MTU2ODkwMjY2OSwidG9rZW5UeXBlIjoiSldUVG9rZW4iLCJpYXQiOjE1Njg4OTkwNjl9.ihw9ATB_l3cJxVz_nlhS8tFbWwU-cn72EpxcxcHD_1V8_BPw0J0W37alOZAryGjwqYSIStkCjuujkjkOJC-x8mCudsFzJj959XAI2wBfvQy2WQRb-MctxJGeUJUX9lcjT9UhotDaei89tZI3y-KY1RSfkw46xvx19twAJ2OrzpOA3Z97mWppU4yAy3eQu_63R4 — AqgSBdtkO7etmTnkJeC-H2KmEiu9JNL2y8qz5Eh6jfLEeQTXkbf5rwfQBH1n9_V6lR2dUND-99SMcqp0hqBPV_zzHRIgEglUiWRW4ry9P-ZApU9_LX5B8TOI49-alaAeR7B8id4FVSWcYvZl_w”,
“token_type”: “Bearer”,
“expires_in”: 3599
}*********************
Hitting tokeninfo endpoint
{
“realm”: “/”,
“profile”: “”,
“scope”: [
“openid”,
“profile”
],
“client_id”: “client”,
“expires_in”: 3599,
“token_type”: “Bearer”,
“access_token”: “WiG0SvVOakf5uVPt5scjU_9vuvc”,
“grant_type”: “authorization_code”,
“auth_level”: 0,
“auditTrackingId”: “4d7f84a7–9de8–4015–87d2-ace6aee64259–1811”,
“openid”: “”
}*********************
Hitting introspect endpoint for Access token
{
“iss”: “http://openam.test.com:9496/openam/oauth2",
“sub”: “demo”,
“auditTrackingId”: “4d7f84a7–9de8–4015–87d2-ace6aee64259–1811”,
“auth_level”: 0,
“active”: true,
“scope”: “openid profile”,
“client_id”: “client”,
“user_id”: “demo”,
“token_type”: “Bearer”,
“exp”: 1568902669
}*********************
Hitting introspect endpoint for Refresh token
{
“iss”: “http://openam.test.com:9496/openam/oauth2",
“sub”: “demo”,
“auditTrackingId”: “4d7f84a7–9de8–4015–87d2-ace6aee64259–1810”,
“auth_level”: 0,
“active”: true,
“scope”: “openid profile”,
“client_id”: “client”,
“user_id”: “demo”,
“token_type”: “refresh_token”,
“exp”: 1569503869
}*********************
Hitting userinfo endpoint
{
“family_name”: “demo”,
“name”: “demo”,
“sub”: “demo”
}*********************
Hitting endSession
*********************
Validating SSO token: zrdG3VhH...{
"valid": false
}
*********************
Conclusion
There you have it a programatic way to understand the OAuth2 Auth Code Grant type, along with PKCE all through cURL. Enjoy!
Script Extract
Note this script is provided for sample and demonstration purposes only and is not supported by ForgeRock in any way nor is it suitable for Production use. Stash location is here.
#!/bin/bash
# Written by Darinder S Shokar - ForgeRock Customer Success
# Script requires the "jq" tool be already installed to function
# Article: https://developer.forgerock.com/docs/platform/how-tos/script-executing-oauth2-authorization-code-flow-pkce-am
# Script Location: https://stash.forgerock.org/users/shokard/repos/oauth2/browse/oauth2_test.sh
# Parameters. Modify as appropriate:
REALM=alpha
AM_HOST=https://my-am-host.com/am
#Ensure Token Endpoint Authentication Method is set to client_secret_post and NOT client_secret_basic
CLIENT_ID=XXXXXX
# CLIENT_SECRET is not required for a public client but is required for token introspection
CLIENT_SECRET=XXXXXX
#Comment out CLIENT_TYPE as required
CLIENT_TYPE=confidential
#CLIENT_TYPE=public
SCOPES=openid%20profile
REDIRECT_URL=https://httpbin.org/anything
POST_LOGOUT_REDIRECT_URI=https://httpbin.org/anything
USERNAME=demo
PASSWORD=Ch4ng31t
AM_TREE=Login
#Public clients can no longer introspect tokens as a client secret is required.
#To simulate a backend application which needs to introspect the public client access token a new introspect confidential OAuth2 client is created.
#This client is configured to only allow the client_credentials grant_type and has a single scope defined of am-introspect-all-tokens.
#This introspect client is then used to introspect the original public client access token.
INTROSPECT_CLIENT_ID=introspect
INTROSPECT_CLIENT_SECRET=XXXXXX
AM_AUTHENTICATE="$AM_HOST/json/realms/$REALM/authenticate?authIndexType=service&authIndexValue=$AM_TREE"
AM_VALIDATE="$AM_HOST/json/realms/root/realms/$REALM/sessions?_prettyPrint=true&_action=validate"
AM_AUTHORIZE=$AM_HOST/oauth2/realms/$REALM/authorize
AM_ACCESS_TOKEN=$AM_HOST/oauth2/realms/$REALM/access_token
AM_TOKENINFO=$AM_HOST/oauth2/realms/$REALM/tokeninfo
AM_INTROSPECT=$AM_HOST/oauth2/realms/$REALM/introspect
AM_USERINFO=$AM_HOST/oauth2/realms/$REALM/userinfo
AM_ENDSESSION=$AM_HOST/oauth2/realms/root/realms/$REALM/connect/endSession
RESPONSE_TYPE=code
VERSION_HEADER='resource=2.0, protocol=1.0'
CONTENT_TYPE='application/json'
#On latent network connections there may be a need to retry, hence the following curl is used
CURL='curl --connect-timeout 1 --max-time 5 --retry 2'
MODE=$1
if [ -z "$1" ]; then
echo "Execute using ./oauth2_test.sh non-pkce|pkce. For example ./oauth2_test.sh pkce"
exit 1
fi
jqCheck(){
hash jq &> /dev/null
if [ $? -eq 1 ]; then
echo >&2 "The jq Command-line JSON processor is not installed on the system. Please install and re-run."
exit 1
fi
}
getCookieName() {
echo "Getting cookie name"
AM_COOKIENAME=`$CURL -k "$AM_HOST"/json/serverinfo/\* -s | jq -r .cookieName`
echo "CookieName is: $AM_COOKIENAME"
}
authN(){
echo "*********************"
echo "Authenticating $USERNAME user to generate SSO token"
SSO_TOKEN=`$CURL -k -s --request POST --header "Content-Type: $CONTENT_TYPE" --header "Accept-API-Version: $VERSION_HEADER" --header "X-OpenAM-Username: $USERNAME" --header "X-OpenAM-Password: $PASSWORD" -d '' "$AM_AUTHENTICATE" | jq -r .tokenId`
echo "SSO Token: $SSO_TOKEN"
echo ""
echo "*********************"
}
validateSession() {
echo "Validating SSO token: $SSO_TOKEN"
echo ""
$CURL -k -s --request POST --header "Content-Type: $CONTENT_TYPE" --header "Accept-API-Version: $VERSION_HEADER" --Cookie "$AM_COOKIENAME=$SSO_TOKEN" $AM_VALIDATE | jq .
echo "*********************"
}
gen_PKCEMaterial() {
if [ $MODE == "pkce" ]; then
echo "Generating PKCE Verifier"
VERIFIER=`LC_CTYPE=C && LANG=C && cat /dev/urandom | tr -dc 'a-zA-Z0-9' | fold -w 50 | head -n 1`
echo "Verifier is: $VERIFIER"
#Generate PKCE Challenge from Verifier and convert / + = characters"
CHALLENGE=`/bin/echo -n $VERIFIER | shasum -a 256 | cut -d " " -f 1 | xxd -r -p | base64 | tr / _ | tr + - | tr -d =`
echo "Challenge is: $CHALLENGE"
echo ""
echo "*********************"
fi
}
getAuthCode() {
echo "Getting auth code"
if [ $MODE == "pkce" ]; then
AUTH_CODE=`$CURL -k --request POST --header "Content-Type: application/x-www-form-urlencoded" --Cookie "$AM_COOKIENAME=$SSO_TOKEN" --data "redirect_uri=$REDIRECT_URL&scope=$SCOPES&response_type=$RESPONSE_TYPE&client_id=$CLIENT_ID&csrf=$SSO_TOKEN&decision=allow&code_challenge=$CHALLENGE&code_challenge_method=S256" "$AM_AUTHORIZE" -v --stderr - | grep "code=" | cut -d '=' -f2 | cut -d '&' -f1`
else
AUTH_CODE=`$CURL -k --request POST --header "Content-Type: application/x-www-form-urlencoded" --Cookie "$AM_COOKIENAME=$SSO_TOKEN" --data "redirect_uri=$REDIRECT_URL&scope=$SCOPES&response_type=$RESPONSE_TYPE&client_id=$CLIENT_ID&csrf=$SSO_TOKEN&decision=allow" "$AM_AUTHORIZE" -v --stderr - | grep "code=" | cut -d '=' -f2 | cut -d '&' -f1`
fi
echo "Auth code is: $AUTH_CODE"
echo ""
echo "*********************"
}
getTokens() {
echo "Getting access and refresh tokens"
echo "using auth code $AUTH_CODE"
if [ $MODE == "pkce" ] && [ $CLIENT_TYPE == "confidential" ]; then
#code_verifier parameter added
TOKENS=`$CURL -s --request POST --header "Cache-Control: no-cache" --data "grant_type=authorization_code&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&redirect_uri=$REDIRECT_URL&code=$AUTH_CODE&code_verifier=$VERIFIER" -k "$AM_ACCESS_TOKEN" | jq .`
elif [ $MODE == "pkce" ] && [ $CLIENT_TYPE == "public" ]; then
#client_secret parameter removed
TOKENS=`$CURL -s --request POST --header "Cache-Control: no-cache" --data "grant_type=authorization_code&client_id=$CLIENT_ID&redirect_uri=$REDIRECT_URL&code=$AUTH_CODE&code_verifier=$VERIFIER" -k "$AM_ACCESS_TOKEN" | jq .`
else
TOKENS=`$CURL -s --request POST --header "Cache-Control: no-cache" --data "grant_type=authorization_code&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&redirect_uri=$REDIRECT_URL&code=$AUTH_CODE" -k "$AM_ACCESS_TOKEN" | jq .`
fi
echo $TOKENS | jq .
ACCESS_TOKEN=`echo $TOKENS | jq -r .access_token`
REFRESH_TOKEN=`echo $TOKENS | jq -r .refresh_token`
ID_TOKEN=`echo $TOKENS | jq -r .id_token`
echo ""
echo "*********************"
}
hitTokenInfo() {
echo "Hitting tokeninfo endpoint"
TOKENINFO=`$CURL -k -s "$AM_TOKENINFO?access_token=$ACCESS_TOKEN" | jq .`
echo $TOKENINFO | jq .
echo ""
echo "*********************"
}
decodeJWT() {
echo "Decoding id_token: $ID_TOKEN"
jq -R 'split(".") | .[1] | @base64d | fromjson' <<< "$ID_TOKEN"
echo ""
echo "*********************"
}
hitIntrospectAccessToken() {
echo "Hitting introspect endpoint for ${1} token"
if [ $MODE == "pkce" ] && [ $CLIENT_TYPE == "public" ]; then
echo ""
echo "Using the INTROSPECT CLIENT_ID and CLIENT_SECRET for client: $INTROSPECT_CLIENT_ID "
INTROSPECT=`$CURL -k -s --request POST --user "$INTROSPECT_CLIENT_ID:$INTROSPECT_CLIENT_SECRET" --data "token=${2}" "$AM_INTROSPECT" | jq .`
echo ""
echo $INTROSPECT | jq .
else
echo ""
INTROSPECT=`$CURL -k -s --request POST --user "$CLIENT_ID:$CLIENT_SECRET" --data "token=${2}" "$AM_INTROSPECT" | jq .`
fi
echo $INTROSPECT | jq .
echo ""
echo "*********************"
}
hitUserInfo() {
echo "Hitting userinfo endpoint"
USERINFO=`$CURL -k -s --request POST --Cookie "$AM_COOKIENAME=$SSO_TOKEN" --header "Authorization: Bearer $ACCESS_TOKEN" -d '' "$AM_USERINFO" | jq .`
echo $USERINFO | jq .
echo ""
echo "*********************"
}
refreshToken() {
echo "Using the following refresh tokento generate new access token:"
echo "$REFRESH_TOKEN"
echo
echo "Current access token is: $ACCESS_TOKEN"
TOKENS=`$CURL -s --request POST --data "grant_type=refresh_token&refresh_token=$REFRESH_TOKEN&client_id=$CLIENT_ID&client_secret=$CLIENT_SECRET&scope=$SCOPES" "$AM_ACCESS_TOKEN" | jq .`
ACCESS_TOKEN=`echo $TOKENS | jq -r .access_token`
REFRESH_TOKEN=`echo $TOKENS | jq -r .refresh_token`
ID_TOKEN=`echo $TOKENS | jq -r .id_token`
echo
echo "New access token is $ACCESS_TOKEN"
echo "*********************"
echo "New id_token is: $ID_TOKEN"
decodeJWT
}
endSession() {
echo "Hitting endSession"
$CURL -k -s --request GET --header "Authorization: Bearer $ACCESS_TOKEN" "$AM_ENDSESSION?id_token_hint=$ID_TOKEN&post_logout_redirect_uri=$POST_LOGOUT_REDIRECT_URI&client_id=$CLIENT_ID" | jq .
echo "*********************"
}
#Functions
jqCheck
clear
getCookieName
authN
validateSession
gen_PKCEMaterial
getAuthCode
getTokens
decodeJWT
hitTokenInfo
hitIntrospectAccessToken Access ${ACCESS_TOKEN}
hitIntrospectAccessToken Refresh ${REFRESH_TOKEN}
hitUserInfo ${ACCESS_TOKEN}
refreshToken
hitIntrospectAccessToken Access ${ACCESS_TOKEN}
endSession
validateSession