Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 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
Empty file.
5 changes: 5 additions & 0 deletions addon_imps/citations/zotero_org.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from addon_toolkit.interfaces.storage import StorageAddonImp


class ZoteroOrgCitationImp(StorageAddonImp):
pass
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",
)
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",
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",
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_request_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()

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
7 changes: 7 additions & 0 deletions addon_service/authorized_storage_account/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,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,8 +96,14 @@ 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:
Expand Down
9 changes: 8 additions & 1 deletion addon_service/common/credentials_formats.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,27 @@
from enum import Enum
from enum import (
Enum,
unique,
)

from addon_toolkit import credentials


@unique
class CredentialsFormats(Enum):
UNSPECIFIED = 0
OAUTH2 = 1
ACCESS_KEY_SECRET_KEY = 2
USERNAME_PASSWORD = 3
PERSONAL_ACCESS_TOKEN = 4
OAUTH1A = 5

@property
def dataclass(self):
match self:
case CredentialsFormats.OAUTH2:
return credentials.AccessTokenCredentials
case CredentialsFormats.OAUTH1A:
return credentials.OAuth1Credentials
case CredentialsFormats.ACCESS_KEY_SECRET_KEY:
return credentials.AccessKeySecretKeyCredentials
case CredentialsFormats.PERSONAL_ACCESS_TOKEN:
Expand Down
3 changes: 3 additions & 0 deletions addon_service/common/known_imps.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import enum

from addon_imps.citations import zotero_org
from addon_imps.storage import box_dot_com
from addon_service.common.enum_decorators import enum_names_same_as
from addon_toolkit import AddonImp
Expand Down Expand Up @@ -54,6 +55,7 @@ class KnownAddonImps(enum.Enum):
"""Static mapping from API-facing name for an AddonImp to the Imp itself"""

BOX_DOT_COM = box_dot_com.BoxDotComStorageImp
ZOTERO_ORG = zotero_org.ZoteroOrgCitationImp

if __debug__:
BLARG = my_blarg.MyBlargStorage
Expand All @@ -65,6 +67,7 @@ class AddonImpNumbers(enum.Enum):
"""Static mapping from each AddonImp name to a unique integer (for database use)"""

BOX_DOT_COM = 1001
ZOTERO_ORG = 1002

if __debug__:
BLARG = -7
2 changes: 1 addition & 1 deletion addon_service/common/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ async def do_send(self, request: HttpRequestInfo):
async with self._try_send(request) as _response:
yield _response
except exceptions.ExpiredAccessToken:
await _PrivateNetworkInfo.get(self).account.refresh_oauth_access_token()
await _PrivateNetworkInfo.get(self).account.refresh_oauth2_access_token()
# if this one fails, don't try refreshing again
async with self._try_send(request) as _response:
yield _response
Expand Down
10 changes: 9 additions & 1 deletion addon_service/credentials/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,15 @@ def authorized_accounts(self):
other types of accounts for the same user could point to the same set of credentials
"""
try:
return (self.authorized_storage_account,)
return [
*filter(
bool,
[
getattr(self, "authorized_storage_account", None),
getattr(self, "temporary_authorized_storage_account", None),
],
)
]
except ExternalCredentials.authorized_storage_account.RelatedObjectDoesNotExist:
return None

Expand Down
4 changes: 3 additions & 1 deletion addon_service/credentials/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
SUPPORTED_CREDENTIALS_FORMATS = set(CredentialsFormats) - {
CredentialsFormats.UNSPECIFIED,
CredentialsFormats.OAUTH2,
CredentialsFormats.OAUTH1A,
}


Expand All @@ -17,7 +18,8 @@ def __init__(self, write_only=True, required=False, *args, **kwargs):
super().__init__(write_only=write_only, required=required)

def to_internal_value(self, data):
if not data:
# this issue still hasn't been fixed on FE, so keeping this for now
if not data or not any(data.values()):
return None # consider empty {} same as omitting the field
# No access to the credentials format here, so just try all of them
for creds_format in SUPPORTED_CREDENTIALS_FORMATS:
Expand Down
8 changes: 8 additions & 0 deletions addon_service/external_storage_service/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,14 @@ class ExternalStorageService(AddonsServiceBaseModel):
# Distinct from `display_name` to avoid over-coupling
wb_key = models.CharField(null=False, blank=True, default="")

oauth1_client_config = models.ForeignKey(
"addon_service.OAuth1ClientConfig",
on_delete=models.SET_NULL,
related_name="external_storage_services",
null=True,
blank=True,
)

oauth2_client_config = models.ForeignKey(
"addon_service.OAuth2ClientConfig",
on_delete=models.SET_NULL,
Expand Down
Loading