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

Draft: Choose auth method including token exchange #17837

Draft
wants to merge 1 commit into
base: dev
Choose a base branch
from

Conversation

Kharonus
Copy link
Member

@Kharonus Kharonus commented Feb 5, 2025

What are you trying to accomplish?

  • Depending on the configuration, the code must be able to authenticate with exchanged tokens over the authorization code flow from standard OAuth2

And now?

Please check the inline comments I prefixed with #README. Those are the thoughts I had. Those should serve as a basis for discussion.

@Kharonus Kharonus self-assigned this Feb 5, 2025
.with_user(user)
UserBound = ->(user:, storage:) do
# README: This check does not match for user that are only hijacked by an IDP based login.
token_exchange_preferred = storage.audience.present? && user.authentication_provider.present?
Copy link
Contributor

Choose a reason for hiding this comment

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

I'd call this sso_preferred, since the underlying strategy does not solely work with Token Exchange, but with all kinds of tokens obtained through SSO.

Copy link
Member Author

Choose a reason for hiding this comment

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

yep, naming is hard :D

else
# README: If for a user - storage combination no possible authentication method is found, the
# noop strategy will result in non authorized requests.
::Storages::Peripherals::StorageInteraction::AuthenticationStrategies::Noop.strategy
Copy link
Contributor

Choose a reason for hiding this comment

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

Will this result in us making actual requests that will then be rejected?

IIRC we talked about how we should avoid sending out requests that we know will fail eventually.

Would it be better to cause an error/exception here that can be caught before a request goes out?

Copy link
Member Author

Choose a reason for hiding this comment

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

That is a question we should answer together. I can understand both approaches.

# In the case above, the token will be used without refresh, which will lead to an unauthorized response
# once the token is no longer valid. This can only be solved right now by logging out
# and in again - which will replace the account token itself.
# Open question: Do we want to support this case?
Copy link
Contributor

Choose a reason for hiding this comment

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

My opinion: If we want to keep our implementation simple here by enforcing that we know an expiration time, we should at least try to carry the expiration time communicated by the token endpoint along. This should give us an expiration time for the majority of OAuth 2.0 implementations.

If you agree I'd write a feature for that and would probably exchange with Wieland whether this limitation is acceptable.

Copy link
Member Author

Choose a reason for hiding this comment

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

Not sure I understand, what is this feature proposal :D

Copy link
Contributor

Choose a reason for hiding this comment

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

Let me back up: Right now we only know of an expiration time if we can parse the token as JWT, read it successfully and extract the exp claim. In all other situations, we don't know the expiration and would try to use the token forever.

You are suggesting here, that we never try to refresh the token, unless we know that it's expired.

My opinion: Right now, this is not good enough, because we would fail too often.

My suggestion: We should know the expiration time in more cases, to make your proposal feasible. That's the feature I want to write. Important remark is that we will not be able to cover 100% of cases according to the OAuth 2.0 specification. That's where I'd clarify with Wieland, that we are "good enough" from a product perspective.

Copy link
Member Author

Choose a reason for hiding this comment

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

You have my support in raising this as a feature proposal ;)

when :oauth_user_token
when :oauth_user_token || :sso_user_token
# README: So, this might lead to problems. How can we ensure, that at this point in time,
# the remote identity is already created for token exchange?
origin_user_id = RemoteIdentity.where(user_id: auth_strategy.user, oauth_client: storage.oauth_client)
Copy link
Contributor

Choose a reason for hiding this comment

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

Can't we "simply" turn this into a "find or create" style method for the SSO case?

I.e. if we find a remote identity, we take it. If we don't find one, we make an API call to figure it out and store it.

This means the first time that an SSO user needs a remote identity, they will fetch it via an API call. At all subsequent times, we will have it "cached" in our remote identities.

Copy link
Contributor

@NobodysNightmare NobodysNightmare Feb 5, 2025

Choose a reason for hiding this comment

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

