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 "Sign in with Apple" extra configuration #46

Closed
wants to merge 24 commits into from
Closed
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
30 changes: 28 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,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 @@ -161,7 +162,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 @@ -184,7 +185,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 @@ -210,6 +211,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 setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@
"plone.api",
"plone.restapi>=8.34.0",
"oic",
"PyJWT",
],
extras_require={
"test": [
Expand Down
4 changes: 3 additions & 1 deletion src/pas/plugins/oidc/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Init and utils."""

from AccessControl.Permissions import manage_users as ManageUsers
from Products.PluggableAuthService import PluggableAuthService as PAS
from zope.i18nmessageid import MessageFactory
Expand All @@ -23,5 +24,6 @@ def initialize(context): # pragma: no cover
context.registerClass(
plugins.OIDCPlugin,
permission=ManageUsers,
constructors=(plugins.add_oidc_plugin,),
constructors=(plugins.manage_addOIDCPluginForm, plugins.addOIDCPlugin),
visibility=None,
)
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
1 change: 1 addition & 0 deletions src/pas/plugins/oidc/interfaces.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Module where all interfaces, events and exceptions live."""

from zope.publisher.interfaces.browser import IDefaultBrowserLayer


Expand Down
1 change: 1 addition & 0 deletions src/pas/plugins/oidc/locales/update.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
"""Update locales."""

from pathlib import Path

import logging
Expand Down
91 changes: 84 additions & 7 deletions src/pas/plugins/oidc/plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from plone.base.utils import safe_text
from plone.protect.utils import safeWrite
from Products.CMFCore.utils import getToolByName
from Products.PageTemplates.PageTemplateFile import PageTemplateFile
from Products.PluggableAuthService.interfaces.plugins import IAuthenticationPlugin
from Products.PluggableAuthService.interfaces.plugins import IChallengePlugin
from Products.PluggableAuthService.interfaces.plugins import IUserAdderPlugin
Expand All @@ -21,8 +22,28 @@
from zope.interface import Interface

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


manage_addOIDCPluginForm = PageTemplateFile(
"www/oidcPluginForm", globals(), __name__="manage_addOIDCPluginForm"
)


def addOIDCPlugin(dispatcher, id, title=None, REQUEST=None):
"""Add a HTTP Basic Auth Helper to a Pluggable Auth Service."""
plugin = OIDCPlugin(id, title)
dispatcher._setObject(plugin.getId(), plugin)

if REQUEST is not None:
REQUEST["RESPONSE"].redirect(
"%s/manage_workspace"
"?manage_tabs_message="
"OIDC+Plugin+added." % dispatcher.absolute_url()
)


PWCHARS = string.ascii_letters + string.digits + string.punctuation
Expand Down Expand Up @@ -68,6 +89,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="issuer", type="string", mode="w", label="OIDC/Oauth2 Issuer"),
Expand Down Expand Up @@ -135,8 +159,35 @@ 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):
self._setId(id)
self.title = title

def rememberIdentity(self, userinfo):
if not isinstance(userinfo, (OpenIDSchema, dict)):
raise AssertionError(
Expand Down Expand Up @@ -295,6 +346,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 @@ -307,8 +382,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 Expand Up @@ -367,12 +450,6 @@ def challenge(self, request, response):
)


def add_oidc_plugin():
# Form for manually adding our plugin.
# But we do this in setuphandlers.py always.
pass


# https://github.com/collective/Products.AutoUserMakerPASPlugin/blob/master/Products/AutoUserMakerPASPlugin/auth.py
@contextmanager
def safe_write(request):
Expand Down
7 changes: 5 additions & 2 deletions src/pas/plugins/oidc/services/oidc/oidc.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,9 @@ def json_body(self) -> dict:
def plugin(self) -> OIDCPlugin:
if not self._plugin:
try:
self._plugin = utils.get_plugin()
for plugin in utils.get_plugins():
if plugin.getId() == self.provider_id:
self._plugin = plugin
except AttributeError:
# Plugin not installed yet
self._plugin = None
Expand Down Expand Up @@ -77,7 +79,8 @@ def reply(self) -> dict:
"""
provider = self.provider_id
plugin = self.plugin
if not (plugin and provider == "oidc"):

if not plugin:
return self._provider_not_found(provider)

session = utils.initialize_session(plugin, self.request)
Expand Down
40 changes: 19 additions & 21 deletions src/pas/plugins/oidc/setuphandlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ def post_install(context):
# Create plugin if it does not exist.
if PLUGIN_ID not in pas.objectIds():
plugin = OIDCPlugin(
id=PLUGIN_ID,
title="OpenID Connect",
)
plugin.id = PLUGIN_ID
Expand Down Expand Up @@ -60,27 +61,24 @@ def post_install(context):

def activate_plugin(context, interface_name, move_to_top=False):
pas = api.portal.get_tool("acl_users")
if PLUGIN_ID not in pas.objectIds():
raise ValueError(f"acl_users has no plugin {PLUGIN_ID}.")

plugin = getattr(pas, PLUGIN_ID)
if not isinstance(plugin, OIDCPlugin):
raise ValueError(f"Existing PAS plugin {PLUGIN_ID} is not a OIDCPlugin.")

# This would activate one interface and deactivate all others:
# plugin.manage_activateInterfaces([interface_name])
# So only take over the necessary code from manage_activateInterfaces.
plugins = pas.plugins
iface = plugins._getInterfaceFromName(interface_name)
if PLUGIN_ID not in plugins.listPluginIds(iface):
plugins.activatePlugin(iface, PLUGIN_ID)
logger.info(f"Activated interface {interface_name} for plugin {PLUGIN_ID}")

if move_to_top:
# Order some plugins to make sure our plugin is at the top.
# This is not needed for all plugin interfaces.
plugins.movePluginsTop(iface, [PLUGIN_ID])
logger.info(f"Moved {PLUGIN_ID} to top of {interface_name}.")
for plugin in pas.objectValues():
if isinstance(plugin, OIDCPlugin):
# This would activate one interface and deactivate all others:
# plugin.manage_activateInterfaces([interface_name])
# So only take over the necessary code from manage_activateInterfaces.
plugins = pas.plugins
iface = plugins._getInterfaceFromName(interface_name)
if plugin.getId() not in plugins.listPluginIds(iface):
plugins.activatePlugin(iface, plugin.getId())
logger.info(
f"Activated interface {interface_name} for plugin {plugin.getId()}"
)

if move_to_top:
# Order some plugins to make sure our plugin is at the top.
# This is not needed for all plugin interfaces.
plugins.movePluginsTop(iface, [plugin.getId()])
logger.info(f"Moved {plugin.getId()} to top of {interface_name}.")


def activate_challenge_plugin(context):
Expand Down
Loading
Loading