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