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

Recommendations for handling OAuth 2 #462

Open
NCarrellOmni opened this issue Jun 19, 2024 · 11 comments
Open

Recommendations for handling OAuth 2 #462

NCarrellOmni opened this issue Jun 19, 2024 · 11 comments
Labels
enhancement New feature or request question Further information is requested

Comments

@NCarrellOmni
Copy link

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:

  • Removed tenantName
  • Replaced the basicAuthentication methods with BuilderCustomAuthenticator<T> customAuthenticator(Authenticator authenticator)
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
Copy link
Owner

davidmoten commented Jun 20, 2024

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
Copy link
Owner

davidmoten commented Jun 20, 2024

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

@davidmoten
Copy link
Owner

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
Copy link
Owner

davidmoten commented Jun 20, 2024

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
Copy link
Author

Thank you for the information.

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

@davidmoten
Copy link
Owner

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

@davidmoten
Copy link
Owner

davidmoten commented Jun 24, 2024

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
Copy link
Author

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
Copy link
Owner

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

@davidmoten davidmoten added enhancement New feature or request question Further information is requested labels Jun 27, 2024
@NCarrellOmni
Copy link
Author

Sorry about the delay, our project got placed on hold for a bit here and I'm just getting back to this.
It looks like clientSecret is required. We are using ROPC grant flow and passing a clientSecret with that request causes the token provider (AD FS) to return error HTTP 400.
We understand Microsoft recommends against using ROPC, so it's understandable if support can't be added for this.
Have I overlooked something in the builder that would let us omit "clientSecret"? Setting it to an empty string doesn't seem to get around the issue.

Microsoft recommends this format for ROPC

client_id=00001111-aaaa-2222-bbbb-3333cccc4444
&scope=user.read%20openid%20profile%20offline_access
&username=MyUsername@myTenant.com
&password=SuperS3cret
&grant_type=password

@davidmoten
Copy link
Owner

Sorry, I missed this, feel free to bump to get my attention.

If we don't want to go down the ROPC requirements rabbit hole too deeply then an easy way of doing this is via the client builder which allows you to specify an httpServiceTransformer. This allows you to intercept and modify requests.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request question Further information is requested
Projects
None yet
Development

No branches or pull requests

2 participants