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

[ENG-5769] Oauth 1.0a integration #78

Merged
merged 15 commits into from
Jul 15, 2024
Empty file.
6 changes: 6 additions & 0 deletions addon_imps/citations/zotero_org.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from addon_toolkit.interfaces.storage import StorageAddonImp


class ZoteroOrgCitationImp(StorageAddonImp):
jwalz marked this conversation as resolved.
Show resolved Hide resolved
async def get_external_account_id(self, auth_result_extras: dict[str, str]) -> str:
return auth_result_extras["userID"]
5 changes: 5 additions & 0 deletions addon_imps/storage/box_dot_com.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ class BoxDotComStorageImp(storage.StorageAddonImp):
see https://developer.box.com/reference/
"""

async def get_external_account_id(self, auth_result_extras: dict[str, str]) -> str:
async with self.network.GET("/users/me") as _response:
_json = await _response.json_content()
return str(_json["id"])

async def list_root_items(self, page_cursor: str = "") -> storage.ItemSampleResult:
return storage.ItemSampleResult(
items=[await self.get_item_info(_box_root_id())],
Expand Down
11 changes: 8 additions & 3 deletions addon_service/addon_imp/instantiation.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
from addon_service.common.aiohttp_session import get_singleton_client_session__blocking
from asgiref.sync import async_to_sync

from addon_service.common.aiohttp_session import get_singleton_client_session
from addon_service.common.network import GravyvaletHttpRequestor
from addon_service.models import AuthorizedStorageAccount
from addon_toolkit.interfaces.storage import (
Expand All @@ -7,7 +9,7 @@
)


def get_storage_addon_instance(
async def get_storage_addon_instance(
imp_cls: type[StorageAddonImp],
account: AuthorizedStorageAccount,
config: StorageConfig,
Expand All @@ -16,8 +18,11 @@ def get_storage_addon_instance(
return imp_cls(
config=config,
network=GravyvaletHttpRequestor(
client_session=get_singleton_client_session__blocking(),
client_session=await get_singleton_client_session(),
prefix_url=config.external_api_url,
account=account,
),
)


get_storage_addon_instance__blocking = async_to_sync(get_storage_addon_instance)
10 changes: 10 additions & 0 deletions addon_service/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,13 @@ class OAuth2ClientConfigAdmin(GravyvaletModelAdmin):
"created",
"modified",
)


@admin.register(models.OAuth1ClientConfig)
@linked_many_field("external_storage_services")
class OAuth1ClientConfigAdmin(GravyvaletModelAdmin):
readonly_fields = (
"id",
"created",
"modified",
)
17 changes: 17 additions & 0 deletions addon_service/authorized_storage_account/callbacks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
from addon_service.addon_imp.instantiation import get_storage_addon_instance
from addon_service.authorized_storage_account.models import AuthorizedStorageAccount


async def after_successful_auth(
account: AuthorizedStorageAccount,
auth_result_extras: dict[str, str] | None = None,
):
_imp = await get_storage_addon_instance(
account.imp_cls, # type: ignore[arg-type]
account,
account.storage_imp_config(),
)
account.external_account_id = await _imp.get_external_account_id(
auth_result_extras or {}
)
await account.asave()
126 changes: 95 additions & 31 deletions addon_service/authorized_storage_account/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,20 +14,24 @@
from addon_service.common.service_types import ServiceTypes
from addon_service.common.validators import validate_addon_capability
from addon_service.credentials.models import ExternalCredentials
from addon_service.oauth import utils as oauth_utils
from addon_service.oauth.models import (
from addon_service.oauth1 import utils as oauth1_utils
from addon_service.oauth2 import utils as oauth2_utils
from addon_service.oauth2.models import (
OAuth2ClientConfig,
OAuth2TokenMetadata,
)
from addon_toolkit import (
AddonCapabilities,
AddonImp,
)
from addon_toolkit.credentials import (
Credentials,
OAuth1Credentials,
)
from addon_toolkit.interfaces.storage import StorageConfig


class AuthorizedStorageAccountManager(models.Manager):

def active(self):
"""filter to accounts owned by non-deactivated users"""
return self.get_queryset().filter(account_owner__deactivated__isnull=True)
Expand Down Expand Up @@ -68,12 +72,21 @@ class AuthorizedStorageAccount(AddonsServiceBaseModel):
blank=True,
related_name="authorized_storage_account",
)
_temporary_oauth1_credentials = models.OneToOneField(
"addon_service.ExternalCredentials",
aaxelb marked this conversation as resolved.
Show resolved Hide resolved
on_delete=models.CASCADE,
primary_key=False,
null=True,
blank=True,
related_name="temporary_authorized_storage_account",
)
oauth2_token_metadata = models.ForeignKey(
"addon_service.OAuth2TokenMetadata",
opaduchak marked this conversation as resolved.
Show resolved Hide resolved
on_delete=models.CASCADE, # probs not
null=True,
blank=True,
related_name="authorized_storage_accounts",
related_query_name="%(class)s_authorized_storage_account",
)

class Meta:
Expand Down Expand Up @@ -108,17 +121,40 @@ def credentials(self):

@credentials.setter
def credentials(self, credentials_data):
if self.temporary_oauth1_credentials:
self._temporary_oauth1_credentials.delete()
self._temporary_oauth1_credentials = None
self._set_credentials("_credentials", credentials_data)

@property
def temporary_oauth1_credentials(self) -> OAuth1Credentials | None:
if self._temporary_oauth1_credentials:
return self._temporary_oauth1_credentials.decrypted_credentials
return None

@temporary_oauth1_credentials.setter
def temporary_oauth1_credentials(self, credentials_data: OAuth1Credentials):
if self.credentials_format is not CredentialsFormats.OAUTH1A:
raise ValidationError(
"Trying to set temporary credentials for non OAuth1A account"
)
self._set_credentials("_temporary_oauth1_credentials", credentials_data)

def _set_credentials(self, credentials_field: str, credentials_data: Credentials):
creds_type = type(credentials_data)
if not hasattr(self, credentials_field):
raise ValidationError("Trying to set credentials to non-existing field")
if creds_type is not self.credentials_format.dataclass:
raise ValidationError(
f"Expectd credentials of type type {self.credentials_format.dataclass}."
f"Expected credentials of type type {self.credentials_format.dataclass}."
f"Got credentials of type {creds_type}."
)
if not self._credentials:
self._credentials = ExternalCredentials.new()
if not getattr(self, credentials_field, None):
setattr(self, credentials_field, ExternalCredentials.new())
try:
self._credentials.decrypted_credentials = credentials_data
self._credentials.save()
creds = getattr(self, credentials_field)
creds.decrypted_credentials = credentials_data
creds.save()
except TypeError as e:
raise ValidationError(e)

Expand All @@ -129,7 +165,7 @@ def authorized_capabilities(self) -> AddonCapabilities:

@authorized_capabilities.setter
def authorized_capabilities(self, new_capabilities: AddonCapabilities):
"""set int_authorized_capabilities without caring it's int"""
"""set int_authorized_capabilities without caring its int"""
self.int_authorized_capabilities = new_capabilities.value

