Skip to content

Commit

Permalink
Work on deeper integration of keycloak and dev reviews
Browse files Browse the repository at this point in the history
  • Loading branch information
boehlke committed Nov 4, 2024
1 parent 1e93f91 commit 5b1cdd3
Show file tree
Hide file tree
Showing 5 changed files with 151 additions and 46 deletions.
Empty file added src/auth/__init__.py
Empty file.
8 changes: 3 additions & 5 deletions src/auth.py → src/auth/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
from urllib import parse

import requests
from flask import current_app as app
from flask import request
Expand All @@ -9,15 +7,15 @@
AUTHENTICATION_HEADER,
AuthenticateException,
AuthHandler,
InvalidCredentialsException,
)
from .exceptions import ServerError
InvalidCredentialsException, )
from ..exceptions import ServerError


def get_user_id():
"""Returns the user id from the auth cookie."""
auth_handler = AuthHandler(app.logger.debug)
authentication = request.headers.get(AUTHENTICATION_HEADER, "")
app.logger.debug(f"Get user id from auth header: {authentication}")
try:
(user_id, _) = auth_handler.authenticate(authentication)
except (AuthenticateException, InvalidCredentialsException):
Expand Down
21 changes: 21 additions & 0 deletions src/auth/claims.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from authlib.jose.errors import InvalidClaimError
from authlib.oauth2.rfc9068.claims import JWTAccessTokenClaims


class OpenSlidesAccessTokenClaims(JWTAccessTokenClaims):
REGISTERED_CLAIMS = JWTAccessTokenClaims.REGISTERED_CLAIMS + [
'sid',
'userId'
]

def validate(self, **kwargs):
super().validate(**kwargs)
self._validate_claim_value('sid')
self._validate_claim_value('userId')

def validate_typ(self):
# The resource server MUST verify that the 'typ' header value is 'at+jwt'
# or 'application/at+jwt' and reject tokens carrying any other value.
# -- Added jwt for keycloak compatibility
if self.header['typ'].lower() not in ('at+jwt', 'application/at+jwt', 'jwt'):
raise InvalidClaimError('typ')
70 changes: 70 additions & 0 deletions src/auth/token_validator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import requests
from authlib.jose import jwt, JsonWebKey
from authlib.jose.errors import DecodeError
from authlib.oauth2.rfc6750.errors import InvalidTokenError
from authlib.oauth2.rfc9068 import JWTBearerTokenValidator
from authlib.oidc.discovery import OpenIDProviderMetadata, get_well_known_url

from .claims import OpenSlidesAccessTokenClaims


class JWTBearerOpenSlidesTokenValidator(JWTBearerTokenValidator):
# Cache the JWKS keys to avoid fetching them repeatedly
jwk_set = None

def __init__(self, issuer, issuer_internal, resource_server, *args, **kwargs):
self.issuerInternal = issuer_internal
super().__init__(issuer, resource_server,*args, **kwargs)

def get_jwks(self):
if self.jwk_set is None:
oidc_configuration = OpenIDProviderMetadata(requests.get(get_well_known_url(self.issuerInternal, True)).json())
response = requests.get(oidc_configuration.get('jwks_uri'))
response.raise_for_status()
jwks_keys = response.json()
self.jwk_set = JsonWebKey.import_key_set(jwks_keys)
return self.jwk_set

def authenticate_token(self, token_string):

claims_options = {
'iss': {'essential': True, 'validate': self.validate_iss},
'exp': {'essential': True},
'aud': {'essential': True, 'value': self.resource_server},
'sub': {'essential': True},
'client_id': {'essential': True},
'iat': {'essential': True},
'jti': {'essential': True},
'auth_time': {'essential': False},
'acr': {'essential': False},
'amr': {'essential': False},
'scope': {'essential': False},
'groups': {'essential': False},
'roles': {'essential': False},
'entitlements': {'essential': False},
'sid': {'essential': True},
'userId': {'essential': True},
}
jwks = self.get_jwks()

# If the JWT access token is encrypted, decrypt it using the keys and algorithms
# that the resource server specified during registration. If encryption was
# negotiated with the authorization server at registration time and the incoming
# JWT access token is not encrypted, the resource server SHOULD reject it.

# The resource server MUST validate the signature of all incoming JWT access
# tokens according to [RFC7515] using the algorithm specified in the JWT 'alg'
# Header Parameter. The resource server MUST reject any JWT in which the value
# of 'alg' is 'none'. The resource server MUST use the keys provided by the
# authorization server.
try:
return jwt.decode(
token_string,
key=jwks,
claims_cls=OpenSlidesAccessTokenClaims,
claims_options=claims_options,
)
except DecodeError:
raise InvalidTokenError(
realm=self.realm, extra_attributes=self.extra_attributes
)
98 changes: 57 additions & 41 deletions src/mediaserver.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,69 +4,85 @@
import sys
from signal import SIGINT, SIGTERM, signal

