From c4f2335f1151c8b1046bd23b376297e7f8fb6086 Mon Sep 17 00:00:00 2001
From: 18alantom <2.alan.tom@gmail.com>
Date: Fri, 4 Jul 2025 14:35:59 +0530
Subject: [PATCH] fix: support public client
---
.../doctype/oauth_client/oauth_client.json | 11 ++-
.../doctype/oauth_client/oauth_client.py | 20 +++++
.../oauth_settings/oauth_settings.json | 8 +-
.../doctype/oauth_settings/oauth_settings.py | 2 +-
frappe/integrations/oauth2.py | 74 +++++++++++++------
frappe/integrations/utils.py | 14 ++--
6 files changed, 96 insertions(+), 33 deletions(-)
diff --git a/frappe/integrations/doctype/oauth_client/oauth_client.json b/frappe/integrations/doctype/oauth_client/oauth_client.json
index 5ddc61061c..a41d62ac4d 100644
--- a/frappe/integrations/doctype/oauth_client/oauth_client.json
+++ b/frappe/integrations/doctype/oauth_client/oauth_client.json
@@ -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",
diff --git a/frappe/integrations/doctype/oauth_client/oauth_client.py b/frappe/integrations/doctype/oauth_client/oauth_client.py
index 7d974e3e35..35df45dbc3 100644
--- a/frappe/integrations/doctype/oauth_client/oauth_client.py
+++ b/frappe/integrations/doctype/oauth_client/oauth_client.py
@@ -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())
diff --git a/frappe/integrations/doctype/oauth_settings/oauth_settings.json b/frappe/integrations/doctype/oauth_settings/oauth_settings.json
index f30124392f..713bb04a9a 100644
--- a/frappe/integrations/doctype/oauth_settings/oauth_settings.json
+++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.json
@@ -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 https://frappe.io), or * to accept all.\n
\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",
diff --git a/frappe/integrations/doctype/oauth_settings/oauth_settings.py b/frappe/integrations/doctype/oauth_settings/oauth_settings.py
index 7133b2b1f5..fb7b6e2263 100644
--- a/frappe/integrations/doctype/oauth_settings/oauth_settings.py
+++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.py
@@ -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
diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py
index 2e2dc6fab5..96224f4c5a 100644
--- a/frappe/integrations/oauth2.py
+++ b/frappe/integrations/oauth2.py
@@ -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
diff --git a/frappe/integrations/utils.py b/frappe/integrations/utils.py
index a97ceba498..4f7d27d461 100644
--- a/frappe/integrations/utils.py
+++ b/frappe/integrations/utils.py
@@ -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