Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 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):
Comment thread
jwalz marked this conversation as resolved.
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",
)
62 changes: 48 additions & 14 deletions addon_service/authorized_storage_account/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@
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,
)
Expand All @@ -27,7 +28,6 @@


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 @@ -76,6 +76,11 @@ class AuthorizedStorageAccount(AddonsServiceBaseModel):
related_name="authorized_storage_accounts",
)

is_oauth1_ready = models.BooleanField(
Comment thread
opaduchak marked this conversation as resolved.
Outdated
null=True,
blank=True,
)

class Meta:
verbose_name = "Authorized Storage Account"
verbose_name_plural = "Authorized Storage Accounts"
Expand Down Expand Up @@ -163,13 +168,27 @@ def auth_url(self) -> str | None:
Returns None if the ExternalStorageService does not support OAuth2
or if the initial credentials exchange has already ocurred.
"""
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):
client_config = self.external_service.oauth1_client_config
if self.credentials:
return oauth1_utils.build_auth_url(
auth_uri=client_config.auth_url,
request_token=self.credentials.oauth_token,
)

@property
def oauth2_auth_url(self):
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 @@ -189,15 +208,29 @@ def api_base_url(self, value):
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.is_oauth1_ready = False
self.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 Down Expand Up @@ -249,21 +282,22 @@ def validate_oauth_state(self):
)

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

async def refresh_oauth_access_token(self) -> None:
Comment thread
opaduchak marked this conversation as resolved.
Outdated
_oauth_client_config, _oauth_token_metadata = (
await self._load_client_config_and_token_metadata()
)
_fresh_token_result = await oauth_utils.get_refreshed_access_token(
(
_oauth_client_config,
_oauth_token_metadata,
) = await self._load_client_config_and_token_metadata()
Comment thread
opaduchak marked this conversation as resolved.
Outdated
_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()
Comment thread
opaduchak marked this conversation as resolved.

refresh_oauth_access_token__blocking = async_to_sync(refresh_oauth_access_token)

Expand Down
6 changes: 6 additions & 0 deletions addon_service/authorized_storage_account/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,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"
] = authorized_account.pk

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we have to store the authorized_account.pk in the session? Is there anything in the payload of the request to the callback url that can be used to identify which authorized account this is?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There isn't anything in payload which'd allow for is to identify it, I don't like this approach too, but I don't have any better ideas

@aaxelb aaxelb Jul 5, 2024

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the callback request includes the same temporary token received when initiating oauth1 -- could add a model to hold oauth1 temporary state (parallel OAuth2TokenMetadata) with indexed temporary_token field

(edit: nevermind -- i think that's true in specs but not for zotero, oh well)

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.OAuth1TokenCredentials

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For Oauth 1a, it should still be AccessTokenCredentials, right? I believe this affects how the header is constructed for the http requests.

@opaduchak opaduchak Jul 5, 2024

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are oauth_token and oauth_token_secret, which in their nature and usage are drastically different from OAuth2 access and refresh tokens

case CredentialsFormats.ACCESS_KEY_SECRET_KEY:
return credentials.AccessKeySecretKeyCredentials
case CredentialsFormats.PERSONAL_ACCESS_TOKEN:
Expand Down
10 changes: 9 additions & 1 deletion 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 @@ -47,24 +48,31 @@ def get_imp_number(imp: type[AddonImp]) -> int:

###
# Static registry of known addon implementations -- add new imps to the enums below
class RegexEnum(enum.Enum):

@classmethod
def regex(cls):
return "(?:" + "|".join(cls) + ")"
Comment thread
opaduchak marked this conversation as resolved.
Outdated


@enum.unique
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


@enum.unique
@enum_names_same_as(KnownAddonImps)
class AddonImpNumbers(enum.Enum):
class AddonImpNumbers(enum.IntEnum):
Comment thread
opaduchak marked this conversation as resolved.
Outdated
"""Static mapping from each AddonImp name to a unique integer (for database use)"""

BOX_DOT_COM = 1001
ZOTERO_ORG = 1002

if __debug__:
BLARG = -7
1 change: 1 addition & 0 deletions 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 Down
9 changes: 9 additions & 0 deletions addon_service/external_storage_service/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
validate_service_type,
validate_storage_imp_number,
)
from addon_service.oauth1.models import OAuth1ClientConfig


class ExternalStorageService(AddonsServiceBaseModel):
Expand Down Expand Up @@ -40,6 +41,14 @@ class ExternalStorageService(AddonsServiceBaseModel):
# Distinct from `display_name` to avoid over-coupling
wb_key = models.CharField(null=False, blank=True, default="")

oauth1_client_config: OAuth1ClientConfig = models.ForeignKey(
Comment thread
opaduchak marked this conversation as resolved.
Outdated
"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
2 changes: 1 addition & 1 deletion addon_service/management/commands/do_box_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ def _setup_oauth(self, user_uri: str, client_id, client_secret):
)
_account.initiate_oauth2_flow()
self.stdout.write(
self.style.SUCCESS("set up for oauth! now do the flow in a browser:")
self.style.SUCCESS("set up for oauth2! now do the flow in a browser:")
)
self.stdout.write(_account.auth_url)
self.stdout.write(
Expand Down
45 changes: 44 additions & 1 deletion addon_service/migrations/0001_initial.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Generated by Django 4.2.7 on 2024-07-01 13:30
# Generated by Django 4.2.7 on 2024-07-05 09:47

import django.contrib.postgres.fields
import django.db.models.deletion
Expand Down Expand Up @@ -44,12 +44,45 @@ class Migration(migrations.Migration):
),
("default_root_folder", models.CharField(blank=True)),
("_api_base_url", models.URLField(blank=True)),
(
"is_oauth1_ready",
models.BooleanField(
blank=True,
null=True,
verbose_name="addon_service.OAuth2TokenMetadata",
),
),
],
options={
"verbose_name": "Authorized Storage Account",
"verbose_name_plural": "Authorized Storage Accounts",
},
),
migrations.CreateModel(
name="OAuth1ClientConfig",
fields=[
(
"id",
addon_service.common.str_uuid_field.StrUUIDField(
default=addon_service.common.str_uuid_field.str_uuid4,
editable=False,
primary_key=True,
serialize=False,
),
),
("created", models.DateTimeField(editable=False)),
("modified", models.DateTimeField()),
("request_token_url", models.URLField()),
("auth_url", models.URLField()),
("access_token_url", models.URLField()),
("client_key", models.CharField(null=True)),
("client_secret", models.CharField(null=True)),
],
options={
"verbose_name": "OAuth1 Client Config",
"verbose_name_plural": "OAuth1 Client Configs",
},
),
migrations.CreateModel(
name="OAuth2ClientConfig",
fields=[
Expand Down Expand Up @@ -206,6 +239,16 @@ class Migration(migrations.Migration):
),
("api_base_url", models.URLField(blank=True, default="")),
("wb_key", models.CharField(blank=True, default="")),
(
"oauth1_client_config",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="external_storage_services",
to="addon_service.oauth1clientconfig",
),
),
(
"oauth2_client_config",
models.ForeignKey(
Expand Down
6 changes: 4 additions & 2 deletions addon_service/models.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
""" Import models here so they auto-detect for makemigrations """
"""Import models here so they auto-detect for makemigrations"""

from addon_service.addon_imp.models import AddonImpModel
from addon_service.addon_operation.models import AddonOperationModel
Expand All @@ -7,7 +7,8 @@
from addon_service.configured_storage_addon.models import ConfiguredStorageAddon
from addon_service.credentials.models import ExternalCredentials
from addon_service.external_storage_service.models import ExternalStorageService
from addon_service.oauth.models import (
from addon_service.oauth1.models import OAuth1ClientConfig
from addon_service.oauth2.models import (
OAuth2ClientConfig,
OAuth2TokenMetadata,
)
Expand All @@ -25,6 +26,7 @@
"ExternalStorageService",
"OAuth2ClientConfig",
"OAuth2TokenMetadata",
"OAuth1ClientConfig",
"ResourceReference",
"UserReference",
)
5 changes: 5 additions & 0 deletions addon_service/oauth1/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from . import utils
from .models import OAuth1ClientConfig


__all__ = ("OAuth1ClientConfig", "utils")
31 changes: 31 additions & 0 deletions addon_service/oauth1/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
from django.db import models

from addon_service.common.base_model import AddonsServiceBaseModel


class OAuth1ClientConfig(AddonsServiceBaseModel):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there not an auth_callback_url?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

auth_url is auth_callback_url, I'll change it if it's confusing

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the record: we're storing the auth_callback_url explicitly in case there are services that won't allow us to update the list of callbacks without invalidating our client and/or old credentials. That way, if necessary, we can encode this as https://[environment.]osf.io/oauth/callback/[provider] -- where we already have things set up to forward requests to GV if appropriate.

"""
Model for storing attributes that are required for managing
OAuth1 credentials exchanges with an ExternalService on behalf
of a registered client (e.g. the OSF)
"""

# URI that allows to obtain temporary request token to proceed with user auth
request_token_url = models.URLField(null=False)
# URI to which user will be redirected to authenticate
auth_url = models.URLField(null=False)
Comment thread
jwalz marked this conversation as resolved.
# URI to obtain access token
access_token_url = models.URLField(null=False)

client_key = models.CharField(null=True)
client_secret = models.CharField(null=True)
Comment thread
opaduchak marked this conversation as resolved.

@aaxelb aaxelb Jul 12, 2024

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hmm, the client secret should be stored encrypted -- but since OAuth2ClientConfig is the same, maybe worth splitting that into another ticket? (...and i'm starting to reconsider the EncryptedDataclassModel abstract base, now that we have three potential uses for it...)

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, storing any unencrypted credentials in the db, poses security risks

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, separate ticket. Need input from cloud eng/devops as to whether they want this in the database at all or if they'd prefer an approach like this one


class Meta:
verbose_name = "OAuth1 Client Config"
verbose_name_plural = "OAuth1 Client Configs"
app_label = "addon_service"

def __repr__(self):
return f'<{self.__class__.__qualname__}(pk="{self.pk}", auth_uri="{self.auth_url}, access_token_url="{self.access_token_url}", request_token_url="{self.request_token_url}", client_key="{self.client_key}")>'

__str__ = __repr__
Loading