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

Conversation

opaduchak
Copy link
Collaborator

@opaduchak opaduchak commented Jul 5, 2024

Implementation of Oauth1.0a for GravyValet
ENG-5769

Copy link
Collaborator

@adlius adlius left a comment

Choose a reason for hiding this comment

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

First round. Some questions.

addon_imps/citations/zotero_org.py Show resolved Hide resolved
Comment on lines 23 to 24
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

addon_service/oauth1/models.py Show resolved Hide resolved
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.

@@ -27,6 +27,29 @@ class AccessKeySecretKeyCredentials(Credentials):
secret_key: str


@dataclasses.dataclass(frozen=True, slots=True)
class OAuth1TokenCredentials:
Copy link
Collaborator

Choose a reason for hiding this comment

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

I think for OAuth1 we should still use AccessTokenCredentials. The idea of the AccessTokenCredentials dataclass is to implement the iter_headers method so that GravyvaletHttpRequestors can automatically append the appropriate auth headers for the oauth requests.

Copy link
Contributor

Choose a reason for hiding this comment

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

Depends on if OAuth1 requests require the secret to be sent along with the token. If so, it's a different credentials shape. Otherwise, yes, we should keep using AccessTokenCredentials and create an OAuth1 equivalent to OAuth2TokenMetadata for tracking other details

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Well, auth headers are different for OAuth1.0a and OAuth2, and even more: they are different for Zotero

Comment on lines 79 to 83
is_oauth1_ready = models.BooleanField(
"addon_service.OAuth2TokenMetadata",
null=True,
blank=True,
)

Copy link
Collaborator

Choose a reason for hiding this comment

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

I am not quite sure whether this boolean field is necessary.

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

It is necessary as it allows to discern temporary Request token credentials from final credentials

Comment on lines 100 to 102
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)

Copy link
Contributor

@jwalz jwalz left a comment

Choose a reason for hiding this comment

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

Thanks! This is a great start. I'll probably dig a bit more into the testing stuff to see if there's an easy way to consolidate strategies.

addon_service/common/known_imps.py Outdated Show resolved Hide resolved
addon_service/credentials/serializers.py Show resolved Hide resolved
addon_service/oauth1/models.py Outdated Show resolved Hide resolved
Comment on lines 102 to 120
class ExternalStorageOAuth2ServiceFactory(ExternalStorageServiceFactory):
oauth2_client_config = factory.SubFactory(OAuth2ClientConfigFactory)


class ExternalStorageOAuth1ServiceFactory(ExternalStorageServiceFactory):
oauth1_client_config = factory.SubFactory(OAuth1ClientConfigFactory)

@classmethod
def _create(
cls,
model_class,
credentials_format=CredentialsFormats.OAUTH1A,
service_type=ServiceTypes.PUBLIC,
*args,
**kwargs,
):
return super()._create(
model_class, credentials_format, service_type, *args, **kwargs
)
Copy link
Contributor

Choose a reason for hiding this comment

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

We never shoul had oauth_client_config as a sub-factory, considering it isnt' relevant and shouldn't be set for non-oauth(2) services.

Would recommend instead just updating the existing ExternalStorageServiceFactory._create function to

  1. Accept oauth1_client_config and oauth2_client_config as parameters
  2. Use the credentials_format to construct the appropriate form of client config using the appropriate factory if one wasn't passed
  3. Ignore or error any irrelevant client_config that was passed.

Copy link
Contributor

Choose a reason for hiding this comment

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

Note, this is obviously simpler if we decide to collapse down to one OAuthConfig.

addon_service/oauth1/utils.py Show resolved Hide resolved
addon_service/authorized_storage_account/models.py Outdated Show resolved Hide resolved
addon_service/oauth1/utils.py Outdated Show resolved Hide resolved
addon_service/oauth1/views.py Outdated Show resolved Hide resolved
addon_service/tests/_helpers.py Outdated Show resolved Hide resolved
Copy link
Collaborator

@aaxelb aaxelb left a comment

Choose a reason for hiding this comment

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

looks good so far! had some suggestions -- most breakingly, adding a new model for the "temporary" oauth1 credentials

addon_service/common/known_imps.py Outdated Show resolved Hide resolved
addon_service/authorized_storage_account/models.py Outdated Show resolved Hide resolved
addon_service/authorized_storage_account/models.py Outdated Show resolved Hide resolved
addon_service/oauth_utlis.py Outdated Show resolved Hide resolved
addon_service/tests/_helpers.py Outdated Show resolved Hide resolved
addon_service/oauth1/utils.py Outdated Show resolved Hide resolved
addon_service/oauth1/utils.py Outdated Show resolved Hide resolved
addon_service/oauth1/models.py Outdated Show resolved Hide resolved
addon_service/oauth1/models.py Show resolved Hide resolved
opaduchak and others added 2 commits July 12, 2024 12:37
(avoid any logic in addon_service that explicitly branches by imp)
Comment on lines +37 to +42
This is Zotero specific as other OAuth1.0a clients require request signing,
as per current architecture, we cannot it here.
"""

yield "Authorization", f"Bearer {self.oauth_token_secret}"
# TODO: implement request signing for OAuth1.0a services that require it
Copy link
Collaborator

Choose a reason for hiding this comment

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

gah, i missed that zotero uses the secret as bearer token instead of signing the request... feels wrong, but maybe it's true that https makes it safe, as argued in their example client code:

/** OAuth support for all api requests may be added in the future
* but for now secure https provides similar benefits anyway
*/

(this speaks to the need for modular/swappable request-auth logic (instead of iter_headers) but sounds like that'll be part of s3 work -- this TODO seems fine for now)

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

We should be safe because https encypts entire http payload (including headers, query params, path, body, etc.)

access_token_url = models.URLField(null=False)

client_key = models.CharField(null=True)
client_secret = models.CharField(null=True)
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

@opaduchak opaduchak merged commit b7a43cc into CenterForOpenScience:develop Jul 15, 2024
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants