Merge pull request #33188 from frappe/oauth2-updates

feat: OAuth2 updates, adds RFC 8414, 7591, 9278
This commit is contained in:
Alan 2025-07-04 16:21:12 +05:30 committed by GitHub
commit b3b845f022
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 876 additions and 44 deletions

View file

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

View file

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

View file

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

View file

@ -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.<br> 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<br>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<br>\n<b>Should remain same</b> 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<br>\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": [],

View file

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

View file

@ -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")}
)

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,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 <code>/.well-known/oauth-authorization-server</code> endpoint. Reference: <a href=\"https://datatracker.ietf.org/doc/html/rfc8414\">RFC8414</a>",
"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. Reference: <a href=\"https://datatracker.ietf.org/doc/html/rfc9728#name-protected-resource-metadata\">RFC9728</a>",
"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. Reference: <a href=\"https://datatracker.ietf.org/doc/html/rfc7591\">RFC7591</a>",
"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 <code>https://frappe.io</code>), or <code>*</code> to accept all.\n<br>\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": []
}

View file

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

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

View file

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