Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
83 changes: 54 additions & 29 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@
[![PyPI - License](https://img.shields.io/pypi/l/pas.plugins.oidc)](https://pypi.org/project/pas.plugins.oidc/)
[![PyPI - Status](https://img.shields.io/pypi/status/pas.plugins.oidc)](https://pypi.org/project/pas.plugins.oidc/)


[![PyPI - Plone Versions](https://img.shields.io/pypi/frameworkversions/plone/pas.plugins.oidc)](https://pypi.org/project/pas.plugins.oidc/)

[![Meta](https://github.com/collective/pas.plugins.oidc/actions/workflows/meta.yml/badge.svg)](https://github.com/collective/pas.plugins.oidc/actions/workflows/meta.yml)
Expand All @@ -22,6 +21,7 @@
</div>

## Intro

This is a Plone authentication plugin for OpenID Connect.
OAuth 2.0 should work as well because OpenID Connect is built on top of this protocol.

Expand All @@ -40,49 +40,48 @@ This package supports Plone sites using Volto and ClassicUI.

For proper Volto support, the requirements are:

* plone.restapi >= 8.34.0
* Volto >= 16.10.0
- plone.restapi >= 8.34.0
- Volto >= 16.10.0

Add **pas.plugins.oidc** to the Plone installation using `pip`:

``bash
`bash
pip install pas.plugins.oidc
``
`

### Requirements

As of version 2.* of this package the minimum requirements are Plone 6.0 and python 3.8.
As of version 2.\* of this package the minimum requirements are Plone 6.0 and python 3.8.

### Warning

Pay attention to the customization of `User info property used as userid` field, with the wrong configuration it's easy to impersonate another user.


## Configure the plugin

* Go to the Add-ons control panel and install `pas.plugins.oidc`.
* In the ZMI go to the plugin properties at `http://localhost:8080/Plone/acl_users/oidc/manage_propertiesForm`
* Configure the properties with the data obtained from your provider:
* `OIDC/Oauth2 Issuer`
* `Client ID`
* `Client secret`
* `redirect_uris`: this needs to match the **public URL** where the user will be redirected after the login flow is completed. It needs to include
- Go to the Add-ons control panel and install `pas.plugins.oidc`.
- In the ZMI go to the plugin properties at `http://localhost:8080/Plone/acl_users/oidc/manage_propertiesForm`
- Configure the properties with the data obtained from your provider:
- `OIDC/Oauth2 Issuer`
- `Client ID`
- `Client secret`
- `redirect_uris`: this needs to match the **public URL** where the user will be redirected after the login flow is completed. It needs to include
the `/Plone/acl_users/oidc/callback` part. When using Volto you need to expose Plone somehow to have the login process finish correctly.
* `Use Zope session data manager`: see the section below about the usage of session.
* `Create user / update user properties`: when selected the user data in Plone will be updated with the data coming from the OIDC provider.
* `Create authentication __ac ticket`: when selected the user will be allowed to act as a logged-in user in Plone.
* `Create authentication auth_token (Volto/REST API) ticket`: when selected the user will be allowed to act as a logged-in user in the Volto frontend.
* `Open ID scopes to request to the server`: information requested to the OIDC provider. Leave it as it is or modify it according to your provider's information.
* `Use PKCE`: when enabled uses [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) when requesting authentication from the provider.
- `Use Zope session data manager`: see the section below about the usage of session.
- `Create user / update user properties`: when selected the user data in Plone will be updated with the data coming from the OIDC provider.
- `Create authentication __ac ticket`: when selected the user will be allowed to act as a logged-in user in Plone.
- `Create authentication auth_token (Volto/REST API) ticket`: when selected the user will be allowed to act as a logged-in user in the Volto frontend.
- `Open ID scopes to request to the server`: information requested to the OIDC provider. Leave it as it is or modify it according to your provider's information.
- `Use PKCE`: when enabled uses [PKCE](https://datatracker.ietf.org/doc/html/rfc7636) when requesting authentication from the provider.

### Login and Logout URLs

#### Default UI (Volto)

When using this plugin with a [Volto frontend](https://6.docs.plone.org/volto/index.html), please install [@plone-collective/volto-authomatic](https://github.com/collective/volto-authomatic) add-on on your frontend project.

* **Login URL**: `<Path to your Plone site>`/login
* **Logout URL**: `<Path to your Plone site>`/logout
- **Login URL**: `<Path to your Plone site>`/login
- **Logout URL**: `<Path to your Plone site>`/logout

Also, on the OpenID provider, configure the Redirect URL as **`<Path to your Plone site>`/login_oidc/oidc**.

Expand All @@ -94,14 +93,14 @@ will not trigger the usage of the plugin.

To login into a site using the OIDC provider, you will need to change those login URLs to the following:

* **Login URL**: /`<Plone Site Id>`/acl_users/`<oidc pas plugin id>`/login
* **Logout URL**: /`<Plone Site Id>`/acl_users/`<oidc pas plugin id>`/logout
- **Login URL**: /`<Plone Site Id>`/acl_users/`<oidc pas plugin id>`/login
- **Logout URL**: /`<Plone Site Id>`/acl_users/`<oidc pas plugin id>`/logout

*Where:*
_Where:_

* `Plone Site Id`: is the id you gave to the Plone site when you created it. It is usually `Plone` but may vary. It is the last part of the URL when you browse Plone directly without using any proxy server, ex. `http://localhost:8080/Plone+` -> `Plone`.
- `Plone Site Id`: is the id you gave to the Plone site when you created it. It is usually `Plone` but may vary. It is the last part of the URL when you browse Plone directly without using any proxy server, ex. `http://localhost:8080/Plone+` -> `Plone`.

* `oidc pas plugin id`: is the id you gave to the OIDC plugin when you created it inside the Plone PAS administration panel. If you just used the default configuration and installed this plugin using Plone's Add-on Control Panel, this id will be `oidc`.
- `oidc pas plugin id`: is the id you gave to the OIDC plugin when you created it inside the Plone PAS administration panel. If you just used the default configuration and installed this plugin using Plone's Add-on Control Panel, this id will be `oidc`.

### Example setup with Keycloak

Expand Down Expand Up @@ -154,6 +153,7 @@ parameter enabled also. The problem is that if the deprecated parameter is enabl
will not work.

So, this is the way it works:

* With legacy `redirect_uri` parameter enabled in Keycloak, the plugin works in default mode.
* With legacy `redirect_uri` parameter enabled in Keycloak, the plugin also works with legacy mode.
* With legacy `redirect_uri` parameter disabled in Keycloak (default after version 18), the plugin works in default mode.
Expand All @@ -164,7 +164,7 @@ So, for Keycloak, it does not matter if we use the default or legacy mode if the
*Notes:*

* If legacy `redirect_uri` parameter is disabled in Keycloak, this is the default since version 18 of Keycloak according
to this comment in *Starck Overflow*: https://stackoverflow.com/a/72142887.
to this comment in *Stack Overflow*: https://stackoverflow.com/a/72142887.
* The plugin will work only if the `Use deprecated redirect_uri for logout url(/Plone/acl_users/oidc/logout)`
option is un-checked at the plugin properties at http://localhost:8081/Plone/acl_users/oidc/manage_propertiesForm.

Expand All @@ -187,7 +187,7 @@ Specifically, here we will use a Docker image, so follow the instructions on how
* `Open ID scopes to request to the server`: this depends on which version of Keycloak you are using, and which scopes are available there.
In recent Keycloak versions, you *must* include `openid` as scope.
Suggestion is to use `openid` and `profile`.
* **Tip:** Leave the rest at the defaults, unless you know what you are doing.
* **Tip:** Leave the rest at the defaults, unless you know what you are doing.
* Click `Save`.

**Plone is ready done configured!**
Expand All @@ -213,6 +213,31 @@ Currently, the Plone logout form is unchanged.
Instead, for testing go to the logout page of the plugin: http://localhost:8081/Plone/acl_users/oidc/logout,
this will take you to Keycloak to logout, and then return to the post-logout redirect URL.

### Configuration example for Sign in with Apple

[Sign in with Apple](https://developer.apple.com/sign-in-with-apple/) is a way to delegate user authentication on Apple, so all Apple users can sign in in your site seamesly.

But this means that you will need to do some extra steps on the Apple side in order to get your site correctly configured.

1. Register an application

Go to the Apple Developer Portal and create a new App ID in the [Certificates, Identifiers and Profiles](https://developer.apple.com/account/resources/identifiers/list/bundleId) section.

2. Create a Service ID

In the same page, create a new Service ID. The identifier you will add here will be the client*id to be used when configuring the login process. Use the reverse-domain-name style notation to create this id, something like: com.yourcompany.yoursite. Tick the \_Sign in with Apple* option. Click on _Configure_ next to the option and set your application domain and the callback url. You must enter an _https_ URL.

3. Create a Private key

Choose Keys on the side tab, select "Sign in with Apple" and click _Configure_. You will need to select the App Id created on the first step and download your private key, that will be shown just once.

Now you have all the required items to configure this plugin:

- client_id: the service id you created
- client_secret: the value of the private key downloaded in the last step. Open the file with a text editor of your choice, remove the ----PRIVATE KEY BEGIN---- and ----PRIVATE KEY END---- markers and any new line.
- Apple consumer team: this is your id in Apple. You can find in in the top right part of the site when logged in in the Apple developer portal
- Apple consumer id key: it is shown in the private you created in the last step.

## Technical Decisions

### Usage of sessions in the login process
Expand Down
1 change: 1 addition & 0 deletions news/49.feature
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Add Sign in with Apple support @erral
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ Zope = [
'Products.CMFCore', 'Products.CMFDynamicViewFTI',
]
python-dateutil = ['dateutil']
PyJWT = ['jwt']
ignore-packages = ['plone.restapi', 'plone.volto', 'zestreleaser.towncrier', 'zest.releaser', 'pytest', 'pytest-cov', 'pytest-plone', 'pytest-docker', 'pytest-vcr', 'pytest-mock', 'zope.pytestlayer', 'requests-mock', 'vcrpy']
Plone = ['Products.CMFPlone', 'Products.CMFCore', 'Products.GenericSetup', 'Products.PluggableAuthService', 'Products.PlonePAS']

Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"plone.protect",
"plone.restapi>=8.34.0",
"oic",
"PyJWT",
],
extras_require={
"test": [
Expand Down
34 changes: 33 additions & 1 deletion src/pas/plugins/oidc/browser/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@
from urllib.parse import quote
from zExceptions import Unauthorized

import json
import urllib.parse


class RequireLoginView(BrowserView):
"""Our version of the require-login view from Plone.
Expand Down Expand Up @@ -120,6 +123,10 @@ def __call__(self):
session = utils.load_existing_session(self.context, self.request)
client = self.context.get_oauth2_client()
qs = self.request.environ["QUERY_STRING"]
if not qs:
# With Apple, the response comes as a POST, thus it does not
# com in the QUERY_STRING but in the request.form
qs = urllib.parse.urlencode(self.request.form)
args, state = utils.parse_authorization_response(
self.context, qs, client, session
)
Expand All @@ -130,10 +137,35 @@ def __call__(self):
"phone_number_verified": utils.SINGLE_OPTIONAL_BOOLEAN_AS_STRING,
}
)

# The response you get back is an instance of an AccessTokenResponse
# or again possibly an ErrorResponse instance.

if self.context.getProperty("apple_login_enabled"):
args.update(
{
"client_id": self.context.getProperty("client_id"),
"client_secret": self.context._build_apple_secret(),
}
)

initial_user_info = {}
if self.context.getProperty("apple_login_enabled"):
# Let's check if this is this user's first login
# if so, their name and email could come in the first
# response from authorization response
# Weird Apple issues...
user = self.request.form.get("user", "")
if user:
user_decoded = json.loads(user)
first_name = user_decoded.get("name", {}).get("firstName", "")
last_name = user_decoded.get("name", {}).get("lastName", "")
email = user_decoded.get("email", "")
initial_user_info["given_name"] = first_name
initial_user_info["family_name"] = last_name
initial_user_info["email"] = email

user_info = utils.get_user_info(client, state, args)
user_info.update(initial_user_info)
if user_info:
self.context.rememberIdentity(user_info)
self.request.response.setHeader(
Expand Down
62 changes: 61 additions & 1 deletion src/pas/plugins/oidc/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@
from zope.interface import Interface

import itertools
import jwt
import plone.api as api
import string
import time


manage_addOIDCPluginForm = PageTemplateFile(
Expand Down Expand Up @@ -88,6 +90,9 @@ class OIDCPlugin(BasePlugin):
use_deprecated_redirect_uri_for_logout = False
use_modified_openid_schema = False
user_property_as_userid = "sub"
apple_login_enabled = False
apple_consumer_team = ""
apple_consumer_id_key = ""

_properties = (
dict(id="title", type="string", mode="w", label="Title"),
Expand Down Expand Up @@ -156,6 +161,29 @@ class OIDCPlugin(BasePlugin):
mode="w",
label="User info property used as userid, default 'sub'",
),
dict(
id="apple_login_enabled",
type="boolean",
mode="w",
label="Check if you want to login with Apple",
),
dict(
id="apple_consumer_team",
type="string",
mode="w",
label="Apple consumer team as defined by Apple",
),
dict(
id="apple_consumer_id_key",
type="string",
mode="w",
label="Apple consumer id key as defined by Apple",
),
)

APPLE_TOKEN_TTL_SEC = 6 * 30 * 24 * 60 * 60
APPLE_TOKEN_AUDIENCE = (
"https://appleid.apple.com" # nosec bandit: disable hardcoded_password_string
)

def __init__(self, id, title=None):
Expand Down Expand Up @@ -320,6 +348,30 @@ def _setupJWTTicket(self, user_id, user):
# TODO: take care of path, cookiename and domain options ?
response.setCookie("auth_token", token, path="/")

def _build_apple_secret(self):
now = int(time.time())

client_id = self.getProperty("client_id")
team_id = self.getProperty("apple_consumer_team")
key_id = self.getProperty("apple_consumer_id_key")
private_key = self.getProperty("client_secret")

headers = {"kid": key_id}
payload = {
"iss": team_id,
"iat": now,
"exp": now + self.APPLE_TOKEN_TTL_SEC,
"aud": self.APPLE_TOKEN_AUDIENCE,
"sub": client_id,
}

private_key = (
f"-----BEGIN PRIVATE KEY-----\n{private_key}\n-----END PRIVATE KEY-----"
)
return jwt.encode(
payload, key=private_key.encode(), algorithm="ES256", headers=headers
)

# TODO: memoize (?)
def get_oauth2_client(self):
try:
Expand All @@ -332,8 +384,16 @@ def get_oauth2_client(self):
provider_info = client.provider_config(self.getProperty("issuer")) # noqa
info = {
"client_id": self.getProperty("client_id"),
"client_secret": self.getProperty("client_secret"),
"token_endpoint_auth_method": provider_info.get(
"token_endpoint_auth_methods_supported"
)[0],
}

if self.getProperty("apple_login_enabled"):
info.update({"client_secret": self._build_apple_secret()})
else:
info.update({"client_secret": self.getProperty("client_secret")})

client_reg = RegistrationResponse(**info)
client.store_registration_info(client_reg)
return client
Expand Down
31 changes: 28 additions & 3 deletions src/pas/plugins/oidc/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,10 @@ def authorization_flow_args(plugin: plugins.OIDCPlugin, session: Session) -> dic
# and send it in the request as a base64-encoded urlsafe string of the sha256 hash of that string
args["code_challenge"] = pkce_code_verifier_challenge(session.get("verifier"))
args["code_challenge_method"] = "S256"

if plugin.getProperty("apple_login_enabled"):
args["response_mode"] = "form_post"

return args


Expand Down Expand Up @@ -170,10 +174,31 @@ def parse_authorization_response(


def get_user_info(client, state, args) -> Union[message.OpenIDSchema, dict]:
# Decide which authentication method to use
allowed_authn_methods = client.registration_response.get(
"token_endpoint_auth_method"
)
allowed_authn_method = "client_secret_basic"
if allowed_authn_methods and isinstance(allowed_authn_methods, list):
# Here we should decide which method we will use among the ones
# offered by the provider.
# But we would need extra information to implement some of those
# methods such as private_key_jwt or client_secret_jwt.
# So that's the reason why we only allow `client_secret_post` (the
# only one allowed by Apple) or `client_secret_basic` (the most
# basic one, allowed by most of the providers we have worked with)
if "client_secret_post" in allowed_authn_methods:
allowed_authn_method = "client_secret_post"
elif "client_secret_basic" in allowed_authn_methods:
allowed_authn_method = "client_secret_basic"
elif allowed_authn_methods and isinstance(allowed_authn_methods, str):
# Yay, Apple returns a string in the allowed_authn_methods
# We may face the same in some other providers, so we keep it
# as we receive it
allowed_authn_method = allowed_authn_methods

resp = client.do_access_token_request(
state=state,
request_args=args,
authn_method="client_secret_basic",
state=state, request_args=args, authn_method=allowed_authn_method
)
user_info = {}
if isinstance(resp, message.AccessTokenResponse):
Expand Down