diff --git a/frappe/app.py b/frappe/app.py
index 2bac39016e..e5a1fba4a7 100644
--- a/frappe/app.py
+++ b/frappe/app.py
@@ -275,10 +275,12 @@ def process_response(response: Response):
def set_cors_headers(response):
+ allowed_origins = frappe.conf.allow_cors
+ if hasattr(frappe.local, "allow_cors"):
+ allowed_origins = frappe.local.allow_cors
+
if not (
- (allowed_origins := frappe.conf.allow_cors)
- and (request := frappe.local.request)
- and (origin := request.headers.get("Origin"))
+ allowed_origins and (request := frappe.local.request) and (origin := request.headers.get("Origin"))
):
return
diff --git a/frappe/hooks.py b/frappe/hooks.py
index 992cb2a2e0..bebb1f7e73 100644
--- a/frappe/hooks.py
+++ b/frappe/hooks.py
@@ -413,6 +413,7 @@ before_request = [
"frappe.recorder.record",
"frappe.monitor.start",
"frappe.rate_limiter.apply",
+ "frappe.integrations.oauth2.set_cors_for_privileged_requests",
]
after_request = [
diff --git a/frappe/integrations/doctype/oauth_settings/oauth_settings.json b/frappe/integrations/doctype/oauth_settings/oauth_settings.json
index be8c13c590..f30124392f 100644
--- a/frappe/integrations/doctype/oauth_settings/oauth_settings.json
+++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.json
@@ -6,15 +6,21 @@
"doctype": "DocType",
"engine": "InnoDB",
"field_order": [
+ "authorization_tab",
"authorization_server_section",
"show_auth_server_metadata",
- "enable_dynamic_client_registration",
"skip_authorization",
+ "column_break_ogmd",
+ "enable_dynamic_client_registration",
+ "allowed_origins_for_public_client_registration",
+ "resource_tab",
+ "config_section",
+ "show_protected_resource_metadata",
+ "column_break_wlfj",
+ "show_social_login_key_as_authorization_server",
"resource_server_section",
"resource_name",
"resource_policy_uri",
- "show_protected_resource_metadata",
- "show_social_login_key_as_authorization_server",
"column_break_zyte",
"resource_documentation",
"resource_tos_uri",
@@ -22,10 +28,10 @@
],
"fields": [
{
- "description": "These fields are used to provide resource server metadata to clients querying the \"/.well-known/oauth-protected-resource\" end point. For additional reference view: https://datatracker.ietf.org/doc/html/rfc9728",
+ "description": "These fields are used to provide resource server metadata to clients querying the \"well known protected resource\" end point.",
"fieldname": "resource_server_section",
"fieldtype": "Section Break",
- "label": "Resource Server"
+ "label": "Metadata"
},
{
"default": "Frappe Framework Application",
@@ -59,8 +65,7 @@
},
{
"fieldname": "authorization_server_section",
- "fieldtype": "Section Break",
- "label": "Authorization Server"
+ "fieldtype": "Section Break"
},
{
"default": "1",
@@ -102,13 +107,42 @@
"fieldname": "show_social_login_key_as_authorization_server",
"fieldtype": "Check",
"label": "Show Social Login Key as Authorization Server"
+ },
+ {
+ "fieldname": "column_break_ogmd",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "authorization_tab",
+ "fieldtype": "Tab Break",
+ "label": "Authorization"
+ },
+ {
+ "fieldname": "resource_tab",
+ "fieldtype": "Tab Break",
+ "label": "Resource"
+ },
+ {
+ "fieldname": "config_section",
+ "fieldtype": "Section Break",
+ "label": "Config"
+ },
+ {
+ "fieldname": "column_break_wlfj",
+ "fieldtype": "Column Break"
+ },
+ {
+ "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",
+ "fieldtype": "Small Text",
+ "label": "Allowed Origins for Public Client Registration"
}
],
"grid_page_length": 50,
"index_web_pages_for_search": 1,
"issingle": 1,
"links": [],
- "modified": "2025-07-04 11:20:15.528611",
+ "modified": "2025-07-04 12:05:50.723018",
"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 afffb32226..7133b2b1f5 100644
--- a/frappe/integrations/doctype/oauth_settings/oauth_settings.py
+++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.py
@@ -14,6 +14,7 @@ class OAuthSettings(Document):
if TYPE_CHECKING:
from frappe.types import DF
+ allowed_origins_for_public_client_registration: 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 06329b88eb..2e2dc6fab5 100644
--- a/frappe/integrations/oauth2.py
+++ b/frappe/integrations/oauth2.py
@@ -258,7 +258,7 @@ def introspect_token(token=None, token_type_hint=None):
def handle_wellknown(path: str):
- """Path handler for /.well-known/ endpoints. Invoked in app.py"""
+ """Path handler for GET requests to /.well-known/ endpoints. Invoked in app.py"""
if path.startswith("/.well-known/openid-configuration"):
return get_openid_configuration()
@@ -284,6 +284,7 @@ def get_authorization_server_metadata():
response = Response()
response.mimetype = "application/json"
response.data = frappe.as_json(_get_authorization_server_metadata())
+ frappe.local.allow_cors = "*"
return response
@@ -321,7 +322,7 @@ def _get_authorization_server_metadata():
return metadata
-@frappe.whitelist(allow_guest=True)
+@frappe.whitelist(allow_guest=True, methods=["POST"])
def register_client():
"""
Registers an OAuth client.
@@ -473,3 +474,51 @@ def _del_none_values(d: dict):
for k in list(d.keys()):
if k in d and d[k] is None:
del d[k]
+
+
+def set_cors_for_privileged_requests():
+ """
+ Called in before_request hook, prevents failure of privileged requests,
+ for OPTIONS and:
+ 1. GET requests on /.well-known/
+ 2. POST requests on /api/method/frappe.integrations.oauth2.register_client
+
+ Point 2. also depends on OAuth Settings for dynamic client registration.
+ Without these, registration requests from public clients will fail due to
+ preflight requests failing.
+ """
+ if (
+ frappe.conf.allow_cors == "*"
+ or not frappe.local.request
+ or not frappe.local.request.headers.get("Origin")
+ ):
+ return
+
+ if frappe.request.path.startswith("/.well-known/") and frappe.request.method in ("GET", "OPTIONS"):
+ frappe.local.allow_cors = "*"
+ 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(
+ "OAuth Settings",
+ "OAuth Settings",
+ "enable_dynamic_client_registration",
+ )
+ ):
+ return
+
+ allowed = frappe.get_cached_value(
+ "OAuth Settings",
+ "OAuth Settings",
+ "allowed_origins_for_public_client_registration",
+ )
+ if not allowed:
+ return
+
+ allowed = allowed.strip().splitlines()
+ if "*" in allowed:
+ frappe.local.allow_cors = "*"
+ else:
+ frappe.local.allow_cors = allowed
diff --git a/frappe/integrations/utils.py b/frappe/integrations/utils.py
index e428b55b8d..a97ceba498 100644
--- a/frappe/integrations/utils.py
+++ b/frappe/integrations/utils.py
@@ -252,7 +252,7 @@ def create_new_oauth_client(client: OAuth2DynamicClientMetadata):
if client.software_version:
doc.software_version = client.software_version
- doc.save()
+ doc.save(ignore_permissions=True)
return doc