556 lines
16 KiB
Python
556 lines
16 KiB
Python
import datetime
|
|
import json
|
|
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
|
|
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,
|
|
generate_json_error_response,
|
|
get_server_url,
|
|
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):
|
|
oauth_validator = OAuthWebRequestValidator()
|
|
frappe.local.oauth_server = WebApplicationServer(oauth_validator)
|
|
|
|
return frappe.local.oauth_server
|
|
|
|
|
|
def sanitize_kwargs(param_kwargs):
|
|
"""Remove 'data' and 'cmd' keys, if present."""
|
|
arguments = param_kwargs
|
|
arguments.pop("data", None)
|
|
arguments.pop("cmd", None)
|
|
|
|
return arguments
|
|
|
|
|
|
def encode_params(params):
|
|
"""
|
|
Encode a dict of params into a query string.
|
|
|
|
Use `quote_via=urllib.parse.quote` so that whitespaces will be encoded as
|
|
`%20` instead of as `+`. This is needed because oauthlib cannot handle `+`
|
|
as a whitespace.
|
|
"""
|
|
return urlencode(params, quote_via=quote)
|
|
|
|
|
|
@frappe.whitelist()
|
|
def approve(*args, **kwargs):
|
|
r = frappe.request
|
|
|
|
try:
|
|
(
|
|
scopes,
|
|
frappe.flags.oauth_credentials,
|
|
) = get_oauth_server().validate_authorization_request(r.url, r.method, r.get_data(), r.headers)
|
|
|
|
headers, _body, _status = get_oauth_server().create_authorization_response(
|
|
uri=frappe.flags.oauth_credentials["redirect_uri"],
|
|
body=r.get_data(),
|
|
headers=r.headers,
|
|
scopes=scopes,
|
|
credentials=frappe.flags.oauth_credentials,
|
|
)
|
|
uri = headers.get("Location", None)
|
|
|
|
frappe.local.response["type"] = "redirect"
|
|
frappe.local.response["location"] = uri
|
|
return
|
|
|
|
except (FatalClientError, OAuth2Error) as e:
|
|
return generate_json_error_response(e)
|
|
|
|
|
|
@frappe.whitelist(allow_guest=True)
|
|
def authorize(**kwargs):
|
|
success_url = "/api/method/frappe.integrations.oauth2.approve?" + encode_params(sanitize_kwargs(kwargs))
|
|
failure_url = frappe.form_dict.get("redirect_uri", "") + "?error=access_denied"
|
|
|
|
if frappe.session.user == "Guest":
|
|
# Force login, redirect to preauth again.
|
|
frappe.local.response["type"] = "redirect"
|
|
frappe.local.response["location"] = "/login?" + encode_params({"redirect-to": frappe.request.url})
|
|
else:
|
|
try:
|
|
r = frappe.request
|
|
(
|
|
scopes,
|
|
frappe.flags.oauth_credentials,
|
|
) = get_oauth_server().validate_authorization_request(r.url, r.method, r.get_data(), r.headers)
|
|
|
|
skip_auth = frappe.db.get_value(
|
|
"OAuth Client",
|
|
frappe.flags.oauth_credentials["client_id"],
|
|
"skip_authorization",
|
|
)
|
|
unrevoked_tokens = frappe.db.exists(
|
|
"OAuth Bearer Token", {"status": "Active", "user": frappe.session.user}
|
|
)
|
|
|
|
if skip_auth or (get_oauth_settings().skip_authorization == "Auto" and unrevoked_tokens):
|
|
frappe.local.response["type"] = "redirect"
|
|
frappe.local.response["location"] = success_url
|
|
else:
|
|
if "openid" in scopes:
|
|
scopes.remove("openid")
|
|
scopes.extend(["Full Name", "Email", "User Image", "Roles"])
|
|
|
|
# Show Allow/Deny screen.
|
|
response_html_params = frappe._dict(
|
|
{
|
|
"client_id": frappe.db.get_value("OAuth Client", kwargs["client_id"], "app_name"),
|
|
"success_url": success_url,
|
|
"failure_url": failure_url,
|
|
"details": scopes,
|
|
}
|
|
)
|
|
resp_html = frappe.render_template(
|
|
"templates/includes/oauth_confirmation.html", response_html_params
|
|
)
|
|
frappe.respond_as_web_page(frappe._("Confirm Access"), resp_html, primary_action=None)
|
|
except (FatalClientError, OAuth2Error) as e:
|
|
return generate_json_error_response(e)
|
|
|
|
|
|
@frappe.whitelist(allow_guest=True)
|
|
def get_token(*args, **kwargs):
|
|
try:
|
|
r = frappe.request
|
|
_headers, body, _status = get_oauth_server().create_token_response(
|
|
r.url, r.method, r.form, r.headers, frappe.flags.oauth_credentials
|
|
)
|
|
body = frappe._dict(json.loads(body))
|
|
|
|
if body.error:
|
|
frappe.local.response = body
|
|
frappe.local.response["http_status_code"] = 400
|
|
return
|
|
|
|
frappe.local.response = body
|
|
return
|
|
|
|
except (FatalClientError, OAuth2Error) as e:
|
|
return generate_json_error_response(e)
|
|
|
|
|
|
@frappe.whitelist(allow_guest=True)
|
|
def revoke_token(*args, **kwargs):
|
|
try:
|
|
r = frappe.request
|
|
_headers, _body, status = get_oauth_server().create_revocation_response(
|
|
r.url,
|
|
headers=r.headers,
|
|
body=r.form,
|
|
http_method=r.method,
|
|
)
|
|
except (FatalClientError, OAuth2Error):
|
|
pass
|
|
|
|
# status_code must be 200
|
|
frappe.local.response = frappe._dict({})
|
|
frappe.local.response["http_status_code"] = status or 200
|
|
return
|
|
|
|
|
|
@frappe.whitelist()
|
|
def openid_profile(*args, **kwargs):
|
|
try:
|
|
r = frappe.request
|
|
_headers, body, _status = get_oauth_server().create_userinfo_response(
|
|
r.url,
|
|
headers=r.headers,
|
|
body=r.form,
|
|
)
|
|
body = frappe._dict(json.loads(body))
|
|
frappe.local.response = body
|
|
return
|
|
|
|
except (FatalClientError, OAuth2Error) as e:
|
|
return generate_json_error_response(e)
|
|
|
|
|
|
def get_openid_configuration():
|
|
response = Response()
|
|
response.mimetype = "application/json"
|
|
frappe_server_url = get_server_url()
|
|
response.data = frappe.as_json(
|
|
{
|
|
"issuer": frappe_server_url,
|
|
"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",
|
|
"code id_token",
|
|
"code token id_token",
|
|
"id_token",
|
|
"id_token token",
|
|
],
|
|
"subject_types_supported": ["public"],
|
|
"id_token_signing_alg_values_supported": ["HS256"],
|
|
}
|
|
)
|
|
return response
|
|
|
|
|
|
@frappe.whitelist(allow_guest=True)
|
|
def introspect_token(token=None, token_type_hint=None):
|
|
if token_type_hint not in ["access_token", "refresh_token"]:
|
|
token_type_hint = "access_token"
|
|
try:
|
|
bearer_token = None
|
|
if token_type_hint == "access_token":
|
|
bearer_token = frappe.get_doc("OAuth Bearer Token", {"access_token": token})
|
|
elif token_type_hint == "refresh_token":
|
|
bearer_token = frappe.get_doc("OAuth Bearer Token", {"refresh_token": token})
|
|
|
|
client = frappe.get_doc("OAuth Client", bearer_token.client)
|
|
|
|
token_response = frappe._dict(
|
|
{
|
|
"client_id": client.client_id,
|
|
"trusted_client": client.skip_authorization,
|
|
"active": bearer_token.status == "Active",
|
|
"exp": round(bearer_token.expiration_time.timestamp()),
|
|
"scope": bearer_token.scopes,
|
|
}
|
|
)
|
|
|
|
if "openid" in bearer_token.scopes:
|
|
sub = frappe.get_value(
|
|
"User Social Login",
|
|
{"provider": "frappe", "parent": bearer_token.user},
|
|
"userid",
|
|
)
|
|
|
|
if sub:
|
|
token_response.update({"sub": sub})
|
|
user = frappe.get_doc("User", bearer_token.user)
|
|
userinfo = get_userinfo(user)
|
|
token_response.update(userinfo)
|
|
|
|
frappe.local.response = token_response
|
|
|
|
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
|