Merge "Support auth in multiple tabs"

This commit is contained in:
Zuul 2021-11-24 22:28:10 +00:00 committed by Gerrit Code Review
commit 7f3aede03f
9 changed files with 286 additions and 44 deletions

View File

@ -12,6 +12,7 @@
"@patternfly/react-table": "4.29.58",
"@softwarefactory-project/re-ansi": "^0.4.0",
"axios": "^0.19.0",
"broadcast-channel": "^4.5.0",
"js-yaml": "^3.13.0",
"lodash": "^4.17.10",
"moment": "^2.22.2",

View File

@ -17,6 +17,7 @@ import { create, act } from 'react-test-renderer'
import ReactDOM from 'react-dom'
import { Link, BrowserRouter as Router } from 'react-router-dom'
import { Provider } from 'react-redux'
import { BroadcastChannel, createLeaderElection } from 'broadcast-channel'
import { fetchInfoIfNeeded } from './actions/info'
import configureStore from './store'
@ -32,20 +33,27 @@ api.fetchStatus = jest.fn()
api.fetchConfigErrors = jest.fn()
api.fetchConfigErrors.mockImplementation(() => Promise.resolve({data: []}))
it('renders without crashing', () => {
it('renders without crashing', async () => {
const store = configureStore()
const channel = new BroadcastChannel('zuul')
const auth_election = createLeaderElection(channel)
const div = document.createElement('div')
ReactDOM.render(
<Provider store={store}>
<ZuulAuthProvider><Router><App /></Router></ZuulAuthProvider>
<ZuulAuthProvider channel={channel} election={auth_election}>
<Router><App /></Router>
</ZuulAuthProvider>
</Provider>,
div)
ReactDOM.unmountComponentAtNode(div)
await auth_election.die()
await channel.close()
})
it('renders multi tenant', async () => {
const store = configureStore()
const channel = new BroadcastChannel('zuul')
const auth_election = createLeaderElection(channel)
api.fetchInfo.mockImplementation(
() => Promise.resolve({data: {
info: {capabilities: {}}
@ -57,7 +65,9 @@ it('renders multi tenant', async () => {
const application = create(
<Provider store={store}>
<ZuulAuthProvider><Router><App /></Router></ZuulAuthProvider>
<ZuulAuthProvider channel={channel} election={auth_election}>
<Router><App /></Router>
</ZuulAuthProvider>
</Provider>
)
@ -80,10 +90,14 @@ it('renders multi tenant', async () => {
expect(application.root.findAllByType(TenantsPage)).not.toEqual(null)
// Fetch tenants has been called
expect(api.fetchTenants).toBeCalled()
await auth_election.die()
await channel.close()
})
it('renders single tenant', async () => {
const store = configureStore()
const channel = new BroadcastChannel('zuul')
const auth_election = createLeaderElection(channel)
api.fetchInfo.mockImplementation(
() => Promise.resolve({data: {
info: {capabilities: {}, tenant: 'openstack'}
@ -95,7 +109,9 @@ it('renders single tenant', async () => {
const application = create(
<Provider store={store}>
<ZuulAuthProvider><Router><App /></Router></ZuulAuthProvider>
<ZuulAuthProvider channel={channel} election={auth_election}>
<Router><App /></Router>
</ZuulAuthProvider>
</Provider>
)
@ -116,4 +132,6 @@ it('renders single tenant', async () => {
expect(application.root.findAllByType(StatusPage)).not.toEqual(null)
// Fetch status has been called
expect(api.fetchStatus).toBeCalled()
await auth_election.die()
await channel.close()
})

View File

@ -19,6 +19,9 @@ import { connect } from 'react-redux'
import { AuthProvider } from 'oidc-react'
import { userLoggedIn, userLoggedOut } from './actions/user'
import { UserManager, User } from 'oidc-client'
import { getHomepageUrl } from './api'
import _ from 'lodash'
class ZuulAuthProvider extends React.Component {
@ -41,26 +44,136 @@ class ZuulAuthProvider extends React.Component {
*/
static propTypes = {
auth_params: PropTypes.object,
channel: PropTypes.object,
election: PropTypes.object,
dispatch: PropTypes.func,
children: PropTypes.any,
}
render() {
const { auth_params } = this.props
const { auth_params, channel, election } = this.props
console.debug('ZuulAuthProvider rendering with params', auth_params)
const userManager = new UserManager({
...auth_params,
response_type: 'token id_token',
silent_redirect_uri: getHomepageUrl() + 'silent_callback',
redirect_uri: getHomepageUrl() + 'auth_callback',
includeIdTokenInSilentRenew: false,
})
const oidcConfig = {
onSignIn: async (user) => {
// Update redux with the logged in state and send the
// credentials to any other tabs.
this.props.dispatch(userLoggedIn(user))
this.props.channel.postMessage({
type: 'signIn',
auth_params: auth_params,
user: user
})
},
onSignOut: async () => {
// Update redux with the logged out state and send the
// credentials to any other tabs.
this.props.dispatch(userLoggedOut())
this.props.channel.postMessage({
type: 'signOut',
auth_params: auth_params
})
},
responseType: 'token id_token',
autoSignIn: false,
...auth_params,
userManager: userManager,
}
// This is called whenever we receive a message from another tab
channel.onmessage = (msg) => {
console.debug('Received broadcast message', msg)
if (msg.type === 'signIn' && _.isEqual(msg.auth_params, auth_params)) {
const user = new User(msg.user)
userManager.getUser().then((olduser) => {
// In some cases, we can receive our own message, so make
// sure that the user info we received is different from
// what we already have.
let needToUpdate = true
if (olduser) {
if (user.toStorageString() === olduser.toStorageString()) {
needToUpdate = false
}
}
if (needToUpdate) {
console.debug('New token from other tab')
userManager.storeUser(user)
userManager.events.load(user)
this.props.dispatch(userLoggedIn(user))
}
})
}
else if (msg.type === 'signOut' && _.isEqual(msg.auth_params, auth_params)) {
userManager.removeUser()
this.props.dispatch(userLoggedOut())
}
else if (msg.type === 'init') {
// A new tab has been created; send our token in case it's helpful.
userManager.getUser().then((user) => {
if (user) {
console.debug('Sending token to new tab')
this.props.channel.postMessage({
type: 'signIn',
auth_params: auth_params,
user: user
})
}
})
}
}
// If we already have user data saved in session storage, we need to
// tell redux about it.
userManager.getUser().then((user) => {
if (user) {
console.debug('Restoring initial login from userManager')
this.props.dispatch(userLoggedIn(user))
} else {
// Maybe another tab is logged in. Ask them to send us tokens.
console.debug('Asking other tabs for auth tokens')
this.props.channel.postMessage({ type: 'init' })
}
})
// This is called when a token is expired
userManager.events.addAccessTokenExpired(() => {
console.log('Auth token expired')
userManager.removeUser()
this.props.dispatch(userLoggedOut())
this.props.channel.postMessage({ 'type': 'signOut' })
})
// This is called about 1 minute before a token is expired. We will try
// to renew the token. We use a leader election so that only one tab
// makes the attempt; the others will receive the token via a broadcast
// event.
userManager.events.addAccessTokenExpiring(() => {
if (election.isLeader) {
console.debug('Token is expiring; renewing')
userManager.signinSilent().then(user => {
console.debug('Token renewal successful')
this.props.dispatch(userLoggedIn(user))
channel.postMessage({
type: 'signIn',
auth_params: auth_params,
user: user
})
}, err => {
console.error('Error renewing token:', err.message)
})
} else {
console.debug('Token is expiring; expecting leader to renew')
}
})
return (
<React.Fragment>
<AuthProvider {...oidcConfig} key={JSON.stringify(auth_params)}>

View File

@ -34,7 +34,7 @@ function createAuthParamsFromJson(json) {
let auth_params = {
authority: '',
clientId: '',
client_id: '',
scope: '',
}
if (!auth_info) {
@ -44,7 +44,7 @@ function createAuthParamsFromJson(json) {
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.client_id = client_config.client_id
auth_params.scope = client_config.scope
auth_params.authority = client_config.authority
return auth_params

View File

@ -41,7 +41,6 @@ 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 {
@ -55,7 +54,6 @@ class AuthContainer extends React.Component {
// Props coming from withAuth
signIn: PropTypes.func,
signOut: PropTypes.func,
userData: PropTypes.object,
}
constructor(props) {
@ -76,30 +74,11 @@ class AuthContainer extends React.Component {
}
}
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) {
if (user.data && user.tenant !== tenant.name) {
console.log('Refreshing ACL', user.tenant, tenant.name)
this.props.dispatch(fetchUserACL(tenant.name, user))
}
@ -188,7 +167,7 @@ class AuthContainer extends React.Component {
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' })
this.props.signIn()
}}>
Sign in &nbsp;
<SignInAltIcon title='Sign In' />

View File

@ -19,6 +19,7 @@ import React from 'react'
import ReactDOM from 'react-dom'
import { BrowserRouter as Router } from 'react-router-dom'
import { Provider } from 'react-redux'
import { BroadcastChannel, createLeaderElection } from 'broadcast-channel'
import 'patternfly/dist/css/patternfly.min.css'
import 'patternfly/dist/css/patternfly-additions.min.css'
// NOTE (felix): The Patternfly 4 CSS file must be imported before the App
@ -47,16 +48,48 @@ import App from './App'
// is imported within the App).
import './index.css'
import ZuulAuthProvider from './ZuulAuthProvider'
import SilentCallback from './pages/SilentCallback'
const store = configureStore()
// Uncomment the next 3 lines to enable debug-level logging from
// oidc-client.
// import { Log } from 'oidc-client'
// Log.logger = console
// Log.level = Log.DEBUG
// Load info endpoint
store.dispatch(fetchInfoIfNeeded())
// Don't render the entire application to handle a silent
// authentication callback.
if ((window.location.origin + window.location.pathname) ===
(getHomepageUrl() + 'silent_callback')) {
ReactDOM.render(
<Provider store={store}>
<ZuulAuthProvider>
<Router basename={new URL(getHomepageUrl()).pathname}><App /></Router>
</ZuulAuthProvider>
</Provider>, document.getElementById('root'))
registerServiceWorker()
ReactDOM.render(
<SilentCallback/>,
document.getElementById('root'))
} else {
const store = configureStore()
// Load info endpoint
store.dispatch(fetchInfoIfNeeded())
// Create a broadcast channel for sending auth (or other)
// information between tabs.
const channel = new BroadcastChannel('zuul')
// Create an election so that only one tab will renew auth tokens. We run the
// election perpetually and just check whether we are the leader when it's time
// to renew tokens.
const auth_election = createLeaderElection(channel)
const waitForever = new Promise(function () {})
auth_election.awaitLeadership().then(()=> {
waitForever.then(function() {})
})
ReactDOM.render(
<Provider store={store}>
<ZuulAuthProvider channel={channel} election={auth_election}>
<Router basename={new URL(getHomepageUrl()).pathname}><App /></Router>
</ZuulAuthProvider>
</Provider>, document.getElementById('root'))
registerServiceWorker()
}

View File

@ -0,0 +1,36 @@
// 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.
// This is an abbreviated rendering of the app which only renders the
// AuthProvider. This is rendered in a hidden iframe during
// authentication token renewal. We don't need to render the entire
// app, and we also don't need to render the AuthProvider with all of
// our settings.
import React from 'react'
import { AuthProvider } from 'oidc-react'
import { UserManager } from 'oidc-client'
class SilentCallback extends React.Component {
render() {
const oidcConfig = {
autoSignIn: false,
userManager: new UserManager(),
}
return <AuthProvider {...oidcConfig}/>
}
}
export default SilentCallback

View File

@ -24,7 +24,7 @@ import {
const stored_params = localStorage.getItem('zuul_auth_params')
let auth_params = {
authority: '',
clientId: '',
client_id: '',
scope: '',
}
if (stored_params !== null) {

View File

@ -1017,6 +1017,13 @@
dependencies:
regenerator-runtime "^0.13.4"
"@babel/runtime@^7.16.0", "@babel/runtime@^7.6.2":
version "7.16.3"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.3.tgz#b86f0db02a04187a3c17caa77de69840165d42d5"
integrity sha512-WBwekcqacdY2e9AF/Q7WLFUWmdJGJTkbjqTjoMDgXkVZ3ZRUvOPsLb5KdwISoQVsbP+DQzVZW4Zhci0DvpbNTQ==
dependencies:
regenerator-runtime "^0.13.4"
"@babel/template@^7.4.0", "@babel/template@^7.8.3", "@babel/template@^7.8.6":
version "7.8.6"
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.8.6.tgz#86b22af15f828dfb086474f964dcc3e39c43ce2b"
@ -3080,6 +3087,11 @@ before-after-hook@^2.0.0:
resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635"
integrity sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A==
big-integer@^1.6.16:
version "1.6.51"
resolved "https://registry.yarnpkg.com/big-integer/-/big-integer-1.6.51.tgz#0df92a5d9880560d3ff2d5fd20245c889d130686"
integrity sha512-GPEid2Y9QU1Exl1rpO9B2IPJGHPSupF5GnVIP0blYvNOMer2bTvSWs1jGOUg04hTmu67nmLsQ9TBo1puaotBHg==
big.js@^5.2.2:
version "5.2.2"
resolved "https://registry.yarnpkg.com/big.js/-/big.js-5.2.2.tgz#65f0af382f578bcdc742bd9c281e9cb2d7768328"
@ -3260,6 +3272,19 @@ breakjs@^1.0.0:
resolved "https://registry.yarnpkg.com/breakjs/-/breakjs-1.0.0.tgz#ec8353a06862eb43962deae09072ee66a4cd8459"
integrity sha1-7INToGhi60OWLergkHLuZqTNhFk=
broadcast-channel@^4.5.0:
version "4.5.0"
resolved "https://registry.yarnpkg.com/broadcast-channel/-/broadcast-channel-4.5.0.tgz#d4717c493e219908fcb7f2f9078fe0baf95b77c1"
integrity sha512-jp+VPlQ1HyR0CM3uIYUrdpXupBvhTMFRkjR6mEmt5W4HaGDPFEzrO2Jqvi2PZ6zCC4zwLeco7CC5EUJPrVH8Tw==
dependencies:
"@babel/runtime" "^7.16.0"
detect-node "^2.1.0"
microseconds "0.2.0"
nano-time "1.0.0"
oblivious-set "1.0.0"
rimraf "3.0.2"
unload "2.3.1"
brorand@^1.0.1:
version "1.1.0"
resolved "https://registry.yarnpkg.com/brorand/-/brorand-1.1.0.tgz#12c25efe40a45e3c323eb8675a0a0ce57b22371f"
@ -5039,6 +5064,11 @@ detect-newline@^2.1.0:
resolved "https://registry.yarnpkg.com/detect-newline/-/detect-newline-2.1.0.tgz#f41f1c10be4b00e87b5f13da680759f2c5bfd3e2"
integrity sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=
detect-node@2.1.0, detect-node@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.1.0.tgz#c9c70775a49c3d03bc2c06d9a73be550f978f8b1"
integrity sha512-T0NIuQpnTvFDATNuHN5roPwSBG83rFsuO+MXXH9/3N1eFbn4wcPjttvjMLEPWJ0RGUYgQE7cGgS3tNxbqCGM7g==
detect-node@^2.0.4:
version "2.0.4"
resolved "https://registry.yarnpkg.com/detect-node/-/detect-node-2.0.4.tgz#014ee8f8f669c5c58023da64b8179c083a28c46c"
@ -9418,6 +9448,11 @@ micromatch@^4.0.2:
braces "^3.0.1"
picomatch "^2.0.5"
microseconds@0.2.0:
version "0.2.0"
resolved "https://registry.yarnpkg.com/microseconds/-/microseconds-0.2.0.tgz#233b25f50c62a65d861f978a4a4f8ec18797dc39"
integrity sha512-n7DHHMjR1avBbSpsTBj6fmMGh2AGrifVV4e+WYc3Q9lO+xnSZ3NyhcBND3vzzatt05LFhoKFRxrIyklmLlUtyA==
miller-rabin@^4.0.0:
version "4.0.1"
resolved "https://registry.yarnpkg.com/miller-rabin/-/miller-rabin-4.0.1.tgz#f080351c865b0dc562a8462966daa53543c78a4d"
@ -9690,6 +9725,13 @@ nan@^2.12.1:
resolved "https://registry.yarnpkg.com/nan/-/nan-2.14.1.tgz#d7be34dfa3105b91494c3147089315eff8874b01"
integrity sha512-isWHgVjnFjh2x2yuJ/tj3JbwoHu3UC2dX5G/88Cm24yB6YopVgxvBObDY7n5xW6ExmFhJpSEQqFPvq9zaXc8Jw==
nano-time@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/nano-time/-/nano-time-1.0.0.tgz#b0554f69ad89e22d0907f7a12b0993a5d96137ef"
integrity sha1-sFVPaa2J4i0JB/ehKwmTpdlhN+8=
dependencies:
big-integer "^1.6.16"
nanoid@2.1.11:
version "2.1.11"
resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-2.1.11.tgz#ec24b8a758d591561531b4176a01e3ab4f0f0280"
@ -10297,6 +10339,11 @@ object.values@^1.1.0, object.values@^1.1.1:
function-bind "^1.1.1"
has "^1.0.3"
oblivious-set@1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/oblivious-set/-/oblivious-set-1.0.0.tgz#c8316f2c2fb6ff7b11b6158db3234c49f733c566"
integrity sha512-z+pI07qxo4c2CulUHCDf9lcqDlMSo72N/4rLUpRXf6fu+q8vjt8y0xS+Tlf8NTJDdTXHbdeO1n3MlbctwEoXZw==
obuf@^1.0.0, obuf@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/obuf/-/obuf-1.1.2.tgz#09bea3343d41859ebd446292d11c9d4db619084e"
@ -13090,6 +13137,13 @@ rimraf@2.6.3:
dependencies:
glob "^7.1.3"
rimraf@3.0.2:
version "3.0.2"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
integrity sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
dependencies:
glob "^7.1.3"
rimraf@^2.5.2, rimraf@^2.5.4, rimraf@^2.6.2, rimraf@^2.6.3, rimraf@^2.7.1:
version "2.7.1"
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-2.7.1.tgz#35797f13a7fdadc566142c29d4f07ccad483e3ec"
@ -14735,6 +14789,14 @@ universalify@^0.1.0:
resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66"
integrity sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==
unload@2.3.1:
version "2.3.1"
resolved "https://registry.yarnpkg.com/unload/-/unload-2.3.1.tgz#9d16862d372a5ce5cb630ad1309c2fd6e35dacfe"
integrity sha512-MUZEiDqvAN9AIDRbbBnVYVvfcR6DrjCqeU2YQMmliFZl9uaBUjTkhuDQkBiyAy8ad5bx1TXVbqZ3gg7namsWjA==
dependencies:
"@babel/runtime" "^7.6.2"
detect-node "2.1.0"
unpipe@1.0.0, unpipe@~1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec"