*** Wartungsfenster jeden ersten Mittwoch vormittag im Monat ***

Skip to content
Snippets Groups Projects
Commit 9ef3bad4 authored by Moser, Maximilian's avatar Moser, Maximilian
Browse files

Remove the duplicate Keycloak OAuthClient implementation

* since the keycloak contribution was added to the latest version of
  Invenio-OAuthClient, the fallback isn't required here anymore -- to
  increase maintainability, drop the duplicate code here
parent 8e04e7b1
Branches
Tags
No related merge requests found
......@@ -7,6 +7,9 @@
"""Invenio module containing the configuration for TU Wien."""
from invenio_oauthclient.contrib.keycloak import KeycloakSettingsHelper
try:
from flask_babelex import gettext as _
......@@ -162,11 +165,6 @@ RECAPTCHA_PRIVATE_KEY = None
# Invenio-Keycloak
# ================
try:
from invenio_oauthclient.contrib.keycloak import KeycloakSettingsHelper
except ModuleNotFoundError:
from .keycloak import KeycloakSettingsHelper
helper = KeycloakSettingsHelper(
base_url="https://s194.dl.hpc.tuwien.ac.at", realm="tu-data-test"
)
......
"""Toolkit for creating remote apps that enable sign in/up with Keycloak.
**Note:** In contrast to e.g. GitHub, Keycloak is a self-hosted solution that
requires a bit of configuration to allow OAuth client applications, such as
Invenio. An explanation of how to set up Keycloak is out of scope for this
document, where we focus on configuring Invenio.
0. Set up a Keycloak server and make sure it is configured appropriately,
i.e. a client application for Invenio is configured in a realm in Keycloak.
1. Add the following items to your configuration (``invenio.cfg``).
The ``KeycloakSettingsHelper`` class can be used to help with setting up
the configuration values:
.. code-block:: python
from invenio_oauthclient.contrib import keycloak as k
helper = k.KeycloakSettingsHelper(
base_url="http://yourkeycloakserver.com:8080",
realm="invenio"
)
# create the configuration for Keycloak
# because the URLs usually follow a certain schema, the settings helper
# can be used to more easily build the configuration values:
OAUTHCLIENT_KEYCLOAK_REALM_URL = helper.realm_url
OAUTHCLIENT_KEYCLOAK_USER_INFO_URL = helper.user_info_url
# Keycloak uses JWTs (https://jwt.io/) for their tokens, which
# contain information about the target audience (AUD)
# verification of the expected AUD value can be configured with:
OAUTHCLIENT_KEYCLOAK_VERIFY_AUD = True
OAUTHCLIENT_KEYCLOAK_AUD = "invenio"
# the settings helper can also be used to create the REMOTE APP dicts
OAUTHCLIENT_KEYCLOAK_REMOTE_APP = helper.remote_app()
OAUTHCLIENT_KEYCLOAK_REMOTE_REST_APP = helper.remote_rest_app()
# add Keycloak to the dictionary of remote apps
OAUTHCLIENT_REMOTE_APPS = dict(
keycloak=OAUTHCLIENT_KEYCLOAK_REMOTE_APP,
# ...
)
# to automatically set a user's email address on sign-up, the
# registration forms have to be extended
USERPROFILES_EXTEND_SECURITY_FORMS = True
2. Grab the *Client ID* and *Client Secret* from the client application in
Keycloak and add them to your instance configuration (``invenio.cfg``):
.. code-block:: python
KEYCLOAK_APP_CREDENTIALS = dict(
consumer_key='<CLIENT ID>',
consumer_secret='<CLIENT SECRET>',
)
3. Now go to ``CFG_SITE_SECURE_URL/oauth/login/keycloak/`` (e.g.
https://localhost:4000/oauth/login/keycloak/) and log in.
4. After authenticating successfully, you should see Keycloak listed under
Linked accounts: https://localhost:4000/account/settings/linkedaccounts/
"""
from .handlers import (
disconnect_handler,
disconnect_rest_handler,
info_handler,
setup_handler,
)
from .settings import (
OAUTHCLIENT_KEYCLOAK_AUD,
OAUTHCLIENT_KEYCLOAK_REALM_URL,
OAUTHCLIENT_KEYCLOAK_REMOTE_APP,
OAUTHCLIENT_KEYCLOAK_REMOTE_REST_APP,
OAUTHCLIENT_KEYCLOAK_USER_INFO_URL,
OAUTHCLIENT_KEYCLOAK_VERIFY_AUD,
KeycloakSettingsHelper,
)
__all__ = (
"disconnect_handler",
"disconnect_rest_handler",
"info_handler",
"setup_handler",
"KeycloakSettingsHelper",
"OAUTHCLIENT_KEYCLOAK_AUD",
"OAUTHCLIENT_KEYCLOAK_REALM_URL",
"OAUTHCLIENT_KEYCLOAK_REMOTE_APP",
"OAUTHCLIENT_KEYCLOAK_REMOTE_REST_APP",
"OAUTHCLIENT_KEYCLOAK_USER_INFO_URL",
"OAUTHCLIENT_KEYCLOAK_VERIFY_AUD",
)
# -*- coding: utf-8 -*-
#
# Copyright (C) 2020 FAIR Data Austria.
#
# Invenio-Keycloak is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.
"""OAuthClient endpoint handlers for communication with Keycloak.
The handler functions provided in this module are tailored to communicate
with Keycloak using OpenID-Connect.
To use them, they must be referenced in a REMOTE_APP configuration dictionary,
e.g.:
.. code-block:: python
KEYCLOAK_REMOTE_APP = {
# ...
"authorized_handler": "invenio_oauthclient.handlers"
":authorized_signup_handler",
"disconnect_handler": "invenio_keycloak.handlers"
":disconnect_handler",
"signup_handler": {
"info": "invenio_keycloak.handlers:info_handler",
"setup": "invenio_keycloak.handlers:setup_handler",
"view": "invenio_oauthclient.handlers:signup_handler"
},
# ...
}
"""
from flask import current_app, redirect, url_for
from flask_login import current_user
from invenio_db import db
from invenio_oauthclient.handlers.rest import response_handler
from invenio_oauthclient.models import RemoteAccount
from invenio_oauthclient.utils import oauth_link_external_id, oauth_unlink_external_id
from .helpers import (
_format_public_key,
_get_aud,
_get_realm_url,
_get_user_info_url,
_get_verify_aud,
get_public_key,
get_user_info,
)
KEYCLOAK_EXTERNAL_METHOD = "keycloak"
def info_handler(remote, resp):
"""Retrieve remote account information for finding matching local users."""
user_info = get_user_info(remote, resp)
# fill out the information required by
# 'invenio-accounts' and 'invenio-userprofiles'.
#
# note: "external_id": `preferred_username` should also work,
# as it is seemingly not editable in Keycloak
result = {
"user": {
"active": True,
"email": user_info.get("email"),
"profile": {
"full_name": user_info.get("name"),
"username": user_info.get("preferred_username"),
},
},
"external_id": user_info.get("sub"),
"external_method": KEYCLOAK_EXTERNAL_METHOD,
}
return result
def setup_handler(remote, token, resp):
"""Perform additional setup after the user has been logged in."""
user_info = get_user_info(remote, resp)
with db.session.begin_nested():
# fetch the user's Keycloak ID and set it in extra_data
keycloak_id = user_info.get("sub")
token.remote_account.extra_data = {
"keycloak_id": keycloak_id,
}
user = token.remote_account.user
external_id = {"id": keycloak_id, "method": KEYCLOAK_EXTERNAL_METHOD}
# link account with external Keycloak ID
oauth_link_external_id(user, external_id)
def _disconnect(remote, *args, **kwargs):
"""Common logic for handling disconnection of remote accounts."""
if not current_user.is_authenticated:
return current_app.login_manager.unauthorized()
account = RemoteAccount.get(
user_id=current_user.get_id(), client_id=remote.consumer_key
)
keycloak_id = account.extra_data.get("keycloak_id")
if keycloak_id:
external_id = {"id": keycloak_id, "method": KEYCLOAK_EXTERNAL_METHOD}
oauth_unlink_external_id(external_id)
if account:
with db.session.begin_nested():
account.delete()
def disconnect_handler(remote, *args, **kwargs):
"""Handle unlinking of the remote account."""
_disconnect(remote, *args, **kwargs)
return redirect(url_for("invenio_oauthclient_settings.index"))
def disconnect_rest_handler(remote, *args, **kwargs):
"""Handle unlinking of the remote account."""
_disconnect(remote, *args, **kwargs)
rconfig = current_app.config["OAUTHCLIENT_REST_REMOTE_APPS"][remote.name]
redirect_url = rconfig["disconnect_redirect_url"]
return response_handler(remote, redirect_url)
# -*- coding: utf-8 -*-
#
# Copyright (C) 2020 FAIR Data Austria.
#
# Invenio-Keycloak is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.
"""Helper functions for the endpoint handlers."""
import jwt
from flask import current_app
def _get_config_item(item):
return current_app.config.get("OAUTHCLIENT_" + item)
def _get_user_info_url():
"""Get URL for the Keycloak userinfo endpoint from `app.config`."""
return _get_config_item("KEYCLOAK_USER_INFO_URL")
def _get_realm_url():
"""Get URL for the Keycloak realm from `app.config`."""
return _get_config_item("KEYCLOAK_REALM_URL")
def _get_aud():
"""Get the target audience ('aud' field) for Keycloak's JWT."""
return _get_config_item("KEYCLOAK_AUD")
def _get_verify_aud():
"""Get the boolean flag whether or not to check 'aud' in JWT."""
return _get_config_item("KEYCLOAK_VERIFY_AUD")
def _format_public_key(public_key):
"""PEM-format the public key."""
public_key = public_key.strip()
if not public_key.startswith("-----BEGIN PUBLIC KEY-----"):
public_key = (
"-----BEGIN PUBLIC KEY-----\n" + public_key + "\n-----END PUBLIC KEY-----"
)
return public_key
def get_public_key(remote):
"""Get the realm's public key with the ID kid from Keycloak."""
certs_resp = remote.get(_get_realm_url()).data
key = certs_resp["public_key"]
return key
def get_user_info(remote, resp_token, fallback_to_endpoint=True, options=dict()):
"""Get the user information from Keycloak.
:param remote: The OAuthClient remote app
:param resp: The response from the 'token' endpoint; expected to be a dict
and to contain a JWT 'id_token'
:param fallback_to_endpoint: Whether or not to fall back to the 'userinfo'
endpoint mechanism when verifying the 'id_token' fails
:param options: A dictionary with additional options for `jwt.decode`
"""
try:
# try to parse the "id_token" part of Keycloak's (JWT) response
token = resp_token["id_token"]
pubkey = _format_public_key(get_public_key(remote))
alg = jwt.get_unverified_header(token)["alg"]
if not isinstance(options, dict):
options = {}
# consult the config whether to check the target audience
options.update({"verify_aud": False})
aud = _get_aud()
if _get_verify_aud() and (aud is not None):
options.update({"verify_aud": True})
user_info = jwt.decode(
token, key=pubkey, algorithms=[alg], audience=aud, options=options
)
except Exception:
if not fallback_to_endpoint:
raise
# as a fallback, we can still contact Keycloak's userinfo endpoint
# `remote.get(...)` automatically includes OAuth2 tokens in the header
# and the response's `data` field is a dict
user_info = remote.get(_get_user_info_url()).data
return user_info
# -*- coding: utf-8 -*-
#
# Copyright (C) 2020 FAIR Data Austria.
#
# Invenio-Keycloak is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.
"""Pre-defined defaults and helpers for Invenio-Keycloak configuration."""
class KeycloakSettingsHelper:
"""Helper for creating REMOTE_APP configuration dictionaries for Keycloak.
This class can be used to easily create a base configuration with sensible
defaults for a default-ish Keycloak setup.
It requires knowledge about the base URL of the Keycloak instance and the
realm on which the Invenio client application is configured.
Because the default endpoint URLs follow a simple schema, this information
can be used to create a simple base configuration.
The methods ``remote_app()`` and ``remote_rest_app()`` create and return
a dictionary in the form expected by Invenio-OAuthClient.
The latter can be used for providing SSO capabilities to SPAs communicating
with Invenio via the REST API.
Further, the helper provides some properties for commonly used default
endpoint URLs.
"""
def __init__(self, base_url, realm):
"""The constructor takes two arguments.
:param base_url: The base URL on which Keycloak is running
(e.g. "http://localhost:8080")
:param realm: Realm in which the invenio client application is defined
"""
self.base_url = base_url
self.realm = realm
self._access_token_url = self.make_url("token")
self._authorize_url = self.make_url("auth")
self._user_info_url = self.make_url("userinfo")
self._realm_url = self.make_realm_url()
@property
def access_token_url(self):
"""URL for the access token endpoint."""
return self._access_token_url
@property
def authorize_url(self):
"""URL for the authorization endpoint."""
return self._authorize_url
@property
def user_info_url(self):
"""URL for the user info endpoint."""
return self._user_info_url
@property
def realm_url(self):
"""URL for the realm's endpoint."""
return self._realm_url
def make_realm_url(self):
"""Create a URL pointing towards the Keycloak realm."""
base_url = self.base_url.rstrip("/")
return "{}/auth/realms/{}".format(base_url, self.realm)
def make_url(self, endpoint):
"""Create an endpoint URL following the default Keycloak URL schema.
:param endpoint: The endpoint to use (e.g. "auth", "token", ...)
"""
return "{}/protocol/openid-connect/{}".format(self.make_realm_url(), endpoint)
def remote_app(self) -> dict:
"""Create a KEYCLOAK_REMOTE_APP using the given base URL and realm."""
return dict(
title="Keycloak",
description="Your local keycloak installation",
icon="",
authorized_handler="invenio_oauthclient.handlers"
":authorized_signup_handler",
disconnect_handler="invenio_config_tuw.keycloak.handlers"
":disconnect_handler",
signup_handler=dict(
info="invenio_config_tuw.keycloak.handlers:info_handler",
setup="invenio_config_tuw.keycloak.handlers:setup_handler",
view="invenio_oauthclient.handlers:signup_handler",
),
params=dict(
base_url=self.base_url,
request_token_params={"scope": "openid"},
request_token_url=None,
access_token_url=self.access_token_url,
access_token_method="POST",
authorize_url=self.authorize_url,
app_key="KEYCLOAK_APP_CREDENTIALS",
),
)
def remote_rest_app(self) -> dict:
"""Crete a KEYCLOAK_REMOTE_REST_APP using the given configuration."""
rest_remote_app = self.remote_app()
rest_remote_app.update(
dict(
authorized_handler="invenio_oauthclient.handlers.rest"
":authorized_signup_handler",
disconnect_handler="invenio_config_tuw.keycloak.handlers"
":disconnect_rest_handler",
signup_handler=dict(
info="invenio_config_tuw.keycloak.handlers:info_handler",
setup="invenio_config_tuw.keycloak.handlers:setup_handler",
view="invenio_oauthclient.handlers.rest:signup_handler",
),
response_handler=(
"invenio_oauthclient.handlers.rest"
":default_remote_response_handler"
),
authorized_redirect_url="/",
disconnect_redirect_url="/",
signup_redirect_url="/",
error_redirect_url="/",
)
)
return rest_remote_app
helper = KeycloakSettingsHelper("https://locahost:8080", "invenio")
OAUTHCLIENT_KEYCLOAK_REALM_URL = helper.realm_url
OAUTHCLIENT_KEYCLOAK_USER_INFO_URL = helper.user_info_url
OAUTHCLIENT_KEYCLOAK_REMOTE_APP = helper.remote_app()
OAUTHCLIENT_KEYCLOAK_REMOTE_REST_APP = helper.remote_rest_app()
OAUTHCLIENT_KEYCLOAK_VERIFY_AUD = True
OAUTHCLIENT_KEYCLOAK_AUD = "invenio"
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Please register or to comment