diff --git a/src/app/Rules/CreateRule.tsx b/src/app/Rules/CreateRule.tsx index a6f637bd19..8195f13e84 100644 --- a/src/app/Rules/CreateRule.tsx +++ b/src/app/Rules/CreateRule.tsx @@ -53,6 +53,7 @@ import { Button, Card, CardBody, + CardTitle, Form, FormGroup, FormSelect, @@ -659,6 +660,7 @@ const Comp: React.FC = () => { + Match Expression Visualizer diff --git a/src/app/Shared/Services/Login.service.tsx b/src/app/Shared/Services/Login.service.tsx index d162c16a3a..a8b391bcee 100644 --- a/src/app/Shared/Services/Login.service.tsx +++ b/src/app/Shared/Services/Login.service.tsx @@ -39,7 +39,7 @@ import { Base64 } from 'js-base64'; import { combineLatest, Observable, ObservableInput, of, ReplaySubject } from 'rxjs'; import { fromFetch } from 'rxjs/fetch'; import { catchError, concatMap, debounceTime, distinctUntilChanged, first, map, tap } from 'rxjs/operators'; -import { ApiV2Response, HttpError } from './Api.service'; +import { ApiV2Response } from './Api.service'; import { Credential, AuthCredentials } from './AuthCredentials.service'; import { isQuotaExceededError } from './Report.service'; import { SettingsService } from './Settings.service'; @@ -108,9 +108,7 @@ export class LoginService { headers: this.getAuthHeaders(token, method), }).pipe( concatMap((response) => { - if (!this.authMethod.isStopped) { - this.completeAuthMethod(response.headers.get('X-WWW-Authenticate') || ''); - } + this.updateAuthMethod(response.headers.get('X-WWW-Authenticate') || ''); if (response.status === 302) { const redirectUrl = response.headers.get('X-Location'); @@ -133,7 +131,7 @@ export class LoginService { return jsonResp.meta.status === 'OK'; }), catchError((e: Error): ObservableInput => { - window.console.error(JSON.stringify(e)); + window.console.error(JSON.stringify(e, Object.getOwnPropertyNames(e))); this.authMethod.complete(); return of(false); }) @@ -173,7 +171,9 @@ export class LoginService { } getAuthMethod(): Observable { - return this.authMethod.asObservable(); + return this.authMethod + .asObservable() + .pipe(distinctUntilChanged(), debounceTime(this.settings.webSocketDebounceMs())); } getUsername(): Observable { @@ -197,45 +197,97 @@ export class LoginService { const token = parts[0]; const method = parts[1]; - return fromFetch(`${this.authority}/api/v2.1/logout`, { + // Call the logout backend endpoint + const resp = fromFetch(`${this.authority}/api/v2.1/logout`, { credentials: 'include', mode: 'cors', method: 'POST', body: null, headers: this.getAuthHeaders(token, method), }); + return combineLatest([of(method), resp]); }), - concatMap((response) => { - if (response.status === 302) { + concatMap(([method, response]) => { + if (method === AuthMethod.BEARER) { + // Assume Bearer method means OpenShift const redirectUrl = response.headers.get('X-Location'); - if (!redirectUrl) { - throw new HttpError(response); + // On OpenShift, the backend logout endpoint should respond with a redirect + if (response.status !== 302 || !redirectUrl) { + throw new Error('Could not find OAuth logout endpoint'); } - - return fromFetch(redirectUrl, { - credentials: 'include', - mode: 'cors', - method: 'POST', - body: null, - }); - } else { - return of(response); - } - }), - map((response) => response.ok), - tap((responseOk) => { - if (responseOk) { - this.resetSessionState(); - this.navigateToLoginPage(); + return this.openshiftLogout(redirectUrl); } + return of(response).pipe( + map((response) => response.ok), + tap(() => { + this.resetSessionState(); + this.resetAuthMethod(); + this.navigateToLoginPage(); + }) + ); }), catchError((e: Error): ObservableInput => { - window.console.error(JSON.stringify(e)); + window.console.error(JSON.stringify(e, Object.getOwnPropertyNames(e))); return of(false); }) ); } + private openshiftLogout(logoutUrl: string): Observable { + // Query the backend auth endpoint. On OpenShift, without providing a + // token, this should return a redirect to OpenShift's OAuth login. + const resp = fromFetch(`${this.authority}/api/v2.1/auth`, { + credentials: 'include', + mode: 'cors', + method: 'POST', + body: null, + }); + + return resp.pipe( + first(), + map((response) => { + // Fail if we don't get a valid redirect URL for the user to log + // back in. + const loginUrlString = response.headers.get('X-Location'); + if (response.status !== 302 || !loginUrlString) { + throw new Error('Could not find OAuth login endpoint'); + } + + const loginUrl = new URL(loginUrlString); + if (!loginUrl) { + throw new Error(`OAuth login endpoint is invalid: ${loginUrlString}`); + } + return loginUrl; + }), + tap(() => { + this.resetSessionState(); + this.resetAuthMethod(); + }), + map((loginUrl) => { + // Create a hidden form to submit to the OAuth server's + // logout endpoint. The "then" parameter will redirect back + // to the login/authorize endpoint once logged out. + const form = document.createElement('form'); + form.id = 'logoutForm'; + form.action = logoutUrl; + form.method = 'POST'; + + const input = document.createElement('input'); + // The OAuth server is strict about valid redirects. Convert + // the result from our auth response into a relative URL. + input.value = `${loginUrl.pathname}${loginUrl.search}`; + input.name = 'then'; + input.type = 'hidden'; + + form.appendChild(input); + document.body.appendChild(form); + + form.submit(); + return true; + }) + ); + } + setSessionState(state: SessionState): void { this.sessionState.next(state); } @@ -247,9 +299,12 @@ export class LoginService { this.sessionState.next(SessionState.NO_USER_SESSION); } - private navigateToLoginPage(): void { + private resetAuthMethod(): void { this.authMethod.next(AuthMethod.UNKNOWN); this.removeCacheItem(this.AUTH_METHOD_KEY); + } + + private navigateToLoginPage(): void { const url = new URL(window.location.href.split('#')[0]); window.location.href = url.pathname.match(/\/settings/i) ? '/' : url.pathname; } @@ -281,7 +336,7 @@ export class LoginService { } } - private completeAuthMethod(method: string): void { + private updateAuthMethod(method: string): void { let validMethod = method as AuthMethod; if (!Object.values(AuthMethod).includes(validMethod)) { @@ -290,7 +345,6 @@ export class LoginService { this.authMethod.next(validMethod); this.setCacheItem(this.AUTH_METHOD_KEY, validMethod); - this.authMethod.complete(); } private getCacheItem(key: string): string {