From 8972232112dd6298de9f2d5a10bf75a47b9a857b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 13:13:14 +0000 Subject: [PATCH 01/22] First naive stab at passing serviceConfiguration manually --- Example/App.js | 8 +- .../com/reactlibrary/RNAppAuthModule.java | 126 +++++++++++++----- index.js | 10 +- 3 files changed, 109 insertions(+), 35 deletions(-) diff --git a/Example/App.js b/Example/App.js index 617f6209..2c4ac16a 100644 --- a/Example/App.js +++ b/Example/App.js @@ -18,7 +18,11 @@ const config = { clientId: 'native.code', redirectUrl: 'io.identityserver.demo:/oauthredirect', additionalParameters: {}, - scopes: ['openid', 'profile', 'email', 'offline_access'] + scopes: ['openid', 'profile', 'email', 'offline_access'], + serviceConfiguration: { + authorizationEndpoint: 'https://demo.identityserver.io/connect/authorize', + tokenEndpoint: 'https://demo.identityserver.io/connect/token' + } }; export default class App extends Component<{}, State> { @@ -41,7 +45,7 @@ export default class App extends Component<{}, State> { authorize = async () => { try { const authState = await authorize(config); - + this.animateState( { hasLoggedInOnce: true, diff --git a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java index 771e815c..be688d7d 100644 --- a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java +++ b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java @@ -131,6 +131,37 @@ private Uri buildConfigurationUriFromIssuer(Uri openIdConnectIssuerUri) { .build(); } + private void authorizeWithConfiguration( + final AuthorizationServiceConfiguration serviceConfiguration, + final AppAuthConfiguration appAuthConfiguration, + final String clientId, + final String scopesString, + final String redirectUrl, + final ReadableMap additionalParameters + ) { + + final Context context = this.reactContext; + final Activity currentActivity = getCurrentActivity(); + + AuthorizationRequest.Builder authRequestBuilder = + new AuthorizationRequest.Builder( + serviceConfiguration, + clientId, + ResponseTypeValues.CODE, + Uri.parse(redirectUrl) + ) + .setScope(scopesString); + + if (additionalParameters != null) { + authRequestBuilder.setAdditionalParameters(additionalParametersToMap(additionalParameters)); + } + + AuthorizationRequest authRequest = authRequestBuilder.build(); + AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); + Intent authIntent = authService.getAuthorizationRequestIntent(authRequest); + currentActivity.startActivityForResult(authIntent, 0); + } + @ReactMethod public void authorize( String issuer, @@ -138,56 +169,87 @@ public void authorize( final String clientId, final ReadableArray scopes, final ReadableMap additionalParameters, + final ReadableMap serviceConfiguration, 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; 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); + final AppAuthConfiguration appAuthConfiguration = this.createAppAuthConfiguration(builder); - AuthorizationServiceConfiguration.fetchFromUrl( - buildConfigurationUriFromIssuer(issuerUri), - new AuthorizationServiceConfiguration.RetrieveConfigurationCallback() { - public void onFetchConfigurationCompleted( - @Nullable AuthorizationServiceConfiguration serviceConfiguration, - @Nullable AuthorizationException ex) { - if (ex != null) { - promise.reject("RNAppAuth Error", "Failed to fetch configuration", ex); - return; - } + // when serviceConfiguration is provided, we don't need to hit up the OpenID well-known id endpoint + if (serviceConfiguration != null) { - AuthorizationRequest.Builder authRequestBuilder = - new AuthorizationRequest.Builder( - serviceConfiguration, - clientId, - ResponseTypeValues.CODE, - Uri.parse(redirectUrl) - ) - .setScope(scopesString); + // @TODO Refactor validation + if (!serviceConfiguration.hasKey("authorizationEndpoint")) { + promise.reject("RNAppAuth Error", "serviceConfiguration passed without an authorizationEndpoint"); + return; + } - if (additionalParameters != null) { - authRequestBuilder.setAdditionalParameters(additionalParametersToMap(additionalParameters)); + if (!serviceConfiguration.hasKey("tokenEndpoint")) { + promise.reject("RNAppAuth Error", "serviceConfiguration passed without a tokenEndpoint"); + return; + } + + Uri authorizationEndpoint = Uri.parse(serviceConfiguration.getString("authorizationEndpoint")); + Uri tokenEndpoint = Uri.parse(serviceConfiguration.getString("tokenEndpoint")); + Uri registrationEndpoint = null; + + if (serviceConfiguration.hasKey("registrationEndpoint")) { + registrationEndpoint = Uri.parse(serviceConfiguration.getString("registrationEndPoint")); + } + + + AuthorizationServiceConfiguration authorizationServiceConfiguration = new AuthorizationServiceConfiguration( + authorizationEndpoint, + tokenEndpoint, + registrationEndpoint + ); + + authorizeWithConfiguration( + authorizationServiceConfiguration, + appAuthConfiguration, + clientId, + scopesString, + redirectUrl, + additionalParameters + ); + } else { + final Uri issuerUri = Uri.parse(issuer); + AuthorizationServiceConfiguration.fetchFromUrl( + buildConfigurationUriFromIssuer(issuerUri), + new AuthorizationServiceConfiguration.RetrieveConfigurationCallback() { + public void onFetchConfigurationCompleted( + @Nullable AuthorizationServiceConfiguration fetchedConfiguration, + @Nullable AuthorizationException ex) { + if (ex != null) { + promise.reject("RNAppAuth Error", "Failed to fetch configuration", ex); + return; + } + + authorizeWithConfiguration( + fetchedConfiguration, + appAuthConfiguration, + clientId, + scopesString, + redirectUrl, + additionalParameters + ); } + }, + builder + ); + } + - AuthorizationRequest authRequest = authRequestBuilder.build(); - AuthorizationService authService = new AuthorizationService(context, configuration); - Intent authIntent = authService.getAuthorizationRequestIntent(authRequest); - currentActivity.startActivityForResult(authIntent, 0); - } - }, - builder - ); } diff --git a/index.js b/index.js index d7ec5a87..84727464 100644 --- a/index.js +++ b/index.js @@ -18,6 +18,7 @@ export const authorize = ({ clientId, scopes, additionalParameters, + serviceConfiguration, dangerouslyAllowInsecureHttpRequests = false, }) => { validateScopes(scopes); @@ -26,7 +27,14 @@ export const authorize = ({ validateRedirectUrl(redirectUrl); // TODO: validateAdditionalParameters - const nativeMethodArguments = [issuer, redirectUrl, clientId, scopes, additionalParameters]; + const nativeMethodArguments = [ + issuer, + redirectUrl, + clientId, + scopes, + additionalParameters, + serviceConfiguration, + ]; if (Platform.OS === 'android') { nativeMethodArguments.push(dangerouslyAllowInsecureHttpRequests); } From 927c92c36526254d5679385c45f58ae7cdfc3089 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 16:14:50 +0000 Subject: [PATCH 02/22] Naive implementation of manually configured refreshing + client secret support --- .../com/reactlibrary/RNAppAuthModule.java | 189 ++++++++++++------ .../java/com/reactlibrary/utils/MapUtils.java | 29 +++ index.js | 16 +- 3 files changed, 171 insertions(+), 63 deletions(-) create mode 100644 android/src/main/java/com/reactlibrary/utils/MapUtils.java diff --git a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java index be688d7d..177fbc6f 100644 --- a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java +++ b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java @@ -17,6 +17,7 @@ import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; import com.facebook.react.bridge.WritableMap; +import com.reactlibrary.utils.MapUtils; import com.reactlibrary.utils.UnsafeConnectionBuilder; import net.openid.appauth.AppAuthConfiguration; @@ -38,9 +39,12 @@ import java.net.URL; import java.sql.Connection; import java.text.SimpleDateFormat; +import java.util.Collection; import java.util.Date; import java.util.HashMap; import java.util.Iterator; +import java.util.Map; +import java.util.Set; import java.util.concurrent.TimeUnit; public class RNAppAuthModule extends ReactContextBaseJavaModule implements ActivityEventListener { @@ -48,6 +52,7 @@ public class RNAppAuthModule extends ReactContextBaseJavaModule implements Activ private final ReactApplicationContext reactContext; private Promise promise; private Boolean dangerouslyAllowInsecureHttpRequests; + private Map additionalParametersMap; public RNAppAuthModule(ReactApplicationContext reactContext) { super(reactContext); @@ -95,19 +100,6 @@ private WritableMap tokenResponseToMap(TokenResponse response) { return map; } - private HashMap additionalParametersToMap(ReadableMap additionalParameters) { - - HashMap additionalParametersHash = new HashMap<>(); - - ReadableMapKeySetIterator iterator = additionalParameters.keySetIterator(); - - while (iterator.hasNextKey()) { - String nextKey = iterator.nextKey(); - additionalParametersHash.put(nextKey, additionalParameters.getString(nextKey)); - } - - return additionalParametersHash; - } private AppAuthConfiguration createAppAuthConfiguration(ConnectionBuilder connectionBuilder) { return new AppAuthConfiguration @@ -137,7 +129,7 @@ private void authorizeWithConfiguration( final String clientId, final String scopesString, final String redirectUrl, - final ReadableMap additionalParameters + final Map additionalParametersMap ) { final Context context = this.reactContext; @@ -152,8 +144,8 @@ private void authorizeWithConfiguration( ) .setScope(scopesString); - if (additionalParameters != null) { - authRequestBuilder.setAdditionalParameters(additionalParametersToMap(additionalParameters)); + if (additionalParametersMap != null) { + authRequestBuilder.setAdditionalParameters(additionalParametersMap); } AuthorizationRequest authRequest = authRequestBuilder.build(); @@ -162,6 +154,48 @@ private void authorizeWithConfiguration( currentActivity.startActivityForResult(authIntent, 0); } + private void refreshWithConfiguration( + final AuthorizationServiceConfiguration serviceConfiguration, + final AppAuthConfiguration appAuthConfiguration, + final String refreshToken, + final String clientId, + final String scopesString, + final String redirectUrl, + final Map additionalParametersMap, + final Promise promise + ) { + + final Context context = this.reactContext; + + TokenRequest.Builder tokenRequestBuilder = + new TokenRequest.Builder( + serviceConfiguration, + clientId + ) + .setScope(scopesString) + .setRefreshToken(refreshToken) + .setRedirectUri(Uri.parse(redirectUrl)); + + if (!additionalParametersMap.isEmpty()) { + tokenRequestBuilder.setAdditionalParameters(additionalParametersMap); + } + + TokenRequest tokenRequest = tokenRequestBuilder.build(); + + AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); + authService.performTokenRequest(tokenRequest, new AuthorizationService.TokenResponseCallback() { + @Override + public void onTokenRequestCompleted(@Nullable TokenResponse response, @Nullable AuthorizationException ex) { + if (response != null) { + WritableMap map = tokenResponseToMap(response); + promise.resolve(map); + } else { + promise.reject("RNAppAuth Error", "Failed refresh token"); + } + } + }); + } + @ReactMethod public void authorize( String issuer, @@ -175,14 +209,16 @@ public void authorize( ) { - // store args in private fields for later use in onActivityResult handler - this.promise = promise; - this.dangerouslyAllowInsecureHttpRequests = dangerouslyAllowInsecureHttpRequests; final String scopesString = this.arrayToString(scopes); final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests); final AppAuthConfiguration appAuthConfiguration = this.createAppAuthConfiguration(builder); + final HashMap additionalParametersMap = MapUtils.readableMapToHashMap(additionalParameters); + // store args in private fields for later use in onActivityResult handler + this.promise = promise; + this.dangerouslyAllowInsecureHttpRequests = dangerouslyAllowInsecureHttpRequests; + this.additionalParametersMap = additionalParametersMap; // when serviceConfiguration is provided, we don't need to hit up the OpenID well-known id endpoint if (serviceConfiguration != null) { @@ -219,7 +255,7 @@ public void authorize( clientId, scopesString, redirectUrl, - additionalParameters + additionalParametersMap ); } else { final Uri issuerUri = Uri.parse(issuer); @@ -240,7 +276,7 @@ public void onFetchConfigurationCompleted( clientId, scopesString, redirectUrl, - additionalParameters + additionalParametersMap ); } }, @@ -261,6 +297,7 @@ public void refresh( final String refreshToken, final ReadableArray scopes, final ReadableMap additionalParameters, + final ReadableMap serviceConfiguration, final Boolean dangerouslyAllowInsecureHttpRequests, final Promise promise ) { @@ -268,53 +305,81 @@ public void refresh( final String scopesString = this.arrayToString(scopes); final Uri issuerUri = Uri.parse(issuer); final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests); - final AppAuthConfiguration configuration = createAppAuthConfiguration(builder); + final AppAuthConfiguration appAuthConfiguration = createAppAuthConfiguration(builder); + final HashMap additionalParametersMap = MapUtils.readableMapToHashMap(additionalParameters); // store setting in private field for later use in onActivityResult handler this.dangerouslyAllowInsecureHttpRequests = dangerouslyAllowInsecureHttpRequests; + this.additionalParametersMap = additionalParametersMap; - AuthorizationServiceConfiguration.fetchFromUrl( - buildConfigurationUriFromIssuer(issuerUri), - new AuthorizationServiceConfiguration.RetrieveConfigurationCallback() { - public void onFetchConfigurationCompleted( - @Nullable AuthorizationServiceConfiguration serviceConfiguration, - @Nullable AuthorizationException ex) { - if (ex != null) { - promise.reject("RNAppAuth Error", "Failed to fetch configuration", ex); - return; - } + // when serviceConfiguration is provided, we don't need to hit up the OpenID well-known id endpoint + if (serviceConfiguration != null) { + + // @TODO Refactor validation + if (!serviceConfiguration.hasKey("authorizationEndpoint")) { + promise.reject("RNAppAuth Error", "serviceConfiguration passed without an authorizationEndpoint"); + return; + } + + if (!serviceConfiguration.hasKey("tokenEndpoint")) { + promise.reject("RNAppAuth Error", "serviceConfiguration passed without a tokenEndpoint"); + return; + } + + Uri authorizationEndpoint = Uri.parse(serviceConfiguration.getString("authorizationEndpoint")); + Uri tokenEndpoint = Uri.parse(serviceConfiguration.getString("tokenEndpoint")); + Uri registrationEndpoint = null; + + if (serviceConfiguration.hasKey("registrationEndpoint")) { + registrationEndpoint = Uri.parse(serviceConfiguration.getString("registrationEndPoint")); + } - TokenRequest.Builder tokenRequestBuilder = - new TokenRequest.Builder( - serviceConfiguration, - clientId - ) - .setScope(scopesString) - .setRefreshToken(refreshToken) - .setRedirectUri(Uri.parse(redirectUrl)); - - if (additionalParameters != null) { - tokenRequestBuilder.setAdditionalParameters(additionalParametersToMap(additionalParameters)); - } - TokenRequest tokenRequest = tokenRequestBuilder.build(); - - AuthorizationService authService = new AuthorizationService(context, configuration); - authService.performTokenRequest(tokenRequest, new AuthorizationService.TokenResponseCallback() { - @Override - public void onTokenRequestCompleted(@Nullable TokenResponse response, @Nullable AuthorizationException ex) { - if (response != null) { - WritableMap map = tokenResponseToMap(response); - promise.resolve(map); - } else { - promise.reject("RNAppAuth Error", "Failed refresh token"); - } + AuthorizationServiceConfiguration authorizationServiceConfiguration = new AuthorizationServiceConfiguration( + authorizationEndpoint, + tokenEndpoint, + registrationEndpoint + ); + + refreshWithConfiguration( + authorizationServiceConfiguration, + appAuthConfiguration, + refreshToken, + clientId, + scopesString, + redirectUrl, + additionalParametersMap, + promise + ); + } else { + // @TODO: Refactor to avoid hitting IDP endpoint on refresh, reuse fetchedConfiguration + // if possible. + AuthorizationServiceConfiguration.fetchFromUrl( + buildConfigurationUriFromIssuer(issuerUri), + new AuthorizationServiceConfiguration.RetrieveConfigurationCallback() { + public void onFetchConfigurationCompleted( + @Nullable AuthorizationServiceConfiguration fetchedConfiguration, + @Nullable AuthorizationException ex) { + if (ex != null) { + promise.reject("RNAppAuth Error", "Failed to fetch configuration", ex); + return; } - }); - } - }, - builder); + refreshWithConfiguration( + fetchedConfiguration, + appAuthConfiguration, + refreshToken, + clientId, + scopesString, + redirectUrl, + additionalParametersMap, + promise + ); + } + }, + builder); + } + } @Override @@ -333,8 +398,10 @@ public void onActivityResult(Activity activity, int requestCode, int resultCode, ); AuthorizationService authService = new AuthorizationService(this.reactContext, configuration); + + TokenRequest tokenRequest = response.createTokenExchangeRequest(this.additionalParametersMap); authService.performTokenRequest( - response.createTokenExchangeRequest(), + tokenRequest, new AuthorizationService.TokenResponseCallback() { @Override diff --git a/android/src/main/java/com/reactlibrary/utils/MapUtils.java b/android/src/main/java/com/reactlibrary/utils/MapUtils.java new file mode 100644 index 00000000..27901c57 --- /dev/null +++ b/android/src/main/java/com/reactlibrary/utils/MapUtils.java @@ -0,0 +1,29 @@ +package com.reactlibrary.utils; + +import android.support.annotation.Nullable; + +import com.facebook.react.bridge.ReadableMap; +import com.facebook.react.bridge.ReadableMapKeySetIterator; + +import java.util.HashMap; + +/** + * Created by formidable on 21/02/2018. + */ + +public class MapUtils { + + public static HashMap readableMapToHashMap(@Nullable ReadableMap readableMap) { + + HashMap hashMap = new HashMap<>(); + if (readableMap != null) { + ReadableMapKeySetIterator iterator = readableMap.keySetIterator(); + while (iterator.hasNextKey()) { + String nextKey = iterator.nextKey(); + hashMap.put(nextKey, readableMap.getString(nextKey)); + } + } + + return hashMap; + } +} diff --git a/index.js b/index.js index 84727464..f7587a34 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,7 @@ export const authorize = ({ issuer, redirectUrl, clientId, + clientSecret, scopes, additionalParameters, serviceConfiguration, @@ -27,12 +28,16 @@ export const authorize = ({ validateRedirectUrl(redirectUrl); // TODO: validateAdditionalParameters + const nativeAdditionalParameters = clientSecret + ? { ...additionalParameters, client_secret: clientSecret } //eslint-disable-line camelcase + : additionalParameters; + const nativeMethodArguments = [ issuer, redirectUrl, clientId, scopes, - additionalParameters, + nativeAdditionalParameters, serviceConfiguration, ]; if (Platform.OS === 'android') { @@ -47,8 +52,10 @@ export const refresh = ( issuer, redirectUrl, clientId, + clientSecret, scopes, additionalParameters, + serviceConfiguration, dangerouslyAllowInsecureHttpRequests = false, }, { refreshToken } @@ -60,13 +67,18 @@ export const refresh = ( invariant(refreshToken, 'Please pass in a refresh token'); // TODO: validateAdditionalParameters + const nativeAdditionalParameters = clientSecret + ? { ...additionalParameters, client_secret: clientSecret } //eslint-disable-line camelcase + : additionalParameters; + const nativeMethodArguments = [ issuer, redirectUrl, clientId, refreshToken, scopes, - additionalParameters, + nativeAdditionalParameters, + serviceConfiguration, ]; if (Platform.OS === 'android') { From 4f965f66490cd8da01f70669946c15fae9dccd94 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 16:24:33 +0000 Subject: [PATCH 03/22] Add support for revocation with manual service configuration --- index.js | 35 +++++++++++++++++++++++++---------- 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/index.js b/index.js index f7587a34..cadc26f5 100644 --- a/index.js +++ b/index.js @@ -7,6 +7,12 @@ const validateScopes = scopes => invariant(scopes && scopes.length, 'Scope error: please add at least one scope'); const validateIssuer = issuer => invariant(typeof issuer === 'string', 'Config error: issuer must be a string'); +const validateIssuerOrServiceConfigurationRevocationEndpoint = (issuer, serviceConfiguration) => + invariant( + typeof issuer === 'string' || + (serviceConfiguration && typeof serviceConfiguration.revocationEndPoint === 'string'), + 'Config error: issuer must be a string' + ); const validateClientId = clientId => invariant(typeof clientId === 'string', 'Config error: clientId must be a string'); const validateRedirectUrl = redirectUrl => @@ -88,18 +94,28 @@ export const refresh = ( return RNAppAuth.refresh(...nativeMethodArguments); }; -export const revoke = async ({ clientId, issuer }, { tokenToRevoke, sendClientId = false }) => { +export const revoke = async ( + { clientId, issuer, serviceConfiguration }, + { tokenToRevoke, sendClientId = false } +) => { invariant(tokenToRevoke, 'Please include the token to revoke'); validateClientId(clientId); - validateIssuer(issuer); + validateIssuerOrServiceConfigurationRevocationEndpoint(issuer, serviceConfiguration); - const response = await fetch(`${issuer}/.well-known/openid-configuration`); - const openidConfig = await response.json(); + let revocationEndpoint; + if (serviceConfiguration && serviceConfiguration.revocationEndpoint) { + revocationEndpoint = serviceConfiguration.revocationEndpoint; + } else { + const response = await fetch(`${issuer}/.well-known/openid-configuration`); + const openidConfig = await response.json(); - invariant( - openidConfig.revocation_endpoint, - 'The openid config does not specify a revocation endpoint' - ); + invariant( + openidConfig.revocation_endpoint, + 'The openid config does not specify a revocation endpoint' + ); + + revocationEndpoint = openidConfig.revocationEndpoint; + } /** Identity Server insists on client_id being passed in the body, @@ -107,8 +123,7 @@ export const revoke = async ({ clientId, issuer }, { tokenToRevoke, sendClientId so defaulting to no client_id https://tools.ietf.org/html/rfc7009#section-2.1 **/ - - return await fetch(openidConfig.revocation_endpoint, { + return await fetch(revocationEndpoint, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded', From a5e0bcec5b112463b23c1a891b993183b7415a0a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 18:37:37 +0000 Subject: [PATCH 04/22] Make clientSecret a native method parameter instead of bundling in additionalProperties --- index.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/index.js b/index.js index cadc26f5..e9d2fa12 100644 --- a/index.js +++ b/index.js @@ -34,16 +34,13 @@ export const authorize = ({ validateRedirectUrl(redirectUrl); // TODO: validateAdditionalParameters - const nativeAdditionalParameters = clientSecret - ? { ...additionalParameters, client_secret: clientSecret } //eslint-disable-line camelcase - : additionalParameters; - const nativeMethodArguments = [ issuer, redirectUrl, clientId, + clientSecret, scopes, - nativeAdditionalParameters, + additionalParameters, serviceConfiguration, ]; if (Platform.OS === 'android') { @@ -73,17 +70,14 @@ export const refresh = ( invariant(refreshToken, 'Please pass in a refresh token'); // TODO: validateAdditionalParameters - const nativeAdditionalParameters = clientSecret - ? { ...additionalParameters, client_secret: clientSecret } //eslint-disable-line camelcase - : additionalParameters; - const nativeMethodArguments = [ issuer, redirectUrl, clientId, + clientSecret, refreshToken, scopes, - nativeAdditionalParameters, + additionalParameters, serviceConfiguration, ]; From 851311d13a56269eaee60a85a204373588035fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 18:38:05 +0000 Subject: [PATCH 05/22] Initial implementation of manual configuration for ios --- ios/RNAppAuth.m | 271 +++++++++++++++++++++++++++++++----------------- 1 file changed, 177 insertions(+), 94 deletions(-) diff --git a/ios/RNAppAuth.m b/ios/RNAppAuth.m index 3671798a..1665e3f0 100644 --- a/ios/RNAppAuth.m +++ b/ios/RNAppAuth.m @@ -11,125 +11,208 @@ - (dispatch_queue_t)methodQueue { return dispatch_get_main_queue(); } + RCT_EXPORT_MODULE() RCT_REMAP_METHOD(authorize, issuer: (NSString *) issuer redirectUrl: (NSString *) redirectUrl clientId: (NSString *) clientId + clientSecret: (NSString *) clientSecret scopes: (NSArray *) scopes additionalParameters: (NSDictionary *_Nullable) additionalParameters - resolve:(RCTPromiseResolveBlock) resolve + serviceConfiguration: (NSDictionary *_Nullable) serviceConfiguration + resolve: (RCTPromiseResolveBlock) resolve reject: (RCTPromiseRejectBlock) reject) { - [OIDAuthorizationService discoverServiceConfigurationForIssuer:[NSURL URLWithString:issuer] - completion:^(OIDServiceConfiguration *_Nullable configuration, NSError *_Nullable error) { - if (!configuration) { - reject(@"RNAppAuth Error", [error localizedDescription], error); - return; - } - - // builds authentication request - OIDAuthorizationRequest *request = - [[OIDAuthorizationRequest alloc] initWithConfiguration:configuration - clientId:clientId - scopes:scopes - redirectURL:[NSURL URLWithString:redirectUrl] - responseType:OIDResponseTypeCode - additionalParameters:additionalParameters]; - - - // performs authentication request - AppDelegate *appDelegate = - (AppDelegate *)[UIApplication sharedApplication].delegate; - - appDelegate.currentAuthorizationFlow = - [OIDAuthState authStateByPresentingAuthorizationRequest:request - presentingViewController:appDelegate.window.rootViewController - callback:^(OIDAuthState *_Nullable authState, - NSError *_Nullable error) { - if (authState) { - NSDate *expirationDate = authState.lastTokenResponse.accessTokenExpirationDate ? authState.lastTokenResponse.accessTokenExpirationDate : [NSDate alloc]; - - NSDateFormatter *dateFormat = [[NSDateFormatter alloc] init]; - [dateFormat setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZ"]; - NSString *exporationDateString = [dateFormat stringFromDate:expirationDate]; - - NSDictionary *authStateDict = @{ - @"accessToken": authState.lastTokenResponse.accessToken, - @"accessTokenExpirationDate": exporationDateString, - @"additionalParameters": authState.lastTokenResponse.additionalParameters, - @"idToken": authState.lastTokenResponse.idToken, - @"refreshToken": authState.lastTokenResponse.refreshToken ? authState.lastTokenResponse.refreshToken : @"", - @"tokenType": authState.lastTokenResponse.tokenType, - }; - resolve(authStateDict); - } else { - reject(@"RNAppAuth Error", [error localizedDescription], error); - } - - }]; // end [OIDAuthState authStateByPresentingAuthorizationRequest:request - - }]; // end [OIDAuthorizationService discoverServiceConfigurationForIssuer:[NSURL URLWithString:issuer] - + // if we have manually provided configuration, we can use it and skip the OIDC well-known discovery endpoint call + if (serviceConfiguration) { + NSURL *authorizationEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"authorizationEndpoint"]]; + NSURL *tokenEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"tokenEndpoint"]]; + + OIDServiceConfiguration *configuration = + [[OIDServiceConfiguration alloc] + initWithAuthorizationEndpoint:authorizationEndpoint + tokenEndpoint:tokenEndpoint]; + + [self authorizeWithConfiguration: configuration + redirectUrl: redirectUrl + clientId: clientId + clientSecret: clientSecret + scopes: scopes + additionalParameters: additionalParameters + resolve: resolve + reject: reject]; + + } else { + [OIDAuthorizationService discoverServiceConfigurationForIssuer:[NSURL URLWithString:issuer] + completion:^(OIDServiceConfiguration *_Nullable configuration, NSError *_Nullable error) { + if (!configuration) { + reject(@"RNAppAuth Error", [error localizedDescription], error); + return; + } + + [self authorizeWithConfiguration: configuration + redirectUrl: redirectUrl + clientId: clientId + clientSecret: clientSecret + scopes: scopes + additionalParameters: additionalParameters + resolve: resolve + reject: reject]; + }]; + } } // end RCT_REMAP_METHOD(authorize, RCT_REMAP_METHOD(refresh, issuer: (NSString *) issuer redirectUrl: (NSString *) redirectUrl clientId: (NSString *) clientId + clientSecret: (NSString *) clientSecret refreshToken: (NSString *) refreshToken scopes: (NSArray *) scopes additionalParameters: (NSDictionary *_Nullable) additionalParameters + serviceConfiguration: (NSDictionary *_Nullable) serviceConfiguration resolve:(RCTPromiseResolveBlock) resolve reject: (RCTPromiseRejectBlock) reject) { - [OIDAuthorizationService discoverServiceConfigurationForIssuer:[NSURL URLWithString:issuer] - completion:^(OIDServiceConfiguration *_Nullable configuration, NSError *_Nullable error) { - if (!configuration) { - reject(@"RNAppAuth Error", [error localizedDescription], error); - return; - } - - OIDTokenRequest *tokenRefreshRequest = - [[OIDTokenRequest alloc] initWithConfiguration:configuration - grantType:@"refresh_token" - authorizationCode:nil - redirectURL:[NSURL URLWithString:redirectUrl] - clientID:clientId - clientSecret:nil - scopes:scopes - refreshToken:refreshToken - codeVerifier:nil - additionalParameters:additionalParameters]; - - [OIDAuthorizationService performTokenRequest:tokenRefreshRequest - callback:^(OIDTokenResponse *_Nullable response, - NSError *_Nullable error) { - - if (response) { + // if we have manually provided configuration, we can use it and skip the OIDC well-known discovery endpoint call + if (serviceConfiguration) { + NSURL *authorizationEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"authorizationEndpoint"]]; + NSURL *tokenEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"tokenEndpoint"]]; + + OIDServiceConfiguration *configuration = + [[OIDServiceConfiguration alloc] + initWithAuthorizationEndpoint:authorizationEndpoint + tokenEndpoint:tokenEndpoint]; + + [self refreshWithConfiguration: configuration + redirectUrl: redirectUrl + clientId: clientId + clientSecret: clientSecret + refreshToken: refreshToken + scopes: scopes + additionalParameters: additionalParameters + resolve: resolve + reject: reject]; + + } else { + // otherwise hit up the discovery endpoint + [OIDAuthorizationService discoverServiceConfigurationForIssuer:[NSURL URLWithString:issuer] + completion:^(OIDServiceConfiguration *_Nullable configuration, NSError *_Nullable error) { + if (!configuration) { + reject(@"RNAppAuth Error", [error localizedDescription], error); + return; + } + [self refreshWithConfiguration: configuration + redirectUrl: redirectUrl + clientId: clientId + clientSecret: clientSecret + refreshToken: refreshToken + scopes: scopes + additionalParameters: additionalParameters + resolve: resolve + reject: reject]; + }]; + } +} // end RCT_REMAP_METHOD(refresh, - NSDate *expirationDate = response.accessTokenExpirationDate ? - response.accessTokenExpirationDate : [NSDate alloc]; +/* + * Authorize a user in exchange for a token with provided OIDServiceConfiguration + */ +- (void)authorizeWithConfiguration: (OIDServiceConfiguration *) configuration + redirectUrl: (NSString *) redirectUrl + clientId: (NSString *) clientId + clientSecret: (NSString *) clientSecret + scopes: (NSArray *) scopes + additionalParameters: (NSDictionary *_Nullable) additionalParameters + resolve: (RCTPromiseResolveBlock) resolve + reject: (RCTPromiseRejectBlock) reject +{ + // builds authentication request + OIDAuthorizationRequest *request = + [[OIDAuthorizationRequest alloc] initWithConfiguration:configuration + clientId:clientId + clientSecret:clientSecret + scopes:scopes + redirectURL:[NSURL URLWithString:redirectUrl] + responseType:OIDResponseTypeCode + additionalParameters:additionalParameters]; + + + // performs authentication request + AppDelegate *appDelegate = (AppDelegate *)[UIApplication sharedApplication].delegate; + + appDelegate.currentAuthorizationFlow = + [OIDAuthState authStateByPresentingAuthorizationRequest:request + presentingViewController:appDelegate.window.rootViewController + callback:^(OIDAuthState *_Nullable authState, + NSError *_Nullable error) { + if (authState) { + resolve([self formatResponse:authState.lastTokenResponse]); + } else { + reject(@"RNAppAuth Error", [error localizedDescription], error); + } + + }]; // end [OIDAuthState authStateByPresentingAuthorizationRequest:request +} - NSDateFormatter *dateFormat = [[NSDateFormatter alloc] init]; - [dateFormat setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZ"]; - NSString *exporationDateString = [dateFormat stringFromDate:expirationDate]; - resolve(@{ - @"accessToken": response.accessToken ? response.accessToken : @"", - @"accessTokenExpirationDate": exporationDateString, - @"additionalParameters": response.additionalParameters, - @"idToken": response.idToken ? response.idToken : @"", - @"refreshToken": response.refreshToken ? response.refreshToken : @"", - @"tokenType": response.tokenType ? response.tokenType : @"", - }); - } else { - reject(@"RNAppAuth Error", [error localizedDescription], error); - } - }]; +/* + * Refresh a token with provided OIDServiceConfiguration + */ +- (void)refreshWithConfiguration: (OIDServiceConfiguration *)configuration + redirectUrl: (NSString *) redirectUrl + clientId: (NSString *) clientId + clientSecret: (NSString *) clientSecret + refreshToken: (NSString *) refreshToken + scopes: (NSArray *) scopes + additionalParameters: (NSDictionary *_Nullable) additionalParameters + resolve:(RCTPromiseResolveBlock) resolve + reject: (RCTPromiseRejectBlock) reject { + + OIDTokenRequest *tokenRefreshRequest = + [[OIDTokenRequest alloc] initWithConfiguration:configuration + grantType:@"refresh_token" + authorizationCode:nil + redirectURL:[NSURL URLWithString:redirectUrl] + clientID:clientId + clientSecret:clientSecret + scopes:scopes + refreshToken:refreshToken + codeVerifier:nil + additionalParameters:additionalParameters]; + + [OIDAuthorizationService performTokenRequest:tokenRefreshRequest + callback:^(OIDTokenResponse *_Nullable response, + NSError *_Nullable error) { + if (response) { + resolve([self formatResponse:response]); + } else { + reject(@"RNAppAuth Error", [error localizedDescription], error); + } + }]; + +} - }]; // end [OIDAuthorizationService discoverServiceConfigurationForIssuer:[NSURL URLWithString:issuer] -} // end RCT_REMAP_METHOD(refresh, +/* + * Take raw OIDTokenResponse and turn it to a token response format to pass to JavaScript caller + */ +- (NSDictionary*)formatResponse: (OIDTokenResponse*) response { + NSDate *expirationDate = response.accessTokenExpirationDate ? + response.accessTokenExpirationDate : [NSDate alloc]; + + NSDateFormatter *dateFormat = [[NSDateFormatter alloc] init]; + [dateFormat setDateFormat:@"yyyy-MM-dd'T'HH:mm:ssZ"]; + NSString *expirationDateString = [dateFormat stringFromDate:expirationDate]; + + return @{@"accessToken": response.accessToken ? response.accessToken : @"", + @"accessTokenExpirationDate": expirationDateString, + @"additionalParameters": response.additionalParameters, + @"idToken": response.idToken ? response.idToken : @"", + @"refreshToken": response.refreshToken ? response.refreshToken : @"", + @"tokenType": response.tokenType ? response.tokenType : @"", + }; +} @end From 23cd1bc33ebf76f0e6e2e7033aaf3b436c6122fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 18:43:50 +0000 Subject: [PATCH 06/22] Accept clientSecret as a parameter to authorize/refresh --- .../main/java/com/reactlibrary/RNAppAuthModule.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java index 177fbc6f..20bb386e 100644 --- a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java +++ b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java @@ -53,6 +53,7 @@ public class RNAppAuthModule extends ReactContextBaseJavaModule implements Activ private Promise promise; private Boolean dangerouslyAllowInsecureHttpRequests; private Map additionalParametersMap; + private String clientSecret; public RNAppAuthModule(ReactApplicationContext reactContext) { super(reactContext); @@ -201,6 +202,7 @@ public void authorize( String issuer, final String redirectUrl, final String clientId, + final String clientSecret, final ReadableArray scopes, final ReadableMap additionalParameters, final ReadableMap serviceConfiguration, @@ -208,13 +210,15 @@ public void authorize( final Promise promise ) { - - final String scopesString = this.arrayToString(scopes); final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests); final AppAuthConfiguration appAuthConfiguration = this.createAppAuthConfiguration(builder); final HashMap additionalParametersMap = MapUtils.readableMapToHashMap(additionalParameters); + if (clientSecret != null) { + additionalParametersMap.put("client_secret", clientSecret); + } + // store args in private fields for later use in onActivityResult handler this.promise = promise; this.dangerouslyAllowInsecureHttpRequests = dangerouslyAllowInsecureHttpRequests; @@ -294,6 +298,7 @@ public void refresh( String issuer, final String redirectUrl, final String clientId, + final String clientSecret, final String refreshToken, final ReadableArray scopes, final ReadableMap additionalParameters, @@ -308,6 +313,10 @@ public void refresh( final AppAuthConfiguration appAuthConfiguration = createAppAuthConfiguration(builder); final HashMap additionalParametersMap = MapUtils.readableMapToHashMap(additionalParameters); + if (clientSecret != null) { + additionalParametersMap.put("client_secret", clientSecret); + } + // store setting in private field for later use in onActivityResult handler this.dangerouslyAllowInsecureHttpRequests = dangerouslyAllowInsecureHttpRequests; this.additionalParametersMap = additionalParametersMap; From feb40c6c9ec22c0c8f3238a1b297f0028e98cf46 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 18:54:43 +0000 Subject: [PATCH 07/22] Update tests --- index.spec.js | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/index.spec.js b/index.spec.js index 9cd7f840..628c8b0a 100644 --- a/index.spec.js +++ b/index.spec.js @@ -28,7 +28,9 @@ describe('AppAuth', () => { issuer: 'test-issuer', redirectUrl: 'test-redirectUrl', clientId: 'test-clientId', + clientSecret: 'test-clientSecret', additionalParameters: { hello: 'world' }, + serviceConfiguration: null, scopes: ['my-scope'], }; @@ -74,8 +76,10 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, config.scopes, - config.additionalParameters + config.additionalParameters, + config.serviceConfiguration ); }); @@ -94,8 +98,10 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, config.scopes, config.additionalParameters, + config.serviceConfiguration, false ); }); @@ -106,8 +112,10 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, config.scopes, config.additionalParameters, + config.serviceConfiguration, false ); }); @@ -118,8 +126,10 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, config.scopes, config.additionalParameters, + config.serviceConfiguration, true ); }); @@ -174,9 +184,11 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, 'such-token', config.scopes, - config.additionalParameters + config.additionalParameters, + config.serviceConfiguration ); }); @@ -195,9 +207,11 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, 'such-token', config.scopes, config.additionalParameters, + config.serviceConfiguration, false ); }); @@ -211,9 +225,11 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, 'such-token', config.scopes, config.additionalParameters, + config.serviceConfiguration, false ); }); @@ -227,9 +243,11 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, 'such-token', config.scopes, config.additionalParameters, + config.serviceConfiguration, true ); }); From b60855b2bc2c6361571e9dedd8d85da5e312e1c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 19:04:44 +0000 Subject: [PATCH 08/22] Add typescript definitions --- index.d.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/index.d.ts b/index.d.ts index 57756774..2c0fd6d4 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,8 +1,17 @@ +export interface ServiceConfiguration { + authorizationEndpoint: string; + tokenEndpoint: string; + revocationEndpoint?: string; +} + export interface BaseAuthConfiguration { - issuer: string; clientId: string; + issuer?: string; + serviceConfiguration?: ServiceConfiguration; } + export interface AuthConfiguration extends BaseAuthConfiguration { + clientSecret?: string; scopes: string[]; redirectUrl: string; additionalParameters?: { [name: string]: string }; From 547b0deb09702d420e075e23890f156f7ae217e6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 19:27:25 +0000 Subject: [PATCH 09/22] Update readme --- README.md | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bf13ea89..49ad8baa 100644 --- a/README.md +++ b/README.md @@ -72,14 +72,19 @@ const result = await authorize(config); This is your configuration object for the client. The config is passed into each of the methods with optional overrides. -* **issuer** - (`string`) _REQUIRED_ the url of the auth server +* **issuer** - (`string`) base URI of the authentication server. If no `serviceConfiguration` (below) is not provided, issuer is a mandatory field, so that the configuration can be fetched from the issuer's [OIDC discovery endpoint](https://openid.net/specs/openid-connect-discovery-1_0.html). +* **serviceConfiguration** - (`object`) you may manually configure token exchange endpoints in cases where the issuer does not support the OIDC discovery protocol, or simply to avoid an additional round trip to fetch the configuration. If no `issuer` (above) is provided, the service configuration is mandatory. + * **authorizationEndpoint** - (`string`) _REQUIRED_ fully formed url to the OAuth authorization endpoint + * **tokenEndpoint** - (`string`) _REQUIRED_ fully formed url to the OAuth token exchange endpoint + * **revocationEndpoint** - (`string`) fully formed url to the OAuth token revocation endpoint. If you want to be able to revoke a token and no `issuer` is specified, this field is mandatory. * **clientId** - (`string`) _REQUIRED_ your client id on the auth server +* **clientSecret** - (`string`) client secret to pass to token exchange requests. :warning: Read more about [client secrets](#note-about-client-secrets) * **redirectUrl** - (`string`) _REQUIRED_ the url that links back to your app with the auth code * **scopes** - (`array`) _REQUIRED_ the scopes for your token, e.g. `['email', 'offline_access']` * **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. +* **dangerouslyAllowInsecureHttpRequests** - (`boolean`) _ANDROID_ whether to allow requests over plain HTTP or with self-signed SSL certificates. :warning: 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 @@ -353,6 +358,14 @@ try { See example configurations for different providers below. +### Note about client secrets + +Some authentication providers, including examples cited below, require you to provide a client secret. The authors of the AppAuth library + +> [strongly recommend](https://github.com/openid/AppAuth-Android#utilizing-client-secrets-dangerous) you avoid using static client secrets in your native applications whenever possible. Client secrets derived via a dynamic client registration are safe to use, but static client secrets can be easily extracted from your apps and allow others to impersonate your app and steal user data. If client secrets must be used by the OAuth2 provider you are integrating with, we strongly recommend performing the code exchange step on your backend, where the client secret can be kept hidden. + +Having said this, in some cases using client secrets is unavoidable. In these cases, a `clientSecret` parameter can be provided to `authorize`/`refresh` calls when performing a token request. + ### Identity Server 4 This library supports authenticating for Identity Server 4 out of the box. Some quirks: From 4003e4f0a95cda1fb509feb5a028ebaf73a46298 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 19:50:49 +0000 Subject: [PATCH 10/22] Add support for registrationEndpoint, refactor --- README.md | 1 + index.d.ts | 17 ++++++++++++----- ios/RNAppAuth.m | 38 +++++++++++++++++++++----------------- 3 files changed, 34 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 49ad8baa..86823009 100644 --- a/README.md +++ b/README.md @@ -77,6 +77,7 @@ with optional overrides. * **authorizationEndpoint** - (`string`) _REQUIRED_ fully formed url to the OAuth authorization endpoint * **tokenEndpoint** - (`string`) _REQUIRED_ fully formed url to the OAuth token exchange endpoint * **revocationEndpoint** - (`string`) fully formed url to the OAuth token revocation endpoint. If you want to be able to revoke a token and no `issuer` is specified, this field is mandatory. + * **registrationEndpoint** - (`string`) fully formed url to your OAuth/OpenID Connect registration endpoint. Only necessary for servers that require client registration. * **clientId** - (`string`) _REQUIRED_ your client id on the auth server * **clientSecret** - (`string`) client secret to pass to token exchange requests. :warning: Read more about [client secrets](#note-about-client-secrets) * **redirectUrl** - (`string`) _REQUIRED_ the url that links back to your app with the auth code diff --git a/index.d.ts b/index.d.ts index 2c0fd6d4..4eab0acf 100644 --- a/index.d.ts +++ b/index.d.ts @@ -2,13 +2,20 @@ export interface ServiceConfiguration { authorizationEndpoint: string; tokenEndpoint: string; revocationEndpoint?: string; + registrationEndpoint?: string; } -export interface BaseAuthConfiguration { - clientId: string; - issuer?: string; - serviceConfiguration?: ServiceConfiguration; -} +export type BaseAuthConfiguration = + | { + clientId: string; + issuer?: string; + serviceConfiguration: ServiceConfiguration; + } + | { + clientId: string; + issuer: string; + serviceConfiguration?: ServiceConfiguration; + }; export interface AuthConfiguration extends BaseAuthConfiguration { clientSecret?: string; diff --git a/ios/RNAppAuth.m b/ios/RNAppAuth.m index 1665e3f0..6cdd34ef 100644 --- a/ios/RNAppAuth.m +++ b/ios/RNAppAuth.m @@ -27,14 +27,7 @@ - (dispatch_queue_t)methodQueue { // if we have manually provided configuration, we can use it and skip the OIDC well-known discovery endpoint call if (serviceConfiguration) { - NSURL *authorizationEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"authorizationEndpoint"]]; - NSURL *tokenEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"tokenEndpoint"]]; - - OIDServiceConfiguration *configuration = - [[OIDServiceConfiguration alloc] - initWithAuthorizationEndpoint:authorizationEndpoint - tokenEndpoint:tokenEndpoint]; - + OIDServiceConfiguration *configuration = [self createServiceConfiguration:serviceConfiguration]; [self authorizeWithConfiguration: configuration redirectUrl: redirectUrl clientId: clientId @@ -51,7 +44,7 @@ - (dispatch_queue_t)methodQueue reject(@"RNAppAuth Error", [error localizedDescription], error); return; } - + [self authorizeWithConfiguration: configuration redirectUrl: redirectUrl clientId: clientId @@ -78,14 +71,7 @@ - (dispatch_queue_t)methodQueue { // if we have manually provided configuration, we can use it and skip the OIDC well-known discovery endpoint call if (serviceConfiguration) { - NSURL *authorizationEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"authorizationEndpoint"]]; - NSURL *tokenEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"tokenEndpoint"]]; - - OIDServiceConfiguration *configuration = - [[OIDServiceConfiguration alloc] - initWithAuthorizationEndpoint:authorizationEndpoint - tokenEndpoint:tokenEndpoint]; - + OIDServiceConfiguration *configuration = [self createServiceConfiguration:serviceConfiguration]; [self refreshWithConfiguration: configuration redirectUrl: redirectUrl clientId: clientId @@ -117,6 +103,24 @@ - (dispatch_queue_t)methodQueue } } // end RCT_REMAP_METHOD(refresh, + +/* + * Create a OIDServiceConfiguration from passed serviceConfiguration dictionary + */ +- (OIDServiceConfiguration *) createServiceConfiguration: (NSDictionary *) serviceConfiguration { + NSURL *authorizationEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"authorizationEndpoint"]]; + NSURL *tokenEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"tokenEndpoint"]]; + NSURL *registrationEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"registrationEndpoint"]]; + + OIDServiceConfiguration *configuration = + [[OIDServiceConfiguration alloc] + initWithAuthorizationEndpoint:authorizationEndpoint + tokenEndpoint:tokenEndpoint + registrationEndpoint:registrationEndpoint]; + + return configuration; +} + /* * Authorize a user in exchange for a token with provided OIDServiceConfiguration */ From d4d205a1767961419b52b2aac69dd0a6db45c8b2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 20:03:47 +0000 Subject: [PATCH 11/22] Refactor and clean up --- .../com/reactlibrary/RNAppAuthModule.java | 427 +++++++++--------- 1 file changed, 207 insertions(+), 220 deletions(-) diff --git a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java index 20bb386e..e3fbba16 100644 --- a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java +++ b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java @@ -4,7 +4,6 @@ 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; @@ -15,7 +14,6 @@ import com.facebook.react.bridge.Promise; import com.facebook.react.bridge.ReadableArray; import com.facebook.react.bridge.ReadableMap; -import com.facebook.react.bridge.ReadableMapKeySetIterator; import com.facebook.react.bridge.WritableMap; import com.reactlibrary.utils.MapUtils; import com.reactlibrary.utils.UnsafeConnectionBuilder; @@ -26,26 +24,17 @@ 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.Collection; import java.util.Date; import java.util.HashMap; import java.util.Iterator; import java.util.Map; -import java.util.Set; -import java.util.concurrent.TimeUnit; public class RNAppAuthModule extends ReactContextBaseJavaModule implements ActivityEventListener { @@ -53,7 +42,6 @@ public class RNAppAuthModule extends ReactContextBaseJavaModule implements Activ private Promise promise; private Boolean dangerouslyAllowInsecureHttpRequests; private Map additionalParametersMap; - private String clientSecret; public RNAppAuthModule(ReactApplicationContext reactContext) { super(reactContext); @@ -61,142 +49,6 @@ public RNAppAuthModule(ReactApplicationContext reactContext) { reactContext.addActivityEventListener(this); } - - private String arrayToString(ReadableArray array) { - StringBuilder strBuilder = new StringBuilder(); - for (int i = 0; i < array.size(); i++) { - if (i != 0) { - strBuilder.append(' '); - } - strBuilder.append(array.getString(i)); - } - return strBuilder.toString(); - } - - private WritableMap tokenResponseToMap(TokenResponse response) { - - Date expirationDate = new Date(response.accessTokenExpirationTime); - SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ"); - String expirationDateString = formatter.format(expirationDate); - WritableMap additionalParametersMap = Arguments.createMap(); - - if (!response.additionalParameters.isEmpty()) { - - Iterator iterator = response.additionalParameters.keySet().iterator(); - - while(iterator.hasNext()) { - String key = iterator.next(); - additionalParametersMap.putString(key, response.additionalParameters.get(key)); - } - } - - WritableMap map = Arguments.createMap(); - map.putString("accessToken", response.accessToken); - map.putString("accessTokenExpirationDate", expirationDateString); - map.putMap("additionalParameters", additionalParametersMap); - map.putString("idToken", response.idToken); - map.putString("refreshToken", response.refreshToken); - map.putString("tokenType", response.tokenType); - - return map; - } - - - 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(); - } - - private void authorizeWithConfiguration( - final AuthorizationServiceConfiguration serviceConfiguration, - final AppAuthConfiguration appAuthConfiguration, - final String clientId, - final String scopesString, - final String redirectUrl, - final Map additionalParametersMap - ) { - - final Context context = this.reactContext; - final Activity currentActivity = getCurrentActivity(); - - AuthorizationRequest.Builder authRequestBuilder = - new AuthorizationRequest.Builder( - serviceConfiguration, - clientId, - ResponseTypeValues.CODE, - Uri.parse(redirectUrl) - ) - .setScope(scopesString); - - if (additionalParametersMap != null) { - authRequestBuilder.setAdditionalParameters(additionalParametersMap); - } - - AuthorizationRequest authRequest = authRequestBuilder.build(); - AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); - Intent authIntent = authService.getAuthorizationRequestIntent(authRequest); - currentActivity.startActivityForResult(authIntent, 0); - } - - private void refreshWithConfiguration( - final AuthorizationServiceConfiguration serviceConfiguration, - final AppAuthConfiguration appAuthConfiguration, - final String refreshToken, - final String clientId, - final String scopesString, - final String redirectUrl, - final Map additionalParametersMap, - final Promise promise - ) { - - final Context context = this.reactContext; - - TokenRequest.Builder tokenRequestBuilder = - new TokenRequest.Builder( - serviceConfiguration, - clientId - ) - .setScope(scopesString) - .setRefreshToken(refreshToken) - .setRedirectUri(Uri.parse(redirectUrl)); - - if (!additionalParametersMap.isEmpty()) { - tokenRequestBuilder.setAdditionalParameters(additionalParametersMap); - } - - TokenRequest tokenRequest = tokenRequestBuilder.build(); - - AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); - authService.performTokenRequest(tokenRequest, new AuthorizationService.TokenResponseCallback() { - @Override - public void onTokenRequestCompleted(@Nullable TokenResponse response, @Nullable AuthorizationException ex) { - if (response != null) { - WritableMap map = tokenResponseToMap(response); - promise.resolve(map); - } else { - promise.reject("RNAppAuth Error", "Failed refresh token"); - } - } - }); - } - @ReactMethod public void authorize( String issuer, @@ -226,41 +78,18 @@ public void authorize( // when serviceConfiguration is provided, we don't need to hit up the OpenID well-known id endpoint if (serviceConfiguration != null) { - - // @TODO Refactor validation - if (!serviceConfiguration.hasKey("authorizationEndpoint")) { - promise.reject("RNAppAuth Error", "serviceConfiguration passed without an authorizationEndpoint"); - return; - } - - if (!serviceConfiguration.hasKey("tokenEndpoint")) { - promise.reject("RNAppAuth Error", "serviceConfiguration passed without a tokenEndpoint"); - return; - } - - Uri authorizationEndpoint = Uri.parse(serviceConfiguration.getString("authorizationEndpoint")); - Uri tokenEndpoint = Uri.parse(serviceConfiguration.getString("tokenEndpoint")); - Uri registrationEndpoint = null; - - if (serviceConfiguration.hasKey("registrationEndpoint")) { - registrationEndpoint = Uri.parse(serviceConfiguration.getString("registrationEndPoint")); + try { + authorizeWithConfiguration( + createAuthorizationServiceConfiguration(serviceConfiguration), + appAuthConfiguration, + clientId, + scopesString, + redirectUrl, + additionalParametersMap + ); + } catch (Exception e) { + promise.reject(e); } - - - AuthorizationServiceConfiguration authorizationServiceConfiguration = new AuthorizationServiceConfiguration( - authorizationEndpoint, - tokenEndpoint, - registrationEndpoint - ); - - authorizeWithConfiguration( - authorizationServiceConfiguration, - appAuthConfiguration, - clientId, - scopesString, - redirectUrl, - additionalParametersMap - ); } else { final Uri issuerUri = Uri.parse(issuer); AuthorizationServiceConfiguration.fetchFromUrl( @@ -323,46 +152,22 @@ public void refresh( // when serviceConfiguration is provided, we don't need to hit up the OpenID well-known id endpoint if (serviceConfiguration != null) { - - // @TODO Refactor validation - if (!serviceConfiguration.hasKey("authorizationEndpoint")) { - promise.reject("RNAppAuth Error", "serviceConfiguration passed without an authorizationEndpoint"); - return; - } - - if (!serviceConfiguration.hasKey("tokenEndpoint")) { - promise.reject("RNAppAuth Error", "serviceConfiguration passed without a tokenEndpoint"); - return; - } - - Uri authorizationEndpoint = Uri.parse(serviceConfiguration.getString("authorizationEndpoint")); - Uri tokenEndpoint = Uri.parse(serviceConfiguration.getString("tokenEndpoint")); - Uri registrationEndpoint = null; - - if (serviceConfiguration.hasKey("registrationEndpoint")) { - registrationEndpoint = Uri.parse(serviceConfiguration.getString("registrationEndPoint")); + try { + refreshWithConfiguration( + createAuthorizationServiceConfiguration(serviceConfiguration), + appAuthConfiguration, + refreshToken, + clientId, + scopesString, + redirectUrl, + additionalParametersMap, + promise + ); + } catch (Exception e) { + promise.reject(e); } - - - AuthorizationServiceConfiguration authorizationServiceConfiguration = new AuthorizationServiceConfiguration( - authorizationEndpoint, - tokenEndpoint, - registrationEndpoint - ); - - refreshWithConfiguration( - authorizationServiceConfiguration, - appAuthConfiguration, - refreshToken, - clientId, - scopesString, - redirectUrl, - additionalParametersMap, - promise - ); } else { - // @TODO: Refactor to avoid hitting IDP endpoint on refresh, reuse fetchedConfiguration - // if possible. + // @TODO: Refactor to avoid hitting IDP endpoint on refresh, reuse fetchedConfiguration if possible. AuthorizationServiceConfiguration.fetchFromUrl( buildConfigurationUriFromIssuer(issuerUri), new AuthorizationServiceConfiguration.RetrieveConfigurationCallback() { @@ -391,6 +196,9 @@ public void onFetchConfigurationCompleted( } + /* + * Called when the OAuth browser activity completes + */ @Override public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { if (requestCode == 0) { @@ -428,6 +236,185 @@ public void onTokenRequestCompleted( } } + /* + * Authorize user with the provided configuration + */ + private void authorizeWithConfiguration( + final AuthorizationServiceConfiguration serviceConfiguration, + final AppAuthConfiguration appAuthConfiguration, + final String clientId, + final String scopesString, + final String redirectUrl, + final Map additionalParametersMap + ) { + + final Context context = this.reactContext; + final Activity currentActivity = getCurrentActivity(); + + AuthorizationRequest.Builder authRequestBuilder = + new AuthorizationRequest.Builder( + serviceConfiguration, + clientId, + ResponseTypeValues.CODE, + Uri.parse(redirectUrl) + ) + .setScope(scopesString); + + if (additionalParametersMap != null) { + authRequestBuilder.setAdditionalParameters(additionalParametersMap); + } + + AuthorizationRequest authRequest = authRequestBuilder.build(); + AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); + Intent authIntent = authService.getAuthorizationRequestIntent(authRequest); + currentActivity.startActivityForResult(authIntent, 0); + } + + /* + * Refresh authentication token with the provided configuration + */ + private void refreshWithConfiguration( + final AuthorizationServiceConfiguration serviceConfiguration, + final AppAuthConfiguration appAuthConfiguration, + final String refreshToken, + final String clientId, + final String scopesString, + final String redirectUrl, + final Map additionalParametersMap, + final Promise promise + ) { + + final Context context = this.reactContext; + + TokenRequest.Builder tokenRequestBuilder = + new TokenRequest.Builder( + serviceConfiguration, + clientId + ) + .setScope(scopesString) + .setRefreshToken(refreshToken) + .setRedirectUri(Uri.parse(redirectUrl)); + + if (!additionalParametersMap.isEmpty()) { + tokenRequestBuilder.setAdditionalParameters(additionalParametersMap); + } + + TokenRequest tokenRequest = tokenRequestBuilder.build(); + + AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); + authService.performTokenRequest(tokenRequest, new AuthorizationService.TokenResponseCallback() { + @Override + public void onTokenRequestCompleted(@Nullable TokenResponse response, @Nullable AuthorizationException ex) { + if (response != null) { + WritableMap map = tokenResponseToMap(response); + promise.resolve(map); + } else { + promise.reject("RNAppAuth Error", "Failed refresh token"); + } + } + }); + } + + /* + * Create a space-delimited string from an array + */ + private String arrayToString(ReadableArray array) { + StringBuilder strBuilder = new StringBuilder(); + for (int i = 0; i < array.size(); i++) { + if (i != 0) { + strBuilder.append(' '); + } + strBuilder.append(array.getString(i)); + } + return strBuilder.toString(); + } + + /* + * Read raw token response into a React Native map to be passed down the bridge + */ + private WritableMap tokenResponseToMap(TokenResponse response) { + + Date expirationDate = new Date(response.accessTokenExpirationTime); + SimpleDateFormat formatter = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ"); + String expirationDateString = formatter.format(expirationDate); + WritableMap additionalParametersMap = Arguments.createMap(); + + if (!response.additionalParameters.isEmpty()) { + + Iterator iterator = response.additionalParameters.keySet().iterator(); + + while(iterator.hasNext()) { + String key = iterator.next(); + additionalParametersMap.putString(key, response.additionalParameters.get(key)); + } + } + + WritableMap map = Arguments.createMap(); + map.putString("accessToken", response.accessToken); + map.putString("accessTokenExpirationDate", expirationDateString); + map.putMap("additionalParameters", additionalParametersMap); + map.putString("idToken", response.idToken); + map.putString("refreshToken", response.refreshToken); + map.putString("tokenType", response.tokenType); + + return map; + } + + /* + * Create an App Auth configuration using the provided connection builder + */ + private AppAuthConfiguration createAppAuthConfiguration(ConnectionBuilder connectionBuilder) { + return new AppAuthConfiguration + .Builder() + .setConnectionBuilder(connectionBuilder) + .build(); + } + + /* + * Create appropriate connection builder based on provided settings + */ + private ConnectionBuilder createConnectionBuilder(Boolean allowInsecureConnections) { + if (allowInsecureConnections.equals(true)) { + return UnsafeConnectionBuilder.INSTANCE; + } + + return DefaultConnectionBuilder.INSTANCE; + } + + /* + * Replicated private method from AuthorizationServiceConfiguration + */ + private Uri buildConfigurationUriFromIssuer(Uri openIdConnectIssuerUri) { + return openIdConnectIssuerUri.buildUpon() + .appendPath(AuthorizationServiceConfiguration.WELL_KNOWN_PATH) + .appendPath(AuthorizationServiceConfiguration.OPENID_CONFIGURATION_RESOURCE) + .build(); + } + + private AuthorizationServiceConfiguration createAuthorizationServiceConfiguration(ReadableMap serviceConfiguration) throws Exception { + if (!serviceConfiguration.hasKey("authorizationEndpoint")) { + throw new Exception("serviceConfiguration passed without an authorizationEndpoint"); + } + + if (!serviceConfiguration.hasKey("tokenEndpoint")) { + throw new Exception("serviceConfiguration passed without a tokenEndpoint"); + } + + Uri authorizationEndpoint = Uri.parse(serviceConfiguration.getString("authorizationEndpoint")); + Uri tokenEndpoint = Uri.parse(serviceConfiguration.getString("tokenEndpoint")); + Uri registrationEndpoint = null; + if (serviceConfiguration.hasKey("registrationEndpoint")) { + registrationEndpoint = Uri.parse(serviceConfiguration.getString("registrationEndPoint")); + } + + return new AuthorizationServiceConfiguration( + authorizationEndpoint, + tokenEndpoint, + registrationEndpoint + ); + } + + @Override public void onNewIntent(Intent intent) { From 80f55a6138d30ee01fd10f822efb66ca942e5f9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 20:10:38 +0000 Subject: [PATCH 12/22] Fix discovered revocation endpoint url --- index.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/index.js b/index.js index e9d2fa12..91c15dab 100644 --- a/index.js +++ b/index.js @@ -11,7 +11,7 @@ const validateIssuerOrServiceConfigurationRevocationEndpoint = (issuer, serviceC invariant( typeof issuer === 'string' || (serviceConfiguration && typeof serviceConfiguration.revocationEndPoint === 'string'), - 'Config error: issuer must be a string' + 'Config error: you must provide either an issue or a revocation endpoint' ); const validateClientId = clientId => invariant(typeof clientId === 'string', 'Config error: clientId must be a string'); @@ -108,7 +108,7 @@ export const revoke = async ( 'The openid config does not specify a revocation endpoint' ); - revocationEndpoint = openidConfig.revocationEndpoint; + revocationEndpoint = openidConfig.revocation_endpoint; } /** From 6bfa668b140a01cc8f74fed15a1abd619aaaf4f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 20:18:13 +0000 Subject: [PATCH 13/22] Fix revocation endpoint validation --- index.js | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index 91c15dab..a858a6f4 100644 --- a/index.js +++ b/index.js @@ -5,12 +5,18 @@ const { RNAppAuth } = NativeModules; const validateScopes = scopes => invariant(scopes && scopes.length, 'Scope error: please add at least one scope'); -const validateIssuer = issuer => - invariant(typeof issuer === 'string', 'Config error: issuer must be a string'); +const validateIssuerOrServiceConfigurationEndpoints = (issuer, serviceConfiguration) => + invariant( + typeof issuer === 'string' || + (serviceConfiguration && + typeof serviceConfiguration.authorizationEndpoint === 'string' && + typeof serviceConfiguration.tokenEndpoint === 'string'), + 'Config error: you must provide either an issue or a revocation endpoint' + ); const validateIssuerOrServiceConfigurationRevocationEndpoint = (issuer, serviceConfiguration) => invariant( typeof issuer === 'string' || - (serviceConfiguration && typeof serviceConfiguration.revocationEndPoint === 'string'), + (serviceConfiguration && typeof serviceConfiguration.revocationEndpoint === 'string'), 'Config error: you must provide either an issue or a revocation endpoint' ); const validateClientId = clientId => @@ -29,7 +35,7 @@ export const authorize = ({ dangerouslyAllowInsecureHttpRequests = false, }) => { validateScopes(scopes); - validateIssuer(issuer); + validateIssuerOrServiceConfigurationEndpoints(issuer, serviceConfiguration); validateClientId(clientId); validateRedirectUrl(redirectUrl); // TODO: validateAdditionalParameters @@ -64,7 +70,7 @@ export const refresh = ( { refreshToken } ) => { validateScopes(scopes); - validateIssuer(issuer); + validateIssuerOrServiceConfigurationEndpoints(issuer, serviceConfiguration); validateClientId(clientId); validateRedirectUrl(redirectUrl); invariant(refreshToken, 'Please pass in a refresh token'); From 24f36f8ca45a180e271dd1c43495f9fcf06b8a12 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 20:23:55 +0000 Subject: [PATCH 14/22] Example of using serviceConfiguration + clientSecret with Uber --- README.md | 36 ++++++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/README.md b/README.md index 86823009..dfa7fa96 100644 --- a/README.md +++ b/README.md @@ -481,6 +481,42 @@ const refreshedState = await refresh(config, { }); ``` +### Uber + +Uber provides an OAuth 2.0 endpoint for logging in with a Uber user's credentiala. You'll need to first [create an Uber OAuth application here](https://developer.uber.com/docs/riders/guides/authentication/introduction). + +Please note: + +* Uber does not provide a OIDC discovery endpoint, so `serviceConfiguration` is used instead. +* Uber OAuth requires a [client secret](#note-about-client-secrets). + +```js +const config = { + clientId: 'your-client-id-generated-by-uber', + clientSecret: 'your-client-id-generated-by-uber', + redirectUrl: 'com.whatever.url.you.configured.in.uber.oauth://redirect', //note: path is required + scopes: ['profile', 'delivery'], // whatever scopes you configured in Uber OAuth portal + serviceConfiguration: { + authorizationEndpoint: 'https://login.uber.com/oauth/v2/authorize', + tokenEndpoint: 'https://login.uber.com/oauth/v2/token', + revocationEndpoint: 'https://login.uber.com/oauth/v2/revoke' + } +}; + +// Log in to get an authentication token +const authState = await authorize(config); + +// Refresh token +const refreshedState = await refresh(config, { + refreshToken: authState.refreshToken, +}); + +// Revoke token +await revoke(config, { + tokenToRevoke: refreshedState.refreshToken +}); +``` + ## Contributors Thanks goes to these wonderful people From b2abfbdbe00b376e9442823595ec50bfdfa6f663 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 20:28:50 +0000 Subject: [PATCH 15/22] Update tests for error messages --- index.js | 2 +- index.spec.js | 48 ++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/index.js b/index.js index a858a6f4..f0159cb5 100644 --- a/index.js +++ b/index.js @@ -11,7 +11,7 @@ const validateIssuerOrServiceConfigurationEndpoints = (issuer, serviceConfigurat (serviceConfiguration && typeof serviceConfiguration.authorizationEndpoint === 'string' && typeof serviceConfiguration.tokenEndpoint === 'string'), - 'Config error: you must provide either an issue or a revocation endpoint' + 'Config error: you must provide either an issue or a service endpoints' ); const validateIssuerOrServiceConfigurationRevocationEndpoint = (issuer, serviceConfiguration) => invariant( diff --git a/index.spec.js b/index.spec.js index 628c8b0a..39ffde9c 100644 --- a/index.spec.js +++ b/index.spec.js @@ -40,10 +40,30 @@ describe('AppAuth', () => { mockRefresh.mockReset(); }); - it('throws an error when issuer is not a string', () => { + it('throws an error when issuer is not a string and serviceConfiguration is not passed', () => { expect(() => { authorize({ ...config, issuer: () => ({}) }); - }).toThrow('Config error: issuer must be a string'); + }).toThrow('Config error: you must provide either an issue or a service endpoints'); + }); + + it('throws an error when serviceConfiguration does not have tokenEndpoint and issuer is not passed', () => { + expect(() => { + authorize({ + ...config, + issuer: undefined, + serviceConfiguration: { authorizationEndpoint: '' }, + }); + }).toThrow('Config error: you must provide either an issue or a service endpoints'); + }); + + it('throws an error when serviceConfiguration does not have tokenEndpoint and issuer is not passed', () => { + expect(() => { + authorize({ + ...config, + issuer: undefined, + serviceConfiguration: { authorizationEndpoint: '' }, + }); + }).toThrow('Config error: you must provide either an issue or a service endpoints'); }); it('throws an error when redirectUrl is not a string', () => { @@ -142,10 +162,30 @@ describe('AppAuth', () => { mockRefresh.mockReset(); }); - it('throws an error when issuer is not a string', () => { + it('throws an error when issuer is not a string and serviceConfiguration is not passed', () => { expect(() => { authorize({ ...config, issuer: () => ({}) }); - }).toThrow('Config error: issuer must be a string'); + }).toThrow('Config error: you must provide either an issue or a service endpoints'); + }); + + it('throws an error when serviceConfiguration does not have tokenEndpoint and issuer is not passed', () => { + expect(() => { + authorize({ + ...config, + issuer: undefined, + serviceConfiguration: { authorizationEndpoint: '' }, + }); + }).toThrow('Config error: you must provide either an issue or a service endpoints'); + }); + + it('throws an error when serviceConfiguration does not have tokenEndpoint and issuer is not passed', () => { + expect(() => { + authorize({ + ...config, + issuer: undefined, + serviceConfiguration: { authorizationEndpoint: '' }, + }); + }).toThrow('Config error: you must provide either an issue or a service endpoints'); }); it('throws an error when redirectUrl is not a string', () => { From 04c58f035b970e7df0bfa544b3712e7e05c22ded Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Wed, 21 Feb 2018 20:34:35 +0000 Subject: [PATCH 16/22] Comment out (but intentionally leave in) manual serviceConfiguration example for easy testing --- Example/App.js | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Example/App.js b/Example/App.js index 2c4ac16a..8e86b17f 100644 --- a/Example/App.js +++ b/Example/App.js @@ -18,11 +18,13 @@ const config = { clientId: 'native.code', redirectUrl: 'io.identityserver.demo:/oauthredirect', additionalParameters: {}, - scopes: ['openid', 'profile', 'email', 'offline_access'], - serviceConfiguration: { - authorizationEndpoint: 'https://demo.identityserver.io/connect/authorize', - tokenEndpoint: 'https://demo.identityserver.io/connect/token' - } + scopes: ['openid', 'profile', 'email', 'offline_access'] + + // serviceConfiguration: { + // authorizationEndpoint: 'https://demo.identityserver.io/connect/authorize', + // tokenEndpoint: 'https://demo.identityserver.io/connect/token', + // revocationEndpoint: 'https://demo.identityserver.io/connect/revoke' + // } }; export default class App extends Component<{}, State> { From 8587380a2c74e54c9068ec4d2af55b664b3ed0ce Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Thu, 22 Feb 2018 10:05:13 +0000 Subject: [PATCH 17/22] Fix typo in readme --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index dfa7fa96..20b65d6c 100644 --- a/README.md +++ b/README.md @@ -493,7 +493,7 @@ Please note: ```js const config = { clientId: 'your-client-id-generated-by-uber', - clientSecret: 'your-client-id-generated-by-uber', + clientSecret: 'your-client-secret-generated-by-uber', redirectUrl: 'com.whatever.url.you.configured.in.uber.oauth://redirect', //note: path is required scopes: ['profile', 'delivery'], // whatever scopes you configured in Uber OAuth portal serviceConfiguration: { From bd7dfbb0395c876893681cfbf72d842101db9ac4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Thu, 22 Feb 2018 11:04:40 +0000 Subject: [PATCH 18/22] Better error messages --- .../src/main/java/com/reactlibrary/RNAppAuthModule.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java index e3fbba16..411647e9 100644 --- a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java +++ b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java @@ -88,7 +88,7 @@ public void authorize( additionalParametersMap ); } catch (Exception e) { - promise.reject(e); + promise.reject("RNAppAuth Error", "Failed to authenticate", e); } } else { final Uri issuerUri = Uri.parse(issuer); @@ -135,9 +135,7 @@ public void refresh( 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 appAuthConfiguration = createAppAuthConfiguration(builder); final HashMap additionalParametersMap = MapUtils.readableMapToHashMap(additionalParameters); @@ -164,9 +162,10 @@ public void refresh( promise ); } catch (Exception e) { - promise.reject(e); + promise.reject("RNAppAuth Error", "Failed to refresh token", e); } } else { + final Uri issuerUri = Uri.parse(issuer); // @TODO: Refactor to avoid hitting IDP endpoint on refresh, reuse fetchedConfiguration if possible. AuthorizationServiceConfiguration.fetchFromUrl( buildConfigurationUriFromIssuer(issuerUri), From e49bd87ba8933c564d85dd5385015893e91eb484 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Thu, 22 Feb 2018 11:04:55 +0000 Subject: [PATCH 19/22] Remove autogenerated comments --- android/src/main/java/com/reactlibrary/utils/MapUtils.java | 4 ---- 1 file changed, 4 deletions(-) diff --git a/android/src/main/java/com/reactlibrary/utils/MapUtils.java b/android/src/main/java/com/reactlibrary/utils/MapUtils.java index 27901c57..6f1f463c 100644 --- a/android/src/main/java/com/reactlibrary/utils/MapUtils.java +++ b/android/src/main/java/com/reactlibrary/utils/MapUtils.java @@ -7,10 +7,6 @@ import java.util.HashMap; -/** - * Created by formidable on 21/02/2018. - */ - public class MapUtils { public static HashMap readableMapToHashMap(@Nullable ReadableMap readableMap) { From 5239f90612fb1fe1c9a52de6ebc7fa8ca2deb8d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Thu, 22 Feb 2018 11:05:06 +0000 Subject: [PATCH 20/22] Fix typo in error message --- index.js | 2 +- index.spec.js | 12 ++++++------ 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/index.js b/index.js index f0159cb5..0c83aee3 100644 --- a/index.js +++ b/index.js @@ -11,7 +11,7 @@ const validateIssuerOrServiceConfigurationEndpoints = (issuer, serviceConfigurat (serviceConfiguration && typeof serviceConfiguration.authorizationEndpoint === 'string' && typeof serviceConfiguration.tokenEndpoint === 'string'), - 'Config error: you must provide either an issue or a service endpoints' + 'Config error: you must provide either an issuer or a service endpoints' ); const validateIssuerOrServiceConfigurationRevocationEndpoint = (issuer, serviceConfiguration) => invariant( diff --git a/index.spec.js b/index.spec.js index 39ffde9c..fe14e2b5 100644 --- a/index.spec.js +++ b/index.spec.js @@ -43,7 +43,7 @@ describe('AppAuth', () => { it('throws an error when issuer is not a string and serviceConfiguration is not passed', () => { expect(() => { authorize({ ...config, issuer: () => ({}) }); - }).toThrow('Config error: you must provide either an issue or a service endpoints'); + }).toThrow('Config error: you must provide either an issuer or a service endpoints'); }); it('throws an error when serviceConfiguration does not have tokenEndpoint and issuer is not passed', () => { @@ -53,7 +53,7 @@ describe('AppAuth', () => { issuer: undefined, serviceConfiguration: { authorizationEndpoint: '' }, }); - }).toThrow('Config error: you must provide either an issue or a service endpoints'); + }).toThrow('Config error: you must provide either an issuer or a service endpoints'); }); it('throws an error when serviceConfiguration does not have tokenEndpoint and issuer is not passed', () => { @@ -63,7 +63,7 @@ describe('AppAuth', () => { issuer: undefined, serviceConfiguration: { authorizationEndpoint: '' }, }); - }).toThrow('Config error: you must provide either an issue or a service endpoints'); + }).toThrow('Config error: you must provide either an issuer or a service endpoints'); }); it('throws an error when redirectUrl is not a string', () => { @@ -165,7 +165,7 @@ describe('AppAuth', () => { it('throws an error when issuer is not a string and serviceConfiguration is not passed', () => { expect(() => { authorize({ ...config, issuer: () => ({}) }); - }).toThrow('Config error: you must provide either an issue or a service endpoints'); + }).toThrow('Config error: you must provide either an issuer or a service endpoints'); }); it('throws an error when serviceConfiguration does not have tokenEndpoint and issuer is not passed', () => { @@ -175,7 +175,7 @@ describe('AppAuth', () => { issuer: undefined, serviceConfiguration: { authorizationEndpoint: '' }, }); - }).toThrow('Config error: you must provide either an issue or a service endpoints'); + }).toThrow('Config error: you must provide either an issuer or a service endpoints'); }); it('throws an error when serviceConfiguration does not have tokenEndpoint and issuer is not passed', () => { @@ -185,7 +185,7 @@ describe('AppAuth', () => { issuer: undefined, serviceConfiguration: { authorizationEndpoint: '' }, }); - }).toThrow('Config error: you must provide either an issue or a service endpoints'); + }).toThrow('Config error: you must provide either an issuer or a service endpoints'); }); it('throws an error when redirectUrl is not a string', () => { From a3775c51a29a6f69e45c5c4a34c0d94c1fc565ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Thu, 22 Feb 2018 11:05:18 +0000 Subject: [PATCH 21/22] Fix typos in README --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 20b65d6c..28e46c48 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ const result = await authorize(config); This is your configuration object for the client. The config is passed into each of the methods with optional overrides. -* **issuer** - (`string`) base URI of the authentication server. If no `serviceConfiguration` (below) is not provided, issuer is a mandatory field, so that the configuration can be fetched from the issuer's [OIDC discovery endpoint](https://openid.net/specs/openid-connect-discovery-1_0.html). +* **issuer** - (`string`) base URI of the authentication server. If no `serviceConfiguration` (below) is provided, issuer is a mandatory field, so that the configuration can be fetched from the issuer's [OIDC discovery endpoint](https://openid.net/specs/openid-connect-discovery-1_0.html). * **serviceConfiguration** - (`object`) you may manually configure token exchange endpoints in cases where the issuer does not support the OIDC discovery protocol, or simply to avoid an additional round trip to fetch the configuration. If no `issuer` (above) is provided, the service configuration is mandatory. * **authorizationEndpoint** - (`string`) _REQUIRED_ fully formed url to the OAuth authorization endpoint * **tokenEndpoint** - (`string`) _REQUIRED_ fully formed url to the OAuth token exchange endpoint @@ -483,7 +483,7 @@ const refreshedState = await refresh(config, { ### Uber -Uber provides an OAuth 2.0 endpoint for logging in with a Uber user's credentiala. You'll need to first [create an Uber OAuth application here](https://developer.uber.com/docs/riders/guides/authentication/introduction). +Uber provides an OAuth 2.0 endpoint for logging in with a Uber user's credentials. You'll need to first [create an Uber OAuth application here](https://developer.uber.com/docs/riders/guides/authentication/introduction). Please note: From 08abd472a2cf6344e9db3bf0668d96f9ad39c3fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jani=20Ev=C3=A4kallio?= Date: Thu, 22 Feb 2018 11:06:43 +0000 Subject: [PATCH 22/22] Fix typos in error messages --- index.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/index.js b/index.js index 0c83aee3..d0aa5498 100644 --- a/index.js +++ b/index.js @@ -17,7 +17,7 @@ const validateIssuerOrServiceConfigurationRevocationEndpoint = (issuer, serviceC invariant( typeof issuer === 'string' || (serviceConfiguration && typeof serviceConfiguration.revocationEndpoint === 'string'), - 'Config error: you must provide either an issue or a revocation endpoint' + 'Config error: you must provide either an issuer or a revocation endpoint' ); const validateClientId = clientId => invariant(typeof clientId === 'string', 'Config error: clientId must be a string');