diff --git a/frappe/app.py b/frappe/app.py
index ada60979c7..2bac39016e 100644
--- a/frappe/app.py
+++ b/frappe/app.py
@@ -22,7 +22,7 @@ import frappe.recorder
import frappe.utils.response
from frappe import _
from frappe.auth import SAFE_HTTP_METHODS, UNSAFE_HTTP_METHODS, HTTPRequest, check_request_ip, validate_auth
-from frappe.integrations.oauth2 import handle_wellknown
+from frappe.integrations.oauth2 import get_resource_url, handle_wellknown, is_oauth_metadata_enabled
from frappe.middlewares import StaticDataMiddleware
from frappe.permissions import handle_does_not_exist_error
from frappe.utils import CallbackManager, cint, get_site_name
@@ -259,6 +259,9 @@ def process_response(response: Response):
if hasattr(frappe.local, "conf"):
set_cors_headers(response)
+ if response.status_code in (401, 403) and is_oauth_metadata_enabled("resource"):
+ set_authenticate_headers(response)
+
# Update custom headers added during request processing
response.headers.update(frappe.local.response_headers)
@@ -306,6 +309,13 @@ def set_cors_headers(response):
response.headers.update(cors_headers)
+def set_authenticate_headers(response: Response):
+ headers = {
+ "WWW-Authenticate": f'Bearer resource_metadata="{get_resource_url()}/.well-known/oauth-protected-resource"'
+ }
+ response.headers.update(headers)
+
+
def make_form_dict(request: Request):
request_data = request.get_data(as_text=True)
if request_data and request.is_json:
diff --git a/frappe/integrations/doctype/oauth_settings/__init__.py b/frappe/integrations/doctype/oauth_settings/__init__.py
new file mode 100644
index 0000000000..e69de29bb2
diff --git a/frappe/integrations/doctype/oauth_settings/oauth_settings.js b/frappe/integrations/doctype/oauth_settings/oauth_settings.js
new file mode 100644
index 0000000000..f8839d6e6c
--- /dev/null
+++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2025, Frappe Technologies and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("OAuth Settings", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/frappe/integrations/doctype/oauth_settings/oauth_settings.json b/frappe/integrations/doctype/oauth_settings/oauth_settings.json
new file mode 100644
index 0000000000..6a4fcfda26
--- /dev/null
+++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.json
@@ -0,0 +1,116 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2025-07-03 12:04:14.759362",
+ "description": "A Frappe Framework instance can function as an OAuth Client, Resource, or Authorization server. This DocType contains settings related to all three.",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "authorization_server_section",
+ "show_auth_server_metadata",
+ "enable_dynamic_client_registration",
+ "resource_server_section",
+ "resource_name",
+ "resource_policy_uri",
+ "show_protected_resource_metadata",
+ "column_break_zyte",
+ "resource_documentation",
+ "resource_tos_uri",
+ "scopes_supported"
+ ],
+ "fields": [
+ {
+ "description": "These settings are used when this Frappe instance functions as a resource server.",
+ "fieldname": "resource_server_section",
+ "fieldtype": "Section Break",
+ "label": "Resource Server"
+ },
+ {
+ "default": "Frappe Framework Application",
+ "description": "Human-readable name intended for display to the end user.",
+ "fieldname": "resource_name",
+ "fieldtype": "Data",
+ "label": "Resource Name"
+ },
+ {
+ "fieldname": "column_break_zyte",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "https://docs.frappe.io/framework",
+ "description": "URL of a human-readable page with info that developers might need.",
+ "fieldname": "resource_documentation",
+ "fieldtype": "Data",
+ "label": "Resource Documentation"
+ },
+ {
+ "description": "URL of human-readable page with info on requirements about how the client can use the data.",
+ "fieldname": "resource_policy_uri",
+ "fieldtype": "Data",
+ "label": "Resource Policy URI"
+ },
+ {
+ "description": "URL of human-readable page with info about the protected resource's terms of service.",
+ "fieldname": "resource_tos_uri",
+ "fieldtype": "Data",
+ "label": "Resource TOS URI"
+ },
+ {
+ "fieldname": "authorization_server_section",
+ "fieldtype": "Section Break",
+ "label": "Authorization Server"
+ },
+ {
+ "default": "1",
+ "description": "Allows clients to fetch metadata from the /.well-known/oauth-authorization-server endpoint.",
+ "fieldname": "show_auth_server_metadata",
+ "fieldtype": "Check",
+ "label": "Show Auth Server Metadata"
+ },
+ {
+ "default": "1",
+ "description": "Allows clients to fetch metadata from the /.well-known/oauth-protected-resource endpoint.",
+ "fieldname": "show_protected_resource_metadata",
+ "fieldtype": "Check",
+ "label": "Show Protected Resource Metadata"
+ },
+ {
+ "description": "New line separated list of scope values.",
+ "fieldname": "scopes_supported",
+ "fieldtype": "Small Text",
+ "label": "Scopes Supported"
+ },
+ {
+ "default": "1",
+ "description": "Allows clients to register themselves without manual intervention. Registration creates a OAuth Client entry.",
+ "fieldname": "enable_dynamic_client_registration",
+ "fieldtype": "Check",
+ "label": "Enable Dynamic Client Registration"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "issingle": 1,
+ "links": [],
+ "modified": "2025-07-03 12:49:31.650861",
+ "modified_by": "Administrator",
+ "module": "Integrations",
+ "name": "OAuth Settings",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "print": 1,
+ "read": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "row_format": "Dynamic",
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/frappe/integrations/doctype/oauth_settings/oauth_settings.py b/frappe/integrations/doctype/oauth_settings/oauth_settings.py
new file mode 100644
index 0000000000..55cc8e7ab4
--- /dev/null
+++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.py
@@ -0,0 +1,27 @@
+# Copyright (c) 2025, Frappe Technologies and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class OAuthSettings(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ enable_dynamic_client_registration: DF.Check
+ resource_documentation: DF.Data | None
+ resource_name: DF.Data | None
+ resource_policy_uri: DF.Data | None
+ resource_tos_uri: DF.Data | None
+ scopes_supported: DF.SmallText | None
+ show_auth_server_metadata: DF.Check
+ show_protected_resource_metadata: DF.Check
+ # end: auto-generated types
+
+ pass
diff --git a/frappe/integrations/doctype/oauth_settings/test_oauth_settings.py b/frappe/integrations/doctype/oauth_settings/test_oauth_settings.py
new file mode 100644
index 0000000000..887c9e7278
--- /dev/null
+++ b/frappe/integrations/doctype/oauth_settings/test_oauth_settings.py
@@ -0,0 +1,20 @@
+# Copyright (c) 2025, Frappe Technologies and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+class IntegrationTestOAuthSettings(IntegrationTestCase):
+ """
+ Integration tests for OAuthSettings.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/frappe/integrations/doctype/social_login_key/social_login_key.json b/frappe/integrations/doctype/social_login_key/social_login_key.json
index 55c9f96abb..ab63adcec8 100644
--- a/frappe/integrations/doctype/social_login_key/social_login_key.json
+++ b/frappe/integrations/doctype/social_login_key/social_login_key.json
@@ -20,6 +20,7 @@
"base_url",
"configuration_section",
"sign_ups",
+ "show_in_resource_metadata",
"client_urls",
"authorize_url",
"access_token_url",
@@ -172,11 +173,19 @@
"fieldtype": "Select",
"label": "Sign ups",
"options": "\nAllow\nDeny"
+ },
+ {
+ "default": "1",
+ "description": "Allows clients to view this as an Authorization Server when querying the /.well-known/oauth-protected-resource end point.",
+ "fieldname": "show_in_resource_metadata",
+ "fieldtype": "Check",
+ "label": "Show in Resource Metadata"
}
],
+ "grid_page_length": 50,
"index_web_pages_for_search": 1,
"links": [],
- "modified": "2024-09-06 15:22:46.342392",
+ "modified": "2025-07-03 12:47:01.696817",
"modified_by": "Administrator",
"module": "Integrations",
"name": "Social Login Key",
@@ -195,9 +204,10 @@
"write": 1
}
],
+ "row_format": "Dynamic",
"sort_field": "creation",
"sort_order": "DESC",
"states": [],
"title_field": "provider_name",
"track_changes": 1
-}
\ No newline at end of file
+}
diff --git a/frappe/integrations/doctype/social_login_key/social_login_key.py b/frappe/integrations/doctype/social_login_key/social_login_key.py
index fbfa83fb3d..0e1567a995 100644
--- a/frappe/integrations/doctype/social_login_key/social_login_key.py
+++ b/frappe/integrations/doctype/social_login_key/social_login_key.py
@@ -54,6 +54,7 @@ class SocialLoginKey(Document):
icon: DF.Data | None
provider_name: DF.Data
redirect_url: DF.Data | None
+ show_in_resource_metadata: DF.Check
sign_ups: DF.Literal["", "Allow", "Deny"]
social_login_provider: DF.Literal[
"Custom",
diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py
index 96ee5f196e..47559b6e38 100644
--- a/frappe/integrations/oauth2.py
+++ b/frappe/integrations/oauth2.py
@@ -1,6 +1,6 @@
import datetime
import json
-from typing import cast
+from typing import Literal, cast
from urllib.parse import quote, urlencode, urlparse
from oauthlib.oauth2 import FatalClientError, OAuth2Error
@@ -10,6 +10,7 @@ from werkzeug import Response
from werkzeug.exceptions import NotFound
import frappe
+from frappe import oauth
from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import (
get_oauth_settings,
)
@@ -264,10 +265,12 @@ def handle_wellknown(path: str):
if path.startswith("/.well-known/openid-configuration"):
return get_openid_configuration()
- if path.startswith("/.well-known/oauth-authorization-server"):
+ if path.startswith("/.well-known/oauth-authorization-server") and is_oauth_metadata_enabled(
+ "auth_server"
+ ):
return get_authorization_server_metadata()
- if path.startswith("/.well-known/oauth-protected-resource"):
+ if path.startswith("/.well-known/oauth-protected-resource") and is_oauth_metadata_enabled("resource"):
return get_protected_resource_metadata()
raise NotFound
@@ -298,9 +301,8 @@ def _get_authorization_server_metadata():
is an unsafe practice, so code is the only supported response type.
"""
- request_url = urlparse(frappe.request.url)
- issuer = f"{request_url.scheme}://{request_url.netloc}"
- return dict(
+ 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",
@@ -313,9 +315,13 @@ def _get_authorization_server_metadata():
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",
- registration_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.register_client",
)
+ if frappe.get_cached_value("OAuth Settings", "OAuth Settings", "enable_dynamic_client_registration"):
+ metadata["registration_endpoint"] = f"{issuer}/api/method/frappe.integrations.oauth2.register_client"
+
+ return metadata
+
@frappe.whitelist(allow_guest=True)
def register_client():
@@ -325,6 +331,9 @@ def register_client():
Reference: https://datatracker.ietf.org/doc/html/rfc7591
"""
+ if not frappe.get_cached_value("OAuth Settings", "OAuth Settings", "enable_dynamic_client_registration"):
+ raise NotFound
+
response = Response()
response.mimetype = "application/json"
data = frappe.request.json
@@ -377,12 +386,84 @@ def register_client():
"contacts": doc.contacts.split("\n") if doc.contacts else None,
}
- for k in list(response_data.keys()):
- if k in response_data and response_data[k] is None:
- del response_data[k]
-
+ _del_none_values(response_data)
response.data = frappe.as_json(response_data)
return response
-def get_protected_resource_metadata(): ...
+def get_protected_resource_metadata():
+ """
+ Creates response for the /.well-known/oauth-protected-resource endpoint.
+
+ Reference: https://datatracker.ietf.org/doc/html/rfc9728
+ """
+
+ response = Response()
+ response.mimetype = "application/json"
+ response.data = frappe.as_json(_get_protected_resource_metadata())
+ return response
+
+
+def _get_protected_resource_metadata():
+ from frappe.integrations.doctype.oauth_settings.oauth_settings import OAuthSettings
+
+ # TODO:
+ # - header on 401
+ # - cache this response (5 minutes)
+ authorization_servers = frappe.get_list(
+ "Social Login Key",
+ filters={
+ "enable_social_login": True,
+ "show_in_resource_metadata": True,
+ },
+ pluck="base_url",
+ )
+ resource = get_resource_url()
+ oauth_settings = cast(OAuthSettings, frappe.get_cached_doc("OAuth Settings"))
+
+ metadata = dict(
+ resource=resource,
+ authorization_servers=[resource, *authorization_servers],
+ bearer_methods_supported=["header"],
+ resource_name=oauth_settings.resource_name,
+ resource_documentation=oauth_settings.resource_documentation,
+ resource_policy_uri=oauth_settings.resource_policy_uri,
+ resource_tos_uri=oauth_settings.resource_tos_uri,
+ )
+
+ if oauth_settings.scopes_supported is not None:
+ scopes = []
+ for _s in oauth_settings.scopes_supported.split("\n"):
+ s = _s.strip()
+ if s is None:
+ continue
+ scopes.append(s)
+
+ if scopes:
+ metadata["scopes_supported"] = scopes
+ _del_none_values(metadata)
+ return metadata
+
+
+def is_oauth_metadata_enabled(label: Literal["resource", "auth_server"]):
+ fieldname = (
+ "show_auth_server_metadata" if label == "authorization" else "show_protected_resource_metadata"
+ )
+
+ return frappe.get_cached_value(
+ "OAuth Settings",
+ "OAuth Settings",
+ fieldname,
+ )
+
+
+def get_resource_url():
+ """Uses request URL to reflect the resource URL"""
+ request_url = urlparse(frappe.request.url)
+ return f"{request_url.scheme}://{request_url.netloc}"
+
+
+def _del_none_values(d: dict):
+ for k in list(d.keys()):
+ if k in d and d[k] is None:
+ del d[k]