From f8425b6520c9def65bd793b5f02731be571f0488 Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Tue, 1 Jul 2025 13:33:52 +0530 Subject: [PATCH 01/12] feat(OAuth2): support RFC 8414 This allows an OAuth client to get metadata about the auth server, i.e. the frappe bench being used as an OAuth2 auth server. Metadata includes values for auth server urls and endpoints and supported types and modes. --- frappe/app.py | 5 ++++ frappe/integrations/oauth2.py | 48 ++++++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/frappe/app.py b/frappe/app.py index 81194d7606..910df78ae2 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -125,6 +125,11 @@ def application(request: Request): elif request.path.startswith("/private/files/"): response = frappe.utils.response.download_private_file(request.path) + elif request.path.startswith("/.well-known/oauth-authorization-server") and request.method == "GET": + from frappe.integrations.oauth2 import get_authorization_server_metadata + + response = get_authorization_server_metadata() + elif request.method in ("GET", "HEAD", "POST"): response = get_response() diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index 59e9f675b6..187de58bd8 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -1,5 +1,5 @@ import json -from urllib.parse import quote, urlencode +from urllib.parse import quote, urlencode, urlparse from oauthlib.oauth2 import FatalClientError, OAuth2Error from oauthlib.openid.connect.core.endpoints.pre_configured import Server as WebApplicationServer @@ -244,3 +244,49 @@ def introspect_token(token=None, token_type_hint=None): except Exception: frappe.local.response = frappe._dict({"active": False}) + + +def get_authorization_server_metadata(): + """ + Creates response for the /.well-known/oauth-authorization-server endpoint. + + Reference: https://datatracker.ietf.org/doc/html/rfc8414 + """ + from werkzeug import Response + + response = Response() + response.mimetype = "application/json" + response.data = frappe.as_json(_get_authorization_server_metadata()) + return response + + +def _get_authorization_server_metadata(): + """ + Responds with the authorization server metadata. + + Reference: https://datatracker.ietf.org/doc/html/rfc8414#section-2 + + Note: + Value for response_types_supported does not include token because, PKCE + token flow is not supported. Responding with token in the redirect URL + 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=issuer, + authorization_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.authorize", + token_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.get_token", + response_types_supported=["code"], + response_modes_supported=["query"], + grant_types_supported=["authorization_code", "refresh_token"], + token_endpoint_auth_methods_supported=["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_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", # TODO: RFC 7591 + # scopes_supported=[], + ) From 3a478015987d9bbb03953e9207b01f2b701bc4c3 Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Wed, 2 Jul 2025 12:20:31 +0530 Subject: [PATCH 02/12] feat(OAuth2): support RFC 7591 This allows a client to be registered without manual intervention. --- .../doctype/oauth_client/oauth_client.json | 105 +++++++++++++++--- .../doctype/oauth_client/oauth_client.py | 7 ++ frappe/integrations/oauth2.py | 80 ++++++++++++- frappe/integrations/utils.py | 90 +++++++++++++++ 4 files changed, 262 insertions(+), 20 deletions(-) diff --git a/frappe/integrations/doctype/oauth_client/oauth_client.json b/frappe/integrations/doctype/oauth_client/oauth_client.json index e60cc1f5f1..5ddc61061c 100644 --- a/frappe/integrations/doctype/oauth_client/oauth_client.json +++ b/frappe/integrations/doctype/oauth_client/oauth_client.json @@ -7,17 +7,26 @@ "engine": "InnoDB", "field_order": [ "client_id", - "app_name", "user", "allowed_roles", "cb_1", "client_secret", - "skip_authorization", - "sb_1", - "scopes", - "cb_3", - "redirect_uris", "default_redirect_uri", + "skip_authorization", + "client_metadata_section", + "app_name", + "scopes", + "column_break_htfq", + "redirect_uris", + "section_break_ggiv", + "client_uri", + "software_id", + "tos_uri", + "contacts", + "column_break_ziii", + "logo_uri", + "software_version", + "policy_uri", "sb_advanced", "grant_type", "cb_2", @@ -27,13 +36,13 @@ { "fieldname": "client_id", "fieldtype": "Data", - "label": "App Client ID", + "label": "Client ID", "read_only": 1 }, { "fieldname": "app_name", "fieldtype": "Data", - "label": "App Name", + "label": "App Name (Client Name)", "reqd": 1 }, { @@ -50,7 +59,7 @@ { "fieldname": "client_secret", "fieldtype": "Data", - "label": "App Client Secret", + "label": "Client Secret", "read_only": 1 }, { @@ -60,10 +69,6 @@ "fieldtype": "Check", "label": "Skip Authorization" }, - { - "fieldname": "sb_1", - "fieldtype": "Section Break" - }, { "default": "all openid", "description": "A list of resources which the Client App will have access to after the user allows it.
e.g. project", @@ -72,10 +77,6 @@ "label": "Scopes", "reqd": 1 }, - { - "fieldname": "cb_3", - "fieldtype": "Column Break" - }, { "description": "URIs for receiving authorization code once the user allows access, as well as failure responses. Typically a REST endpoint exposed by the Client App.\n
e.g. http://hostname/api/method/frappe.integrations.oauth2_logins.login_via_facebook", "fieldname": "redirect_uris", @@ -121,10 +122,77 @@ "fieldtype": "Table MultiSelect", "label": "Allowed Roles", "options": "OAuth Client Role" + }, + { + "fieldname": "client_metadata_section", + "fieldtype": "Section Break", + "label": "Client Metadata" + }, + { + "depends_on": "eval: doc.client_uri", + "description": "URL of a web page providing information about the client.", + "fieldname": "client_uri", + "fieldtype": "Data", + "label": "Client URI" + }, + { + "fieldname": "column_break_htfq", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: doc.client_uri", + "description": "URL that references a logo for the client.", + "fieldname": "logo_uri", + "fieldtype": "Data", + "label": "Logo URI" + }, + { + "fieldname": "section_break_ggiv", + "fieldtype": "Section Break" + }, + { + "depends_on": "eval: doc.software_id", + "description": "Unique ID assigned by the client developer used to identify the client software to be dynamically registered.\n
\nShould remain same across multiple versions or updates of the software.", + "fieldname": "software_id", + "fieldtype": "Data", + "label": "Software ID" + }, + { + "fieldname": "column_break_ziii", + "fieldtype": "Column Break" + }, + { + "depends_on": "eval: doc.software_version", + "description": "A version identifier string for the client software.\n
\nThe value of the should change on any update of the client software with the same Software ID.", + "fieldname": "software_version", + "fieldtype": "Data", + "label": "Software Version" + }, + { + "depends_on": "eval: doc.tos_uri", + "description": "URL that points to a human-readable terms of service document for the client. Should be shown to end-user before authorizing.", + "fieldname": "tos_uri", + "fieldtype": "Data", + "label": "TOS URI" + }, + { + "depends_on": "eval: doc.policy_uri", + "description": "URL that points to a human-readable policy document for the client. Should be shown to end-user before authorizing.", + "fieldname": "policy_uri", + "fieldtype": "Data", + "label": "Policy URI" + }, + { + "depends_on": "eval: doc.contacts", + "description": "New lines separated list of strings representing ways to contact people responsible for this client, typically email addresses.", + "fieldname": "contacts", + "fieldtype": "Small Text", + "label": "Contacts" } ], + "grid_page_length": 50, "links": [], - "modified": "2024-04-29 12:07:07.946980", + "modified": "2025-07-02 11:52:00.978956", "modified_by": "Administrator", "module": "Integrations", "name": "OAuth Client", @@ -143,6 +211,7 @@ "write": 1 } ], + "row_format": "Dynamic", "sort_field": "creation", "sort_order": "DESC", "states": [], diff --git a/frappe/integrations/doctype/oauth_client/oauth_client.py b/frappe/integrations/doctype/oauth_client/oauth_client.py index 4b084898fb..7d974e3e35 100644 --- a/frappe/integrations/doctype/oauth_client/oauth_client.py +++ b/frappe/integrations/doctype/oauth_client/oauth_client.py @@ -21,12 +21,19 @@ class OAuthClient(Document): app_name: DF.Data client_id: DF.Data | None client_secret: DF.Data | None + client_uri: DF.Data | None + contacts: DF.SmallText | None default_redirect_uri: DF.Data grant_type: DF.Literal["Authorization Code", "Implicit"] + logo_uri: DF.Data | None + policy_uri: DF.Data | None redirect_uris: DF.Text | None response_type: DF.Literal["Code", "Token"] scopes: DF.Text skip_authorization: DF.Check + software_id: DF.Data | None + software_version: DF.Data | None + tos_uri: DF.Data | None user: DF.Link | None # end: auto-generated types diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index 187de58bd8..c47fe7467b 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -1,13 +1,22 @@ +import datetime import json +from typing import cast from urllib.parse import quote, urlencode, urlparse from oauthlib.oauth2 import FatalClientError, OAuth2Error from oauthlib.openid.connect.core.endpoints.pre_configured import Server as WebApplicationServer +from pydantic import ValidationError +from werkzeug import Response import frappe from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import ( get_oauth_settings, ) +from frappe.integrations.utils import ( + OAuth2DynamicClientMetadata, + create_new_oauth_client, + validate_dynamic_client_metadata, +) from frappe.oauth import ( OAuthWebRequestValidator, generate_json_error_response, @@ -287,6 +296,73 @@ 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", # TODO: RFC 7591 - # scopes_supported=[], + registration_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.register_client", ) + + +@frappe.whitelist(allow_guest=True) +def register_client(): + """ + Registers an OAuth client. + + Reference: https://datatracker.ietf.org/doc/html/rfc7591 + """ + + response = Response() + response.mimetype = "application/json" + data = frappe.request.json + + if data is None: + response.status_code = 400 + response.data = frappe.as_json( + { + "error": "invalid_client_metadata", + "error_description": "Request body is empty", + } + ) + return response + + try: + client = OAuth2DynamicClientMetadata.model_validate(data) + except ValidationError as e: + response.status_code = 400 + response.data = frappe.as_json({"error": "invalid_client_metadata", "error_description": str(e)}) + return response + + 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_secret_expires_at": 0, + # Response should include registered metadata + "client_name": doc.app_name, + "client_uri": doc.client_uri, + "grant_types": ["authorization_code"], + "response_types": ["code"], + "logo_uri": doc.logo_uri, + "tos_uri": doc.tos_uri, + "policy_uri": doc.policy_uri, + "software_id": doc.software_id, + "software_version": doc.software_version, + "scope": doc.scopes, + "redirect_uris": doc.redirect_uris.split("\n") if doc.redirect_uris else None, + "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] + + response.data = frappe.as_json(response_data) + return response diff --git a/frappe/integrations/utils.py b/frappe/integrations/utils.py index bb4f62463b..602aec3c1a 100644 --- a/frappe/integrations/utils.py +++ b/frappe/integrations/utils.py @@ -3,12 +3,48 @@ import datetime import json +from typing import cast from urllib.parse import parse_qs +from pydantic import BaseModel, HttpUrl + import frappe +from frappe.integrations.doctype.oauth_client.oauth_client import OAuthClient from frappe.utils import get_request_session +class OAuth2DynamicClientMetadata(BaseModel): + """ + OAuth 2.0 Dynamic Client Registration Metadata. + + As defined in RFC7591 - OAuth 2.0 Dynamic Client Registration Protocol + https://datatracker.ietf.org/doc/html/rfc7591#section-2 + """ + + # Used to identify the client to the authorization server + redirect_uris: list[HttpUrl] + token_endpoint_auth_method: str | None = "client_secret_basic" + grant_types: list[str] | None = ["authorization_code"] + response_types: list[str] | None = ["code"] + + # Client identifiers shown to user + client_name: str + scope: str + client_uri: HttpUrl | None = None + logo_uri: HttpUrl | None = None + + # Client contact and other information for the client + contacts: list[str] | None = None + tos_uri: HttpUrl | None = None + policy_uri: HttpUrl | None = None + software_id: str | None = None + software_version: str | None = None + + # JSON Web Key Set (JWKS) not used here + jwks_uri: HttpUrl | None = None + jwks: dict | None = None + + def make_request(method: str, url: str, auth=None, headers=None, data=None, json=None, params=None): auth = auth or "" data = data or {} @@ -164,3 +200,57 @@ def get_json(obj): def json_handler(obj): if isinstance(obj, datetime.date | datetime.timedelta | datetime.datetime): return str(obj) + + +def validate_dynamic_client_metadata(client: OAuth2DynamicClientMetadata): + invalidation_reasons = [] + 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 not in ["authorization_code"]: + invalidation_reasons.append("only authorization_code and refresh_token grant types are supported") + + if client.response_types not in ["code"]: + invalidation_reasons.append("only code response_type is supported") + + if not frappe.conf.developer_mode and any(c.scheme != "https" for c in client.redirect_uris): + invalidation_reasons.append("redirect_uris must be https") + + if invalidation_reasons: + return ",\n".join(invalidation_reasons) + + return None + + +def create_new_oauth_client(client: OAuth2DynamicClientMetadata): + doc = cast(OAuthClient, frappe.get_doc({"doctype": "OAuth Client"})) + redirect_uris = [str(uri) for uri in client.redirect_uris] + + doc.app_name = client.client_name + doc.scopes = client.scope + doc.redirect_uris = "\n".join(redirect_uris) + doc.default_redirect_uri = redirect_uris[0] + doc.response_type = "Code" + doc.grant_type = "Authorization Code" + doc.skip_authorization = False + + if client.client_uri: + doc.client_uri = client.client_uri.encoded_string() + if client.logo_uri: + doc.logo_uri = client.logo_uri.encoded_string() + if client.tos_uri: + doc.tos_uri = client.tos_uri.encoded_string() + if client.policy_uri: + doc.policy_uri = client.policy_uri.encoded_string() + if client.contacts: + doc.contacts = "\n".join(client.contacts) + if client.software_id: + doc.software_id = client.software_id + if client.software_version: + doc.software_version = client.software_version + + doc.save() + return doc From 4cd8115c4c7a8c6bca749f50752e8bc072a541b1 Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Wed, 2 Jul 2025 15:17:42 +0530 Subject: [PATCH 03/12] refactor: unify how .well-known routes are handled --- frappe/app.py | 7 +++---- frappe/hooks.py | 4 ---- frappe/integrations/oauth2.py | 28 ++++++++++++++++++++++++---- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/frappe/app.py b/frappe/app.py index 910df78ae2..ada60979c7 100644 --- a/frappe/app.py +++ b/frappe/app.py @@ -22,6 +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.middlewares import StaticDataMiddleware from frappe.permissions import handle_does_not_exist_error from frappe.utils import CallbackManager, cint, get_site_name @@ -125,10 +126,8 @@ def application(request: Request): elif request.path.startswith("/private/files/"): response = frappe.utils.response.download_private_file(request.path) - elif request.path.startswith("/.well-known/oauth-authorization-server") and request.method == "GET": - from frappe.integrations.oauth2 import get_authorization_server_metadata - - response = get_authorization_server_metadata() + elif request.path.startswith("/.well-known/") and request.method == "GET": + response = handle_wellknown(request.path) elif request.method in ("GET", "HEAD", "POST"): response = get_response() diff --git a/frappe/hooks.py b/frappe/hooks.py index 0340e891d3..992cb2a2e0 100644 --- a/frappe/hooks.py +++ b/frappe/hooks.py @@ -62,10 +62,6 @@ website_route_rules = [ website_redirects = [ {"source": r"/desk(.*)", "target": r"/app\1"}, - { - "source": "/.well-known/openid-configuration", - "target": "/api/method/frappe.integrations.oauth2.openid_configuration", - }, ] base_template = "templates/base.html" diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index c47fe7467b..96ee5f196e 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -7,6 +7,7 @@ from oauthlib.oauth2 import FatalClientError, OAuth2Error from oauthlib.openid.connect.core.endpoints.pre_configured import Server as WebApplicationServer from pydantic import ValidationError from werkzeug import Response +from werkzeug.exceptions import NotFound import frappe from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import ( @@ -188,10 +189,11 @@ def openid_profile(*args, **kwargs): return generate_json_error_response(e) -@frappe.whitelist(allow_guest=True) -def openid_configuration(): +def get_openid_configuration(): + response = Response() + response.mimetype = "application/json" frappe_server_url = get_server_url() - frappe.local.response = frappe._dict( + response.data = frappe.as_json( { "issuer": frappe_server_url, "authorization_endpoint": f"{frappe_server_url}/api/method/frappe.integrations.oauth2.authorize", @@ -211,6 +213,7 @@ def openid_configuration(): "id_token_signing_alg_values_supported": ["HS256"], } ) + return response @frappe.whitelist(allow_guest=True) @@ -255,13 +258,27 @@ def introspect_token(token=None, token_type_hint=None): frappe.local.response = frappe._dict({"active": False}) +def handle_wellknown(path: str): + """Path handler for /.well-known/ endpoints. Invoked in app.py""" + + if path.startswith("/.well-known/openid-configuration"): + return get_openid_configuration() + + if path.startswith("/.well-known/oauth-authorization-server"): + return get_authorization_server_metadata() + + if path.startswith("/.well-known/oauth-protected-resource"): + return get_protected_resource_metadata() + + raise NotFound + + def get_authorization_server_metadata(): """ Creates response for the /.well-known/oauth-authorization-server endpoint. Reference: https://datatracker.ietf.org/doc/html/rfc8414 """ - from werkzeug import Response response = Response() response.mimetype = "application/json" @@ -366,3 +383,6 @@ def register_client(): response.data = frappe.as_json(response_data) return response + + +def get_protected_resource_metadata(): ... From 1215afdf96b44153a8798227bbf4f96762ed959c Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Thu, 3 Jul 2025 13:10:41 +0530 Subject: [PATCH 04/12] feat(OAuth2): support RFC 9728 adds OAuth Settings to configure settings pertaining to Frappe used as an OAuth auth server and resource server --- frappe/app.py | 12 +- .../doctype/oauth_settings/__init__.py | 0 .../doctype/oauth_settings/oauth_settings.js | 8 ++ .../oauth_settings/oauth_settings.json | 116 ++++++++++++++++++ .../doctype/oauth_settings/oauth_settings.py | 27 ++++ .../oauth_settings/test_oauth_settings.py | 20 +++ .../social_login_key/social_login_key.json | 14 ++- .../social_login_key/social_login_key.py | 1 + frappe/integrations/oauth2.py | 105 ++++++++++++++-- 9 files changed, 288 insertions(+), 15 deletions(-) create mode 100644 frappe/integrations/doctype/oauth_settings/__init__.py create mode 100644 frappe/integrations/doctype/oauth_settings/oauth_settings.js create mode 100644 frappe/integrations/doctype/oauth_settings/oauth_settings.json create mode 100644 frappe/integrations/doctype/oauth_settings/oauth_settings.py create mode 100644 frappe/integrations/doctype/oauth_settings/test_oauth_settings.py 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] From 5ca8ad9d843d811c1fd92e1037dde9a1a041ff48 Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Thu, 3 Jul 2025 14:05:59 +0530 Subject: [PATCH 05/12] refactor: deprecate OAuth Provider Settings OAuth Settings has its fields now (only one) --- frappe/integrations/README.md | 26 +++++++++++++++++++ .../oauth_provider_settings.py | 7 ----- .../oauth_settings/oauth_settings.json | 10 ++++++- .../doctype/oauth_settings/oauth_settings.py | 1 + frappe/integrations/oauth2.py | 4 +-- frappe/integrations/utils.py | 16 +++++++++++- 6 files changed, 52 insertions(+), 12 deletions(-) create mode 100644 frappe/integrations/README.md diff --git a/frappe/integrations/README.md b/frappe/integrations/README.md new file mode 100644 index 0000000000..1d9cc30d44 --- /dev/null +++ b/frappe/integrations/README.md @@ -0,0 +1,26 @@ +# Integrations + +## OAuth 2 + +Frappe Framwork uses [`oauthlib`](https://github.com/oauthlib/oauthlib) to manage OAuth2 requirements. A Frappe instance can function as all of these: + +1. **Resource Server**: contains resources, for example the data in your DocTypes. +2. **Authorization Server**: server that issues tokens to access some resource. +3. **Client**: app that requires access to some resource on a resource server. + +Different DocTypes and features pertain to each of roles: + +0. **Common**: + - **OAuth Settings**: allows configuring certain OAuth features. +1. **Authorization Server** + - **OAuth Client**: keeps records of _clients_ registered with the frappe instance. + - **OAuth Bearer Token**: tokens given out to registered _clients_ are maintained here. + - **OAuth Authorization Code**: keeps track of OAuth codes a client responds with in exchange for a token. + - **OAuth Provider Settings**: allows skipping authorization +2. **Client** + - **Connected App**: keeps records of _authorization servers_ against whom this frappe instance is registered as a _client_ so some resource can be accessed. Eg. a users Google Drive account. + - **Social Key Login**: similar to **Connected App**, but for the purpose of logging into the frappe instance. Eg. a users Google account to enable "Login with Google". + - **Token Cache**: tokens received by the Frappe instance when accessing a **Connected App**. +3. + +## OAuth Settings diff --git a/frappe/integrations/doctype/oauth_provider_settings/oauth_provider_settings.py b/frappe/integrations/doctype/oauth_provider_settings/oauth_provider_settings.py index 74fa9fdd80..1b91bbfdeb 100644 --- a/frappe/integrations/doctype/oauth_provider_settings/oauth_provider_settings.py +++ b/frappe/integrations/doctype/oauth_provider_settings/oauth_provider_settings.py @@ -19,10 +19,3 @@ class OAuthProviderSettings(Document): # end: auto-generated types pass - - -def get_oauth_settings(): - """Return OAuth settings.""" - return frappe._dict( - {"skip_authorization": frappe.db.get_single_value("OAuth Provider Settings", "skip_authorization")} - ) diff --git a/frappe/integrations/doctype/oauth_settings/oauth_settings.json b/frappe/integrations/doctype/oauth_settings/oauth_settings.json index 6a4fcfda26..add8b2eb70 100644 --- a/frappe/integrations/doctype/oauth_settings/oauth_settings.json +++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.json @@ -9,6 +9,7 @@ "authorization_server_section", "show_auth_server_metadata", "enable_dynamic_client_registration", + "skip_authorization", "resource_server_section", "resource_name", "resource_policy_uri", @@ -86,13 +87,20 @@ "fieldname": "enable_dynamic_client_registration", "fieldtype": "Check", "label": "Enable Dynamic Client Registration" + }, + { + "default": "0", + "description": "Allows skipping authorization if a user has active tokens.", + "fieldname": "skip_authorization", + "fieldtype": "Check", + "label": "Skip Authorization" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-07-03 12:49:31.650861", + "modified": "2025-07-03 14:07:31.542741", "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 55cc8e7ab4..8b2aafd072 100644 --- a/frappe/integrations/doctype/oauth_settings/oauth_settings.py +++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.py @@ -22,6 +22,7 @@ class OAuthSettings(Document): scopes_supported: DF.SmallText | None show_auth_server_metadata: DF.Check show_protected_resource_metadata: DF.Check + skip_authorization: DF.Check # end: auto-generated types pass diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index 47559b6e38..976532473b 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -11,12 +11,10 @@ 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, -) from frappe.integrations.utils import ( OAuth2DynamicClientMetadata, create_new_oauth_client, + get_oauth_settings, validate_dynamic_client_metadata, ) from frappe.oauth import ( diff --git a/frappe/integrations/utils.py b/frappe/integrations/utils.py index 602aec3c1a..07d649388a 100644 --- a/frappe/integrations/utils.py +++ b/frappe/integrations/utils.py @@ -3,7 +3,7 @@ import datetime import json -from typing import cast +from typing import Any, cast from urllib.parse import parse_qs from pydantic import BaseModel, HttpUrl @@ -254,3 +254,17 @@ def create_new_oauth_client(client: OAuth2DynamicClientMetadata): doc.save() return doc + + +def get_oauth_settings(): + """Return OAuth settings.""" + settings: dict[str, Any] = frappe._dict({"skip_authorization": None}) + if frappe.get_cached_value("OAuth Settings", "OAuth Settings", "skip_authorization"): + settings["skip_authorization"] = "Auto" # based on legacy OAuth Provider Settings value + + elif value := frappe.get_cached_value( + "OAuth Provider Settings", "OAuth Provider Settings", "skip_authorization" + ): + settings["skip_authorization"] = value + + return settings From befca37299a58f00ef852c17f98d876e5b88681a Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Thu, 3 Jul 2025 14:36:04 +0530 Subject: [PATCH 06/12] chore: README for integrations with OAuth2 docs - Update descriptionsi in OAuth Settings --- frappe/integrations/README.md | 52 ++++++++++++++++--- .../oauth_settings/oauth_settings.json | 8 +-- 2 files changed, 48 insertions(+), 12 deletions(-) diff --git a/frappe/integrations/README.md b/frappe/integrations/README.md index 1d9cc30d44..b0ddb0564b 100644 --- a/frappe/integrations/README.md +++ b/frappe/integrations/README.md @@ -8,19 +8,55 @@ Frappe Framwork uses [`oauthlib`](https://github.com/oauthlib/oauthlib) to manag 2. **Authorization Server**: server that issues tokens to access some resource. 3. **Client**: app that requires access to some resource on a resource server. -Different DocTypes and features pertain to each of roles: +DocTypes pertaining to the above roles: -0. **Common**: - - **OAuth Settings**: allows configuring certain OAuth features. -1. **Authorization Server** +1. **Common** + - **OAuth Settings**: allows configuring certain OAuth features pertaining to the three roles. +2. **Authorization Server** - **OAuth Client**: keeps records of _clients_ registered with the frappe instance. - **OAuth Bearer Token**: tokens given out to registered _clients_ are maintained here. - **OAuth Authorization Code**: keeps track of OAuth codes a client responds with in exchange for a token. - - **OAuth Provider Settings**: allows skipping authorization -2. **Client** + - **OAuth Provider Settings**: allows skipping authorization. `[DEPRECATED]` use **OAuth Settings** instead. +3. **Client** - **Connected App**: keeps records of _authorization servers_ against whom this frappe instance is registered as a _client_ so some resource can be accessed. Eg. a users Google Drive account. - **Social Key Login**: similar to **Connected App**, but for the purpose of logging into the frappe instance. Eg. a users Google account to enable "Login with Google". - **Token Cache**: tokens received by the Frappe instance when accessing a **Connected App**. -3. -## OAuth Settings +### Features + +Additional features over `oauthlib` that have implemented in the Framework: + +- **Dynamic Client Registration**: allows a client to register itself without manual configuration by the resource owner. [RFC7591](https://datatracker.ietf.org/doc/html/rfc7591) +- **Authorization Server Metadata Discovery**: allows a client to view the instance's auth server (itself) metadata such as auth end points. [RFC8414](https://datatracker.ietf.org/doc/html/rfc8414) +- **Resource Server Metadata Discovery**: allows a client to view the instance's resource server metadata such as documentation, auth servers, etc. [RFC9278](https://datatracker.ietf.org/doc/html/rfc9728) + +### Additional Docs + +Documentation of various OAuth2 features: + +1. [How to setup OAuth 2?](https://docs.frappe.io/framework/user/en/guides/integration/how_to_set_up_oauth) +2. [OAuth 2](https://docs.frappe.io/framework/user/en/guides/integration/rest_api/oauth-2) +3. [Token Based Authentication](https://docs.frappe.io/framework/user/en/guides/integration/rest_api/token_based_authentication) +4. [Using Frappe as OAuth Service](https://docs.frappe.io/framework/user/en/using_frappe_as_oauth_service) +5. [Social Login Key](https://docs.frappe.io/framework/user/en/guides/integration/social_login_key) +6. [Connected App](https://docs.frappe.io/framework/user/en/guides/app-development/connected-app) + +> [!WARNING] +> +> Some of these might be outdated, it is always recommended to check the code +> when in doubt. + +### OAuth Settings + +A Single doctype that allows configuring OAuth2 related features. It is +recommended to open the DocType page itself as each field and section has a +sufficiently descriptive help text. + +The settings allow toggling the following features: + +- Authorization check when active token is present using the _Skip Authorization_ field. _**Note**: Keep this unchecked in production._ +- **Authorization Server Metadata Discovery**: by toggling the _Show Auth Server Metadata_ field. +- **Dynamic Client Registration**: by toggling the _Enable Dynamic Client Registration_ field. +- **Resource Server Metadata Discovery**: by toggling the _Show Protected Resource Metadata_. + +The remaining fields (in the **Resource Server** section) are used only when responding to requests on `/.well-known/oauth-protected-resource` diff --git a/frappe/integrations/doctype/oauth_settings/oauth_settings.json b/frappe/integrations/doctype/oauth_settings/oauth_settings.json index add8b2eb70..696accde2b 100644 --- a/frappe/integrations/doctype/oauth_settings/oauth_settings.json +++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.json @@ -21,7 +21,7 @@ ], "fields": [ { - "description": "These settings are used when this Frappe instance functions as a resource server.", + "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", "fieldname": "resource_server_section", "fieldtype": "Section Break", "label": "Resource Server" @@ -63,7 +63,7 @@ }, { "default": "1", - "description": "Allows clients to fetch metadata from the /.well-known/oauth-authorization-server endpoint.", + "description": "Allows clients to fetch metadata from the /.well-known/oauth-authorization-server endpoint. Reference: RFC8414", "fieldname": "show_auth_server_metadata", "fieldtype": "Check", "label": "Show Auth Server Metadata" @@ -83,7 +83,7 @@ }, { "default": "1", - "description": "Allows clients to register themselves without manual intervention. Registration creates a OAuth Client entry.", + "description": "Allows clients to register themselves without manual intervention. Registration creates a OAuth Client entry. Reference: RFC7591", "fieldname": "enable_dynamic_client_registration", "fieldtype": "Check", "label": "Enable Dynamic Client Registration" @@ -100,7 +100,7 @@ "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-07-03 14:07:31.542741", + "modified": "2025-07-03 14:29:35.314601", "modified_by": "Administrator", "module": "Integrations", "name": "OAuth Settings", From c174e9cbdc2b96870294bf1996a48e11745f2e79 Mon Sep 17 00:00:00 2001 From: Alan <2.alan.tom@gmail.com> Date: Thu, 3 Jul 2025 14:48:54 +0530 Subject: [PATCH 07/12] fix: apply fixes on accepted Copilot suggestions Update frappe/integrations/oauth2.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Update frappe/integrations/README.md Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Update frappe/integrations/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Update frappe/integrations/utils.py Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- frappe/integrations/README.md | 2 +- frappe/integrations/oauth2.py | 19 ++++++++++++------- frappe/integrations/utils.py | 6 +++--- 3 files changed, 16 insertions(+), 11 deletions(-) diff --git a/frappe/integrations/README.md b/frappe/integrations/README.md index b0ddb0564b..219ea6ba61 100644 --- a/frappe/integrations/README.md +++ b/frappe/integrations/README.md @@ -2,7 +2,7 @@ ## OAuth 2 -Frappe Framwork uses [`oauthlib`](https://github.com/oauthlib/oauthlib) to manage OAuth2 requirements. A Frappe instance can function as all of these: +Frappe Framework uses [`oauthlib`](https://github.com/oauthlib/oauthlib) to manage OAuth2 requirements. A Frappe instance can function as all of these: 1. **Resource Server**: contains resources, for example the data in your DocTypes. 2. **Authorization Server**: server that issues tokens to access some resource. diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index 976532473b..07a2c28e14 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -444,14 +444,19 @@ def _get_protected_resource_metadata(): def is_oauth_metadata_enabled(label: Literal["resource", "auth_server"]): - fieldname = ( - "show_auth_server_metadata" if label == "authorization" else "show_protected_resource_metadata" - ) + if label not in ["resource", "auth_server"]: + return False - return frappe.get_cached_value( - "OAuth Settings", - "OAuth Settings", - fieldname, + fieldname = "show_auth_server_metadata" + if label == "resource": + fieldname = "show_protected_resource_metadata" + + return bool( + frappe.get_cached_value( + "OAuth Settings", + "OAuth Settings", + fieldname, + ) ) diff --git a/frappe/integrations/utils.py b/frappe/integrations/utils.py index 07d649388a..e428b55b8d 100644 --- a/frappe/integrations/utils.py +++ b/frappe/integrations/utils.py @@ -210,11 +210,11 @@ def validate_dynamic_client_metadata(client: OAuth2DynamicClientMetadata): 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 not in ["authorization_code"]: + 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") - if client.response_types not in ["code"]: - invalidation_reasons.append("only code response_type is 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") if not frappe.conf.developer_mode and any(c.scheme != "https" for c in client.redirect_uris): invalidation_reasons.append("redirect_uris must be https") From 933ec04074176489daf3e06d86becdcc71f5e634 Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Thu, 3 Jul 2025 15:12:25 +0530 Subject: [PATCH 08/12] chore: fix typo --- frappe/integrations/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frappe/integrations/README.md b/frappe/integrations/README.md index 219ea6ba61..211614e531 100644 --- a/frappe/integrations/README.md +++ b/frappe/integrations/README.md @@ -28,7 +28,7 @@ Additional features over `oauthlib` that have implemented in the Framework: - **Dynamic Client Registration**: allows a client to register itself without manual configuration by the resource owner. [RFC7591](https://datatracker.ietf.org/doc/html/rfc7591) - **Authorization Server Metadata Discovery**: allows a client to view the instance's auth server (itself) metadata such as auth end points. [RFC8414](https://datatracker.ietf.org/doc/html/rfc8414) -- **Resource Server Metadata Discovery**: allows a client to view the instance's resource server metadata such as documentation, auth servers, etc. [RFC9278](https://datatracker.ietf.org/doc/html/rfc9728) +- **Resource Server Metadata Discovery**: allows a client to view the instance's resource server metadata such as documentation, auth servers, etc. [RFC9728](https://datatracker.ietf.org/doc/html/rfc9728) ### Additional Docs From e76c1830e1a20b0e11001df61e2e64a6ec90f602 Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Fri, 4 Jul 2025 11:25:06 +0530 Subject: [PATCH 09/12] chore: add global flag for Social Login Key --- .../oauth_settings/oauth_settings.json | 12 ++++++-- .../doctype/oauth_settings/oauth_settings.py | 1 + frappe/integrations/oauth2.py | 29 ++++++++++--------- 3 files changed, 27 insertions(+), 15 deletions(-) diff --git a/frappe/integrations/doctype/oauth_settings/oauth_settings.json b/frappe/integrations/doctype/oauth_settings/oauth_settings.json index 696accde2b..be8c13c590 100644 --- a/frappe/integrations/doctype/oauth_settings/oauth_settings.json +++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.json @@ -14,6 +14,7 @@ "resource_name", "resource_policy_uri", "show_protected_resource_metadata", + "show_social_login_key_as_authorization_server", "column_break_zyte", "resource_documentation", "resource_tos_uri", @@ -70,7 +71,7 @@ }, { "default": "1", - "description": "Allows clients to fetch metadata from the /.well-known/oauth-protected-resource endpoint.", + "description": "Allows clients to fetch metadata from the /.well-known/oauth-protected-resource endpoint. Reference: RFC9728", "fieldname": "show_protected_resource_metadata", "fieldtype": "Check", "label": "Show Protected Resource Metadata" @@ -94,13 +95,20 @@ "fieldname": "skip_authorization", "fieldtype": "Check", "label": "Skip Authorization" + }, + { + "default": "0", + "description": "Allows enabled Social Login Key Base URL to be shown as authorization server.", + "fieldname": "show_social_login_key_as_authorization_server", + "fieldtype": "Check", + "label": "Show Social Login Key as Authorization Server" } ], "grid_page_length": 50, "index_web_pages_for_search": 1, "issingle": 1, "links": [], - "modified": "2025-07-03 14:29:35.314601", + "modified": "2025-07-04 11:20:15.528611", "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 8b2aafd072..afffb32226 100644 --- a/frappe/integrations/doctype/oauth_settings/oauth_settings.py +++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.py @@ -22,6 +22,7 @@ class OAuthSettings(Document): scopes_supported: DF.SmallText | None show_auth_server_metadata: DF.Check show_protected_resource_metadata: DF.Check + show_social_login_key_as_authorization_server: DF.Check skip_authorization: DF.Check # end: auto-generated types diff --git a/frappe/integrations/oauth2.py b/frappe/integrations/oauth2.py index 07a2c28e14..06329b88eb 100644 --- a/frappe/integrations/oauth2.py +++ b/frappe/integrations/oauth2.py @@ -405,23 +405,26 @@ def get_protected_resource_metadata(): 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", - ) + oauth_settings = cast(OAuthSettings, frappe.get_cached_doc("OAuth Settings", ignore_permissions=True)) resource = get_resource_url() - oauth_settings = cast(OAuthSettings, frappe.get_cached_doc("OAuth Settings")) + authorization_servers = [resource] + + if oauth_settings.show_social_login_key_as_authorization_server: + authorization_servers.extend( + frappe.get_list( + "Social Login Key", + filters={ + "enable_social_login": True, + "show_in_resource_metadata": True, + }, + pluck="base_url", + ignore_permissions=True, + ) + ) metadata = dict( resource=resource, - authorization_servers=[resource, *authorization_servers], + authorization_servers=authorization_servers, bearer_methods_supported=["header"], resource_name=oauth_settings.resource_name, resource_documentation=oauth_settings.resource_documentation, From db4a7504e5335a7c44e5a4e1c92beb6fd865710d Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Fri, 4 Jul 2025 13:17:23 +0530 Subject: [PATCH 10/12] fix: add hooks to handle cors --- frappe/app.py | 8 +-- frappe/hooks.py | 1 + .../oauth_settings/oauth_settings.json | 50 ++++++++++++++--- .../doctype/oauth_settings/oauth_settings.py | 1 + frappe/integrations/oauth2.py | 53 ++++++++++++++++++- frappe/integrations/utils.py | 2 +- 6 files changed, 101 insertions(+), 14 deletions(-) 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 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 11/12] 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 From 3eab2b73b79a8936624b95780730f02aa05a73b3 Mon Sep 17 00:00:00 2001 From: 18alantom <2.alan.tom@gmail.com> Date: Fri, 4 Jul 2025 15:24:44 +0530 Subject: [PATCH 12/12] chore: update readme --- frappe/integrations/README.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frappe/integrations/README.md b/frappe/integrations/README.md index 211614e531..864665c25f 100644 --- a/frappe/integrations/README.md +++ b/frappe/integrations/README.md @@ -59,4 +59,12 @@ The settings allow toggling the following features: - **Dynamic Client Registration**: by toggling the _Enable Dynamic Client Registration_ field. - **Resource Server Metadata Discovery**: by toggling the _Show Protected Resource Metadata_. -The remaining fields (in the **Resource Server** section) are used only when responding to requests on `/.well-known/oauth-protected-resource` +The remaining fields (in the **Resource** section) are used only when responding to requests on `/.well-known/oauth-protected-resource` + +> **Regarding Public Clients** +> +> Public clients, for example an SPA, have restricted access by default. This +> restriction is applied by use of CORS. +> +> To side-step this restriction for certain trusted clients, you may add their +> hostnames to the **Allowed Public Client Origins** field.