feat(OAuth2): support RFC 9728

adds OAuth Settings to configure settings pertaining to Frappe used as
an OAuth auth server and resource server
This commit is contained in:
18alantom 2025-07-03 13:10:41 +05:30
parent 4cd8115c4c
commit 1215afdf96
No known key found for this signature in database
GPG key ID: 942F199B7FFF4BF7
9 changed files with 288 additions and 15 deletions

View file

@ -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:

View file

@ -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) {
// },
// });

View file

@ -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 <code>/.well-known/oauth-authorization-server</code> endpoint.",
"fieldname": "show_auth_server_metadata",
"fieldtype": "Check",
"label": "Show Auth Server Metadata"
},
{
"default": "1",
"description": "Allows clients to fetch metadata from the <code>/.well-known/oauth-protected-resource</code> 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 <b>OAuth Client</b> 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": []
}

View file

@ -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

View file

@ -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

View file

@ -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 <code>/.well-known/oauth-protected-resource</code> 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
}
}

View file

@ -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",

View file

@ -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]