fix: support public client

This commit is contained in:
18alantom 2025-07-04 14:35:59 +05:30
parent db4a7504e5
commit c4f2335f11
No known key found for this signature in database
GPG key ID: 942F199B7FFF4BF7
6 changed files with 96 additions and 33 deletions

View file

@ -29,6 +29,7 @@
"policy_uri",
"sb_advanced",
"grant_type",
"token_endpoint_auth_method",
"cb_2",
"response_type"
],
@ -188,11 +189,19 @@
"fieldname": "contacts",
"fieldtype": "Small Text",
"label": "Contacts"
},
{
"default": "Client Secret Basic",
"description": "Value of \"None\" implies a public client. In such a case Client Secret is not given to the client and token exchange makes use of PKCE.",
"fieldname": "token_endpoint_auth_method",
"fieldtype": "Select",
"label": "Token Endpoint Auth Method",
"options": "Client Secret Basic\nClient Secret Post\nNone"
}
],
"grid_page_length": 50,
"links": [],
"modified": "2025-07-02 11:52:00.978956",
"modified": "2025-07-04 14:07:36.146393",
"modified_by": "Administrator",
"module": "Integrations",
"name": "OAuth Client",

View file

@ -1,7 +1,11 @@
# Copyright (c) 2015, Frappe Technologies and contributors
# License: MIT. See LICENSE
import datetime
import time
import frappe
import frappe.utils
from frappe import _
from frappe.model.document import Document
from frappe.permissions import SYSTEM_USER_ROLE
@ -33,6 +37,7 @@ class OAuthClient(Document):
skip_authorization: DF.Check
software_id: DF.Data | None
software_version: DF.Data | None
token_endpoint_auth_method: DF.Literal["Client Secret Basic", "Client Secret Post", "None"]
tos_uri: DF.Data | None
user: DF.Link | None
# end: auto-generated types
@ -62,3 +67,18 @@ class OAuthClient(Document):
"""Returns true if session user is allowed to use this client."""
allowed_roles = {d.role for d in self.allowed_roles}
return bool(allowed_roles & set(frappe.get_roles()))
def is_public_client(self) -> bool:
return self.token_endpoint_auth_method == "None"
def client_id_issued_at(self) -> int:
"""Returns UNIX timestamp (seconds since epoch) of the client creation time."""
if isinstance(self.creation, datetime.datetime):
return int(self.creation.timestamp())
try:
d = datetime.datetime.fromisoformat(self.creation)
return int(d.timestamp())
except Exception:
return int(frappe.utils.now_datetime().timestamp())

View file

