Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/source/endpoints/index.md
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
---
myst:
html_meta:
"description": "Usage of the Plone REST API."
"property=og:description": "Usage of the Plone REST API."
"property=og:title": "Usage of the Plone REST API"
"keywords": "Plone, plone.restapi, REST, API, Usage"
"description": "Endpoints of the Plone REST API."
"property=og:description": "Endpoints of the Plone REST API."
"property=og:title": "Endpoints of the Plone REST API"
"keywords": "Plone, plone.restapi, REST, API, endpoints"
---

(restapi-endpoints)=
Expand Down Expand Up @@ -33,6 +33,7 @@ groups
history
linkintegrity
locking
login
navigation
navroot
actions
Expand Down
71 changes: 71 additions & 0 deletions docs/source/endpoints/login.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
---
myst:
html_meta:
"description": "The @login endpoint exposes the list of external authentication services that may be used in the Plone site."
"property=og:description": "The @login endpoint exposes the list of external authentication services that may be used in the Plone site."
"property=og:title": "@login for external authentication links"
"keywords": "Plone, plone.restapi, REST, API, login, authentication, external services"
---

# Login for external authentication links

It is common to use add-ons that allow logging in to your site using third party services.
Such add-ons include using authentication services provided by KeyCloak, GitHub, or other OAuth2 or OpenID Connect enabled services.

When you install one of these add-ons, it modifies the login process, directing the user to third party services.

To expose the links provided by these add-ons, `plone.restapi` provides an adapter based service registration.
It lets those add-ons know that the REST API can use those services to authenticate users.
This will mostly be used by frontends that need to show the end user the links to those services.

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.

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`.

An example adapter would be the following, in a file named {file}`adapter.py`:

```python
from zope.component import adapter
from zope.interface import implementer

@adapter(IPloneSiteRoot)
@implementer(IExternalLoginProviders)
class MyExternalLinks:
def __init__(self, context):
self.context = context

