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

Feat/credential provider refresh #945

Merged
merged 48 commits into from
Jan 14, 2021

Conversation

MarcosCela
Copy link

Description

This PR attempts to solve: #941

@MarcosCela
Copy link
Author

MarcosCela commented Sep 25, 2020

@bitwiseman Hi Liam!
Just as you said in #941, this adds a CredentialProvider. Right now the changes just ensure that credentials are always retrieved
from the CredentialProvider, but we are mising the refresh logic.

As I see it, the logic that the user will need to follow is:

  • Create a JWT token, with a specified expiration time.
  • With the JWT token, request an installation token, this installation token lives up to 1h.

We will need to either implement a JWTokenProvider in a similar fashion, leaving the user to provide a valid JWT token to use in a CredentialProvider, or alternatively implement all the logic in the library. I am going to do the first approach for now!

@bitwiseman
Copy link
Member

@MarcosCela

If I understand correctly, we'll need to do the second approach in order for refresh to work for longer than the expiration time of the JWT Token. And JWT Tokens have a shorter life span than the GitHub App Tokens.

@MarcosCela
Copy link
Author

@MarcosCela

If I understand correctly, we'll need to do the second approach in order for refresh to work for longer than the expiration time of the JWT Token. And JWT Tokens have a shorter life span than the GitHub App Tokens.

Sorry, bad explanation. As I see it right now we have 2 choices:

  • Users of the library provide a JWTTokenProvider that abstracts the creation of the JWT token from the library. We would not need to include any external libraries.
  • We implement the creation of the JWT token in the library, and users will just provide an application ID and a private key. This will need to include some external libraries:
 <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt-api</artifactId>
    </dependency>
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt-impl</artifactId>
    </dependency>
    <dependency>
      <groupId>io.jsonwebtoken</groupId>
      <artifactId>jjwt-jackson</artifactId>
    </dependency>

Will address the other issues, thank you for your time and insight!

Marcos Cela López added 18 commits September 28, 2020 10:02
This is basically an implementation of a CredentialProvider
that will always authenticate anonymously
These methods let us build the most-used cases
for static credentials that will never change:
- JWT credentials
- Token-based credentials
- Basic Auth credentials
With this we also need to check for exceptions when calling
"/user", because now we don't know what kind of credentials
are coming from the provider, and we could be requesting
a "/user" when the type of credentials is not supported
The JWTTokenProvider implementation is left to the end user,
otherwise we would need to include specific libraries, at least
as far as I am aware.
The OrgInstallationCredentialProvider will give a token,
refreshing when necessary and using the JWTTokenProvider
that the user needs to provide to request new tokens
@MarcosCela
Copy link
Author

MarcosCela commented Sep 28, 2020

As it's right now, an example on how to use this will be as follows:

  • First, you create a GitHubBuilder and configure it as follows:
// User is left with the responsibility to make sure that the passed credential provider always returns
        // a valid JWT token
        GitHub jwtAuthenticated = new GitHubBuilder().withCredentialProvider(() -> "a valid JWT token").build();

        // With the JWT authenticated client, we will be requesting installation apps
        GitHub gh = new GitHubBuilder()
                .withCredentialProvider(new OrgInstallationCredentialProvider("myOrganization", jwtAuthenticated))
                .build();

Where OrgInstallationCredentialProvider is an Oauth2 Token provider that uses a JWT authenticated client. With the JWT token, we request a new installation token when the current one has expired. Users need to provide valid tokens. E.g:

@Slf4j
@Component
@RequiredArgsConstructor
public class JWTTokens {

    private static final long MINUTES_10 = Duration.buildByMinutes(10).getMilliseconds();


    private final PrivateKey privateKey;

    public String token() {
        log.debug("Getting a new JWT token");
        return getJWT();
    }


    public String getJWT() {
        long nowMillis = System.currentTimeMillis();
        Date now = new Date(nowMillis);


        // Let's set the JWT Claims
        JwtBuilder builder = Jwts.builder()
            .setIssuedAt(now)
            .setIssuer("myApplicationId")
            .signWith(privateKey, SignatureAlgorithm.RS256);

        // if it has been specified, let's add the expiration
        if (MINUTES_10 > 0) {
            long expMillis = nowMillis + MINUTES_10;
            Date exp = new Date(expMillis);
            builder.setExpiration(exp);
        }

        // Builds the JWT and serializes it to a compact, URL-safe string
        return builder.compact();
    }

}

We could simplify this and provide the creation of the JWT token from a given private key and applicationId, but it would mean to add dependencies to the library. I think that it's better to let users chose what kind of JWT libraries they use.

We are of course missing tests.
What do you think?

Ready to review!

@bitwiseman bitwiseman force-pushed the feat/credential-provider-refresh branch from 4c16eaa to 6670446 Compare December 31, 2020 17:50
@bitwiseman
Copy link
Member

@MarcosCela
Cloud you take a look at the changes I've made and make sure the make sense to you?

@MarcosCela
Copy link
Author

@MarcosCela
Cloud you take a look at the changes I've made and make sure the make sense to you?

Definetly, I will take a look as soon as I can, thanks again for your time and dedication!!

@MarcosCela
Copy link
Author

MarcosCela commented Jan 4, 2021

