seitime-frappe/frappe/integrations/oauth2.py
Akhil Narang 6ca4d4d167
refactor(treewide): ruff format
Signed-off-by: Akhil Narang <me@akhilnarang.dev>
2025-09-26 13:16:43 +05:30

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