From our IRL discussion: The current plan is that there might an an event triggered from the token exchange or from the request of a token for a certain audience.

Since those events are handled synchronously, there is no race condition that can happen here. Once we arrive at this point, the event should have created the RemoteIdentity.

Essentially, we can keep the code as-is.

@@ -70,6 +70,10 @@ def authenticate_via_idp?
authentication_method == "oauth2_sso"
end

def audience
nextcloud_audience
Copy link
Contributor

Choose a reason for hiding this comment

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

It's not yet too late to rename the nextcloud_audience property to audience, making this alias unnecessary.

Copy link
Member Author

Choose a reason for hiding this comment

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

the method gives us another possibility: having control about which type of storage CAN have an audience.

I have no big pain in keeping it like this for now. But open for anything.

Copy link
Contributor

@mereghost mereghost left a comment

Choose a reason for hiding this comment

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

Seems fine overall.

::Storages::Peripherals::StorageInteraction::AuthenticationStrategies::OAuthUserToken
.strategy
.with_user(user)
UserBound = ->(user:, storage:) do
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we, instead of passing the whole storage, just sending its audience?

Copy link
Contributor

Choose a reason for hiding this comment

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

You mean UserBound = ->(user:, audience:) { ... }?

I'd prefer the current way, as it seems to make more sense from a caller's perspective. You hand in a storage and a user to figure out how the user might authenticate.

Otherwise the caller would have to be concerned with how to get an audience from a storage and what that might mean. It also feels like the more brittle interface to offer.

Copy link
Contributor

Choose a reason for hiding this comment

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

More akin to sso_supported.

The thing is, we spread the knowledge of a storage to a thing that has no business knowing that it even exists.

Copy link
Member Author

@Kharonus Kharonus Feb 7, 2025

Choose a reason for hiding this comment

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

@mereghost I think sending just the audience is something very specific. We might fall into the same pit we had for requests, that were build for specific nextcloud and then we had trouble in writing those for OneDrive.

My thoughts are coming from a yesterdays experiment, were Jan and me created an Azure SSO, and we were able to make server to server request with fully obfuscated audiences, it was a weird UUID.

So, point is, maybe we should have a better abstraction here. What is the entity, that holds the information about all configured authentication methods? right now, it is the storage object.

we spread the knowledge of a storage

true, but not here. this adapter is called nextcloud_strategies. it is bound to a storage type.

Copy link
Contributor

Choose a reason for hiding this comment

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

I'd +1 what Eric said.

Also note that even accepting a parameter like sso_supported will also spread knowledge, because the caller of this thing, will suddenly have to know about SSO, to pick an auth strategy, when previously it just needed to know that an auth strategy is broadly related to a user and a storage.

Copy link
Contributor

Choose a reason for hiding this comment

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

true, but not here. this adapter is called nextcloud_strategies. it is bound to a storage type.

I'd say that it has to know about the "authentication bits" of nextcloud, not the storage itself.

I get the feeling that we are missing an actual abstraction here. We started to draft this with the OAuthConfiguration but maybe that was the wrong name.

::Storages::Peripherals::StorageInteraction::AuthenticationStrategies::OAuthUserToken
.strategy
.with_user(user)
UserBound = ->(user:, storage:) do
Copy link
Contributor

Choose a reason for hiding this comment

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

Would if be worth making UserBound into a full blown object? 🤔

Copy link
Member Author

Choose a reason for hiding this comment

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

How would it look like? Do you mind writing down in pseudo code some of the interface you have in mind?

Copy link
Contributor

@mereghost mereghost Feb 10, 2025

Choose a reason for hiding this comment

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

Not very different than it looks like now, but since it is getting smarter and smarter, the fact that it is a lambda kinda bothers me. We can even keep the signature.

Very::Good::Name::UserBound.call(user:, storage:)

It would probably lead to better test-ability in the end too.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

3 participants