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:
parent
4cd8115c4c
commit
1215afdf96
9 changed files with 288 additions and 15 deletions
|
|
@ -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:
|
||||
|
|
|
|||
0
frappe/integrations/doctype/oauth_settings/__init__.py
Normal file
0
frappe/integrations/doctype/oauth_settings/__init__.py
Normal 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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
116
frappe/integrations/doctype/oauth_settings/oauth_settings.json
Normal file
116
frappe/integrations/doctype/oauth_settings/oauth_settings.json
Normal 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": []
|
||||
}
|
||||
27
frappe/integrations/doctype/oauth_settings/oauth_settings.py
Normal file
27
frappe/integrations/doctype/oauth_settings/oauth_settings.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue