diff --git a/README.md b/README.md index 7353e520c..743231b5b 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,7 @@ with optional overrides. - **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. + - **endSessionEndpoint** - (`string`) fully formed url to your OpenID Connect end session endpoint. If you want to be able to end a user's session 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 @@ -192,6 +193,23 @@ const result = await revoke(config, { }); ``` +### `logout` + +This method will logout a user, as per the [OpenID Connect RP Initiated Logout](https://openid.net/specs/openid-connect-rpinitiated-1_0.html) specification. It requires an `idToken`, obtained after successfully authenticating with OpenID Connect, and a URL to redirect back after the logout has been performed. + +```js +import { logout } from 'react-native-app-auth'; + +const config = { + issuer: '' +}; + +const result = await logout(config, { + idToken: '', + postLogoutRedirectUrl: '' +}); +``` + ### `register` This will perform [dynamic client registration](https://openid.net/specs/openid-connect-registration-1_0.html) on the given provider. diff --git a/android/src/main/java/com/rnappauth/RNAppAuthModule.java b/android/src/main/java/com/rnappauth/RNAppAuthModule.java index d0f6ebb0f..a103e0c8e 100644 --- a/android/src/main/java/com/rnappauth/RNAppAuthModule.java +++ b/android/src/main/java/com/rnappauth/RNAppAuthModule.java @@ -28,6 +28,7 @@ import com.rnappauth.utils.UnsafeConnectionBuilder; import com.rnappauth.utils.RegistrationResponseFactory; import com.rnappauth.utils.TokenResponseFactory; +import com.rnappauth.utils.EndSessionResponseFactory; import com.rnappauth.utils.CustomConnectionBuilder; import net.openid.appauth.AppAuthConfiguration; @@ -45,6 +46,8 @@ import net.openid.appauth.ResponseTypeValues; import net.openid.appauth.TokenResponse; import net.openid.appauth.TokenRequest; +import net.openid.appauth.EndSessionRequest; +import net.openid.appauth.EndSessionResponse; import net.openid.appauth.connectivity.ConnectionBuilder; import net.openid.appauth.connectivity.DefaultConnectionBuilder; @@ -84,15 +87,15 @@ public RNAppAuthModule(ReactApplicationContext reactContext) { @ReactMethod public void prefetchConfiguration( - final Boolean warmAndPrefetchChrome, - final String issuer, - final String redirectUrl, - final String clientId, - final ReadableArray scopes, - final ReadableMap serviceConfiguration, - final boolean dangerouslyAllowInsecureHttpRequests, - final ReadableMap headers, - final Promise promise + final Boolean warmAndPrefetchChrome, + final String issuer, + final String redirectUrl, + final String clientId, + final ReadableArray scopes, + final ReadableMap serviceConfiguration, + final boolean dangerouslyAllowInsecureHttpRequests, + final ReadableMap headers, + final Promise promise ) { if (warmAndPrefetchChrome) { warmChromeCustomTab(reactContext, issuer); @@ -145,17 +148,17 @@ 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 + 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); @@ -394,6 +397,72 @@ public void onFetchConfigurationCompleted( } + @ReactMethod + public void logout( + String issuer, + final String idTokenHint, + final String postLogoutRedirectUri, + final ReadableMap serviceConfiguration, + final ReadableMap additionalParameters, + final boolean dangerouslyAllowInsecureHttpRequests, + final Promise promise + ) { + final ConnectionBuilder builder = createConnectionBuilder(dangerouslyAllowInsecureHttpRequests, null); + final AppAuthConfiguration appAuthConfiguration = this.createAppAuthConfiguration(builder, dangerouslyAllowInsecureHttpRequests); + final HashMap additionalParametersMap = MapUtil.readableMapToHashMap(additionalParameters); + + this.promise = promise; + + if (serviceConfiguration != null || hasServiceConfiguration(issuer)) { + try { + final AuthorizationServiceConfiguration serviceConfig = hasServiceConfiguration(issuer) ? getServiceConfiguration(issuer) : createAuthorizationServiceConfiguration(serviceConfiguration); + endSessionWithConfiguration( + serviceConfig, + appAuthConfiguration, + idTokenHint, + postLogoutRedirectUri, + additionalParametersMap + ); + } catch (ActivityNotFoundException e) { + promise.reject("browser_not_found", e.getMessage()); + } catch (Exception e) { + promise.reject("end_session_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", ex.getLocalizedMessage(), ex); + return; + } + + setServiceConfiguration(issuer, fetchedConfiguration); + + try { + endSessionWithConfiguration( + fetchedConfiguration, + appAuthConfiguration, + idTokenHint, + postLogoutRedirectUri, + additionalParametersMap + ); + } catch (ActivityNotFoundException e) { + promise.reject("browser_not_found", e.getMessage()); + } catch (Exception e) { + promise.reject("end_session_failed", e.getMessage()); + } + } + }, + builder + ); + } + } + /* * Called when the OAuth browser activity completes */ @@ -468,21 +537,41 @@ public void onTokenRequestCompleted( } } + + if (requestCode == 53) { + if (data == null) { + if (promise != null) { + promise.reject("end_session_failed", "Data intent is null" ); + } + return; + } + EndSessionResponse response = EndSessionResponse.fromIntent(data); + AuthorizationException ex = AuthorizationException.fromIntent(data); + if (ex != null) { + if (promise != null) { + handleAuthorizationException("end_session_failed", ex, promise); + } + return; + } + final Promise endSessionPromise = this.promise; + WritableMap map = EndSessionResponseFactory.endSessionResponseToMap(response); + endSessionPromise.resolve(map); + } } /* * 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 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; @@ -490,10 +579,10 @@ private void registerWithConfiguration( RegistrationRequest.Builder registrationRequestBuilder = new RegistrationRequest.Builder( - serviceConfiguration, - arrayToUriList(redirectUris) + serviceConfiguration, + arrayToUriList(redirectUris) ) - .setAdditionalParameters(additionalParametersMap); + .setAdditionalParameters(additionalParametersMap); if (responseTypes != null) { registrationRequestBuilder.setResponseTypeValues(arrayToList(responseTypes)); @@ -677,6 +766,47 @@ public void onTokenRequestCompleted(@Nullable TokenResponse response, @Nullable } } + /* + * End user session with provided configuration + */ + private void endSessionWithConfiguration( + final AuthorizationServiceConfiguration serviceConfiguration, + final AppAuthConfiguration appAuthConfiguration, + final String idTokenHint, + final String postLogoutRedirectUri, + final Map additionalParametersMap + ) { + final Context context = this.reactContext; + final Activity currentActivity = getCurrentActivity(); + + EndSessionRequest.Builder endSessionRequestBuilder = + new EndSessionRequest.Builder( + serviceConfiguration, + idTokenHint, + Uri.parse(postLogoutRedirectUri) + ); + + if (additionalParametersMap != null) { + if (additionalParametersMap.containsKey("state")) { + endSessionRequestBuilder.setState(additionalParametersMap.get("state")); + } + } + + EndSessionRequest endSessionRequest = endSessionRequestBuilder.build(); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.LOLLIPOP) { + AuthorizationService authService = new AuthorizationService(context, appAuthConfiguration); + Intent endSessionIntent = authService.getEndSessionRequestIntent(endSessionRequest); + + currentActivity.startActivityForResult(endSessionIntent, 53); + } else { + AuthorizationService authService = new AuthorizationService(currentActivity, appAuthConfiguration); + PendingIntent pendingIntent = currentActivity.createPendingResult(53, new Intent(), 0); + + authService.performEndSessionRequest(endSessionRequest, pendingIntent); + } + } + private void parseHeaderMap (ReadableMap headerMap) { if (headerMap == null) { return; @@ -793,14 +923,19 @@ private AuthorizationServiceConfiguration createAuthorizationServiceConfiguratio Uri authorizationEndpoint = Uri.parse(serviceConfiguration.getString("authorizationEndpoint")); Uri tokenEndpoint = Uri.parse(serviceConfiguration.getString("tokenEndpoint")); Uri registrationEndpoint = null; + Uri endSessionEndpoint = null; if (serviceConfiguration.hasKey("registrationEndpoint")) { registrationEndpoint = Uri.parse(serviceConfiguration.getString("registrationEndpoint")); } + if (serviceConfiguration.hasKey("endSessionEndpoint")) { + endSessionEndpoint = Uri.parse(serviceConfiguration.getString("endSessionEndpoint")); + } return new AuthorizationServiceConfiguration( authorizationEndpoint, tokenEndpoint, - registrationEndpoint + registrationEndpoint, + endSessionEndpoint ); } @@ -837,7 +972,7 @@ private AuthorizationServiceConfiguration getServiceConfiguration(@Nullable Stri } private void handleAuthorizationException(final String fallbackErrorCode, final AuthorizationException ex, final Promise promise) { - if (ex.getLocalizedMessage() == null) { + if (ex.getLocalizedMessage() == null) { promise.reject(fallbackErrorCode, ex.error, ex); } else { promise.reject(ex.error != null ? ex.error: fallbackErrorCode, ex.getLocalizedMessage(), ex); diff --git a/android/src/main/java/com/rnappauth/utils/EndSessionResponseFactory.java b/android/src/main/java/com/rnappauth/utils/EndSessionResponseFactory.java new file mode 100644 index 000000000..4cad2da2a --- /dev/null +++ b/android/src/main/java/com/rnappauth/utils/EndSessionResponseFactory.java @@ -0,0 +1,21 @@ +package com.rnappauth.utils; + +import com.facebook.react.bridge.Arguments; +import com.facebook.react.bridge.WritableMap; + +import net.openid.appauth.EndSessionResponse; + +public final class EndSessionResponseFactory { + /* + * Read raw end session response into a React Native map to be passed down the bridge + */ + public static final WritableMap endSessionResponseToMap(EndSessionResponse response) { + WritableMap map = Arguments.createMap(); + + map.putString("state", response.state); + map.putString("idTokenHint", response.request.idToken); + map.putString("postLogoutRedirectUri", response.request.redirectUri.toString()); + + return map; + } +} diff --git a/docs/config-examples/okta.md b/docs/config-examples/okta.md index 9a4fbb7d6..e30e7a9e9 100644 --- a/docs/config-examples/okta.md +++ b/docs/config-examples/okta.md @@ -7,6 +7,8 @@ Full support out of the box. > Log in to your Okta Developer account and navigate to **Applications** > **Add Application**. Click **Native** and click the **Next** button. Give the app a name you’ll remember (e.g., `React Native`), select `Refresh Token` as a grant type, in addition to the default `Authorization Code`. Copy the **Login redirect URI** (e.g., `com.oktapreview.dev-158606:/callback`) and save it somewhere. You'll need this value when configuring your app. > > Click **Done** and you'll see a client ID on the next screen. Copy the redirect URI and clientId values into your App Auth config. +> +> To end the session, `postLogoutRedirectUrl` has to be one of the **Sign-out redirect URIs** defined in the **General Settings** > **LOGIN** section of the application page previously created. ```js const config = { @@ -28,4 +30,10 @@ const refreshedState = await refresh(config, { await revoke(config, { tokenToRevoke: refreshedState.refreshToken }); + +// End session +await logout(config, { + idToken: authState.idToken, + postLogoutRedirectUrl: 'com.{yourReversedOktaDomain}:/logout' +}); ``` diff --git a/index.d.ts b/index.d.ts index 8d704fe41..839774c75 100644 --- a/index.d.ts +++ b/index.d.ts @@ -3,6 +3,7 @@ export interface ServiceConfiguration { tokenEndpoint: string; revocationEndpoint?: string; registrationEndpoint?: string; + endSessionEndpoint?: string; } export type BaseConfiguration = @@ -79,6 +80,11 @@ export type AuthConfiguration = BaseAuthConfiguration & { skipCodeExchange?: boolean; }; +export type EndSessionConfiguration = BaseAuthConfiguration & { + additionalParameters?: { [name: string]: string }; + dangerouslyAllowInsecureHttpRequests?: boolean; +}; + export interface AuthorizeResult { accessToken: string; accessTokenExpirationDate: string; @@ -111,6 +117,17 @@ export interface RefreshConfiguration { refreshToken: string; } +export interface LogoutConfiguration { + idToken: string; + postLogoutRedirectUrl: string; +} + +export interface EndSessionResult { + idTokenHint: string; + postLogoutRedirectUri: string; + state: string; +} + export function prefetchConfiguration(config: AuthConfiguration): Promise; export function register(config: RegistrationConfiguration): Promise; @@ -127,6 +144,11 @@ export function revoke( revokeConfig: RevokeConfiguration ): Promise; +export function logout( + config: EndSessionConfiguration, + logoutConfig: LogoutConfiguration +): Promise; + // https://tools.ietf.org/html/rfc6749#section-4.1.2.1 type OAuthAuthorizationErrorCode = | 'unauthorized_client' @@ -150,7 +172,8 @@ type AppAuthErrorCode = | 'authentication_failed' | 'token_refresh_failed' | 'registration_failed' - | 'browser_not_found'; + | 'browser_not_found' + | 'end_session_failed'; type ErrorCode = | OAuthAuthorizationErrorCode diff --git a/index.js b/index.js index c6595ade8..f77bc55ce 100644 --- a/index.js +++ b/index.js @@ -24,6 +24,12 @@ const validateIssuerOrServiceConfigurationRevocationEndpoint = (issuer, serviceC (serviceConfiguration && typeof serviceConfiguration.revocationEndpoint === 'string'), 'Config error: you must provide either an issuer or a revocation endpoint' ); +const validateIssuerOrServiceConfigurationEndSessionEndpoint = (issuer, serviceConfiguration) => + invariant( + typeof issuer === 'string' || + (serviceConfiguration && typeof serviceConfiguration.endSessionEndpoint === 'string'), + 'Config error: you must provide either an issuer or an end session endpoint' + ); const validateClientId = clientId => invariant(typeof clientId === 'string', 'Config error: clientId must be a string'); const validateRedirectUrl = redirectUrl => @@ -305,3 +311,31 @@ export const revoke = async ( throw new Error('Failed to revoke token', error); }); }; + +export const logout = ( + { + issuer, + serviceConfiguration, + additionalParameters, + dangerouslyAllowInsecureHttpRequests = false, + }, + { idToken, postLogoutRedirectUrl } +) => { + validateIssuerOrServiceConfigurationEndSessionEndpoint(issuer, serviceConfiguration); + validateRedirectUrl(postLogoutRedirectUrl); + invariant(idToken, 'Please pass in the ID token'); + + const nativeMethodArguments = [ + issuer, + idToken, + postLogoutRedirectUrl, + serviceConfiguration, + additionalParameters, + ]; + + if (Platform.OS === 'android') { + nativeMethodArguments.push(dangerouslyAllowInsecureHttpRequests); + } + + return RNAppAuth.logout(...nativeMethodArguments); +}; diff --git a/index.spec.js b/index.spec.js index e5a224ed3..36a60d9c9 100644 --- a/index.spec.js +++ b/index.spec.js @@ -1,4 +1,4 @@ -import { authorize, refresh, register } from './'; +import { authorize, refresh, register, logout } from './'; jest.mock('react-native', () => ({ NativeModules: { @@ -6,6 +6,7 @@ jest.mock('react-native', () => ({ register: jest.fn(), authorize: jest.fn(), refresh: jest.fn(), + logout: jest.fn(), }, }, Platform: { @@ -17,6 +18,7 @@ describe('AppAuth', () => { let mockRegister; let mockAuthorize; let mockRefresh; + let mockLogout; beforeAll(() => { mockRegister = require('react-native').NativeModules.RNAppAuth.register; @@ -27,6 +29,9 @@ describe('AppAuth', () => { mockRefresh = require('react-native').NativeModules.RNAppAuth.refresh; mockRefresh.mockReturnValue('REFRESHED'); + + mockLogout = require('react-native').NativeModules.RNAppAuth.logout; + mockLogout.mockReturnValue('LOGOUT'); }); const config = { @@ -62,6 +67,7 @@ describe('AppAuth', () => { mockRegister.mockReset(); mockAuthorize.mockReset(); mockRefresh.mockReset(); + mockLogout.mockReset(); }); it('throws an error when issuer is not a string and serviceConfiguration is not passed', () => { @@ -320,6 +326,7 @@ describe('AppAuth', () => { mockRegister.mockReset(); mockAuthorize.mockReset(); mockRefresh.mockReset(); + mockLogout.mockReset(); }); it('throws an error when issuer is not a string and serviceConfiguration is not passed', () => { @@ -338,16 +345,6 @@ describe('AppAuth', () => { }).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', () => { expect(() => { authorize({ ...config, redirectUrl: {} }); @@ -664,6 +661,7 @@ describe('AppAuth', () => { mockRegister.mockReset(); mockAuthorize.mockReset(); mockRefresh.mockReset(); + mockLogout.mockReset(); }); it('throws an error when issuer is not a string and serviceConfiguration is not passed', () => { @@ -682,16 +680,6 @@ describe('AppAuth', () => { }).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', () => { expect(() => { authorize({ ...config, redirectUrl: {} }); @@ -852,4 +840,108 @@ describe('AppAuth', () => { }); }); }); + + describe('end session', () => { + beforeEach(() => { + mockRegister.mockReset(); + mockAuthorize.mockReset(); + mockRefresh.mockReset(); + mockLogout.mockReset(); + }); + + it('throws an error when issuer is not a string and serviceConfiguration is not passed', () => { + expect(() => { + logout( + { ...config, issuer: () => ({}) }, + { idToken: 'token', postLogoutRedirectUrl: 'redirect' } + ); + }).toThrow('Config error: you must provide either an issuer or an end session endpoint'); + }); + + it('throws an error when serviceConfiguration does not have endSessionEndpoint and issuer is not passed', () => { + expect(() => { + logout( + { ...config, issuer: undefined }, + { idToken: 'token', postLogoutRedirectUrl: 'redirect' } + ); + }).toThrow('Config error: you must provide either an issuer or an end session endpoint'); + }); + + it('throws an error when postLogoutRedirectUrl is not a string', () => { + expect(() => { + logout(config, { idToken: 'token', postLogoutRedirectUrl: {} }); + }).toThrow('Config error: redirectUrl must be a string'); + }); + + it('throws an error when idToken is not passed in', () => { + expect(() => { + logout({ ...config }, { postLogoutRedirectUrl: 'redirect' }); + }).toThrow('Please pass in the ID token'); + }); + + describe('iOS-specific', () => { + beforeEach(() => { + require('react-native').Platform.OS = 'ios'; + }); + + it('calls the native wrapper with the correct args', () => { + logout({ ...config }, { idToken: '_token_', postLogoutRedirectUrl: '_redirect_' }); + expect(mockLogout).toHaveBeenCalledWith( + config.issuer, + '_token_', + '_redirect_', + config.serviceConfiguration, + config.additionalParameters + ); + }); + }); + + describe('Android-specific', () => { + beforeEach(() => { + require('react-native').Platform.OS = 'android'; + }); + + it('calls the native wrapper with the correct args and undefined dangerouslyAllowInsecureHttpRequests', () => { + logout({ ...config }, { idToken: '_token_', postLogoutRedirectUrl: '_redirect_' }); + expect(mockLogout).toHaveBeenCalledWith( + config.issuer, + '_token_', + '_redirect_', + config.serviceConfiguration, + config.additionalParameters, + false + ); + }); + + it('calls the native wrapper with the correct args and dangerouslyAllowInsecureHttpRequests set to `true`', () => { + logout( + { ...config, dangerouslyAllowInsecureHttpRequests: true }, + { idToken: '_token_', postLogoutRedirectUrl: '_redirect_' } + ); + expect(mockLogout).toHaveBeenCalledWith( + config.issuer, + '_token_', + '_redirect_', + config.serviceConfiguration, + config.additionalParameters, + true + ); + }); + + it('calls the native wrapper with the correct args and dangerouslyAllowInsecureHttpRequests set to `false`', () => { + logout( + { ...config, dangerouslyAllowInsecureHttpRequests: false }, + { idToken: '_token_', postLogoutRedirectUrl: '_redirect_' } + ); + expect(mockLogout).toHaveBeenCalledWith( + config.issuer, + '_token_', + '_redirect_', + config.serviceConfiguration, + config.additionalParameters, + false + ); + }); + }); + }); }); diff --git a/ios/RNAppAuth.m b/ios/RNAppAuth.m index fb89d2063..57968e8a9 100644 --- a/ios/RNAppAuth.m +++ b/ios/RNAppAuth.m @@ -184,6 +184,40 @@ - (dispatch_queue_t)methodQueue } } // end RCT_REMAP_METHOD(refresh, +RCT_REMAP_METHOD(logout, + issuer: (NSString *) issuer + idTokenHint: (NSString *) idTokenHint + postLogoutRedirectURL: (NSString *) postLogoutRedirectURL + serviceConfiguration: (NSDictionary *_Nullable) serviceConfiguration + additionalParameters: (NSDictionary *_Nullable) additionalParameters + resolve:(RCTPromiseResolveBlock) resolve + reject: (RCTPromiseRejectBlock) reject) +{ + if (serviceConfiguration) { + OIDServiceConfiguration *configuration = [self createServiceConfiguration:serviceConfiguration]; + [self endSessionWithConfiguration: configuration + idTokenHint: idTokenHint + postLogoutRedirectURL: postLogoutRedirectURL + 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 endSessionWithConfiguration: configuration + idTokenHint: idTokenHint + postLogoutRedirectURL: postLogoutRedirectURL + additionalParameters: additionalParameters + resolve: resolve + reject: reject]; + }]; + } +} // end RCT_REMAP_METHOD(logout, /* * Create a OIDServiceConfiguration from passed serviceConfiguration dictionary @@ -192,12 +226,15 @@ - (OIDServiceConfiguration *) createServiceConfiguration: (NSDictionary *) servi NSURL *authorizationEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"authorizationEndpoint"]]; NSURL *tokenEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"tokenEndpoint"]]; NSURL *registrationEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"registrationEndpoint"]]; + NSURL *endSessionEndpoint = [NSURL URLWithString: [serviceConfiguration objectForKey:@"endSessionEndpoint"]]; OIDServiceConfiguration *configuration = [[OIDServiceConfiguration alloc] initWithAuthorizationEndpoint:authorizationEndpoint tokenEndpoint:tokenEndpoint - registrationEndpoint:registrationEndpoint]; + issuer:nil + registrationEndpoint:registrationEndpoint + endSessionEndpoint:endSessionEndpoint]; return configuration; } @@ -384,6 +421,49 @@ - (void)refreshWithConfiguration: (OIDServiceConfiguration *)configuration }]; } +- (void)endSessionWithConfiguration: (OIDServiceConfiguration *) configuration + idTokenHint: (NSString *) idTokenHint + postLogoutRedirectURL: (NSString *) postLogoutRedirectURL + additionalParameters: (NSDictionary *_Nullable) additionalParameters + resolve: (RCTPromiseResolveBlock) resolve + reject: (RCTPromiseRejectBlock) reject { + + OIDEndSessionRequest *endSessionRequest = + [[OIDEndSessionRequest alloc] initWithConfiguration: configuration + idTokenHint: idTokenHint + postLogoutRedirectURL: [NSURL URLWithString:postLogoutRedirectURL] + additionalParameters: additionalParameters]; + + id appDelegate = (id)[UIApplication sharedApplication].delegate; + if (![[appDelegate class] conformsToProtocol:@protocol(RNAppAuthAuthorizationFlowManager)]) { + [NSException raise:@"RNAppAuth Missing protocol conformance" + format:@"%@ does not conform to RNAppAuthAuthorizationFlowManager", appDelegate]; + } + appDelegate.authorizationFlowManagerDelegate = self; + __weak typeof(self) weakSelf = self; + + rnAppAuthTaskId = [UIApplication.sharedApplication beginBackgroundTaskWithExpirationHandler:^{ + [UIApplication.sharedApplication endBackgroundTask:rnAppAuthTaskId]; + rnAppAuthTaskId = UIBackgroundTaskInvalid; + }]; + + UIViewController *presentingViewController = appDelegate.window.rootViewController.view.window ? appDelegate.window.rootViewController : appDelegate.window.rootViewController.presentedViewController; + + _currentSession = [OIDAuthorizationService presentEndSessionRequest: endSessionRequest + externalUserAgent: [self getExternalUserAgentWithPresentingViewController:presentingViewController] + callback: ^(OIDEndSessionResponse *_Nullable response, NSError *_Nullable error) { + typeof(self) strongSelf = weakSelf; + strongSelf->_currentSession = nil; + [UIApplication.sharedApplication endBackgroundTask:rnAppAuthTaskId]; + rnAppAuthTaskId = UIBackgroundTaskInvalid; + if (response) { + resolve([self formatEndSessionResponse:response]); + } else { + reject([self getErrorCode: error defaultCode:@"end_session_failed"], + [self getErrorMessage: error], error); + } + }]; +} - (void) configureUrlSession: (NSDictionary*) headers { NSURLSessionConfiguration* configuration = [NSURLSessionConfiguration defaultSessionConfiguration]; @@ -485,6 +565,14 @@ - (NSDictionary*)formatRegistrationResponse: (OIDRegistrationResponse*) response }; } +- (NSDictionary*)formatEndSessionResponse: (OIDEndSessionResponse*)response +{ + return @{@"state": response.state ? response.state : @"", + @"idTokenHint": response.request.idTokenHint, + @"postLogoutRedirectUri": response.request.postLogoutRedirectURL.absoluteString + }; +} + - (NSString*)getErrorCode: (NSError*) error defaultCode: (NSString *) defaultCode { if ([[error domain] isEqualToString:OIDOAuthAuthorizationErrorDomain]) { switch ([error code]) { @@ -544,4 +632,17 @@ - (NSString*)getErrorMessage: (NSError*) error { } } +- (id)getExternalUserAgentWithPresentingViewController: (UIViewController *)presentingViewController +{ + id externalUserAgent; + #if TARGET_OS_IOS + externalUserAgent = [[OIDExternalUserAgentIOS alloc] initWithPresentingViewController:presentingViewController]; + #elif TARGET_OS_MACCATALYST + externalUserAgent = [[OIDExternalUserAgentCatalyst alloc] initWithPresentingViewController:presentingViewController]; + #elif TARGET_OS_OSX + externalUserAgent = [[OIDExternalUserAgentMac alloc] init]; + #endif + return externalUserAgent; +} + @end