I have this daemon application that connects to Project Online and performs some queries. It consists of getting a bearer token and then performing the queries, like in the two steps below:
- Request Auth Token
POST https://accounts.accesscontrol.windows.net/{tenant}/tokens/OAuth/2"
HTTP Headers:
"Content-Type": "application/x-www-form-urlencoded"
HTTP Body:
grant_type=refresh_token
&client_id={client_id}
&client_secret={client_secret}
&resource={resource}
&refresh_token={refresh_token}
&redirect_uri=https://localhost
- Execute Project OData query:
GET https://{tenant_name}.sharepoint.com/sites/{pwa_site}/_api/projectdata/Tasks?$select=TaskId,ProjectId,TaskName&inlinecount=allpages&$top=1
HTTP Headers:
"Authorization": "Bearer {token}"
This worked fine until now.
However, my company wants to change this authentication flow to a new Azure Application registration that uses a certificate to authenticate to the SharePoint site (it seems Microsoft will deprecate the current one on April 2nd, 2026, or so I've been told). Therefore, I've requested this new app registration using a certificate. This app has delegated permissions to the Project. Read, Project. Write, and ProjectWebApp.FullControl, under SharePoint -> Delegated Permissions.
After this new app was registered, I could successfully generate a JWT token and use it against graph.microsoft.com to retrieve a Bearer token, as follows:
- Generate a JWT token that has "https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token" as its audience through the following script:
#!/bin/bash
# Check for the correct number of arguments
if [ "$#" -ne 5 ]; then
echo "Incorrect number of arguments. Need to pass Tenant ID, Passphrase, Client_ID, Private_Key, and certificate for generating the JWT token."
exit 1
fi
TENANT_ID=$1
PASSPHRASE=$2
CLIENT_ID=$3
PRIVATE_KEY=$4
CERT_PEM=$5
AUD="https://login.microsoftonline.com/$TENANT_ID/oauth2/v2.0/token"
JTI=$(uuidgen)
# EXP=$(($(date +%s) + 600)) # 600 veut dire 10 min => donc Expiration dans 10 minutes
EXP=$(($(date +%s) + 3599)) # 3599 veut dire 1 hr => donc Expiration dans 1 hr
b64url() {
openssl base64 -e -A | tr '+/' '-_' | tr -d '='
}
# get thumbprint du certificat
THUMBPRINT=$(openssl x509 -in "$CERT_PEM" -noout -fingerprint -sha1 | cut -d'=' -f2 | tr -d ':' | xxd -r -p | openssl base64 -e -A | tr '+/' '-_' | tr -d '=')
# create header JSON
HEADER=$(jq -n --arg x5t "$THUMBPRINT" '{"alg":"RS256","x5t":$x5t}')
# create payload JSON
PAYLOAD=$(jq -n \
--arg aud "$AUD" \
--arg iss "$CLIENT_ID" \
--arg sub "$CLIENT_ID" \
--arg jti "$JTI" \
--argjson exp "$EXP" \
'{
"aud": $aud,
"iss": $iss,
"sub": $sub,
"jti": $jti,
"exp": $exp
}')
# Encoder le header et le payload en base64url
HEADER_B64=$(echo -n "$HEADER" | b64url)
PAYLOAD_B64=$(echo -n "$PAYLOAD" | b64url)
# create signature
SIGNING_INPUT="$HEADER_B64.$PAYLOAD_B64"
SIGNATURE=$(echo -n "$SIGNING_INPUT" | openssl dgst -sha256 -sign "$PRIVATE_KEY" -passin pass:"$PASSPHRASE" | b64url)
# Assembler le JWT
JWT="$SIGNING_INPUT.$SIGNATURE"
echo "$JWT"
- Use this JWT token to request a Bearer token:
POST https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token
HTTP Headers:
Content-Type: application/x-www-form-urlencoded
HTTP Body:
client_id: {client_id}
client_assertion: {JWT_token_from_previous_step}
client_assertion_type: urn:ietf:params:oauth:client-assertion-type:jwt-bearer
grant_type: client_credentials
scope: https://{tenant_name}.sharepoint.com/.default
This seems to work fine and returns a valid response with a Bearer token like below:
{
"token_type": "Bearer",
"expires_in": 3599,
"ext_expires_in": 3599,
"access_token": "eyJ0eXAiOiJKV1QiLCJub25jZSI6"...
}
All good until now, but the issue starts in the next step, on which I need to use this token on Project Online:
- Execute Project OData query (please note this is the same query as in the current approach, since graph api seems not to support querying Project Online:
GET https://{tenant_name}.sharepoint.com/sites/{pwa_site}/_api/projectdata/Tasks?$select=TaskId,ProjectId,TaskName&inlinecount=allpages&$top=1
HTTP Headers:
"Authorization": "Bearer {token}"
This will either return HTTP 401 with payload {"error_description": "ID3035: The request was not valid or is malformed."} or HTTP 401 with payload {"error_description": "Exception of type 'Microsoft.IdentityModel.Tokens.AudienceUriValidationFailedException' was thrown."} if the scope set on step 2 is https://graph.microsoft.com/.default.
Does anyone have any idea how to solve this and successfully retrieve data from Project Online using a Token retrieved from graph.microsoft.com?