@property
Expand Down Expand Up @@ -158,18 +194,32 @@ def authorized_operation_names(self) -> list[str]:

@property
def auth_url(self) -> str | None:
"""Generates the url required to initiate OAuth2 credentials exchange.
"""Generates the url required to initiate OAuth credentials exchange.

Returns None if the ExternalStorageService does not support OAuth2
or if the initial credentials exchange has already ocurred.
Returns None if the ExternalStorageService does not support OAuth
or if the initial credentials exchange has already occurred.
"""
if self.credentials_format is not CredentialsFormats.OAUTH2:
return None
match self.credentials_format:
case CredentialsFormats.OAUTH2:
return self.oauth2_auth_url
case CredentialsFormats.OAUTH1A:
return self.oauth1_auth_url

@property
def oauth1_auth_url(self) -> str:
client_config = self.external_service.oauth1_client_config
if self._temporary_oauth1_credentials:
return oauth1_utils.build_auth_url(
auth_uri=client_config.auth_url,
temporary_oauth_token=self.temporary_oauth1_credentials.oauth_token,
)

@property
def oauth2_auth_url(self) -> str | None:
state_token = self.oauth2_token_metadata.state_token
if not state_token:
return None
return oauth_utils.build_auth_url(
return oauth2_utils.build_auth_url(
auth_uri=self.external_service.oauth2_client_config.auth_uri,
client_id=self.external_service.oauth2_client_config.client_id,
state_token=state_token,
Expand All @@ -178,26 +228,39 @@ def auth_url(self) -> str | None:
)

@property
def api_base_url(self):
def api_base_url(self) -> str:
return self._api_base_url or self.external_service.api_base_url

@api_base_url.setter
def api_base_url(self, value):
def api_base_url(self, value: str):
self._api_base_url = value

@property
def imp_cls(self) -> type[AddonImp]:
return self.external_service.addon_imp.imp_cls

@transaction.atomic
def initiate_oauth1_flow(self):
if self.credentials_format is not CredentialsFormats.OAUTH1A:
raise ValueError("Cannot initiate OAuth1 flow for non-OAuth1 credentials")
client_config = self.external_service.oauth1_client_config
request_token_result, _ = async_to_sync(oauth1_utils.get_temporary_token)(
client_config.request_token_url,
client_config.client_key,
client_config.client_secret,
)
self.temporary_oauth1_credentials = request_token_result
self.save()

@transaction.atomic
def initiate_oauth2_flow(self, authorized_scopes=None):
if self.credentials_format is not CredentialsFormats.OAUTH2:
raise ValueError("Cannot initaite OAuth flow for non-OAuth credentials")
raise ValueError("Cannot initiate OAuth2 flow for non-OAuth2 credentials")
self.oauth2_token_metadata = OAuth2TokenMetadata.objects.create(
authorized_scopes=(
authorized_scopes or self.external_service.supported_scopes
),
state_nonce=oauth_utils.generate_state_nonce(),
state_nonce=oauth2_utils.generate_state_nonce(),
)
self.save()

Expand All @@ -209,8 +272,8 @@ def storage_imp_config(self) -> StorageConfig:
external_account_id=self.external_account_id,
)

def clean(self, *args, **kwargs):
super().clean(*args, **kwargs)
def clean(self):
super().clean()
self.validate_api_base_url()
self.validate_oauth_state()

Expand Down Expand Up @@ -249,26 +312,27 @@ def validate_oauth_state(self):
)

###
# async functions for use in oauth callback flows

async def refresh_oauth_access_token(self) -> None:
_oauth_client_config, _oauth_token_metadata = (
await self._load_client_config_and_token_metadata()
)
_fresh_token_result = await oauth_utils.get_refreshed_access_token(
# async functions for use in oauth2 callback flows

async def refresh_oauth2_access_token(self) -> None:
(
_oauth_client_config,
_oauth_token_metadata,
) = await self._load_oauth2_client_config_and_token_metadata()
_fresh_token_result = await oauth2_utils.get_refreshed_access_token(
token_endpoint_url=_oauth_client_config.token_endpoint_url,
refresh_token=_oauth_token_metadata.refresh_token,
auth_callback_url=_oauth_client_config.auth_callback_url,
client_id=_oauth_client_config.client_id,
client_secret=_oauth_client_config.client_secret,
)
await _oauth_token_metadata.update_with_fresh_token(_fresh_token_result)
await sync_to_async(self.refresh_from_db)()
await self.arefresh_from_db()
opaduchak marked this conversation as resolved.
Show resolved Hide resolved

refresh_oauth_access_token__blocking = async_to_sync(refresh_oauth_access_token)
refresh_oauth_access_token__blocking = async_to_sync(refresh_oauth2_access_token)

@sync_to_async
def _load_client_config_and_token_metadata(
def _load_oauth2_client_config_and_token_metadata(
self,
) -> tuple[OAuth2ClientConfig, OAuth2TokenMetadata]:
# wrap db access in `sync_to_async`
Expand Down
12 changes: 12 additions & 0 deletions addon_service/authorized_storage_account/serializers.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from asgiref.sync import async_to_sync
from django.core.exceptions import ValidationError as ModelValidationError
from rest_framework_json_api import serializers
from rest_framework_json_api.relations import (
Expand All @@ -7,6 +8,7 @@
from rest_framework_json_api.utils import get_resource_type_from_model

from addon_service.addon_operation.models import AddonOperationModel
from addon_service.authorized_storage_account.callbacks import after_successful_auth
from addon_service.common import view_names
from addon_service.common.credentials_formats import CredentialsFormats
from addon_service.models import (
Expand All @@ -15,6 +17,7 @@
ExternalStorageService,
UserReference,
)
from addon_service.osf_models.fields import encrypt_string
from addon_service.serializer_fields import (
CredentialsField,
DataclassRelatedLinkField,
Expand Down Expand Up @@ -95,13 +98,22 @@ def create(self, validated_data):
authorized_account.initiate_oauth2_flow(
validated_data.get("authorized_scopes")
)
elif external_service.credentials_format is CredentialsFormats.OAUTH1A:
authorized_account.initiate_oauth1_flow()
self.context["request"].session["oauth1a_account_id"] = encrypt_string(
authorized_account.pk
)
else:
authorized_account.credentials = validated_data["credentials"]

try:
authorized_account.save()
except ModelValidationError as e:
raise serializers.ValidationError(e)

if external_service.credentials_format.is_direct_from_user:
async_to_sync(after_successful_auth)(authorized_account)

return authorized_account

class Meta:
Expand Down
Loading