summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorMatthieu Huin <mhuin@redhat.com>2021-11-09 15:27:38 +0100
committerMatthieu Huin <mhuin@redhat.com>2021-11-18 16:39:17 +0100
commitb13ff51ddaccbdf3cf496d52094226edba8195a6 (patch)
tree40f8450da23e73620177bb0220dbbf53f8628d2b
parent59af3e2c17a7b7996ead2e72b1b665170714a8ef (diff)
downloadzuul-b13ff51ddaccbdf3cf496d52094226edba8195a6.tar.gz
web UI: user login with OpenID Connect
Under the hood, this uses AuthProvider as supplied by oidc-react. Most of the theory is explained in the comment in ZuulAuthProvider.jsx The benefit of doing this is that we allow the AuthProvider and userManager to handle the callback logic, so we don't need to handle the callback logic ourselves. A callback page is still required though in order to deal with the parameters passed in a successful redirection from the Identity Provider. The challenge in using these classes as-is is that our authority endpoints (eg, the IDP itself) may change from one tenant to the next; these classes aren't set up for that. So we need to be careful about how and when we change those authority URLs. In terms of functionalities: if the default realm's authentication driver is set to "OpenIDConnect", display a "Sign in" button. If the the user is logged in, redirect to the last page visited prior to logging in; fetch user authorizations and add them to the redux store; display the user's preferred username in the upper right corner. Clicking on the user icon in the right corner displays a modal with user information such as the user's zuul-client configuration, and a sign out button. Clicking on the sign out button removes user information from the store (note that it does not log the user out from the Identity Provider). Add some basic documentation explaining how to configure Zuul with Google's authentication, and with a Keycloak server. (This squashes https://review.opendev.org/c/zuul/zuul/+/816208 into https://review.opendev.org/c/zuul/zuul/+/734082 ) Co-authored-by: James E. Blair <jim@acmegating.com> Change-Id: I31e71f2795f3f7c4253d0d5b8ed309bfd7d4f98e
-rw-r--r--doc/source/howtos/admin.rst1
-rw-r--r--doc/source/howtos/openid-connect-examples.rst21
-rw-r--r--doc/source/howtos/openid-with-google.rst65
-rw-r--r--doc/source/howtos/openid-with-keycloak.rst110
-rw-r--r--releasenotes/notes/webui-openidconnect-514c09b26f7fd15e.yaml5
-rw-r--r--web/package.json2
-rw-r--r--web/src/App.jsx34
-rw-r--r--web/src/App.test.jsx14
-rw-r--r--web/src/ZuulAuthProvider.jsx76
-rw-r--r--web/src/actions/auth.js85
-rw-r--r--web/src/actions/info.js5
-rw-r--r--web/src/actions/user.js72
-rw-r--r--web/src/api.js66
-rw-r--r--web/src/containers/auth/Auth.jsx242
-rw-r--r--web/src/containers/config/Config.jsx2
-rw-r--r--web/src/index.js5
-rw-r--r--web/src/pages/AuthCallback.jsx41
-rw-r--r--web/src/reducers/auth.js76
-rw-r--r--web/src/reducers/index.js4
-rw-r--r--web/src/reducers/info.js2
-rw-r--r--web/src/reducers/initialState.js2
-rw-r--r--web/src/reducers/user.js74
-rw-r--r--web/src/routes.js16
-rw-r--r--web/src/store.dev.js6
-rw-r--r--web/yarn.lock65
25 files changed, 1045 insertions, 46 deletions
diff --git a/doc/source/howtos/admin.rst b/doc/source/howtos/admin.rst
index 9fd4933f2..c4301f97e 100644
--- a/doc/source/howtos/admin.rst
+++ b/doc/source/howtos/admin.rst
@@ -6,5 +6,6 @@ Admin How-to Guides
installation
zuul-from-scratch
+ openid-connect-examples
troubleshooting
zookeeper
diff --git a/doc/source/howtos/openid-connect-examples.rst b/doc/source/howtos/openid-connect-examples.rst
new file mode 100644
index 000000000..a4fedc43d
--- /dev/null
+++ b/doc/source/howtos/openid-connect-examples.rst
@@ -0,0 +1,21 @@
+OpenID Connect Integration Examples
+===================================
+
+This document lists simple How-Tos to help administrators enable OpenID
+Connect authentication in Zuul and Zuul's Web UI.
+
+.. toctree::
+ :maxdepth: 1
+
+ openid-with-google
+ openid-with-keycloak
+
+Debugging
+---------
+
+If problems appear:
+
+* Make sure your configuration is correct, especially callback URIs.
+* More information can be found in Zuul's web service logs.
+* From the user's side, activating the web console in the browser can be helpful
+ to debug API calls.
diff --git a/doc/source/howtos/openid-with-google.rst b/doc/source/howtos/openid-with-google.rst
new file mode 100644
index 000000000..4b25337ef
--- /dev/null
+++ b/doc/source/howtos/openid-with-google.rst
@@ -0,0 +1,65 @@
+Configuring Google Authentication
+=================================
+
+This document explains how to configure Zuul in order to enable authentication
+with Google.
+
+Prerequisites
+-------------
+
+* The Zuul instance must be able to query Google's OAUTH API servers. This
+ simply generally means that the Zuul instance must be able to send and
+ receive HTTPS data to and from the Internet.
+* You must set up a project in `Google's developers console <https://console.developers.google.com/>`_.
+
+Setting up credentials with Google
+----------------------------------
+
+In the developers console, choose your project and click `APIs & Services`.
+
+Choose `Credentials` in the menu on the left, then click `Create Credentials`.
+
+Choose `Create OAuth client ID`. You might need to configure a consent screen first.
+
+Create OAuth client ID
+......................
+
+Choose `Web application` as Application Type.
+
+In `Authorized JavaScript Origins`, add the base URL of Zuul's Web UI. For example,
+if you are running a yarn development server on your computer, it would be
+`http://localhost:3000` .
+
+In `Authorized redirect URIs`, write down the base URL of Zuul's Web UI followed
+by "/t/<tenant>/auth_callback", for each tenant on which you want to enable
+authentication. For example, if you are running a yarn development server on
+your computer and want to set up authentication for tenant "local",
+write `http://localhost:3000/t/local/auth_callback` .
+
+Click Save. Google will generate a Client ID and a Client secret for your new
+credentials; we will only need the Client ID for the rest of this How-To.
+
+Configure Zuul
+..............
+
+Edit the ``/etc/zuul/zuul.conf`` to add the google authenticator:
+
+.. code-block:: ini
+
+ [auth google_auth]
+ default=true
+ driver=OpenIDConnect
+ realm=my_realm
+ issuer_id=https://accounts.google.com
+ client_id=<your Google Client ID>
+
+
+Restart Zuul services (scheduler, web).
+
+Head to your tenant's status page. If all went well, you should see a "Sign in"
+button in the upper right corner of the page. Congratulations!
+
+Further Reading
+---------------
+
+This How-To is based on `Google's documentation on their implementation of OpenID Connect <https://developers.google.com/identity/protocols/oauth2/openid-connect>`_.
diff --git a/doc/source/howtos/openid-with-keycloak.rst b/doc/source/howtos/openid-with-keycloak.rst
new file mode 100644
index 000000000..e80376801
--- /dev/null
+++ b/doc/source/howtos/openid-with-keycloak.rst
@@ -0,0 +1,110 @@
+Configuring Keycloak Authentication
+===================================
+
+This document explains how to configure Zuul and Keycloak in order to enable
+authentication in Zuul with Keycloak.
+
+Prerequisites
+-------------
+
+* The Zuul instance must be able to query Keycloak over HTTPS.
+* Authenticating users must be able to reach Keycloak's web UI.
+* Have a realm set up in Keycloak.
+ `Instructions on how to do so can be found here <https://www.keycloak.org/docs/latest/getting_started/index.html#creating-a-realm-and-user>`_ .
+
+By convention, we will assume the Keycloak server's FQDN is ``keycloak``, and
+Zuul's Web UI's base URL is ``https://zuul/``. We will use the realm ``my_realm``.
+
+Most operations below regarding the configuration of Keycloak can be performed through
+Keycloak's admin CLI. The following steps must be performed as an admin on Keycloak's
+GUI.
+
+Setting up Keycloak
+-------------------
+
+Create a client
+...............
+
+Choose the realm ``my_realm``, then click ``clients`` in the Configure panel.
+Click ``Create``.
+
+Name your client as you please. We will pick ``zuul`` for this example. Make sure
+to fill the following fields:
+
+* Client Protocol: ``openid-connect``
+* Access Type: ``public``
+* Implicit Flow Enabled: ``ON``
+* Valid Redirect URIs: ``https://zuul/*``
+* Web Origins: ``https://zuul/``
+
+Click "Save" when done.
+
+Create a client scope
+......................
+
+Keycloak maps the client ID to a specific claim, instead of the usual `aud` claim.
+We need to configure Keycloak to add our client ID to the `aud` claim by creating
+a custom client scope for our client.
+
+Choose the realm ``my_realm``, then click ``client scopes`` in the Configure panel.
+Click ``Create``.
+
+Name your scope as you please. We will name it ``zuul_aud`` for this example.
+Make sure you fill the following fields:
+
+* Protocol: ``openid-connect``
+* Include in Token Scope: ``ON``
+
+Click "Save" when done.
+
+On the Client Scopes page, click on ``zuul_aud`` to configure it; click on
+``Mappers`` then ``create``.
+
+Make sure to fill the following:
+
+* Mapper Type: ``Audience``
+* Included Client Audience: ``zuul``
+* Add to ID token: ``ON``
+* Add to access token: ``ON``
+
+Then save.
+
+Finally, go back to the clients list and pick the ``zuul`` client again. Click
+on ``Client Scopes``, and add the ``zuul_aud`` scope to the ``Assigned Default
+Client Scopes``.
+
+(Optional) Set up a social identity provider
+............................................
+
+Keycloak can delegate authentication to predefined social networks. Follow
+`these steps to find out how. <https://www.keycloak.org/docs/latest/server_admin/index.html#social-identity-providers>`_
+
+If you don't set up authentication delegation, make sure to create at least one
+user in your realm, or allow self-registration. See Keycloak's documentation section
+on `user management <https://www.keycloak.org/docs/latest/server_admin/index.html#user-management>`_
+for more details on how to do so.
+
+Setting up Zuul
+---------------
+
+Edit the ``/etc/zuul/zuul.conf`` to add the keycloak authenticator:
+
+.. code-block:: ini
+
+ [auth keycloak]
+ default=true
+ driver=OpenIDConnect
+ realm=my_realm
+ issuer_id=https://keycloak/auth/realms/my_realm
+ client_id=zuul
+
+Restart Zuul services (scheduler, web).
+
+Head to your tenant's status page. If all went well, you should see a "Sign in"
+button in the upper right corner of the page. Congratulations!
+
+Further Reading
+---------------
+
+This How-To is based on `Keycloak's documentation <https://www.keycloak.org/documentation.html>`_,
+specifically `the documentation about clients <https://www.keycloak.org/docs/latest/server_admin/#_clients>`_.
diff --git a/releasenotes/notes/webui-openidconnect-514c09b26f7fd15e.yaml b/releasenotes/notes/webui-openidconnect-514c09b26f7fd15e.yaml
new file mode 100644
index 000000000..030eacf45
--- /dev/null
+++ b/releasenotes/notes/webui-openidconnect-514c09b26f7fd15e.yaml
@@ -0,0 +1,5 @@
+---
+features:
+ - |
+ Add authentication in the web UI. Zuul's web UI can be configured to authenticate
+ users against an Identity Provider supporting the OpenID Connect protocol.
diff --git a/web/package.json b/web/package.json
index e13a7d9c3..16c3e88c1 100644
--- a/web/package.json
+++ b/web/package.json
@@ -17,6 +17,8 @@
"moment": "^2.22.2",
"moment-duration-format": "2.3.2",
"moment-timezone": "^0.5.28",
+ "oidc-client": "^1.10.1",
+ "oidc-react": "^1.5.1",
"patternfly-react": "^2.39.16",
"prop-types": "^15.6.2",
"react": "^16.13.1",
diff --git a/web/src/App.jsx b/web/src/App.jsx
index c64f09ccf..68e7ca379 100644
--- a/web/src/App.jsx
+++ b/web/src/App.jsx
@@ -58,6 +58,7 @@ import {
UsersIcon,
} from '@patternfly/react-icons'
+import AuthContainer from './containers/auth/Auth'
import ErrorBoundary from './containers/ErrorBoundary'
import { Fetching } from './containers/Fetching'
import SelectTz from './containers/timezone/SelectTz'
@@ -67,6 +68,7 @@ import { clearError } from './actions/errors'
import { fetchConfigErrorsAction } from './actions/configErrors'
import { routes } from './routes'
import { setTenantAction } from './actions/tenant'
+import { configureAuthFromTenant, configureAuthFromInfo } from './actions/auth'
class App extends React.Component {
static propTypes = {
@@ -79,6 +81,7 @@ class App extends React.Component {
history: PropTypes.object,
dispatch: PropTypes.func,
isKebabDropdownOpen: PropTypes.bool,
+ user: PropTypes.object,
}
state = {
@@ -106,7 +109,7 @@ class App extends React.Component {
)
} else {
// Return an empty navigation bar in case we don't have an active tenant
- return <Nav aria-label="Nav" variant="horizontal"/>
+ return <Nav aria-label="Nav" variant="horizontal" />
}
}
@@ -165,7 +168,7 @@ class App extends React.Component {
whiteLabel = false
const match = matchPath(
- this.props.location.pathname, {path: '/t/:tenant'})
+ this.props.location.pathname, { path: '/t/:tenant' })
if (match) {
tenantName = match.params.tenant
@@ -177,6 +180,14 @@ class App extends React.Component {
this.props.dispatch(tenantAction)
if (tenantName) {
this.props.dispatch(fetchConfigErrorsAction(tenantAction.tenant))
+ if (whiteLabel) {
+ // The app info endpoint was already a tenant info
+ // endpoint, so auth info was already provided.
+ this.props.dispatch(configureAuthFromInfo(info))
+ } else {
+ // Query the tenant info endpoint for auth info.
+ this.props.dispatch(configureAuthFromTenant(tenantName))
+ }
}
}
}
@@ -231,7 +242,7 @@ class App extends React.Component {
<TimedToastNotification
key={error.id}
type='error'
- onDismiss={() => {this.props.dispatch(clearError(error.id))}}
+ onDismiss={() => { this.props.dispatch(clearError(error.id)) }}
>
<span title={moment.utc(error.date).tz(this.props.timezone).format()}>
<strong>{error.text}</strong> ({error.status})&nbsp;
@@ -263,14 +274,14 @@ class App extends React.Component {
variant="danger"
onClick={() => {
history.push(this.props.tenant.linkPrefix + '/config-errors')
- this.setState({showErrors: false})
+ this.setState({ showErrors: false })
}}
>
<NotificationDrawerListItemHeader
title={item.source_context.project + ' | ' + ctxPath}
variant="danger" />
<NotificationDrawerListItemBody>
- <pre style={{whiteSpace: 'pre-wrap'}}>
+ <pre style={{ whiteSpace: 'pre-wrap' }}>
{error}
</pre>
</NotificationDrawerListItemBody>
@@ -402,14 +413,16 @@ class App extends React.Component {
aria-label="Notifications"
onClick={(e) => {
e.preventDefault()
- this.setState({showErrors: !this.state.showErrors})
+ this.setState({ showErrors: !this.state.showErrors })
}}
>
<BellIcon />
</NotificationBadge>
}
- <SelectTz/>
- <ConfigModal/>
+ <SelectTz />
+ <ConfigModal />
+
+ {tenant.name && (<AuthContainer />)}
</PageHeaderTools>
)
@@ -418,7 +431,7 @@ class App extends React.Component {
const pageHeader = (
<PageHeader
logo={<Brand src={logo} alt='Zuul logo' className="zuul-brand" />}
- logoProps={{to: logoUrl}}
+ logoProps={{ to: logoUrl }}
logoComponent={Link}
headerTools={pageHeaderTools}
topNav={nav}
@@ -446,6 +459,7 @@ export default withRouter(connect(
configErrors: state.configErrors,
info: state.info,
tenant: state.tenant,
- timezone: state.timezone
+ timezone: state.timezone,
+ user: state.user
})
)(App))
diff --git a/web/src/App.test.jsx b/web/src/App.test.jsx
index e4f093d51..d3f168c64 100644
--- a/web/src/App.test.jsx
+++ b/web/src/App.test.jsx
@@ -23,6 +23,7 @@ import configureStore from './store'
import App from './App'
import TenantsPage from './pages/Tenants'
import StatusPage from './pages/Status'
+import ZuulAuthProvider from './ZuulAuthProvider'
import * as api from './api'
api.fetchInfo = jest.fn()
@@ -35,7 +36,10 @@ api.fetchConfigErrors.mockImplementation(() => Promise.resolve({data: []}))
it('renders without crashing', () => {
const store = configureStore()
const div = document.createElement('div')
- ReactDOM.render(<Provider store={store}><Router><App /></Router></Provider>,
+ ReactDOM.render(
+ <Provider store={store}>
+ <ZuulAuthProvider><Router><App /></Router></ZuulAuthProvider>
+ </Provider>,
div)
ReactDOM.unmountComponentAtNode(div)
})
@@ -52,7 +56,9 @@ it('renders multi tenant', async () => {
)
const application = create(
- <Provider store={store}><Router><App /></Router></Provider>
+ <Provider store={store}>
+ <ZuulAuthProvider><Router><App /></Router></ZuulAuthProvider>
+ </Provider>
)
await act(async () => {
@@ -88,7 +94,9 @@ it('renders single tenant', async () => {
)
const application = create(
- <Provider store={store}><Router><App /></Router></Provider>
+ <Provider store={store}>
+ <ZuulAuthProvider><Router><App /></Router></ZuulAuthProvider>
+ </Provider>
)
await act(async () => {
diff --git a/web/src/ZuulAuthProvider.jsx b/web/src/ZuulAuthProvider.jsx
new file mode 100644
index 000000000..a31460517
--- /dev/null
+++ b/web/src/ZuulAuthProvider.jsx
@@ -0,0 +1,76 @@
+// Copyright 2020 Red Hat, Inc
+// Copyright 2021 Acme Gating, LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License 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 React from 'react'
+import PropTypes from 'prop-types'
+import { connect } from 'react-redux'
+
+import { AuthProvider } from 'oidc-react'
+import { userLoggedIn, userLoggedOut } from './actions/user'
+
+
+class ZuulAuthProvider extends React.Component {
+ /*
+ This wraps the oidc-react AuthProvider and supplies the necessary
+ information as props.
+
+ The oidc-react AuthProvider is not really meant to be reconstructed
+ frequently. Calling render multiple times (even if nothing actually
+ changes) during a login can cause multiple AuthProviders to be created
+ which can interfere with the login process.
+
+ We connect this class to state.auth.auth_params, so make sure that isn't
+ updated unless the OIDC parameters are actually changed.
+
+ If they are changed, then we will create a new AuthProvider with the
+ new parameters. Save those parameters in local storage so that when
+ we return from the IDP redirect, an AuthProvider with the same
+ configuration is created.
+ */
+ static propTypes = {
+ auth_params: PropTypes.object,
+ dispatch: PropTypes.func,
+ children: PropTypes.any,
+ }
+
+ render() {
+ const { auth_params } = this.props
+
+ console.debug('ZuulAuthProvider rendering with params', auth_params)
+
+ const oidcConfig = {
+ onSignIn: async (user) => {
+ this.props.dispatch(userLoggedIn(user))
+ },
+ onSignOut: async () => {
+ this.props.dispatch(userLoggedOut())
+ },
+ responseType: 'token id_token',
+ autoSignIn: false,
+ ...auth_params,
+ }
+ return (
+ <React.Fragment>
+ <AuthProvider {...oidcConfig} key={JSON.stringify(auth_params)}>
+ {this.props.children}
+ </AuthProvider>
+ </React.Fragment>
+ )
+ }
+}
+
+export default connect(state => ({
+ auth_params: state.auth.auth_params,
+}))(ZuulAuthProvider)
diff --git a/web/src/actions/auth.js b/web/src/actions/auth.js
new file mode 100644
index 000000000..222ab05c1
--- /dev/null
+++ b/web/src/actions/auth.js
@@ -0,0 +1,85 @@
+// Copyright 2020 Red Hat, Inc
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License 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 API from '../api'
+
+export const AUTH_CONFIG_REQUEST = 'AUTH_CONFIG_REQUEST'
+export const AUTH_CONFIG_SUCCESS = 'AUTH_CONFIG_SUCCESS'
+export const AUTH_CONFIG_FAIL = 'AUTH_CONFIG_FAIL'
+
+export const USER_ACL_REQUEST = 'USER_ACL_REQUEST'
+export const USER_ACL_SUCCESS = 'USER_ACL_SUCCESS'
+export const USER_ACL_FAIL = 'USER_ACL_FAIL'
+
+export const AUTH_START = 'AUTH_START'
+
+const authConfigRequest = () => ({
+ type: AUTH_CONFIG_REQUEST
+})
+
+function createAuthParamsFromJson(json) {
+ let auth_info = json.info.capabilities.auth
+
+ let auth_params = {
+ authority: '',
+ clientId: '',
+ scope: '',
+ }
+ if (!auth_info) {
+ console.log('No auth config')
+ return auth_params
+ }
+ const realm = auth_info.default_realm
+ const client_config = auth_info.realms[realm]
+ if (client_config.driver === 'OpenIDConnect') {
+ auth_params.clientId = client_config.client_id
+ auth_params.scope = client_config.scope
+ auth_params.authority = client_config.authority
+ return auth_params
+ } else {
+ console.log('No OpenIDConnect provider found')
+ return auth_params
+ }
+}
+
+const authConfigSuccess = (json, auth_params) => ({
+ type: AUTH_CONFIG_SUCCESS,
+ info: json.info,
+ auth_params: auth_params,
+})
+
+const authConfigFail = error => ({
+ type: AUTH_CONFIG_FAIL,
+ error
+})
+
+export const configureAuthFromTenant = (tenantName) => (dispatch) => {
+ dispatch(authConfigRequest())
+ return API.fetchTenantInfo('tenant/' + tenantName + '/')
+ .then(response => {
+ dispatch(authConfigSuccess(
+ response.data,
+ createAuthParamsFromJson(response.data)))
+ })
+ .catch(error => {
+ dispatch(authConfigFail(error))
+ })
+}
+
+export const configureAuthFromInfo = (info) => (dispatch) => {
+ dispatch(authConfigSuccess(
+ {info: info},
+ createAuthParamsFromJson({info: info})))
+}
diff --git a/web/src/actions/info.js b/web/src/actions/info.js
index a28c62651..9024ab550 100644
--- a/web/src/actions/info.js
+++ b/web/src/actions/info.js
@@ -25,6 +25,7 @@ export const fetchInfoRequest = () => ({
export const fetchInfoSuccess = json => ({
type: INFO_FETCH_SUCCESS,
tenant: json.info.tenant,
+ capabilities: json.info.capabilities,
})
const fetchInfoFail = error => ({
@@ -35,7 +36,9 @@ const fetchInfoFail = error => ({
const fetchInfo = () => dispatch => {
dispatch(fetchInfoRequest())
return API.fetchInfo()
- .then(response => dispatch(fetchInfoSuccess(response.data)))
+ .then(response => {
+ dispatch(fetchInfoSuccess(response.data))
+ })
.catch(error => {
dispatch(fetchInfoFail(error))
setTimeout(() => {dispatch(fetchInfo())}, 5000)
diff --git a/web/src/actions/user.js b/web/src/actions/user.js
new file mode 100644
index 000000000..9b3261c9f
--- /dev/null
+++ b/web/src/actions/user.js
@@ -0,0 +1,72 @@
+// Copyright 2020 Red Hat, Inc
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License 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 API from '../api'
+import { USER_ACL_FAIL, USER_ACL_REQUEST, USER_ACL_SUCCESS } from './auth'
+
+export const USER_LOGGED_IN = 'USER_LOGGED_IN'
+export const USER_LOGGED_OUT = 'USER_LOGGED_OUT'
+
+// Access tokens are not necessary JWTs (Google OAUTH uses a custom format)
+// check the access token, if it isn't a JWT, use the ID token
+
+export function getToken(user) {
+ try {
+ JSON.parse(atob(user.access_token.split('.')[1]))
+ return user.access_token
+ } catch (e) {
+ return user.id_token
+ }
+}
+
+export const fetchUserACLRequest = (tenant) => ({
+ type: USER_ACL_REQUEST,
+ tenant: tenant,
+})
+
+export const userLoggedIn = (user) => (dispatch) => {
+ dispatch({
+ type: USER_LOGGED_IN,
+ user: user,
+ token: getToken(user),
+ })
+}
+
+export const userLoggedOut = () => (dispatch) => {
+ dispatch({
+ type: USER_LOGGED_OUT,
+ })
+}
+
+const fetchUserACLSuccess = (json) => ({
+ type: USER_ACL_SUCCESS,
+ isAdmin: json.zuul.admin,
+ scope: json.zuul.scope,
+})
+
+const fetchUserACLFail = error => ({
+ type: USER_ACL_FAIL,
+ error
+})
+
+export const fetchUserACL = (tenant, user) => (dispatch) => {
+ dispatch(fetchUserACLRequest(tenant))
+ let apiPrefix = 'tenant/' + tenant + '/'
+ return API.fetchUserAuthorizations(apiPrefix, user.token)
+ .then(response => dispatch(fetchUserACLSuccess(response.data)))
+ .catch(error => {
+ dispatch(fetchUserACLFail(error))
+ })
+}
diff --git a/web/src/api.js b/web/src/api.js
index c29ba3805..c032aa75e 100644
--- a/web/src/api.js
+++ b/web/src/api.js
@@ -14,7 +14,7 @@
import Axios from 'axios'
-function getHomepageUrl (url) {
+function getHomepageUrl(url) {
//
// Discover serving location from href.
//
@@ -64,14 +64,14 @@ function getHomepageUrl (url) {
if (baseUrl.includes('/t/')) {
baseUrl = baseUrl.slice(0, baseUrl.lastIndexOf('/t/') + 1)
}
- if (! baseUrl.endsWith('/')) {
+ if (!baseUrl.endsWith('/')) {
baseUrl = baseUrl + '/'
}
// console.log('Homepage url is ', baseUrl)
return baseUrl
}
-function getZuulUrl () {
+function getZuulUrl() {
// Return the zuul root api absolute url
const ZUUL_API = process.env.REACT_APP_ZUUL_API
let apiUrl
@@ -81,12 +81,12 @@ function getZuulUrl () {
apiUrl = ZUUL_API
} else {
// Api url is relative to homepage path
- apiUrl = getHomepageUrl () + 'api/'
+ apiUrl = getHomepageUrl() + 'api/'
}
- if (! apiUrl.endsWith('/')) {
+ if (!apiUrl.endsWith('/')) {
apiUrl = apiUrl + '/'
}
- if (! apiUrl.endsWith('/api/')) {
+ if (!apiUrl.endsWith('/api/')) {
apiUrl = apiUrl + 'api/'
}
// console.log('Api url is ', apiUrl)
@@ -95,7 +95,7 @@ function getZuulUrl () {
const apiUrl = getZuulUrl()
-function getStreamUrl (apiPrefix) {
+function getStreamUrl(apiPrefix) {
const streamUrl = (apiUrl + apiPrefix)
.replace(/(http)(s)?:\/\//, 'ws$2://') + 'console-stream'
// console.log('Stream url is ', streamUrl)
@@ -103,7 +103,7 @@ function getStreamUrl (apiPrefix) {
}
// Direct APIs
-function fetchInfo () {
+function fetchInfo() {
return Axios.get(apiUrl + 'info')
}
@@ -111,60 +111,76 @@ function fetchComponents() {
return Axios.get(apiUrl + 'components')
}
-function fetchOpenApi () {
- return Axios.get(getHomepageUrl () + 'openapi.yaml')
+function fetchTenantInfo(apiPrefix) {
+ return Axios.get(apiUrl + apiPrefix + 'info')
}
-function fetchTenants () {
+function fetchOpenApi() {
+ return Axios.get(getHomepageUrl() + 'openapi.yaml')
+}
+function fetchTenants() {
return Axios.get(apiUrl + 'tenants')
}
-function fetchConfigErrors (apiPrefix) {
+function fetchConfigErrors(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'config-errors')
}
-function fetchStatus (apiPrefix) {
+function fetchStatus(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'status')
}
-function fetchChangeStatus (apiPrefix, changeId) {
+function fetchChangeStatus(apiPrefix, changeId) {
return Axios.get(apiUrl + apiPrefix + 'status/change/' + changeId)
}
-function fetchBuild (apiPrefix, buildId) {
+function fetchBuild(apiPrefix, buildId) {
return Axios.get(apiUrl + apiPrefix + 'build/' + buildId)
}
-function fetchBuilds (apiPrefix, queryString) {
+function fetchBuilds(apiPrefix, queryString) {
let path = 'builds'
if (queryString) {
path += '?' + queryString.slice(1)
}
return Axios.get(apiUrl + apiPrefix + path)
}
-function fetchBuildset (apiPrefix, buildsetId) {
+function fetchBuildset(apiPrefix, buildsetId) {
return Axios.get(apiUrl + apiPrefix + 'buildset/' + buildsetId)
}
-function fetchBuildsets (apiPrefix, queryString) {
+function fetchBuildsets(apiPrefix, queryString) {
let path = 'buildsets'
if (queryString) {
path += '?' + queryString.slice(1)
}
return Axios.get(apiUrl + apiPrefix + path)
}
-function fetchProject (apiPrefix, projectName) {
+function fetchProject(apiPrefix, projectName) {
return Axios.get(apiUrl + apiPrefix + 'project/' + projectName)
}
-function fetchProjects (apiPrefix) {
+function fetchProjects(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'projects')
}
-function fetchJob (apiPrefix, jobName) {
+function fetchJob(apiPrefix, jobName) {
return Axios.get(apiUrl + apiPrefix + 'job/' + jobName)
}
-function fetchJobs (apiPrefix) {
+function fetchJobs(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'jobs')
}
-function fetchLabels (apiPrefix) {
+function fetchLabels(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'labels')
}
-function fetchNodes (apiPrefix) {
+function fetchNodes(apiPrefix) {
return Axios.get(apiUrl + apiPrefix + 'nodes')
}
+// token-protected API
+function fetchUserAuthorizations(apiPrefix, token) {
+ // Axios.defaults.headers.common['Authorization'] = 'Bearer ' + token
+ const instance = Axios.create({
+ baseURL: apiUrl
+ })
+ instance.defaults.headers.common['Authorization'] = 'Bearer ' + token
+ let res = instance.get(apiPrefix + 'authorizations')
+ .catch(err => { console.log('An error occurred', err) })
+ // Axios.defaults.headers.common['Authorization'] = ''
+ return res
+}
+
export {
apiUrl,
getHomepageUrl,
@@ -186,4 +202,6 @@ export {
fetchTenants,
fetchInfo,
fetchComponents,
+ fetchTenantInfo,
+ fetchUserAuthorizations,
}
diff --git a/web/src/containers/auth/Auth.jsx b/web/src/containers/auth/Auth.jsx
new file mode 100644
index 000000000..70a1753c6
--- /dev/null
+++ b/web/src/containers/auth/Auth.jsx
@@ -0,0 +1,242 @@
+// Copyright 2020 Red Hat, Inc
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License 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 React from 'react'
+import PropTypes from 'prop-types'
+import { connect } from 'react-redux'
+
+import {
+ Accordion,
+ AccordionItem,
+ AccordionToggle,
+ AccordionContent,
+ Button,
+ ButtonVariant,
+ ClipboardCopy,
+ ClipboardCopyVariant,
+ Modal,
+ ModalVariant
+} from '@patternfly/react-core'
+import {
+ UserIcon,
+ SignInAltIcon,
+ SignOutAltIcon,
+ HatWizardIcon
+} from '@patternfly/react-icons'
+
+import * as moment from 'moment'
+
+import { apiUrl } from '../../api'
+import { fetchUserACL } from '../../actions/user'
+import { withAuth } from 'oidc-react'
+import { getHomepageUrl } from '../../api'
+import { userLoggedIn, userLoggedOut } from '../../actions/user'
+
+
+class AuthContainer extends React.Component {
+ static propTypes = {
+ user: PropTypes.object,
+ tenant: PropTypes.object,
+ dispatch: PropTypes.func.isRequired,
+ timezone: PropTypes.string.isRequired,
+ info: PropTypes.object,
+ auth: PropTypes.object,
+ // Props coming from withAuth
+ signIn: PropTypes.func,
+ signOut: PropTypes.func,
+ userData: PropTypes.object,
+ }
+
+ constructor(props) {
+ super(props)
+ this.state = {
+ isModalOpen: false,
+ showZuulClientConfig: false,
+ }
+ this.handleModalToggle = () => {
+ this.setState(({ isModalOpen }) => ({
+ isModalOpen: !isModalOpen
+ }))
+ }
+ this.handleConfigToggle = () => {
+ this.setState(({ showZuulClientConfig }) => ({
+ showZuulClientConfig: !showZuulClientConfig
+ }))
+ }
+ }
+
+ componentDidMount() {
+ const { user, userData } = this.props
+
+ // Make sure redux is synced with the userManager
+ const now = Date.now() / 1000
+ if (userData && userData.expires_at < now) {
+ console.log('Token expired, logging out')
+ this.props.signOut()
+ this.props.dispatch(userLoggedOut())
+ } else if (user.data !== userData) {
+ console.log('Restoring login from userManager')
+ this.props.dispatch(userLoggedIn(userData))
+ }
+ }
+
+ componentDidUpdate() {
+ const { user, tenant } = this.props
+
+ // Make sure the token is current and the tenant is up to date.
+ const now = Date.now() / 1000
+ if (user.data && user.data.expires_at < now) {
+ console.log('Token expired, logging out')
+ this.props.signOut()
+ } else if (user.data && user.tenant !== tenant.name) {
+ console.log('Refreshing ACL', user.tenant, tenant.name)
+ this.props.dispatch(fetchUserACL(tenant.name, user))
+ }
+ }
+
+ ZuulClientConfig() {
+ const { user, tenant } = this.props
+
+ let ZCconfig
+ ZCconfig = '[' + tenant.name + ']\n'
+ ZCconfig = ZCconfig + 'url=' + apiUrl.slice(0, -4) + '\n'
+ ZCconfig = ZCconfig + 'tenant=' + tenant.name + '\n'
+ ZCconfig = ZCconfig + 'auth_token=' + user.token + '\n'
+
+ return ZCconfig
+ }
+
+ renderModal() {
+ const { user, tenant, timezone } = this.props
+ const { isModalOpen, showZuulClientConfig } = this.state
+ let config = this.ZuulClientConfig(tenant, user.data)
+ let valid_until = moment.unix(user.data.expires_at).tz(timezone).format('YYYY-MM-DD HH:mm:ss')
+ return (
+ <React.Fragment>
+ <Modal
+ variant={ModalVariant.small}
+ title="User Info"
+ isOpen={isModalOpen}
+ onClose={this.handleModalToggle}
+ actions={[
+ <Button
+ key="SignOut"
+ variant="primary"
+ onClick={() => {
+ this.props.signOut()
+ }}
+ title="Note that you will be logged out of Zuul, but not out of your identity provider.">
+ Sign Out &nbsp;
+ <SignOutAltIcon title='Sign Out' />
+ </Button>
+ ]}
+ >
+ <div>
+ <p key="user">Name: <strong>{user.data.profile.name}</strong></p>
+ <p key="preferred_username">Logged in as: <strong>{user.data.profile.preferred_username}</strong>&nbsp;
+ {(user.isAdmin && user.scope.indexOf(tenant.name) !== -1) && (
+ <HatWizardIcon title='This user can perform admin tasks' />
+ )}</p>
+ <Accordion asDefinitionList>
+ <AccordionItem>
+ <AccordionToggle
+ onClick={this.handleConfigToggle}
+ isExpanded={showZuulClientConfig}
+ title='Configuration parameters that can be used to perform tasks with the CLI'
+ id="ZCConfig">
+ Show Zuul Client Config
+ </AccordionToggle>
+ <AccordionContent
+ isHidden={!showZuulClientConfig}>
+ <ClipboardCopy isCode isReadOnly variant={ClipboardCopyVariant.expansion}>{config}</ClipboardCopy>
+ </AccordionContent>
+ </AccordionItem>
+ </Accordion>
+ <p key="valid_until">Token expiry date: <strong>{valid_until}</strong></p>
+ <p key="footer">
+ Zuul stores and uses information such as your username
+ and your email to provide some features. This data is
+ stored <strong>in your browser only</strong> and is
+ discarded once you log out.
+ </p>
+ </div>
+ </Modal>
+ </React.Fragment>
+ )
+ }
+
+ renderButton(containerStyles) {
+
+ const { user } = this.props
+ if (!user.data) {
+ return (
+ <div style={containerStyles}>
+ <Button
+ key="SignIn"
+ variant={ButtonVariant.plain}
+ onClick={() => {
+ const redirect_target = window.location.href.slice(getHomepageUrl().length)
+ localStorage.setItem('zuul_auth_redirect', redirect_target)
+ this.props.signIn({ redirect_uri: getHomepageUrl() + 'auth_callback' })
+ }}>
+ Sign in &nbsp;
+ <SignInAltIcon title='Sign In' />
+ </Button>
+ </div>
+ )
+ } else {
+ return (user.data.isFetching ? <div style={containerStyles}>Loading...</div> :
+ <div style={containerStyles}>
+ {this.renderModal()}
+ <Button
+ variant={ButtonVariant.plain}
+ key="userinfo"
+ onClick={this.handleModalToggle}>
+ <UserIcon title='User details' />
+ &nbsp;{user.data.profile.preferred_username}&nbsp;
+ </Button>
+ </div>
+ )
+ }
+ }
+
+ render() {
+ const { info, auth } = this.props
+ const textColor = '#d1d1d1'
+ const containerStyles = {
+ color: textColor,
+ border: 'solid #2b2b2b',
+ borderWidth: '0 0 0 1px',
+ display: 'initial',
+ padding: '6px'
+ }
+
+ if (info.isFetching) {
+ return (<><div style={containerStyles}>Fetching auth info ...</div></>)
+ }
+ if (auth.info) {
+ return this.renderButton(containerStyles)
+ } else {
+ return (<div style={containerStyles} title="Authentication disabled">-</div>)
+ }
+ }
+}
+
+export default connect(state => ({
+ auth: state.auth,
+ user: state.user,
+ tenant: state.tenant,
+ timezone: state.timezone,
+ info: state.info,
+}))(withAuth(AuthContainer))
diff --git a/web/src/containers/config/Config.jsx b/web/src/containers/config/Config.jsx
index 7c21229cb..3d402a116 100644
--- a/web/src/containers/config/Config.jsx
+++ b/web/src/containers/config/Config.jsx
@@ -90,7 +90,7 @@ class ConfigModal extends React.Component {
]}
>
<div>
- <p key="info">User configurable settings are saved in browser local storage only.</p>
+ <p key="info">Application settings are saved in browser local storage only. They are applied whether authenticated or not.</p>
<Switch
key="autoreload"
id="autoreload"
diff --git a/web/src/index.js b/web/src/index.js
index 15b24dd59..c1f90fa28 100644
--- a/web/src/index.js
+++ b/web/src/index.js
@@ -46,6 +46,7 @@ import App from './App'
// style attributes of PF4 component (as their CSS is loaded when the component
// is imported within the App).
import './index.css'
+import ZuulAuthProvider from './ZuulAuthProvider'
const store = configureStore()
@@ -54,6 +55,8 @@ store.dispatch(fetchInfoIfNeeded())
ReactDOM.render(
<Provider store={store}>
- <Router basename={new URL(getHomepageUrl()).pathname}><App /></Router>
+ <ZuulAuthProvider>
+ <Router basename={new URL(getHomepageUrl()).pathname}><App /></Router>
+ </ZuulAuthProvider>
</Provider>, document.getElementById('root'))
registerServiceWorker()
diff --git a/web/src/pages/AuthCallback.jsx b/web/src/pages/AuthCallback.jsx
new file mode 100644
index 000000000..c31f1d222
--- /dev/null
+++ b/web/src/pages/AuthCallback.jsx
@@ -0,0 +1,41 @@
+// Copyright 2020 Red Hat, Inc
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License 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 React, { useEffect } from 'react'
+import { useHistory } from 'react-router-dom'
+
+import { Fetching } from '../containers/Fetching'
+
+// Several pages use the location hash in a way that would be
+// difficult to disentangle from the OIDC callback parameters. This
+// dedicated callback page accepts the OIDC params and then internally
+// redirects to the page we saved before redirecting to the IDP.
+
+function AuthCallbackPage() {
+ let history = useHistory()
+
+ useEffect(() => {
+ const redirect = localStorage.getItem('zuul_auth_redirect')
+ history.push(redirect)
+ }, [history])
+
+ return (
+ <>
+ <div>Login successful. You will be redirected shortly...</div>
+ <Fetching />
+ </>
+ )
+}
+
+export default AuthCallbackPage
diff --git a/web/src/reducers/auth.js b/web/src/reducers/auth.js
new file mode 100644
index 000000000..a06bf5412
--- /dev/null
+++ b/web/src/reducers/auth.js
@@ -0,0 +1,76 @@
+// Copyright 2020 Red Hat, Inc
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License 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 {
+ AUTH_CONFIG_REQUEST,
+ AUTH_CONFIG_SUCCESS,
+ AUTH_CONFIG_FAIL,
+} from '../actions/auth'
+
+// Load the defaults from local storage if it exists so that we
+// construct the same AuthProvider we had before we navigated to the
+// IDP redirect.
+const stored_params = localStorage.getItem('zuul_auth_params')
+let auth_params = {
+ authority: '',
+ clientId: '',
+ scope: '',
+}
+if (stored_params !== null) {
+ auth_params = JSON.parse(stored_params)
+}
+
+export default (state = {
+ isFetching: false,
+ info: null,
+ auth_params: auth_params,
+}, action) => {
+ const json_params = JSON.stringify(action.auth_params)
+ switch (action.type) {
+ case AUTH_CONFIG_REQUEST:
+ return {
+ ...state,
+ isFetching: true,
+ info: null,
+ }
+ case AUTH_CONFIG_SUCCESS:
+ // Make sure we only update the auth_params object if something actually
+ // changes. Otherwise, it will re-create the AuthProvider which
+ // may cause errors with auth state if it happens concurrently with
+ // a login.
+ if (json_params === JSON.stringify(state.auth_params)) {
+ return {
+ ...state,
+ isFetching: false,
+ info: action.info,
+ }
+ } else {
+ localStorage.setItem('zuul_auth_params', json_params)
+ return {
+ ...state,
+ isFetching: false,
+ info: action.info,
+ auth_params: action.auth_params,
+ }
+ }
+ case AUTH_CONFIG_FAIL:
+ return {
+ ...state,
+ isFetching: false,
+ info: null,
+ }
+ default:
+ return state
+ }
+}
diff --git a/web/src/reducers/index.js b/web/src/reducers/index.js
index 32b2aa7d4..2e47320cd 100644
--- a/web/src/reducers/index.js
+++ b/web/src/reducers/index.js
@@ -14,6 +14,7 @@
import { combineReducers } from 'redux'
+import auth from './auth'
import configErrors from './configErrors'
import change from './change'
import component from './component'
@@ -33,8 +34,10 @@ import status from './status'
import tenant from './tenant'
import tenants from './tenants'
import timezone from './timezone'
+import user from './user'
const reducers = {
+ auth,
build,
change,
component,
@@ -54,6 +57,7 @@ const reducers = {
tenants,
timezone,
preferences,
+ user,
}
export default combineReducers(reducers)
diff --git a/web/src/reducers/info.js b/web/src/reducers/info.js
index 89cadf275..f29ab46ec 100644
--- a/web/src/reducers/info.js
+++ b/web/src/reducers/info.js
@@ -21,6 +21,7 @@ import {
export default (state = {
isFetching: false,
tenant: null,
+ capabilities: null,
}, action) => {
switch (action.type) {
case INFO_FETCH_REQUEST:
@@ -33,6 +34,7 @@ export default (state = {
return {
isFetching: false,
tenant: action.tenant,
+ capabilities: action.capabilities,
ready: true
}
default:
diff --git a/web/src/reducers/initialState.js b/web/src/reducers/initialState.js
index 422bf9f3e..ce74956e9 100644
--- a/web/src/reducers/initialState.js
+++ b/web/src/reducers/initialState.js
@@ -26,4 +26,6 @@ export default {
isFetching: false,
url: null,
},
+ auth: {},
+ user: {},
}
diff --git a/web/src/reducers/user.js b/web/src/reducers/user.js
new file mode 100644
index 000000000..1a36fdae1
--- /dev/null
+++ b/web/src/reducers/user.js
@@ -0,0 +1,74 @@
+// Copyright 2020 Red Hat, Inc
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may
+// not use this file except in compliance with the License. You may obtain
+// a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License 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 {
+ USER_LOGGED_IN,
+ USER_LOGGED_OUT,
+} from '../actions/user'
+import {
+ USER_ACL_REQUEST,
+ USER_ACL_SUCCESS,
+ USER_ACL_FAIL,
+} from '../actions/auth'
+
+export default (state = {
+ isFetching: false,
+ data: null,
+ scope: [],
+ isAdmin: false,
+ tenant: null,
+}, action) => {
+ switch (action.type) {
+ case USER_LOGGED_IN: {
+ return {
+ isFetching: false,
+ data: action.user,
+ token: action.token,
+ scope: [],
+ isAdmin: false
+ }
+ }
+ case USER_LOGGED_OUT:
+ return {
+ isFetching: false,
+ data: null,
+ token: null,
+ scope: [],
+ isAdmin: false
+ }
+ case USER_ACL_REQUEST:
+ return {
+ ...state,
+ tenant: action.tenant,
+ isFetching: true
+ }
+ case USER_ACL_FAIL:
+ return {
+ ...state,
+ isFetching: false,
+ scope: [],
+ isAdmin: false
+ }
+ case USER_ACL_SUCCESS:
+ return {
+ ...state,
+ isFetching: false,
+ scope: action.scope,
+ isAdmin: action.isAdmin
+ }
+ default:
+ return state
+ }
+}
diff --git a/web/src/routes.js b/web/src/routes.js
index 90a06bc3b..efbef2d04 100644
--- a/web/src/routes.js
+++ b/web/src/routes.js
@@ -29,6 +29,7 @@ import ConfigErrorsPage from './pages/ConfigErrors'
import TenantsPage from './pages/Tenants'
import StreamPage from './pages/Stream'
import OpenApiPage from './pages/OpenApi'
+import AuthCallbackPage from './pages/AuthCallback'
// The Route object are created in the App component.
// Object with a title are created in the menu.
@@ -89,27 +90,27 @@ const routes = () => [
{
to: '/build/:buildId',
component: BuildPage,
- props: {'activeTab': 'results'},
+ props: { 'activeTab': 'results' },
},
{
to: '/build/:buildId/artifacts',
component: BuildPage,
- props: {'activeTab': 'artifacts'},
+ props: { 'activeTab': 'artifacts' },
},
{
to: '/build/:buildId/logs',
component: BuildPage,
- props: {'activeTab': 'logs'},
+ props: { 'activeTab': 'logs' },
},
{
to: '/build/:buildId/console',
component: BuildPage,
- props: {'activeTab': 'console'},
+ props: { 'activeTab': 'console' },
},
{
to: '/build/:buildId/log/:file*',
component: BuildPage,
- props: {'activeTab': 'logs', 'logfile': true},
+ props: { 'activeTab': 'logs', 'logfile': true },
},
{
to: '/buildset/:buildsetId',
@@ -134,6 +135,11 @@ const routes = () => [
component: ComponentsPage,
noTenantPrefix: true,
},
+ {
+ to: '/auth_callback',
+ component: AuthCallbackPage,
+ noTenantPrefix: true,
+ },
]
export { routes }
diff --git a/web/src/store.dev.js b/web/src/store.dev.js
index 2c0f16a96..6bdb750d9 100644
--- a/web/src/store.dev.js
+++ b/web/src/store.dev.js
@@ -32,7 +32,11 @@ export default function configureStore(initialState) {
// TODO (felix): Re-enable the status.status path once we know how to
// solve the weird state mutations that are done somewhere deep within
// the logic of the status page (or its child components).
- reduxImmutableStateInvariant({ ignore: ['status.status'] })
+ reduxImmutableStateInvariant({
+ ignore: [
+ 'status.status',
+ ]
+ })
)
)
)
diff --git a/web/yarn.lock b/web/yarn.lock
index 98c8c14c2..0c7d45b27 100644
--- a/web/yarn.lock
+++ b/web/yarn.lock
@@ -2415,6 +2415,11 @@ acorn@^7.1.1:
resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.2.0.tgz#17ea7e40d7c8640ff54a694c889c26f31704effe"
integrity sha512-apwXVmYVpQ34m/i71vrApRrRKCWQnZZF1+npOD0WV5xZFfwWOmKGQ2RWlfdy9vWITsenisM8M0Qeq8agcFHNiQ==
+acorn@^7.4.1:
+ version "7.4.1"
+ resolved "https://registry.yarnpkg.com/acorn/-/acorn-7.4.1.tgz#feaed255973d2e77555b83dbc08851a6c63520fa"
+ integrity sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==
+
address@1.1.2, address@^1.0.1:
version "1.1.2"
resolved "https://registry.yarnpkg.com/address/-/address-1.1.2.tgz#bf1116c9c758c51b7a933d296b72c221ed9428b6"
@@ -3040,6 +3045,11 @@ base64-js@^1.0.2, base64-js@^1.2.0:
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.3.1.tgz#58ece8cb75dd07e71ed08c736abc5fac4dbf8df1"
integrity sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g==
+base64-js@^1.3.0, base64-js@^1.5.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
+ integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
+
base@^0.11.1:
version "0.11.2"
resolved "https://registry.yarnpkg.com/base/-/base-0.11.2.tgz#7bde5ced145b6d551a90db87f83c558b4eb48a8f"
@@ -4247,11 +4257,21 @@ core-js@^2.4.0, core-js@^2.5.0, core-js@^2.5.1, core-js@^2.6.5:
resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.11.tgz#38831469f9922bded8ee21c9dc46985e0399308c"
integrity sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==
+core-js@^2.6.4:
+ version "2.6.12"
+ resolved "https://registry.yarnpkg.com/core-js/-/core-js-2.6.12.tgz#d9333dfa7b065e347cc5682219d6f690859cc2ec"
+ integrity sha512-Kb2wC0fvsWfQrgk8HU5lW6U/Lcs8+9aaYcy4ZFc6DDlo4nZ7n70dEgE5rtR0oG6ufKDUnrwfWL1mXR5ljDatrQ==
+
core-js@^3.5.0:
version "3.6.5"
resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.6.5.tgz#7395dc273af37fb2e50e9bd3d9fe841285231d1a"
integrity sha512-vZVEEwZoIsI+vPEuoF9Iqf5H7/M3eeQqWlQnYa8FSKKePuYTf5MWnxb5SDAzCa60b3JBRS5g9b+Dq7b1y/RCrA==
+core-js@^3.8.3:
+ version "3.16.1"
+ resolved "https://registry.yarnpkg.com/core-js/-/core-js-3.16.1.tgz#f4485ce5c9f3c6a7cb18fa80488e08d362097249"
+ integrity sha512-AAkP8i35EbefU+JddyWi12AWE9f2N/qr/pwnDtWz4nyUIBGMJPX99ANFFRSw6FefM374lDujdtLDyhN2A/btHw==
+
core-util-is@1.0.2, core-util-is@~1.0.0:
version "1.0.2"
resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7"
@@ -4396,6 +4416,16 @@ crypto-browserify@^3.11.0:
randombytes "^2.0.0"
randomfill "^1.0.3"
+crypto-js@^3.1.9-1:
+ version "3.3.0"
+ resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-3.3.0.tgz#846dd1cce2f68aacfa156c8578f926a609b7976b"
+ integrity sha512-DIT51nX0dCfKltpRiXV+/TVZq+Qq2NgF4644+K7Ttnla7zEzqc+kjJyiB96BHNyUTBxyjzRcZYpUdZa+QAqi6Q==
+
+crypto-js@^4.0.0:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/crypto-js/-/crypto-js-4.1.1.tgz#9e485bcf03521041bd85844786b83fb7619736cf"
+ integrity sha512-o2JlM7ydqd3Qk9CA0L4NL6mTzU2sdx96a+oOfPu8Mkl/PK51vSyoi8/rQ8NknZtk44vq15lmhAj9CIAGwgeWKw==
+
crypto-random-string@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e"
@@ -10277,6 +10307,34 @@ octokit-pagination-methods@^1.1.0:
resolved "https://registry.yarnpkg.com/octokit-pagination-methods/-/octokit-pagination-methods-1.1.0.tgz#cf472edc9d551055f9ef73f6e42b4dbb4c80bea4"
integrity sha512-fZ4qZdQ2nxJvtcasX7Ghl+WlWS/d9IgnBIwFZXVNNZUmzpno91SX5bc5vuxiuKoCtK78XxGGNuSCrDC7xYB3OQ==
+oidc-client@^1.10.1:
+ version "1.10.1"
+ resolved "https://registry.yarnpkg.com/oidc-client/-/oidc-client-1.10.1.tgz#fe67ae54924fc1c338062f3fd733be362026192c"
+ integrity sha512-/QB5Nl7c9GmT9ir1E+OVY3+yZZnuk7Qa9ZEAJqSvDq0bAyAU9KAgeKipTEfKjGdGLTeOLy9FRWuNpULMkfZydQ==
+ dependencies:
+ base64-js "^1.3.0"
+ core-js "^2.6.4"
+ crypto-js "^3.1.9-1"
+ uuid "^3.3.2"
+
+oidc-client@^1.11.5:
+ version "1.11.5"
+ resolved "https://registry.yarnpkg.com/oidc-client/-/oidc-client-1.11.5.tgz#020aa193d68a3e1f87a24fcbf50073b738de92bb"
+ integrity sha512-LcKrKC8Av0m/KD/4EFmo9Sg8fSQ+WFJWBrmtWd+tZkNn3WT/sQG3REmPANE9tzzhbjW6VkTNy4xhAXCfPApAOg==
+ dependencies:
+ acorn "^7.4.1"
+ base64-js "^1.5.1"
+ core-js "^3.8.3"
+ crypto-js "^4.0.0"
+ serialize-javascript "^4.0.0"
+
+oidc-react@^1.5.1:
+ version "1.5.1"
+ resolved "https://registry.yarnpkg.com/oidc-react/-/oidc-react-1.5.1.tgz#afe83335252b7657835298b80f5e4b8bbb8f5bd1"
+ integrity sha512-rbNfWUBRiqolht9J5tRu5HktDs8yXii1KfOBXtM9uwQNaiY0UgvIzl2h7NNOYA04TFxKtGlXkwrs9cPn28JwEg==
+ dependencies:
+ oidc-client "^1.11.5"
+
on-finished@~2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.3.0.tgz#20f1336481b083cd75337992a16971aa2d906947"
@@ -13271,6 +13329,13 @@ serialize-javascript@^2.1.2:
resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-2.1.2.tgz#ecec53b0e0317bdc95ef76ab7074b7384785fa61"
integrity sha512-rs9OggEUF0V4jUSecXazOYsLfu7OGK2qIn3c7IPBiffz32XniEp/TX9Xmc9LQfK2nQ2QKHvZ2oygKUGU0lG4jQ==
+serialize-javascript@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/serialize-javascript/-/serialize-javascript-4.0.0.tgz#b525e1238489a5ecfc42afacc3fe99e666f4b1aa"
+ integrity sha512-GaNA54380uFefWghODBWEGisLZFj00nS5ACs6yHa9nLqlLpVLO8ChDGeKRjZnV4Nh4n0Qi7nhYZD/9fCPzEqkw==
+ dependencies:
+ randombytes "^2.1.0"
+
serve-index@^1.9.1:
version "1.9.1"
resolved "https://registry.yarnpkg.com/serve-index/-/serve-index-1.9.1.tgz#d3768d69b1e7d82e5ce050fff5b453bea12a9239"