diff --git a/.eslintrc b/.eslintrc index 49323899..445620b7 100644 --- a/.eslintrc +++ b/.eslintrc @@ -24,3 +24,8 @@ rules: prettier/prettier: - error - trailingComma: es5 + eqeqeq: + - error + - smart + max-statements: + - off diff --git a/README.md b/README.md index 9e4bb3a4..80f9599b 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ with optional overrides. * **customHeaders** - (`object`) _ANDROID_ you can specify custom headers to pass during authorize request and/or token request. * **authorize** - (`{ [key: string]: value }`) headers to be passed during authorization request. * **token** - (`{ [key: string]: value }`) headers to be passed during token retrieval request. + * **register** - (`{ [key: string]: value }`) headers to be passed during registration request. * **useNonce** - (`boolean`) _IOS_ (default: true) optionally allows not sending the nonce parameter, to support non-compliant providers * **usePKCE** - (`boolean`) (default: true) optionally allows not sending the code_challenge parameter and skipping PKCE code verification, to support non-compliant providers. @@ -179,6 +180,49 @@ const result = await revoke(config, { }); ``` + +### `register` + +This will perform [dynamic client registration](https://openid.net/specs/openid-connect-registration-1_0.html) on the given provider. +If the provider supports dynamic client registration, it will generate a `clientId` for you to use in subsequent calls to this library. + +```js +import { register } from 'react-native-app-auth'; + +const registerConfig = { + issuer: '', + redirectUrls: ['', ''], +}; + +const registerResult = await register(registerConfig); +``` + +#### registerConfig + +* **issuer** - (`string`) same as in authorization config +* **serviceConfiguration** - (`object`) same as in authorization config +* **redirectUrls** - (`array`) _REQUIRED_ specifies all of the redirect urls that your client will use for authentication +* **responseTypes** - (`array`) an array that specifies which [OAuth 2.0 response types](https://openid.net/specs/oauth-v2-multiple-response-types-1_0.html) your client will use. The default value is `['code']` +* **grantTypes** - (`array`) an array that specifies which [OAuth 2.0 grant types](https://oauth.net/2/grant-types/) your client will use. The default value is `['authorization_code']` +* **subjectType** - (`string`) requests a specific [subject type](https://openid.net/specs/openid-connect-core-1_0.html#SubjectIDTypes) for your client +* **tokenEndpointAuthMethod** (`string`) specifies which `clientAuthMethod` your client will use for authentication. The default value is `'client_secret_basic'` +* **additionalParameters** - (`object`) additional parameters that will be passed in the registration request. + Must be string values! E.g. setting `additionalParameters: { hello: 'world', foo: 'bar' }` would add + `hello=world&foo=bar` to the authorization request. +* **dangerouslyAllowInsecureHttpRequests** - (`boolean`) _ANDROID_ same as in authorization config +* **customHeaders** - (`object`) _ANDROID_ same as in authorization config + +#### registerResult + +This is the result from the auth server + +* **clientId** - (`string`) the assigned client id +* **clientIdIssuedAt** - (`string`) _OPTIONAL_ date string of when the client id was issued +* **clientSecret** - (`string`) _OPTIONAL_ the assigned client secret +* **clientSecretExpiresAt** - (`string`) date string of when the client secret expires, which will be provided if `clientSecret` is provided. If `new Date(clientSecretExpiresAt).getTime() === 0`, then the secret never expires +* **registrationClientUri** - (`string`) _OPTIONAL_ uri that can be used to perform subsequent operations on the registration +* **registrationAccessToken** - (`string`) token that can be used at the endpoint given by `registrationClientUri` to perform subsequent operations on the registration. Will be provided if `registrationClientUri` is provided + ## Getting started ```sh diff --git a/android/src/main/java/com/rnappauth/RNAppAuthModule.java b/android/src/main/java/com/rnappauth/RNAppAuthModule.java index 0ff0b9b2..6879eed2 100644 --- a/android/src/main/java/com/rnappauth/RNAppAuthModule.java +++ b/android/src/main/java/com/rnappauth/RNAppAuthModule.java @@ -24,6 +24,7 @@ import com.rnappauth.utils.MapUtil; import com.rnappauth.utils.UnsafeConnectionBuilder; +import com.rnappauth.utils.RegistrationResponseFactory; import com.rnappauth.utils.TokenResponseFactory; import com.rnappauth.utils.CustomConnectionBuilder; @@ -36,14 +37,18 @@ import net.openid.appauth.ClientAuthentication; import net.openid.appauth.ClientSecretBasic; import net.openid.appauth.ClientSecretPost; +import net.openid.appauth.RegistrationRequest; +import net.openid.appauth.RegistrationResponse; 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.util.ArrayList; import java.util.Collections; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.CountDownLatch; @@ -56,6 +61,7 @@ public class RNAppAuthModule extends ReactContextBaseJavaModule implements Activ private Promise promise; private Boolean dangerouslyAllowInsecureHttpRequests; private String clientAuthMethod = "basic"; + private Map registrationRequestHeaders = null; private Map authorizationRequestHeaders = null; private Map tokenRequestHeaders = null; private Map additionalParametersMap; @@ -130,6 +136,75 @@ public void onFetchConfigurationCompleted( } } + @ReactMethod + public void register( + String issuer, + final ReadableArray redirectUris, + final ReadableArray responseTypes, + final ReadableArray grantTypes, + final String subjectType, + final String tokenEndpointAuthMethod, + final ReadableMap additionalParameters, + final ReadableMap serviceConfiguration, + final Boolean dangerouslyAllowInsecureHttpRequests, + final ReadableMap headers, + final Promise promise + ) { + this.parseHeaderMap(headers); + final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, this.registrationRequestHeaders); + final AppAuthConfiguration appAuthConfiguration = this.createAppAuthConfiguration(builder); + final HashMap additionalParametersMap = MapUtil.readableMapToHashMap(additionalParameters); + + // when serviceConfiguration is provided, we don't need to hit up the OpenID well-known id endpoint + if (serviceConfiguration != null || mServiceConfiguration.get() != null) { + try { + final AuthorizationServiceConfiguration serviceConfig = mServiceConfiguration.get() != null ? mServiceConfiguration.get() : createAuthorizationServiceConfiguration(serviceConfiguration); + registerWithConfiguration( + serviceConfig, + appAuthConfiguration, + redirectUris, + responseTypes, + grantTypes, + subjectType, + tokenEndpointAuthMethod, + additionalParametersMap, + promise + ); + } catch (Exception e) { + promise.reject("registration_failed", e.getMessage()); + } + } 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("service_configuration_fetch_error", getErrorMessage(ex)); + return; + } + + mServiceConfiguration.set(fetchedConfiguration); + + registerWithConfiguration( + fetchedConfiguration, + appAuthConfiguration, + redirectUris, + responseTypes, + grantTypes, + subjectType, + tokenEndpointAuthMethod, + additionalParametersMap, + promise + ); + } + }, + builder); + } + } + @ReactMethod public void authorize( String issuer, @@ -150,10 +225,6 @@ public void authorize( final AppAuthConfiguration appAuthConfiguration = this.createAppAuthConfiguration(builder); final HashMap additionalParametersMap = MapUtil.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; @@ -345,6 +416,64 @@ public void onTokenRequestCompleted( } } + /* + * Perform dynamic client registration with the provided configuration + */ + private void registerWithConfiguration( + final AuthorizationServiceConfiguration serviceConfiguration, + final AppAuthConfiguration appAuthConfiguration, + final ReadableArray redirectUris, + final ReadableArray responseTypes, + final ReadableArray grantTypes, + final String subjectType, + final String tokenEndpointAuthMethod, + final Map additionalParametersMap, + final Promise promise + ) { + final Context context = this.reactContext; + + AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); + + RegistrationRequest.Builder registrationRequestBuilder = + new RegistrationRequest.Builder( + serviceConfiguration, + arrayToUriList(redirectUris) + ) + .setAdditionalParameters(additionalParametersMap); + + if (responseTypes != null) { + registrationRequestBuilder.setResponseTypeValues(arrayToList(responseTypes)); + } + + if (grantTypes != null) { + registrationRequestBuilder.setGrantTypeValues(arrayToList(grantTypes)); + } + + if (subjectType != null) { + registrationRequestBuilder.setSubjectType(subjectType); + } + + if (tokenEndpointAuthMethod != null) { + registrationRequestBuilder.setTokenEndpointAuthenticationMethod(tokenEndpointAuthMethod); + } + + RegistrationRequest registrationRequest = registrationRequestBuilder.build(); + + AuthorizationService.RegistrationResponseCallback registrationResponseCallback = new AuthorizationService.RegistrationResponseCallback() { + @Override + public void onRegistrationRequestCompleted(@Nullable RegistrationResponse response, @Nullable AuthorizationException ex) { + if (response != null) { + WritableMap map = RegistrationResponseFactory.registrationResponseToMap(response); + promise.resolve(map); + } else { + promise.reject("registration_failed", getErrorMessage(ex)); + } + } + }; + + authService.performRegistrationRequest(registrationRequest, registrationResponseCallback); + } + /* * Authorize user with the provided configuration */ @@ -487,6 +616,9 @@ private void parseHeaderMap (ReadableMap headerMap) { if (headerMap == null) { return; } + if (headerMap.hasKey("register") && headerMap.getType("register") == ReadableType.Map) { + this.registrationRequestHeaders = MapUtil.readableMapToHashMap(headerMap.getMap("register")); + } if (headerMap.hasKey("authorize") && headerMap.getType("authorize") == ReadableType.Map) { this.authorizationRequestHeaders = MapUtil.readableMapToHashMap(headerMap.getMap("authorize")); } @@ -527,6 +659,28 @@ private String arrayToString(ReadableArray array) { return strBuilder.toString(); } + /* + * Create a string list from an array of strings + */ + private List arrayToList(ReadableArray array) { + ArrayList list = new ArrayList<>(); + for (int i = 0; i < array.size(); i++) { + list.add(array.getString(i)); + } + return list; + } + + /* + * Create a Uri list from an array of strings + */ + private List arrayToUriList(ReadableArray array) { + ArrayList list = new ArrayList<>(); + for (int i = 0; i < array.size(); i++) { + list.add(Uri.parse(array.getString(i))); + } + return list; + } + /* * Create an App Auth configuration using the provided connection builder */ diff --git a/android/src/main/java/com/rnappauth/utils/MapUtil.java b/android/src/main/java/com/rnappauth/utils/MapUtil.java index 7354564e..bec1b1c0 100644 --- a/android/src/main/java/com/rnappauth/utils/MapUtil.java +++ b/android/src/main/java/com/rnappauth/utils/MapUtil.java @@ -2,10 +2,14 @@ import androidx.annotation.Nullable; +import com.facebook.react.bridge.Arguments; import com.facebook.react.bridge.ReadableMap; import com.facebook.react.bridge.ReadableMapKeySetIterator; +import com.facebook.react.bridge.WritableMap; import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; public class MapUtil { @@ -22,4 +26,20 @@ public static HashMap readableMapToHashMap(@Nullable ReadableMap return hashMap; } + + public static final WritableMap createAdditionalParametersMap(Map additionalParameters) { + WritableMap additionalParametersMap = Arguments.createMap(); + + if (!additionalParameters.isEmpty()) { + + Iterator iterator = additionalParameters.keySet().iterator(); + + while(iterator.hasNext()) { + String key = iterator.next(); + additionalParametersMap.putString(key, additionalParameters.get(key)); + } + } + + return additionalParametersMap; + } } diff --git a/android/src/main/java/com/rnappauth/utils/RegistrationResponseFactory.java b/android/src/main/java/com/rnappauth/utils/RegistrationResponseFactory.java new file mode 100644 index 00000000..778f5e23 --- /dev/null +++ b/android/src/main/java/com/rnappauth/utils/RegistrationResponseFactory.java @@ -0,0 +1,44 @@ +package com.rnappauth.utils; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; + +import net.openid.appauth.RegistrationResponse; + +public final class RegistrationResponseFactory { + /* + * Read raw registration response into a React Native map to be passed down the bridge + */ + public static final WritableMap registrationResponseToMap(RegistrationResponse response) { + WritableMap map = Arguments.createMap(); + + map.putString("clientId", response.clientId); + map.putMap("additionalParameters", MapUtil.createAdditionalParametersMap(response.additionalParameters)); + + if (response.clientIdIssuedAt != null) { + map.putString("clientIdIssuedAt", DateUtil.formatTimestamp(response.clientIdIssuedAt)); + } + + if (response.clientSecret != null) { + map.putString("clientSecret", response.clientSecret); + } + + if (response.clientSecretExpiresAt != null) { + map.putString("clientSecretExpiresAt", DateUtil.formatTimestamp(response.clientSecretExpiresAt)); + } + + if (response.registrationAccessToken != null) { + map.putString("registrationAccessToken", response.registrationAccessToken); + } + + if (response.registrationClientUri != null) { + map.putString("registrationClientUri", response.registrationClientUri.toString()); + } + + if (response.tokenEndpointAuthMethod != null) { + map.putString("tokenEndpointAuthMethod", response.tokenEndpointAuthMethod); + } + + return map; + } +} diff --git a/android/src/main/java/com/rnappauth/utils/TokenResponseFactory.java b/android/src/main/java/com/rnappauth/utils/TokenResponseFactory.java index efd769f4..5921967b 100644 --- a/android/src/main/java/com/rnappauth/utils/TokenResponseFactory.java +++ b/android/src/main/java/com/rnappauth/utils/TokenResponseFactory.java @@ -9,25 +9,7 @@ import net.openid.appauth.AuthorizationResponse; import net.openid.appauth.TokenResponse; -import java.util.Iterator; -import java.util.Map; - public final class TokenResponseFactory { - private static final WritableMap createAdditionalParametersMap(Map additionalParameters) { - WritableMap additionalParametersMap = Arguments.createMap(); - - if (!additionalParameters.isEmpty()) { - - Iterator iterator = additionalParameters.keySet().iterator(); - - while(iterator.hasNext()) { - String key = iterator.next(); - additionalParametersMap.putString(key, additionalParameters.get(key)); - } - } - - return additionalParametersMap; - } private static final WritableArray createScopeArray(String scope) { WritableArray scopeArray = Arguments.createArray(); @@ -51,7 +33,7 @@ public static final WritableMap tokenResponseToMap(TokenResponse response) { WritableMap map = Arguments.createMap(); map.putString("accessToken", response.accessToken); - map.putMap("additionalParameters", createAdditionalParametersMap(response.additionalParameters)); + map.putMap("additionalParameters", MapUtil.createAdditionalParametersMap(response.additionalParameters)); map.putString("idToken", response.idToken); map.putString("refreshToken", response.refreshToken); map.putString("tokenType", response.tokenType); @@ -70,9 +52,9 @@ public static final WritableMap tokenResponseToMap(TokenResponse response, Autho WritableMap map = Arguments.createMap(); map.putString("accessToken", response.accessToken); - map.putMap("authorizeAdditionalParameters", createAdditionalParametersMap(authResponse.additionalParameters)); - map.putMap("tokenAdditionalParameters", createAdditionalParametersMap(response.additionalParameters)); - map.putMap("additionalParameters", createAdditionalParametersMap(response.additionalParameters)); // DEPRECATED + map.putMap("authorizeAdditionalParameters", MapUtil.createAdditionalParametersMap(authResponse.additionalParameters)); + map.putMap("tokenAdditionalParameters", MapUtil.createAdditionalParametersMap(response.additionalParameters)); + map.putMap("additionalParameters", MapUtil.createAdditionalParametersMap(response.additionalParameters)); // DEPRECATED map.putString("idToken", response.idToken); map.putString("refreshToken", response.refreshToken); map.putString("tokenType", response.tokenType); diff --git a/index.d.ts b/index.d.ts index f3d508ff..118b0992 100644 --- a/index.d.ts +++ b/index.d.ts @@ -5,27 +5,60 @@ export interface ServiceConfiguration { registrationEndpoint?: string; } -export type BaseAuthConfiguration = +export type BaseConfiguration = | { - clientId: string; issuer?: string; serviceConfiguration: ServiceConfiguration; } | { - clientId: string; issuer: string; serviceConfiguration?: ServiceConfiguration; }; +type CustomHeaders = { + authorize?: Record; + token?: Record; + register?: Record; +}; + +interface BuiltInRegistrationParameters { + client_name?: string; + logo_uri?: string; + client_uri?: string; + policy_uri?: string; + tos_uri?: string; +} + +export type RegistrationConfiguration = BaseConfiguration & { + redirectUrls: string[]; + responseTypes?: string[]; + grantTypes?: string[]; + subjectType?: string; + tokenEndpointAuthMethod?: string; + additionalParameters?: BuiltInRegistrationParameters & { [name: string]: string }; + dangerouslyAllowInsecureHttpRequests?: boolean; + customHeaders?: CustomHeaders; +}; + +export interface RegistrationResponse { + clientId: string; + additionalParameters?: { [name: string]: string }; + clientIdIssuedAt?: string; + clientSecret?: string; + clientSecretExpiresAt?: string; + registrationAccessToken?: string; + registrationClientUri?: string; + tokenEndpointAuthMethod?: string; +} + interface BuiltInParameters { display?: 'page' | 'popup' | 'touch' | 'wap'; login_prompt?: string; prompt?: 'consent' | 'login' | 'none' | 'select_account'; } -type CustomHeaders = { - authorize?: Record; - token?: Record; +export type BaseAuthConfiguration = BaseConfiguration & { + clientId: string; }; export type AuthConfiguration = BaseAuthConfiguration & { @@ -72,6 +105,8 @@ export interface RefreshConfiguration { export function prefetchConfiguration(config: AuthConfiguration): Promise; +export function register(config: RegistrationConfiguration): Promise; + export function authorize(config: AuthConfiguration): Promise; export function refresh( diff --git a/index.js b/index.js index 762911f5..6ba697ef 100644 --- a/index.js +++ b/index.js @@ -11,6 +11,12 @@ const validateIssuerOrServiceConfigurationEndpoints = (issuer, serviceConfigurat typeof serviceConfiguration.tokenEndpoint === 'string'), 'Config error: you must provide either an issuer or a service endpoints' ); +const validateIssuerOrServiceConfigurationRegistrationEndpoint = (issuer, serviceConfiguration) => + invariant( + typeof issuer === 'string' || + (serviceConfiguration && typeof serviceConfiguration.registrationEndpoint === 'string'), + 'Config error: you must provide either an issuer or a registration endpoint' + ); const validateIssuerOrServiceConfigurationRevocationEndpoint = (issuer, serviceConfiguration) => invariant( typeof issuer === 'string' || @@ -27,9 +33,9 @@ const validateHeaders = headers => { return; } const customHeaderTypeErrorMessage = - 'Config error: customHeaders type must be { token?: { [key: string]: string }, authorize?: { [key: string]: string }}'; + 'Config error: customHeaders type must be { token?: { [key: string]: string }, authorize?: { [key: string]: string }, register: { [key: string]: string }}'; - const authorizedKeys = ['token', 'authorize']; + const authorizedKeys = ['token', 'authorize', 'register']; const keys = Object.keys(headers); const correctKeys = keys.filter(key => authorizedKeys.includes(key)); invariant( @@ -79,6 +85,62 @@ export const prefetchConfiguration = async ({ } }; +export const register = ({ + issuer, + redirectUrls, + responseTypes, + grantTypes, + subjectType, + tokenEndpointAuthMethod, + additionalParameters, + serviceConfiguration, + dangerouslyAllowInsecureHttpRequests = false, + customHeaders, +}) => { + validateIssuerOrServiceConfigurationRegistrationEndpoint(issuer, serviceConfiguration); + validateHeaders(customHeaders); + invariant( + Array.isArray(redirectUrls) && redirectUrls.every(url => typeof url === 'string'), + 'Config error: redirectUrls must be an Array of strings' + ); + invariant( + responseTypes == null || + (Array.isArray(responseTypes) && responseTypes.every(rt => typeof rt === 'string')), + 'Config error: if provided, responseTypes must be an Array of strings' + ); + invariant( + grantTypes == null || + (Array.isArray(grantTypes) && grantTypes.every(gt => typeof gt === 'string')), + 'Config error: if provided, grantTypes must be an Array of strings' + ); + invariant( + subjectType == null || typeof subjectType === 'string', + 'Config error: if provided, subjectType must be a string' + ); + invariant( + tokenEndpointAuthMethod == null || typeof tokenEndpointAuthMethod === 'string', + 'Config error: if provided, tokenEndpointAuthMethod must be a string' + ); + + const nativeMethodArguments = [ + issuer, + redirectUrls, + responseTypes, + grantTypes, + subjectType, + tokenEndpointAuthMethod, + additionalParameters, + serviceConfiguration, + ]; + + if (Platform.OS === 'android') { + nativeMethodArguments.push(dangerouslyAllowInsecureHttpRequests); + nativeMethodArguments.push(customHeaders); + } + + return RNAppAuth.register(...nativeMethodArguments); +}; + export const authorize = ({ issuer, redirectUrl, diff --git a/index.spec.js b/index.spec.js index 0f4bfd1c..57c3f350 100644 --- a/index.spec.js +++ b/index.spec.js @@ -1,8 +1,9 @@ -import { authorize, refresh } from './'; +import { register, authorize, refresh } from './'; jest.mock('react-native', () => ({ NativeModules: { RNAppAuth: { + register: jest.fn(), authorize: jest.fn(), refresh: jest.fn(), }, @@ -13,10 +14,14 @@ jest.mock('react-native', () => ({ })); describe('AppAuth', () => { + let mockRegister; let mockAuthorize; let mockRefresh; beforeAll(() => { + mockRegister = require('react-native').NativeModules.RNAppAuth.register; + mockRegister.mockReturnValue('REGISTERED'); + mockAuthorize = require('react-native').NativeModules.RNAppAuth.authorize; mockAuthorize.mockReturnValue('AUTHORIZED'); @@ -38,8 +43,242 @@ describe('AppAuth', () => { customHeaders: null, }; + const registerConfig = { + issuer: 'test-issuer', + redirectUrls: ['test-redirectUrl'], + responseTypes: ['code'], + grantTypes: ['authorization_code'], + subjectType: 'public', + tokenEndpointAuthMethod: 'client_secret_post', + additionalParameters: {}, + serviceConfiguration: null, + }; + + describe('register', () => { + beforeEach(() => { + mockRegister.mockReset(); + mockAuthorize.mockReset(); + mockRefresh.mockReset(); + }); + + it('throws an error when issuer is not a string and serviceConfiguration is not passed', () => { + expect(() => { + register({ ...registerConfig, issuer: () => ({}) }); + }).toThrow('Config error: you must provide either an issuer or a registration endpoint'); + }); + + it('throws an error when serviceConfiguration does not have registrationEndpoint and issuer is not passed', () => { + expect(() => { + register({ + ...registerConfig, + issuer: undefined, + serviceConfiguration: { authorizationEndpoint: '' }, + }); + }).toThrow('Config error: you must provide either an issuer or a registration endpoint'); + }); + + it('throws an error when redirectUrls is not an Array', () => { + expect(() => { + register({ ...registerConfig, redirectUrls: 'test-url' }); + }).toThrow('Config error: redirectUrls must be an Array of strings'); + }); + + it('throws an error when redirectUrls does not contain strings', () => { + expect(() => { + register({ ...registerConfig, redirectUrls: [null] }); + }).toThrow('Config error: redirectUrls must be an Array of strings'); + }); + + it('throws an error when responseTypes is not an Array', () => { + expect(() => { + register({ ...registerConfig, responseTypes: 'test-type' }); + }).toThrow('Config error: if provided, responseTypes must be an Array of strings'); + }); + + it('throws an error when responseTypes does not contain strings', () => { + expect(() => { + register({ ...registerConfig, responseTypes: [null] }); + }).toThrow('Config error: if provided, responseTypes must be an Array of strings'); + }); + + it('throws an error when grantTypes is not an Array', () => { + expect(() => { + register({ ...registerConfig, grantTypes: 'test-type' }); + }).toThrow('Config error: if provided, grantTypes must be an Array of strings'); + }); + + it('throws an error when grantTypes does not contain strings', () => { + expect(() => { + register({ ...registerConfig, grantTypes: [null] }); + }).toThrow('Config error: if provided, grantTypes must be an Array of strings'); + }); + + it('throws an error when subjectType is not a string', () => { + expect(() => { + register({ ...registerConfig, subjectType: 7 }); + }).toThrow('Config error: if provided, subjectType must be a string'); + }); + + it('throws an error when tokenEndpointAuthMethod is not a string', () => { + expect(() => { + register({ ...registerConfig, tokenEndpointAuthMethod: () => 'test-method' }); + }).toThrow('Config error: if provided, tokenEndpointAuthMethod must be a string'); + }); + + it('throws an error when customHeaders has too few keys', () => { + expect(() => { + register({ ...registerConfig, customHeaders: {} }); + }).toThrow(); + }); + + it('throws an error when customHeaders has too many keys', () => { + expect(() => { + register({ + ...registerConfig, + customHeaders: { + register: { toto: 'titi' }, + authorize: { toto: 'titi' }, + unknownKey: { toto: 'titi' }, + }, + }); + }).toThrow(); + }); + + it('throws an error when customHeaders has unknown keys', () => { + expect(() => { + register({ + ...registerConfig, + customHeaders: { + reg: { toto: 'titi' }, + authorize: { toto: 'titi' }, + }, + }); + }).toThrow(); + expect(() => { + register({ + ...registerConfig, + customHeaders: { + reg: { toto: 'titi' }, + }, + }); + }).toThrow(); + }); + + it('throws an error when customHeaders values arent Record', () => { + expect(() => { + register({ + ...registerConfig, + customHeaders: { + register: { toto: {} }, + }, + }); + }).toThrow(); + }); + + it('calls the native wrapper with the correct args on iOS', () => { + register(registerConfig); + expect(mockRegister).toHaveBeenCalledWith( + registerConfig.issuer, + registerConfig.redirectUrls, + registerConfig.responseTypes, + registerConfig.grantTypes, + registerConfig.subjectType, + registerConfig.tokenEndpointAuthMethod, + registerConfig.additionalParameters, + registerConfig.serviceConfiguration + ); + }); + + describe('Android-specific', () => { + beforeEach(() => { + require('react-native').Platform.OS = 'android'; + }); + + afterEach(() => { + require('react-native').Platform.OS = 'ios'; + }); + + describe('dangerouslyAllowInsecureHttpRequests parameter', () => { + it('calls the native wrapper with default value `false`', () => { + register(registerConfig); + expect(mockRegister).toHaveBeenCalledWith( + registerConfig.issuer, + registerConfig.redirectUrls, + registerConfig.responseTypes, + registerConfig.grantTypes, + registerConfig.subjectType, + registerConfig.tokenEndpointAuthMethod, + registerConfig.additionalParameters, + registerConfig.serviceConfiguration, + false, + registerConfig.customHeaders + ); + }); + + it('calls the native wrapper with passed value `false`', () => { + register({ ...registerConfig, dangerouslyAllowInsecureHttpRequests: false }); + expect(mockRegister).toHaveBeenCalledWith( + registerConfig.issuer, + registerConfig.redirectUrls, + registerConfig.responseTypes, + registerConfig.grantTypes, + registerConfig.subjectType, + registerConfig.tokenEndpointAuthMethod, + registerConfig.additionalParameters, + registerConfig.serviceConfiguration, + false, + registerConfig.customHeaders + ); + }); + + it('calls the native wrapper with passed value `true`', () => { + register({ ...registerConfig, dangerouslyAllowInsecureHttpRequests: true }); + expect(mockRegister).toHaveBeenCalledWith( + registerConfig.issuer, + registerConfig.redirectUrls, + registerConfig.responseTypes, + registerConfig.grantTypes, + registerConfig.subjectType, + registerConfig.tokenEndpointAuthMethod, + registerConfig.additionalParameters, + registerConfig.serviceConfiguration, + true, + registerConfig.customHeaders + ); + }); + }); + + describe('customHeaders parameter', () => { + it('calls the native wrapper with headers', () => { + const customTokenHeaders = { Authorization: 'Basic someBase64Value' }; + const customAuthorizeHeaders = { Authorization: 'Basic someOtherBase64Value' }; + const customRegisterHeaders = { Authorization: 'Basic some3rdBase64Value' }; + const customHeaders = { + token: customTokenHeaders, + authorize: customAuthorizeHeaders, + register: customRegisterHeaders, + }; + register({ ...registerConfig, customHeaders }); + expect(mockRegister).toHaveBeenCalledWith( + registerConfig.issuer, + registerConfig.redirectUrls, + registerConfig.responseTypes, + registerConfig.grantTypes, + registerConfig.subjectType, + registerConfig.tokenEndpointAuthMethod, + registerConfig.additionalParameters, + registerConfig.serviceConfiguration, + false, + customHeaders + ); + }); + }); + }); + }); + describe('authorize', () => { beforeEach(() => { + mockRegister.mockReset(); mockAuthorize.mockReset(); mockRefresh.mockReset(); }); @@ -210,7 +449,12 @@ describe('AppAuth', () => { it('calls the native wrapper with headers', () => { const customTokenHeaders = { Authorization: 'Basic someBase64Value' }; const customAuthorizeHeaders = { Authorization: 'Basic someOtherBase64Value' }; - const customHeaders = { token: customTokenHeaders, authorize: customAuthorizeHeaders }; + const customRegisterHeaders = { Authorization: 'Basic some3rdBase64Value' }; + const customHeaders = { + token: customTokenHeaders, + authorize: customAuthorizeHeaders, + register: customRegisterHeaders, + }; authorize({ ...config, customHeaders }); expect(mockAuthorize).toHaveBeenCalledWith( config.issuer, @@ -232,6 +476,7 @@ describe('AppAuth', () => { describe('refresh', () => { beforeEach(() => { + mockRegister.mockReset(); mockAuthorize.mockReset(); mockRefresh.mockReset(); }); diff --git a/ios/RNAppAuth.m b/ios/RNAppAuth.m index 3b591a7a..e9fb76ed 100644 --- a/ios/RNAppAuth.m +++ b/ios/RNAppAuth.m @@ -33,6 +33,50 @@ - (dispatch_queue_t)methodQueue static NSUInteger const kCodeVerifierBytes = 32; RCT_EXPORT_MODULE() + +RCT_REMAP_METHOD(register, + issuer: (NSString *) issuer + redirectUrls: (NSArray *) redirectUrls + responseTypes: (NSArray *) responseTypes + grantTypes: (NSArray *) grantTypes + subjectType: (NSString *) subjectType + tokenEndpointAuthMethod: (NSString *) tokenEndpointAuthMethod + additionalParameters: (NSDictionary *_Nullable) additionalParameters + serviceConfiguration: (NSDictionary *_Nullable) serviceConfiguration + resolve: (RCTPromiseResolveBlock) resolve + reject: (RCTPromiseRejectBlock) reject) +{ + // 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 registerWithConfiguration: configuration + redirectUrls: redirectUrls + responseTypes: responseTypes + grantTypes: grantTypes + subjectType: subjectType + tokenEndpointAuthMethod: tokenEndpointAuthMethod + additionalParameters: additionalParameters + resolve: resolve + reject: reject]; + } else { + [OIDAuthorizationService discoverServiceConfigurationForIssuer:[NSURL URLWithString:issuer] + completion:^(OIDServiceConfiguration *_Nullable configuration, NSError *_Nullable error) { + if (!configuration) { + reject(@"service_configuration_fetch_error", [error localizedDescription], error); + return; + } + [self registerWithConfiguration: configuration + redirectUrls: redirectUrls + responseTypes: responseTypes + grantTypes: grantTypes + subjectType: subjectType + tokenEndpointAuthMethod: tokenEndpointAuthMethod + additionalParameters: additionalParameters + resolve: resolve + reject: reject]; + }]; + } +} // end RCT_REMAP_METHOD(register, RCT_REMAP_METHOD(authorize, issuer: (NSString *) issuer @@ -163,6 +207,45 @@ + (nullable NSString *)codeChallengeS256ForVerifier:(NSString *)codeVerifier { return [OIDTokenUtilities encodeBase64urlNoPadding:sha256Verifier]; } + +/* + * Perform dynamic client registration with provided OIDServiceConfiguration + */ +- (void)registerWithConfiguration: (OIDServiceConfiguration *) configuration + redirectUrls: (NSArray *) redirectUrlStrings + responseTypes: (NSArray *) responseTypes + grantTypes: (NSArray *) grantTypes + subjectType: (NSString *) subjectType + tokenEndpointAuthMethod: (NSString *) tokenEndpointAuthMethod + additionalParameters: (NSDictionary *_Nullable) additionalParameters + resolve: (RCTPromiseResolveBlock) resolve + reject: (RCTPromiseRejectBlock) reject +{ + NSMutableArray *redirectUrls = [NSMutableArray arrayWithCapacity:[redirectUrlStrings count]]; + for (NSString *urlString in redirectUrlStrings) { + [redirectUrls addObject:[NSURL URLWithString:urlString]]; + } + + OIDRegistrationRequest *request = + [[OIDRegistrationRequest alloc] initWithConfiguration:configuration + redirectURIs:redirectUrls + responseTypes:responseTypes + grantTypes:grantTypes + subjectType:subjectType + tokenEndpointAuthMethod:tokenEndpointAuthMethod + additionalParameters:additionalParameters]; + + [OIDAuthorizationService performRegistrationRequest:request + completion:^(OIDRegistrationResponse *_Nullable response, + NSError *_Nullable error) { + if (response) { + resolve([self formatRegistrationResponse:response]); + } else { + reject(@"registration_failed", [error localizedDescription], error); + } + }]; +} + /* * Authorize a user in exchange for a token with provided OIDServiceConfiguration */ @@ -297,5 +380,22 @@ - (NSDictionary*)formatResponse: (OIDTokenResponse*) response @"scopes": authResponse.scope ? [authResponse.scope componentsSeparatedByString:@" "] : [NSArray new], }; } + +- (NSDictionary*)formatRegistrationResponse: (OIDRegistrationResponse*) response { + NSDateFormatter *dateFormat = [[NSDateFormatter alloc] init]; + dateFormat.timeZone = [NSTimeZone timeZoneWithAbbreviation: @"UTC"]; + [dateFormat setLocale:[NSLocale localeWithLocaleIdentifier:@"en_US_POSIX"]]; + [dateFormat setDateFormat:@"yyyy-MM-dd'T'HH:mm:ss'Z'"]; + + return @{@"clientId": response.clientID, + @"additionalParameters": response.additionalParameters, + @"clientIdIssuedAt": response.clientIDIssuedAt ? [dateFormat stringFromDate:response.clientIDIssuedAt] : @"", + @"clientSecret": response.clientSecret ? response.clientSecret : @"", + @"clientSecretExpiresAt": response.clientSecretExpiresAt ? [dateFormat stringFromDate:response.clientSecretExpiresAt] : @"", + @"registrationAccessToken": response.registrationAccessToken ? response.registrationAccessToken : @"", + @"registrationClientUri": response.registrationClientURI ? response.registrationClientURI : @"", + @"tokenEndpointAuthMethod": response.tokenEndpointAuthenticationMethod ? response.tokenEndpointAuthenticationMethod : @"", + }; +} @end