diff --git a/public/apps/login/login-page.tsx b/public/apps/login/login-page.tsx
index 4591c032..ebf5f63e 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 783bdd14..bdb8fe37 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 4b4f6483..ba7daada 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 0f450e2b..6841033b 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 00000000..9d8f2824
--- /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');
+ });
+});