def get_providers(self):
return [
{
"id": "myprovider",
"title": "Provider",
"plugin": "myprovider",
"url": "https://some.example.com/login-url",
},
{
"id": "github",
"title": "GitHub",
"plugin": "github",
"url": "https://some.example.com/login-authomatic/github",
},
]
```

With the corresponding ZCML stanza, in the corresponding {file}`configure.zcml` file:

```xml
<adapter factory=".adapter.MyExternalLinks" name="my-external-links"/>
```

The API request would be as follows:

```{eval-rst}
.. http:example:: curl httpie python-requests
:request: ../../../src/plone/restapi/tests/http-examples/external_authentication_links.req
```

The server will respond with a `Status 200` and the list of external providers:

```{literalinclude} ../../../src/plone/restapi/tests/http-examples/external_authentication_links.resp
:language: http
```
1 change: 1 addition & 0 deletions news/1757.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add a `@login` endpoint to get external login services' links. @erral
11 changes: 11 additions & 0 deletions src/plone/restapi/interfaces.py
Original file line number Diff line number Diff line change
Expand Up @@ -240,3 +240,14 @@ class IBlockVisitor(Interface):

def __call__(self, block):
"""Return an iterable of sub-blocks found inside `block`."""


class IExternalLoginProviders(Interface):
"""An interface needed to be implemented by providers that want to be listed
in the @login endpoint
"""

def get_providers():
"""
return a list of login providers, with its id, title, plugin and url
"""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should be defined in plone.base instead of plone.restapi, so that an addon doesn't need to depend on plone.restapi just to register this adapter and provide information about what providers it supports?

On the other hand, that would make it harder to backport support for this feature to older versions of Plone.

I don't feel strongly about it but in general, plone.restapi's purpose is to provide API access to features that exist at a lower level and might also be accessed via other interfaces.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the whole idea of the endpoint is to avoid duplicated @login endpoints such the ones we have in pas.plugins.authomatic and pas.plugins.oidc to support volto-authomatic

I agree that perhaps this should go in plone.base somewhere and integrate it with Plone's standard login page.

Perhaps we can open an issue in Plone and work on that later on?

7 changes: 7 additions & 0 deletions src/plone/restapi/services/auth/configure.zcml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@
xmlns:plone="http://namespaces.plone.org/plone"
xmlns:zcml="http://namespaces.zope.org/zcml"
>
<plone:service
method="GET"
factory=".get.Login"
for="Products.CMFPlone.interfaces.IPloneSiteRoot"
permission="zope.Public"
name="@login"
/>

<plone:service
method="POST"
Expand Down
14 changes: 14 additions & 0 deletions src/plone/restapi/services/auth/get.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# -*- coding: utf-8 -*-
from plone.restapi.interfaces import IExternalLoginProviders
from plone.restapi.services import Service
from zope.component import getAdapters


class Login(Service):
def reply(self):
adapters = getAdapters((self.context,), IExternalLoginProviders)
external_providers = []
for name, adapter in adapters:
external_providers.extend(adapter.get_providers())

return {"options": external_providers}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
GET /plone/@login HTTP/1.1
Accept: application/json
Authorization: Basic YWRtaW46c2VjcmV0
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
HTTP/1.1 200 OK
Content-Type: application/json

{
"options": [
{
"id": "myprovider",
"plugin": "myprovider",
"title": "Provider",
"url": "https://some.example.com/login-url"
},
{
"id": "github",
"plugin": "github",
"title": "GitHub",
"url": "https://some.example.com/login-authomatic/github"
}
]
}
57 changes: 57 additions & 0 deletions src/plone/restapi/tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@
from zExceptions import Unauthorized
from zope.event import notify
from ZPublisher.pubevents import PubStart
from zope.component import provideAdapter
from plone.restapi.interfaces import IExternalLoginProviders
from Products.CMFPlone.interfaces import IPloneSiteRoot


class TestLogin(TestCase):
Expand Down Expand Up @@ -208,3 +211,57 @@ def test_renew_fails_on_invalid_token(self):
self.assertEqual(
res["error"]["type"], "Invalid or expired authentication token"
)


class MyExternalLinks:
def __init__(self, context):
self.context = context

def get_providers(self):
return [
{
"id": "myprovider",
"title": "Provider",
"plugin": "myprovider",
"url": "https://some.example.com/login-url",
},
{
"id": "github",
"title": "GitHub",
"plugin": "github",
"url": "https://some.example.com/login-authomatic/github",
},
]


class TestExternalLoginServices(TestCase):
layer = PLONE_RESTAPI_DX_INTEGRATION_TESTING

def setUp(self):
self.portal = self.layer["portal"]
self.request = self.layer["request"]

provideAdapter(
MyExternalLinks,
adapts=(IPloneSiteRoot,),
provides=IExternalLoginProviders,
name="test-external-links",
)

def traverse(self, path="/plone/@login", accept="application/json", method="GET"):
request = self.layer["request"]
request.environ["PATH_INFO"] = path
request.environ["PATH_TRANSLATED"] = path
request.environ["HTTP_ACCEPT"] = accept
request.environ["REQUEST_METHOD"] = method
notify(PubStart(request))
return request.traverse(path)

def test_provider_returns_list(self):
service = self.traverse()
res = service.reply()
self.assertEqual(service.request.response.status, 200)
self.assertTrue(isinstance(res, dict))
self.assertIn("options", res)
self.assertTrue(isinstance(res.get("options"), list))
self.assertTrue(len(res.get("options")), 2)
38 changes: 38 additions & 0 deletions src/plone/restapi/tests/test_documentation.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,10 @@
from plone.app.testing import popGlobalRegistry
from plone.app.testing import pushGlobalRegistry
from plone.restapi.testing import register_static_uuid_utility
from zope.component import provideAdapter
from plone.restapi.interfaces import IExternalLoginProviders
from Products.CMFPlone.interfaces import IPloneSiteRoot


import collections
import json
Expand Down Expand Up @@ -86,6 +90,27 @@
open_kw = {"newline": "\n"}


class MyExternalLinks:
def __init__(self, context):
self.context = context

def get_providers(self):
return [
{
"id": "myprovider",
"title": "Provider",
"plugin": "myprovider",
"url": "https://some.example.com/login-url",
},
{
"id": "github",
"title": "GitHub",
"plugin": "github",
"url": "https://some.example.com/login-authomatic/github",
},
]


def normalize_test_port(value):
# When you run these tests in the Plone core development buildout,
# the port number is random. Normalize this to the default port.
Expand Down Expand Up @@ -227,6 +252,13 @@ def setUp(self):
super().setUp()
self.document = self.create_document()
alsoProvides(self.document, ITTWLockable)
provideAdapter(
MyExternalLinks,
adapts=(IPloneSiteRoot,),
provides=IExternalLoginProviders,
name="test-external-links",
)

transaction.commit()

def tearDown(self):
Expand Down Expand Up @@ -787,6 +819,12 @@ def test_documentation_jwt_logout(self):
)
save_request_and_response_for_docs("jwt_logout", response)

def test_documentation_external_doc_links(self):
response = self.api_session.get(
f"{self.portal.absolute_url()}/@login",
)
save_request_and_response_for_docs("external_authentication_links", response)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With my rename of the docs file, should this be changed as well?

Suggested change
save_request_and_response_for_docs("external_authentication_links", response)
save_request_and_response_for_docs("login", response)

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No, this is for the test-generated request and response file. I would leave this as it is, because we already have login.req file in the http-examples folder


def test_documentation_batching(self):
folder = self.portal[
self.portal.invokeFactory("Folder", id="folder", title="Folder")
Expand Down
Loading