Skip to content

Commit

Permalink
Add JWT authentication type to MultipleAuthentication (#2107) (#2133)
Browse files Browse the repository at this point in the history
* Add JWT authentication type to MultipleAuthentication



* clarify comments in AuthenticationType.authHandler



* collect additional auth headers from all multi-auth handlers



* implement MultipleAuthentication.getCookie



* Add test for multiauth with JWT



* add explanatory comments in login page



* remove logging of JWT in test



* add check for empty auth options list in login page



* Add comments about getCookie method



* remove unneeded comment



* Don't load sample data in JWT multiauth test



* remove sample data code and unneeded promise handling



* update test for missing JWT



* ensure JWT signing key consistency



---------



(cherry picked from commit 252d8fb)

Signed-off-by: merlinz01 <[email protected]>
Signed-off-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Derek Ho <[email protected]>
  • Loading branch information
3 people authored Oct 7, 2024
1 parent a40fe21 commit a58664c
Show file tree
Hide file tree
Showing 5 changed files with 240 additions and 36 deletions.
31 changes: 14 additions & 17 deletions public/apps/login/login-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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(<EuiSpacer size="xs" />);
formBody.push(<EuiHorizontalRule size="full" margin="xl" />);
formBody.push(<EuiSpacer size="xs" />);
Expand All @@ -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(
Expand Down
35 changes: 17 additions & 18 deletions server/auth/types/authentication_type.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,77 +96,76 @@ 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({
body: error.message,
});
}
} 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) {
this.logger.error(`Error parsing cookie: ${error.message}`);
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);
Expand Down
26 changes: 25 additions & 1 deletion server/auth/types/multiple/multi_auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ import {
OpenIdAuthentication,
ProxyAuthentication,
SamlAuthentication,
JwtAuthentication,
} from '../../types';

export class MultipleAuthentication extends AuthenticationType {
Expand Down Expand Up @@ -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]}`);
}
Expand Down Expand Up @@ -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 {};
}

Expand Down
3 changes: 3 additions & 0 deletions test/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
181 changes: 181 additions & 0 deletions test/jest_integration/jwt_multiauth.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});

0 comments on commit a58664c

Please sign in to comment.