diff --git a/Example/ios/AppAuthExample/Info.plist b/Example/ios/AppAuthExample/Info.plist index ae1dad08..753b660b 100644 --- a/Example/ios/AppAuthExample/Info.plist +++ b/Example/ios/AppAuthExample/Info.plist @@ -39,6 +39,11 @@ NSExceptionDomains + demo.identityserver.io + + NSExceptionAllowsInsecureHTTPLoads + + localhost NSExceptionAllowsInsecureHTTPLoads diff --git a/README.md b/README.md index fc2283f1..bf13ea89 100644 --- a/README.md +++ b/README.md @@ -79,6 +79,7 @@ with optional overrides. * **additionalParameters** - (`object`) additional parameters that will be passed in the authorization request. Must be string values! E.g. setting `additionalParameters: { hello: 'world', foo: 'bar' }` would add `hello=world&foo=bar` to the authorization request. +* :warning: **dangerouslyAllowInsecureHttpRequests** - (`boolean`) _ANDROID_ whether to allow requests over plain HTTP or with self-signed SSL certificates. Can be useful for testing against local server, _should not be used in production._ This setting has no effect on iOS; to enable insecure HTTP requests, add a [NSExceptionAllowsInsecureHTTPLoads exception](https://cocoacasts.com/how-to-add-app-transport-security-exception-domains) to your App Transport Security settings. #### result diff --git a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java index 5eee9a5c..771e815c 100644 --- a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java +++ b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java @@ -4,6 +4,7 @@ import android.content.Context; import android.content.Intent; import android.net.Uri; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import com.facebook.react.bridge.ActivityEventListener; @@ -16,25 +17,37 @@ import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; import com.facebook.react.bridge.WritableMap; +import com.reactlibrary.utils.UnsafeConnectionBuilder; +import net.openid.appauth.AppAuthConfiguration; import net.openid.appauth.AuthorizationException; import net.openid.appauth.AuthorizationRequest; import net.openid.appauth.AuthorizationResponse; import net.openid.appauth.AuthorizationService; import net.openid.appauth.AuthorizationServiceConfiguration; +import net.openid.appauth.Preconditions; import net.openid.appauth.ResponseTypeValues; import net.openid.appauth.TokenResponse; import net.openid.appauth.TokenRequest; - +import net.openid.appauth.connectivity.ConnectionBuilder; +import net.openid.appauth.connectivity.DefaultConnectionBuilder; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.MalformedURLException; +import java.net.URL; +import java.sql.Connection; import java.text.SimpleDateFormat; import java.util.Date; import java.util.HashMap; import java.util.Iterator; +import java.util.concurrent.TimeUnit; public class RNAppAuthModule extends ReactContextBaseJavaModule implements ActivityEventListener { private final ReactApplicationContext reactContext; private Promise promise; + private Boolean dangerouslyAllowInsecureHttpRequests; public RNAppAuthModule(ReactApplicationContext reactContext) { super(reactContext); @@ -96,6 +109,28 @@ private HashMap additionalParametersToMap(ReadableMap additional return additionalParametersHash; } + private AppAuthConfiguration createAppAuthConfiguration(ConnectionBuilder connectionBuilder) { + return new AppAuthConfiguration + .Builder() + .setConnectionBuilder(connectionBuilder) + .build(); + } + + private ConnectionBuilder createConnectionBuilder(Boolean allowInsecureConnections) { + if (allowInsecureConnections.equals(true)) { + return UnsafeConnectionBuilder.INSTANCE; + } + + return DefaultConnectionBuilder.INSTANCE; + } + + private Uri buildConfigurationUriFromIssuer(Uri openIdConnectIssuerUri) { + return openIdConnectIssuerUri.buildUpon() + .appendPath(AuthorizationServiceConfiguration.WELL_KNOWN_PATH) + .appendPath(AuthorizationServiceConfiguration.OPENID_CONFIGURATION_RESOURCE) + .build(); + } + @ReactMethod public void authorize( String issuer, @@ -103,17 +138,24 @@ public void authorize( final String clientId, final ReadableArray scopes, final ReadableMap additionalParameters, + final Boolean dangerouslyAllowInsecureHttpRequests, final Promise promise ) { final Context context = this.reactContext; + + // store args in private fields for later use in onActivityResult handler this.promise = promise; - final Activity currentActivity = getCurrentActivity(); + this.dangerouslyAllowInsecureHttpRequests = dangerouslyAllowInsecureHttpRequests; + final Activity currentActivity = getCurrentActivity(); final String scopesString = this.arrayToString(scopes); + final Uri issuerUri = Uri.parse(issuer); + final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests); + final AppAuthConfiguration configuration = this.createAppAuthConfiguration(builder); - AuthorizationServiceConfiguration.fetchFromIssuer( - Uri.parse(issuer), + AuthorizationServiceConfiguration.fetchFromUrl( + buildConfigurationUriFromIssuer(issuerUri), new AuthorizationServiceConfiguration.RetrieveConfigurationCallback() { public void onFetchConfigurationCompleted( @Nullable AuthorizationServiceConfiguration serviceConfiguration, @@ -123,6 +165,7 @@ public void onFetchConfigurationCompleted( return; } + AuthorizationRequest.Builder authRequestBuilder = new AuthorizationRequest.Builder( serviceConfiguration, @@ -137,13 +180,14 @@ public void onFetchConfigurationCompleted( } AuthorizationRequest authRequest = authRequestBuilder.build(); - - AuthorizationService authService = new AuthorizationService(context); + AuthorizationService authService = new AuthorizationService(context, configuration); Intent authIntent = authService.getAuthorizationRequestIntent(authRequest); currentActivity.startActivityForResult(authIntent, 0); } - }); + }, + builder + ); } @@ -155,14 +199,20 @@ public void refresh( final String refreshToken, final ReadableArray scopes, final ReadableMap additionalParameters, + final Boolean dangerouslyAllowInsecureHttpRequests, final Promise promise ) { final Context context = this.reactContext; - final String scopesString = this.arrayToString(scopes); + final Uri issuerUri = Uri.parse(issuer); + final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests); + final AppAuthConfiguration configuration = createAppAuthConfiguration(builder); - AuthorizationServiceConfiguration.fetchFromIssuer( - Uri.parse(issuer), + // store setting in private field for later use in onActivityResult handler + this.dangerouslyAllowInsecureHttpRequests = dangerouslyAllowInsecureHttpRequests; + + AuthorizationServiceConfiguration.fetchFromUrl( + buildConfigurationUriFromIssuer(issuerUri), new AuthorizationServiceConfiguration.RetrieveConfigurationCallback() { public void onFetchConfigurationCompleted( @Nullable AuthorizationServiceConfiguration serviceConfiguration, @@ -187,9 +237,7 @@ public void onFetchConfigurationCompleted( TokenRequest tokenRequest = tokenRequestBuilder.build(); - - AuthorizationService authService = new AuthorizationService(context); - + AuthorizationService authService = new AuthorizationService(context, configuration); authService.performTokenRequest(tokenRequest, new AuthorizationService.TokenResponseCallback() { @Override public void onTokenRequestCompleted(@Nullable TokenResponse response, @Nullable AuthorizationException ex) { @@ -203,7 +251,8 @@ public void onTokenRequestCompleted(@Nullable TokenResponse response, @Nullable }); } - }); + }, + builder); } @Override @@ -217,9 +266,11 @@ public void onActivityResult(Activity activity, int requestCode, int resultCode, } final Promise authorizePromise = this.promise; + final AppAuthConfiguration configuration = createAppAuthConfiguration( + createConnectionBuilder(this.dangerouslyAllowInsecureHttpRequests) + ); - AuthorizationService authService = new AuthorizationService(this.reactContext); - + AuthorizationService authService = new AuthorizationService(this.reactContext, configuration); authService.performTokenRequest( response.createTokenExchangeRequest(), new AuthorizationService.TokenResponseCallback() { @@ -248,4 +299,4 @@ public void onNewIntent(Intent intent) { public String getName() { return "RNAppAuth"; } -} +} \ No newline at end of file diff --git a/android/src/main/java/com/reactlibrary/utils/UnsafeConnectionBuilder.java b/android/src/main/java/com/reactlibrary/utils/UnsafeConnectionBuilder.java new file mode 100644 index 00000000..10b192d0 --- /dev/null +++ b/android/src/main/java/com/reactlibrary/utils/UnsafeConnectionBuilder.java @@ -0,0 +1,128 @@ +package com.reactlibrary.utils; + +/* + * Copyright 2016 The AppAuth for Android Authors. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except + * in compliance with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software distributed under the + * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing permissions and + * limitations under the License. + */ + + +import android.annotation.SuppressLint; +import android.net.Uri; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.util.Log; + +import net.openid.appauth.Preconditions; +import net.openid.appauth.connectivity.ConnectionBuilder; + +import java.io.IOException; +import java.net.HttpURLConnection; +import java.net.URL; +import java.security.KeyManagementException; +import java.security.NoSuchAlgorithmException; +import java.security.cert.X509Certificate; +import java.util.concurrent.TimeUnit; + +import javax.net.ssl.HostnameVerifier; +import javax.net.ssl.HttpsURLConnection; +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSession; +import javax.net.ssl.TrustManager; +import javax.net.ssl.X509TrustManager; + +/** + * An implementation of {@link ConnectionBuilder} that permits connecting to http + * links, and ignores certificates for https connections. *THIS SHOULD NOT BE USED IN PRODUCTION + * CODE*. It is intended to facilitate easier testing of AppAuth against development servers + * only. + */ +public final class UnsafeConnectionBuilder implements ConnectionBuilder { + + public static final UnsafeConnectionBuilder INSTANCE = new UnsafeConnectionBuilder(); + + private static final String TAG = "ConnBuilder"; + + private static final int CONNECTION_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(15); + private static final int READ_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(10); + + private static final String HTTP = "http"; + private static final String HTTPS = "https"; + + @SuppressLint("TrustAllX509TrustManager") + private static final TrustManager[] ANY_CERT_MANAGER = new TrustManager[] { + new X509TrustManager() { + public X509Certificate[] getAcceptedIssuers() { + return null; + } + + public void checkClientTrusted(X509Certificate[] certs, String authType) {} + + public void checkServerTrusted(X509Certificate[] certs, String authType) {} + } + }; + + @SuppressLint("BadHostnameVerifier") + private static final HostnameVerifier ANY_HOSTNAME_VERIFIER = new HostnameVerifier() { + public boolean verify(String hostname, SSLSession session) { + return true; + } + }; + + @Nullable + private static final SSLContext TRUSTING_CONTEXT; + + static { + SSLContext context; + try { + context = SSLContext.getInstance("SSL"); + } catch (NoSuchAlgorithmException e) { + Log.e("ConnBuilder", "Unable to acquire SSL context"); + context = null; + } + + SSLContext initializedContext = null; + if (context != null) { + try { + context.init(null, ANY_CERT_MANAGER, new java.security.SecureRandom()); + initializedContext = context; + } catch (KeyManagementException e) { + Log.e(TAG, "Failed to initialize trusting SSL context"); + } + } + + TRUSTING_CONTEXT = initializedContext; + } + + private UnsafeConnectionBuilder() { + // no need to construct new instances + } + + @NonNull + @Override + public HttpURLConnection openConnection(@NonNull Uri uri) throws IOException { + Preconditions.checkNotNull(uri, "url must not be null"); + Preconditions.checkArgument(HTTP.equals(uri.getScheme()) || HTTPS.equals(uri.getScheme()), + "scheme or uri must be http or https"); + HttpURLConnection conn = (HttpURLConnection) new URL(uri.toString()).openConnection(); + conn.setConnectTimeout(CONNECTION_TIMEOUT_MS); + conn.setReadTimeout(READ_TIMEOUT_MS); + conn.setInstanceFollowRedirects(false); + + if (conn instanceof HttpsURLConnection && TRUSTING_CONTEXT != null) { + HttpsURLConnection httpsConn = (HttpsURLConnection) conn; + httpsConn.setSSLSocketFactory(TRUSTING_CONTEXT.getSocketFactory()); + httpsConn.setHostnameVerifier(ANY_HOSTNAME_VERIFIER); + } + + return conn; + } +} \ No newline at end of file diff --git a/index.d.ts b/index.d.ts index 196708f9..57756774 100644 --- a/index.d.ts +++ b/index.d.ts @@ -6,6 +6,7 @@ export interface AuthConfiguration extends BaseAuthConfiguration { scopes: string[]; redirectUrl: string; additionalParameters?: { [name: string]: string }; + dangerouslyAllowInsecureHttpRequests?: boolean; } export interface RevokeConfiguration { diff --git a/index.js b/index.js index f1406936..d7ec5a87 100644 --- a/index.js +++ b/index.js @@ -1,5 +1,5 @@ import invariant from 'invariant'; -import { NativeModules } from 'react-native'; +import { NativeModules, Platform } from 'react-native'; const { RNAppAuth } = NativeModules; @@ -12,18 +12,37 @@ const validateClientId = clientId => const validateRedirectUrl = redirectUrl => invariant(typeof redirectUrl === 'string', 'Config error: redirectUrl must be a string'); -export const authorize = ({ issuer, redirectUrl, clientId, scopes, additionalParameters }) => { +export const authorize = ({ + issuer, + redirectUrl, + clientId, + scopes, + additionalParameters, + dangerouslyAllowInsecureHttpRequests = false, +}) => { validateScopes(scopes); validateIssuer(issuer); validateClientId(clientId); validateRedirectUrl(redirectUrl); // TODO: validateAdditionalParameters - return RNAppAuth.authorize(issuer, redirectUrl, clientId, scopes, additionalParameters); + const nativeMethodArguments = [issuer, redirectUrl, clientId, scopes, additionalParameters]; + if (Platform.OS === 'android') { + nativeMethodArguments.push(dangerouslyAllowInsecureHttpRequests); + } + + return RNAppAuth.authorize(...nativeMethodArguments); }; export const refresh = ( - { issuer, redirectUrl, clientId, scopes, additionalParameters }, + { + issuer, + redirectUrl, + clientId, + scopes, + additionalParameters, + dangerouslyAllowInsecureHttpRequests = false, + }, { refreshToken } ) => { validateScopes(scopes); @@ -33,14 +52,20 @@ export const refresh = ( invariant(refreshToken, 'Please pass in a refresh token'); // TODO: validateAdditionalParameters - return RNAppAuth.refresh( + const nativeMethodArguments = [ issuer, redirectUrl, clientId, refreshToken, scopes, - additionalParameters - ); + additionalParameters, + ]; + + if (Platform.OS === 'android') { + nativeMethodArguments.push(dangerouslyAllowInsecureHttpRequests); + } + + return RNAppAuth.refresh(...nativeMethodArguments); }; export const revoke = async ({ clientId, issuer }, { tokenToRevoke, sendClientId = false }) => { diff --git a/index.spec.js b/index.spec.js index d7af8f98..9cd7f840 100644 --- a/index.spec.js +++ b/index.spec.js @@ -7,6 +7,9 @@ jest.mock('react-native', () => ({ refresh: jest.fn(), }, }, + Platform: { + OS: 'ios', + }, })); describe('AppAuth', () => { @@ -65,7 +68,7 @@ describe('AppAuth', () => { }).toThrow('Scope error: please add at least one scope'); }); - it('calls the native wrapper with the correct args', () => { + it('calls the native wrapper with the correct args on iOS', () => { authorize(config); expect(mockAuthorize).toHaveBeenCalledWith( config.issuer, @@ -75,6 +78,52 @@ describe('AppAuth', () => { config.additionalParameters ); }); + + describe('Android-specific dangerouslyAllowInsecureHttpRequests parameter', () => { + beforeEach(() => { + require('react-native').Platform.OS = 'android'; + }); + + afterEach(() => { + require('react-native').Platform.OS = 'ios'; + }); + + it('calls the native wrapper with default value `false`', () => { + authorize(config); + expect(mockAuthorize).toHaveBeenCalledWith( + config.issuer, + config.redirectUrl, + config.clientId, + config.scopes, + config.additionalParameters, + false + ); + }); + + it('calls the native wrapper with passed value `false`', () => { + authorize({ ...config, dangerouslyAllowInsecureHttpRequests: false }); + expect(mockAuthorize).toHaveBeenCalledWith( + config.issuer, + config.redirectUrl, + config.clientId, + config.scopes, + config.additionalParameters, + false + ); + }); + + it('calls the native wrapper with passed value `true`', () => { + authorize({ ...config, dangerouslyAllowInsecureHttpRequests: true }); + expect(mockAuthorize).toHaveBeenCalledWith( + config.issuer, + config.redirectUrl, + config.clientId, + config.scopes, + config.additionalParameters, + true + ); + }); + }); }); describe('refresh', () => { @@ -119,7 +168,7 @@ describe('AppAuth', () => { }).toThrow('Scope error: please add at least one scope'); }); - it('calls the native wrapper with the correct args', () => { + it('calls the native wrapper with the correct args on iOS', () => { refresh({ ...config }, { refreshToken: 'such-token' }); expect(mockRefresh).toHaveBeenCalledWith( config.issuer, @@ -130,5 +179,60 @@ describe('AppAuth', () => { config.additionalParameters ); }); + + describe('Android-specific dangerouslyAllowInsecureHttpRequests parameter', () => { + beforeEach(() => { + require('react-native').Platform.OS = 'android'; + }); + + afterEach(() => { + require('react-native').Platform.OS = 'ios'; + }); + + it('calls the native wrapper with default value `false`', () => { + refresh(config, { refreshToken: 'such-token' }); + expect(mockRefresh).toHaveBeenCalledWith( + config.issuer, + config.redirectUrl, + config.clientId, + 'such-token', + config.scopes, + config.additionalParameters, + false + ); + }); + + it('calls the native wrapper with passed value `false`', () => { + refresh( + { ...config, dangerouslyAllowInsecureHttpRequests: false }, + { refreshToken: 'such-token' } + ); + expect(mockRefresh).toHaveBeenCalledWith( + config.issuer, + config.redirectUrl, + config.clientId, + 'such-token', + config.scopes, + config.additionalParameters, + false + ); + }); + + it('calls the native wrapper with passed value `true`', () => { + refresh( + { ...config, dangerouslyAllowInsecureHttpRequests: true }, + { refreshToken: 'such-token' } + ); + expect(mockRefresh).toHaveBeenCalledWith( + config.issuer, + config.redirectUrl, + config.clientId, + 'such-token', + config.scopes, + config.additionalParameters, + true + ); + }); + }); }); });