diff --git a/README.md b/README.md index bb0a712..180307c 100644 --- a/README.md +++ b/README.md @@ -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) @@ -22,6 +21,7 @@ ## 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. @@ -40,40 +40,39 @@ 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 @@ -81,8 +80,8 @@ Pay attention to the customization of `User info property used as userid` field, 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**: ``/login -* **Logout URL**: ``/logout +- **Login URL**: ``/login +- **Logout URL**: ``/logout Also, on the OpenID provider, configure the Redirect URL as **``/login_oidc/oidc**. @@ -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**: /``/acl_users/``/login -* **Logout URL**: /``/acl_users/``/logout +- **Login URL**: /``/acl_users/``/login +- **Logout URL**: /``/acl_users/``/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 @@ -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. @@ -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. @@ -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!** @@ -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 diff --git a/news/49.feature b/news/49.feature new file mode 100644 index 0000000..a5b582c --- /dev/null +++ b/news/49.feature @@ -0,0 +1 @@ +Add Sign in with Apple support @erral diff --git a/pyproject.toml b/pyproject.toml index 2d8fd56..44371d3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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'] diff --git a/setup.py b/setup.py index e0b252a..4f78310 100644 --- a/setup.py +++ b/setup.py @@ -60,6 +60,7 @@ "plone.protect", "plone.restapi>=8.34.0", "oic", + "PyJWT", ], extras_require={ "test": [ diff --git a/src/pas/plugins/oidc/browser/view.py b/src/pas/plugins/oidc/browser/view.py index 91868d6..d1c170e 100644 --- a/src/pas/plugins/oidc/browser/view.py +++ b/src/pas/plugins/oidc/browser/view.py @@ -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. @@ -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 ) @@ -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( diff --git a/src/pas/plugins/oidc/plugins.py b/src/pas/plugins/oidc/plugins.py index bc8368b..40b0726 100644 --- a/src/pas/plugins/oidc/plugins.py +++ b/src/pas/plugins/oidc/plugins.py @@ -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( @@ -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"), @@ -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): @@ -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: @@ -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 diff --git a/src/pas/plugins/oidc/utils.py b/src/pas/plugins/oidc/utils.py index 8652c9f..50a86cb 100644 --- a/src/pas/plugins/oidc/utils.py +++ b/src/pas/plugins/oidc/utils.py @@ -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 @@ -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):