Skip to content
This repository has been archived by the owner on May 29, 2024. It is now read-only.

feature: add OpenShiftCluster#getOauthUrl #973

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from 3 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
5 changes: 2 additions & 3 deletions frontend/packages/launcher-app/.env.production-api
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,5 @@ REACT_APP_CREATOR_API_URL=https://forge.api.openshift.io/creator
REACT_APP_LAUNCHER_API_URL=https://forge.api.openshift.io/api

REACT_APP_AUTHENTICATION=keycloak
REACT_APP_KEYCLOAK_CLIENT_ID=openshiftio-public
REACT_APP_KEYCLOAK_REALM=rh-developers-launch
REACT_APP_KEYCLOAK_URL=https://sso.openshift.io/auth

REACT_APP_OAUTH_OPENSHIFT_CLIENT_ID=openshift-public
12 changes: 3 additions & 9 deletions frontend/packages/launcher-app/src/app/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import axios from 'axios';

import { OpenshiftConfig, KeycloakConfig, GitProviderConfig } from '../auth/types';
import { OpenshiftConfig, GitProviderConfig } from '../auth/types';
import { checkNotNull } from '../client/helpers/preconditions';

function getEnv(env: string | undefined, name: string): string | undefined {
Expand Down Expand Up @@ -32,20 +32,14 @@ function getAuthMode(keycloakUrl?: string, openshiftOAuthUrl?: string) {
return 'no';
}

function getAuthConfig(authMode: string): KeycloakConfig | OpenshiftConfig | undefined {
function getAuthConfig(authMode: string): OpenshiftConfig | undefined {
switch (authMode) {
case 'keycloak':
return {
clientId: requireEnv(process.env.REACT_APP_KEYCLOAK_CLIENT_ID, 'keycloakClientId'),
realm: requireEnv(process.env.REACT_APP_KEYCLOAK_REALM, 'keycloakRealm'),
url: requireEnv(process.env.REACT_APP_KEYCLOAK_URL, 'keycloakUrl'),
gitProvider: (getEnv(process.env.REACT_APP_GIT_PROVIDER, 'gitProvider') || 'github') === 'github' ? 'github' : 'gitea'
} as KeycloakConfig;
case 'oauth-openshift':
const base: OpenshiftConfig = {
openshift: {
clientId: requireEnv(process.env.REACT_APP_OAUTH_OPENSHIFT_CLIENT_ID, 'openshiftOAuthClientId'),
url: requireEnv(process.env.REACT_APP_OAUTH_OPENSHIFT_URL, 'openshiftOAuthUrl'),
url: getEnv(process.env.REACT_APP_OAUTH_OPENSHIFT_URL, 'openshiftOAuthUrl'),
validateTokenUri: `${requireEnv(process.env.REACT_APP_LAUNCHER_API_URL, 'launcherApiUrl')}/services/openshift/user`,
},
loadGitProvider: () => {
Expand Down
11 changes: 5 additions & 6 deletions frontend/packages/launcher-app/src/app/launcher-app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,18 @@ import React from 'react';
import { Redirect, Route, Switch } from 'react-router';
import { BrowserRouter } from 'react-router-dom';
import { AuthenticationApiContext, useAuthenticationApiStateProxy } from '../auth/auth-context';
import { AuthRouter, newAuthApi } from '../auth/authentication-api-factory';
import { newAuthApi } from '../auth/authentication-api-factory';
import { createRouterLink, getRequestedRoute, useRouter } from '../router/use-router';
import { authConfig, authMode, creatorApiUrl, launcherApiUrl, publicUrl } from './config';
import './launcher-app.scss';
import { Layout } from './layout';
import { LoginPage } from './login-page';
import { LauncherMenu } from '../launcher/launcher';
import { CreateNewAppFlow } from '../flows/create-new-app-flow';
import { ImportExistingFlow } from '../flows/import-existing-flow';
import { DeployExampleAppFlow } from '../flows/deploy-example-app-flow';
import { DataLoader } from '@launcher/component';
import { LauncherDepsProvider } from '../contexts/launcher-client-provider';
import { LoginPage } from './login-page';


function Routes(props: {}) {
Expand Down Expand Up @@ -42,11 +42,12 @@ function Routes(props: {}) {
const DeployExampleAppFlowRoute = () => (<WithCancel>{onCancel => <DeployExampleAppFlow onCancel={onCancel} />}</WithCancel>);
return (
<Switch>
<Route path="/" exact component={LoginPage} />
<Route path="/home" exact component={Menu} />
<Route path="/flow/new-app" exact component={CreateNewAppFlowRoute} />
<Route path="/flow/import-existing-app" exact component={ImportExistingFlowRoute} />
<Route path="/flow/deploy-example-app" exact component={DeployExampleAppFlowRoute} />
<Redirect to="/home" />
<Redirect to="/" />
</Switch>
);
}
Expand Down Expand Up @@ -78,9 +79,7 @@ export function LauncherApp() {
creatorUrl={creatorApiUrl}
launcherUrl={launcherApiUrl}
>
<AuthRouter loginPage={LoginPage} basename={publicUrl}>
<HomePage />
</AuthRouter>
<HomePage />
</LauncherDepsProvider>
</AuthenticationApiContext.Provider>
</DataLoader >
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
$assetPath: "./assets/logo";

.main {
background-color: #fff;
margin:-20px
}

.intro {
background-image: url(#{$assetPath}/background.png);
background-size: cover;
Expand Down Expand Up @@ -76,7 +81,7 @@ button.loginButton {
button.loginButton {
font-size: 10pt;
}

.container {
margin-top: 0;
display: initial;
Expand Down
13 changes: 7 additions & 6 deletions frontend/packages/launcher-app/src/app/login-page.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,22 @@
import { Button, Card, CardBody, CardFooter, CardHeader, PageSection, PageSectionVariants, Text, TextContent, TextVariants } from '@patternfly/react-core';
import { ExternalLinkSquareAltIcon } from '@patternfly/react-icons';
import * as React from 'react';
import { useAuthenticationApi } from '../auth/auth-context';
import { Layout } from './layout';
import style from './login-page.module.scss';
import { NewAppRuntimesLoader } from '../loaders/new-app-runtimes-loaders';
import { PropertyValue } from '../client/types';
import { useRouter, createRouterLink } from '../router/use-router';

function LoginCard() {
const auth = useAuthenticationApi();
const router = useRouter();
const homeLink = createRouterLink(router, '/home');

return (
<div className={style.loginCard}>
<p className={style.loginText}>
When you click on start, you will first have to login or register an account for free
with the Red Hat Developer Program.
</p>
<Button variant="primary" onClick={auth.login} className={style.loginButton}>
<Button variant="primary" onClick={homeLink.onClick} className={style.loginButton}>
Start
</Button>
</div>
Expand All @@ -41,7 +42,7 @@ function Runtime(props: RuntimeProps) {
}

export const LoginPage = () => (
<Layout>
<div className={style.main}>
<section className={style.intro}>
<div className="container">
<h1 className={style.mainTitle}>Launcher</h1>
Expand Down Expand Up @@ -70,5 +71,5 @@ export const LoginPage = () => (
{runtimes => runtimes.map(r => (<Runtime {...r} key={r.id} />))}
</NewAppRuntimesLoader>
</PageSection>
</Layout>
</div>
);
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,25 @@ import { KeycloakAuthenticationApi } from './impl/keycloak-authentication-api';
import NoAuthenticationApi from './impl/no-authentication-api';
import { AuthenticationApi } from './authentication-api';
import { OpenshiftAuthenticationApi } from './impl/openshift-authentication-api';
import { KeycloakConfig, OpenshiftConfig } from './types';
import { OpenshiftConfig } from './types';
import { checkNotNull } from '../client/helpers/preconditions';

export { AuthenticationApiContext, useAuthenticationApi, useAuthenticationApiStateProxy } from './auth-context';
export { AuthRouter } from './auth-router';

export function newMockAuthApi() { return new MockAuthenticationApi(); }
export function newKCAuthApi(config: KeycloakConfig) { return new KeycloakAuthenticationApi(config); }
export function newKCAuthApi(config: OpenshiftConfig) { return new KeycloakAuthenticationApi(config); }
export function newOpenshiftAuthApi(config: OpenshiftConfig) { return new OpenshiftAuthenticationApi(config); }
export function newNoAuthApi() { return new NoAuthenticationApi(); }

export function newAuthApi(authenticationMode?: string, config?: OpenshiftConfig|KeycloakConfig): AuthenticationApi {
export function newAuthApi(authenticationMode?: string, config?: OpenshiftConfig): AuthenticationApi {
switch (authenticationMode) {
case 'no':
return new NoAuthenticationApi();
case 'mock':
return new MockAuthenticationApi();
case 'keycloak':
return new KeycloakAuthenticationApi(checkNotNull(config as KeycloakConfig, 'keycloakConfig'));
return new KeycloakAuthenticationApi(checkNotNull(config as OpenshiftConfig, 'keycloakConfig'));
case 'oauth-openshift':
return new OpenshiftAuthenticationApi(checkNotNull(config as OpenshiftConfig, 'openshiftConfig'));
default:
Expand Down
Original file line number Diff line number Diff line change
@@ -1,193 +1,12 @@
import jsSHA from 'jssha';
import Keycloak from 'keycloak-js';
import _ from 'lodash';
import { v4 as uuidv4 } from 'uuid';
import { AuthenticationApi } from '../authentication-api';
import { Authorizations, KeycloakConfig, OptionalUser } from '../types';
import { OpenshiftAuthenticationApi } from './openshift-authentication-api';

interface StoredData {
token: string;
refreshToken?: string;
idToken?: string;
}

function takeFirst<R>(fn: (...args: any) => Promise<R>): (...args: any) => Promise<R> {
let pending: Promise<R> | undefined;
let resolve: (val: R) => void;
let reject: (err: Error) => void;
return function(...args) {
if (!pending) {
pending = new Promise((_resolve, _reject) => {
resolve = _resolve;
reject = _reject;
});
fn(...args).then((val) => {
pending = undefined;
resolve(val);
}, error => {
pending = undefined;
reject(error);
});
}
return pending;
};
}

export class KeycloakAuthenticationApi implements AuthenticationApi {

private _user: OptionalUser;
private onUserChangeListener?: (user: OptionalUser) => void = undefined;

private static base64ToUri(b64: string): string {
return b64.replace(/=/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}

private readonly keycloak: Keycloak.KeycloakInstance;

constructor(private config: KeycloakConfig, keycloakCoreFactory = Keycloak) {
this.keycloak = keycloakCoreFactory(config);
this.refreshToken = takeFirst(this.refreshToken);
}

public setOnUserChangeListener(listener: (user: OptionalUser) => void) {
this.onUserChangeListener = listener;
}

public init = (): Promise<OptionalUser> => {
return new Promise((resolve, reject) => {
const sessionKC = KeycloakAuthenticationApi.getStoredData();
this.keycloak.init({ ...sessionKC, checkLoginIframe: false })
.error((e) => reject(e))
.success(() => {
this.initUser();
resolve(this._user);
});
this.keycloak.onTokenExpired = () => {
this.refreshToken(true)
.catch(e => console.error(e));
};
});
};

public async getAuthorizations(provider: string): Promise<Authorizations | undefined> {
if (!this._user) {
return;
}
return this._user.authorizationsByProvider['kc'];
}

public get user() {
return this._user;
}

public login = () => {
this.keycloak.login();
return Promise.resolve();
};

public logout = () => {
KeycloakAuthenticationApi.clearStoredData();
this.keycloak.logout();
};
export class KeycloakAuthenticationApi extends OpenshiftAuthenticationApi {

public getAccountManagementLink = () => {
if (!this._user) {
return undefined;
}
return this.keycloak.createAccountUrl();
};

public refreshToken = (force: boolean = false): Promise<OptionalUser> => {
return new Promise<OptionalUser>((resolve, reject) => {
if (this._user) {
console.info('Checking if token needs to be refreshed...');
this.keycloak.updateToken(force ? -1 : 60)
.success(() => {
this.initUser();
resolve(this.user);
})
.error(() => {
this.logout();
reject('Failed to refresh token');
});
} else {
reject('User is not authenticated');
}
});
};

public generateAuthorizationLink = (provider: string = this.config.gitProvider, redirect?: string): string => {
if (!this.user) {
throw new Error('User is not authenticated');
}
if (this.user.accountLink[provider]) {
return this.user.accountLink[provider];
public generateAuthorizationLink(provider?: string, redirect?: string): string {
if (provider !== 'github' && provider !== 'gitea') {
return provider || '';
}
const nonce = uuidv4();
const clientId = this.config.clientId;
const hash = nonce + this.user.sessionState
+ clientId + provider;
const shaObj = new jsSHA('SHA-256', 'TEXT');
shaObj.update(hash);
const hashed = KeycloakAuthenticationApi.base64ToUri(shaObj.getHash('B64'));
// tslint:disable-next-line
const link = `${this.keycloak.authServerUrl}/realms/${this.config.realm}/broker/${provider}/link?nonce=${encodeURI(nonce)}&hash=${hashed}&client_id=${encodeURI(clientId)}&redirect_uri=${encodeURI(redirect || window.location.href)}`;
this.user.accountLink[provider] = link;
return link;
return super.generateAuthorizationLink(provider, redirect);
};

private initUser() {
if (!this.keycloak) {
this._user = {
userName: 'Anonymous',
userPreferredName: 'Anonymous',
authorizationsByProvider: { kc: { Authorization: `Bearer eyJhbGciOiJIUzI1NiJ9.e30.ZRrHA1JJJW8opsbCGfG_HACGpVUMN_a9IV7pAx_Zmeo` } },
sessionState: 'sessionState',
accountLink: {},
};
this.triggerUserChange();
return;
}
if (this.keycloak.token) {
KeycloakAuthenticationApi.setStoredData({
token: this.keycloak.token,
refreshToken: this.keycloak.refreshToken,
idToken: this.keycloak.idToken,
});
this._user = {
userName: _.get(this.keycloak, 'tokenParsed.name'),
userPreferredName: _.get(this.keycloak, 'tokenParsed.preferred_username'),
authorizationsByProvider: { kc: { Authorization: `Bearer ${this.keycloak.token}` } },
sessionState: _.get(this.keycloak, 'tokenParsed.session_state'),
accountLink: {},
};
this.triggerUserChange();
}
}

public get enabled(): boolean {
return true;
}

private triggerUserChange() {
if (this.onUserChangeListener) {
this.onUserChangeListener(this._user);
}
}

private static clearStoredData() {
sessionStorage.clear();
localStorage.removeItem('kc');
}

private static setStoredData(data: StoredData) {
localStorage.setItem('kc', JSON.stringify(data));
}

private static getStoredData(): StoredData | undefined {
const item = localStorage.getItem('kc');
return item && JSON.parse(item);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class OpenshiftAuthenticationApi implements AuthenticationApi {
return this._user.authorizationsByProvider[provider];
}

public generateAuthorizationLink = (provider?: string, redirect?: string): string => {
public generateAuthorizationLink(provider?: string, redirect?: string): string {
const gitProvider = provider || this.gitConfig.gitProvider;
if (gitProvider === 'github') {
const redirectUri = redirect || this.cleanUrl(window.location.href);
Expand Down
2 changes: 1 addition & 1 deletion frontend/packages/launcher-app/src/auth/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ export interface OpenshiftConfig {
loadGitProvider: () => Promise<GitProviderConfig>,
openshift: {
clientId: string;
url: string;
url?: string;
validateTokenUri: string;
responseType?: string;
};
Expand Down
Loading