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 Okta compatibility; Add token revokation via a new user_logout #4

Open
wants to merge 1 commit into
base: auth-object-return-token
Choose a base branch
from
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
155 changes: 131 additions & 24 deletions ckanext/auth/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
import jwt
import requests
from jwt.algorithms import RSAAlgorithm
from okta_jwt_verifier import IDTokenVerifier
from cryptography.hazmat.primitives import serialization
import random
import string
from sqlalchemy import func
import asyncio

import ckan.lib.authenticator as authenticator
from ckan.common import _, config
Expand All @@ -15,9 +17,20 @@
log = logging.getLogger(__name__)


def get_azure_keys(tenant_id):
jwks_uri = f'https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys'
def _check_response(response):
if response.status_code != 200:
log.error(f'Error: {response.text}')
return False
return True


def _get_keys(jwks_uri):
response = requests.get(jwks_uri)

if not _check_response(response):
log.error(f'Failed to get JWKS from {jwks_uri}')
return {}

jwks = response.json()

if 'keys' not in jwks:
Expand All @@ -36,36 +49,84 @@ def get_azure_keys(tenant_id):
return pem_keys


def validate_azure_jwt(token):
tenant_id = config.get('ckanext.auth.azure_tenant_id')
client_id = config.get('ckanext.auth.azure_client_id')
issuer = f'https://login.microsoftonline.com/{tenant_id}/v2.0'

if not tenant_id or not client_id:
raise ValueError('Azure tenant ID or client ID not configured')

def _decode_token(token, key, algorithms, audience, issuer):
try:
unverified_header = jwt.get_unverified_header(token)
kid = unverified_header.get('kid')
azure_keys = get_azure_keys(tenant_id)

if kid not in azure_keys:
raise ValueError(f'Key ID {kid} not found in Azure JWKS')
if kid not in key:
raise ValueError(f'Key ID {kid} not found in JWKS')

key = azure_keys[kid]
decoded_token = jwt.decode(
token,
key=key,
algorithms=['RS256'],
audience=client_id,
key=key[kid],
algorithms=algorithms,
audience=audience,
issuer=issuer,
options={'verify_exp': True},
)

return decoded_token
except Exception as e:
print(f'Token validation failed: {e}')
return None
log.error(f'Token validation failed: {e}')
return {}


def get_azure_keys(tenant_id):
jwks_uri = f'https://login.microsoftonline.com/{tenant_id}/discovery/v2.0/keys'
return _get_keys(jwks_uri)


def get_okta_keys(okta_domain):
jwks_uri = f'{okta_domain}/v1/keys'
return _get_keys(jwks_uri)


def validate_azure_jwt(token):
tenant_id = config.get('ckanext.auth.azure_tenant_id')
client_id = config.get('ckanext.auth.azure_client_id')
issuer = f'https://login.microsoftonline.com/{tenant_id}/v2.0'

if not tenant_id:
raise ValueError('azure_tenant_id not configured')
if not client_id:
raise ValueError('azure_client_id not configured')

return _decode_token(token, get_azure_keys(tenant_id), ['RS256'], client_id, issuer)


def validate_okta_jwt(token):
okta_issuer = config.get('ckanext.auth.okta_issuer')
okta_client_id = config.get('ckanext.auth.okta_client_id')

if not okta_issuer:
raise ValueError('okta_issuer not configured')
if not okta_client_id:
raise ValueError('okta_client_id not configured')

async def async_verify():
verifier = IDTokenVerifier(issuer=okta_issuer, client_id=okta_client_id)
await verifier.verify(token, nonce=None)

try:
loop = asyncio.get_event_loop()
except RuntimeError as e:
log.info(f'No event loop found: {e}')
log.info('Creating new event loop')

loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)

try:
loop.run_until_complete(async_verify())
except Exception as e:
log.error(f'Token validation failed: {e}')
finally:
loop.close()

return _decode_token(
token, get_okta_keys(okta_issuer), ['RS256'], okta_client_id, okta_issuer
)


def user_login(context, data_dict):
Expand All @@ -78,19 +139,33 @@ def user_login(context, data_dict):
}

from_azure = data_dict.get('from_azure', False)
from_okta = data_dict.get('from_okta', False)

if from_azure:
if from_azure or from_okta:
jwt_token = data_dict['id_token']
model = context['model']

context['ignore_auth'] = True

validated_token = validate_azure_jwt(jwt_token)
if from_azure:
validated_token = validate_azure_jwt(jwt_token)
elif from_okta:
validated_token = validate_okta_jwt(jwt_token)

user_email = validated_token.get('email')

if not user_email or not validated_token:
if not user_email:
log.error('No email found in token. Cannot login or create user')
if not validated_token:
log.error('Token validation failed. Cannot login or create user')
return generic_error_message

user = session.query(model.User).filter(func.lower(model.User.email) == func.lower(user_email)).first()
user = (
session.query(model.User)
.filter(func.lower(model.User.email) == func.lower(user_email))
.first()
)

if not user:
log.info(f'No user found with email {user_email}. Creating user...')
Expand Down Expand Up @@ -138,8 +213,13 @@ def user_login(context, data_dict):
return generic_error_message

model = context['model']
if "@" in data_dict.get("id", ""):
user = session.query(model.User).filter(model.User.email == data_dict.get("id", "")).first()

if '@' in data_dict.get('id', ''):
user = (
session.query(model.User)
.filter(model.User.email == data_dict.get('id', ''))
.first()
)
else:
user = model.User.get(data_dict['id'])

Expand Down Expand Up @@ -189,6 +269,33 @@ def generate_token(context, user):
user['frontend_token'] = frontend_token.get('token')

except Exception as e:
log.error('Failed to generate frontend token')
log.error(e)

return user


def user_logout(context, data_dict):
context['ignore_auth'] = True
user = toolkit.get_action('user_show')(context, {'id': data_dict.get('id')})


if config.get('ckanext.auth.include_frontend_login_token', False):
log.info('Logging out - Revoking frontend token for user...')
try:
api_tokens = toolkit.get_action('api_token_list')(
context, {'user_id': user['name']}
)

for token in api_tokens:
if token['name'] == 'frontend_token':
toolkit.get_action('api_token_revoke')(
context, {'jti': token['id']}
)
log.info('Frontend token revoked successfully')

except Exception as e:
log.error('Failed to revoke frontend token')
log.error(e)

return user
5 changes: 3 additions & 2 deletions ckanext/auth/plugin.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import ckan.plugins as plugins
import ckan.plugins.toolkit as toolkit
from ckanext.auth.logic import user_login
from ckanext.auth.logic import user_login, user_logout

class AuthPlugin(plugins.SingletonPlugin):
plugins.implements(plugins.IConfigurer)
Expand All @@ -15,5 +15,6 @@ def update_config(self, config_):

def get_actions(self):
return {
'user_login': user_login
'user_login': user_login,
'user_logout': user_logout,
}
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
okta-jwt-verifier