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