@ -12,7 +12,7 @@
"skip_authorization",
"column_break_ogmd",
"enable_dynamic_client_registration",
"allowed_origins_for_public_client_registration",
"allowed_public_client_origins",
"resource_tab",
"config_section",
"show_protected_resource_metadata",
@ -133,16 +133,16 @@
},
{
"description": "New line separated list of allowed public client URLs (eg <code>https://frappe.io</code>), or <code>*</code> to accept all.\n<br>\nPublic clients are restricted by default.",
"fieldname": "allowed_origins_for_public_client_registration",
"fieldname": "allowed_public_client_origins",
"fieldtype": "Small Text",
"label": "Allowed Origins for Public Client Registration"
"label": "Allowed Public Client Origins"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
"modified": "2025-07-04 12:05:50.723018",
"modified": "2025-07-04 15:01:45.453238",
"modified_by": "Administrator",
"module": "Integrations",
"name": "OAuth Settings",

View file

@ -14,7 +14,7 @@ class OAuthSettings(Document):
if TYPE_CHECKING:
from frappe.types import DF
allowed_origins_for_public_client_registration: DF.SmallText | None
allowed_public_client_origins: DF.SmallText | None
enable_dynamic_client_registration: DF.Check
resource_documentation: DF.Data | None
resource_name: DF.Data | None

View file

@ -10,6 +10,7 @@ from werkzeug import Response
from werkzeug.exceptions import NotFound
import frappe
import frappe.utils
from frappe import oauth
from frappe.integrations.utils import (
OAuth2DynamicClientMetadata,
@ -24,6 +25,14 @@ from frappe.oauth import (
get_userinfo,
)
ENDPOINTS = {
"token_endpoint": "/api/method/frappe.integrations.oauth2.get_token",
"userinfo_endpoint": "/api/method/frappe.integrations.oauth2.openid_profile",
"revocation_endpoint": "/api/method/frappe.integrations.oauth2.revoke_token",
"authorization_endpoint": "/api/method/frappe.integrations.oauth2.authorize",
"introspection_endpoint": "/api/method/frappe.integrations.oauth2.introspect_token",
}
def get_oauth_server():
if not getattr(frappe.local, "oauth_server", None):
@ -195,11 +204,11 @@ def get_openid_configuration():
response.data = frappe.as_json(
{
"issuer": frappe_server_url,
"authorization_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.authorize",
"token_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.get_token",
"userinfo_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.openid_profile",
"revocation_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.revoke_token",
"introspection_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.introspect_token",
"authorization_endpoint": f"{frappe_server_url}{ENDPOINTS['authorization_endpoint']}",
"token_endpoint": f"{frappe_server_url}{ENDPOINTS['token_endpoint']}",
"userinfo_endpoint": f"{frappe_server_url}{ENDPOINTS['userinfo_endpoint']}",
"revocation_endpoint": f"{frappe_server_url}{ENDPOINTS['revocation_endpoint']}",
"introspection_endpoint": f"{frappe_server_url}{ENDPOINTS['introspection_endpoint']}",
"response_types_supported": [
"code",
"token",
@ -303,17 +312,18 @@ def _get_authorization_server_metadata():
issuer = get_resource_url()
metadata = dict(
issuer=issuer,
authorization_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.authorize",
token_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.get_token",
authorization_endpoint=f"{issuer}{ENDPOINTS['authorization_endpoint']}",
token_endpoint=f"{issuer}{ENDPOINTS['token_endpoint']}",
response_types_supported=["code"],
response_modes_supported=["query"],
grant_types_supported=["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported=["client_secret_basic"],
token_endpoint_auth_methods_supported=["none", "client_secret_basic"],
service_documentation="https://docs.frappe.io/framework/user/en/guides/integration/how_to_set_up_oauth#add-a-client-app",
revocation_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.revoke_token",
revocation_endpoint=f"{issuer}{ENDPOINTS['revocation_endpoint']}",
revocation_endpoint_auth_methods_supported=["client_secret_basic"],
introspection_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.introspect_token",
userinfo_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.openid_profile",
introspection_endpoint=f"{issuer}{ENDPOINTS['introspection_endpoint']}",
userinfo_endpoint=f"{issuer}{ENDPOINTS['userinfo_endpoint']}",
code_challenge_methods_supported=["S256"],
)
if frappe.get_cached_value("OAuth Settings", "OAuth Settings", "enable_dynamic_client_registration"):
@ -354,21 +364,27 @@ def register_client():
response.data = frappe.as_json({"error": "invalid_client_metadata", "error_description": str(e)})
return response
"""
Note:
A check for existing client cannot be done unless a software_statement (JWT)
is issued. Use of software_statement is not yet implemented.
Doing an exists check based on just client_name or other replicable
parameters risks leaking client_id and client_secret. So it's better to
issue a new client.
"""
if error := validate_dynamic_client_metadata(client):
response.status_code = 400
response.data = frappe.as_json({"error": "invalid_client_metadata", "error_description": error})
return response
doc = create_new_oauth_client(client)
if isinstance(doc.creation, datetime.datetime):
client_id_issued_at = doc.creation.isoformat()
else:
client_id_issued_at = doc.creation
response_data = {
"client_id": doc.client_id,
"client_secret": doc.client_secret,
"client_id_issued_at": client_id_issued_at,
"client_id_issued_at": doc.client_id_issued_at(),
"client_secret_expires_at": 0,
# Response should include registered metadata
"client_name": doc.app_name,
@ -385,7 +401,11 @@ def register_client():
"contacts": doc.contacts.split("\n") if doc.contacts else None,
}
if doc.is_public_client():
del response_data["client_secret"]
_del_none_values(response_data)
response.status_code = 201 # Created
response.data = frappe.as_json(response_data)
return response
@ -499,20 +519,32 @@ def set_cors_for_privileged_requests():
return
if (
not frappe.request.path.startswith("/api/method/frappe.integrations.oauth2.register_client")
or frappe.request.method not in ("POST", "OPTIONS")
or not frappe.get_cached_value(
frappe.request.path.startswith("/api/method/frappe.integrations.oauth2.register_client")
and frappe.request.method in ("POST", "OPTIONS")
and frappe.get_cached_value(
"OAuth Settings",
"OAuth Settings",
"enable_dynamic_client_registration",
)
):
_set_allowed_cors()
return
if (
frappe.request.path.startswith(ENDPOINTS["token_endpoint"])
or frappe.request.path.startswith(ENDPOINTS["revocation_endpoint"])
or frappe.request.path.startswith(ENDPOINTS["introspection_endpoint"])
or frappe.request.path.startswith(ENDPOINTS["userinfo_endpoint"])
) and frappe.request.method in ("POST", "OPTIONS"):
_set_allowed_cors()
return
def _set_allowed_cors():
allowed = frappe.get_cached_value(
"OAuth Settings",
"OAuth Settings",
"allowed_origins_for_public_client_registration",
"allowed_public_client_origins",
)
if not allowed:
return

View file

@ -29,7 +29,7 @@ class OAuth2DynamicClientMetadata(BaseModel):
# Client identifiers shown to user
client_name: str
scope: str
scope: str | None = None
client_uri: HttpUrl | None = None
logo_uri: HttpUrl | None = None
@ -207,11 +207,8 @@ def validate_dynamic_client_metadata(client: OAuth2DynamicClientMetadata):
if len(client.redirect_uris) == 0:
invalidation_reasons.append("redirect_uris is required")
if client.token_endpoint_auth_method not in ["client_secret_basic"]:
invalidation_reasons.append("only client_secret_basic token_endpoint_auth_method is supported")
if client.grant_types and not set(client.grant_types).issubset({"authorization_code", "refresh_token"}):
invalidation_reasons.append("only authorization_code and refresh_token grant types are supported")
invalidation_reasons.append("only 'authorization_code' and 'refresh_token' grant types are supported")
if client.response_types and not all(rt == "code" for rt in client.response_types):
invalidation_reasons.append("only 'code' response_type is supported")
@ -230,7 +227,7 @@ def create_new_oauth_client(client: OAuth2DynamicClientMetadata):
redirect_uris = [str(uri) for uri in client.redirect_uris]
doc.app_name = client.client_name
doc.scopes = client.scope
doc.scopes = client.scope or "all"
doc.redirect_uris = "\n".join(redirect_uris)
doc.default_redirect_uri = redirect_uris[0]
doc.response_type = "Code"
@ -252,6 +249,11 @@ def create_new_oauth_client(client: OAuth2DynamicClientMetadata):
if client.software_version:
doc.software_version = client.software_version
if client.token_endpoint_auth_method == "none":
doc.token_endpoint_auth_method = "None"
if client.token_endpoint_auth_method == "client_secret_post":
doc.token_endpoint_auth_method = "Client Secret Post"
doc.save(ignore_permissions=True)
return doc