-
-
Notifications
You must be signed in to change notification settings - Fork 74
/
Copy pathlast_commit.txt
81 lines (47 loc) · 13.7 KB
/
last_commit.txt
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
Repository: plone.restapi
Branch: refs/heads/main
Date: 2025-01-17T14:49:28-08:00
Author: Mikel Larreategi (erral) <mlarreategi@codesyntax.com>
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 <web@stevepiercy.com>
* Update news/1757.feature
Co-authored-by: Steve Piercy <web@stevepiercy.com>
* Update news/1757.feature
Co-authored-by: Steve Piercy <web@stevepiercy.com>
* 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 <web@stevepiercy.com>
Co-authored-by: David Glick <david@glicksoftware.com>
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
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'