mirror of
https://github.com/OpenHands/OpenHands.git
synced 2025-12-26 05:48:36 +08:00
715 lines
27 KiB
Python
715 lines
27 KiB
Python
import base64
|
|
import hashlib
|
|
import json
|
|
import time
|
|
from base64 import b64encode
|
|
from urllib.parse import parse_qs
|
|
|
|
import httpx
|
|
import jwt
|
|
from cryptography.fernet import Fernet
|
|
from jwt.exceptions import DecodeError
|
|
from keycloak.exceptions import (
|
|
KeycloakAuthenticationError,
|
|
KeycloakConnectionError,
|
|
KeycloakError,
|
|
)
|
|
from server.auth.constants import (
|
|
BITBUCKET_APP_CLIENT_ID,
|
|
BITBUCKET_APP_CLIENT_SECRET,
|
|
GITHUB_APP_CLIENT_ID,
|
|
GITHUB_APP_CLIENT_SECRET,
|
|
GITLAB_APP_CLIENT_ID,
|
|
GITLAB_APP_CLIENT_SECRET,
|
|
KEYCLOAK_REALM_NAME,
|
|
KEYCLOAK_SERVER_URL,
|
|
KEYCLOAK_SERVER_URL_EXT,
|
|
)
|
|
from server.auth.keycloak_manager import get_keycloak_admin, get_keycloak_openid
|
|
from server.config import get_config
|
|
from server.logger import logger
|
|
from sqlalchemy import String as SQLString
|
|
from sqlalchemy import type_coerce
|
|
from storage.auth_token_store import AuthTokenStore
|
|
from storage.database import session_maker
|
|
from storage.github_app_installation import GithubAppInstallation
|
|
from storage.offline_token_store import OfflineTokenStore
|
|
from tenacity import RetryCallState, retry, retry_if_exception_type, stop_after_attempt
|
|
|
|
from openhands.integrations.service_types import ProviderType
|
|
from openhands.utils.http_session import httpx_verify_option
|
|
|
|
|
|
def _before_sleep_callback(retry_state: RetryCallState) -> None:
|
|
logger.info(f'Retry attempt {retry_state.attempt_number} for Keycloak operation')
|
|
|
|
|
|
def create_encryption_utility(secret_key: bytes):
|
|
"""Creates an encryption utility using a 32-byte secret key.
|
|
|
|
Args:
|
|
secret_key (bytes): A 32-byte secret key
|
|
Returns:
|
|
tuple: (encrypt_string, decrypt_string) functions.
|
|
"""
|
|
# Convert the 32-byte key into a Fernet key (32 bytes -> urlsafe base64)
|
|
fernet_key = b64encode(hashlib.sha256(secret_key).digest())
|
|
f = Fernet(fernet_key)
|
|
|
|
def encrypt_text(text: str) -> str:
|
|
return f.encrypt(text.encode()).decode()
|
|
|
|
def encrypt_payload(payload: dict) -> str:
|
|
"""Encrypts a string and returns the result as a base64 string."""
|
|
text = json.dumps(payload)
|
|
return encrypt_text(text)
|
|
|
|
def decrypt_text(encrypted_text: str) -> str:
|
|
return f.decrypt(encrypted_text.encode()).decode()
|
|
|
|
def decrypt_payload(encrypted_text: str) -> dict:
|
|
"""Decrypts a base64 encoded encrypted string."""
|
|
text = decrypt_text(encrypted_text)
|
|
return json.loads(text)
|
|
|
|
return encrypt_payload, decrypt_payload, encrypt_text, decrypt_text
|
|
|
|
|
|
class TokenManager:
|
|
def __init__(self, external: bool = False):
|
|
self.external = external
|
|
jwt_secret = get_config().jwt_secret.get_secret_value()
|
|
(
|
|
self.encrypt_payload,
|
|
self.decrypt_payload,
|
|
self.encrypt_text,
|
|
self.decrypt_text,
|
|
) = create_encryption_utility(jwt_secret.encode())
|
|
|
|
async def get_keycloak_tokens(
|
|
self, code: str, redirect_uri: str
|
|
) -> tuple[str | None, str | None]:
|
|
try:
|
|
token_response = await get_keycloak_openid(self.external).a_token(
|
|
grant_type='authorization_code',
|
|
code=code,
|
|
redirect_uri=redirect_uri,
|
|
)
|
|
|
|
logger.debug(f'token_response: {token_response}')
|
|
|
|
if (
|
|
'access_token' not in token_response
|
|
or 'refresh_token' not in token_response
|
|
):
|
|
logger.error('Missing either access or refresh token in response')
|
|
return None, None
|
|
|
|
return token_response['access_token'], token_response['refresh_token']
|
|
except Exception:
|
|
logger.exception('Exception when getting Keycloak tokens')
|
|
return None, None
|
|
|
|
async def verify_keycloak_token(
|
|
self, keycloak_token: str, refresh_token: str
|
|
) -> tuple[str, str]:
|
|
try:
|
|
await get_keycloak_openid(self.external).a_userinfo(keycloak_token)
|
|
return keycloak_token, refresh_token
|
|
except KeycloakAuthenticationError:
|
|
logger.debug('attempting to refresh keycloak access token')
|
|
new_keycloak_tokens = await get_keycloak_openid(
|
|
self.external
|
|
).a_refresh_token(refresh_token)
|
|
logger.info('Refreshed keycloak access token')
|
|
return (
|
|
new_keycloak_tokens['access_token'],
|
|
new_keycloak_tokens['refresh_token'],
|
|
)
|
|
|
|
# UserInfo from Keycloak return a dictionary with the following format:
|
|
# {
|
|
# 'sub': '248289761001',
|
|
# 'name': 'Jane Doe',
|
|
# 'given_name': 'Jane',
|
|
# 'family_name': 'Doe',
|
|
# 'preferred_username': 'j.doe',
|
|
# 'email': 'janedoe@example.com',
|
|
# 'picture': 'http://example.com/janedoe/me.jpg'
|
|
# 'github_id': '354322532'
|
|
# }
|
|
async def get_user_info(self, access_token: str) -> dict:
|
|
if not access_token:
|
|
return {}
|
|
user_info = await get_keycloak_openid(self.external).a_userinfo(access_token)
|
|
return user_info
|
|
|
|
@retry(
|
|
stop=stop_after_attempt(2),
|
|
retry=retry_if_exception_type(KeycloakConnectionError),
|
|
before_sleep=_before_sleep_callback,
|
|
)
|
|
async def store_idp_tokens(
|
|
self,
|
|
idp: ProviderType,
|
|
user_id: str,
|
|
keycloak_access_token: str,
|
|
):
|
|
data = await self.get_idp_tokens_from_keycloak(keycloak_access_token, idp)
|
|
if data:
|
|
await self._store_idp_tokens(
|
|
user_id,
|
|
idp,
|
|
str(data['access_token']),
|
|
str(data['refresh_token']),
|
|
int(data['access_token_expires_at']),
|
|
int(data['refresh_token_expires_at']),
|
|
)
|
|
|
|
async def _store_idp_tokens(
|
|
self,
|
|
user_id: str,
|
|
identity_provider: ProviderType,
|
|
access_token: str,
|
|
refresh_token: str,
|
|
access_token_expires_at: int,
|
|
refresh_token_expires_at: int,
|
|
):
|
|
token_store = await AuthTokenStore.get_instance(
|
|
keycloak_user_id=user_id, idp=identity_provider
|
|
)
|
|
encrypted_access_token = self.encrypt_text(access_token)
|
|
encrypted_refresh_token = self.encrypt_text(refresh_token)
|
|
await token_store.store_tokens(
|
|
encrypted_access_token,
|
|
encrypted_refresh_token,
|
|
access_token_expires_at,
|
|
refresh_token_expires_at,
|
|
)
|
|
|
|
async def get_idp_tokens_from_keycloak(
|
|
self,
|
|
access_token: str,
|
|
idp: ProviderType,
|
|
) -> dict[str, str | int]:
|
|
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
|
base_url = KEYCLOAK_SERVER_URL_EXT if self.external else KEYCLOAK_SERVER_URL
|
|
url = f'{base_url}/realms/{KEYCLOAK_REALM_NAME}/broker/{idp.value}/token'
|
|
headers = {
|
|
'Authorization': f'Bearer {access_token}',
|
|
}
|
|
|
|
data: dict[str, str | int] = {}
|
|
response = await client.get(url, headers=headers)
|
|
content_str = response.content.decode('utf-8')
|
|
if (
|
|
f'Identity Provider [{idp.value}] does not support this operation.'
|
|
in content_str
|
|
):
|
|
return data
|
|
response.raise_for_status()
|
|
try:
|
|
# Try parsing as JSON
|
|
data = json.loads(response.text)
|
|
except json.JSONDecodeError:
|
|
# If it's not JSON, try parsing as a URL-encoded string
|
|
parsed = parse_qs(response.text)
|
|
# Convert lists to strings and specific keys to integers
|
|
data = {
|
|
key: int(value[0])
|
|
if key
|
|
in {'expires_in', 'refresh_token_expires_in', 'refresh_expires_in'}
|
|
else value[0]
|
|
for key, value in parsed.items()
|
|
}
|
|
|
|
current_time = int(time.time())
|
|
expires_in = int(data.get('expires_in', 0))
|
|
refresh_expires_in = int(
|
|
data.get('refresh_token_expires_in', data.get('refresh_expires_in', 0))
|
|
)
|
|
access_token_expires_at = (
|
|
0 if expires_in == 0 else current_time + expires_in
|
|
)
|
|
refresh_token_expires_at = (
|
|
0 if refresh_expires_in == 0 else current_time + refresh_expires_in
|
|
)
|
|
|
|
return {
|
|
'access_token': data['access_token'],
|
|
'refresh_token': data['refresh_token'],
|
|
'access_token_expires_at': access_token_expires_at,
|
|
'refresh_token_expires_at': refresh_token_expires_at,
|
|
}
|
|
|
|
@retry(
|
|
stop=stop_after_attempt(2),
|
|
retry=retry_if_exception_type(KeycloakConnectionError),
|
|
before_sleep=_before_sleep_callback,
|
|
)
|
|
async def get_idp_token(
|
|
self,
|
|
access_token: str,
|
|
idp: ProviderType,
|
|
) -> str:
|
|
# Get user info to determine user_id and idp
|
|
user_info = await self.get_user_info(access_token=access_token)
|
|
user_id = user_info.get('sub')
|
|
username = user_info.get('preferred_username')
|
|
logger.info(f'Getting token for user {username} and IDP {idp}')
|
|
token_store = await AuthTokenStore.get_instance(
|
|
keycloak_user_id=user_id, idp=idp
|
|
)
|
|
|
|
try:
|
|
token_info = await token_store.load_tokens(
|
|
self._check_expiration_and_refresh
|
|
)
|
|
if not token_info:
|
|
logger.info(f'No tokens for user: {username}, identity provider: {idp}')
|
|
raise ValueError(
|
|
f'No tokens for user: {username}, identity provider: {idp}'
|
|
)
|
|
access_token = self.decrypt_text(token_info['access_token'])
|
|
logger.info(f'Got {idp} token: {access_token[0:5]}')
|
|
return access_token
|
|
except httpx.HTTPStatusError as e:
|
|
# Log the full response details including the body
|
|
logger.error(
|
|
f'Failed to get tokens for user {username}, identity provider {idp} from URL {e.response.url}. '
|
|
f'Status code: {e.response.status_code}, '
|
|
f'Response body: {e.response.text}'
|
|
)
|
|
raise ValueError(
|
|
f'Failed to get token for user: {username}, identity provider: {idp}. '
|
|
f'Status code: {e.response.status_code}, '
|
|
f'Response body: {e.response.text}'
|
|
) from e
|
|
|
|
async def _check_expiration_and_refresh(
|
|
self,
|
|
identity_provider: ProviderType,
|
|
encrypted_refresh_token: str,
|
|
access_token_expires_at: int,
|
|
refresh_token_expires_at: int,
|
|
) -> dict[str, str | int] | None:
|
|
current_time = int(time.time())
|
|
# expire access_token four hours before actual expiration
|
|
# This ensures tokens are refreshed on resume to have at least 4 hours validity
|
|
access_expired = (
|
|
False
|
|
if access_token_expires_at == 0
|
|
else access_token_expires_at < current_time + 14400
|
|
)
|
|
refresh_expired = (
|
|
False
|
|
if refresh_token_expires_at == 0
|
|
else refresh_token_expires_at < current_time
|
|
)
|
|
|
|
if not access_expired:
|
|
return None
|
|
if access_expired and refresh_expired:
|
|
logger.error('Both Access and Refresh Tokens expired.')
|
|
raise ValueError('Both Access and Refresh Tokens expired.')
|
|
|
|
logger.info(f'Access token expired for {identity_provider}. Refreshing token.')
|
|
refresh_token = self.decrypt_text(encrypted_refresh_token)
|
|
token_data = await self._refresh_token(identity_provider, refresh_token)
|
|
access_token = token_data['access_token']
|
|
refresh_token = token_data['refresh_token']
|
|
access_expiration = token_data['access_token_expires_at']
|
|
refresh_expiration = token_data['refresh_token_expires_at']
|
|
|
|
return {
|
|
'access_token': self.encrypt_text(access_token),
|
|
'refresh_token': self.encrypt_text(refresh_token),
|
|
'access_token_expires_at': access_expiration,
|
|
'refresh_token_expires_at': refresh_expiration,
|
|
}
|
|
|
|
async def _refresh_token(
|
|
self, idp: ProviderType, refresh_token: str
|
|
) -> dict[str, str | int]:
|
|
logger.info(f'Refreshing {idp} token')
|
|
if idp == ProviderType.GITHUB:
|
|
return await self._refresh_github_token(refresh_token)
|
|
elif idp == ProviderType.GITLAB:
|
|
return await self._refresh_gitlab_token(refresh_token)
|
|
elif idp == ProviderType.BITBUCKET:
|
|
return await self._refresh_bitbucket_token(refresh_token)
|
|
else:
|
|
raise ValueError(f'Unsupported IDP: {idp}')
|
|
|
|
async def _refresh_github_token(self, refresh_token: str) -> dict[str, str | int]:
|
|
url = 'https://github.com/login/oauth/access_token'
|
|
logger.info(f'Refreshing GitHub token with URL: {url}')
|
|
|
|
payload = {
|
|
'client_id': GITHUB_APP_CLIENT_ID,
|
|
'client_secret': GITHUB_APP_CLIENT_SECRET,
|
|
'refresh_token': refresh_token,
|
|
'grant_type': 'refresh_token',
|
|
}
|
|
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
|
response = await client.post(url, data=payload)
|
|
response.raise_for_status()
|
|
logger.info('Successfully refreshed GitHub token')
|
|
parsed = parse_qs(response.text)
|
|
|
|
# Convert lists to strings and specific keys to integers
|
|
data = {
|
|
key: int(value[0])
|
|
if key
|
|
in {'expires_in', 'refresh_token_expires_in', 'refresh_expires_in'}
|
|
else value[0]
|
|
for key, value in parsed.items()
|
|
}
|
|
return await self._parse_refresh_response(data)
|
|
|
|
async def _refresh_gitlab_token(self, refresh_token: str) -> dict[str, str | int]:
|
|
url = 'https://gitlab.com/oauth/token'
|
|
logger.info(f'Refreshing GitLab token with URL: {url}')
|
|
|
|
payload = {
|
|
'client_id': GITLAB_APP_CLIENT_ID,
|
|
'client_secret': GITLAB_APP_CLIENT_SECRET,
|
|
'refresh_token': refresh_token,
|
|
'grant_type': 'refresh_token',
|
|
}
|
|
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
|
response = await client.post(url, data=payload)
|
|
response.raise_for_status()
|
|
logger.info('Successfully refreshed GitLab token')
|
|
|
|
data = response.json()
|
|
return await self._parse_refresh_response(data)
|
|
|
|
async def _refresh_bitbucket_token(
|
|
self, refresh_token: str
|
|
) -> dict[str, str | int]:
|
|
url = 'https://bitbucket.org/site/oauth2/access_token'
|
|
logger.info(f'Refreshing Bitbucket token with URL: {url}')
|
|
|
|
auth = base64.b64encode(
|
|
f'{BITBUCKET_APP_CLIENT_ID}:{BITBUCKET_APP_CLIENT_SECRET}'.encode()
|
|
).decode()
|
|
|
|
headers = {
|
|
'Authorization': f'Basic {auth}',
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
}
|
|
|
|
data = {
|
|
'grant_type': 'refresh_token',
|
|
'refresh_token': refresh_token,
|
|
}
|
|
|
|
async with httpx.AsyncClient(verify=httpx_verify_option()) as client:
|
|
response = await client.post(url, data=data, headers=headers)
|
|
response.raise_for_status()
|
|
logger.info('Successfully refreshed Bitbucket token')
|
|
|
|
data = response.json()
|
|
return await self._parse_refresh_response(data)
|
|
|
|
async def _parse_refresh_response(self, data: dict) -> dict[str, str | int]:
|
|
access_token = data.get('access_token')
|
|
refresh_token = data.get('refresh_token')
|
|
if not access_token or not refresh_token:
|
|
raise ValueError(
|
|
'Failed to refresh token: missing access_token or refresh_token in response.'
|
|
)
|
|
|
|
expires_in = int(data.get('expires_in', 0))
|
|
refresh_expires_in = int(
|
|
data.get('refresh_token_expires_in', data.get('refresh_expires_in', 0))
|
|
)
|
|
current_time = int(time.time())
|
|
access_token_expires_at = 0 if expires_in == 0 else current_time + expires_in
|
|
refresh_token_expires_at = (
|
|
0 if refresh_expires_in == 0 else current_time + refresh_expires_in
|
|
)
|
|
|
|
logger.info(
|
|
f'Token refresh successful. New access token expires at: {access_token_expires_at}, refresh token expires at: {refresh_token_expires_at}'
|
|
)
|
|
return {
|
|
'access_token': access_token,
|
|
'refresh_token': refresh_token,
|
|
'access_token_expires_at': access_token_expires_at,
|
|
'refresh_token_expires_at': refresh_token_expires_at,
|
|
}
|
|
|
|
@retry(
|
|
stop=stop_after_attempt(2),
|
|
retry=retry_if_exception_type(KeycloakConnectionError),
|
|
before_sleep=_before_sleep_callback,
|
|
)
|
|
async def get_idp_token_from_offline_token(
|
|
self, offline_token: str, idp: ProviderType
|
|
) -> str:
|
|
logger.info('Getting IDP token from offline token')
|
|
|
|
try:
|
|
tokens = await get_keycloak_openid(self.external).a_refresh_token(
|
|
offline_token
|
|
)
|
|
return await self.get_idp_token(tokens['access_token'], idp)
|
|
except KeycloakConnectionError:
|
|
logger.exception('KeycloakConnectionError when refreshing token')
|
|
raise
|
|
|
|
@retry(
|
|
stop=stop_after_attempt(2),
|
|
retry=retry_if_exception_type(KeycloakConnectionError),
|
|
before_sleep=_before_sleep_callback,
|
|
)
|
|
async def get_idp_token_from_idp_user_id(
|
|
self, idp_user_id: str, idp: ProviderType
|
|
) -> str | None:
|
|
logger.info(f'Getting IDP token from IDP user_id: {idp_user_id}')
|
|
user_id = await self.get_user_id_from_idp_user_id(idp_user_id, idp)
|
|
if not user_id:
|
|
return None
|
|
|
|
try:
|
|
offline_token = await self.load_offline_token(user_id=user_id)
|
|
if not offline_token:
|
|
logger.warning(f'No offline token found for user_id: {user_id}')
|
|
return None
|
|
return await self.get_idp_token_from_offline_token(
|
|
offline_token=offline_token, idp=idp
|
|
)
|
|
except KeycloakConnectionError as e:
|
|
logger.exception(
|
|
f'KeycloakConnectionError when getting IDP token for IDP user_id {idp_user_id}: {str(e)}'
|
|
)
|
|
raise
|
|
|
|
async def get_user_id_from_idp_user_id(
|
|
self, idp_user_id: str, idp: ProviderType
|
|
) -> str | None:
|
|
keycloak_admin = get_keycloak_admin(self.external)
|
|
users = await keycloak_admin.a_get_users({'q': f'{idp.value}_id:{idp_user_id}'})
|
|
if not users:
|
|
logger.info(f'{idp.value} user with IDP ID {idp_user_id} not found.')
|
|
return None
|
|
keycloak_user_id = users[0]['id']
|
|
logger.info(f'Got user ID {keycloak_user_id} from IDP user ID: {idp_user_id}')
|
|
return keycloak_user_id
|
|
|
|
async def get_user_id_from_user_email(self, email: str) -> str | None:
|
|
keycloak_admin = get_keycloak_admin(self.external)
|
|
users = await keycloak_admin.a_get_users({'q': f'email:{email}'})
|
|
if not users:
|
|
logger.error(f'User with email {email} not found.')
|
|
return None
|
|
keycloak_user_id = users[0]['id']
|
|
logger.info(f'Got user ID {keycloak_user_id} from email: {email}')
|
|
return keycloak_user_id
|
|
|
|
async def get_user_info_from_user_id(self, user_id: str) -> dict | None:
|
|
keycloak_admin = get_keycloak_admin(self.external)
|
|
user = await keycloak_admin.a_get_user(user_id)
|
|
if not user:
|
|
logger.error(f'User with ID {user_id} not found.')
|
|
return None
|
|
return user
|
|
|
|
async def get_github_id_from_user_id(self, user_id: str) -> str | None:
|
|
user_info = await self.get_user_info_from_user_id(user_id)
|
|
if user_info is None:
|
|
return None
|
|
github_ids = (user_info.get('attributes') or {}).get('github_id')
|
|
if not github_ids:
|
|
return None
|
|
github_id = github_ids[0]
|
|
return github_id
|
|
|
|
async def disable_keycloak_user(
|
|
self, user_id: str, email: str | None = None
|
|
) -> None:
|
|
"""Disable a Keycloak user account.
|
|
|
|
Args:
|
|
user_id: The Keycloak user ID to disable
|
|
email: Optional email address for logging purposes
|
|
|
|
This method attempts to disable the user account but will not raise exceptions.
|
|
Errors are logged but do not prevent the operation from completing.
|
|
"""
|
|
try:
|
|
keycloak_admin = get_keycloak_admin(self.external)
|
|
# Get current user to preserve other fields
|
|
user = await keycloak_admin.a_get_user(user_id)
|
|
if user:
|
|
# Update user with enabled=False to disable the account
|
|
await keycloak_admin.a_update_user(
|
|
user_id=user_id,
|
|
payload={
|
|
'enabled': False,
|
|
'username': user.get('username', ''),
|
|
'email': user.get('email', ''),
|
|
'emailVerified': user.get('emailVerified', False),
|
|
},
|
|
)
|
|
email_str = f', email: {email}' if email else ''
|
|
logger.info(
|
|
f'Disabled Keycloak account for user_id: {user_id}{email_str}'
|
|
)
|
|
else:
|
|
logger.warning(
|
|
f'User not found in Keycloak when attempting to disable: {user_id}'
|
|
)
|
|
except Exception as e:
|
|
# Log error but don't raise - the caller should handle the blocking regardless
|
|
email_str = f', email: {email}' if email else ''
|
|
logger.error(
|
|
f'Failed to disable Keycloak account for user_id: {user_id}{email_str}: {str(e)}',
|
|
exc_info=True,
|
|
)
|
|
|
|
def store_org_token(self, installation_id: int, installation_token: str):
|
|
"""Store a GitHub App installation token.
|
|
|
|
Args:
|
|
installation_id: GitHub installation ID (integer or string)
|
|
installation_token: The token to store
|
|
"""
|
|
with session_maker() as session:
|
|
# Ensure installation_id is a string
|
|
str_installation_id = str(installation_id)
|
|
# Use type_coerce to ensure SQLAlchemy treats the parameter as a string
|
|
installation = (
|
|
session.query(GithubAppInstallation)
|
|
.filter(
|
|
GithubAppInstallation.installation_id
|
|
== type_coerce(str_installation_id, SQLString)
|
|
)
|
|
.first()
|
|
)
|
|
if installation:
|
|
installation.encrypted_token = self.encrypt_text(installation_token)
|
|
else:
|
|
session.add(
|
|
GithubAppInstallation(
|
|
installation_id=str_installation_id, # Use the string version
|
|
encrypted_token=self.encrypt_text(installation_token),
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
def load_org_token(self, installation_id: int) -> str | None:
|
|
"""Load a GitHub App installation token.
|
|
|
|
Args:
|
|
installation_id: GitHub installation ID (integer or string)
|
|
|
|
Returns:
|
|
The decrypted token if found, None otherwise
|
|
"""
|
|
with session_maker() as session:
|
|
# Ensure installation_id is a string and use type_coerce
|
|
str_installation_id = str(installation_id)
|
|
installation = (
|
|
session.query(GithubAppInstallation)
|
|
.filter(
|
|
GithubAppInstallation.installation_id
|
|
== type_coerce(str_installation_id, SQLString)
|
|
)
|
|
.first()
|
|
)
|
|
if not installation:
|
|
return None
|
|
token = self.decrypt_text(installation.encrypted_token)
|
|
return token
|
|
|
|
async def store_offline_token(self, user_id: str, offline_token: str):
|
|
token_store = await OfflineTokenStore.get_instance(get_config(), user_id)
|
|
encrypted_tokens = self.encrypt_payload({'refresh_token': offline_token})
|
|
payload = {'tokens': encrypted_tokens}
|
|
await token_store.store_token(json.dumps(payload))
|
|
|
|
@retry(
|
|
stop=stop_after_attempt(2),
|
|
retry=retry_if_exception_type(KeycloakConnectionError),
|
|
before_sleep=_before_sleep_callback,
|
|
)
|
|
async def refresh(self, refresh_token: str) -> dict:
|
|
try:
|
|
return await get_keycloak_openid(self.external).a_refresh_token(
|
|
refresh_token
|
|
)
|
|
except KeycloakError as e:
|
|
try:
|
|
# We can log the token payload without the signature
|
|
refresh_token_payload = jwt.decode(
|
|
refresh_token, options={'verify_signature': False}
|
|
)
|
|
logger.info(
|
|
'error_with_refresh_token',
|
|
extra={
|
|
'refresh_token': refresh_token_payload,
|
|
'error': str(e),
|
|
},
|
|
)
|
|
except DecodeError:
|
|
# Whatever was passed in as a refresh token was completely wrong.
|
|
# We can log this on the basis of it not being a real secret.
|
|
logger.info(
|
|
'refresh_token_was_not_a_jwt',
|
|
extra={'refresh_token': refresh_token},
|
|
)
|
|
raise
|
|
|
|
async def validate_offline_token(self, user_id: str) -> bool:
|
|
offline_token = await self.load_offline_token(user_id=user_id)
|
|
if not offline_token:
|
|
return False
|
|
|
|
validated = False
|
|
try:
|
|
await get_keycloak_openid(self.external).a_refresh_token(offline_token)
|
|
validated = True
|
|
except KeycloakError:
|
|
pass
|
|
|
|
return validated
|
|
|
|
async def check_offline_token_is_active(self, user_id: str) -> bool:
|
|
offline_token = await self.load_offline_token(user_id=user_id)
|
|
if not offline_token:
|
|
return False
|
|
|
|
active = False
|
|
try:
|
|
token_info = await get_keycloak_openid(self.external).a_introspect(
|
|
offline_token
|
|
)
|
|
if token_info.get('active'):
|
|
active = True
|
|
except KeycloakError:
|
|
pass
|
|
|
|
return active
|
|
|
|
async def load_offline_token(self, user_id: str) -> str | None:
|
|
token_store = await OfflineTokenStore.get_instance(get_config(), user_id)
|
|
payload = await token_store.load_token()
|
|
if not payload:
|
|
return None
|
|
cred = json.loads(payload)
|
|
encrypted_tokens = cred['tokens']
|
|
tokens = self.decrypt_payload(encrypted_tokens)
|
|
return tokens['refresh_token']
|
|
|
|
async def logout(self, refresh_token: str):
|
|
try:
|
|
await get_keycloak_openid(self.external).a_logout(
|
|
refresh_token=refresh_token
|
|
)
|
|
except Exception:
|
|
logger.exception('Exception when logging out of keycloak')
|
|
raise
|