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 <me@akhilnarang.dev>

---------

Signed-off-by: Akhil Narang <me@akhilnarang.dev>
Co-authored-by: Akhil Narang <me@akhilnarang.dev>
This commit is contained in:
ALB.Leach 2024-07-22 12:17:18 +01:00 committed by GitHub
parent 82fd0f012a
commit dde466be3d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 53 additions and 9 deletions

View file

@ -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: {

View file

@ -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",

View file

@ -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

View file

@ -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):