Skip to content

Commit

Permalink
Allow adding multiple oidc plugins to your site (#48)
Browse files Browse the repository at this point in the history
  • Loading branch information
erral authored Mar 17, 2024
1 parent 7fa4aa6 commit e07e5fc
Show file tree
Hide file tree
Showing 14 changed files with 179 additions and 40 deletions.
1 change: 1 addition & 0 deletions news/48.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Allow multiple instances of OIDC plugin in a given Plone site @erral
4 changes: 3 additions & 1 deletion src/pas/plugins/oidc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Init and utils."""

from AccessControl.Permissions import manage_users as ManageUsers
from Products.PluggableAuthService import PluggableAuthService as PAS
from zope.i18nmessageid import MessageFactory
Expand All @@ -23,5 +24,6 @@ def initialize(context): # pragma: no cover
context.registerClass(
plugins.OIDCPlugin,
permission=ManageUsers,
constructors=(plugins.add_oidc_plugin,),
constructors=(plugins.manage_addOIDCPluginForm, plugins.addOIDCPlugin),
visibility=None,
)
1 change: 1 addition & 0 deletions src/pas/plugins/oidc/interfaces.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Module where all interfaces, events and exceptions live."""

from zope.publisher.interfaces.browser import IDefaultBrowserLayer


Expand Down
1 change: 1 addition & 0 deletions src/pas/plugins/oidc/locales/update.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Update locales."""

from pathlib import Path

import logging
Expand Down
31 changes: 25 additions & 6 deletions src/pas/plugins/oidc/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from plone.base.utils import safe_text
from plone.protect.utils import safeWrite
from Products.CMFCore.utils import getToolByName
from Products.PageTemplates.PageTemplateFile import PageTemplateFile
from Products.PluggableAuthService.interfaces.plugins import IAuthenticationPlugin
from Products.PluggableAuthService.interfaces.plugins import IChallengePlugin
from Products.PluggableAuthService.interfaces.plugins import IUserAdderPlugin
Expand All @@ -25,6 +26,24 @@
import string


manage_addOIDCPluginForm = PageTemplateFile(
"www/oidcPluginForm", globals(), __name__="manage_addOIDCPluginForm"
)


def addOIDCPlugin(dispatcher, id, title=None, REQUEST=None):
"""Add a HTTP Basic Auth Helper to a Pluggable Auth Service."""
plugin = OIDCPlugin(id, title)
dispatcher._setObject(plugin.getId(), plugin)

if REQUEST is not None:
REQUEST["RESPONSE"].redirect(
"%s/manage_workspace"
"?manage_tabs_message="
"OIDC+Plugin+added." % dispatcher.absolute_url()
)


PWCHARS = string.ascii_letters + string.digits + string.punctuation


Expand Down Expand Up @@ -53,6 +72,7 @@ class OIDCPlugin(BasePlugin):
meta_type = "OIDC Plugin"
security = ClassSecurityInfo()

title = "OIDC Plugin"
issuer = ""
client_id = ""
client_secret = "" # nosec B105
Expand All @@ -70,6 +90,7 @@ class OIDCPlugin(BasePlugin):
user_property_as_userid = "sub"

_properties = (
dict(id="title", type="string", mode="w", label="Title"),
dict(id="issuer", type="string", mode="w", label="OIDC/Oauth2 Issuer"),
dict(id="client_id", type="string", mode="w", label="Client ID"),
dict(id="client_secret", type="string", mode="w", label="Client secret"),
Expand Down Expand Up @@ -137,6 +158,10 @@ class OIDCPlugin(BasePlugin):
),
)

def __init__(self, id, title=None):
self._setId(id)
self.title = title

def rememberIdentity(self, userinfo):
if not isinstance(userinfo, (OpenIDSchema, dict)):
raise AssertionError(
Expand Down Expand Up @@ -367,12 +392,6 @@ def challenge(self, request, response):
)


def add_oidc_plugin():
# Form for manually adding our plugin.
# But we do this in setuphandlers.py always.
pass


# https://github.com/collective/Products.AutoUserMakerPASPlugin/blob/master/Products/AutoUserMakerPASPlugin/auth.py
@contextmanager
def safe_write(request):
Expand Down
21 changes: 13 additions & 8 deletions src/pas/plugins/oidc/services/login/get.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
from pas.plugins.oidc import utils
from pas.plugins.oidc.plugins import OIDCPlugin
from plone import api
from plone.restapi.services import Service
from typing import Dict
Expand All @@ -18,14 +20,17 @@ def list_login_providers() -> List[Dict]:
:returns: List of login options.
"""
portal_url = api.portal.get().absolute_url()
plugins = [
{
"id": "oidc",
"plugin": "oidc",
"url": f"{portal_url}/@login-oidc/oidc",
"title": "OIDC Authentication",
}
]
plugins = []
for plugin in utils.get_plugins():
if isinstance(plugin, OIDCPlugin):
plugins.append(
{
"id": plugin.getId(),
"plugin": "oidc",
"url": f"{portal_url}/@login-oidc/{plugin.getId()}",
"title": plugin.title,
}
)
return plugins

def reply(self) -> Dict[str, List[Dict]]:
Expand Down
14 changes: 8 additions & 6 deletions src/pas/plugins/oidc/services/oidc/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ def json_body(self) -> dict:
def plugin(self) -> OIDCPlugin:
if not self._plugin:
try:
self._plugin = utils.get_plugin()
for plugin in utils.get_plugins():
if plugin.getId() == self.provider_id:
self._plugin = plugin
except AttributeError:
# Plugin not installed yet
self._plugin = None
Expand Down Expand Up @@ -77,7 +79,8 @@ def reply(self) -> dict:
"""
provider = self.provider_id
plugin = self.plugin
if not (plugin and provider == "oidc"):

if not plugin:
return self._provider_not_found(provider)

session = utils.initialize_session(plugin, self.request)
Expand Down Expand Up @@ -121,10 +124,9 @@ def reply(self) -> dict:
:returns: URL and session information.
"""
provider = "oidc"
plugin = self.plugin
if not (plugin and provider == "oidc"):
return self._provider_not_found(provider)
if not plugin:
return self._provider_not_found(self.provider_id)

try:
client = plugin.get_oauth2_client()
Expand Down Expand Up @@ -192,7 +194,7 @@ def reply(self) -> dict:
"""
provider = self.provider_id
plugin = self.plugin
if not (plugin and provider == "oidc"):
if not plugin:
return self._provider_not_found(provider)

session = utils.load_existing_session(plugin, self.request)
Expand Down
18 changes: 5 additions & 13 deletions src/pas/plugins/oidc/setuphandlers.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from pas.plugins.oidc import logger
from pas.plugins.oidc import PLUGIN_ID
from pas.plugins.oidc import utils
from pas.plugins.oidc.plugins import OIDCPlugin
from plone import api
from Products.CMFPlone.interfaces import INonInstallable
Expand All @@ -23,6 +24,7 @@ def post_install(context):
# Create plugin if it does not exist.
if PLUGIN_ID not in pas.objectIds():
plugin = OIDCPlugin(
id=PLUGIN_ID,
title="OpenID Connect",
)
plugin.id = PLUGIN_ID
Expand Down Expand Up @@ -89,18 +91,8 @@ def activate_challenge_plugin(context):

def uninstall(context):
"""Uninstall script"""
from pas.plugins.oidc.utils import PLUGIN_ID

pas = api.portal.get_tool("acl_users")

# Remove plugin if it exists.
if PLUGIN_ID not in pas.objectIds():
return
from pas.plugins.oidc.plugins import OIDCPlugin

plugin = getattr(pas, PLUGIN_ID)
if not isinstance(plugin, OIDCPlugin):
logger.warning(f"PAS plugin {PLUGIN_ID} not removed: it is not a OIDCPlugin.")
return
pas._delObject(PLUGIN_ID)
logger.info(f"Removed OIDCPlugin {PLUGIN_ID} from acl_users.")
for plugin in utils.get_plugins():
pas._delObject(plugin.getId())
logger.info(f"Removed OIDCPlugin {plugin.getId()} from acl_users.")
13 changes: 9 additions & 4 deletions src/pas/plugins/oidc/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
from oic.exception import RequestError
from oic.oic import message
from pas.plugins.oidc import logger
from pas.plugins.oidc import PLUGIN_ID
from pas.plugins.oidc import plugins
from pas.plugins.oidc.plugins import OIDCPlugin
from pas.plugins.oidc.session import Session
from plone import api
from typing import Union
Expand Down Expand Up @@ -77,10 +77,15 @@ def url_cleanup(url: str) -> str:
return url


def get_plugin() -> plugins.OIDCPlugin:
"""Return the OIDC plugin for the current portal."""
def get_plugins() -> list:
"""Return all OIDC plugins for the current portal."""
pas = api.portal.get_tool("acl_users")
return getattr(pas, PLUGIN_ID)
plugins_to_return = []
for plugin in pas.objectValues():
if isinstance(plugin, OIDCPlugin):
plugins_to_return.append(plugin)

return plugins_to_return


# Flow: Start
Expand Down
66 changes: 66 additions & 0 deletions src/pas/plugins/oidc/www/oidcPluginForm.zpt
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
<h1 tal:replace="structure here/manage_page_header">Header</h1>

<main class="container-fluid"
i18n:domain="pas.plugins.oidc"
>

<h2 tal:define="
form_title string:Add OIDC Plugin;
"
tal:replace="structure here/manage_form_title"
>Form Title</h2>

<p class="form-help"
i18n:translate=""
>
OIDC Plugin manage the details of the OpenID Connect Authentication plugin
Pluggable Auth Service functionality.
</p>

<form action="addOIDCPlugin"
enctype="multipart/form-data"
method="post"
>

<div class="form-group row">
<label class="form-label col-sm-3 col-md-2"
for="id"
i18n:translate=""
>Id</label>
<div class="col-sm-9 col-md-10">
<input class="form-control"
id="id"
name="id"
type="text"
/>
</div>
</div>

<div class="form-group row form-optional">
<label class="form-label col-sm-3 col-md-2"
for="title"
i18n:translate=""
>Title</label>
<div class="col-sm-9 col-md-10">
<input class="form-control"
id="title"
name="title"
type="text"
/>
</div>
</div>

<div class="zmi-controls">
<input class="btn btn-primary"
name="submit"
type="submit"
value="Add"
i18n:attributes="value"
/>
</div>

</form>

</main>

<h1 tal:replace="structure here/manage_page_footer">Footer</h1>
12 changes: 12 additions & 0 deletions tests/services/conftest.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
from bs4 import BeautifulSoup
from pas.plugins.oidc.plugins import OIDCPlugin
from plone import api
from plone.app.testing import SITE_OWNER_NAME
from plone.app.testing import SITE_OWNER_PASSWORD
Expand Down Expand Up @@ -102,3 +103,14 @@ def func(url: str):
return qs

return func


@pytest.fixture()
def google(restapi):
portal = restapi["portal"]
setSite(portal)
with api.env.adopt_roles(["Manager", "Member"]):
portal.acl_users._setObject("google", OIDCPlugin("google", "Google"))

transaction.commit()
yield portal
2 changes: 1 addition & 1 deletion tests/services/test_services_login_get.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ def test_login_get_available(self):
[0, "id", "oidc"],
[0, "plugin", "oidc"],
[0, "url", "/@login-oidc/oidc"],
[0, "title", "OIDC Authentication"],
[0, "title", "OpenID Connect"],
],
)
def test_login_get_options(self, idx: int, key: str, expected: str):
Expand Down
33 changes: 33 additions & 0 deletions tests/services/test_servides_login_get_multiple_plugins.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import pytest


class TestSetupInstall:
@pytest.fixture(autouse=True)
def _initialize(self, google, api_anon_request):
self.portal = google
self.api_session = api_anon_request

def test_login_get_available(self):
response = self.api_session.get("@login")
assert response.status_code == 200
data = response.json()
assert isinstance(data, dict)

@pytest.mark.parametrize(
"idx, key, expected",
[
[0, "id", "oidc"],
[0, "plugin", "oidc"],
[0, "url", "/@login-oidc/oidc"],
[0, "title", "OpenID Connect"],
[1, "id", "google"],
[1, "plugin", "oidc"],
[1, "url", "/@login-oidc/google"],
[1, "title", "Google"],
],
)
def test_login_get_options(self, idx: int, key: str, expected: str):
response = self.api_session.get("@login")
data = response.json()
options = data["options"]
assert expected in options[idx][key]
2 changes: 1 addition & 1 deletion tests/utils/test_utils_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ class TestUtilsFlowStart:
def _initialize(self, portal, http_request):
self.portal = portal
self.http_request = http_request
self.plugin = utils.get_plugin()
self.plugin = utils.get_plugins()[0]

@pytest.fixture()
def session_factory(self):
Expand Down

0 comments on commit e07e5fc

Please sign in to comment.