diff --git a/client/app/assets/images/microsoft_logo.svg b/client/app/assets/images/microsoft_logo.svg new file mode 100644 index 0000000000..1f73976483 --- /dev/null +++ b/client/app/assets/images/microsoft_logo.svg @@ -0,0 +1 @@ +MS-SymbolLockup \ No newline at end of file diff --git a/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx b/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx new file mode 100644 index 0000000000..5b1fab4020 --- /dev/null +++ b/client/app/pages/settings/components/AuthSettings/AzureLoginSettings.jsx @@ -0,0 +1,58 @@ +import { isEmpty, join } from "lodash"; +import React from "react"; +import Form from "antd/lib/form"; +import Select from "antd/lib/select"; +import Alert from "antd/lib/alert"; +import DynamicComponent from "@/components/DynamicComponent"; +import { clientConfig } from "@/services/auth"; +import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-types"; + +export default function AzureLoginSettings(props) { + const { values, onChange } = props; + + if (!clientConfig.azureLoginEnabled) { + return null; + } + + return ( + +

Microsoft Work or School Account Login

+ + onChange({ auth_azure_roles: value })} /> + {!isEmpty(values.auth_azure_roles) && ( + + Restrict access to users assigned the {join(values.auth_azure_roles, ", ")} role. +

+ } + className="m-t-16" + /> + )} +
+
+ ); +} + +AzureLoginSettings.propTypes = SettingsEditorPropTypes; + +AzureLoginSettings.defaultProps = SettingsEditorDefaultProps; diff --git a/client/app/pages/settings/components/AuthSettings/index.jsx b/client/app/pages/settings/components/AuthSettings/index.jsx index 5d74f20aee..774e02286f 100644 --- a/client/app/pages/settings/components/AuthSettings/index.jsx +++ b/client/app/pages/settings/components/AuthSettings/index.jsx @@ -6,6 +6,7 @@ import { SettingsEditorPropTypes, SettingsEditorDefaultProps } from "../prop-typ import PasswordLoginSettings from "./PasswordLoginSettings"; import GoogleLoginSettings from "./GoogleLoginSettings"; +import AzureLoginSettings from "./AzureLoginSettings"; import SAMLSettings from "./SAMLSettings"; export default function AuthSettings(props) { @@ -14,7 +15,10 @@ export default function AuthSettings(props) { changes => { const allSettings = { ...values, ...changes }; const allAuthMethodsDisabled = - !clientConfig.googleLoginEnabled && !clientConfig.ldapLoginEnabled && !allSettings.auth_saml_enabled; + !clientConfig.googleLoginEnabled && + !clientConfig.azureLoginEnabled && + !clientConfig.ldapLoginEnabled && + !allSettings.auth_saml_enabled; if (allAuthMethodsDisabled) { changes = { ...changes, auth_password_login_enabled: true }; } @@ -31,6 +35,7 @@ export default function AuthSettings(props) {
+ ); diff --git a/redash/authentication/__init__.py b/redash/authentication/__init__.py index f06cd3cdb2..6d10467d7f 100644 --- a/redash/authentication/__init__.py +++ b/redash/authentication/__init__.py @@ -249,6 +249,7 @@ def init_app(app): ) from redash.authentication.google_oauth import create_google_oauth_blueprint + from redash.authentication.azure_oauth import create_azure_oauth_blueprint login_manager.init_app(app) login_manager.anonymous_user = models.AnonymousUser @@ -262,7 +263,7 @@ def extend_session(): from redash.security import csrf # Authlib's flask oauth client requires a Flask app to initialize - for blueprint in [create_google_oauth_blueprint(app), saml_auth.blueprint, remote_user_auth.blueprint, ldap_auth.blueprint, ]: + for blueprint in [create_google_oauth_blueprint(app), create_azure_oauth_blueprint(app), saml_auth.blueprint, remote_user_auth.blueprint, ldap_auth.blueprint, ]: csrf.exempt(blueprint) app.register_blueprint(blueprint) diff --git a/redash/authentication/azure_oauth.py b/redash/authentication/azure_oauth.py new file mode 100644 index 0000000000..73827b267f --- /dev/null +++ b/redash/authentication/azure_oauth.py @@ -0,0 +1,184 @@ +import logging +import requests +import base64 +import json +from flask import redirect, url_for, Blueprint, flash, request, session + + +from redash import models, settings +from redash.authentication import ( + create_and_login_user, + logout_and_redirect_to_index, + get_next_path, +) +from redash.authentication.org_resolving import current_org + +from authlib.integrations.flask_client import OAuth + + +def verify_profile(org, profile): + if org.is_public: + return True + + email = profile["email"] + domain = email.split("@")[-1] + + if domain in org.azure_apps_domains: + return True + + if org.has_user(email) == 1: + return True + + return False + +def get_roles_in_id_token(id_token, logger): + logger.debug("Validating ID token") + id_token_parts = id_token.split(".") + if len(id_token_parts) < 2: + logger.warning("Malformed ID token") + decoded_token_json = json.loads(base64.b64decode(id_token_parts[1] + '==')) + logger.debug("Successfully decoded token") + if "roles" in decoded_token_json: + roles = decoded_token_json["roles"] + logger.debug("Found roles: " + (", ".join(roles))) + return roles + return [] + +def verify_roles(org, roles, logger): + if org.azure_roles: + if not roles: + return False + for azure_role in org.azure_roles: + logger.debug("Verifying role: " + azure_role) + if azure_role in roles: + logger.debug("Role verified: " + azure_role) + return True + return False + +def create_azure_oauth_blueprint(app): + logger = logging.getLogger("azure_oauth") + blueprint = Blueprint("azure_oauth", __name__) + + if not settings.AZURE_TENANT_ID: + # multi-tenant + CONF_URL = "https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration" + else: + CONF_URL = "https://login.microsoftonline.com/" + settings.AZURE_TENANT_ID + "/v2.0/.well-known/openid-configuration" + + oauth = OAuth(app) + oauth.register( + name="azure", + server_metadata_url=CONF_URL, + client_kwargs={"scope": "openid email profile"}, + ) + + def get_user_profile(access_token): + headers = {"Authorization": "Bearer {}".format(access_token)} + + response = requests.get( + "https://graph.microsoft.com/oidc/userinfo", headers=headers + ) + + if response.status_code == 401: + logger.warning("Failed getting user profile (response code 401).") + return None + + return response.json() + + @blueprint.route("//oauth/azure", endpoint="authorize_org") + def org_login(org_slug): + session["org_slug"] = current_org.slug + return redirect(url_for(".authorize", next=request.args.get("next", None))) + + @blueprint.route("/oauth/azure", endpoint="authorize") + def login(): + + redirect_uri = url_for(".callback", _external=True) + + next_path = request.args.get( + "next", url_for("redash.index", org_slug=session.get("org_slug")) + ) + logger.debug("Callback url: %s", redirect_uri) + logger.debug("Next is: %s", next_path) + + session["next_url"] = next_path + + return oauth.azure.authorize_redirect(redirect_uri) + + @blueprint.route("/oauth/azure_callback", endpoint="callback") + def authorized(): + + logger.debug("Authorized user inbound") + + resp = oauth.azure.authorize_access_token() + user = resp.get("userinfo") + if user: + session["user"] = user + + access_token = resp["access_token"] + id_token = resp["id_token"] + + if access_token is None: + logger.warning("Access token missing in call back request.") + flash("Validation error. Please retry.") + return redirect(url_for("redash.login")) + + if id_token is None: + logger.warning("Id token missing in call back request.") + flash("Validation error. Please retry.") + return redirect(url_for("redash.login")) + + if "org_slug" in session: + org = models.Organization.get_by_slug(session.pop("org_slug")) + else: + org = current_org + + profile = get_user_profile(access_token) + + if org.azure_roles: + roles = get_roles_in_id_token(id_token, logger) + else: + roles = [] + + if not verify_roles(org, roles, logger): + logger.warning( + "User tried to login without authorized role assignment: %s. Valid roles are: %s. Provided roles are: %s", + profile["email"], + ", ".join(org.azure_roles), + ", ".join(roles), + ) + flash( + "Your Azure AD account ({}) isn't allowed as you are not assigned a required role: {}. Your assigned roles are: {}".format(profile["email"], ", ".join(org.azure_roles), ", ".join(roles)) + ) + return redirect(url_for("redash.login", org_slug=org.slug)) + + if profile is None: + flash("Validation error. Please retry.") + return redirect(url_for("redash.login")) + + if not verify_profile(org, profile): + logger.warning( + "User tried to login with unauthorized domain name: %s (org: %s)", + profile["email"], + org, + ) + flash( + "Your Azure AD account ({}) isn't allowed.".format(profile["email"]) + ) + return redirect(url_for("redash.login", org_slug=org.slug)) + + # Do not read picture URL as applications often do not have Graph API permissions + user = create_and_login_user( + org, profile["name"], profile["email"] + ) + if user is None: + return logout_and_redirect_to_index() + + unsafe_next_path = session.get("next_url") or url_for( + "redash.index", org_slug=org.slug + ) + next_path = get_next_path(unsafe_next_path) + + return redirect(next_path) + + return blueprint diff --git a/redash/cli/organization.py b/redash/cli/organization.py index 45c73551fc..5e95381a33 100644 --- a/redash/cli/organization.py +++ b/redash/cli/organization.py @@ -33,6 +33,31 @@ def show_google_apps_domains(): ) ) +@manager.command() +@argument("domains") +def set_azure_apps_domains(domains): + """ + Sets the allowable domains to the comma separated list DOMAINS. + """ + organization = models.Organization.query.first() + k = models.Organization.SETTING_AZURE_APPS_DOMAINS + organization.settings[k] = domains.split(",") + models.db.session.add(organization) + models.db.session.commit() + print( + "Updated list of allowed domains to: {}".format( + organization.azure_apps_domains + ) + ) + +@manager.command() +def show_azure_apps_domains(): + organization = models.Organization.query.first() + print( + "Current list of Azure Apps domains: {}".format( + ", ".join(organization.azure_apps_domains) + ) + ) @manager.command(name="list") def list_command(): diff --git a/redash/cli/users.py b/redash/cli/users.py index a6e35a02b6..ba3a98597a 100644 --- a/redash/cli/users.py +++ b/redash/cli/users.py @@ -71,6 +71,13 @@ def grant_admin(email, organization="default"): default=False, help="user uses Google Auth to login", ) +@option( + "--azure", + "azure_auth", + is_flag=True, + default=False, + help="user uses Azure AD Auth to login", +) @option( "--password", "password", @@ -89,6 +96,7 @@ def create( groups, is_admin=False, google_auth=False, + azure_auth=False, password=None, organization="default", ): @@ -98,14 +106,15 @@ def create( print("Creating user (%s, %s) in organization %s..." % (email, name, organization)) print("Admin: %r" % is_admin) print("Login with Google Auth: %r\n" % google_auth) + print("Login with Azure Auth: %r\n" % azure_auth) org = models.Organization.get_by_slug(organization) groups = build_groups(org, groups, is_admin) user = models.User(org=org, email=email, name=name, group_ids=groups) - if not password and not google_auth: + if not password and not google_auth and not azure_auth: password = prompt("Password", hide_input=True, confirmation_prompt=True) - if not google_auth: + if not google_auth and not azure_auth: user.hash_password(password) try: @@ -132,6 +141,13 @@ def create( default=False, help="user uses Google Auth to login", ) +@option( + "--azure", + "azure_auth", + is_flag=True, + default=False, + help="user uses Azure AD Auth to login", +) @option( "--password", "password", @@ -139,7 +155,7 @@ def create( help="Password for root user who don't use Google Auth " "(leave blank for prompt).", ) -def create_root(email, name, google_auth=False, password=None, organization="default"): +def create_root(email, name, google_auth=False, azure_auth=False, password=None, organization="default"): """ Create root user. """ @@ -148,6 +164,7 @@ def create_root(email, name, google_auth=False, password=None, organization="def % (email, name, organization) ) print("Login with Google Auth: %r\n" % google_auth) + print("Login with Azure Auth: %r\n" % azure_auth) user = models.User.query.filter(models.User.email == email).first() if user is not None: @@ -183,7 +200,7 @@ def create_root(email, name, google_auth=False, password=None, organization="def name=name, group_ids=[admin_group.id, default_group.id], ) - if not google_auth: + if not google_auth and not azure_auth: user.hash_password(password) try: diff --git a/redash/handlers/authentication.py b/redash/handlers/authentication.py index c4e467c2eb..d09c946a8e 100644 --- a/redash/handlers/authentication.py +++ b/redash/handlers/authentication.py @@ -29,7 +29,15 @@ def get_google_auth_url(next_path): else: google_auth_url = url_for("google_oauth.authorize", next=next_path) return google_auth_url - + +def get_azure_auth_url(next_path): + if settings.MULTI_ORG: + azure_auth_url = url_for( + "azure_oauth.authorize_org", next=next_path, org_slug=current_org.slug + ) + else: + azure_auth_url = url_for("azure_oauth.authorize", next=next_path) + return azure_auth_url def render_token_login_page(template, org_slug, token, invite): try: @@ -94,11 +102,15 @@ def render_token_login_page(template, org_slug, token, invite): google_auth_url = get_google_auth_url(url_for("redash.index", org_slug=org_slug)) + azure_auth_url = get_azure_auth_url(url_for("redash.index", org_slug=org_slug)) + return ( render_template( template, show_google_openid=settings.GOOGLE_OAUTH_ENABLED, google_auth_url=google_auth_url, + show_azure_openid=settings.AZURE_OAUTH_ENABLED, + azure_auth_url=azure_auth_url, show_saml_login=current_org.get_setting("auth_saml_enabled"), show_remote_user_login=settings.REMOTE_USER_LOGIN_ENABLED, show_ldap_login=settings.LDAP_LOGIN_ENABLED, @@ -219,9 +231,10 @@ def login(org_slug=None): flash("Password login is not enabled for your organization.") - google_auth_url = get_google_auth_url(next_path) + azure_auth_url = get_azure_auth_url(next_path) + return render_template( "login.html", org_slug=org_slug, @@ -229,6 +242,8 @@ def login(org_slug=None): email=request.form.get("email", ""), show_google_openid=settings.GOOGLE_OAUTH_ENABLED, google_auth_url=google_auth_url, + show_azure_openid=settings.AZURE_OAUTH_ENABLED, + azure_auth_url=azure_auth_url, show_password_login=current_org.get_setting("auth_password_login_enabled"), show_saml_login=current_org.get_setting("auth_saml_enabled"), show_remote_user_login=settings.REMOTE_USER_LOGIN_ENABLED, @@ -302,6 +317,7 @@ def client_config(): "dashboardRefreshIntervals": settings.DASHBOARD_REFRESH_INTERVALS, "queryRefreshIntervals": settings.QUERY_REFRESH_INTERVALS, "googleLoginEnabled": settings.GOOGLE_OAUTH_ENABLED, + "azureLoginEnabled": settings.AZURE_OAUTH_ENABLED, "ldapLoginEnabled": settings.LDAP_LOGIN_ENABLED, "pageSize": settings.PAGE_SIZE, "pageSizeOptions": settings.PAGE_SIZE_OPTIONS, diff --git a/redash/handlers/settings.py b/redash/handlers/settings.py index d684f42c35..ba30e2f07f 100644 --- a/redash/handlers/settings.py +++ b/redash/handlers/settings.py @@ -21,6 +21,8 @@ def get_settings_with_defaults(defaults, org): settings[setting] = current_value settings["auth_google_apps_domains"] = org.google_apps_domains + settings["auth_azure_apps_domains"] = org.azure_apps_domains + settings["auth_azure_roles"] = org.azure_roles return settings @@ -44,6 +46,12 @@ def post(self): if k == "auth_google_apps_domains": previous_values[k] = self.current_org.google_apps_domains self.current_org.settings[Organization.SETTING_GOOGLE_APPS_DOMAINS] = v + elif k == "auth_azure_apps_domains": + previous_values[k] = self.current_org.azure_apps_domains + self.current_org.settings[Organization.SETTING_AZURE_APPS_DOMAINS] = v + elif k == "auth_azure_roles": + previous_values[k] = self.current_org.azure_roles + self.current_org.settings[Organization.SETTING_AZURE_ROLES] = v else: previous_values[k] = self.current_org.get_setting( k, raise_on_missing=False diff --git a/redash/models/organizations.py b/redash/models/organizations.py index 18dd9f1898..76eb10d13b 100644 --- a/redash/models/organizations.py +++ b/redash/models/organizations.py @@ -12,6 +12,8 @@ @generic_repr("id", "name", "slug") class Organization(TimestampMixin, db.Model): SETTING_GOOGLE_APPS_DOMAINS = "google_apps_domains" + SETTING_AZURE_APPS_DOMAINS = "azure_apps_domains" + SETTING_AZURE_ROLES = "azure_roles" SETTING_IS_PUBLIC = "is_public" id = primary_key("Organization") @@ -44,6 +46,14 @@ def default_group(self): def google_apps_domains(self): return self.settings.get(self.SETTING_GOOGLE_APPS_DOMAINS, []) + @property + def azure_apps_domains(self): + return self.settings.get(self.SETTING_AZURE_APPS_DOMAINS, []) + + @property + def azure_roles(self): + return self.settings.get(self.SETTING_AZURE_ROLES, []) + @property def is_public(self): return self.settings.get(self.SETTING_IS_PUBLIC, False) diff --git a/redash/settings/__init__.py b/redash/settings/__init__.py index 6dff105d30..835010b6ab 100644 --- a/redash/settings/__init__.py +++ b/redash/settings/__init__.py @@ -171,6 +171,11 @@ GOOGLE_CLIENT_SECRET = os.environ.get("REDASH_GOOGLE_CLIENT_SECRET", "") GOOGLE_OAUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) +AZURE_CLIENT_ID = os.environ.get("REDASH_AZURE_CLIENT_ID", "") +AZURE_CLIENT_SECRET = os.environ.get("REDASH_AZURE_CLIENT_SECRET", "") +AZURE_TENANT_ID = os.environ.get("REDASH_AZURE_TENANT", "") +AZURE_OAUTH_ENABLED = bool(AZURE_CLIENT_ID and AZURE_CLIENT_SECRET) + # If Redash is behind a proxy it might sometimes receive a X-Forwarded-Proto of HTTP # even if your actual Redash URL scheme is HTTPS. This will cause Flask to build # the SAML redirect URL incorrect thus failing auth. This is especially common if diff --git a/redash/templates/invite.html b/redash/templates/invite.html index e52dbe4b41..daeabf12c5 100644 --- a/redash/templates/invite.html +++ b/redash/templates/invite.html @@ -27,7 +27,14 @@ Login with Google {% endif %} - + + {% if show_azure_openid %} + + {% endif %} + {% if show_saml_login %} {% endif %} @@ -40,7 +47,7 @@ {% endif %} - {% if show_google_openid or show_saml_login or show_remote_user_login or show_ldap_login %} + {% if show_google_openid or show_azure_openid or show_saml_login or show_remote_user_login or show_ldap_login %}
{% endif %} diff --git a/redash/templates/login.html b/redash/templates/login.html index 926a084444..f5438991ef 100644 --- a/redash/templates/login.html +++ b/redash/templates/login.html @@ -19,6 +19,13 @@ Login with Google {% endif %} + + {% if show_azure_openid %} + + {% endif %} {% if show_saml_login %} @@ -33,7 +40,7 @@ {% endif %} {% if show_password_login %} - {% if show_google_openid or show_saml_login or show_remote_user_login or show_ldap_login %} + {% if show_google_openid or show_azure_openid or show_saml_login or show_remote_user_login or show_ldap_login %}
{% endif %} diff --git a/requirements_bundles.txt b/requirements_bundles.txt index 3f57a20dd2..844b4c6eba 100644 --- a/requirements_bundles.txt +++ b/requirements_bundles.txt @@ -4,5 +4,5 @@ # It's automatically installed when running npm run bundle # These can be removed when upgrading to Python 3.x -importlib-metadata>=1.6 # remove when on 3.8 +importlib-metadata>=1.6,<5.0 # remove when on 3.8 importlib_resources==1.5 # remove when on 3.9 diff --git a/tests/handlers/test_settings.py b/tests/handlers/test_settings.py index 6c9e33b9a9..92c1838e32 100644 --- a/tests/handlers/test_settings.py +++ b/tests/handlers/test_settings.py @@ -40,6 +40,30 @@ def test_updates_google_apps_domains(self): updated_org = Organization.get_by_slug(self.factory.org.slug) self.assertEqual(updated_org.google_apps_domains, domains) + def test_updates_azure_apps_domains(self): + admin = self.factory.create_admin() + domains = ["example.com"] + rv = self.make_request( + "post", + "/api/settings/organization", + data={"auth_azure_apps_domains": domains}, + user=admin, + ) + updated_org = Organization.get_by_slug(self.factory.org.slug) + self.assertEqual(updated_org.azure_apps_domains, domains) + + def test_updates_azure_roles(self): + admin = self.factory.create_admin() + roles = ["admin"] + rv = self.make_request( + "post", + "/api/settings/organization", + data={"auth_azure_roles": roles}, + user=admin, + ) + updated_org = Organization.get_by_slug(self.factory.org.slug) + self.assertEqual(updated_org.azure_apps_roles, roles) + def test_get_returns_google_appas_domains(self): admin = self.factory.create_admin() domains = ["example.com"] @@ -47,3 +71,19 @@ def test_get_returns_google_appas_domains(self): rv = self.make_request("get", "/api/settings/organization", user=admin) self.assertEqual(rv.json["settings"]["auth_google_apps_domains"], domains) + + def test_get_returns_azure_app_domains(self): + admin = self.factory.create_admin() + domains = ["example.com"] + admin.org.settings[Organization.SETTING_AZURE_APPS_DOMAINS] = domains + + rv = self.make_request("get", "/api/settings/organization", user=admin) + self.assertEqual(rv.json["settings"]["auth_azure_apps_domains"], domains) + + def test_get_returns_azure_roles(self): + admin = self.factory.create_admin() + roles = ["admin"] + admin.org.settings[Organization.SETTING_AZURE_ROLES] = roles + + rv = self.make_request("get", "/api/settings/organization", user=admin) + self.assertEqual(rv.json["settings"]["auth_azure_roles"], roles) diff --git a/tests/test_authentication.py b/tests/test_authentication.py index 91be52ea76..141f288c3d 100644 --- a/tests/test_authentication.py +++ b/tests/test_authentication.py @@ -12,6 +12,7 @@ sign, ) from redash.authentication.google_oauth import create_and_login_user, verify_profile +from redash.authentication.azure_oauth import create_and_login_user, verify_profile as verify_profile_azure from redash.utils import utcnow from sqlalchemy.orm.exc import NoResultFound from tests import BaseTestCase @@ -238,6 +239,46 @@ def test_user_not_in_domain_but_account_exists(self): self.assertTrue(verify_profile(self.factory.org, profile)) +class TestVerifyProfileAzure(BaseTestCase): + def test_no_domain_allowed_for_org(self): + profile = dict(email="arik@example.com") + self.assertFalse(verify_profile_azure(self.factory.org, profile)) + + def test_domain_not_in_org_domains_list(self): + profile = dict(email="arik@example.com") + self.factory.org.settings[models.Organization.SETTING_AZURE_APPS_DOMAINS] = [ + "example.org" + ] + self.assertFalse(verify_profile_azure(self.factory.org, profile)) + + def test_domain_in_org_domains_list(self): + profile = dict(email="arik@example.com") + self.factory.org.settings[models.Organization.SETTING_AZURE_APPS_DOMAINS] = [ + "example.com" + ] + self.assertTrue(verify_profile_azure(self.factory.org, profile)) + + self.factory.org.settings[models.Organization.SETTING_AZURE_APPS_DOMAINS] = [ + "example.org", + "example.com", + ] + self.assertTrue(verify_profile_azure(self.factory.org, profile)) + + def test_org_in_public_mode_accepts_any_domain(self): + profile = dict(email="arik@example.com") + self.factory.org.settings[models.Organization.SETTING_IS_PUBLIC] = True + self.factory.org.settings[models.Organization.SETTING_AZURE_APPS_DOMAINS] = [] + self.assertTrue(verify_profile_azure(self.factory.org, profile)) + + def test_user_not_in_domain_but_account_exists(self): + profile = dict(email="arik@example.com") + self.factory.create_user(email="arik@example.com") + self.factory.org.settings[models.Organization.SETTING_AZURE_APPS_DOMAINS] = [ + "example.org" + ] + self.assertTrue(verify_profile_azure(self.factory.org, profile)) + + class TestGetLoginUrl(BaseTestCase): def test_when_multi_org_enabled_and_org_exists(self): with self.app.test_request_context("/{}/".format(self.factory.org.slug)):