"Token signer mismatch" when requesting auth token using JWKS

Situation

I’m trying to create an app that can add new patients and update their records through the OpenEMR Standard API. I’ve spun up a test instance of OpenEMR through the official docker image, configured it for API access, registered an app and enabled it. When I try to request an authorization token, I get an error (see “Logs” below for error, and “Appendix” below for all the code involved).

OpenEMR Version
7.0.2

Browser:
n/a

Operating System
OpenEMR is running in the official Docker image, the host is running MacOS 14.5. Also reproduced on an Ubuntu 24.04.1 host.

Search
Did you search the forum for similar questions? Yes

Logs

The error message:

{"error":"invalid_client","error_description":"Client authentication failed","message":"Client authentication failed"}

When I look inside the container in /var/log/apache2/error.log, I see:

[Thu Sep 05 02:40:41.070239 2024] [php:notice] [pid 198] [client 10.107.99.26:53636] [2024-09-05T02:40:41.066100+00:00] OpenEMR.ERROR: CustomClientCredentialsGrant->validateClient() jwt failed required constraints {"client":"19w7eEMUVA1wFfgvMgXel6r1SzbObXAdXqpSHmVV9fI","exceptionMessage":"The token violates some mandatory constraints, details:\\n- Token signer mismatch","claims":{"iss":"19w7eEMUVA1wFfgvMgXel6r1SzbObXAdXqpSHmVV9fI","sub":"19w7eEMUVA1wFfgvMgXel6r1SzbObXAdXqpSHmVV9fI","aud":["http://localhost/oauth2/default/token"],"jti":"deadbeef"},"expectedAudience":"http://localhost/oauth2/default/token"} []

Apendix

This code is in Python, with dependencies requests, jwt, and jwcrypto.

The Python code I use to generate the JWKS:

from pathlib import Path
from jwcrypto import jwk

key_type = "RSA"
alg = "RS256"
size = 2048
use = "sig"
key_name = "test"

key = jwk.JWK.generate(kty=key_type, size=size, kid=key_name, use=use, alg=alg)

keys_path = Path("keys")
keys_path.mkdir()
private_path = keys_path / f"{key_name}_private.json"
public_path = keys_path / f"{key_name}_public.json"
pem_path = keys_path / f"{key_name}.pem"

private_path.write_text(key.export_private())
public_path.write_text(key.export_public())
pem_path.write_text(key.export_to_pem("private_key", password=None).decode("utf-8"))

The code I use to register the API client:

import sys
from pathlib import Path
import json
import requests

REG_URL = "http://localhost/oauth2/default/registration"

app_name = "test"
jwks_path = Path("keys/test_public.json")
jwks_json = {"keys": [json.loads(jwks_path.read_text())]}

payload = {
    "client_name": app_name,
    "contacts": [""],
    "application_type":"private",

    "redirect_uris": [""],
    "initiate_login_uri": "",
    "post_logout_redirect_uris": [""],

    "token_endpoint_auth_method": "client_secret_post",

    "scope": "openid fhirUser online_access offline_access launch launch/patient api:oemr api:fhir api:port system/Patient.$export system/Group.$export system/*.$bulkdata-status system/*.$export profile name address given_name family_name nickname phone phone_verified email email_verified site:default system/AllergyIntolerance.read system/Appointment.read system/Binary.read system/CarePlan.read system/CareTeam.read system/Condition.read system/Coverage.read system/Device.read system/DiagnosticReport.read system/DocumentReference.$docref system/DocumentReference.read system/Encounter.read system/Goal.read system/Group.read system/Immunization.read system/Location.read system/Medication.read system/MedicationRequest.read system/Observation.read system/Organization.read system/Patient.read system/Person.read system/Practitioner.read system/PractitionerRole.read system/Procedure.read system/Provenance.read system/ValueSet.read",

    "jwks_uri": "",
    "jwks": jwks_json,
}

r = requests.post(REG_URL, json=payload)
response_json = json.loads(r.text)

out_path = Path("registration.json")
out_path.write_text(json.dumps(response_json, indent=2))

Finally, the code to make the request:

import json
import os
from pathlib import Path

import requests
from jwt import JWT, jwk_from_dict
import urllib3

key_path = Path("keys")

public_key_file = key_path / "test_public.json"
public_key_dict = json.loads(public_key_file.read_text())

private_key_file = key_path / "test_private.json"
private_key_dict = json.loads(private_key_file.read_text())

api_file = Path("registration.json")
api_dict = json.loads(api_file.read_text())

TOKEN_URL = "http://localhost/oauth2/default/token"

header = {
    "alg": public_key_dict["alg"],
    "typ": "JWT",
    "kid": public_key_dict["kid"],
}
print(header)

payload = {
    "iss": api_dict["client_id"],
    "sub": api_dict["client_id"],
    "aud": TOKEN_URL,
    "jti": "deadbeef",
}
print(payload)

signing_key = jwk_from_dict(private_key_dict)

instance = JWT()

compact_jws = instance.encode(payload, signing_key, alg=public_key_dict["alg"])
print(compact_jws)

token_request = {
    "scope": api_dict["scope"],
    "grant_type": "client_credentials",
    "client_assertion_type": "urn:ietf:params:oauth:client-assertion-type:jwt-bearer",
    "client_assertion": compact_jws,
}

