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

Add token exchange capability to FetchService #17678

Open
wants to merge 3 commits into
base: token-fetch-service
Choose a base branch
from

Conversation

NobodysNightmare
Copy link
Contributor

@NobodysNightmare NobodysNightmare commented Jan 21, 2025

We will exchange tokens if there is no token for
the given audience or if it's the only way to perform a refresh.

Ticket

https://community.openproject.org/work_packages/60152/activity

What are you trying to accomplish?

Allowing to perform an OAuth 2.0 Token Exchange when that's necessary to obtain a token to be used at the target audience.

What approach did you choose and why?

I integrated this into the existing FetchService, which now performs 2-3 things, depending on how you count:

  • Looking up a token (a glorified where statement)
  • Refreshing the access token if expired
  • Exchanging the token if it can't be found or it's expired, but there is no refresh token

I chose to integrate all of these aspects into a single service, because it makes sense from a callers perspective, where you just want to have a token and you want to use it. However, I also found it hard to separate these aspects, because they depend on each other. Token Exchange is not only needed for tokens that can't be found, but also for tokens that need to be refreshed. And to perform a token exchange, we need to fetch the correct token first.

Merge checklist

  • Added/updated tests for the entire behaviour
  • Splitting tests according to units, after splitting into 3 services

We will exchange tokens if there is no token for
the given audience or if it's the only way to perform
a refresh.
@NobodysNightmare
Copy link
Contributor Author

NobodysNightmare commented Jan 21, 2025

Since the separation of concerns topic already came up as a question in the daily today, I'll make another shot at separating involved services more cleanly. Let's see how this will work out.

edit: This has been done

Now splitting responsibilities into:
* Fetching
* Refreshing
* Exchanging

Where fetching is composed of the other two services, so that
a caller can mostly care about receiving a usable access token
and not the intricacies of refreshing and exchanging tokens that
were obtained.

This is intended to improve readability and understandability of
the code, while keeping things together that need to be done
in concert.
@NobodysNightmare NobodysNightmare force-pushed the fetch-service-exchange-token branch from 600e27e to 0966142 Compare January 21, 2025 15:58
@NobodysNightmare NobodysNightmare requested a review from a team January 21, 2025 16:06
Copy link
Member

@Kharonus Kharonus left a comment

Choose a reason for hiding this comment

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

In combination with the base branch I'd like to raise the discussion of IoC and a bit polishing on the monad coding style.

both topics certainly can be extracted out of this PR into a following refactoring.

def initialize(user:,
jwt_parser: JwtParser.new(verify_audience: false, verify_expiration: false),
token_exchange: ExchangeService.new(user:),
token_refresh: RefreshService.new(user:, token_exchange:))
Copy link
Member

Choose a reason for hiding this comment

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

🟡 Seeing constructors like this makes me feel itchy. Should we consider any kind of IoC here? like Dry::AutoInject?

Also peeking into the direction of @mereghost

Copy link
Contributor Author

Choose a reason for hiding this comment

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

To be fair: I would have probably not added token_exchange and token_refresh as parameters at all, but implemented them as private methods and then in specs I'd have mocked the new method of them, but @mereghost told me having a dependency injecting constructor would be preferrable to that.

In this specific case, I was at least able to use the DI for production code as well, by implementing ExchangeService::Disabled, which is a way to avoid passing an additional enable_token_exchange parameter.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I just checked the code example of dry-autoinject. I understand that we use it in certain places (e.g. our storage queries), but to me it feels like a too big gun to use here ("Kanonen auf Spatzen").

If the problem we wanted to solve was, that it's somehow bad practice to mock internal implementation details of a class (e.g. knowing that it would internally call OtherService.new), then I can understand that making the injection of said class a part of the class's public interface (e.g. by passing OtherService.new as an argument).

However, I start to become skeptical, when the solution to now having to pass a class in via an argument, is to use a library that makes the internally used class a secret again (it would be "hidden" in a private method again), but allows to change it from another place (e.g. by changing the outside "container").

That at least does not contribute to clarity IMO. So the question is, which problem we are solving by doing that.

class ExchangeService
include Dry::Monads[:result]

class Disabled
Copy link
Member

Choose a reason for hiding this comment

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

🟡 This also hints into the direction of dependency injection.

So, depending on the condition, either the ExchangeService or the DisabledExchangeService is injected.

def call(audience)
return Failure("Provider does not support token exchange") unless supported?

FetchService.new(user: @user, token_exchange: Disabled)
Copy link
Member

Choose a reason for hiding this comment

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

🟡 a do notation would make this piece of code less indented and IMHO better readable

Because do-notation might be more readable.
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.

2 participants