From be53a4c0e717b3dfff27b3c58bf68cb627b2f217 Mon Sep 17 00:00:00 2001 From: Elliott Baron Date: Tue, 27 Jun 2023 21:17:36 -0400 Subject: [PATCH] fix(login): submit form to log out from OpenShift OAuth Signed-off-by: Elliott Baron --- jest.config.js | 6 + src/app/Shared/Services/Login.service.tsx | 109 ++++- .../Shared/Services/Login.service.test.tsx | 442 ++++++++++++++++++ 3 files changed, 532 insertions(+), 25 deletions(-) create mode 100644 src/test/Shared/Services/Login.service.test.tsx diff --git a/jest.config.js b/jest.config.js index f2a5643e1..dfcbae0a4 100644 --- a/jest.config.js +++ b/jest.config.js @@ -5,6 +5,12 @@ module.exports = { // Automatically clear mock calls and instances between every test clearMocks: true, + // Automatically reset mock state before every test + resetMocks: true, + + // Automatically restore mock state and implementation before every test + restoreMocks: true, + // The directory where Jest should output its coverage files coverageDirectory: 'coverage', diff --git a/src/app/Shared/Services/Login.service.tsx b/src/app/Shared/Services/Login.service.tsx index d162c16a3..667aa1fca 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'; @@ -133,7 +133,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); }) @@ -197,45 +197,100 @@ 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(token), of(method), resp]); }), - concatMap((response) => { - if (response.status === 302) { + concatMap((parts) => { + const token = parts[0]; + const method = parts[1]; + const response = parts[2]; + + 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.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(), + concatMap((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 of(loginUrl); + }), + tap(() => { + this.resetSessionState(); + this.resetAuthMethod(); + }), + concatMap((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 of(true); + }) + ); + } + setSessionState(state: SessionState): void { this.sessionState.next(state); } @@ -247,9 +302,13 @@ 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 { + this.resetAuthMethod(); const url = new URL(window.location.href.split('#')[0]); window.location.href = url.pathname.match(/\/settings/i) ? '/' : url.pathname; } diff --git a/src/test/Shared/Services/Login.service.test.tsx b/src/test/Shared/Services/Login.service.test.tsx new file mode 100644 index 000000000..2ecc0d9c6 --- /dev/null +++ b/src/test/Shared/Services/Login.service.test.tsx @@ -0,0 +1,442 @@ +/* + * Copyright The Cryostat Authors + * + * The Universal Permissive License (UPL), Version 1.0 + * + * Subject to the condition set forth below, permission is hereby granted to any + * person obtaining a copy of this software, associated documentation and/or data + * (collectively the "Software"), free of charge and under any and all copyright + * rights in the Software, and any and all patent rights owned or freely + * licensable by each licensor hereunder covering either (i) the unmodified + * Software as contributed to or provided by such licensor, or (ii) the Larger + * Works (as defined below), to deal in both + * + * (a) the Software, and + * (b) any piece of software and/or hardware listed in the lrgrwrks.txt file if + * one is included with the Software (each a "Larger Work" to which the Software + * is contributed by such licensors), + * + * without restriction, including without limitation the rights to copy, create + * derivative works of, display, perform, and distribute the Software and make, + * use, sell, offer for sale, import, export, have made, and have sold the + * Software and the Larger Work(s), and to sublicense the foregoing rights on + * either these or other terms. + * + * This license is subject to the following condition: + * The above copyright notice and either this complete permission notice or at + * a minimum a reference to the UPL must be included in all copies or + * substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { AuthMethod, LoginService, SessionState } from '@app/Shared/Services/Login.service'; +import { TargetService } from '@app/Shared/Services/Target.service'; +import { AuthCredentials } from '@app/Shared/Services/AuthCredentials.service'; +import { ApiV2Response } from '@app/Shared/Services/Api.service'; +import { SettingsService } from '@app/Shared/Services/Settings.service'; +import { firstValueFrom, of, timeout } from 'rxjs'; +import { fromFetch } from 'rxjs/fetch'; + +jest.mock('rxjs/fetch', () => { + return { + fromFetch: jest.fn((url: any, opts: any): any => {}), + }; +}); + +jest.unmock('@app/Shared/Services/Login.service'); + +describe('Login.service', () => { + const mockFromFetch = fromFetch as jest.Mock; + let svc: LoginService; + + describe('setLoggedOut', () => { + let authCreds: AuthCredentials; + let targetSvc: TargetService; + let settingsSvc: SettingsService; + let saveLocation: Location; + + beforeAll(() => { + // Redirection is unimplemented in JSDOM, and cannot by spied on + saveLocation = window.location; + const locationMock = { + ...saveLocation, + replace: jest.fn(), + } as Location; + Object.defineProperty(window, 'location', { + value: locationMock, + writable: true, + }); + }); + + afterAll(() => { + // Restore the original location + Object.defineProperty(window, 'location', { + value: saveLocation, + writable: false, + }); + }); + + beforeEach(() => { + authCreds = {} as AuthCredentials; + targetSvc = {} as TargetService; + settingsSvc = new SettingsService(); + (settingsSvc.webSocketDebounceMs as jest.Mock).mockReturnValue(0); + }); + + afterEach(() => { + sessionStorage.clear(); + }); + + describe('with Basic AuthMethod', () => { + beforeEach(async () => { + const initAuthResp = createResponse(401, false, new Headers({ 'X-WWW-Authenticate': 'Basic' }), { + meta: { + type: 'text/plain', + status: 'Unauthorized', + }, + data: { + reason: 'HTTP Authorization Failure', + }, + }); + const authResp = createResponse(200, true, new Headers({ 'X-WWW-Authenticate': 'Basic' }), { + meta: { + type: 'application/json', + status: 'OK', + }, + data: { + result: { + username: 'user', + }, + }, + }); + const logoutResp = createResponse(200, true); + mockFromFetch + .mockReturnValueOnce(of(initAuthResp)) + .mockReturnValueOnce(of(authResp)) + .mockReturnValueOnce(of(logoutResp)); + const token = 'user:d74ff0ee8da3b9806b18c877dbf29bbde50b5bd8e4dad7a3a725000feb82e8f1'; + window.location.href = 'https://example.com/'; + location.href = window.location.href; + svc = new LoginService(targetSvc, authCreds, settingsSvc); + await expect(firstValueFrom(svc.checkAuth(token, AuthMethod.BASIC))).resolves.toBeTruthy(); + }); + + it('should emit true', async () => { + const result = await firstValueFrom(svc.setLoggedOut()); + expect(result).toBeTruthy(); + }); + + it('should make expected API calls', async () => { + await firstValueFrom(svc.setLoggedOut()); + expect(mockFromFetch).toHaveBeenCalledTimes(3); + expect(mockFromFetch).toHaveBeenNthCalledWith(2, `/api/v2.1/auth`, { + credentials: 'include', + mode: 'cors', + method: 'POST', + body: null, + headers: new Headers({ + Authorization: `Basic dXNlcjpkNzRmZjBlZThkYTNiOTgwNmIxOGM4NzdkYmYyOWJiZGU1MGI1YmQ4ZTRkYWQ3YTNhNzI1MDAwZmViODJlOGYx`, + }), + }); + expect(mockFromFetch).toHaveBeenNthCalledWith(3, `/api/v2.1/logout`, { + credentials: 'include', + mode: 'cors', + method: 'POST', + body: null, + headers: new Headers({ + Authorization: `Basic dXNlcjpkNzRmZjBlZThkYTNiOTgwNmIxOGM4NzdkYmYyOWJiZGU1MGI1YmQ4ZTRkYWQ3YTNhNzI1MDAwZmViODJlOGYx`, + }), + }); + }); + + it('should emit logged-out', async () => { + await firstValueFrom(svc.setLoggedOut()); + await firstValueFrom(svc.loggedOut().pipe(timeout({ first: 1000 }))); + }); + + it('should reset session state', async () => { + const beforeState = await firstValueFrom(svc.getSessionState()); + expect(beforeState).toEqual(SessionState.CREATING_USER_SESSION); + await firstValueFrom(svc.setLoggedOut()); + const afterState = await firstValueFrom(svc.getSessionState()); + expect(afterState).toEqual(SessionState.NO_USER_SESSION); + }); + + it('should reset the auth method', async () => { + await firstValueFrom(svc.setLoggedOut()); + await expect(firstValueFrom(svc.getAuthMethod())).resolves.toEqual(AuthMethod.UNKNOWN); + }); + + it('should redirect to login page', async () => { + await firstValueFrom(svc.setLoggedOut()); + expect(window.location.href).toEqual('/'); + }); + }); + + describe('with Bearer AuthMethod', () => { + let authResp: Response; + let logoutResp: Response; + let authRedirectResp: Response; + let submitSpy: jest.SpyInstance; + + beforeEach(async () => { + authResp = createResponse(200, true, new Headers({ 'X-WWW-Authenticate': 'Bearer' }), { + meta: { + type: 'application/json', + status: 'OK', + }, + data: { + result: { + username: 'kube:admin', + }, + }, + }); + logoutResp = createResponse( + 302, + true, + new Headers({ + 'X-Location': 'https://oauth-server.example.com/logout', + 'access-control-expose-headers': 'Location', + }) + ); + authRedirectResp = createResponse( + 302, + true, + new Headers({ + 'X-Location': + 'https://oauth-server.example.com/oauth/authorize?client_id=system%3Aserviceaccount%3Amy-namespace%3Amy-cryostat&response_type=token&response_mode=fragment&scope=user%3Acheck-access+role%3Acryostat-operator-oauth-client%3Amy-namespace', + 'access-control-expose-headers': 'Location', + }), + { + meta: { + type: 'application/json', + status: 'Found', + }, + data: { + result: undefined, + }, + } + ); + // Submit is unimplemented in JSDOM + submitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit').mockImplementation(); + + mockFromFetch.mockReturnValueOnce(of(authResp)); + const token = 'sha256~helloworld'; + window.location.href = 'https://example.com/#token_type=Bearer&access_token=' + token; + location.hash = 'token_type=Bearer&access_token=' + token; + svc = new LoginService(targetSvc, authCreds, settingsSvc); + await expect(firstValueFrom(svc.getAuthMethod())).resolves.toEqual(AuthMethod.BEARER); + expect(mockFromFetch).toBeCalledTimes(1); + }); + + describe('with no errors', () => { + beforeEach(async () => { + mockFromFetch.mockReturnValueOnce(of(logoutResp)).mockReturnValueOnce(of(authRedirectResp)); + }); + + it('should emit true', async () => { + const result = await firstValueFrom(svc.setLoggedOut()); + expect(result).toBeTruthy(); + }); + + it('should make expected API calls', async () => { + await firstValueFrom(svc.setLoggedOut()); + expect(mockFromFetch).toHaveBeenCalledTimes(3); + expect(mockFromFetch).toHaveBeenNthCalledWith(1, `/api/v2.1/auth`, { + credentials: 'include', + mode: 'cors', + method: 'POST', + body: null, + headers: new Headers({ + Authorization: `Bearer c2hhMjU2fmhlbGxvd29ybGQ`, + }), + }); + expect(mockFromFetch).toHaveBeenNthCalledWith(2, `/api/v2.1/logout`, { + credentials: 'include', + mode: 'cors', + method: 'POST', + body: null, + headers: new Headers({ + Authorization: `Bearer c2hhMjU2fmhlbGxvd29ybGQ`, + }), + }); + expect(mockFromFetch).toHaveBeenNthCalledWith(3, `/api/v2.1/auth`, { + credentials: 'include', + mode: 'cors', + method: 'POST', + body: null, + }); + }); + + it('should submit a form to the OAuth server', async () => { + await firstValueFrom(svc.setLoggedOut()); + const rawForm = document.getElementById('logoutForm'); + expect(rawForm).toBeInTheDocument(); + expect(rawForm).toBeInstanceOf(HTMLFormElement); + const form = rawForm as HTMLFormElement; + expect(form.action).toEqual('https://oauth-server.example.com/logout'); + expect(form.method.toUpperCase()).toEqual('POST'); + + expect(form.childElementCount).toBe(1); + const rawInput = form.firstChild; + expect(rawInput).toBeInstanceOf(HTMLInputElement); + const input = rawInput as HTMLInputElement; + expect(input.value).toEqual( + '/oauth/authorize?client_id=system%3Aserviceaccount%3Amy-namespace%3Amy-cryostat&response_type=token&response_mode=fragment&scope=user%3Acheck-access+role%3Acryostat-operator-oauth-client%3Amy-namespace' + ); + expect(input.name).toEqual('then'); + expect(input.type).toEqual('hidden'); + + expect(document.body).toContainElement(form); + expect(submitSpy).toHaveBeenCalled(); + }); + + it('should emit logged-out', async () => { + await firstValueFrom(svc.setLoggedOut()); + await firstValueFrom(svc.loggedOut().pipe(timeout({ first: 1000 }))); + }); + + it('should reset session state', async () => { + const beforeState = await firstValueFrom(svc.getSessionState()); + expect(beforeState).toEqual(SessionState.CREATING_USER_SESSION); + await firstValueFrom(svc.setLoggedOut()); + const afterState = await firstValueFrom(svc.getSessionState()); + expect(afterState).toEqual(SessionState.NO_USER_SESSION); + }); + + it('should reset the auth method', async () => { + await firstValueFrom(svc.setLoggedOut()); + await expect(firstValueFrom(svc.getAuthMethod())).resolves.toEqual(AuthMethod.UNKNOWN); + }); + }); + + describe('with errors', () => { + let logSpy: jest.SpyInstance; + beforeEach(() => { + logSpy = jest.spyOn(window.console, 'error').mockImplementation(); + }); + + describe('backend logout returns non-302 response', () => { + beforeEach(() => { + const badLogoutResp = createResponse( + 200, + true, + new Headers({ + 'X-Location': 'https://oauth-server.example.com/logout', + 'access-control-expose-headers': 'Location', + }) + ); + mockFromFetch.mockReturnValueOnce(of(badLogoutResp)).mockReturnValueOnce(of(authRedirectResp)); + }); + + it('should fail to log out', async () => { + const result = await firstValueFrom(svc.setLoggedOut()); + expect(result).toBeFalsy(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('"message":"Could not find OAuth logout endpoint"') + ); + }); + }); + + describe('backend logout returns 302 response without X-Location header', () => { + beforeEach(() => { + const badLogoutResp = createResponse( + 302, + true, + new Headers({ + 'access-control-expose-headers': 'Location', + }) + ); + mockFromFetch.mockReturnValueOnce(of(badLogoutResp)).mockReturnValueOnce(of(authRedirectResp)); + }); + + it('should fail to log out', async () => { + const result = await firstValueFrom(svc.setLoggedOut()); + expect(result).toBeFalsy(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('"message":"Could not find OAuth logout endpoint"') + ); + }); + }); + + describe('backend auth returns non-302 response', () => { + beforeEach(() => { + const badAuthRedirectResp = createResponse( + 200, + true, + new Headers({ + 'X-Location': + 'https://oauth-server.example.com/oauth/authorize?client_id=system%3Aserviceaccount%3Amy-namespace%3Amy-cryostat&response_type=token&response_mode=fragment&scope=user%3Acheck-access+role%3Acryostat-operator-oauth-client%3Amy-namespace', + 'access-control-expose-headers': 'Location', + }), + { + meta: { + type: 'application/json', + status: 'OK', + }, + data: { + result: undefined, + }, + } + ); + mockFromFetch.mockReturnValueOnce(of(logoutResp)).mockReturnValueOnce(of(badAuthRedirectResp)); + }); + + it('should fail to log out', async () => { + const result = await firstValueFrom(svc.setLoggedOut()); + expect(result).toBeFalsy(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('"message":"Could not find OAuth login endpoint"') + ); + }); + }); + + describe('backend auth returns 302 response without X-Location header', () => { + beforeEach(() => { + const badAuthRedirectResp = createResponse( + 302, + true, + new Headers({ + 'access-control-expose-headers': 'Location', + }), + { + meta: { + type: 'application/json', + status: 'Found', + }, + data: { + result: undefined, + }, + } + ); + mockFromFetch.mockReturnValueOnce(of(logoutResp)).mockReturnValueOnce(of(badAuthRedirectResp)); + }); + + it('should fail to log out', async () => { + const result = await firstValueFrom(svc.setLoggedOut()); + expect(result).toBeFalsy(); + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining('"message":"Could not find OAuth login endpoint"') + ); + }); + }); + }); + }); + }); +}); + +function createResponse(status: number, ok: boolean, headers?: Headers, jsonBody?: ApiV2Response): Response { + return { + status: status, + ok: ok, + headers: headers, + json: () => Promise.resolve(jsonBody), + } as Response; +}