-
Notifications
You must be signed in to change notification settings - Fork 6
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
Conversation
There was a problem hiding this 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.
case CredentialsFormats.OAUTH1A: | ||
return credentials.OAuth1TokenCredentials |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
from addon_service.common.base_model import AddonsServiceBaseModel | ||
|
||
|
||
class OAuth1ClientConfig(AddonsServiceBaseModel): |
There was a problem hiding this comment.
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
?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
addon_toolkit/credentials.py
Outdated
@@ -27,6 +27,29 @@ class AccessKeySecretKeyCredentials(Credentials): | |||
secret_key: str | |||
|
|||
|
|||
@dataclasses.dataclass(frozen=True, slots=True) | |||
class OAuth1TokenCredentials: |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
is_oauth1_ready = models.BooleanField( | ||
"addon_service.OAuth2TokenMetadata", | ||
null=True, | ||
blank=True, | ||
) | ||
|
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
self.context["request"].session[ | ||
"oauth1a_account_id" | ||
] = authorized_account.pk |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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)
There was a problem hiding this 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/tests/_factories.py
Outdated
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 | ||
) |
There was a problem hiding this comment.
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
- Accept
oauth1_client_config
andoauth2_client_config
as parameters - Use the
credentials_format
to construct the appropriate form of client config using the appropriate factory if one wasn't passed - Ignore or error any irrelevant
client_config
that was passed.
There was a problem hiding this comment.
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
.
There was a problem hiding this 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
fixed verbose comprehensions Co-authored-by: abram axel booth <[email protected]>
(avoid any logic in addon_service that explicitly branches by imp)
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 |
There was a problem hiding this comment.
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)
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
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...)
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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
Implementation of Oauth1.0a for GravyValet
ENG-5769