import requests
from authlib.jose import JsonWebKey, jwt
from authlib.oauth2.rfc9068 import JWTBearerTokenValidator
from authlib.oidc.discovery import get_well_known_url, OpenIDProviderMetadata
from authlib.integrations.flask_oauth2 import ResourceProtector
from authlib.oauth2 import OAuth2Error
from flask import Flask, Response, jsonify, redirect, request
from flask import json

from .auth import AUTHENTICATION_HEADER, check_file_id, check_login
from .auth.auth import AUTHENTICATION_HEADER, check_file_id, check_login
from .auth.token_validator import JWTBearerOpenSlidesTokenValidator
from .config_handling import init_config, is_dev_mode
from .database import Database
from .exceptions import BadRequestError, HttpError, NotFoundError
from .logging import init_logging
from authlib.integrations.flask_oauth2 import ResourceProtector, current_token
import os

cache = {}

# Keycloak settings
KEYCLOAK_DOMAIN = 'http://keycloak:8080'
KEYCLOAK_REALM = 'os'
ISSUER = f"{KEYCLOAK_DOMAIN}/realms/{KEYCLOAK_REALM}"

class MyBearerTokenValidator(JWTBearerTokenValidator):
# Cache the JWKS keys to avoid fetching them repeatedly
jwk_set = None

def get_jwks_key_set(self):
if self.jwk_set is None:
oidc_configuration = OpenIDProviderMetadata(requests.get(get_well_known_url(ISSUER)).json())
response = requests.get(oidc_configuration.get('jwks_uri'))
response.raise_for_status()
jwks_keys = response.json()
self.jwk_set = JsonWebKey.import_key_set(jwks_keys)
return self.jwk_set

def verify_token(self, token):
try:
claims = jwt.decode(token, key=self.get_jwks_key_set())
claims.validate()
return claims
except Exception as e:
return None
KEYCLOAK_REALM = os.environ.get("OPENSLIDES_AUTH_REALM")
KEYCLOAK_URL = os.environ.get("OPENSLIDES_KEYCLOAK_URL")
ISSUER_REAL = os.environ.get("OPENSLIDES_TOKEN_ISSUER")

assert ISSUER_REAL is not None, "OPENSLIDES_TOKEN_ISSUER must be set in environment"
assert KEYCLOAK_REALM is not None, "OPENSLIDES_AUTH_REALM must be set in environment"
assert KEYCLOAK_URL is not None, "OPENSLIDES_KEYCLOAK_URL must be set in environment"

ISSUER_INTERNAL = f"{KEYCLOAK_URL}/realms/{KEYCLOAK_REALM}"


# class MyCustomResourceProtector(ResourceProtector):

# def validate_request(self, scopes, request, **kwargs):
# """Validate the request and return a token."""
# validator, token_string = self.parse_request_authorization(request)
# app.logger.debug(f"Validating request with {validator} and {token_string}")
# validator.validate_request(request)
# app.logger.debug(f"Request validated")
# token = validator.authenticate_token(token_string)
# app.logger.debug(f"Token authenticated: {token}")
# validator.validate_token(token, scopes, request, **kwargs)
# app.logger.debug(f"Token validated")
# return token

require_oauth = ResourceProtector()
require_oauth.register_token_validator(MyBearerTokenValidator(ISSUER, 'https://localhost:8000/system'))
require_oauth.register_token_validator(JWTBearerOpenSlidesTokenValidator(ISSUER_REAL, ISSUER_INTERNAL, 'os'))

app = Flask(__name__)
with app.app_context():
init_logging()
init_config()
database = Database()

app.debug = True
app.logger.info("Started media server")


@app.errorhandler(HttpError)
@app.errorhandler(Exception)
def handle_view_error(error):
app.logger.error(
f"Request to {request.path} resulted in {error.status_code}: "
f"{error.message}"
)
res_content = {"message": f"Media-Server: {error.message}"}
response = jsonify(res_content)
response.status_code = error.status_code
return response
if isinstance(error, HttpError):
app.logger.error(
f"Request to {request.path} resulted in {error.status_code}: "
f"{error.message}"
)
res_content = {"message": f"Media-Server: {error.message}"}
response = jsonify(res_content)
response.status_code = error.status_code
return response
elif isinstance(error, OAuth2Error):
app.logger.error(
f"Request to {request.path} resulted in {error.status_code}: "
f"{error.description} (AuthlibHTTPError)"
)
res_content = {"message": f"Media-Server: {error.description}"}
response = jsonify(res_content)
response.status_code = error.status_code
return response
else:
app.logger.error(f"Request to {request.path} resulted in {error} ({type(error)})")
res_content = {"message": "Media-Server: Internal Server Error"}
response = jsonify(res_content)
response.status_code = 500
return response


@app.route("/system/media/get/<int:file_id>")
Expand Down

0 comments on commit 5b1cdd3

Please sign in to comment.