davidmoten / odata-client

Java client generator for a service described by OData CSDL 4.0 metadata. Includes Microsoft Graph clients (v1.0 and Beta), Graph Explorer client, Analytics for DevOps, Dynamics CRM clients
Apache License 2.0
33 stars 8 forks source link

Recommendations for handling OAuth 2 #462

Open NCarrellOmni opened 2 weeks ago

NCarrellOmni commented 2 weeks ago

My team is building an Android application to interface with Dynamics 365 CRM On-Premise and we will be using OAuth. I read through the odata-client-microsoft-client-builder source to try and piece something together. Are there any recommendations for implementing OAuth with Dynamics 365?

I did the following and found that my implementation does work, but I'm not confident that this is the 'correct' way to use BuilderCustomAuthenticator or the Authenticator interface.

I copied the example from com.github.davidmoten.ms.dynamics.Dynamics and made these changes:

public final class Dynamics {

    private Dynamics() {
        // prevent instantiation
    }

    public static <T extends HasContext> Builder<T> service(Class<T> serviceClass) {
        return new Builder<T>(serviceClass);
    }

    public static final class Builder<T extends HasContext> {

        private final Class<T> serviceCls;
        private Optional<String> baseUrl = Optional.empty();
        private PathStyle pathStyle = PathStyle.IDENTIFIERS_IN_ROUND_BRACKETS;

        Builder(Class<T> serviceClass) {
            Preconditions.checkNotNull(serviceClass);
            this.serviceCls = serviceClass;
        }

        public Builder<T> pathStyle(PathStyle pathStyle) {
            this.pathStyle = pathStyle;
            return this;
        }

        /**
         * Expected URL is like https://SOLUTION.crm4.dynamics.com.
         * @param baseUrl
         * @return builder
         */
        public Builder3<T> baseUrl(String baseUrl) {
            Preconditions.checkNotNull(baseUrl);
            this.baseUrl = Optional.of(baseUrl);
            return new Builder3<T>(this);
        }
    }

    public static final class Builder3<T extends HasContext> {

        private final Builder<T> b;

        public Builder3(Builder<T> b) {
            this.b = b;
        }

        public MicrosoftClientBuilder.BuilderCustomAuthenticator<T> customAuthenticator(Authenticator authenticator) {
            return createBuilder().authenticator(authenticator);
        }

        private MicrosoftClientBuilder<T> createBuilder() {
            Creator<T> creator = context -> {
                try {
                    return b.serviceCls.getConstructor(Context.class).newInstance(context);
                } catch (InstantiationException | IllegalAccessException | IllegalArgumentException
                         | InvocationTargetException | NoSuchMethodException | SecurityException e) {
                    throw new ClientException(e);
                }
            };
            return MicrosoftClientBuilder //
                    .baseUrl(b.baseUrl.get()) //
                    .creator(creator) //
                    .addSchema(SchemaInfo.INSTANCE) //
                    .pathStyle(b.pathStyle) //
                    .build();
        }
    }
}

Then I created my custom Authenticator by implementing the Authenticator interface. I ended up not using the URL parameter. Should I be using this parameter? If so, what for?

public class DynamicsAuthenticator implements Authenticator {
    private String accessToken;

    public DynamicsAuthenticator(String accessToken) {
        this.accessToken = accessToken;
    }

    @Override
    public List<RequestHeader> authenticate(URL url, List<RequestHeader> requestHeaders) {
        // Add Authorization header with Bearer token
        requestHeaders.add(new RequestHeader("authorization", "Bearer " + accessToken));
        return requestHeaders;
    }
}

Finally, I instantiate my client. The getAuthToken() method uses the username/password to retrieve an authorization token from ADFS and returns that token as a String.

public class DynamicsTest {
    public static void main(String[] args) {

        System client = Dynamics
                .service(System.class)
                .baseUrl(RESOURCE)
                .customAuthenticator(new DynamicsAuthenticator(getAuthToken()))
                .connectTimeout(600, TimeUnit.SECONDS)
                .readTimeout(600, TimeUnit.SECONDS)
                .build();
        }
}
davidmoten commented 2 weeks ago

~Don't set a customAuthenticator. If you leave it unset it will use a default Microsoft oauth2 authenticator. You can specify the authenticationEndpoint using MicrosoftClientBuilder if you like otherwise it will use AuthenticationEndpoint.GLOBAL.~

Oops, you are using on-premise. So auth is working or not? Using the URL parameter is optional. I see in MicrosoftClientBuilder a comment on this:

                authenticator((url, requestHeaders) -> {
                    // some streaming endpoints object to auth so don't add header
                    // if not on the base service
                    if (url.toExternalForm().startsWith(b.baseUrl)) {
                        // remove Authorization header if present
                        List<RequestHeader> list = requestHeaders //
                                .stream() //
                                .filter(rh -> !rh.name().equalsIgnoreCase("Authorization")) //
                                .collect(Collectors.toList());
                        // add basic auth request header
                        UsernamePassword c = bc.get();
                        list.add(basicAuth(c.username(), c.password()));
                        return list;
                    } else {
                        return requestHeaders;
                    }
                });

Anyway your approach looks good and is what other Dynamics builders look like too, like this finance one.

davidmoten commented 2 weeks ago

This may be useful (MsGraph client), and this user that went through Dynamics connection too.

davidmoten commented 2 weeks ago

BTW, I would normally use an auto-refreshing token retrieval approach so you only need one instance of the built client. The class ClientCredentialsAccessTokenProvider might be useful.

davidmoten commented 2 weeks ago

You could adapt this from MicrosoftClientBuilder for refreshing tokens. We can discuss making MicrosoftClientBuilder a bit more flexible for on-premise too, we'll see what you find.

NCarrellOmni commented 2 weeks ago

Thank you for the information.

We will look at ClientCredentialsAccessTokenProvider and MicrosoftClientBuilder and see what we can come up with for refreshing tokens.

davidmoten commented 1 week ago

Refactoring ClientCredentialsAccessTokenProvider and MicrosoftClientBuilder now to make it easier for on-prem.

davidmoten commented 1 week ago

Here's an on-prem support PR for you to review if you like. I'll merge it soon if no response and build a new release.

NCarrellOmni commented 1 week ago

I looked over the code and this looks good.
I've pulled down the on-prem branch so we can get started. I will open another issue if we run into any more problems. Thank you again for the info and help.

davidmoten commented 1 week ago

0.2.1 is on Maven Central now with on-prem support.