r = requests.post(TOKEN_URL, data=token_request, verify=False)
print(r.text)

Any insights are much appreciated. Thanks!

Change your algorithm to RS384 and it should be better. Looking at everything else I think it looks fine althrough I’m not a python expert, I can’t see anything else that jumps out at me.

Looks like HL7 changed their links so OpenEMR client credentials documentation doesn’t go to the right page anymore. Here is the client credentials grant documentation we built on if you need another resource. SMART Backend Services: Authorization Guide : /authorization/

1 Like

Switching to RS384 got me one step further! Now I’m getting 401 Unauthorized when I make a request for all patients with the following code:

import json
from pathlib import Path
import requests

access_file = Path("access.json")
access_json = json.loads(access_file.read_text())
header = {"Authorization": f"Bearer {access_json['access_token']}"}

API_URL = "http://localhost/apis/default/api/patient"
r = requests.get(API_URL, headers=header, verify=False)
print(r)

I’m wondering if maybe I’m authorizing the wrong scopes for this request. I got my scopes by using the app registration page on the web interface, setting it to “system client application”, checking everything, and then looking at the request made in the browser console. But this github issue suggests that that may only cover FHIR scopes, whereas I’m looking for api:oemr scopes? I tried again with the following trimmed down list of scopes:

        "scope": "openid offline_access api:oemr user/patient.read user/patient.write
                  user/vital.read user/vital.write user/medication.read user/medication.write
                  user/medical_problem.read user/medical_problem.write
                  user/encounter.read user/encounter.write",

and see in the error.log:

OpenEMR.ERROR: OpenEMR Error: api failed because user role does not have access to the resource {"resource":"/api/patient","userRole":"system"}

If I want to be able to read and create the things in the listed scopes across the system, what scopes should I be requesting?

Thanks!

Figured it out by searching and finding this thread: Api failed because user role does not have access to the resource - #5 by adunsulag

Switched to password grant, and I’m in business.

Taking the queue from @TimM and trying to fix client_credentials grant_type, I was able to get client_credentials to work. The keys were

  1. Tim’s RS384 tip
  2. Updating scopes directly in the database

Make sure to reference JWKS info found at Use FHIR in open EMR V7 - #2 by Mandrake

Update scopes with direct db access using

update openemr.oauth_clients oc 
set scope='<all the scopes you need separated by space>'
where client_name = '<your client name>';

And test with the following python script:

import jwt
import time
import uuid
import requests
import json
import sys
# Your client details
CLIENT_ID = "<your client_id>"
CLIENT_SECRET = "<your client_secret>"

API_URL = "<your api_url>"
TOKEN_URL = f"{API_URL}/oauth2/default/token"
# The audience should match what the server expects
# Load your private key
with open('private_key.pem', 'rb') as f:
    private_key = f.read()
KEY_ID = json.load(open("jwks.json"))['keys'][0]['kid']

# Create JWT payload with longer expiration
now = int(time.time())
payload = {
    "iss": CLIENT_ID,
    "sub": CLIENT_ID,
    "aud": TOKEN_URL,  # yes this is token_url and not AUD url provided when creating a client
    "jti": str(uuid.uuid4()),
    "exp": int(now + 3600),  # 1 hour from now
    "iat": int(now)
}

# Print the payload for debugging
print(f"JWT Payload: {json.dumps(payload, indent=2)}")

# Sign the JWT
assertion = jwt.encode(
    payload,
    private_key,
    algorithm="RS384",
    headers={"kid": KEY_ID, "typ": "JWT"}
)

# Print part of the assertion for debugging
print(f"JWT (first 30 chars): {assertion[:30]}...")

print("Decoded jwt:")
print(jwt.decode(assertion, open('public_key.pem', 'rb').read(), algorithms=["RS384"], audience=TOKEN_URL))

# add other scopes as necessary - they are case sensitive :) 
scope = " ".join([
    "openid",
    "offline_access",
    "api:oemr",
    "user/patient.read",
    "user/patient.write",
    "user/vital.read",
    "user/vital.write",
    "user/medication.read",
    "user/medication.write",
    "user/medical_problem.read",
    "user/medical_problem.write",
    "user/encounter.read",
    "user/encounter.write",
    "fhirUser",
    "online_access",
    "user/Patient.read",
    "system/Patient.read"
])

# Prepare the request
data = {
    'grant_type': 'client_credentials',
    'client_id': CLIENT_ID,
    'client_assertion_type': 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer',
    'client_assertion': assertion,
    'scope': scope,
}

# Print request data for debugging
print(f"Request data: {json.dumps({k: v[:30]+'...' if k == 'client_assertion' else v for k, v in data.items()}, indent=2)}")

# Make the request with verbose error handling
try:
    response = requests.post(TOKEN_URL, data=data)
    print(f"Status Code: {response.status_code}")
    print(f"Response: {response.text}")
    token_data = response.json()
except Exception as e:
    print(f"Request failed: {str(e)}")
    sys.exit(1)

# get the list of patients
headers = {"Authorization": f"Bearer {token_data['access_token']}"}
url = f"{API_URL}/apis/default/fhir/Patient"
r = requests.get(url, headers=headers)
print(f"Status Code: {r.status_code}")
print(f"Response: {r.text}")

I created a written article for the fix at OpenEMR API client_credentials grant_type to hopefully make it easier for future me to stumble upon.