Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backport 2.x] Add JWT authentication type to MultipleAuthentication #2133

Merged
merged 1 commit into from
Oct 7, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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');
});
});
Loading