Issues with OAuth Bearer Token Validation When Using Keycloak for API Access

Hi everyone,

I have successfully set up Keycloak as a social login provider and I am able to log in without any issues. Additionally, I can obtain a token using Postman, and it parses correctly when tested on jwt.io.

However, I’m encountering an error when trying to access the /api/resource/job card endpoint with the Bearer token. The error message I receive is as follows:

{
    "exception": "frappe.exceptions.AuthenticationError",
    "exc_type": "AuthenticationError",
    "exc": "[\"Traceback (most recent call last):\\n  File \\\"apps/frappe/frappe/app.py\\\", line 101, in application\\n    validate_auth()\\n  File \\\"apps/frappe/frappe/auth.py\\\", line 607, in validate_auth\\n    raise frappe.AuthenticationError\\nfrappe.exceptions.AuthenticationError\\n\"]"
}

After tracing the code, I found that in auth.py’s validate_oauth method, frappe.db.get_value("OAuth Bearer Token", token, "scopes") returns null, which causes the code to skip over the validation of the token’s scopes and the set_user method.

	try:
		required_scopes = frappe.db.get_value("OAuth Bearer Token", token, "scopes").split(
			get_url_delimiter()
		)
		valid, oauthlib_request = get_oauth_server().verify_request(
			uri, http_method, body, headers, required_scopes
		)
		if valid:
			frappe.set_user(frappe.db.get_value("OAuth Bearer Token", token, "user"))
			frappe.local.form_dict = form_dict
	except AttributeError:
		pass

As a result, in the validate_auth method, frappe.session.user is Guest, leading to the authentication error being raised.

	if len(authorization_header) == 2 and frappe.session.user in ("", "Guest"):
		raise frappe.AuthenticationError

Here are my questions:

  • a. At what point and where is the “OAuth Bearer Token” stored when making API calls?
  • b. If I log in directly through Keycloak into ERPNext, frappe.session.user is set to the user’s email, and this works fine. Why does direct login via Keycloak work while using the Bearer token for API calls does not?
  • c. Am I missing some configuration or setup? What do I need to do to resolve this issue?

Thank you in advance for your help!

Best regards,

Thank you very much for your reply!

The statement in the Frappe documentation, “If the OAuth 2 Access Token is used to authenticate request, the token is an opaque access_token string provided by the Frappe Server after setting up OAuth 2 and generating the token,” indeed confirms the meaning of points 1-3.

This is the key information I was missing.

In our project, we use keycloak for SSO solution. We plan to use keycloak javascript adapter to login in frontend. I will try using the Castlecraft extension, which seems to be a very promising solution.

Thanks again!

@revant_one Thanks a lot. After install Castlecraft, the token was validated and I can access the api successfully

1 Like

@revant_one
What considerations were made to not save the username as the token’s username, such as preferred_username defined in the social login provider User ID Property field, and instead have Frappe ultimately save it as first_name

If you could clarify this, I would be very grateful.

def create_and_save_user(body, idp):
    """
    Create new User and save based on response
    """
    first_name_claim = idp.first_name_key or "given_name"
    full_name_claim = idp.full_name_key or "name"
    email = body.get(idp.email_key, "email")
    user = frappe.new_doc("User")
    user.name = user.email = email
    user.first_name = body.get(
        first_name_claim,
        body.get(
            full_name_claim,
            email,
        ),
    )
    user.full_name = body.get(full_name_claim, email)
    if body.get("phone_number_verified"):
        user.phone = body.get("phone_number")

    roles = [role.role for role in idp.user_roles]
    for role in roles:
        if frappe.db.get_value("Role", role, "name"):
            user.append("roles", {"role": role})

    user.flags.ignore_permissions = 1
    user.flags.no_welcome_mail = True
    user.save()
    idp_claims = [field.claim for field in idp.user_fields]
    user_claims = frappe.get_doc(
        {
            "doctype": "CFE User Claim",
            "user": user.name,
        }
    )
    for claim in idp_claims:
        if body.get(claim):
            user_claims.append(
                "claims",
                {
                    "claim": claim,
                    "value": body.get(claim),
                },
            )

    user_claims.flags.ignore_permissions = 1
    user_claims.save()
    frappe.db.commit()

    return user

I didn’t get the question.

The code block is setting 3 mandatory fields on user, without it user cannot be saved.

Phone is saved optionally.

For rest of the claims I created separate doctype.

The user.email needs to be valid email and which is what is used as user.name, preferred_username or employee_id or whatever can be returned as an email in user claims, pick the right claim which has valid email.

“Social Login Key” doctype is not related or used in the app.