diff --git a/CHANGELOG.md b/CHANGELOG.md index d3260897..5683ce8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,21 +2,30 @@ ## [Unreleased] -## [3.0.0] - 2021-07-xx +### Added +* Added `logoutUrl` to base options [#97](https://github.com/moberwasserlechner/capacitor-oauth2/issues/97) + +### Fixed +* iOS: Fix boolean param inheritance (#111) [#111](https://github.com/moberwasserlechner/capacitor-oauth2/issues/111) + +## [3.0.0] - 2021-08-02 ### Breaking -* Minimum Capacitor version is **3.0.0** [#138](https://github.com/moberwasserlechner/capacitor-oauth2/issues/138) [#140](https://github.com/moberwasserlechner/capacitor-oauth2/pull/140) +* Minimum Capacitor version is **3.0.0**. Only this plugin version supports Capacitor `3.x`! [#138](https://github.com/moberwasserlechner/capacitor-oauth2/issues/138) [#140](https://github.com/moberwasserlechner/capacitor-oauth2/pull/140) ### Added * Web: Add a new option `windowReplace` that defaults to undefined. Used in `window.open()` 4th param. This will fix https://bugs.chromium.org/p/chromium/issues/detail?id=1164959 [#153](https://github.com/moberwasserlechner/capacitor-oauth2/issues/153) -* Web: Add "authorization_response" and "access_token_response" to "resource response" [#154](https://github.com/moberwasserlechner/capacitor-oauth2/issues/154) +* Web, Android: Add "authorization_response" and "access_token_response" to the result returned to JS. On iOS it is not possible to extract the authorization response because of the used lib. [#154](https://github.com/moberwasserlechner/capacitor-oauth2/issues/154) +* Web, Android: Added `additionalResourceHeaders` to base options +* Web, Android, iOS: Added `logsEnabled` to base options. If enabled extensive logs are written. All logs are prefixed with `I/Capacitor/OAuth2ClientPlugin` across all platforms. ### Changed * Use `window.crypto` if available to generate random strings [#138](https://github.com/moberwasserlechner/capacitor-oauth2/issues/138) [#140](https://github.com/moberwasserlechner/capacitor-oauth2/pull/140) ### Fixed * Web: # in URL causes parser to ignore ? [#132](https://github.com/moberwasserlechner/capacitor-oauth2/issues/132) [#133](https://github.com/moberwasserlechner/capacitor-oauth2/pull/133) +* Android: Fix boolean param inheritance (#162) [#162](https://github.com/moberwasserlechner/capacitor-oauth2/issues/162) ## [2.1.0] - 2020-08-27 @@ -102,6 +111,7 @@ This is controlled by Android specific parameters `handleResultOnNewIntent` for - Fix github security error by updating Jest lib [Unreleased]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/3.0.0...master +[3.1.0]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/3.0.0...3.1.0 [3.0.0]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/2.1.0...3.0.0 [2.1.0]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/2.0.0...2.1.0 [2.0.0]: https://github.com/moberwasserlechner/capacitor-oauth2/compare/1.1.0...2.0.0 diff --git a/README.md b/README.md index b8c0dc4b..4b1dedf3 100644 --- a/README.md +++ b/README.md @@ -21,17 +21,17 @@ Actively maintained: YES ## Install ```bash -npm install @capacitor-community/oauth2 +npm i @byteowls/capacitor-oauth2 npx cap sync ``` ## Versions -| Plugin | Minimum Capacitor | Docs | Notes | +| Plugin | Capacitor | Docs | Notes | |--------|-------------------|----------------------------------------------------------------------------------------|--------------------------------| -| 3.x | 3.0.0 | **(NOT RELEASED YET)** [README](https://github.com/moberwasserlechner/oauth2/blob/master/README.md) | Breaking changes see Changelog. XCode 12.0 needs this version | -| 2.x | 2.0.0 | [README](https://github.com/moberwasserlechner/capacitor-oauth2/blob/2.1.0/README.md) | Breaking changes see Changelog. XCode 11.4 needs this version | -| 1.x | 1.0.0 | [README](https://github.com/moberwasserlechner/capacitor-oauth2/blob/1.1.0/README.md) | | +| 3.x | 3.x.x | [README](https://github.com/moberwasserlechner/oauth2/blob/master/README.md) | Breaking changes see Changelog. XCode 12.0 needs this version | +| 2.x | 2.x.x | [README](https://github.com/moberwasserlechner/capacitor-oauth2/blob/2.1.0/README.md) | Breaking changes see Changelog. XCode 11.4 needs this version | +| 1.x | 1.x.x | [README](https://github.com/moberwasserlechner/capacitor-oauth2/blob/1.1.0/README.md) | | For further details on what has changed see the [CHANGELOG](https://github.com/moberwasserlechner/capacitor-oauth2/blob/master/CHANGELOG.md). @@ -187,18 +187,20 @@ Example: These parameters are overrideable in every platform -| parameter | default | required | description | since | -|---------------------- |--------- |---------- |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |------- | -| appId | | yes | aka clientId, serviceId, ... | | -| authorizationBaseUrl | | yes | | | -| responseType | | yes | | | -| redirectUrl | | yes | | 2.0.0 | -| accessTokenEndpoint | | | If empty the authorization response incl code is returned. Known issue: Not on iOS! | | -| resourceUrl | | | If empty the tokens are return instead. If you need just the `id_token` you have to set both `accessTokenEndpoint` and `resourceUrl` to `null` or empty ``. | | -| pkceEnabled | `false` | | Enable PKCE if you need it. | | -| scope | | | | | -| state | | | The plugin always uses a state.
If you don't provide one we generate it. | | -| additionalParameters | | | Additional parameters for anything you might miss, like `none`, `response_mode`.

Just create a key value pair.
```{ "key1": "value", "key2": "value, "response_mode": "value"}``` | | +| parameter | default | required | description | since | +|---------------------- |--------- |---------- |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |------- | +| appId | | yes | aka clientId, serviceId, ... | | +| authorizationBaseUrl | | yes | | | +| responseType | | yes | | | +| redirectUrl | | yes | | 2.0.0 | +| accessTokenEndpoint | | | If empty the authorization response incl code is returned. Known issue: Not on iOS! | | +| resourceUrl | | | If empty the tokens are return instead. If you need just the `id_token` you have to set both `accessTokenEndpoint` and `resourceUrl` to `null` or empty ``. | | +| additionalResourceHeaders | | | Additional headers for the resource request | 3.0.0 | +| pkceEnabled | `false` | | Enable PKCE if you need it. Note: On iOS because of #111 boolean values are not overwritten. You have to explicitly define the param in the subsection. | | +| logsEnabled | `false` | | Enable extensive logging. All plugin outputs are prefixed with `I/Capacitor/OAuth2ClientPlugin: ` across all platforms. Note: On iOS because of #111 boolean values are not overwritten. You have to explicitly define the param in the subsection. | 3.0.0 | +| scope | | | | | +| state | | | The plugin always uses a state.
If you don't provide one we generate it. | | +| additionalParameters | | | Additional parameters for anything you might miss, like `none`, `response_mode`.

