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

Discover access token audiences #17469

Open
wants to merge 2 commits into
base: save-oidc-tokens-to-open-project-database
Choose a base branch
from

Conversation

NobodysNightmare
Copy link
Contributor

@NobodysNightmare NobodysNightmare commented Dec 16, 2024

Ticket

https://community.openproject.org/projects/cross-application-user-integration-stream/work_packages/60162

What are you trying to accomplish?

This PR is an extension of previous work in #16940. We want to be able to use tokens stored in the corresponding database model for access to third party services, such as Nextcloud.

There are different ways that we can use these existing tokens for that. The case handled in this PR is that the token might already be immediately usable for use in certain services, which we can discover from the token's audience.

What approach did you choose and why?

We are expecting the access token to be a JWT that we can parse and verify using the metadata we have configured for the corresponding OIDC provider. While there is no guarantee that access tokens can be parsed as JWTs, it's very common to find when dealing with an OIDC IDP, since those are required to provide their ID tokens as JWTs, so the "infrastructure" for signing JWTs exists anyways.

To perform the parsing I extracted previously existing code from the JwtOidc warden strategy into a new parser service and adapted it to the common needs of the warden strategy and our new code.

Merge checklist

  • Added/updated tests
    • For JWT parsing
    • For audience discovery
  • Added/updated documentation in Lookbook (patterns, previews, etc)
  • Tested major browsers (Chrome, Firefox, Edge, ...)

@NobodysNightmare NobodysNightmare changed the title Discover audiences Discover access token audiences Dec 16, 2024
@NobodysNightmare NobodysNightmare force-pushed the discover-audiences branch 3 times, most recently from d31e204 to efc65c8 Compare December 16, 2024 13:40
# +

module OpenIDConnect
class ProviderTokenParser
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 am starting to become unhappy about names.

I called the UserToken like that, because it's a token that grants access in the name of the user.

I called the ProviderTokenParser like that, because it only allows proper parsing of JWTs that were issued by a configured provider (they are not parseable without knowing the provider).

But now looking at both names next to each other, it doesn't seem to make sense, because there are no tokens to do stuff in the name of a provider. It seems counter intuitive, that the ProviderTokenParser would parse a UserToken and yet that's what it does...

Copy link
Contributor

Choose a reason for hiding this comment

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

Names are hard as hell. This could easily become just OIDC::JWTParser and the UserToken could just be OIDC::Token.

I suck at naming. XD

Copy link
Contributor Author

Choose a reason for hiding this comment

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

It's definitely not a JWTParser, because the token being a JSON Web Token is optional. Though I'll give TokenParser and Token a second thought.

Copy link
Contributor

@mereghost mereghost Jan 10, 2025

Choose a reason for hiding this comment

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

You could have multiple parsers being chosen at runtime for each type of token, might even clean up the code as each parser gets more and more specialized.

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 was even mixing things up. The token parser completely expects a JWT. Just other parts of the code (AssociateUserToken) treat JWT as optional. Thanks for the input :)

@NobodysNightmare NobodysNightmare force-pushed the discover-audiences branch 2 times, most recently from d4c2af6 to 178b622 Compare December 17, 2024 08:19
@NobodysNightmare NobodysNightmare requested a review from a team December 17, 2024 09:07
@NobodysNightmare NobodysNightmare force-pushed the save-oidc-tokens-to-open-project-database branch from 1ab8a1c to 06f4d7d Compare December 18, 2024 07:56
JWT parsing is rather involved, because we need to fetch
proper certificates first. We will need to parse JWTs in
a different context than authorization as well,
so it makes sense to have the parsing centralized.

This also allowed to add specs for this previously
not (unit) tested piece of code.
We want to know for which purposes tokens can
be used. Assuming that we receive JWTs as access tokens,
it's possible to read their audience and thus check
where these tokens are usable.

Importantly, it's still possible that an access token
is not a JWT, so we have to allow that as well. The
code could be extended in the future to send such tokens
to the introspection endpoint of the IDP, hoping to receive
an audience list as a result of that.
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.

Overall seems solid just one point that'd like to check viability.

# +

module OpenIDConnect
class ProviderTokenParser
Copy link
Contributor

Choose a reason for hiding this comment

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

Names are hard as hell. This could easily become just OIDC::JWTParser and the UserToken could just be OIDC::Token.

I suck at naming. XD

Comment on lines +46 to +49
raise Error, "Token signature algorithm #{alg} is not supported" if SUPPORTED_JWT_ALGORITHMS.exclude?(alg)

provider = fetch_provider(issuer)
raise Error, "The access token issuer is unknown" if provider.blank?
Copy link
Contributor

Choose a reason for hiding this comment

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

🟡 My guess here from the code, is that this exception is being used as flow control to represent an invalid but not exceptional state.

That's were we usually would rely on a ServiceResult, Dry::Monad::Result or similar object to represent a failed state without all the costs of raising an exception.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

That's a fair comment. My excuse is that all of this started from refactoring previously existing code and extracting it into a common class.

But that excuse is bad. I'll look into getting friends with Dry::Monad::Result once more ;-)

Copy link
Contributor

Choose a reason for hiding this comment

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

Feel free to enlist me if you get stuck at using it. =)

def fetch_provider(issuer)
return nil if issuer.blank?

OpenIDConnect::Provider.where(available: true).find { |p| p.issuer == issuer }
Copy link
Contributor

Choose a reason for hiding this comment

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

🔴 This will load all the providers in memory then run Array#find, can't the block become part of the where clause?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

issuer is part of the options. I'll have to check, but assuming that options is a JSONB this should still be possible.

Now that I see this code, I also want to double check whether the new code circumvents a cache that the previous code may have been using (via OpenProject::OpenIDConnect.providers).

Comment on lines +64 to +68
subject

expect(OpenIDConnect::ProviderTokenParser).to have_received(:new)
.with(verify_audience: false, required_claims: ["aud"])
expect(parser).to have_received(:parse).with(access_token)
Copy link
Contributor

Choose a reason for hiding this comment

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

🟢 isn't this testing the internals of the service, checking if a forcebly injected spy actually is being called with the correct params?

Can't we use dependency injection here so that we don't need to stub the .new method? Is there a easy way to not have to mock/spy at all? Wouldn't be tested on the other examples below?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

One way avoid the mocking is to pass a real JWT that has certain properties. We do this in spec/requests/api/v3/authentication_spec.rb where I think this approach is feasible, because it's an integrative spec.

Here I wanted to stay encapsulated, while still checking whether the tested unit properly behaves at its boundaries (e.g. whether it correctly sets-up token parsing). E.g. the token parser should not raise an error when the received token is for a foreign audience, but still it should ensure that an audience claim is part of the token.

Let me try to follow along with your suggestion: Your idea would be to hand in the token parser into the AssociateUserToken service. This would allow us to replace it with a mock during testing more easily (no need to overwrite new).

However, I'd still ask myself where we'd test that the "production parser" is configured correctly. The correct configuration is very much related to the use case of the service under test.

Copy link
Contributor

Choose a reason for hiding this comment

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

However, I'd still ask myself where we'd test that the "production parser" is configured correctly. The correct configuration is very much related to the use case of the service under test.

This could be done by the request specs you mentioned. I'm okay with how it is now, but every time I see a stub of .new, I think of it as a code smell.

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