From e0df82b8c72a5fe4c1d043407751b2d7c409a89e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 7 Oct 2024 14:02:14 +0000 Subject: [PATCH] Add JWT authentication type to MultipleAuthentication (#2107) * Add JWT authentication type to MultipleAuthentication Signed-off-by: merlinz01 * clarify comments in AuthenticationType.authHandler Signed-off-by: merlinz01 * collect additional auth headers from all multi-auth handlers Signed-off-by: merlinz01 * implement MultipleAuthentication.getCookie Signed-off-by: merlinz01 * Add test for multiauth with JWT Signed-off-by: merlinz01 * add explanatory comments in login page Signed-off-by: merlinz01 * remove logging of JWT in test Signed-off-by: merlinz01 * add check for empty auth options list in login page Signed-off-by: merlinz01 * Add comments about getCookie method Signed-off-by: merlinz01 * remove unneeded comment Signed-off-by: merlinz01 * Don't load sample data in JWT multiauth test Signed-off-by: merlinz01 * remove sample data code and unneeded promise handling Signed-off-by: merlinz01 * update test for missing JWT Signed-off-by: merlinz01 * ensure JWT signing key consistency Signed-off-by: merlinz01 --------- Signed-off-by: merlinz01 Co-authored-by: Derek Ho (cherry picked from commit 252d8fb390fb2e2cd0a5ed6687f60e2a8a23d99d) Signed-off-by: github-actions[bot] --- public/apps/login/login-page.tsx | 31 ++-- server/auth/types/authentication_type.ts | 35 ++-- server/auth/types/multiple/multi_auth.ts | 26 ++- test/constant.ts | 3 + test/jest_integration/jwt_multiauth.test.ts | 181 ++++++++++++++++++++ 5 files changed, 240 insertions(+), 36 deletions(-) create mode 100644 test/jest_integration/jwt_multiauth.test.ts diff --git a/public/apps/login/login-page.tsx b/public/apps/login/login-page.tsx index 4591c032c..ebf5f63e0 100644 --- a/public/apps/login/login-page.tsx +++ b/public/apps/login/login-page.tsx @@ -168,22 +168,24 @@ export function LoginPage(props: LoginPageDeps) { const formOptions = (options: string | string[]) => { let formBody = []; const formBodyOp = []; - let authOpts = []; + let authOpts: string[] = []; + // Convert auth options to a usable array if (typeof options === 'string') { - if (options === '') { - authOpts.push(AuthType.BASIC); - } else { + if (options !== '') { authOpts.push(options.toLowerCase()); } - } else { - if (options && options.length === 1 && options[0] === '') { - authOpts.push(AuthType.BASIC); - } else { - authOpts = [...options]; - } + } else if (!(options && options.length === 1 && options[0] === '')) { + authOpts = [...options]; + } + if (authOpts.length === 0) { + authOpts.push(AuthType.BASIC); } + // Remove proxy and jwt from the list because they do not have a login button + // The count of visible options determines if a separator gets added + authOpts = authOpts.filter((auth) => auth !== AuthType.PROXY && auth !== AuthType.JWT); + for (let i = 0; i < authOpts.length; i++) { switch (authOpts[i].toLowerCase()) { case AuthType.BASIC: { @@ -237,10 +239,8 @@ export function LoginPage(props: LoginPageDeps) { ); } - if ( - authOpts.length > 1 && - !(authOpts.includes(AuthType.PROXY) && authOpts.length === 2) - ) { + if (authOpts.length > 1) { + // Add a separator between the username/password form and the other login options formBody.push(); formBody.push(); formBody.push(); @@ -261,9 +261,6 @@ export function LoginPage(props: LoginPageDeps) { formBodyOp.push(renderLoginButton(AuthType.SAML, samlAuthLoginUrl, samlConfig)); break; } - case AuthType.PROXY: { - break; - } default: { setloginFailed(true); setloginError( diff --git a/server/auth/types/authentication_type.ts b/server/auth/types/authentication_type.ts index 783bdd142..bdb8fe37f 100755 --- a/server/auth/types/authentication_type.ts +++ b/server/auth/types/authentication_type.ts @@ -96,35 +96,32 @@ export abstract class AuthenticationType implements IAuthenticationType { } public authHandler: AuthenticationHandler = async (request, response, toolkit) => { - // skip auth for APIs that do not require auth + // Skip authentication for APIs that do not require it if (this.authNotRequired(request)) { return toolkit.authenticated(); } const authState: OpenSearchDashboardsAuthState = {}; - - // if browser request, auth logic is: - // 1. check if request includes auth header or parameter(e.g. jwt in url params) is present, if so, authenticate with auth header. - // 2. if auth header not present, check if auth cookie is present, if no cookie, send to authentication workflow - // 3. verify whether auth cookie is valid, if not valid, send to authentication workflow - // 4. if cookie is valid, pass to route handlers const authHeaders = {}; let cookie: SecuritySessionCookie | null | undefined; let authInfo: any | undefined; - // if this is an REST API call, suppose the request includes necessary auth header + + // If the request contains authentication data (e.g. Authorization header or JWT in url parameters), use that to authenticate the request. if (this.requestIncludesAuthInfo(request)) { try { + // Build the auth headers from the request const additionalAuthHeader = await this.getAdditionalAuthHeader(request); Object.assign(authHeaders, additionalAuthHeader); authInfo = await this.securityClient.authinfo(request, additionalAuthHeader); cookie = this.getCookie(request, authInfo); - // set tenant from cookie if exist + // Set the tenant from the cookie const browserCookie = await this.sessionStorageFactory.asScoped(request).get(); if (browserCookie && isValidTenant(browserCookie.tenant)) { cookie.tenant = browserCookie.tenant; } + // Save the cookie this.sessionStorageFactory.asScoped(request).set(cookie); } catch (error: any) { return response.unauthorized({ @@ -132,7 +129,7 @@ export abstract class AuthenticationType implements IAuthenticationType { }); } } else { - // no auth header in request, try cookie + // If the request does not contain authentication data, check for a stored cookie. try { cookie = await this.sessionStorageFactory.asScoped(request).get(); } catch (error: any) { @@ -140,33 +137,35 @@ export abstract class AuthenticationType implements IAuthenticationType { cookie = undefined; } + // If the cookie is not valid, clear the cookie and send the request to the authentication workflow if (!cookie || !(await this.isValidCookie(cookie, request))) { - // clear cookie + // Clear the cookie this.sessionStorageFactory.asScoped(request).clear(); - // for assets, we can still pass it to resource handler as notHandled. - // marking it as authenticated may result in login pop up when auth challenge + // For assets, we can still pass it to resource handler as notHandled. + // Marking it as authenticated may result in login pop up when auth challenge // is enabled. if (request.url.pathname && request.url.pathname.startsWith('/bundles/')) { return toolkit.notHandled(); } - // allow optional authentication + // Allow optional authentication if (this.authOptional(request)) { return toolkit.authenticated(); } - // send to auth workflow + // Send the request to the authentication workflow return this.handleUnauthedRequest(request, response, toolkit); } - // extend session expiration time + // If the cookie is still valid, update the cookie with a new expiry time. if (this.config.session.keepalive) { cookie!.expiryTime = this.getKeepAliveExpiry(cookie!, request); this.sessionStorageFactory.asScoped(request).set(cookie!); } - // cookie is valid - // build auth header + // At this point we have a valid cookie. + + // Build the auth headers from the cookie. const authHeadersFromCookie = this.buildAuthHeaderFromCookie(cookie!, request); Object.assign(authHeaders, authHeadersFromCookie); const additionalAuthHeader = await this.getAdditionalAuthHeader(request); diff --git a/server/auth/types/multiple/multi_auth.ts b/server/auth/types/multiple/multi_auth.ts index 4b4f64834..ba7daada2 100644 --- a/server/auth/types/multiple/multi_auth.ts +++ b/server/auth/types/multiple/multi_auth.ts @@ -34,6 +34,7 @@ import { OpenIdAuthentication, ProxyAuthentication, SamlAuthentication, + JwtAuthentication, } from '../../types'; export class MultipleAuthentication extends AuthenticationType { @@ -111,6 +112,19 @@ export class MultipleAuthentication extends AuthenticationType { this.authHandlers.set(AuthType.PROXY, ProxyAuth); break; } + case AuthType.JWT: { + const JwtAuth = new JwtAuthentication( + this.config, + this.sessionStorageFactory, + this.router, + this.esClient, + this.coreSetup, + this.logger + ); + await JwtAuth.init(); + this.authHandlers.set(AuthType.JWT, JwtAuth); + break; + } default: { throw new Error(`Unsupported authentication type: ${this.authTypes[i]}`); } @@ -140,11 +154,21 @@ export class MultipleAuthentication extends AuthenticationType { if (reqAuthType && this.authHandlers.has(reqAuthType)) { return await this.authHandlers.get(reqAuthType)!.getAdditionalAuthHeader(request); } else { - return {}; + const authHeaders: any = {}; + for (const handler of this.authHandlers.values()) { + Object.assign(authHeaders, await handler.getAdditionalAuthHeader(request)); + } + return authHeaders; } } getCookie(request: OpenSearchDashboardsRequest, authInfo: any): SecuritySessionCookie { + // TODO: This logic is only applicable for JWT auth type + for (const handler of this.authHandlers.values()) { + if (handler.requestIncludesAuthInfo(request)) { + return handler.getCookie(request, authInfo); + } + } return {}; } diff --git a/test/constant.ts b/test/constant.ts index 0f450e2b8..6841033b3 100644 --- a/test/constant.ts +++ b/test/constant.ts @@ -25,3 +25,6 @@ export const AUTHORIZATION_HEADER_NAME: string = 'Authorization'; export const PROXY_USER: string = 'x-proxy-user'; export const PROXY_ROLE: string = 'x-proxy-roles'; export const PROXY_ADMIN_ROLE: string = 'admin'; + +export const JWT_ADMIN_ROLE: string = 'admin'; +export const JWT_SIGNING_KEY: string = '99011df6ef40e4a2cd9cd6ccb2d649e0'; diff --git a/test/jest_integration/jwt_multiauth.test.ts b/test/jest_integration/jwt_multiauth.test.ts new file mode 100644 index 000000000..9d8f28242 --- /dev/null +++ b/test/jest_integration/jwt_multiauth.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright OpenSearch Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +import * as osdTestServer from '../../../../src/core/test_helpers/osd_server'; +import { Root } from '../../../../src/core/server/root'; +import { resolve } from 'path'; +import { describe, it, beforeAll, afterAll } from '@jest/globals'; +import { + ADMIN_CREDENTIALS, + OPENSEARCH_DASHBOARDS_SERVER_USER, + OPENSEARCH_DASHBOARDS_SERVER_PASSWORD, + ADMIN_USER, + JWT_ADMIN_ROLE, + JWT_SIGNING_KEY, +} from '../constant'; +import wreck from '@hapi/wreck'; +import { SignJWT } from 'jose'; + +describe('start OpenSearch Dashboards server', () => { + let root: Root; + let config; + + beforeAll(async () => { + root = osdTestServer.createRootWithSettings( + { + plugins: { + scanDirs: [resolve(__dirname, '../..')], + }, + home: { disableWelcomeScreen: true }, + server: { + host: 'localhost', + port: 5601, + }, + logging: { + silent: true, + verbose: false, + }, + opensearch: { + hosts: ['https://localhost:9200'], + ignoreVersionMismatch: true, + ssl: { verificationMode: 'none' }, + username: OPENSEARCH_DASHBOARDS_SERVER_USER, + password: OPENSEARCH_DASHBOARDS_SERVER_PASSWORD, + requestHeadersAllowlist: ['securitytenant', 'Authorization'], + }, + opensearch_security: { + auth: { + anonymous_auth_enabled: false, + type: ['basicauth', 'jwt'], + multiple_auth_enabled: true, + }, + jwt: { + url_param: 'token', + }, + multitenancy: { + enabled: true, + tenants: { + enable_global: true, + enable_private: true, + preferred: ['Private', 'Global'], + }, + }, + }, + }, + { + // to make ignoreVersionMismatch setting work + // can be removed when we have corresponding ES version + dev: true, + } + ); + + console.log('Starting OpenSearchDashboards server..'); + await root.setup(); + await root.start(); + + const getConfigResponse = await wreck.get( + 'https://localhost:9200/_plugins/_security/api/securityconfig', + { + rejectUnauthorized: false, + headers: { + authorization: ADMIN_CREDENTIALS, + }, + } + ); + const responseBody = (getConfigResponse.payload as Buffer).toString(); + config = JSON.parse(responseBody).config; + const jwtConfig = { + http_enabled: true, + transport_enabled: true, + order: 0, + http_authenticator: { + challenge: false, + type: 'jwt', + config: { + signing_key: btoa(JWT_SIGNING_KEY), + jwt_header: 'Authorization', + jwt_url_parameter: 'token', + jwt_clock_skew_tolerance_seconds: 30, + roles_key: 'roles', + subject_key: 'sub', + }, + }, + authentication_backend: { + type: 'noop', + config: {}, + }, + }; + try { + config.dynamic!.authc!.jwt_auth_domain = jwtConfig; + config.dynamic!.authc!.basic_internal_auth_domain.http_authenticator.challenge = true; + config.dynamic!.http!.anonymous_auth_enabled = false; + await wreck.put('https://localhost:9200/_plugins/_security/api/securityconfig/config', { + payload: config, + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + }, + }); + } catch (error) { + console.log('Got an error while updating security config!', error.stack); + fail(error); + } + }); + + afterAll(async () => { + console.log('Remove the Security Config'); + await wreck.patch('https://localhost:9200/_plugins/_security/api/securityconfig', { + payload: [ + { + op: 'remove', + path: '/config/dynamic/authc/jwt_auth_domain', + }, + ], + rejectUnauthorized: false, + headers: { + 'Content-Type': 'application/json', + authorization: ADMIN_CREDENTIALS, + }, + }); + // shutdown OpenSearchDashboards server + await root.shutdown(); + }); + + it('Verify JWT access to dashboards', async () => { + console.log('Wreck access home page'); + const adminJWT = await new SignJWT({ + roles: [JWT_ADMIN_ROLE], + sub: ADMIN_USER, + }) + .setProtectedHeader({ alg: 'HS256' }) + .sign(new TextEncoder().encode(JWT_SIGNING_KEY)); + await wreck.get(`http://localhost:5601/app/home?token=${adminJWT}#`, { + rejectUnauthorized: true, + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + + it('Verify access to login page without JWT', async () => { + console.log('Wreck access login page without JWT'); + const response = await wreck.get('http://localhost:5601/app/home', { + rejectUnauthorized: true, + }); + expect(response.res.statusCode).toEqual(302); + expect(response.res.headers.location).toEqual('/app/login?nextUrl=%2Fapp%2Fhome'); + }); +});