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.
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):
jwalz marked this conversation as resolved.
Show resolved Hide 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(
opaduchak marked this conversation as resolved.
Show resolved Hide resolved
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:
opaduchak marked this conversation as resolved.
Show resolved Hide resolved
_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()
opaduchak marked this conversation as resolved.
Show resolved Hide resolved
_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)

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

Copy link
Collaborator

@aaxelb aaxelb Jul 5, 2024

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

Copy link
Collaborator Author

@opaduchak opaduchak Jul 5, 2024

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) + ")"
opaduchak marked this conversation as resolved.
Show resolved Hide resolved


@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):
opaduchak marked this conversation as resolved.
Show resolved Hide resolved
"""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(
opaduchak marked this conversation as resolved.
Show resolved Hide resolved
"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
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
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
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)
jwalz marked this conversation as resolved.
Show resolved Hide 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)
opaduchak marked this conversation as resolved.
Show resolved Hide resolved
Copy link
Collaborator

@aaxelb aaxelb Jul 12, 2024

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