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.