diff --git a/central/api/email_oauth.py b/central/api/email_oauth.py new file mode 100644 index 000000000..a65816f0a --- /dev/null +++ b/central/api/email_oauth.py @@ -0,0 +1,107 @@ +"""Connect a mailbox in one click on a V2 site, for any OAuth mail provider in PROVIDERS. + +Central holds Frappe Cloud's app for each provider; a site reaches `start` and `relay_token` through its Pilot. +""" + +from urllib.parse import urlencode + +import frappe +from frappe import _ + +from central.api.pilot import pilot_credential_auth +from central.iam import can +from central.integrations.email_oauth.email_oauth import ( + CALLBACK_PATH, + claim_code, + exchange_token, + forget_sign_in, + get_sign_in, + mark_login_sent, + remember_code_owner, + start_sign_in, +) + +LOGIN_PATH = "/dashboard/login" + + +# nosemgrep: guest-whitelisted-method -- pilot_credential_auth verifies the caller below. +@frappe.whitelist(allow_guest=True, methods=["POST"]) +@pilot_credential_auth +def start(provider: str, state: str, code_challenge: str, return_url: str) -> dict: + """Called by a site through its Pilot: remember where to send the browser back, return the provider's sign-in link.""" + credential = frappe.local.pilot_credential + return start_sign_in(provider, state, code_challenge, return_url, credential.team, credential.server) + + +# nosemgrep: guest-whitelisted-method -- the provider sends the browser here; the code only goes to the site's team. +@frappe.whitelist(allow_guest=True, methods=["GET"]) +def callback( + state: str | None = None, + code: str | None = None, + error: str | None = None, + error_description: str | None = None, +) -> None: + """Every provider sends the browser here. Single use: the note is deleted on the first read.""" + note = get_sign_in(state) + if not note: + frappe.respond_as_web_page( + _("Link expired"), + _("This sign-in has expired or was already used. Please start again from your site."), + http_status_code=400, + primary_action=None, + ) + return + # a logged-out browser signs in to Central once, then comes back here with the same code (D3) + if code and not error and frappe.session.user == "Guest" and not note.get("login_sent"): + mark_login_sent(state, note) + back = add_query_params(CALLBACK_PATH, state=state, code=code) + redirect(add_query_params(LOGIN_PATH, **{"redirect-to": back})) + return + forget_sign_in(state) + redirect( + add_query_params(note["return_url"], state=state, **site_params(note, code, error, error_description)) + ) + + +def site_params(note: dict, code: str | None, error: str | None, error_description: str | None) -> dict: + if error or not code: + return {"error": error or "invalid_request", "error_description": error_description} + # the code goes only to the site's team, so a stranger's site never gets it (consent phishing) + if can(frappe.session.user, note["team"], "server:view", server=note["server"]): + remember_code_owner(code, note["team"], note["server"]) + return {"code": code} + return {"error": "not_team_member"} + + +def redirect(location: str) -> None: + frappe.local.response.type = "redirect" + frappe.local.response.location = location + + +def add_query_params(url: str, **params: str | None) -> str: + return f"{url}{'&' if '?' in url else '?'}{urlencode({k: v for k, v in params.items() if v})}" + + +# nosemgrep: guest-whitelisted-method -- pilot_credential_auth verifies the caller below. +@frappe.whitelist(allow_guest=True, methods=["POST"]) +@pilot_credential_auth +def relay_token( + provider: str, + grant_type: str, + code: str | None = None, + code_verifier: str | None = None, + refresh_token: str | None = None, + scope: str | None = None, +) -> dict: + """Called by a site through its Pilot: swap a code or refresh token at the provider using Frappe Cloud's app secret.""" + if grant_type == "authorization_code": # initial exchange of the authorization code for a token + credential = frappe.local.pilot_credential + claim_code(code, credential.team, credential.server) + return exchange_token( + provider, + grant_type, + code=code, + code_verifier=code_verifier, + refresh_token=refresh_token, + scope=scope, + ) diff --git a/central/central/doctype/email_oauth_provider/__init__.py b/central/central/doctype/email_oauth_provider/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/central/central/doctype/email_oauth_provider/email_oauth_provider.js b/central/central/doctype/email_oauth_provider/email_oauth_provider.js new file mode 100644 index 000000000..b4db6d084 --- /dev/null +++ b/central/central/doctype/email_oauth_provider/email_oauth_provider.js @@ -0,0 +1,8 @@ +// Copyright (c) 2026, frappe and contributors +// For license information, please see license.txt + +// frappe.ui.form.on("Email OAuth Provider", { +// refresh(frm) { + +// }, +// }); diff --git a/central/central/doctype/email_oauth_provider/email_oauth_provider.json b/central/central/doctype/email_oauth_provider/email_oauth_provider.json new file mode 100644 index 000000000..93af7b41a --- /dev/null +++ b/central/central/doctype/email_oauth_provider/email_oauth_provider.json @@ -0,0 +1,122 @@ +{ + "actions": [], + "allow_bulk_edit": 1, + "autoname": "field:provider", + "creation": "2026-10-20 14:50:38.036333", + "doctype": "DocType", + "editable_grid": 1, + "engine": "InnoDB", + "field_order": [ + "enabled", + "provider", + "client_id", + "client_secret", + "column_break_app", + "endpoints_section", + "authorize_url", + "token_url", + "sign_in_section", + "scopes", + "query_parameters" + ], + "fields": [ + { + "description": "The key sites sign in with, e.g. microsoft", + "fieldname": "provider", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Provider", + "reqd": 1, + "set_only_once": 1, + "unique": 1 + }, + { + "default": "0", + "fieldname": "enabled", + "fieldtype": "Check", + "in_list_view": 1, + "label": "Enabled" + }, + { + "fieldname": "column_break_app", + "fieldtype": "Column Break" + }, + { + "fieldname": "client_id", + "fieldtype": "Data", + "label": "Client ID" + }, + { + "fieldname": "client_secret", + "fieldtype": "Password", + "label": "Client Secret" + }, + { + "fieldname": "endpoints_section", + "fieldtype": "Section Break", + "label": "Endpoints" + }, + { + "fieldname": "authorize_url", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Authorize URL", + "options": "URL", + "reqd": 1 + }, + { + "fieldname": "token_url", + "fieldtype": "Data", + "in_list_view": 1, + "label": "Token URL", + "options": "URL", + "reqd": 1 + }, + { + "fieldname": "sign_in_section", + "fieldtype": "Section Break", + "label": "Sign-in" + }, + { + "fieldname": "scopes", + "fieldtype": "Table", + "label": "Scopes", + "options": "OAuth Scope" + }, + { + "description": "Added to the sign-in link", + "fieldname": "query_parameters", + "fieldtype": "Table", + "label": "Query Parameters", + "options": "Query Parameters" + } + ], + "grid_page_length": 50, + "links": [], + "modified": "2026-10-21 00:44:49.250559", + "modified_by": "Administrator", + "module": "Central", + "name": "Email OAuth Provider", + "naming_rule": "By fieldname", + "owner": "Administrator", + "permissions": [ + { + "create": 1, + "delete": 1, + "email": 1, + "export": 1, + "print": 1, + "read": 1, + "report": 1, + "role": "System Manager", + "share": 1, + "write": 1 + } + ], + "row_format": "Dynamic", + "rows_threshold_for_grid_search": 20, + "sort_field": "creation", + "sort_order": "DESC", + "states": [], + "track_changes": 1 +} diff --git a/central/central/doctype/email_oauth_provider/email_oauth_provider.py b/central/central/doctype/email_oauth_provider/email_oauth_provider.py new file mode 100644 index 000000000..e2d7c9475 --- /dev/null +++ b/central/central/doctype/email_oauth_provider/email_oauth_provider.py @@ -0,0 +1,49 @@ +# Copyright (c) 2026, frappe and contributors +# For license information, please see license.txt + +import frappe +from frappe import _ +from frappe.model.document import Document + + +class EmailOAuthProvider(Document): + # begin: auto-generated types + # This code is auto-generated. Do not modify anything in this block. + + from typing import TYPE_CHECKING + + if TYPE_CHECKING: + from frappe.integrations.doctype.oauth_scope.oauth_scope import OAuthScope + from frappe.integrations.doctype.query_parameters.query_parameters import QueryParameters + from frappe.types import DF + + authorize_url: DF.Data + client_id: DF.Data | None + client_secret: DF.Password | None + enabled: DF.Check + provider: DF.Data + query_parameters: DF.Table[QueryParameters] + scopes: DF.Table[OAuthScope] + token_url: DF.Data + # end: auto-generated types + + def validate(self): + for field in ("authorize_url", "token_url"): + if not self.get(field).startswith("https://"): + frappe.throw( + _("{0} must start with https://").format(_(self.meta.get_label(field))), + title=_("Invalid URL"), + ) + if self.enabled and not ( + self.client_id and self.get_password("client_secret", raise_exception=False) + ): + frappe.throw( + _("Add the Client ID and Client Secret before enabling {0}").format(self.provider), + title=_("Missing credentials"), + ) + + def get_scopes(self) -> list[str]: + return [row.scope for row in self.scopes] + + def get_query_params(self) -> dict[str, str]: + return {row.key: row.value for row in self.query_parameters} diff --git a/central/central/sidebar/central/central.json b/central/central/sidebar/central/central.json index e8eca96f0..51caad872 100644 --- a/central/central/sidebar/central/central.json +++ b/central/central/sidebar/central/central.json @@ -666,9 +666,24 @@ "open_in_new_tab": 1, "show_arrow": 0, "type": "Link" + }, + { + "added": 0, + "child": 1, + "collapsible": 1, + "hidden": 0, + "indent": 0, + "is_default_module": 0, + "keep_closed": 0, + "label": "Email OAuth Provider", + "link_to": "Email OAuth Provider", + "link_type": "DocType", + "open_in_new_tab": 1, + "show_arrow": 0, + "type": "Link" } ], - "modified": "2026-10-11 10:00:00.000000", + "modified": "2026-10-20 10:00:00.000000", "modified_by": "Administrator", "module": "Central", "name": "Central", diff --git a/central/hooks.py b/central/hooks.py index ffde07f6d..75fcf4d74 100644 --- a/central/hooks.py +++ b/central/hooks.py @@ -128,6 +128,7 @@ "central.billing.platform.constraints.ensure_constraints", "central.billing.settings.ensure_welcome_credit_amounts", "central.billing.gateways.setup.ensure_gateway_records", + "central.integrations.email_oauth.setup.ensure_email_oauth_providers", "central.billing.navigation.ensure_workspace_sidebars", ] @@ -255,6 +256,7 @@ "central.billing.settings.ensure_snapshot_settings", "central.billing.platform.constraints.ensure_constraints", "central.billing.gateways.setup.ensure_gateway_records", + "central.integrations.email_oauth.setup.ensure_email_oauth_providers", "central.billing.navigation.ensure_workspace_sidebars", ] @@ -269,6 +271,7 @@ "central.billing.platform.constraints.ensure_constraints", "central.billing.settings.ensure_welcome_credit_amounts", "central.billing.gateways.setup.ensure_gateway_records", + "central.integrations.email_oauth.setup.ensure_email_oauth_providers", "central.billing.navigation.ensure_workspace_sidebars", ] diff --git a/central/integrations/email_oauth/__init__.py b/central/integrations/email_oauth/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/central/integrations/email_oauth/email_oauth.py b/central/integrations/email_oauth/email_oauth.py new file mode 100644 index 000000000..62544cfe4 --- /dev/null +++ b/central/integrations/email_oauth/email_oauth.py @@ -0,0 +1,133 @@ +"""Frappe Cloud's OAuth app at each mail provider, and the note that carries one mailbox sign-in.""" + +import hashlib +from typing import TYPE_CHECKING +from urllib.parse import urlsplit + +import frappe +import requests +from frappe import _ +from frappe.utils import get_url +from oauthlib.oauth2 import WebApplicationClient + +from central.infrastructure.doctype.site.site import get_server_hostnames + +if TYPE_CHECKING: + from central.central.doctype.email_oauth_provider.email_oauth_provider import EmailOAuthProvider + +PROVIDER = "Email OAuth Provider" +CALLBACK_PATH = "/api/method/central.api.email_oauth.callback" +SITE_CALLBACK_PATH = "/api/method/frappe.integrations.frappe_providers.email_oauth.callback" +NOTE_TTL = 10 * 60 +# Pilot keeps only an error's text; the site matches this to fall back to its normal mailbox form +PROVIDER_UNAVAILABLE = "provider_unavailable" + + +def start_sign_in( + provider: str, state: str, code_challenge: str, return_url: str, team: str, server: str | None +) -> dict: + """Remember where to send the browser back, and return the provider's sign-in link.""" + config = get_provider(provider) + check_return_url(return_url, team, server) + + frappe.cache.set_value( + note_key(state), + {"return_url": return_url, "team": team, "server": server}, + expires_in_sec=NOTE_TTL, + ) + + scopes = config.get_scopes() + authorize_url = WebApplicationClient(config.client_id).prepare_request_uri( + config.authorize_url, + redirect_uri=get_url(CALLBACK_PATH), + scope=" ".join(scopes), + state=state, + code_challenge=code_challenge, + code_challenge_method="S256", + **config.get_query_params(), + ) + # the site saves these scopes on its Connected App, so it keeps no list of its own + return {"authorize_url": authorize_url, "client_id": config.client_id, "scopes": scopes} + + +def check_return_url(return_url: str, team: str, server: str | None) -> None: + url = urlsplit(return_url) + schemes = ("https", "http") if frappe.conf.developer_mode else ("https",) + if ( + url.scheme in schemes + and "@" not in url.netloc + and "\\" not in return_url + and url.path == SITE_CALLBACK_PATH + and url.hostname in get_server_hostnames(team, server) + ): + return + frappe.throw( + _("Return URL must be the email callback on one of this server's sites"), frappe.PermissionError + ) + + +def get_sign_in(state: str | None) -> dict | None: + return frappe.cache.get_value(note_key(state)) if state else None + + +def mark_login_sent(state: str, note: dict) -> None: + frappe.cache.set_value(note_key(state), {**note, "login_sent": True}, expires_in_sec=NOTE_TTL) + + +def forget_sign_in(state: str) -> None: + frappe.cache.delete_value(note_key(state)) + + +def note_key(state: str) -> str: + return f"email_oauth:{state}" + + +def remember_code_owner(code: str, team: str, server: str | None) -> None: + """Remember which server's sign-in got `code`, so only that server can swap it.""" + frappe.cache.set_value(code_key(code), {"team": team, "server": server}, expires_in_sec=NOTE_TTL) + + +def claim_code(code: str | None, team: str, server: str | None) -> None: + """Single use: a code swaps only for the server whose sign-in got it.""" + key = code_key(code or "") + owner = frappe.cache.get_value(key) + frappe.cache.delete_value(key) + if owner != {"team": team, "server": server}: + frappe.throw(_("This sign-in code was not given to this server"), frappe.PermissionError) + + +def code_key(code: str) -> str: + # hashed, so the cache never holds a code that still works + return f"email_oauth_code:{hashlib.sha256(code.encode()).hexdigest()}" + + +def exchange_token(provider: str, grant_type: str, **params: str | None) -> dict: + """Swap a code or refresh token at the provider with Frappe Cloud's app secret.""" + config = get_provider(provider) + if grant_type not in ("authorization_code", "refresh_token"): + frappe.throw(_("Unsupported grant type")) + + data = { + **params, + "grant_type": grant_type, + "client_id": config.client_id, + "client_secret": config.get_password("client_secret"), + } + if grant_type == "authorization_code": + data["redirect_uri"] = get_url(CALLBACK_PATH) + + response = requests.post(config.token_url, data={k: v for k, v in data.items() if v}, timeout=30) + token = response.json() + if not response.ok: + # Pilot keeps only this text, so the site can still tell invalid_grant apart (standard OAuth error code) + frappe.throw(token.get("error") or _("The mail provider refused the token request")) + return token + + +def get_provider(provider: str) -> "EmailOAuthProvider": + """The enabled record for `provider`; an enabled one always has a client ID and secret.""" + config = frappe.db.exists(PROVIDER, provider) and frappe.get_cached_doc(PROVIDER, provider) + if not (config and config.enabled): + # nosemgrep: frappe-missing-translate-function-python -- a code the site matches on, never shown + frappe.throw(PROVIDER_UNAVAILABLE) + return config diff --git a/central/integrations/email_oauth/setup.py b/central/integrations/email_oauth/setup.py new file mode 100644 index 000000000..f463aec06 --- /dev/null +++ b/central/integrations/email_oauth/setup.py @@ -0,0 +1,59 @@ +"""Seed an Email OAuth Provider record for each provider Frappe Cloud can sign mailboxes in with.""" + +import frappe + +from central.integrations.email_oauth.email_oauth import PROVIDER + +# Seeded disabled and without credentials; an operator adds the app's ID and secret and enables it +DEFAULT_PROVIDERS = { + "microsoft": { + "authorize_url": "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + "token_url": "https://login.microsoftonline.com/common/oauth2/v2.0/token", + "scopes": [ + "offline_access", + "https://outlook.office.com/IMAP.AccessAsUser.All", + "https://outlook.office.com/SMTP.Send", + "openid", + "email", + "profile", + ], + "query_parameters": {"prompt": "select_account"}, + }, + "google": { + "authorize_url": "https://accounts.google.com/o/oauth2/v2/auth", + "token_url": "https://oauth2.googleapis.com/token", + "scopes": ["https://mail.google.com/", "openid", "email", "profile"], + # a refresh token comes back only with offline access, and again only on consent + "query_parameters": {"access_type": "offline", "prompt": "select_account consent"}, + }, + "yahoo": { + "authorize_url": "https://api.login.yahoo.com/oauth2/request_auth", + "token_url": "https://api.login.yahoo.com/oauth2/get_token", + "scopes": ["mail-w", "openid", "email"], + "query_parameters": {}, + }, + "yandex": { + "authorize_url": "https://oauth.yandex.com/authorize", + "token_url": "https://oauth.yandex.com/token", + "scopes": ["mail:imap_full", "mail:smtp", "login:email"], + "query_parameters": {}, + }, +} + + +def ensure_email_oauth_providers() -> None: + """Add a record for each default provider that has none; never touches an existing one.""" + for provider, config in DEFAULT_PROVIDERS.items(): + if frappe.db.exists(PROVIDER, provider): + continue + # Runs from the install, migrate and test hooks, which have no signed-in user + frappe.get_doc( + { + "doctype": PROVIDER, + "provider": provider, + "authorize_url": config["authorize_url"], + "token_url": config["token_url"], + "scopes": [{"scope": scope} for scope in config["scopes"]], + "query_parameters": [{"key": k, "value": v} for k, v in config["query_parameters"].items()], + } + ).insert(ignore_permissions=True) diff --git a/central/tests/test_email_oauth.py b/central/tests/test_email_oauth.py new file mode 100644 index 000000000..1b2b38025 --- /dev/null +++ b/central/tests/test_email_oauth.py @@ -0,0 +1,313 @@ +# Copyright (c) 2026, frappe and Contributors +# See license.txt + +from unittest.mock import Mock, patch +from urllib.parse import parse_qs, urlsplit + +import frappe +from frappe.tests import IntegrationTestCase +from frappe.utils import set_request + +from central.api.email_oauth import callback, relay_token, start +from central.infrastructure.doctype.pilot_credential.pilot_credential import PilotCredential +from central.infrastructure.doctype.virtual_machine.virtual_machine import VirtualMachine +from central.integrations.email_oauth.email_oauth import PROVIDER, PROVIDER_UNAVAILABLE +from central.integrations.email_oauth.setup import DEFAULT_PROVIDERS, ensure_email_oauth_providers +from central.tests.test_iam import ensure_user +from central.tests.utils import ensure_atlas_instance + +ZONE = "oauth.example.test" +SITE_CALLBACK = "/api/method/frappe.integrations.frappe_providers.email_oauth.callback" + + +class TestEmailOAuth(IntegrationTestCase): + def setUp(self): + frappe.set_user("Administrator") + self.enterContext(patch.object(VirtualMachine, "ensure_subscription_enabled")) + frappe.db.set_single_value("Central Settings", "wildcard_domain", "example.test") + ensure_email_oauth_providers() + self.microsoft = frappe.get_doc(PROVIDER, "microsoft") + self.microsoft.update( + { + "client_id": "ms-client-id", + "client_secret": "ms-secret", + "enabled": 1, + "scopes": [{"scope": scope} for scope in DEFAULT_PROVIDERS["microsoft"]["scopes"]], + } + ).save() + + # fixtures roll back only after the whole class, so each test gets its own names + suffix = frappe.generate_hash(length=6) + self.owner = ensure_user("oauth.owner@example.test") + self.team = ( + frappe.get_doc( + { + "doctype": "Team", + "team_name": "OAuth Team", + "owner_user": self.owner, + "members": [{"user": self.owner, "role": "Owner", "status": "Active"}], + } + ) + .insert() + .name + ) + region = ensure_atlas_instance("test-email-oauth", proxy_domain=ZONE) + self.server = ( + frappe.get_doc( + { + "doctype": "Virtual Machine", + "resource_id": f"vm-{suffix}", + "team": self.team, + "region": region, + } + ) + .insert() + .name + ) + self.site = ( + frappe.get_doc( + { + "doctype": "Site", + "site_name": f"acme-{suffix}.{ZONE}", + "team": self.team, + "server": self.server, + } + ) + .insert() + .name + ) + self.token = PilotCredential.mint( + team=self.team, pilot_credential_id=f"oauth-{suffix}", server=self.server + ) + + def as_pilot(self, path: str): + set_request(method="POST", path=path, headers={"X-Pilot-Token": self.token}) + + def call_start(self, return_url: str, state: str = "state-1") -> dict: + self.as_pilot("/api/method/central.api.email_oauth.start") + return start(provider="microsoft", state=state, code_challenge="challenge-1", return_url=return_url) + + def call_callback(self, user: str, **params) -> frappe._dict: + frappe.set_user(user) + frappe.local.response = frappe._dict() + callback(**params) + return frappe.local.response + + # start + + def test_start_returns_microsofts_link_for_this_sign_in(self): + result = self.call_start(f"https://{self.site}{SITE_CALLBACK}") + + query = parse_qs(urlsplit(result["authorize_url"]).query) + self.assertEqual(result["client_id"], "ms-client-id") + self.assertEqual(result["scopes"], DEFAULT_PROVIDERS["microsoft"]["scopes"]) + self.assertEqual(query["state"], ["state-1"]) + self.assertEqual(query["code_challenge"], ["challenge-1"]) + self.assertEqual(query["prompt"], ["select_account"]) + self.assertTrue(query["redirect_uri"][0].endswith("/api/method/central.api.email_oauth.callback")) + note = frappe.cache.get_value("email_oauth:state-1") + self.assertEqual((note["team"], note["server"]), (self.team, self.server)) + + def test_start_refuses_a_return_url_off_this_server(self): + for url in ( + f"https://evil.example.com{SITE_CALLBACK}", + f"https://{self.site}/some/other/path", + f"https://attacker@{self.site}{SITE_CALLBACK}", + ): + with self.subTest(url=url), self.assertRaises(frappe.PermissionError): + self.call_start(url) + + def test_start_refuses_a_provider_without_an_app(self): + self.as_pilot("/api/method/central.api.email_oauth.start") + for provider in ("google", "evil"): + with ( + self.subTest(provider=provider), + self.assertRaisesRegex(frappe.ValidationError, PROVIDER_UNAVAILABLE), + ): + start( + provider=provider, + state="s", + code_challenge="c", + return_url=f"https://{self.site}{SITE_CALLBACK}", + ) + + def test_start_refuses_a_disabled_provider(self): + self.microsoft.reload().update({"enabled": 0}).save() + + with self.assertRaisesRegex(frappe.ValidationError, PROVIDER_UNAVAILABLE): + self.call_start(f"https://{self.site}{SITE_CALLBACK}") + + # Email OAuth Provider + + def test_provider_cannot_be_enabled_without_credentials(self): + doc = frappe.get_doc(PROVIDER, "microsoft").update({"client_id": ""}) + + with self.assertRaises(frappe.ValidationError): + doc.save() + + def test_seeding_leaves_an_edited_provider_alone(self): + self.microsoft.reload().update({"scopes": [{"scope": "openid"}]}).save() + + ensure_email_oauth_providers() + + doc = frappe.get_doc(PROVIDER, "microsoft") + self.assertEqual(doc.get_scopes(), ["openid"]) + self.assertEqual(doc.client_id, "ms-client-id") + + # both Pilot endpoints + + def test_endpoints_need_a_pilot_credential(self): + calls = { + "start": lambda: start( + provider="microsoft", + state="s", + code_challenge="c", + return_url=f"https://{self.site}{SITE_CALLBACK}", + ), + "relay_token": lambda: relay_token( + provider="microsoft", grant_type="refresh_token", refresh_token="old" + ), + } + for name, call in calls.items(): + set_request(method="POST", path=f"/api/method/central.api.email_oauth.{name}") + with self.subTest(endpoint=name), self.assertRaises(frappe.AuthenticationError): + call() + + # callback + + def test_callback_gives_the_code_to_a_team_member(self): + self.call_start(f"https://{self.site}{SITE_CALLBACK}") + + response = self.call_callback(self.owner, state="state-1", code="the-code") + + self.assertEqual(response.type, "redirect") + self.assertEqual(parse_qs(urlsplit(response.location).query)["code"], ["the-code"]) + + def test_callback_refuses_someone_outside_the_team(self): + self.call_start(f"https://{self.site}{SITE_CALLBACK}") + outsider = ensure_user("oauth.outsider@example.test") + + response = self.call_callback(outsider, state="state-1", code="the-code") + + query = parse_qs(urlsplit(response.location).query) + self.assertEqual(query["error"], ["not_team_member"]) + self.assertNotIn("code", query) + + def test_callback_passes_on_a_cancelled_sign_in(self): + self.call_start(f"https://{self.site}{SITE_CALLBACK}") + + response = self.call_callback(self.owner, state="state-1", error="access_denied") + + query = parse_qs(urlsplit(response.location).query) + self.assertEqual(query["error"], ["access_denied"]) + self.assertNotIn("code", query) + + def test_callback_sends_a_logged_out_browser_to_log_in_and_back(self): + self.call_start(f"https://{self.site}{SITE_CALLBACK}") + + response = self.call_callback("Guest", state="state-1", code="the-code") + + login = urlsplit(response.location) + self.assertEqual(login.path, "/dashboard/login") + back = urlsplit(parse_qs(login.query)["redirect-to"][0]) + self.assertEqual(back.path, "/api/method/central.api.email_oauth.callback") + self.assertEqual(parse_qs(back.query), {"state": ["state-1"], "code": ["the-code"]}) + + # back from the login as a team member: the code goes on to the site + response = self.call_callback(self.owner, state="state-1", code="the-code") + self.assertEqual(parse_qs(urlsplit(response.location).query)["code"], ["the-code"]) + + def test_callback_sends_a_browser_to_log_in_only_once(self): + self.call_start(f"https://{self.site}{SITE_CALLBACK}") + self.call_callback("Guest", state="state-1", code="the-code") + + response = self.call_callback("Guest", state="state-1", code="the-code") + + self.assertEqual(parse_qs(urlsplit(response.location).query)["error"], ["not_team_member"]) + self.assertIsNone(frappe.cache.get_value("email_oauth:state-1")) + + def test_callback_passes_on_an_error_without_a_login(self): + self.call_start(f"https://{self.site}{SITE_CALLBACK}") + + response = self.call_callback("Guest", state="state-1", error="access_denied") + + self.assertEqual(parse_qs(urlsplit(response.location).query)["error"], ["access_denied"]) + + def test_callback_works_only_once(self): + self.call_start(f"https://{self.site}{SITE_CALLBACK}") + self.call_callback(self.owner, state="state-1", code="the-code") + + response = self.call_callback(self.owner, state="state-1", code="the-code") + + self.assertNotEqual(response.get("type"), "redirect") + + # relay_token + + def call_relay_token(self, microsoft_status: int, microsoft_body: dict, **params): + post = self.enterContext(patch("central.integrations.email_oauth.email_oauth.requests.post")) + post.return_value = Mock(ok=microsoft_status < 400, json=Mock(return_value=microsoft_body)) + self.as_pilot("/api/method/central.api.email_oauth.relay_token") + return relay_token(provider="microsoft", **params), post + + def sign_in(self, code: str = "the-code"): + self.call_start(f"https://{self.site}{SITE_CALLBACK}") + self.call_callback(self.owner, state="state-1", code=code) + frappe.set_user("Administrator") + + def test_relay_token_adds_the_secret_and_returns_microsofts_tokens(self): + self.sign_in() + + token, post = self.call_relay_token( + 200, + {"access_token": "at", "refresh_token": "rt"}, + grant_type="authorization_code", + code="the-code", + code_verifier="verifier-1", + ) + + self.assertEqual(token["access_token"], "at") + sent = post.call_args.kwargs["data"] + self.assertEqual((sent["client_id"], sent["client_secret"]), ("ms-client-id", "ms-secret")) + self.assertTrue(sent["redirect_uri"].endswith("/api/method/central.api.email_oauth.callback")) + + def test_relay_token_swaps_a_code_only_for_the_server_whose_sign_in_got_it(self): + self.sign_in() + other_server = frappe.get_doc( + { + "doctype": "Virtual Machine", + "resource_id": f"vm-other-{frappe.generate_hash(length=6)}", + "team": self.team, + "region": frappe.db.get_value("Virtual Machine", self.server, "region"), + } + ).insert() + self.token = PilotCredential.mint( + team=self.team, + pilot_credential_id=f"oauth-other-{frappe.generate_hash(length=6)}", + server=other_server.name, + ) + + with self.assertRaises(frappe.PermissionError): + self.call_relay_token( + 200, + {"access_token": "at"}, + grant_type="authorization_code", + code="the-code", + code_verifier="verifier-1", + ) + + def test_relay_token_refuses_a_code_central_never_gave_out(self): + with self.assertRaises(frappe.PermissionError): + self.call_relay_token( + 200, + {"access_token": "at"}, + grant_type="authorization_code", + code="stolen-code", + code_verifier="verifier-1", + ) + + def test_relay_token_passes_on_microsofts_error_code(self): + with self.assertRaises(frappe.ValidationError) as caught: + self.call_relay_token( + 400, {"error": "invalid_grant"}, grant_type="refresh_token", refresh_token="old" + ) + self.assertIn("invalid_grant", str(caught.exception)) diff --git a/dashboard/src/lib/authRedirect.ts b/dashboard/src/lib/authRedirect.ts index a05188ceb..db20ec565 100644 --- a/dashboard/src/lib/authRedirect.ts +++ b/dashboard/src/lib/authRedirect.ts @@ -3,6 +3,8 @@ import { queryString } from '@/lib/auth' const DEFAULT_DASHBOARD = '/dashboard/servers' const PRODUCT_ONBOARDING = '/dashboard/onboarding/site' +// Central's mailbox sign-in callback sends a logged-out browser here to log in, then back to it +const EMAIL_OAUTH_CALLBACK = '/api/method/central.api.email_oauth.callback?' /** Where to land once signed in: the requested page, the product onboarding, or the console. */ export function signInDestination(query: LocationQuery): string { @@ -27,6 +29,7 @@ function dashboardPath(value: string): string | null { if (!value) return null const path = sameOriginPath(value) + if (path?.startsWith(EMAIL_OAUTH_CALLBACK)) return path if (path === '/dashboard') return DEFAULT_DASHBOARD if (!path?.startsWith('/dashboard/')) return null if (/^\/dashboard\/(login|signup|verify)\b/.test(path)) return null diff --git a/dashboard/tests/auth-redirect.test.mjs b/dashboard/tests/auth-redirect.test.mjs new file mode 100644 index 000000000..c3819b826 --- /dev/null +++ b/dashboard/tests/auth-redirect.test.mjs @@ -0,0 +1,54 @@ +import assert from 'node:assert/strict' +import { readFileSync } from 'node:fs' +import test from 'node:test' +import { runInNewContext } from 'node:vm' +import typescript from 'typescript' + +const source = readFileSync( + new URL('../src/lib/authRedirect.ts', import.meta.url), + 'utf8', +) +const exports = {} +runInNewContext( + typescript.transpileModule(source, { + compilerOptions: { module: typescript.ModuleKind.CommonJS }, + }).outputText, + { + exports, + URL, + URLSearchParams, + window: { location: { origin: 'https://central.example.com' } }, + require: () => ({ + queryString: (value) => (typeof value === 'string' ? value : ''), + }), + }, +) +const { signInDestination } = exports + +test('sign-in returns to the mailbox callback with its query', () => { + const callback = + '/api/method/central.api.email_oauth.callback?state=s1&code=c1' + assert.equal(signInDestination({ 'redirect-to': callback }), callback) + assert.equal( + signInDestination({ + 'redirect-to': `https://central.example.com${callback}`, + }), + callback, + ) +}) + +test('sign-in refuses a foreign origin or a lookalike callback path', () => { + for (const value of [ + 'https://evil.example.com/api/method/central.api.email_oauth.callback?state=s1', + '//evil.example.com/api/method/central.api.email_oauth.callback?state=s1', + '/api/method/central.api.email_oauth.callback_evil?state=s1', + '/api/method/central.api.email_oauth.callback', + '/api/method/central.api.other?next=/api/method/central.api.email_oauth.callback?', + ]) { + assert.equal( + signInDestination({ 'redirect-to': value }), + '/dashboard/servers', + value, + ) + } +})