Merge pull request #33188 from frappe/oauth2-updates
feat: OAuth2 updates, adds RFC 8414, 7591, 9278
This commit is contained in:
commit
b3b845f022
15 changed files with 876 additions and 44 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
70
frappe/integrations/README.md
Normal file
70
frappe/integrations/README.md
Normal 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.
|
||||
|
|
@ -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": [],
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
)
|
||||
|
|
|
|||
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) {
|
||||
|
||||
// },
|
||||
// });
|
||||
166
frappe/integrations/doctype/oauth_settings/oauth_settings.json
Normal file
166
frappe/integrations/doctype/oauth_settings/oauth_settings.json
Normal 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": []
|
||||
}
|
||||
30
frappe/integrations/doctype/oauth_settings/oauth_settings.py
Normal file
30
frappe/integrations/doctype/oauth_settings/oauth_settings.py
Normal 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
|
||||
|
|
@ -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,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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue