Skip to content

Commit

Permalink
[fc] Repository: plone.restapi
Browse files Browse the repository at this point in the history
Branch: refs/heads/main
Date: 2025-01-17T14:49:28-08:00
Author: Mikel Larreategi (erral) <[email protected]>
Commit: plone/plone.restapi@74f3d72

add new @login endpoint to return available external login options (#1757)

* add new @login endpoint to return available external login options

* changelog

* lint

* lint

* lint

* lint

* lint

* Update news/1757.feature

Co-authored-by: Steve Piercy &lt;[email protected]&gt;

* Update news/1757.feature

Co-authored-by: Steve Piercy &lt;[email protected]&gt;

* Update news/1757.feature

Co-authored-by: Steve Piercy &lt;[email protected]&gt;

* add docs

* yaml

* yaml

* docs

* docs

* Review of docs

* Revert `'` to `"`

* properly implement the adapter in tests

* add docs rsults

* black

* fix response

* rename the interface to ILoginProviders

* Apply suggestions from code review

---------

Co-authored-by: Steve Piercy &lt;[email protected]&gt;
Co-authored-by: David Glick &lt;[email protected]&gt;

Files changed:
A docs/source/endpoints/login.md
A news/1757.feature
A src/plone/restapi/services/auth/get.py
A src/plone/restapi/tests/http-examples/external_authentication_links.req
A src/plone/restapi/tests/http-examples/external_authentication_links.resp
M docs/source/endpoints/index.md
M src/plone/restapi/interfaces.py
M src/plone/restapi/services/auth/configure.zcml
M src/plone/restapi/tests/test_auth.py
M src/plone/restapi/tests/test_documentation.py
  • Loading branch information
davisagli committed Jan 17, 2025
1 parent 3aec836 commit 5373f77
Showing 1 changed file with 78 additions and 51 deletions.
129 changes: 78 additions & 51 deletions last_commit.txt
Original file line number Diff line number Diff line change
@@ -1,54 +1,81 @@
Repository: plone.app.vocabularies


Branch: refs/heads/master
Date: 2025-01-16T12:25:14+01:00
Author: Peter Mathis (petschki) <[email protected]>
Commit: https://github.com/plone/plone.app.vocabularies/commit/870a74d875aed609b08fc3fa424690e9012e88be

Fix rare case when a "non friendly" type is looked up in `plone.app.vocabularies.ReallyUserFriendlyTypes`

this came up in `collective.collectionfilter` when `Plone Site` type is in filteritems.

The code here was basically a false `urllib.parse` migration to python3
Repository: plone.restapi


Branch: refs/heads/main
Date: 2025-01-17T14:49:28-08:00
Author: Mikel Larreategi (erral) <[email protected]>
Commit: https://github.com/plone/plone.restapi/commit/74f3d72dfbbfb9254821630620c0d12320eefa9e

add new @login endpoint to return available external login options (#1757)

* add new @login endpoint to return available external login options

* changelog

* lint

* lint

* lint

* lint

* lint

* Update news/1757.feature

Co-authored-by: Steve Piercy &lt;[email protected]&gt;

* Update news/1757.feature

Co-authored-by: Steve Piercy &lt;[email protected]&gt;

* Update news/1757.feature

Co-authored-by: Steve Piercy &lt;[email protected]&gt;

* add docs

* yaml

* yaml

* docs

* docs

* Review of docs

* Revert `'` to `"`

* properly implement the adapter in tests

* add docs rsults

* black

* fix response

* rename the interface to ILoginProviders

* Apply suggestions from code review

---------

Co-authored-by: Steve Piercy &lt;[email protected]&gt;
Co-authored-by: David Glick &lt;[email protected]&gt;

Files changed:
M plone/app/vocabularies/__init__.py
M plone/app/vocabularies/types.py

b'diff --git a/plone/app/vocabularies/__init__.py b/plone/app/vocabularies/__init__.py\nindex f153ac9..2bb522b 100644\n--- a/plone/app/vocabularies/__init__.py\n+++ b/plone/app/vocabularies/__init__.py\n@@ -1,6 +1,6 @@\n from plone.app.vocabularies.interfaces import IPermissiveVocabulary\n from plone.app.vocabularies.interfaces import ISlicableVocabulary\n-from urllib import parse\n+from urllib.parse import unquote\n from zope.interface import directlyProvides\n from zope.interface import implementer\n from zope.schema.vocabulary import SimpleTerm\n@@ -57,5 +57,5 @@ def getTermByToken(self, token):\n v = super().getTermByToken(token)\n except LookupError:\n # fallback using dummy term, assumes token==value\n- return SimpleTerm(token, title=parse(token))\n+ return SimpleTerm(token, title=unquote(token))\n return v\ndiff --git a/plone/app/vocabularies/types.py b/plone/app/vocabularies/types.py\nindex a921e9c..91dcfe1 100644\n--- a/plone/app/vocabularies/types.py\n+++ b/plone/app/vocabularies/types.py\n@@ -280,11 +280,13 @@ class ReallyUserFriendlyTypesVocabulary:\n Containment is unenforced, to make GenericSetup import validation\n handle validation triggered by Choice.fromUnicode() on insertion:\n \n- >>> assert \'arbitrary_value\' in util(context)\n+ >>> non_friendly_type = types.getTermByToken(\'Plone Site\')\n+ >>> non_friendly_type.title, non_friendly_type.token\n+ (\'Plone Site\', \'Plone Site\')\n \n >>> doc = types.by_token[\'Document\']\n >>> doc.title, doc.token, doc.value\n- (u\'Page\', \'Document\', \'Document\')\n+ (\'Page\', \'Document\', \'Document\')\n """\n \n def __call__(self, context):\n'

Repository: plone.app.vocabularies


Branch: refs/heads/master
Date: 2025-01-16T12:29:50+01:00
Author: Peter Mathis (petschki) <[email protected]>
Commit: https://github.com/plone/plone.app.vocabularies/commit/a9796ecaec6280aad26632f453ab0f6cfbb494d7

changenote

Files changed:
A news/99.bugfix

b'diff --git a/news/99.bugfix b/news/99.bugfix\nnew file mode 100644\nindex 0000000..52d8488\n--- /dev/null\n+++ b/news/99.bugfix\n@@ -0,0 +1,2 @@\n+Fix rare case of "non friendly" token lookup in `plone.app.vocabularies.ReallyUserFriendlyTypes`\n+[petschki]\n'

Repository: plone.app.vocabularies


Branch: refs/heads/master
Date: 2025-01-16T14:30:08+01:00
Author: Maurits van Rees (mauritsvanrees) <[email protected]>
Commit: https://github.com/plone/plone.app.vocabularies/commit/5f3c42e3b10552bc910e3d8948f273e330f2821d

Merge pull request #99 from plone/petschki-types-vocab-fallback

Fix token lookup in `ReallyUserFriendlyTypes`

Files changed:
A news/99.bugfix
M plone/app/vocabularies/__init__.py
M plone/app/vocabularies/types.py

b'diff --git a/news/99.bugfix b/news/99.bugfix\nnew file mode 100644\nindex 0000000..52d8488\n--- /dev/null\n+++ b/news/99.bugfix\n@@ -0,0 +1,2 @@\n+Fix rare case of "non friendly" token lookup in `plone.app.vocabularies.ReallyUserFriendlyTypes`\n+[petschki]\ndiff --git a/plone/app/vocabularies/__init__.py b/plone/app/vocabularies/__init__.py\nindex f153ac9..2bb522b 100644\n--- a/plone/app/vocabularies/__init__.py\n+++ b/plone/app/vocabularies/__init__.py\n@@ -1,6 +1,6 @@\n from plone.app.vocabularies.interfaces import IPermissiveVocabulary\n from plone.app.vocabularies.interfaces import ISlicableVocabulary\n-from urllib import parse\n+from urllib.parse import unquote\n from zope.interface import directlyProvides\n from zope.interface import implementer\n from zope.schema.vocabulary import SimpleTerm\n@@ -57,5 +57,5 @@ def getTermByToken(self, token):\n v = super().getTermByToken(token)\n except LookupError:\n # fallback using dummy term, assumes token==value\n- return SimpleTerm(token, title=parse(token))\n+ return SimpleTerm(token, title=unquote(token))\n return v\ndiff --git a/plone/app/vocabularies/types.py b/plone/app/vocabularies/types.py\nindex a921e9c..91dcfe1 100644\n--- a/plone/app/vocabularies/types.py\n+++ b/plone/app/vocabularies/types.py\n@@ -280,11 +280,13 @@ class ReallyUserFriendlyTypesVocabulary:\n Containment is unenforced, to make GenericSetup import validation\n handle validation triggered by Choice.fromUnicode() on insertion:\n \n- >>> assert \'arbitrary_value\' in util(context)\n+ >>> non_friendly_type = types.getTermByToken(\'Plone Site\')\n+ >>> non_friendly_type.title, non_friendly_type.token\n+ (\'Plone Site\', \'Plone Site\')\n \n >>> doc = types.by_token[\'Document\']\n >>> doc.title, doc.token, doc.value\n- (u\'Page\', \'Document\', \'Document\')\n+ (\'Page\', \'Document\', \'Document\')\n """\n \n def __call__(self, context):\n'
A docs/source/endpoints/login.md
A news/1757.feature
A src/plone/restapi/services/auth/get.py
A src/plone/restapi/tests/http-examples/external_authentication_links.req
A src/plone/restapi/tests/http-examples/external_authentication_links.resp
M docs/source/endpoints/index.md
M src/plone/restapi/interfaces.py
M src/plone/restapi/services/auth/configure.zcml
M src/plone/restapi/tests/test_auth.py
M src/plone/restapi/tests/test_documentation.py

b'diff --git a/docs/source/endpoints/index.md b/docs/source/endpoints/index.md\nindex 800b9f5286..2eb88c4de5 100644\n--- a/docs/source/endpoints/index.md\n+++ b/docs/source/endpoints/index.md\n@@ -1,10 +1,10 @@\n ---\n myst:\n html_meta:\n- "description": "Usage of the Plone REST API."\n- "property=og:description": "Usage of the Plone REST API."\n- "property=og:title": "Usage of the Plone REST API"\n- "keywords": "Plone, plone.restapi, REST, API, Usage"\n+ "description": "Endpoints of the Plone REST API."\n+ "property=og:description": "Endpoints of the Plone REST API."\n+ "property=og:title": "Endpoints of the Plone REST API"\n+ "keywords": "Plone, plone.restapi, REST, API, endpoints"\n ---\n \n (restapi-endpoints)=\n@@ -33,6 +33,7 @@ groups\n history\n linkintegrity\n locking\n+login\n navigation\n navroot\n actions\ndiff --git a/docs/source/endpoints/login.md b/docs/source/endpoints/login.md\nnew file mode 100644\nindex 0000000000..8541bb91be\n--- /dev/null\n+++ b/docs/source/endpoints/login.md\n@@ -0,0 +1,71 @@\n+---\n+myst:\n+ html_meta:\n+ "description": "The @login endpoint exposes the list of external authentication services that may be used in the Plone site."\n+ "property=og:description": "The @login endpoint exposes the list of external authentication services that may be used in the Plone site."\n+ "property=og:title": "@login for external authentication links"\n+ "keywords": "Plone, plone.restapi, REST, API, login, authentication, external services"\n+---\n+\n+# Login for external authentication links\n+\n+It is common to use add-ons that allow logging in to your site using third party services.\n+Such add-ons include using authentication services provided by KeyCloak, GitHub, or other OAuth2 or OpenID Connect enabled services.\n+\n+When you install one of these add-ons, it modifies the login process, directing the user to third party services.\n+\n+To expose the links provided by these add-ons, `plone.restapi` provides an adapter based service registration.\n+It lets those add-ons know that the REST API can use those services to authenticate users.\n+This will mostly be used by frontends that need to show the end user the links to those services.\n+\n+To achieve that, third party products need to register one or more adapters for the Plone site root object, providing the `plone.restapi.interfaces.IExternalLoginProviders` interface.\n+\n+In the adapter, the add-on needs to return the list of external links and some metadata, including the `id`, `title`, and name of the `plugin`.\n+\n+An example adapter would be the following, in a file named {file}`adapter.py`:\n+\n+```python\n+from zope.component import adapter\n+from zope.interface import implementer\n+\n+@adapter(IPloneSiteRoot)\n+@implementer(IExternalLoginProviders)\n+class MyExternalLinks:\n+ def __init__(self, context):\n+ self.context = context\n+\n+ def get_providers(self):\n+ return [\n+ {\n+ "id": "myprovider",\n+ "title": "Provider",\n+ "plugin": "pas.plugins.authomatic",\n+ "url": "https://some.example.com/login-url",\n+ },\n+ {\n+ "id": "github",\n+ "title": "GitHub",\n+ "plugin": "pas.plugins.authomatic",\n+ "url": "https://some.example.com/login-authomatic/github",\n+ },\n+ ]\n+```\n+\n+With the corresponding ZCML stanza, in the corresponding {file}`configure.zcml` file:\n+\n+```xml\n+<adapter factory=".adapter.MyExternalLinks" name="my-external-links"/>\n+```\n+\n+The API request would be as follows:\n+\n+```{eval-rst}\n+.. http:example:: curl httpie python-requests\n+ :request: ../../../src/plone/restapi/tests/http-examples/external_authentication_links.req\n+```\n+\n+The server will respond with a `Status 200` and the list of external providers:\n+\n+```{literalinclude} ../../../src/plone/restapi/tests/http-examples/external_authentication_links.resp\n+:language: http\n+```\ndiff --git a/news/1757.feature b/news/1757.feature\nnew file mode 100644\nindex 0000000000..c678441b4e\n--- /dev/null\n+++ b/news/1757.feature\n@@ -0,0 +1 @@\n+Add a `@login` endpoint to get external login services\' links. @erral\ndiff --git a/src/plone/restapi/interfaces.py b/src/plone/restapi/interfaces.py\nindex 5c2aa337e6..9d5c2bcede 100644\n--- a/src/plone/restapi/interfaces.py\n+++ b/src/plone/restapi/interfaces.py\n@@ -240,3 +240,14 @@ class IBlockVisitor(Interface):\n \n def __call__(self, block):\n """Return an iterable of sub-blocks found inside `block`."""\n+\n+\n+class ILoginProviders(Interface):\n+ """An interface needed to be implemented by providers that want to be listed\n+ in the @login endpoint\n+ """\n+\n+ def get_providers():\n+ """\n+ return a list of login providers, with its id, title, plugin and url\n+ """\ndiff --git a/src/plone/restapi/services/auth/configure.zcml b/src/plone/restapi/services/auth/configure.zcml\nindex dec5304c50..f5604d81f2 100644\n--- a/src/plone/restapi/services/auth/configure.zcml\n+++ b/src/plone/restapi/services/auth/configure.zcml\n@@ -3,6 +3,13 @@\n xmlns:plone="http://namespaces.plone.org/plone"\n xmlns:zcml="http://namespaces.zope.org/zcml"\n >\n+ <plone:service\n+ method="GET"\n+ factory=".get.Login"\n+ for="Products.CMFPlone.interfaces.IPloneSiteRoot"\n+ permission="zope.Public"\n+ name="@login"\n+ />\n \n <plone:service\n method="POST"\ndiff --git a/src/plone/restapi/services/auth/get.py b/src/plone/restapi/services/auth/get.py\nnew file mode 100644\nindex 0000000000..d4cae56500\n--- /dev/null\n+++ b/src/plone/restapi/services/auth/get.py\n@@ -0,0 +1,14 @@\n+# -*- coding: utf-8 -*-\n+from plone.restapi.interfaces import ILoginProviders\n+from plone.restapi.services import Service\n+from zope.component import getAdapters\n+\n+\n+class Login(Service):\n+ def reply(self):\n+ adapters = getAdapters((self.context,), ILoginProviders)\n+ external_providers = []\n+ for name, adapter in adapters:\n+ external_providers.extend(adapter.get_providers())\n+\n+ return {"options": external_providers}\ndiff --git a/src/plone/restapi/tests/http-examples/external_authentication_links.req b/src/plone/restapi/tests/http-examples/external_authentication_links.req\nnew file mode 100644\nindex 0000000000..92469012d8\n--- /dev/null\n+++ b/src/plone/restapi/tests/http-examples/external_authentication_links.req\n@@ -0,0 +1,3 @@\n+GET /plone/@login HTTP/1.1\n+Accept: application/json\n+Authorization: Basic YWRtaW46c2VjcmV0\ndiff --git a/src/plone/restapi/tests/http-examples/external_authentication_links.resp b/src/plone/restapi/tests/http-examples/external_authentication_links.resp\nnew file mode 100644\nindex 0000000000..b88f62ab5e\n--- /dev/null\n+++ b/src/plone/restapi/tests/http-examples/external_authentication_links.resp\n@@ -0,0 +1,19 @@\n+HTTP/1.1 200 OK\n+Content-Type: application/json\n+\n+{\n+ "options": [\n+ {\n+ "id": "myprovider",\n+ "plugin": "myprovider",\n+ "title": "Provider",\n+ "url": "https://some.example.com/login-url"\n+ },\n+ {\n+ "id": "github",\n+ "plugin": "github",\n+ "title": "GitHub",\n+ "url": "https://some.example.com/login-authomatic/github"\n+ }\n+ ]\n+}\ndiff --git a/src/plone/restapi/tests/test_auth.py b/src/plone/restapi/tests/test_auth.py\nindex ece162b21e..e7df8470bb 100644\n--- a/src/plone/restapi/tests/test_auth.py\n+++ b/src/plone/restapi/tests/test_auth.py\n@@ -7,6 +7,9 @@\n from zExceptions import Unauthorized\n from zope.event import notify\n from ZPublisher.pubevents import PubStart\n+from zope.component import provideAdapter\n+from plone.restapi.interfaces import ILoginProviders\n+from Products.CMFPlone.interfaces import IPloneSiteRoot\n \n \n class TestLogin(TestCase):\n@@ -208,3 +211,57 @@ def test_renew_fails_on_invalid_token(self):\n self.assertEqual(\n res["error"]["type"], "Invalid or expired authentication token"\n )\n+\n+\n+class MyExternalLinks:\n+ def __init__(self, context):\n+ self.context = context\n+\n+ def get_providers(self):\n+ return [\n+ {\n+ "id": "myprovider",\n+ "title": "Provider",\n+ "plugin": "myprovider",\n+ "url": "https://some.example.com/login-url",\n+ },\n+ {\n+ "id": "github",\n+ "title": "GitHub",\n+ "plugin": "github",\n+ "url": "https://some.example.com/login-authomatic/github",\n+ },\n+ ]\n+\n+\n+class TestExternalLoginServices(TestCase):\n+ layer = PLONE_RESTAPI_DX_INTEGRATION_TESTING\n+\n+ def setUp(self):\n+ self.portal = self.layer["portal"]\n+ self.request = self.layer["request"]\n+\n+ provideAdapter(\n+ MyExternalLinks,\n+ adapts=(IPloneSiteRoot,),\n+ provides=ILoginProviders,\n+ name="test-external-links",\n+ )\n+\n+ def traverse(self, path="/plone/@login", accept="application/json", method="GET"):\n+ request = self.layer["request"]\n+ request.environ["PATH_INFO"] = path\n+ request.environ["PATH_TRANSLATED"] = path\n+ request.environ["HTTP_ACCEPT"] = accept\n+ request.environ["REQUEST_METHOD"] = method\n+ notify(PubStart(request))\n+ return request.traverse(path)\n+\n+ def test_provider_returns_list(self):\n+ service = self.traverse()\n+ res = service.reply()\n+ self.assertEqual(service.request.response.status, 200)\n+ self.assertTrue(isinstance(res, dict))\n+ self.assertIn("options", res)\n+ self.assertTrue(isinstance(res.get("options"), list))\n+ self.assertTrue(len(res.get("options")), 2)\ndiff --git a/src/plone/restapi/tests/test_documentation.py b/src/plone/restapi/tests/test_documentation.py\nindex 774bbd94b5..574873ce6e 100644\n--- a/src/plone/restapi/tests/test_documentation.py\n+++ b/src/plone/restapi/tests/test_documentation.py\n@@ -42,6 +42,10 @@\n from plone.app.testing import popGlobalRegistry\n from plone.app.testing import pushGlobalRegistry\n from plone.restapi.testing import register_static_uuid_utility\n+from zope.component import provideAdapter\n+from plone.restapi.interfaces import ILoginProviders\n+from Products.CMFPlone.interfaces import IPloneSiteRoot\n+\n \n import collections\n import json\n@@ -86,6 +90,27 @@\n open_kw = {"newline": "\\n"}\n \n \n+class MyExternalLinks:\n+ def __init__(self, context):\n+ self.context = context\n+\n+ def get_providers(self):\n+ return [\n+ {\n+ "id": "myprovider",\n+ "title": "Provider",\n+ "plugin": "myprovider",\n+ "url": "https://some.example.com/login-url",\n+ },\n+ {\n+ "id": "github",\n+ "title": "GitHub",\n+ "plugin": "github",\n+ "url": "https://some.example.com/login-authomatic/github",\n+ },\n+ ]\n+\n+\n def normalize_test_port(value):\n # When you run these tests in the Plone core development buildout,\n # the port number is random. Normalize this to the default port.\n@@ -227,6 +252,13 @@ def setUp(self):\n super().setUp()\n self.document = self.create_document()\n alsoProvides(self.document, ITTWLockable)\n+ provideAdapter(\n+ MyExternalLinks,\n+ adapts=(IPloneSiteRoot,),\n+ provides=ILoginProviders,\n+ name="test-external-links",\n+ )\n+\n transaction.commit()\n \n def tearDown(self):\n@@ -787,6 +819,12 @@ def test_documentation_jwt_logout(self):\n )\n save_request_and_response_for_docs("jwt_logout", response)\n \n+ def test_documentation_external_doc_links(self):\n+ response = self.api_session.get(\n+ f"{self.portal.absolute_url()}/@login",\n+ )\n+ save_request_and_response_for_docs("external_authentication_links", response)\n+\n def test_documentation_batching(self):\n folder = self.portal[\n self.portal.invokeFactory("Folder", id="folder", title="Folder")\n'

0 comments on commit 5373f77

Please sign in to comment.