From dde466be3d8e78d0ca3ea4de1f8da4899a60b4d3 Mon Sep 17 00:00:00 2001 From: "ALB.Leach" Date: Mon, 22 Jul 2024 12:17:18 +0100 Subject: [PATCH] feat: Implement OAuth Backend App Flow for Email Accounts (#27167) * feat: Implement OAuth Backend App Flow for Email Accounts * chore: Reformat to satisfy linter * chore: format Signed-off-by: Akhil Narang --------- Signed-off-by: Akhil Narang Co-authored-by: Akhil Narang --- .../doctype/email_account/email_account.js | 6 ++++- .../doctype/email_account/email_account.json | 18 ++++++++++---- .../doctype/email_account/email_account.py | 14 ++++++++--- .../doctype/connected_app/connected_app.py | 24 +++++++++++++++++++ 4 files changed, 53 insertions(+), 9 deletions(-) diff --git a/frappe/email/doctype/email_account/email_account.js b/frappe/email/doctype/email_account/email_account.js index 3a0baa9564..c0632f7ad5 100644 --- a/frappe/email/doctype/email_account/email_account.js +++ b/frappe/email/doctype/email_account/email_account.js @@ -199,7 +199,11 @@ frappe.ui.form.on("Email Account", { }, show_oauth_authorization_message(frm) { - if (frm.doc.auth_method === "OAuth" && frm.doc.connected_app) { + if ( + frm.doc.auth_method === "OAuth" && + frm.doc.connected_app && + !frm.doc.backend_app_flow + ) { frappe.call({ method: "frappe.integrations.doctype.connected_app.connected_app.has_token", args: { diff --git a/frappe/email/doctype/email_account/email_account.json b/frappe/email/doctype/email_account/email_account.json index dd244285c1..cca7cc7165 100644 --- a/frappe/email/doctype/email_account/email_account.json +++ b/frappe/email/doctype/email_account/email_account.json @@ -18,6 +18,7 @@ "frappe_mail_site", "authentication_column", "auth_method", + "backend_app_flow", "authorize_api_access", "validate_frappe_mail_settings", "password", @@ -99,7 +100,7 @@ }, { "default": "0", - "depends_on": "eval: doc.service != \"Frappe Mail\"", + "depends_on": "eval: doc.service != \"Frappe Mail\" && !doc.backend_app_flow", "fieldname": "login_id_is_different", "fieldtype": "Check", "hide_days": 1, @@ -581,7 +582,7 @@ "label": "IMAP Details" }, { - "depends_on": "eval: doc.auth_method === \"OAuth\" && doc.connected_app && doc.connected_user", + "depends_on": "eval: doc.auth_method === \"OAuth\" && doc.connected_app && doc.connected_user && !doc.backend_app_flow", "fieldname": "authorize_api_access", "fieldtype": "Button", "label": "Authorize API Access" @@ -610,11 +611,11 @@ "options": "Connected App" }, { - "depends_on": "eval: doc.auth_method === \"OAuth\"", + "depends_on": "eval: doc.auth_method === \"OAuth\" && !doc.backend_app_flow", "fieldname": "connected_user", "fieldtype": "Link", "label": "Connected User", - "mandatory_depends_on": "eval: doc.auth_method === \"OAuth\"", + "mandatory_depends_on": "eval: doc.auth_method === \"OAuth\" && !doc.backend_app_flow", "options": "User" }, { @@ -684,12 +685,19 @@ "fieldtype": "Password", "label": "API Secret", "mandatory_depends_on": "eval: doc.service == \"Frappe Mail\" && doc.auth_method == \"Basic\"" + }, + { + "default": "0", + "depends_on": "eval: doc.auth_method === \"OAuth\"", + "fieldname": "backend_app_flow", + "fieldtype": "Check", + "label": "Authenticate as Service Principal" } ], "icon": "fa fa-inbox", "index_web_pages_for_search": 1, "links": [], - "modified": "2024-06-28 08:45:43.565934", + "modified": "2024-07-18 11:05:57.193762", "modified_by": "Administrator", "module": "Email", "name": "Email Account", diff --git a/frappe/email/doctype/email_account/email_account.py b/frappe/email/doctype/email_account/email_account.py index aac4a97587..cee8b21e3f 100755 --- a/frappe/email/doctype/email_account/email_account.py +++ b/frappe/email/doctype/email_account/email_account.py @@ -71,6 +71,7 @@ class EmailAccount(Document): auth_method: DF.Literal["Basic", "OAuth"] auto_reply_message: DF.TextEditor | None awaiting_password: DF.Check + backend_app_flow: DF.Check brand_logo: DF.AttachImage | None connected_app: DF.Link | None connected_user: DF.Link | None @@ -780,7 +781,12 @@ class EmailAccount(Document): def get_oauth_token(self): if self.auth_method == "OAuth": connected_app = frappe.get_doc("Connected App", self.connected_app) - return connected_app.get_active_token(self.connected_user) + if self.backend_app_flow: + token = connected_app.get_backend_app_token() + else: + token = connected_app.get_active_token(self.connected_user) + + return token @frappe.whitelist() @@ -879,8 +885,10 @@ def pull(now=False): ) for email_account in email_accounts: - if email_account.auth_method == "OAuth" and not has_token( - email_account.connected_app, email_account.connected_user + if ( + email_account.auth_method == "OAuth" + and not email_account.backend_app_flow + and not has_token(email_account.connected_app, email_account.connected_user) ): # don't try to pull from accounts which dont have access token (for Oauth) continue diff --git a/frappe/integrations/doctype/connected_app/connected_app.py b/frappe/integrations/doctype/connected_app/connected_app.py index 07f5c10b01..0a98869aab 100644 --- a/frappe/integrations/doctype/connected_app/connected_app.py +++ b/frappe/integrations/doctype/connected_app/connected_app.py @@ -4,6 +4,7 @@ import os from urllib.parse import urlencode, urljoin +from oauthlib.oauth2 import BackendApplicationClient from requests_oauthlib import OAuth2Session import frappe @@ -147,6 +148,29 @@ class ConnectedApp(Document): return token_cache + def get_backend_app_token(self): + """Get an Access Token for the Cloud-Registered Service Principal""" + # There is no User assigned to the app, so we give it an empty string, + # otherwise it will assign the logged in user. + token_cache = self.get_token_cache("") + if token_cache is None: + token_cache = frappe.new_doc("Token Cache") + token_cache.connected_app = self.name + elif not token_cache.is_expired(): + return token_cache + + # Get a new Access token for the App + client = BackendApplicationClient(client_id=self.client_id, scope=self.get_scopes()) + oauth_session = OAuth2Session(client=client) + + token = oauth_session.fetch_token(self.token_uri, client_secret=self.get_password("client_secret")) + + token_cache.update_data(token) + token_cache.save(ignore_permissions=True) + frappe.db.commit() + + return token_cache + @frappe.whitelist(methods=["GET"], allow_guest=True) def callback(code=None, state=None):