diff --git a/frappe/app.py b/frappe/app.py
index 81194d7606..e5a1fba4a7 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 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
@@ -125,6 +126,9 @@ 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/") and request.method == "GET":
+ response = handle_wellknown(request.path)
+
elif request.method in ("GET", "HEAD", "POST"):
response = get_response()
@@ -255,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)
@@ -268,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
@@ -302,6 +311,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/hooks.py b/frappe/hooks.py
index 0340e891d3..bebb1f7e73 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"
@@ -417,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/README.md b/frappe/integrations/README.md
new file mode 100644
index 0000000000..864665c25f
--- /dev/null
+++ b/frappe/integrations/README.md
@@ -0,0 +1,70 @@
+# Integrations
+
+## OAuth 2
+
+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.
+3. **Client**: app that requires access to some resource on a resource server.
+
+DocTypes pertaining to the above roles:
+
+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. `[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**.
+
+### 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. [RFC9728](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** 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.
diff --git a/frappe/integrations/doctype/oauth_client/oauth_client.json b/frappe/integrations/doctype/oauth_client/oauth_client.json
index e60cc1f5f1..a41d62ac4d 100644
--- a/frappe/integrations/doctype/oauth_client/oauth_client.json
+++ b/frappe/integrations/doctype/oauth_client/oauth_client.json
@@ -7,19 +7,29 @@
"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",
+ "token_endpoint_auth_method",
"cb_2",
"response_type"
],
@@ -27,13 +37,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 +60,7 @@
{
"fieldname": "client_secret",
"fieldtype": "Data",
- "label": "App Client Secret",
+ "label": "Client Secret",
"read_only": 1
},
{
@@ -60,10 +70,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 +78,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 +123,85 @@
"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"
+ },
+ {
+ "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": "2024-04-29 12:07:07.946980",
+ "modified": "2025-07-04 14:07:36.146393",
"modified_by": "Administrator",
"module": "Integrations",
"name": "OAuth Client",
@@ -143,6 +220,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..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
@@ -21,12 +25,20 @@ 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
+ 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
@@ -55,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_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/__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..713bb04a9a
--- /dev/null
+++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.json
@@ -0,0 +1,166 @@
+{
+ "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_tab",
+ "authorization_server_section",
+ "show_auth_server_metadata",
+ "skip_authorization",
+ "column_break_ogmd",
+ "enable_dynamic_client_registration",
+ "allowed_public_client_origins",
+ "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",
+ "column_break_zyte",
+ "resource_documentation",
+ "resource_tos_uri",
+ "scopes_supported"
+ ],
+ "fields": [
+ {
+ "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": "Metadata"
+ },
+ {
+ "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"
+ },
+ {
+ "default": "1",
+ "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"
+ },
+ {
+ "default": "1",
+ "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"
+ },
+ {
+ "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. Reference: RFC7591",
+ "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"
+ },
+ {
+ "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"
+ },
+ {
+ "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_public_client_origins",
+ "fieldtype": "Small Text",
+ "label": "Allowed Public Client Origins"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "issingle": 1,
+ "links": [],
+ "modified": "2025-07-04 15:01:45.453238",
+ "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..fb7b6e2263
--- /dev/null
+++ b/frappe/integrations/doctype/oauth_settings/oauth_settings.py
@@ -0,0 +1,30 @@
+# 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
+
+ allowed_public_client_origins: DF.SmallText | None
+ 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
+ show_social_login_key_as_authorization_server: DF.Check
+ skip_authorization: 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 59e9f675b6..96224f4c5a 100644
--- a/frappe/integrations/oauth2.py
+++ b/frappe/integrations/oauth2.py
@@ -1,12 +1,22 @@
+import datetime
import json
-from urllib.parse import quote, urlencode
+from typing import Literal, 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
+from werkzeug.exceptions import NotFound
import frappe
-from frappe.integrations.doctype.oauth_provider_settings.oauth_provider_settings import (
+import frappe.utils
+from frappe import oauth
+from frappe.integrations.utils import (
+ OAuth2DynamicClientMetadata,
+ create_new_oauth_client,
get_oauth_settings,
+ validate_dynamic_client_metadata,
)
from frappe.oauth import (
OAuthWebRequestValidator,
@@ -15,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):
@@ -179,17 +197,18 @@ 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",
- "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",
@@ -202,6 +221,7 @@ def openid_configuration():
"id_token_signing_alg_values_supported": ["HS256"],
}
)
+ return response
@frappe.whitelist(allow_guest=True)
@@ -244,3 +264,293 @@ def introspect_token(token=None, token_type_hint=None):
except Exception:
frappe.local.response = frappe._dict({"active": False})
+
+
+def handle_wellknown(path: str):
+ """Path handler for GET requests to /.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") and is_oauth_metadata_enabled(
+ "auth_server"
+ ):
+ return get_authorization_server_metadata()
+
+ if path.startswith("/.well-known/oauth-protected-resource") and is_oauth_metadata_enabled("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
+ """
+
+ response = Response()
+ response.mimetype = "application/json"
+ response.data = frappe.as_json(_get_authorization_server_metadata())
+ frappe.local.allow_cors = "*"
+ 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.
+ """
+
+ issuer = get_resource_url()
+ metadata = dict(
+ issuer=issuer,
+ 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=["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}{ENDPOINTS['revocation_endpoint']}",
+ revocation_endpoint_auth_methods_supported=["client_secret_basic"],
+ 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"):
+ metadata["registration_endpoint"] = f"{issuer}/api/method/frappe.integrations.oauth2.register_client"
+
+ return metadata
+
+
+@frappe.whitelist(allow_guest=True, methods=["POST"])
+def register_client():
+ """
+ Registers an OAuth 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
+
+ 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
+
+ """
+ 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)
+ response_data = {
+ "client_id": doc.client_id,
+ "client_secret": doc.client_secret,
+ "client_id_issued_at": doc.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,
+ }
+
+ 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
+
+
+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
+
+ oauth_settings = cast(OAuthSettings, frappe.get_cached_doc("OAuth Settings", ignore_permissions=True))
+ resource = get_resource_url()
+ 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=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"]):
+ if label not in ["resource", "auth_server"]:
+ return False
+
+ fieldname = "show_auth_server_metadata"
+ if label == "resource":
+ fieldname = "show_protected_resource_metadata"
+
+ return bool(
+ 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]
+
+
+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 (
+ 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_public_client_origins",
+ )
+ 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 bb4f62463b..4f7d27d461 100644
--- a/frappe/integrations/utils.py
+++ b/frappe/integrations/utils.py
@@ -3,12 +3,48 @@
import datetime
import json
+from typing import Any, 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 | None = None
+ 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,73 @@ 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.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 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")
+
+ 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 or "all"
+ 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
+
+ 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
+
+
+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