-
-
Notifications
You must be signed in to change notification settings - Fork 74
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
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 <[email protected]> * Update news/1757.feature Co-authored-by: Steve Piercy <[email protected]> * Update news/1757.feature Co-authored-by: Steve Piercy <[email protected]> * 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 <[email protected]> Co-authored-by: David Glick <[email protected]> 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
Showing
1 changed file
with
78 additions
and
51 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 <[email protected]> | ||
|
||
* Update news/1757.feature | ||
|
||
Co-authored-by: Steve Piercy <[email protected]> | ||
|
||
* Update news/1757.feature | ||
|
||
Co-authored-by: Steve Piercy <[email protected]> | ||
|
||
* 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 <[email protected]> | ||
Co-authored-by: David Glick <[email protected]> | ||
|
||
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' | ||
|