Just create a key value pair.
```{ "key1": "value", "key2": "value, "response_mode": "value"}``` | | **Platform Web** @@ -403,7 +405,7 @@ These are some of the providers that can be configured with this plugin. I'm hap |-----------|------------------------|-------| | Google | [see below](#google) | | | Facebook | [see below](#facebook) | | -| Azure B2C | [see below](#azure-b2c)| | +| Azure AD B2C | [see below](#azure-b2c)| | | Apple | [see below](#apple) | ios only | @@ -501,16 +503,45 @@ not supported ### Azure B2C -In case of problems please read [#91](https://github.com/moberwasserlechner/capacitor-oauth2/issues/91) -and [#96](https://github.com/moberwasserlechner/capacitor-oauth2/issues/96) - -See this [example repo](https://github.com/loonix/capacitor-oauth2-azure-example) by @loonix. +It's important to use the urls you see in the Azure config for the specific platform. #### PWA -See these 2 configs that should work. +Setting up Azure B2C in July 2021 presents me with `microsoftonline.com` urls, so the config looks like: -It's important to use the urls you see in the Azure config for the specific platform. +```typescript +import {OAuth2AuthenticateOptions, OAuth2Client} from "@byteowls/capacitor-oauth2"; + +export class AuthService { + + getAzureB2cOAuth2Options(): OAuth2AuthenticateOptions { + return { + appId: environment.oauthAppId.azureBc2.appId, + authorizationBaseUrl: `https://login.microsoftonline.com/${environment.oauthAppId.azureBc2.tenantId}/oauth2/v2.0/authorize`, + scope: "https://graph.microsoft.com/User.Read", // See Azure Portal -> API permission + accessTokenEndpoint: `https://login.microsoftonline.com/${environment.oauthAppId.azureBc2.tenantId}/oauth2/v2.0/token`, + resourceUrl: "https://graph.microsoft.com/v1.0/me/", + responseType: "code", + pkceEnabled: true, + logsEnabled: true, + web: { + redirectUrl: environment.redirectUrl, + windowOptions: "height=600,left=0,top=0", + }, + android: { + redirectUrl: "msauth://{package-name}/{url-encoded-signature-hash}" // See Azure Portal -> Authentication -> Android Configuration "Redirect URI" + }, + ios: { + pkceEnabled: true, // workaround for bug #111 + redirectUrl: "msauth.{package-name}://auth" + } + }; + } +} +``` + +
+Other configs that works in prior versions ```typescript import {OAuth2Client} from "@byteowls/capacitor-oauth2"; @@ -574,8 +605,53 @@ azureLogin() { } ``` +
+ #### Android +If you have **only** Azure B2C as identity provider you have to add a new `intent-filter` to your main activity in `AndroidManifest.xml`. + +```xml + + + + + + + +``` + +If you have **multiple** identity providers you have to create a new Activity in `AndroidManifest.xml`. + +In my case I had Google and Azure AD B2C. + +Without this extra activity the result was always `RESULT_CANCELED`. + +```xml + + + + + + + + + + + + + + + + + +``` + +Example values +* @string/azure_b2c_scheme ... `msauth` +* @string/package_name ... `com.company.project` +* azure_b2c_signature_hash ... `/your-signature-hash` ... The leading slash is required. Copied from Azure Portal Android Config "Signature hash" field + See [Android Default Config](#android-default-config) #### iOS @@ -588,13 +664,20 @@ Open `Info.plist` in XCode by Right Click on that file -> Open as -> Source Code CFBundleURLSchemes - msauth.BUNDLE_ID + + msauth.com.yourcompany.yourproject ``` -Do not enter `://` and part of your redirect url after those chars. +Do not enter `://` and part of your redirect url. + +#### Troubleshooting +In case of problems please read [#91](https://github.com/moberwasserlechner/capacitor-oauth2/issues/91) +and [#96](https://github.com/moberwasserlechner/capacitor-oauth2/issues/96) + +See this [example repo](https://github.com/loonix/capacitor-oauth2-azure-example) by @loonix. ### Google diff --git a/android/src/main/java/com/byteowls/capacitor/oauth2/OAuth2ClientPlugin.java b/android/src/main/java/com/byteowls/capacitor/oauth2/OAuth2ClientPlugin.java index b45f15e2..879b8bac 100644 --- a/android/src/main/java/com/byteowls/capacitor/oauth2/OAuth2ClientPlugin.java +++ b/android/src/main/java/com/byteowls/capacitor/oauth2/OAuth2ClientPlugin.java @@ -4,6 +4,7 @@ import android.content.ActivityNotFoundException; import android.content.Intent; import android.net.Uri; +import android.os.AsyncTask; import android.util.Log; import androidx.activity.result.ActivityResult; @@ -25,6 +26,7 @@ import net.openid.appauth.AuthorizationServiceConfiguration; import net.openid.appauth.GrantTypeValues; import net.openid.appauth.TokenRequest; +import net.openid.appauth.TokenResponse; import org.json.JSONException; @@ -43,6 +45,7 @@ public class OAuth2ClientPlugin extends Plugin { private static final String PARAM_ACCESS_TOKEN_ENDPOINT = "accessTokenEndpoint"; private static final String PARAM_PKCE_ENABLED = "pkceEnabled"; private static final String PARAM_RESOURCE_URL = "resourceUrl"; + private static final String PARAM_ADDITIONAL_RESOURCE_HEADERS = "additionalResourceHeaders"; private static final String PARAM_ADDITIONAL_PARAMETERS = "additionalParameters"; private static final String PARAM_ANDROID_CUSTOM_HANDLER_CLASS = "android.customHandlerClass"; // Activity result handling @@ -154,13 +157,16 @@ public void authenticate(final PluginCall call) { disposeAuthService(); oauth2Options = buildAuthenticateOptions(call.getData()); if (oauth2Options.getCustomHandlerClass() != null) { + if (oauth2Options.isLogsEnabled()) { + Log.i(getLogTag(), "Entering custom handler: " + oauth2Options.getCustomHandlerClass().getClass().getName()); + } try { Class handlerClass = (Class) Class.forName(oauth2Options.getCustomHandlerClass()); OAuth2CustomHandler handler = handlerClass.newInstance(); handler.getAccessToken(getActivity(), call, new AccessTokenCallback() { @Override public void onSuccess(String accessToken) { - new ResourceUrlAsyncTask(call, oauth2Options, getLogTag()).execute(accessToken); + new ResourceUrlAsyncTask(call, oauth2Options, getLogTag(), null, null).execute(accessToken); } @Override @@ -316,10 +322,12 @@ protected void handleOnNewIntent(Intent intent) { @ActivityCallback private void handleIntentResult(PluginCall call, ActivityResult result) { - if (result.getResultCode() == Activity.RESULT_CANCELED) { - call.reject(USER_CANCELLED); - } else { - handleAuthorizationRequestActivity(result.getData(), call); + if (this.oauth2Options != null && this.oauth2Options.isHandleResultOnActivityResult()) { + if (result.getResultCode() == Activity.RESULT_CANCELED) { + call.reject(USER_CANCELLED); + } else { + handleAuthorizationRequestActivity(result.getData(), call); + } } } @@ -341,6 +349,12 @@ void handleAuthorizationRequestActivity(Intent intent, PluginCall savedCall) { if (error.code == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW.code) { savedCall.reject(USER_CANCELLED); } else if (error.code == AuthorizationException.AuthorizationRequestErrors.STATE_MISMATCH.code) { + if (oauth2Options.isLogsEnabled()) { + Log.i(getLogTag(), "State from web options: " + oauth2Options.getState()); + if (authorizationResponse != null) { + Log.i(getLogTag(), "State returned from provider: " + authorizationResponse.state); + } + } savedCall.reject(ERR_STATES_NOT_MATCH); } else { savedCall.reject(ERR_GENERAL, error); @@ -350,6 +364,9 @@ void handleAuthorizationRequestActivity(Intent intent, PluginCall savedCall) { // this response may contain the authorizationCode but also idToken and accessToken depending on the flow chosen by responseType if (authorizationResponse != null) { + if (oauth2Options.isLogsEnabled()) { + Log.i(getLogTag(), "Authorization response:\n" + authorizationResponse.jsonSerializeString()); + } // if there is a tokenEndpoint configured try to get the accessToken from it. // it might be already in the authorizationResponse but tokenEndpoint might deliver other tokens. if (oauth2Options.getAccessTokenEndpoint() != null) { @@ -363,14 +380,22 @@ void handleAuthorizationRequestActivity(Intent intent, PluginCall savedCall) { savedCall.reject(ERR_AUTHORIZATION_FAILED, exception); } else { if (accessTokenResponse != null) { - if (oauth2Options.getResourceUrl() != null) { - authState.performActionWithFreshTokens(authService, (accessToken, idToken, ex1) - -> new ResourceUrlAsyncTask(savedCall, oauth2Options, getLogTag()).execute(accessToken)); - } else { - createJsObjAndResolve(savedCall, accessTokenResponse.jsonSerializeString()); + if (oauth2Options.isLogsEnabled()) { + Log.i(getLogTag(), "Access token response:\n" + accessTokenResponse.jsonSerializeString()); } + authState.performActionWithFreshTokens(authService, + (accessToken, idToken, ex1) -> { + AsyncTask asyncTask = + new ResourceUrlAsyncTask( + savedCall, + oauth2Options, + getLogTag(), + authorizationResponse, + accessTokenResponse); + asyncTask.execute(accessToken); + }); } else { - savedCall.reject(ERR_NO_ACCESS_TOKEN); + resolveAuthorizationResponse(savedCall, authorizationResponse); } } }); @@ -378,7 +403,7 @@ void handleAuthorizationRequestActivity(Intent intent, PluginCall savedCall) { savedCall.reject(ERR_NO_AUTHORIZATION_CODE, e); } } else { - createJsObjAndResolve(savedCall, authorizationResponse.jsonSerializeString()); + resolveAuthorizationResponse(savedCall, authorizationResponse); } } else { savedCall.reject(ERR_NO_AUTHORIZATION_CODE); @@ -391,13 +416,10 @@ void handleAuthorizationRequestActivity(Intent intent, PluginCall savedCall) { } } - void createJsObjAndResolve(PluginCall call, String jsonStr) { - try { - JSObject json = new JSObject(jsonStr); - call.resolve(json); - } catch (JSONException e) { - call.reject(ERR_GENERAL, e); - } + private void resolveAuthorizationResponse(PluginCall savedCall, AuthorizationResponse authorizationResponse) { + JSObject json = new JSObject(); + OAuth2Utils.assignResponses(json, null, authorizationResponse, null); + savedCall.resolve(json); } OAuth2Options buildAuthenticateOptions(JSObject callData) { @@ -442,6 +464,7 @@ OAuth2Options buildAuthenticateOptions(JSObject callData) { } } } + o.setAdditionalResourceHeaders(ConfigUtils.getOverwrittenAndroidParamMap(callData, PARAM_ADDITIONAL_RESOURCE_HEADERS)); // android only o.setCustomHandlerClass(ConfigUtils.trimToNull(ConfigUtils.getParamString(callData, PARAM_ANDROID_CUSTOM_HANDLER_CLASS))); o.setHandleResultOnNewIntent(ConfigUtils.getParam(Boolean.class, callData, PARAM_ANDROID_HANDLE_RESULT_ON_NEW_INTENT, false)); diff --git a/android/src/main/java/com/byteowls/capacitor/oauth2/OAuth2Options.java b/android/src/main/java/com/byteowls/capacitor/oauth2/OAuth2Options.java index d5624377..98c437bd 100644 --- a/android/src/main/java/com/byteowls/capacitor/oauth2/OAuth2Options.java +++ b/android/src/main/java/com/byteowls/capacitor/oauth2/OAuth2Options.java @@ -19,6 +19,7 @@ public class OAuth2Options { private String accessTokenEndpoint; private String resourceUrl; + private Map additionalResourceHeaders; private boolean pkceEnabled; private boolean logsEnabled; @@ -197,4 +198,21 @@ public boolean isHandleResultOnActivityResult() { public void setHandleResultOnActivityResult(boolean handleResultOnActivityResult) { this.handleResultOnActivityResult = handleResultOnActivityResult; } + + public Map getAdditionalResourceHeaders() { + return additionalResourceHeaders; + } + + public void setAdditionalResourceHeaders(Map additionalResourceHeaders) { + this.additionalResourceHeaders = additionalResourceHeaders; + } + + public void addAdditionalResourceHeader(String key, String value) { + if (key != null && value != null) { + if (this.additionalResourceHeaders == null) { + this.additionalResourceHeaders = new HashMap<>(); + } + this.additionalResourceHeaders.put(key, value); + } + } } diff --git a/android/src/main/java/com/byteowls/capacitor/oauth2/OAuth2Utils.java b/android/src/main/java/com/byteowls/capacitor/oauth2/OAuth2Utils.java new file mode 100644 index 00000000..9ca96769 --- /dev/null +++ b/android/src/main/java/com/byteowls/capacitor/oauth2/OAuth2Utils.java @@ -0,0 +1,22 @@ +package com.byteowls.capacitor.oauth2; + +import com.getcapacitor.JSObject; + +import net.openid.appauth.AuthorizationResponse; +import net.openid.appauth.TokenResponse; + +public abstract class OAuth2Utils { + + public static void assignResponses(JSObject resp, String accessToken, AuthorizationResponse authorizationResponse, TokenResponse accessTokenResponse) { + // #154 + if (authorizationResponse != null) { + resp.put("authorization_response", authorizationResponse.jsonSerializeString()); + } + if (accessTokenResponse != null) { + resp.put("access_token_response", accessTokenResponse.jsonSerializeString()); + } + if (accessToken != null) { + resp.put("access_token", accessToken); + } + } +} diff --git a/android/src/main/java/com/byteowls/capacitor/oauth2/ResourceUrlAsyncTask.java b/android/src/main/java/com/byteowls/capacitor/oauth2/ResourceUrlAsyncTask.java index c432f73b..9228eaf3 100644 --- a/android/src/main/java/com/byteowls/capacitor/oauth2/ResourceUrlAsyncTask.java +++ b/android/src/main/java/com/byteowls/capacitor/oauth2/ResourceUrlAsyncTask.java @@ -4,6 +4,10 @@ import android.util.Log; import com.getcapacitor.JSObject; import com.getcapacitor.PluginCall; + +import net.openid.appauth.AuthorizationResponse; +import net.openid.appauth.TokenResponse; + import org.json.JSONException; import java.io.BufferedReader; @@ -13,6 +17,7 @@ import java.net.HttpURLConnection; import java.net.MalformedURLException; import java.net.URL; +import java.util.Map; /** * @author m.oberwasserlechner@byteowls.com @@ -20,66 +25,98 @@ public class ResourceUrlAsyncTask extends AsyncTask { private static final String ERR_GENERAL = "ERR_GENERAL"; - private PluginCall pluginCall; - private OAuth2Options options; - private String logTag; + private static final String ERR_NO_ACCESS_TOKEN = "ERR_NO_ACCESS_TOKEN"; + private static final String MSG_RETURNED_TO_JS = "Returned to JS:\n"; - ResourceUrlAsyncTask(PluginCall pluginCall, OAuth2Options options, String logTag) { + private final PluginCall pluginCall; + private final OAuth2Options options; + private final String logTag; + private final AuthorizationResponse authorizationResponse; + private final TokenResponse accessTokenResponse; + + public ResourceUrlAsyncTask(PluginCall pluginCall, OAuth2Options options, String logTag, AuthorizationResponse authorizationResponse, TokenResponse accessTokenResponse) { this.pluginCall = pluginCall; this.options = options; this.logTag = logTag; + this.authorizationResponse = authorizationResponse; + this.accessTokenResponse = accessTokenResponse; } @Override protected ResourceCallResult doInBackground(String... tokens) { - String resourceUrl = options.getResourceUrl(); ResourceCallResult result = new ResourceCallResult(); - String accessToken = tokens[0]; - - if (resourceUrl == null) { - JSObject json = new JSObject(); - json.put("access_token", accessToken); - result.setResponse(json); - return result; - } - try { - URL url = new URL(resourceUrl); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.addRequestProperty("Authorization", String.format("Bearer %s", accessToken)); - try { - InputStream is; + String resourceUrl = options.getResourceUrl(); + String accessToken = tokens[0]; + if (resourceUrl != null) { + Log.i(logTag, "Resource url: GET " + resourceUrl); + if (accessToken != null) { + Log.i(logTag, "Access token:\n" + accessToken); + try { + URL url = new URL(resourceUrl); + HttpURLConnection conn = (HttpURLConnection) url.openConnection(); + conn.addRequestProperty("Authorization", String.format("Bearer %s", accessToken)); + // additional headers + if (options.getAdditionalResourceHeaders() != null) { + for (Map.Entry entry : options.getAdditionalResourceHeaders().entrySet()) { + conn.addRequestProperty(entry.getKey(), entry.getValue()); + } + } - if (conn.getResponseCode() >= HttpURLConnection.HTTP_OK - && conn.getResponseCode() < HttpURLConnection.HTTP_MULT_CHOICE) { - is = conn.getInputStream(); - } else { - is = conn.getErrorStream(); - result.setError(true); + InputStream is = null; + try { + if (conn.getResponseCode() >= HttpURLConnection.HTTP_OK + && conn.getResponseCode() < HttpURLConnection.HTTP_MULT_CHOICE) { + is = conn.getInputStream(); + } else { + is = conn.getErrorStream(); + result.setError(true); + } + String resourceResponseBody = readInputStream(is); + if (!result.isError()) { + JSObject resultJson = new JSObject(resourceResponseBody); + if (options.isLogsEnabled()) { + Log.i(logTag, "Resource response:\n" + resourceResponseBody); + } + OAuth2Utils.assignResponses(resultJson, accessToken, this.authorizationResponse, this.accessTokenResponse); + if (options.isLogsEnabled()) { + Log.i(logTag, MSG_RETURNED_TO_JS + resultJson); + } + result.setResponse(resultJson); + } else { + result.setErrorMsg(resourceResponseBody); + } + } catch (IOException e) { + Log.e(logTag, "", e); + } catch (JSONException e) { + Log.e(logTag, "Resource response no valid json.", e); + } finally { + conn.disconnect(); + if (is != null) { + is.close(); + } + } + } catch (MalformedURLException e) { + Log.e(logTag, "Invalid resource url '" + resourceUrl + "'", e); + } catch (IOException e) { + Log.e(logTag, "Unexpected error", e); } - String jsonBody = readInputStream(is); - if (!result.isError()) { - JSObject json = new JSObject(jsonBody); - json.put("access_token", accessToken); - result.setResponse(json); - } else { - result.setErrorMsg(jsonBody); + } else { + if (options.isLogsEnabled()) { + Log.i(logTag, "No accessToken was provided although you configured a resourceUrl. Remove the resourceUrl from the config."); } - return result; - } catch (IOException e) { - Log.e(logTag, "", e); - } catch (JSONException e) { - Log.e(logTag, "Resource response no valid json.", e); - } finally { - conn.disconnect(); + pluginCall.reject(ERR_NO_ACCESS_TOKEN); } - } catch (MalformedURLException e) { - Log.e(logTag, "Invalid resource url '" + resourceUrl + "'", e); - } catch (IOException e) { - Log.e(logTag, "Unexpected error", e); + } else { + JSObject json = new JSObject(); + OAuth2Utils.assignResponses(json, accessToken, this.authorizationResponse, this.accessTokenResponse); + if (options.isLogsEnabled()) { + Log.i(logTag, MSG_RETURNED_TO_JS + json); + } + result.setResponse(json); } - return null; + return result; } @Override @@ -89,7 +126,7 @@ protected void onPostExecute(ResourceCallResult response) { pluginCall.resolve(response.getResponse()); } else { Log.e(logTag, response.getErrorMsg()); - pluginCall.reject(ERR_GENERAL); + pluginCall.reject(ERR_GENERAL, response.getErrorMsg()); } } else { pluginCall.reject(ERR_GENERAL); diff --git a/ios/ByteowlsCapacitorOauth2/Source/ByteowlsCapacitorOauth2.swift b/ios/ByteowlsCapacitorOauth2/Source/ByteowlsCapacitorOauth2.swift index dac9491f..fe2da729 100644 --- a/ios/ByteowlsCapacitorOauth2/Source/ByteowlsCapacitorOauth2.swift +++ b/ios/ByteowlsCapacitorOauth2/Source/ByteowlsCapacitorOauth2.swift @@ -12,6 +12,8 @@ public class OAuth2ClientPlugin: CAPPlugin { var savedPluginCall: CAPPluginCall? let JSON_KEY_ACCESS_TOKEN = "access_token" + let JSON_KEY_AUTHORIZATION_RESPONSE = "authorization_response" + let JSON_KEY_ACCESS_TOKEN_RESPONSE = "access_token_response" let PARAM_REFRESH_TOKEN = "refreshToken" @@ -23,6 +25,7 @@ public class OAuth2ClientPlugin: CAPPlugin { // controlling let PARAM_ACCESS_TOKEN_ENDPOINT = "accessTokenEndpoint" let PARAM_RESOURCE_URL = "resourceUrl" + let PARAM_ADDITIONAL_RESOURCE_HEADERS = "additionalResourceHeaders" let PARAM_ADDITIONAL_PARAMETERS = "additionalParameters" let PARAM_CUSTOM_HANDLER_CLASS = "ios.customHandlerClass" @@ -30,6 +33,8 @@ public class OAuth2ClientPlugin: CAPPlugin { let PARAM_STATE = "state" let PARAM_PKCE_ENABLED = "pkceEnabled" let PARAM_IOS_USE_SCOPE = "ios.siwaUseScope" + let PARAM_LOGOUT_URL = "logoutUrl" + let PARAM_LOGS_ENABLED = "logsEnabled" let ERR_GENERAL = "ERR_GENERAL" @@ -164,13 +169,15 @@ public class OAuth2ClientPlugin: CAPPlugin { return } let resourceUrl = getOverwritableString(call, self.PARAM_RESOURCE_URL) - // Github issue #71 + let logsEnabled: Bool = getOverwritable(call, self.PARAM_LOGS_ENABLED) as? Bool ?? false + // #71 self.oauth2SafariDelegate = OAuth2SafariDelegate(call) // ######### Custom Handler ######## if let handlerClassName = getString(call, PARAM_CUSTOM_HANDLER_CLASS) { if let handlerInstance = self.getOrLoadHandlerInstance(className: handlerClassName) { + log("Entering custom handler: " + handlerClassName) handlerInstance.getAccessToken(viewController: (bridge?.viewController)!, call: call, success: { (accessToken) in if resourceUrl != nil { @@ -206,7 +213,9 @@ public class OAuth2ClientPlugin: CAPPlugin { }, cancelled: { call.reject(SharedConstants.ERR_USER_CANCELLED) }, failure: { error in - self.log("Login failed because '\(error)'") + if logsEnabled { + self.log("Login failed because '\(error)'") + } call.reject(self.ERR_CUSTOM_HANDLER_LOGIN) }) } else { @@ -260,16 +269,7 @@ public class OAuth2ClientPlugin: CAPPlugin { // additional parameters #18 let callParameter: [String: Any] = getOverwritable(call, PARAM_ADDITIONAL_PARAMETERS) as? [String: Any] ?? [:] - var additionalParameters: [String: String] = [:] - for (key, value) in callParameter { - // only non empty string values are allowed - if !key.isEmpty && value is String { - let str = value as! String; - if !str.isEmpty { - additionalParameters[key] = str - } - } - } + let additionalParameters = buildStringDict(callParameter); let requestState = getOverwritableString(call, PARAM_STATE) ?? generateRandom(withLength: 20) let pkceEnabled: Bool = getOverwritable(call, PARAM_PKCE_ENABLED) as? Bool ?? false @@ -285,7 +285,7 @@ public class OAuth2ClientPlugin: CAPPlugin { codeChallenge: pkceCodeChallenge, codeVerifier: pkceCodeVerifier, parameters: additionalParameters) { result in - self.handleAuthorizationResult(result, call, responseType, requestState, resourceUrl) + self.handleAuthorizationResult(result, call, responseType, requestState, logsEnabled, resourceUrl) } } else { oauthSwift.authorize( @@ -293,7 +293,7 @@ public class OAuth2ClientPlugin: CAPPlugin { scope: getOverwritableString(call, PARAM_SCOPE) ?? "", state: requestState, parameters: additionalParameters) { result in - self.handleAuthorizationResult(result, call, responseType, requestState, resourceUrl) + self.handleAuthorizationResult(result, call, responseType, requestState, logsEnabled, resourceUrl) } } } @@ -331,33 +331,71 @@ public class OAuth2ClientPlugin: CAPPlugin { // ### Helper functions // ################################# - private func handleAuthorizationResult(_ result: Result, _ call: CAPPluginCall, _ responseType: String, _ requestState: String, _ resourceUrl: String?) { + private func handleAuthorizationResult(_ result: Result, + _ call: CAPPluginCall, + _ responseType: String, + _ requestState: String, + _ logsEnabled: Bool, + _ resourceUrl: String?) { switch result { case .success(let (credential, response, parameters)): + if logsEnabled, let accessTokenResponse = response { + logDataObj("Authorization or Access token response:", accessTokenResponse.data) + } + // state is aready checked by the lib if resourceUrl != nil && !resourceUrl!.isEmpty { - self.oauthSwift!.client.get( - resourceUrl!, - parameters: parameters) { result in + if logsEnabled { + log("Resource url: \(resourceUrl!)") + log("Access token:\n\(credential.oauthToken)") + } + // resource url request headers + let callParameter: [String: Any] = getOverwritable(call, PARAM_ADDITIONAL_RESOURCE_HEADERS) as? [String: Any] ?? [:] + let additionalHeadersDict = buildStringDict(callParameter); + + self.oauthSwift!.client.get(resourceUrl!, + headers: additionalHeadersDict) { result in switch result { - case .success(let response): + case .success(let resourceResponse): do { - var jsonObj = try JSONSerialization.jsonObject(with: response.data, options: []) as! JSObject + if logsEnabled { + self.logDataObj("Resource response:", resourceResponse.data) + } + + var jsonObj = try JSONSerialization.jsonObject(with: resourceResponse.data, options: []) as! JSObject // send the access token to the caller so e.g. it can be stored on a backend + // #154 + if let accessTokenResponse = response { + let accessTokenJsObject = try? JSONSerialization.jsonObject(with: accessTokenResponse.data, options: []) as? JSObject + jsonObj.updateValue(accessTokenJsObject!, forKey: self.JSON_KEY_ACCESS_TOKEN_RESPONSE) + } + jsonObj.updateValue(credential.oauthToken, forKey: self.JSON_KEY_ACCESS_TOKEN) + + if logsEnabled { + self.log("Returned to JS:\n\(jsonObj)") + } + call.resolve(jsonObj) } catch { - self.log("Invalid json in resource response \(error.localizedDescription)") + self.log("Invalid json in resource response:\n \(error.localizedDescription)") call.reject(self.ERR_GENERAL) } case .failure(let error): - self.log("Access resource request failed with \(error.localizedDescription)"); + self.log("Resource url request failed:\n\(error.description)"); call.reject(self.ERR_GENERAL) } } + // no resource url } else if let responseData = response?.data { do { - let jsonObj = try JSONSerialization.jsonObject(with: responseData, options: []) as! JSObject + var jsonObj = JSObject() + let accessTokenJsObject = try? JSONSerialization.jsonObject(with: responseData, options: []) as? JSObject + jsonObj.updateValue(accessTokenJsObject!, forKey: self.JSON_KEY_ACCESS_TOKEN_RESPONSE) + + if logsEnabled { + self.log("Returned to JS:\n\(jsonObj)") + } call.resolve(jsonObj) } catch { self.log("Invalid json in response \(error.localizedDescription)") @@ -447,7 +485,26 @@ public class OAuth2ClientPlugin: CAPPlugin { } private func log(_ msg: String) { - print("@byteowls/capacitor-oauth2: \(msg).") + print("I/Capacitor/OAuth2ClientPlugin: \(msg)") + } + + private func logDataObj(_ msg: String, _ data: Data) { + let json = try? JSONSerialization.jsonObject(with: data, options: []) + log("\(msg)\n\(json ?? "")") + } + + private func buildStringDict(_ callParameter: [String: Any]) -> [String: String] { + var dict: [String: String] = [:] + for (key, value) in callParameter { + // only non empty string values are allowed + if !key.isEmpty && value is String { + let str = value as! String; + if !str.isEmpty { + dict[key] = str + } + } + } + return dict; } private func loadHandlerInstance(className: String) -> OAuth2CustomHandler? { diff --git a/package-lock.json b/package-lock.json index aeedf64e..cc9c4d92 100644 --- a/package-lock.json +++ b/package-lock.json @@ -521,9 +521,9 @@ "dev": true }, "@eslint/eslintrc": { - "version": "0.4.2", - "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.2.tgz", - "integrity": "sha512-8nmGq/4ycLpIwzvhI4tNDmQztZ8sp+hI7cyG8i1nQDhkAbRzHpXPidRAHlNvCZQpJTKw5ItIpMw9RSToGF00mg==", + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-0.4.3.tgz", + "integrity": "sha512-J6KFFz5QCYUJq3pf0mjEcCJVERbzv71PUIDczuh9JkwGEzced6CO5ADLHB1rbf/+oPBtoPfMYNOpGDzCANlbXw==", "dev": true, "requires": { "ajv": "^6.12.4", @@ -537,6 +537,23 @@ "strip-json-comments": "^3.1.1" } }, + "@humanwhocodes/config-array": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/config-array/-/config-array-0.5.0.tgz", + "integrity": "sha512-FagtKFz74XrTl7y6HCzQpwDfXP0yhxe9lHLD1UZxjvZIcbyRz8zTFF/yYNfSfzU414eDwZ1SrO0Qvtyf+wFMQg==", + "dev": true, + "requires": { + "@humanwhocodes/object-schema": "^1.2.0", + "debug": "^4.1.1", + "minimatch": "^3.0.4" + } + }, + "@humanwhocodes/object-schema": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@humanwhocodes/object-schema/-/object-schema-1.2.0.tgz", + "integrity": "sha512-wdppn25U8z/2yiaT6YGquE6X8sSv7hNMWSXYSSU1jGv/yd6XqjXgTDJ8KP4NgjTXfJ3GbRjeeb8RTV7a/VpM+w==", + "dev": true + }, "@istanbuljs/load-nyc-config": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz", @@ -1046,9 +1063,9 @@ } }, "@types/jest": { - "version": "26.0.23", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.23.tgz", - "integrity": "sha512-ZHLmWMJ9jJ9PTiT58juykZpL7KjwJywFN3Rr2pTSkyQfydf/rk22yS7W8p5DaVUMQ2BQC7oYiU3FjbTM/mYrOA==", + "version": "26.0.24", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-26.0.24.tgz", + "integrity": "sha512-E/X5Vib8BWqZNRlDxj9vYXhsDwPYbPINqKF9BsnSoon4RQ0D9moEuLD8txgyypFLH7J4+Lho9Nr/c8H0Fi+17w==", "dev": true, "requires": { "jest-diff": "^26.0.0", @@ -1074,9 +1091,9 @@ "dev": true }, "@types/yargs": { - "version": "15.0.13", - "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.13.tgz", - "integrity": "sha512-kQ5JNTrbDv3Rp5X2n/iUu37IJBDU2gsZ5R/g1/KHOOEc5IKfUFjXT6DENPGduh08I/pamwtEq4oul7gUqKTQDQ==", + "version": "15.0.14", + "resolved": "https://registry.npmjs.org/@types/yargs/-/yargs-15.0.14.tgz", + "integrity": "sha512-yEJzHoxf6SyQGhBhIYGXQDSCkJjB6HohDShto7m8vaKg9Yp0Yn8+71J9eakh2bnPg6BfsH9PRMhiRTZnd4eXGQ==", "dev": true, "requires": { "@types/yargs-parser": "*" @@ -1111,9 +1128,9 @@ } }, "acorn-jsx": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.1.tgz", - "integrity": "sha512-K0Ptm/47OKfQRpNQ2J/oIN/3QYiK6FwW+eJbILhsdxh2WTLdl+30o8aGdTbm5JbffpFFAg/g+zi1E+jvJha5ng==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true }, "acorn-walk": { @@ -1711,13 +1728,14 @@ } }, "eslint": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.29.0.tgz", - "integrity": "sha512-82G/JToB9qIy/ArBzIWG9xvvwL3R86AlCjtGw+A29OMZDqhTybz/MByORSukGxeI+YPCR4coYyITKk8BFH9nDA==", + "version": "7.32.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", + "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", "dev": true, "requires": { "@babel/code-frame": "7.12.11", - "@eslint/eslintrc": "^0.4.2", + "@eslint/eslintrc": "^0.4.3", + "@humanwhocodes/config-array": "^0.5.0", "ajv": "^6.10.0", "chalk": "^4.0.0", "cross-spawn": "^7.0.2", @@ -2000,9 +2018,9 @@ } }, "flatted": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.1.1.tgz", - "integrity": "sha512-zAoAQiudy+r5SvnSw3KJy5os/oRJYHzrzja/tBDqrZtNhUw8bt6y8OBzMWcjWr+8liV8Eb6yOhw8WZ7VFZ5ZzA==", + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.2.tgz", + "integrity": "sha512-JaTY/wtrcSyvXJl4IMFHPKyFur1sE9AUqc0QnhOaJ0CxHtAoIV8pYDzeEfAaNEtGkOfq4gr3LBFmdXW5mOQFnA==", "dev": true }, "form-data": { @@ -2089,9 +2107,9 @@ } }, "globals": { - "version": "13.9.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.9.0.tgz", - "integrity": "sha512-74/FduwI/JaIrr1H8e71UbDE+5x7pIPs1C2rrwC52SszOo043CsWOZEMW7o2Y58xwm9b+0RBKDxY5n2sUpEFxA==", + "version": "13.10.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.10.0.tgz", + "integrity": "sha512-piHC3blgLGFjvOuMmWZX60f+na1lXFDhQXBf1UYp2fXPXqvEUbOhNwi6BsQ0bQishwedgnjkwv1d9zKf+MWw3g==", "dev": true, "requires": { "type-fest": "^0.20.2" @@ -4306,9 +4324,9 @@ }, "dependencies": { "ajv": { - "version": "8.6.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.0.tgz", - "integrity": "sha512-cnUG4NSBiM4YFBxgZIj/In3/6KX+rQ2l2YPRVcvAMQGWEPKuXoPIhxzwqh31jA3IPbI4qEOp/5ILI4ynioXsGQ==", + "version": "8.6.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.6.2.tgz", + "integrity": "sha512-9807RlWAgT564wT+DjeyU5OFMPjmzxVobvDFmNAhY+5zD6A2ly3jDp6sgnfyDtlIQ+7H97oc/DGCzzfu9rjw9w==", "dev": true, "requires": { "fast-deep-equal": "^3.1.1", @@ -4400,9 +4418,9 @@ } }, "ts-jest": { - "version": "27.0.3", - "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.0.3.tgz", - "integrity": "sha512-U5rdMjnYam9Ucw+h0QvtNDbc5+88nxt7tbIvqaZUhFrfG4+SkWhMXjejCLVGcpILTPuV+H3W/GZDZrnZFpPeXw==", + "version": "27.0.4", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-27.0.4.tgz", + "integrity": "sha512-c4E1ECy9Xz2WGfTMyHbSaArlIva7Wi2p43QOMmCqjSSjHP06KXv+aT+eSY+yZMuqsMi3k7pyGsGj2q5oSl5WfQ==", "dev": true, "requires": { "bs-logger": "0.x", @@ -4454,9 +4472,9 @@ } }, "typescript": { - "version": "4.1.6", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.6.tgz", - "integrity": "sha512-pxnwLxeb/Z5SP80JDRzVjh58KsM6jZHRAOtTpS7sXLS4ogXNKC9ANxHHZqLLeVHZN35jCtI4JdmLLbLiC1kBow==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.1.5.tgz", + "integrity": "sha512-6OSu9PTIzmn9TCDiovULTnET6BgXtDYL4Gg4szY+cGsc3JP1dQL8qvE8kShTRx1NIw4Q9IBHlwODjkjWEtMUyA==", "dev": true }, "universalify": { diff --git a/package.json b/package.json index d192afda..fed9acfc 100644 --- a/package.json +++ b/package.json @@ -57,11 +57,11 @@ "@capacitor/android": "3.0.2", "@capacitor/core": "3.0.2", "@capacitor/ios": "3.0.2", - "@types/jest": "26.0.23", + "@types/jest": "26.0.24", "jest": "27.0.6", - "ts-jest": "27.0.3", - "eslint": "^7.11.0", - "rimraf": "^3.0.0", - "typescript": "~4.1.5" + "ts-jest": "27.0.4", + "eslint": "7.32.0", + "rimraf": "3.0.2", + "typescript": "4.1.5" } } diff --git a/src/definitions.ts b/src/definitions.ts index 05a8ee47..c7014f2c 100644 --- a/src/definitions.ts +++ b/src/definitions.ts @@ -70,7 +70,7 @@ export interface OAuth2AuthenticateBaseOptions { */ accessTokenEndpoint?: string; /** - * Protected resource url. For authentification you only need the basic user details. + * Protected resource url. For authentication you only need the basic user details. */ resourceUrl?: string; /** @@ -95,6 +95,16 @@ export interface OAuth2AuthenticateBaseOptions { * @since 3.0.0 */ logsEnabled?: boolean; + /** + * @since 3.1.0 ... not implemented yet! + */ + logoutUrl?: string; + + /** + * Additional headers for resource url request + * @since 3.0.0 + */ + additionalResourceHeaders?: { [key: string]: string } } export interface OAuth2AuthenticateOptions extends OAuth2AuthenticateBaseOptions { @@ -110,7 +120,7 @@ export interface OAuth2AuthenticateOptions extends OAuth2AuthenticateBaseOptions /** * Custom options for the platform "ios" */ - ios?: IosOptions, + ios?: IosOptions } export interface WebOption extends OAuth2AuthenticateBaseOptions { diff --git a/src/web-utils.test.ts b/src/web-utils.test.ts index 71c44203..f24730f4 100644 --- a/src/web-utils.test.ts +++ b/src/web-utils.test.ts @@ -264,3 +264,36 @@ describe("Crypto utils", () => { }); }); +describe("additional resource headers", () => { + const headerKey = "Access-Control-Allow-Origin"; + + const options: OAuth2AuthenticateOptions = { + appId: "appId", + authorizationBaseUrl: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + accessTokenEndpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + scope: "files.readwrite offline_access", + responseType: "code", + additionalResourceHeaders: { + "Access-Control-Allow-Origin": "will-be-overwritten", + }, + web: { + redirectUrl: "https://oauth2.byteowls.com/authorize", + pkceEnabled: false, + additionalResourceHeaders: { + "Access-Control-Allow-Origin": "*", + } + } + }; + + it('should be defined', async () => { + const webOptions = await WebUtils.buildWebOptions(options); + expect(webOptions.additionalResourceHeaders[headerKey]).toBeDefined(); + }); + + it('should equal *', async () => { + const webOptions = await WebUtils.buildWebOptions(options); + expect(webOptions.additionalResourceHeaders[headerKey]).toEqual("*"); + }); + +}); + diff --git a/src/web-utils.ts b/src/web-utils.ts index b4e6f3e4..79d16913 100644 --- a/src/web-utils.ts +++ b/src/web-utils.ts @@ -153,18 +153,30 @@ export class WebUtils { if (!webOptions.state || webOptions.state.length === 0) { webOptions.state = this.randomString(20); } - let mapHelper = this.getOverwritableValue<{ [key: string]: string }>(configOptions, "additionalParameters"); - if (mapHelper) { + let parametersMapHelper = this.getOverwritableValue<{ [key: string]: string }>(configOptions, "additionalParameters"); + if (parametersMapHelper) { webOptions.additionalParameters = {}; - for (const key in mapHelper) { + for (const key in parametersMapHelper) { if (key && key.trim().length > 0) { - let value = mapHelper[key]; + let value = parametersMapHelper[key]; if (value && value.trim().length > 0) { webOptions.additionalParameters[key] = value; } } } } + let headersMapHelper = this.getOverwritableValue<{ [key: string]: string }>(configOptions, "additionalResourceHeaders"); + if (headersMapHelper) { + webOptions.additionalResourceHeaders = {}; + for (const key in headersMapHelper) { + if (key && key.trim().length > 0) { + let value = headersMapHelper[key]; + if (value && value.trim().length > 0) { + webOptions.additionalResourceHeaders[key] = value; + } + } + } + } webOptions.logsEnabled = this.getOverwritableValue(configOptions, "logsEnabled"); if (configOptions.web) { @@ -257,5 +269,6 @@ export class WebOptions { pkceCodeChallengeMethod: string; additionalParameters: { [key: string]: string }; + additionalResourceHeaders: { [key: string]: string }; } diff --git a/src/web.ts b/src/web.ts index 5ad8b69f..cf5a60ab 100644 --- a/src/web.ts +++ b/src/web.ts @@ -39,7 +39,7 @@ export class OAuth2ClientPluginWeb extends WebPlugin implements OAuth2ClientPlug // open window const authorizationUrl = WebUtils.getAuthorizationUrl(this.webOptions); if (this.webOptions.logsEnabled) { - console.log("AuthorizationUrl: " + authorizationUrl); + this.doLog("Authorization url: " + authorizationUrl); } this.windowHandle = window.open( authorizationUrl, @@ -63,12 +63,12 @@ export class OAuth2ClientPluginWeb extends WebPlugin implements OAuth2ClientPlug if (href != null && href.indexOf(this.webOptions.redirectUrl) >= 0) { if (this.webOptions.logsEnabled) { - console.log("Url from Provider: " + href); + this.doLog("Url from Provider: " + href); } let authorizationRedirectUrlParamObj = WebUtils.getUrlParams(href); if (authorizationRedirectUrlParamObj) { if (this.webOptions.logsEnabled) { - console.log("Url Params: ", authorizationRedirectUrlParamObj); + this.doLog("Authorization response:", authorizationRedirectUrlParamObj); } window.clearInterval(this.intervalId); // check state @@ -81,11 +81,15 @@ export class OAuth2ClientPluginWeb extends WebPlugin implements OAuth2ClientPlug tokenRequest.onload = function () { if (this.status === 200) { let accessTokenResponse = JSON.parse(this.response); + if (self.webOptions.logsEnabled) { + self.doLog("Access token response:", accessTokenResponse); + } self.requestResource(accessTokenResponse.access_token, resolve, reject, authorizationRedirectUrlParamObj, accessTokenResponse); } }; tokenRequest.onerror = function () { - console.log("ERR_GENERAL: See client logs. It might be CORS. Status text: " + this.statusText); + // always log error because of CORS hint + self.doLog("ERR_GENERAL: See client logs. It might be CORS. Status text: " + this.statusText); reject(new Error("ERR_GENERAL")); }; tokenRequest.open("POST", this.webOptions.accessTokenEndpoint, true); @@ -103,8 +107,8 @@ export class OAuth2ClientPluginWeb extends WebPlugin implements OAuth2ClientPlug } } else { if (this.webOptions.logsEnabled) { - console.log("State from web options: " + this.webOptions.state); - console.log("State returned from provider: " + authorizationRedirectUrlParamObj.state); + this.doLog("State from web options: " + this.webOptions.state); + this.doLog("State returned from provider: " + authorizationRedirectUrlParamObj.state); } reject(new Error("ERR_STATES_NOT_MATCH")); this.closeWindow(); @@ -118,30 +122,31 @@ export class OAuth2ClientPluginWeb extends WebPlugin implements OAuth2ClientPlug }); } + private readonly MSG_RETURNED_TO_JS = "Returned to JS:"; + private requestResource(accessToken: string, resolve: any, reject: (reason?: any) => void, authorizationResponse: any, accessTokenResponse: any = null) { if (this.webOptions.resourceUrl) { + const logsEnabled = this.webOptions.logsEnabled; + if (logsEnabled) { + this.doLog("Resource url: " + this.webOptions.resourceUrl); + } if (accessToken) { - const logsEnabled = this.webOptions.logsEnabled; if (logsEnabled) { - console.log("Access token: " + accessToken); + this.doLog("Access token:", accessToken); } const self = this; const request = new XMLHttpRequest(); request.onload = function () { if (this.status === 200) { let resp = JSON.parse(this.response); + if (logsEnabled) { + self.doLog("Resource response:", resp); + } if (resp) { - // #154 - if (authorizationResponse) { - resp["authorization_response"] = authorizationResponse; - } - if (accessTokenResponse) { - resp["access_token_response"] = authorizationResponse; - } - resp["access_token"] = accessToken; + self.assignResponses(resp, accessToken, authorizationResponse, accessTokenResponse); } if (logsEnabled) { - console.log("Resource response: ", resp); + self.doLog(self.MSG_RETURNED_TO_JS, resp); } resolve(resp); } else { @@ -151,28 +156,49 @@ export class OAuth2ClientPluginWeb extends WebPlugin implements OAuth2ClientPlug }; request.onerror = function () { if (logsEnabled) { - console.log("ERR_GENERAL: " + this.statusText); + self.doLog("ERR_GENERAL: " + this.statusText); } reject(new Error("ERR_GENERAL")); self.closeWindow(); }; - if (logsEnabled) { - console.log("Resource url: GET " + this.webOptions.resourceUrl); - } request.open("GET", this.webOptions.resourceUrl, true); request.setRequestHeader('Authorization', `Bearer ${accessToken}`); + if (this.webOptions.additionalResourceHeaders) { + for (const key in this.webOptions.additionalResourceHeaders) { + request.setRequestHeader(key, this.webOptions.additionalResourceHeaders[key]); + } + } request.send(); } else { + if (logsEnabled) { + this.doLog("No accessToken was provided although you configured a resourceUrl. Remove the resourceUrl from the config."); + } reject(new Error("ERR_NO_ACCESS_TOKEN")); this.closeWindow(); } } else { // if no resource url exists just return the accessToken response - resolve(accessToken); + const resp = {}; + this.assignResponses(resp, accessToken, authorizationResponse, accessTokenResponse); + if (this.webOptions.logsEnabled) { + this.doLog(this.MSG_RETURNED_TO_JS, resp); + } + resolve(resp); this.closeWindow(); } } + assignResponses(resp: any, accessToken: string, authorizationResponse: any, accessTokenResponse: any = null): void { + // #154 + if (authorizationResponse) { + resp["authorization_response"] = authorizationResponse; + } + if (accessTokenResponse) { + resp["access_token_response"] = accessTokenResponse; + } + resp["access_token"] = accessToken; + } + async logout(options: OAuth2AuthenticateOptions): Promise { return new Promise((resolve, _reject) => { localStorage.removeItem(WebUtils.getAppId(options)); @@ -182,7 +208,15 @@ export class OAuth2ClientPluginWeb extends WebPlugin implements OAuth2ClientPlug private closeWindow() { window.clearInterval(this.intervalId); + // #164 if the provider's login page is opened in the same tab or window it must not be closed + // if (this.webOptions.windowTarget !== "_self") { + // this.windowHandle?.close(); + // } this.windowHandle?.close(); this.windowClosedByPlugin = true; } + + private doLog(msg: string, obj: any = null) { + console.log("I/Capacitor/OAuth2ClientPlugin: " + msg, obj); + } }