Skip to content
This repository has been archived by the owner on Dec 21, 2023. It is now read-only.

Commit

Permalink
feat(bridge): Login via OpenID (#6076) (#6077)
Browse files Browse the repository at this point in the history
* feat(bridge): Login via OpenID (#6076)

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* use different env for client redirect uri

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* build redirect URL with base and prefix

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* make user identifier configurable

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* logout with POST instead of GET, increased state timeout to 60 minutes

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* Added login via OpenID to README

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* added tests

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* added tests for OAuth flow, removed app in global, fixed non-terminating tests

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* adapted readme and fallback of user identifier

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* added new OAUTH env variables to installer

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* added OAUTH_SCOPE env variable for additional scopes

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* add new scope env to installer

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* fixed test

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* fixed code style

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* redirect to logout page after logout. revoke token on logout

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* fixed test

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* implemented requested changes

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* implemented requested changes, fixed unit tests

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* fixed redirect on logout, renamed loggedOur URL

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>

* fixed unit test

Signed-off-by: Klaus Strießnig <k.striessnig@gmail.com>
  • Loading branch information
Kirdock committed Jan 27, 2022
1 parent 64540b9 commit 1a657c8
Show file tree
Hide file tree
Showing 33 changed files with 842 additions and 317 deletions.
5 changes: 5 additions & 0 deletions bridge/.gitignore
Expand Up @@ -51,3 +51,8 @@ Thumbs.db
# e2e screenshots
e2e/screenshots/*
!e2e/screenshots/.gitkeep

# certificates
*.pem
*.crt
*.key
19 changes: 19 additions & 0 deletions bridge/README.md
Expand Up @@ -75,6 +75,25 @@ Per default login attempts are throttled to 10 requests within 60 minutes. This
- `REQUESTS_WITHIN_TIME` - how many login attempts in which timespan `REQUEST_TIME_LIMIT` (in minutes) are allowed per IP.
- `CLEAN_BUCKET_INTERVAL` - the interval (in minutes) the saved login attempts should be checked and deleted if the last request of an IP is older than `REQUEST_TIME_LIMIT` minutes. Default is 60 minutes.

### Setting up login via OpenID
To set up a login via OpenID you have to register an application with the identity provider you want, in order to get an ID (`CLIENT_ID`) and a secret (`CLIENT_SECRET`).
After this is done, the following environment variables have to be set:
- `OAUTH_ENABLED` - Flag to enable login via OpenID. To enable it set it to `true`.
- `OAUTH_BASE_URL` - URL of the bridge (e.g. `http://localhost:3000` or `https://myBridgeInstallation.com`).
- `OAUTH_DISCOVERY` - Discovery URL of the identity provider (e.g. https://api.login.yahoo.com/.well-known/openid-configuration).
- `OAUTH_CLIENT_ID` - Client ID.
- `OAUTH_CLIENT_SECRET` (optional) - Client secret. Some identity providers require using the client secret.
- `OAUTH_ID_TOKEN_ALG` (optional) - Algorithm that is used to verify the ID token (e.g. `ES256`). Default is `RS256`.
- `OAUTH_SCOPE` (optional) - Additional scopes that should be added to the authentication flow (e.g. `profile email`), separated by space.
- `OAUTH_NAME_PROPERTY` (optional) - The property of the ID token that identifies the user. Default is `name` and fallback to `nickname`, `preferred_username` and `email`.

#### Additional information:
- Make sure you add the redirect URI `https://${yourDomain}/${pathToBridge}/oauth/redirect` to your identity provider.
- The identity provider has to support the grant types `authorization_code` and `refresh_token` and provide the endpoints `authorization_endpoint`, `token_endpoint` and `jwks_uri`.
- The refresh of the token is done by the bridge server on demand.
- If the identity provider provides the endpoint `end_session_endpoint`, it will be used for the logout.
- The bridge server itself is a confidential client.

### Custom Look And Feel

You can change the Look And Feel of the Keptn Bridge by creating a zip archive with your resources and make it downloadable from an URL.
Expand Down
@@ -1,6 +1,11 @@
<div class="user">
<p>User: {{ user }}</p>
<div>
<button (click)="logout()" dt-button>Logout</button>
<form (ngSubmit)="logout($event)" method="POST" [action]="logoutFormData.end_session_endpoint">
<input type="hidden" name="state" [value]="logoutFormData.state" />
<input type="hidden" name="post_logout_redirect_uri" [value]="logoutFormData.post_logout_redirect_uri" />
<input type="hidden" name="id_token_hint" [value]="logoutFormData.id_token_hint" />
<button type="submit" dt-button>Logout</button>
</form>
</div>
</div>
@@ -1,11 +1,14 @@
import { KtbUserComponent } from './ktb-user.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AppModule } from '../../app.module';
import { HttpClientTestingModule } from '@angular/common/http/testing';
import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing';
import { EndSessionData } from '../../../../shared/interfaces/end-session-data';

describe('ktbUserComponentTest', () => {
let component: KtbUserComponent;
let fixture: ComponentFixture<KtbUserComponent>;
let httpMock: HttpTestingController;
const locationAssignMock = mockLocation();

beforeEach(async () => {
await TestBed.configureTestingModule({
Expand All @@ -14,10 +17,46 @@ describe('ktbUserComponentTest', () => {

fixture = TestBed.createComponent(KtbUserComponent);
component = fixture.componentInstance;
httpMock = TestBed.inject(HttpTestingController);
});

afterEach(() => {
locationAssignMock.mockClear();
});

it('should create', () => {
fixture.detectChanges();
expect(component).toBeDefined();
});

it('should send POST to logout', () => {
const submitForm = { target: { submit: (): void => {} } };
const submitSpy = jest.spyOn(submitForm.target, 'submit');
component.logout(submitForm);
httpMock.expectOne('./logout').flush({
id_token_hint: '',
end_session_endpoint: '',
post_logout_redirect_uri: '',
state: '',
} as EndSessionData);
expect(submitSpy).toHaveBeenCalled();
});

it('should redirect to root if no data for logout is returned', () => {
const submitForm = { target: { submit: (): void => {} } };
component.logout(submitForm);
httpMock.expectOne('./logout').flush(null);
expect(locationAssignMock).toBeCalledWith('/logoutsession');
});
});

function mockLocation(): jest.Mock<unknown, unknown[]> {
const locationAssignMock = jest.fn();
/* eslint-disable @typescript-eslint/ban-ts-comment */
// @ts-ignore
delete window.location;
// @ts-ignore
window.location = { assign: locationAssignMock };
/* eslint-enable @typescript-eslint/ban-ts-comment */
return locationAssignMock;
}
28 changes: 24 additions & 4 deletions bridge/client/app/_components/ktb-user/ktb-user.component.ts
@@ -1,4 +1,6 @@
import { Component, Input } from '@angular/core';
import { ChangeDetectorRef, Component, Input } from '@angular/core';
import { DataService } from '../../_services/data.service';
import { EndSessionData } from '../../../../shared/interfaces/end-session-data';
import { Location } from '@angular/common';

@Component({
Expand All @@ -8,10 +10,28 @@ import { Location } from '@angular/common';
})
export class KtbUserComponent {
@Input() user?: string;
public logoutFormData: EndSessionData = {
state: '',
post_logout_redirect_uri: '',
end_session_endpoint: '',
id_token_hint: '',
};

constructor(private readonly location: Location) {}
constructor(
private readonly dataService: DataService,
private readonly _changeDetectorRef: ChangeDetectorRef,
private readonly location: Location
) {}

logout(): void {
window.location.href = this.location.prepareExternalUrl('/logout');
logout(submitEvent: { target: { submit: () => void } }): void {
this.dataService.logout().subscribe((response) => {
if (response) {
this.logoutFormData = response;
this._changeDetectorRef.detectChanges();
submitEvent.target.submit();
} else {
window.location.assign(this.location.prepareExternalUrl('/logoutsession'));
}
});
}
}
5 changes: 5 additions & 0 deletions bridge/client/app/_services/api.service.ts
Expand Up @@ -24,6 +24,7 @@ import { KeptnService } from '../../../shared/models/keptn-service';
import { ServiceState } from '../../../shared/models/service-state';
import { Deployment } from '../../../shared/interfaces/deployment';
import { IServiceRemediationInformation } from '../_interfaces/service-remediation-information';
import { EndSessionData } from '../../../shared/interfaces/end-session-data';
import { ISequencesMetadata } from '../../../shared/interfaces/sequencesMetadata';

@Injectable({
Expand Down Expand Up @@ -478,6 +479,10 @@ export class ApiService {
});
}

public logout(): Observable<EndSessionData | null> {
return this.http.post<EndSessionData | null>(`./logout`, {});
}

public getSequencesMetadata(projectName: string): Observable<ISequencesMetadata> {
return this.http.get<ISequencesMetadata>(`${this._baseUrl}/project/${projectName}/sequences/metadata`);
}
Expand Down
5 changes: 5 additions & 0 deletions bridge/client/app/_services/data.service.ts
Expand Up @@ -28,6 +28,7 @@ import { Service } from '../_models/service';
import { Deployment } from '../_models/deployment';
import { ServiceState } from '../_models/service-state';
import { ServiceRemediationInformation } from '../_models/service-remediation-information';
import { EndSessionData } from '../../../shared/interfaces/end-session-data';
import { ISequencesMetadata } from '../../../shared/interfaces/sequencesMetadata';

@Injectable({
Expand Down Expand Up @@ -746,4 +747,8 @@ export class DataService {
): Observable<Record<string, unknown>> {
return this.apiService.getIntersectedEvent(event, eventSuffix, projectName, stages, services);
}

public logout(): Observable<EndSessionData | null> {
return this.apiService.logout();
}
}
3 changes: 1 addition & 2 deletions bridge/server/.jest/global.d.ts
@@ -1,13 +1,12 @@
import { Express } from 'express';
import { AxiosInstance } from 'axios';

declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace NodeJS {
interface Global {
app: Express;
baseUrl: string;
axiosInstance: AxiosInstance;
issuer?: unknown;
}
}
}
7 changes: 4 additions & 3 deletions bridge/server/.jest/setupServer.ts
@@ -1,8 +1,9 @@
import { init } from '../app';
import Axios from 'axios';
import https from 'https';
import { Express } from 'express';

const setup = async (): Promise<void> => {
const setupServer = async (): Promise<Express> => {
global.baseUrl = 'http://localhost/api/';

global.axiosInstance = Axios.create({
Expand All @@ -16,7 +17,7 @@ const setup = async (): Promise<void> => {
},
});

global.app = await init();
return init();
};

export default setup();
export { setupServer };
1 change: 0 additions & 1 deletion bridge/server/.jest/shutdownServer.ts

This file was deleted.

68 changes: 34 additions & 34 deletions bridge/server/app.ts
Expand Up @@ -14,10 +14,10 @@ import { execSync } from 'child_process';
import { AxiosError } from 'axios';
import { EnvironmentUtils } from './utils/environment.utils';
import { ClientFeatureFlags, ServerFeatureFlags } from './feature-flags';
import { setupOAuth } from './user/oauth';

// eslint-disable-next-line @typescript-eslint/naming-convention
const __dirname = dirname(fileURLToPath(import.meta.url));
const app = express();
const apiUrl: string | undefined = process.env.API_URL;
let apiToken: string | undefined = process.env.API_TOKEN;
let cliDownloadLink: string | undefined = process.env.CLI_DOWNLOAD_LINK;
Expand All @@ -30,19 +30,20 @@ const throttleBucket: { [ip: string]: number[] } = {};
const rootFolder = join(__dirname, '../../../');
const serverFolder = join(rootFolder, 'server');
const oneWeek = 7 * 24 * 3_600_000; // 3600000msec == 1hour
const serverFeatureFlags = new ServerFeatureFlags();
const clientFeatureFlags = new ClientFeatureFlags();
EnvironmentUtils.setFeatureFlags(process.env, serverFeatureFlags);
EnvironmentUtils.setFeatureFlags(process.env, clientFeatureFlags);

if (process.env.NODE_ENV !== 'test') {
setupDefaultLookAndFeel();
}
if (lookAndFeelUrl) {
setupLookAndFeel(lookAndFeelUrl);
}

async function init(): Promise<Express> {
const app = express();
const serverFeatureFlags = new ServerFeatureFlags();
const clientFeatureFlags = new ClientFeatureFlags();
EnvironmentUtils.setFeatureFlags(process.env, serverFeatureFlags);
EnvironmentUtils.setFeatureFlags(process.env, clientFeatureFlags);

if (process.env.NODE_ENV !== 'test') {
setupDefaultLookAndFeel();
}
if (lookAndFeelUrl) {
setupLookAndFeel(lookAndFeelUrl);
}
if (!apiUrl) {
throw Error('API_URL is not provided');
}
Expand All @@ -66,6 +67,7 @@ async function init(): Promise<Express> {

// server static files - Images & CSS
app.use('/static', express.static(join(serverFolder, 'views/static'), { maxAge: oneWeek }));
app.use('/branding', express.static(join(rootFolder, 'dist/assets/branding'), { maxAge: oneWeek }));

// UI static files - Angular application
app.use(
Expand Down Expand Up @@ -113,7 +115,7 @@ async function init(): Promise<Express> {
// Remove the X-Powered-By headers, has to be done via express and not helmet
app.disable('x-powered-by');

const authType: string = await setAuth();
const authType: string = await setAuth(app, serverFeatureFlags.OAUTH_ENABLED);

// everything starting with /api is routed to the api implementation
app.use('/api', apiRouter({ apiUrl, apiToken, cliDownloadLink, integrationsPageLink, authType, clientFeatureFlags }));
Expand All @@ -134,27 +136,25 @@ async function init(): Promise<Express> {
return app;
}

async function setOAUTH(): Promise<void> {
const sessionRouter = (await import('./user/session.js')).sessionRouter(app);
const oauthRouter = await (await import('./user/oauth.js')).oauthRouter;
const authCheck = (await import('./user/session.js')).isAuthenticated;
async function setOAUTH(app: Express): Promise<void> {
const errorSuffix =
'must be defined when oauth based login (OAUTH_ENABLED) is activated.' +
' Please check your environment variables.';

// Initialise session middleware
app.use(sessionRouter);
// Initializing OAuth middleware.
app.use(oauthRouter);
if (!process.env.OAUTH_DISCOVERY) {
throw Error(`OAUTH_DISCOVERY ${errorSuffix}`);
}
if (!process.env.OAUTH_CLIENT_ID) {
throw Error(`OAUTH_CLIENT_ID ${errorSuffix}`);
}
if (!process.env.OAUTH_BASE_URL) {
throw Error(`OAUTH_BASE_URL ${errorSuffix}`);
}

// Authentication filter for API requests
app.use('/api', (req, resp, next) => {
if (!authCheck(req)) {
next({ response: { status: 401 } });
return;
}
return next();
});
await setupOAuth(app, process.env.OAUTH_DISCOVERY, process.env.OAUTH_CLIENT_ID, process.env.OAUTH_BASE_URL);
}

async function setBasisAUTH(): Promise<void> {
async function setBasicAUTH(app: Express): Promise<void> {
console.error('Installing Basic authentication - please check environment variables!');

setInterval(cleanIpBuckets, cleanBucketsInterval);
Expand Down Expand Up @@ -186,14 +186,14 @@ async function setBasisAUTH(): Promise<void> {
});
}

async function setAuth(): Promise<string> {
async function setAuth(app: Express, oAuthEnabled: boolean): Promise<string> {
let authType;
if (serverFeatureFlags.OAUTH_ENABLED) {
await setOAUTH();
if (oAuthEnabled) {
await setOAUTH(app);
authType = 'OAUTH';
} else if (process.env.BASIC_AUTH_USERNAME && process.env.BASIC_AUTH_PASSWORD) {
authType = 'BASIC';
await setBasisAUTH();
await setBasicAUTH(app);
} else {
authType = 'NONE';
console.log('Not installing authentication middleware');
Expand Down
1 change: 1 addition & 0 deletions bridge/server/global.d.ts
Expand Up @@ -5,6 +5,7 @@ declare global {
namespace NodeJS {
interface Global {
axiosInstance?: AxiosInstance;
issuer?: unknown;
}
}
}
2 changes: 0 additions & 2 deletions bridge/server/jest.config.js
Expand Up @@ -12,7 +12,5 @@ export default {
collectCoverage: true,
coverageDirectory: '<rootDir>/coverage',
setupFiles: ['<rootDir>/.jest/setEnvVars.ts'],
setupFilesAfterEnv: ['<rootDir>/.jest/setupServer.ts'],
globalTeardown: '<rootDir>/.jest/shutdownServer.ts',
testPathIgnorePatterns: ['<rootDir>/dist', '<rootDir>/node_modules'],
};
1 change: 1 addition & 0 deletions bridge/server/package.json
Expand Up @@ -24,6 +24,7 @@
"helmet": "4.6.0",
"memorystore": "1.6.6",
"morgan": "1.10.0",
"openid-client": "^5.1.0",
"pug": "3.0.2",
"semver": "7.3.5",
"yaml": "1.10.2"
Expand Down
10 changes: 9 additions & 1 deletion bridge/server/test/bridge-info.spec.ts
@@ -1,8 +1,16 @@
import request from 'supertest';
import { setupServer } from '../.jest/setupServer';
import { Express } from 'express';

describe('Test /bridgeInfo', () => {
let app: Express;

beforeAll(async () => {
app = await setupServer();
});

it('should return bridgeInfo', async () => {
const response = await request(global.app).get('/api/bridgeInfo');
const response = await request(app).get('/api/bridgeInfo');
expect(response.body).toEqual({
bridgeVersion: 'develop',
apiUrl: global.baseUrl,
Expand Down

0 comments on commit 1a657c8

Please sign in to comment.