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]