diff --git a/Example/App.js b/Example/App.js index 617f6209..8e86b17f 100644 --- a/Example/App.js +++ b/Example/App.js @@ -19,6 +19,12 @@ const config = { 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', + // revocationEndpoint: 'https://demo.identityserver.io/connect/revoke' + // } }; export default class App extends Component<{}, State> { @@ -41,7 +47,7 @@ export default class App extends Component<{}, State> { authorize = async () => { try { const authState = await authorize(config); - + this.animateState( { hasLoggedInOnce: true, diff --git a/README.md b/README.md index bf13ea89..28e46c48 100644 --- a/README.md +++ b/README.md @@ -72,14 +72,20 @@ 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 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. + * **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 * **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 +359,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: @@ -467,6 +481,42 @@ const refreshedState = await refresh(config, { }); ``` +### Uber + +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: + +* 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-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: { + 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 diff --git a/android/src/main/java/com/reactlibrary/RNAppAuthModule.java b/android/src/main/java/com/reactlibrary/RNAppAuthModule.java index 771e815c..411647e9 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,8 +14,8 @@ 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; import net.openid.appauth.AppAuthConfiguration; @@ -25,29 +24,24 @@ 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; +import java.util.Map; public class RNAppAuthModule extends ReactContextBaseJavaModule implements ActivityEventListener { private final ReactApplicationContext reactContext; private Promise promise; private Boolean dangerouslyAllowInsecureHttpRequests; + private Map additionalParametersMap; public RNAppAuthModule(ReactApplicationContext reactContext) { super(reactContext); @@ -55,139 +49,76 @@ 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 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 - .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, final String redirectUrl, final String clientId, + final String clientSecret, final ReadableArray scopes, final ReadableMap additionalParameters, + final ReadableMap serviceConfiguration, final Boolean dangerouslyAllowInsecureHttpRequests, final Promise promise ) { - final Context context = this.reactContext; + 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; + this.additionalParametersMap = additionalParametersMap; + + // when serviceConfiguration is provided, we don't need to hit up the OpenID well-known id endpoint + if (serviceConfiguration != null) { + try { + authorizeWithConfiguration( + createAuthorizationServiceConfiguration(serviceConfiguration), + appAuthConfiguration, + clientId, + scopesString, + redirectUrl, + additionalParametersMap + ); + } catch (Exception e) { + promise.reject("RNAppAuth Error", "Failed to authenticate", e); + } + } 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; + } - 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.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; + authorizeWithConfiguration( + fetchedConfiguration, + appAuthConfiguration, + clientId, + scopesString, + redirectUrl, + additionalParametersMap + ); } + }, + builder + ); + } - 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, configuration); - Intent authIntent = authService.getAuthorizationRequestIntent(authRequest); - currentActivity.startActivityForResult(authIntent, 0); - } - }, - builder - ); } @@ -196,65 +127,77 @@ public void refresh( String issuer, final String redirectUrl, final String clientId, + final String clientSecret, final String refreshToken, final ReadableArray scopes, final ReadableMap additionalParameters, + final ReadableMap serviceConfiguration, 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); + 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; + + // when serviceConfiguration is provided, we don't need to hit up the OpenID well-known id endpoint + if (serviceConfiguration != null) { + try { + refreshWithConfiguration( + createAuthorizationServiceConfiguration(serviceConfiguration), + appAuthConfiguration, + refreshToken, + clientId, + scopesString, + redirectUrl, + additionalParametersMap, + promise + ); + } catch (Exception 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), + 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; + } - 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; - } - - TokenRequest.Builder tokenRequestBuilder = - new TokenRequest.Builder( - serviceConfiguration, - clientId - ) - .setScope(scopesString) - .setRefreshToken(refreshToken) - .setRedirectUri(Uri.parse(redirectUrl)); - - if (additionalParameters != null) { - tokenRequestBuilder.setAdditionalParameters(additionalParametersToMap(additionalParameters)); + refreshWithConfiguration( + fetchedConfiguration, + appAuthConfiguration, + refreshToken, + clientId, + scopesString, + redirectUrl, + additionalParametersMap, + promise + ); } + }, + builder); + } - 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"); - } - } - }); - - } - }, - builder); } + /* + * Called when the OAuth browser activity completes + */ @Override public void onActivityResult(Activity activity, int requestCode, int resultCode, Intent data) { if (requestCode == 0) { @@ -271,8 +214,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 @@ -290,6 +235,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) { 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..6f1f463c --- /dev/null +++ b/android/src/main/java/com/reactlibrary/utils/MapUtils.java @@ -0,0 +1,25 @@ +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; + +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.d.ts b/index.d.ts index 57756774..4eab0acf 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,8 +1,24 @@ -export interface BaseAuthConfiguration { - issuer: string; - clientId: string; +export interface ServiceConfiguration { + authorizationEndpoint: string; + tokenEndpoint: string; + revocationEndpoint?: string; + registrationEndpoint?: string; } + +export type BaseAuthConfiguration = + | { + clientId: string; + issuer?: string; + serviceConfiguration: ServiceConfiguration; + } + | { + clientId: string; + issuer: string; + serviceConfiguration?: ServiceConfiguration; + }; + export interface AuthConfiguration extends BaseAuthConfiguration { + clientSecret?: string; scopes: string[]; redirectUrl: string; additionalParameters?: { [name: string]: string }; diff --git a/index.js b/index.js index d7ec5a87..d0aa5498 100644 --- a/index.js +++ b/index.js @@ -5,8 +5,20 @@ 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 issuer or a service endpoints' + ); +const validateIssuerOrServiceConfigurationRevocationEndpoint = (issuer, serviceConfiguration) => + invariant( + typeof issuer === 'string' || + (serviceConfiguration && typeof serviceConfiguration.revocationEndpoint === 'string'), + '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'); const validateRedirectUrl = redirectUrl => @@ -16,17 +28,27 @@ export const authorize = ({ issuer, redirectUrl, clientId, + clientSecret, scopes, additionalParameters, + serviceConfiguration, dangerouslyAllowInsecureHttpRequests = false, }) => { validateScopes(scopes); - validateIssuer(issuer); + validateIssuerOrServiceConfigurationEndpoints(issuer, serviceConfiguration); validateClientId(clientId); validateRedirectUrl(redirectUrl); // TODO: validateAdditionalParameters - const nativeMethodArguments = [issuer, redirectUrl, clientId, scopes, additionalParameters]; + const nativeMethodArguments = [ + issuer, + redirectUrl, + clientId, + clientSecret, + scopes, + additionalParameters, + serviceConfiguration, + ]; if (Platform.OS === 'android') { nativeMethodArguments.push(dangerouslyAllowInsecureHttpRequests); } @@ -39,14 +61,16 @@ export const refresh = ( issuer, redirectUrl, clientId, + clientSecret, scopes, additionalParameters, + serviceConfiguration, dangerouslyAllowInsecureHttpRequests = false, }, { refreshToken } ) => { validateScopes(scopes); - validateIssuer(issuer); + validateIssuerOrServiceConfigurationEndpoints(issuer, serviceConfiguration); validateClientId(clientId); validateRedirectUrl(redirectUrl); invariant(refreshToken, 'Please pass in a refresh token'); @@ -56,9 +80,11 @@ export const refresh = ( issuer, redirectUrl, clientId, + clientSecret, refreshToken, scopes, additionalParameters, + serviceConfiguration, ]; if (Platform.OS === 'android') { @@ -68,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.revocation_endpoint; + } /** Identity Server insists on client_id being passed in the body, @@ -87,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', diff --git a/index.spec.js b/index.spec.js index 9cd7f840..fe14e2b5 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'], }; @@ -38,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 issuer 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 issuer 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 issuer or a service endpoints'); }); it('throws an error when redirectUrl is not a string', () => { @@ -74,8 +96,10 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, config.scopes, - config.additionalParameters + config.additionalParameters, + config.serviceConfiguration ); }); @@ -94,8 +118,10 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, config.scopes, config.additionalParameters, + config.serviceConfiguration, false ); }); @@ -106,8 +132,10 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, config.scopes, config.additionalParameters, + config.serviceConfiguration, false ); }); @@ -118,8 +146,10 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, config.scopes, config.additionalParameters, + config.serviceConfiguration, true ); }); @@ -132,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 issuer 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 issuer 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 issuer or a service endpoints'); }); it('throws an error when redirectUrl is not a string', () => { @@ -174,9 +224,11 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, 'such-token', config.scopes, - config.additionalParameters + config.additionalParameters, + config.serviceConfiguration ); }); @@ -195,9 +247,11 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, 'such-token', config.scopes, config.additionalParameters, + config.serviceConfiguration, false ); }); @@ -211,9 +265,11 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, 'such-token', config.scopes, config.additionalParameters, + config.serviceConfiguration, false ); }); @@ -227,9 +283,11 @@ describe('AppAuth', () => { config.issuer, config.redirectUrl, config.clientId, + config.clientSecret, 'such-token', config.scopes, config.additionalParameters, + config.serviceConfiguration, true ); }); diff --git a/ios/RNAppAuth.m b/ios/RNAppAuth.m index 3671798a..6cdd34ef 100644 --- a/ios/RNAppAuth.m +++ b/ios/RNAppAuth.m @@ -11,125 +11,212 @@ - (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) { + OIDServiceConfiguration *configuration = [self createServiceConfiguration:serviceConfiguration]; + [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]; + // if we have manually provided configuration, we can use it and skip the OIDC well-known discovery endpoint call + if (serviceConfiguration) { + OIDServiceConfiguration *configuration = [self createServiceConfiguration:serviceConfiguration]; + [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, - [OIDAuthorizationService performTokenRequest:tokenRefreshRequest - callback:^(OIDTokenResponse *_Nullable response, - NSError *_Nullable error) { - if (response) { +/* + * 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; +} - 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