This looks extremely promising, to perform automatic access token refresh, I can do something like this:

    CredentialProvider prov = new JWTTokenProvider("applicationId", new File("/my/path.extension"));

    GitHub jwtTokenRequester = new GitHubBuilder()
        .withCredentialProvider(prov)
        .build();
        
    GitHub gh = new GitHubBuilder().withCredentialProvider(() -> {
        // Missing all the refresh token logic, this is requesting a new token all the time (for simplicity)
        return jwtTokenRequester.getApp().getInstallationById("installationId").getAccessTokenUrl();
    }).build();
        
    // From now on, I will just use "gh" for the rest of my program logic, that defers to the inlined
    // credential provider, that uses a second GitHub instance "jwtTokenRequester" to refresh the access token
    // sounds convoluted

With your changes I think that the client is extremely flexible in terms of authentication, looks finished to me!
Perhaps I can add an example of an implementation for the "inline" credential provider that requests an installation
token in the extras package?
Thanks again for your time!

@bitwiseman
Copy link
Member

@MarcosCela

I wouldn't use this as code as the example. I really don't want people to do it the way you've shown above. I want them to implement the bind(GitHub) method. I definitely do not want people to inline credential providers. Maybe the right thing to do is have a separate interface or base class? Or make bind(GitHub) not default?

    CredentialProvider jwtProvider = new JWTTokenProvider("applicationId", new File("/my/path.extension"));

    GitHub gh = new GitHubBuilder().withCredentialProvider(new OrgInstallationCredentialProvider("testOrganization", jwtProvider)).build();

Oh, we need to change OrgInstallationCredentialProvider to AppInstallationCredentialProvider (or at least OrgAppInstallationCredentialProvider).

Also, what do you think of changing CredentialProvider to AuthorizationProvider as that is more accurate to what these are doing now - they are providing the Authorization header value.

@MarcosCela
Copy link
Author

@MarcosCela

I wouldn't use this as code as the example. I really don't want people to do it the way you've shown above. I want them to implement the bind(GitHub) method. I definitely do not want people to inline credential providers. Maybe the right thing to do is have a separate interface or base class? Or make bind(GitHub) not default?

    CredentialProvider jwtProvider = new JWTTokenProvider("applicationId", new File("/my/path.extension"));

    GitHub gh = new GitHubBuilder().withCredentialProvider(new OrgInstallationCredentialProvider("testOrganization", jwtProvider)).build();

Oh, we need to change OrgInstallationCredentialProvider to AppInstallationCredentialProvider (or at least OrgAppInstallationCredentialProvider).

Also, what do you think of changing CredentialProvider to AuthorizationProvider as that is more accurate to what these are doing now - they are providing the Authorization header value.

100% agree on the inlining, it should have a different interface/class class. I will proceed to rename the name to AuthorizationProvider, and all implementations if required (e.g: AppInstallationAuthorizationProvider).
I will also give it another go and see what will be the easiest way to perform the automatic refresh with the bind method, I tried it before but could not understand it fully, will give it another go!
Thanks again!

@MarcosCela
Copy link
Author

MarcosCela commented Jan 7, 2021

I applied your changes and tested it (successfully!) with the following snippet:

  AuthorizationProvider jwt = new JWTTokenProvider("12345",
            Paths.get("/location/to/my/key"));

        OrgAppInstallationAuthorizationProvider orgAUth = 
            new OrgAppInstallationAuthorizationProvider("my-organization", jwt);

        GitHub gh = new GitHubBuilder().withAuthorizationProvider(orgAUth)
            .build();
        System.out.println("gh.getRateLimit() = " + gh.getRateLimit());

Is this the way that you were referring to?

}

protected GHAppInstallation getAppInstallationWithTokenApp3() throws IOException {
return getAppInstallationWithToken(createJwtToken(PRIVATE_KEY_FILE_APP_3, TEST_APP_ID_3));
return getAppInstallationWithToken(JWT_PROVIDER_3.getEncodedAuthorization());
Copy link
Member

Choose a reason for hiding this comment

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

@MarcosCela
To test JWT provider, maybe just add an assertion to the test that uses these.

Copy link
Author

Choose a reason for hiding this comment

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

Tests added, what do you think?

Marcos.Cela added 4 commits January 8, 2021 08:21
This test basically ensures that the requests made with a
JWTTokenProvider follow a valid Authentication pattern,
verifying that the header "conforms" to a valid JWT token
More information on JWT tokens can be found at:

- https://jwt.io/introduction/
Comment on lines 21 to 29
* <pre>
* Authorization: Bearer ey{rest of the header}.{payload}.{signature}
* </pre>
*
* Matched by the regular expression:
*
* <pre>
* ^Bearer (?<JWTHeader>ey\S*)\.(?<JWTPayload>\S*)\.(?<JWTSignature>\S*)$
* </pre>
Copy link
Member

Choose a reason for hiding this comment

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

Very nice.

Rather than exposing an unsafe wrapper for GitHub instances, I added a base class
that can be extended by anyone wanting to implement an authorization provider
that needs a GitHub instance to generate it's authorization string.
@bitwiseman bitwiseman merged commit 3f99541 into hub4j:master Jan 14, 2021
* @throws GeneralSecurityException
* if we couldn't parse the string
*/
private static PrivateKey getPrivateKeyFromString(final String key) throws GeneralSecurityException {
Copy link
Collaborator

Choose a reason for hiding this comment

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

any reason this is private? seems like I just have to re-implement this / copy it if I'm working with strings

Copy link
Member

Choose a reason for hiding this comment

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

Probably better to create a constructor that takes a String.

Copy link
Collaborator

Choose a reason for hiding this comment

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

yes or that...

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.

3 participants