fix: support public client
This commit is contained in:
parent
db4a7504e5
commit
c4f2335f11
6 changed files with 96 additions and 33 deletions
|
|
@ -29,6 +29,7 @@
|
|||
"policy_uri",
|
||||
"sb_advanced",
|
||||
"grant_type",
|
||||
"token_endpoint_auth_method",
|
||||
"cb_2",
|
||||
"response_type"
|
||||
],
|
||||
|
|
@ -188,11 +189,19 @@
|
|||
"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": "2025-07-02 11:52:00.978956",
|
||||
"modified": "2025-07-04 14:07:36.146393",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Integrations",
|
||||
"name": "OAuth Client",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -33,6 +37,7 @@ class OAuthClient(Document):
|
|||
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
|
||||
|
|
@ -62,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())
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
"skip_authorization",
|
||||
"column_break_ogmd",
|
||||
"enable_dynamic_client_registration",
|
||||
"allowed_origins_for_public_client_registration",
|
||||
"allowed_public_client_origins",
|
||||
"resource_tab",
|
||||
"config_section",
|
||||
"show_protected_resource_metadata",
|
||||
|
|
@ -133,16 +133,16 @@
|
|||
},
|
||||
{
|
||||
"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_origins_for_public_client_registration",
|
||||
"fieldname": "allowed_public_client_origins",
|
||||
"fieldtype": "Small Text",
|
||||
"label": "Allowed Origins for Public Client Registration"
|
||||
"label": "Allowed Public Client Origins"
|
||||
}
|
||||
],
|
||||
"grid_page_length": 50,
|
||||
"index_web_pages_for_search": 1,
|
||||
"issingle": 1,
|
||||
"links": [],
|
||||
"modified": "2025-07-04 12:05:50.723018",
|
||||
"modified": "2025-07-04 15:01:45.453238",
|
||||
"modified_by": "Administrator",
|
||||
"module": "Integrations",
|
||||
"name": "OAuth Settings",
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ class OAuthSettings(Document):
|
|||
if TYPE_CHECKING:
|
||||
from frappe.types import DF
|
||||
|
||||
allowed_origins_for_public_client_registration: DF.SmallText | None
|
||||
allowed_public_client_origins: DF.SmallText | None
|
||||
enable_dynamic_client_registration: DF.Check
|
||||
resource_documentation: DF.Data | None
|
||||
resource_name: DF.Data | None
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ from werkzeug import Response
|
|||
from werkzeug.exceptions import NotFound
|
||||
|
||||
import frappe
|
||||
import frappe.utils
|
||||
from frappe import oauth
|
||||
from frappe.integrations.utils import (
|
||||
OAuth2DynamicClientMetadata,
|
||||
|
|
@ -24,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):
|
||||
|
|
@ -195,11 +204,11 @@ def get_openid_configuration():
|
|||
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",
|
||||
|
|
@ -303,17 +312,18 @@ def _get_authorization_server_metadata():
|
|||
issuer = get_resource_url()
|
||||
metadata = dict(
|
||||
issuer=issuer,
|
||||
authorization_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.authorize",
|
||||
token_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.get_token",
|
||||
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=["client_secret_basic"],
|
||||
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}/api/method/frappe.integrations.oauth2.revoke_token",
|
||||
revocation_endpoint=f"{issuer}{ENDPOINTS['revocation_endpoint']}",
|
||||
revocation_endpoint_auth_methods_supported=["client_secret_basic"],
|
||||
introspection_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.introspect_token",
|
||||
userinfo_endpoint=f"{issuer}/api/method/frappe.integrations.oauth2.openid_profile",
|
||||
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"):
|
||||
|
|
@ -354,21 +364,27 @@ def register_client():
|
|||
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)
|
||||
if isinstance(doc.creation, datetime.datetime):
|
||||
client_id_issued_at = doc.creation.isoformat()
|
||||
else:
|
||||
client_id_issued_at = doc.creation
|
||||
|
||||
response_data = {
|
||||
"client_id": doc.client_id,
|
||||
"client_secret": doc.client_secret,
|
||||
"client_id_issued_at": client_id_issued_at,
|
||||
"client_id_issued_at": doc.client_id_issued_at(),
|
||||
"client_secret_expires_at": 0,
|
||||
# Response should include registered metadata
|
||||
"client_name": doc.app_name,
|
||||
|
|
@ -385,7 +401,11 @@ def register_client():
|
|||
"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
|
||||
|
||||
|
|
@ -499,20 +519,32 @@ def set_cors_for_privileged_requests():
|
|||
return
|
||||
|
||||
if (
|
||||
not frappe.request.path.startswith("/api/method/frappe.integrations.oauth2.register_client")
|
||||
or frappe.request.method not in ("POST", "OPTIONS")
|
||||
or not frappe.get_cached_value(
|
||||
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_origins_for_public_client_registration",
|
||||
"allowed_public_client_origins",
|
||||
)
|
||||
if not allowed:
|
||||
return
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ class OAuth2DynamicClientMetadata(BaseModel):
|
|||
|
||||
# Client identifiers shown to user
|
||||
client_name: str
|
||||
scope: str
|
||||
scope: str | None = None
|
||||
client_uri: HttpUrl | None = None
|
||||
logo_uri: HttpUrl | None = None
|
||||
|
||||
|
|
@ -207,11 +207,8 @@ def validate_dynamic_client_metadata(client: OAuth2DynamicClientMetadata):
|
|||
if len(client.redirect_uris) == 0:
|
||||
invalidation_reasons.append("redirect_uris is required")
|
||||
|
||||
if client.token_endpoint_auth_method not in ["client_secret_basic"]:
|
||||
invalidation_reasons.append("only client_secret_basic token_endpoint_auth_method is supported")
|
||||
|
||||
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")
|
||||
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")
|
||||
|
|
@ -230,7 +227,7 @@ def create_new_oauth_client(client: OAuth2DynamicClientMetadata):
|
|||
redirect_uris = [str(uri) for uri in client.redirect_uris]
|
||||
|
||||
doc.app_name = client.client_name
|
||||
doc.scopes = client.scope
|
||||
doc.scopes = client.scope or "all"
|
||||
doc.redirect_uris = "\n".join(redirect_uris)
|
||||
doc.default_redirect_uri = redirect_uris[0]
|
||||
doc.response_type = "Code"
|
||||
|
|
@ -252,6 +249,11 @@ def create_new_oauth_client(client: OAuth2DynamicClientMetadata):
|
|||
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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue