capacitor-community / generic-oauth2

Generic Capacitor OAuth 2 client plugin. Stop the war in Ukraine!
MIT License
234 stars 115 forks source link

Feat: option to disable authState.performActionWithFreshTokens() flow during authenticate #270

Open TheLyndonRay opened 4 months ago

TheLyndonRay commented 4 months ago

Flow type: Authentication Platform: Android Provider: Duende generic-oauth2 version: 5.0.0

Describe the Feature

The ability to disable the authState.performActionWithFreshTokens() call during the authenticate flow. For cases where the providers invalidate refreshTokens with each tokenRequest regardless of the refreshToken being used.

When working with a provider that rolls new refreshTokens anytime the token endpoints are hit, regardless of using a refreshToken, the Android implementation never gets the 2nd refreshToken created. IOS doesn't appear to have this issue and does not attempt a second token request for 'fresh' tokens during authentication.

The Android implementation of the authenticate method attempts two separate token requests. One started via the this.authService.performTokenRequest() and then again via authState.performActionWithFreshTokens() if an exception is not created during the first.

This results in a response that has the initial accessToken and refreshToken in the request's response in a access_token_response along with the 2nd token request's response added as a separate access_token. Example:

{...
  "access_token_response": {
    "request": {
      "configuration": {
        "authorizationEndpoint": "https://xxx",
        "tokenEndpoint": "https://zzz"
      },
      "clientId": "890890",
      "nonce": "9AJax1TSN391wrtdRDVT_w",
      "grantType": "authorization_code",
      "redirectUri": "bbb",
      "authorizationCode": "456456-1",
      "additionalParameters": {}
    },
    "token_type": "Bearer",
    "access_token": "THE_FIRST_ACCESS_TOKEN",
    "expires_at": 1721831273530,
    "id_token": "123123",
    "refresh_token": "THE_FIRST_REFRESH_TOKEN",
    "scope": "openid offline_access",
    "additionalParameters": {}
  },
  "access_token": "THE_SECOND_ACCESS_TOKEN"
}

At this point, our first refreshToken is invalidated by the provider we're using and we don't have the 2nd refreshToken created during that 2nd request.

Code block in question: GenericOAuth2Plugin.java, line 438

if (oauth2Options.getAccessTokenEndpoint() != null) {
                    this.authService = new AuthorizationService(getContext());
                    TokenRequest tokenExchangeRequest;
                    try {
                        tokenExchangeRequest = authorizationResponse.createTokenExchangeRequest();
                        this.authService.performTokenRequest(
                                tokenExchangeRequest,
                                (accessTokenResponse, exception) -> {
                                    authState.update(accessTokenResponse, exception);
                                    if (exception != null) {
                                        savedCall.reject(ERR_AUTHORIZATION_FAILED, String.valueOf(exception.code), exception);
                                    } else {
                                        if (accessTokenResponse != null) {
                                            if (oauth2Options.isLogsEnabled()) {
                                                Log.i(getLogTag(), "Access token response:\n" + accessTokenResponse.jsonSerializeString());
                                            }
                                            authState.performActionWithFreshTokens(
                                                authService,
                                                (accessToken, idToken, ex1) -> {
                                                    AsyncTask<String, Void, ResourceCallResult> asyncTask = new ResourceUrlAsyncTask(
                                                        savedCall,
                                                        oauth2Options,
                                                        getLogTag(),
                                                        authorizationResponse,
                                                        accessTokenResponse
                                                    );
                                                    asyncTask.execute(accessToken);
                                                }
                                            );
                                        } else {
                                            resolveAuthorizationResponse(savedCall, authorizationResponse);
                                        }
                                    }
                                }
                            );

Platform(s) Support Requested

Describe Preferred Solution

As it's openid's AuthState performActionWithFreshTokens()'s callback that's preparing the new accessToken for the ResourceUrlAsyncTask (and the eventual adding of the response and token with OAuth2Utils.assignResponses()), an option to disable the call for authState.performActionWithFreshTokens() via config value (ex: freshTokenAttemptEnabled boolean added to OAuth2Options.java or something) could be the easiest approach. Where the default would be to do the 2nd token call where a false flag would simply not attempt it.

Example config and usage:

var authConfig = {
        appId: CLIENT_ID,
        authorizationBaseUrl: AUTHORIZATION_ENDPOINT,
        accessTokenEndpoint: TOKEN_ENDPOINT,
        logoutIdentityUrl: LOGOUT_ENDPOINT
        pkceEnabled: true,
        responseType: 'code',
        redirectUrl: REDIRECT_URL,
        scope: 'openid',
        ios: {
            resourceUrl: ''
        },
        web: {
            windowTarget: '_blank'
        },
        freshTokenAttemptEnabled: false
...
const config = { ...authConfig, additionalParameters: { culture: languageCode } };
const response = await OAuth2Client.authenticate(config);
...

Example change:

if (oauth2Options.getAccessTokenEndpoint() != null) {
                    this.authService = new AuthorizationService(getContext());
                    TokenRequest tokenExchangeRequest;
                    try {
                        tokenExchangeRequest = authorizationResponse.createTokenExchangeRequest();
                        this.authService.performTokenRequest(tokenExchangeRequest, (accessTokenResponse, exception) -> {
                            authState.update(accessTokenResponse, exception);
                            if (exception != null) {
                                savedCall.reject(ERR_AUTHORIZATION_FAILED, String.valueOf(exception.code), exception);
                            }
                            else {
                                if (oauth2Options.isFreshTokenAttemptEnabled()) {
                                    if (accessTokenResponse != null)) {
                                        if (oauth2Options.isLogsEnabled()) {
                                            Log.i(getLogTag(), "Access token response:\n" + accessTokenResponse.jsonSerializeString());
                                        }
                                        authState.performActionWithFreshTokens(authService,
                                                (accessToken, idToken, ex1) -> {
                                                    AsyncTask<String, Void, ResourceCallResult> asyncTask =
                                                            new ResourceUrlAsyncTask(
                                                                    savedCall,
                                                                    oauth2Options,
                                                                    getLogTag(),
                                                                    authorizationResponse,
                                                                    accessTokenResponse);
                                                    asyncTask.execute(accessToken);
                                                });
                                    } else {
                                        resolveAuthorizationResponse(savedCall, authorizationResponse);
                                    }
                                } else {
                                    resolveAuthorizationResponse(savedCall, authorizationResponse, accessTokenResponse);
                                }
                            }
                        });
                    } catch (Exception e) {
                        savedCall.reject(ERR_NO_AUTHORIZATION_CODE, e);
                    }
                }

and an overload of resolveAuthorizationResponse():

private void resolveAuthorizationResponse(PluginCall savedCall, AuthorizationResponse authorizationResponse, TokenResponse tokenResponse) {
        JSObject json = new JSObject();
        OAuth2Utils.assignResponses(json, null, authorizationResponse, tokenResponse);
        savedCall.resolve(json);
    }

Describe Alternatives

Alternatives for the prevention of the 2nd tokenRequest attempt are limited outside of the mentioned block. Another alternative would be to have openid include the refreshToken in the AuthState's AuthorizationService.TokenResponseCallback() (Followed by including it with the ResourceUrlAsyncTask for the assigning). And then update OAuth2Utils.assignResponses() to also include it:

public static void assignResponses(JSObject resp, String accessToken, String refreshToken, AuthorizationResponse authorizationResponse, TokenResponse accessTokenResponse) {
        // #154
        if (authorizationResponse != null) {
            resp.put("authorization_response", authorizationResponse.jsonSerialize());
        }
        if (accessTokenResponse != null) {
            resp.put("access_token_response", accessTokenResponse.jsonSerialize());
        }
        if (accessToken != null) {
            resp.put("access_token", accessToken);
        }
        if (refreshToken != null) {
            resp.put("refreshToken ", refreshToken);
        }
new AuthorizationService.TokenResponseCallback() {
                    @Override
                    public void onTokenRequestCompleted(
                            @Nullable TokenResponse response,
                            @Nullable AuthorizationException ex) {
                        update(response, ex);

                        String accessToken = null;
                        String idToken = null;
                        AuthorizationException exception = null;

                        if (ex == null) {
                            mNeedsTokenRefreshOverride = false;
                            accessToken = getAccessToken();
                            refreshToken = getRefreshToken();
                            idToken = getIdToken();
                        } else {
                            exception = ex;
                        }

                        ...
                    }
                });

This way, the consumer can decide to make use of either result. The ones inside of the original access_token_response or the appended tokens.

This does not seem viable.

Additional Context

This doesn't appear to be an issue with IOS. ByteowlsCapacitorOauth2.swift and the authenticate flow does not appear to have a case for attempting a request with 'fresh' tokens after the initial accessTokenJsObject or accessTokenResponse is created.