Skip to content

Commit

Permalink
Add Azure sign-in (#2089)
Browse files Browse the repository at this point in the history
  • Loading branch information
dimaryaz authored Mar 6, 2021
1 parent 18a92c2 commit 542514e
Show file tree
Hide file tree
Showing 7 changed files with 235 additions and 0 deletions.
110 changes: 110 additions & 0 deletions catalog/app/containers/Auth/SSOAzure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import { push } from 'connected-react-router/esm/immutable'
import invariant from 'invariant'
import * as React from 'react'
import { FormattedMessage as FM } from 'react-intl'
import * as redux from 'react-redux'
import * as M from '@material-ui/core'

import { useIntl } from 'containers/LanguageProvider'
import * as Notifications from 'containers/Notifications'
import * as Config from 'utils/Config'
import * as NamedRoutes from 'utils/NamedRoutes'
import * as Azure from 'utils/Azure'
import * as Sentry from 'utils/Sentry'
import defer from 'utils/defer'

import * as actions from './actions'
import * as errors from './errors'
import msg from './messages'

import microsoftLogo from './microsoft-logo.svg'

const MUTEX_POPUP = 'sso:azure:popup'
const MUTEX_REQUEST = 'sso:azure:request'

export default function SSOAzure({ mutex, next, ...props }) {
const cfg = Config.useConfig()
invariant(!!cfg.azureClientId, 'Auth.SSO.Azure: config missing "azureClientId"')
invariant(!!cfg.azureBaseUrl, 'Auth.SSO.Azure: config missing "azureBaseUrl"')
const authenticate = Azure.use({
clientId: cfg.azureClientId,
baseUrl: cfg.azureBaseUrl,
})

const sentry = Sentry.use()
const dispatch = redux.useDispatch()
const intl = useIntl()
const { push: notify } = Notifications.use()
const { urls } = NamedRoutes.use()

const handleClick = React.useCallback(async () => {
if (mutex.current) return
mutex.claim(MUTEX_POPUP)

try {
const token = await authenticate()
const provider = 'azure'
const result = defer()
mutex.claim(MUTEX_REQUEST)
try {
dispatch(actions.signIn({ provider, token }, result.resolver))
await result.promise
} catch (e) {
if (e instanceof errors.SSOUserNotFound) {
if (cfg.ssoAuth === true) {
dispatch(push(urls.ssoSignUp({ provider, token, next })))
// dont release mutex on redirect
return
}
notify(intl.formatMessage(msg.ssoAzureNotFound))
} else {
notify(intl.formatMessage(msg.ssoAzureErrorUnexpected))
sentry('captureException', e)
}
mutex.release(MUTEX_REQUEST)
}
} catch (e) {
if (e instanceof Azure.AzureError) {
if (e.code !== 'popup_closed_by_user') {
notify(intl.formatMessage(msg.ssoAzureError, { details: e.details }))
sentry('captureException', e)
}
} else {
notify(intl.formatMessage(msg.ssoAzureErrorUnexpected))
sentry('captureException', e)
}
mutex.release(MUTEX_POPUP)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
authenticate,
cfg.ssoAuth,
dispatch,
intl.formatMessage,
mutex.claim,
mutex.release,
next,
notify,
sentry,
urls,
])

return (
<M.Button
variant="outlined"
onClick={handleClick}
disabled={!!mutex.current}
{...props}
>
{mutex.current === MUTEX_REQUEST ? (
<M.CircularProgress size={18} />
) : (
<M.Box component="img" src={microsoftLogo} alt="" height={18} />
)}
<M.Box mr={1} />
<span style={{ whiteSpace: 'nowrap' }}>
<FM {...msg.ssoAzureUse} />
</span>
</M.Button>
)
}
11 changes: 11 additions & 0 deletions catalog/app/containers/Auth/SignIn.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import useMutex from 'utils/useMutex'
import * as validators from 'utils/validators'

import * as Layout from './Layout'
import SSOAzure from './SSOAzure'
import SSOGoogle from './SSOGoogle'
import SSOOkta from './SSOOkta'
import SSOOneLogin from './SSOOneLogin'
Expand Down Expand Up @@ -145,6 +146,16 @@ export default ({ location: { search } }) => {
/>
</>
)}
{ssoEnabled('azure') && (
<>
<M.Box mt={2} />
<SSOAzure
mutex={mutex}
next={next}
style={{ justifyContent: 'flex-start' }}
/>
</>
)}
</M.Box>
</M.Box>
)}
Expand Down
11 changes: 11 additions & 0 deletions catalog/app/containers/Auth/SignUp.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import useMutex from 'utils/useMutex'
import validate, * as validators from 'utils/validators'

import * as Layout from './Layout'
import SSOAzure from './SSOAzure'
import SSOGoogle from './SSOGoogle'
import SSOOkta from './SSOOkta'
import SSOOneLogin from './SSOOneLogin'
Expand Down Expand Up @@ -238,6 +239,16 @@ export default ({ location: { search } }) => {
/>
</>
)}
{ssoEnabled('azure') && (
<>
<M.Box mt={2} />
<SSOAzure
mutex={mutex}
next={next}
style={{ justifyContent: 'flex-start' }}
/>
</>
)}
</M.Box>
</M.Box>
)}
Expand Down
18 changes: 18 additions & 0 deletions catalog/app/containers/Auth/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,24 @@ export default defineMessages({
defaultMessage:
'No Quilt user linked to this OneLogin account. Notify your Quilt administrator.',
},
ssoAzureUse: {
id: `${scope}.SSO.Azure.use`,
defaultMessage: 'Sign in with Microsoft',
},
ssoAzureError: {
id: `${scope}.SSO.Azure.error`,
defaultMessage: 'Unable to sign in with Microsoft. {details}',
},
ssoAzureErrorUnexpected: {
id: `${scope}.SSO.Azure.errorUnexpected`,
defaultMessage:
'Unable to sign in with Microsoft. Try again later or contact support.',
},
ssoAzureNotFound: {
id: `${scope}.SSO.Azure.notFound`,
defaultMessage:
'No Quilt user linked to this Microsoft account. Notify your Quilt administrator.',
},
ssoSignUpHeading: {
id: `${scope}.SSO.SignUp.heading`,
defaultMessage: 'Complete sign-up',
Expand Down
1 change: 1 addition & 0 deletions catalog/app/containers/Auth/microsoft-logo.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
82 changes: 82 additions & 0 deletions catalog/app/utils/Azure.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import * as React from 'react'

import { parse } from 'querystring'

import * as NamedRoutes from 'utils/NamedRoutes'
import { BaseError } from 'utils/error'

export class AzureError extends BaseError {
constructor(code, details) {
super('Azure login failure', { code, details })
}
}

export function useAzure({ clientId, baseUrl }) {
return React.useCallback(
() =>
new Promise((resolve, reject) => {
const nonce = Math.random().toString(36).substr(2)
const state = Math.random().toString(36).substr(2)
const query = NamedRoutes.mkSearch({
client_id: clientId,
redirect_uri: `${window.location.origin}/oauth-callback`,
response_mode: 'fragment',
response_type: 'id_token',
scope: 'openid email',
nonce,
state,
})
const url = `${baseUrl}/oauth2/v2.0/authorize${query}`
const popup = window.open(url, 'quilt_azure_popup', 'width=500,height=700')
const timer = setInterval(() => {
if (popup.closed) {
window.removeEventListener('message', handleMessage)
clearInterval(timer)
reject(new AzureError('popup_closed_by_user'))
}
}, 500)
const handleMessage = ({ source, origin, data }) => {
if (source !== popup || origin !== window.location.origin) return
try {
const { type, fragment } = data
if (type !== 'callback') return

const {
id_token: idToken,
error,
error_description: details,
state: respState,
} = parse(fragment.substr(1))
if (respState !== state) {
throw new AzureError(
'state_mismatch',
"Response state doesn't match request state",
)
}
if (error) {
throw new AzureError(error, details)
}
const { nonce: respNonce } = JSON.parse(atob(idToken.split('.')[1]))
if (respNonce !== nonce) {
throw new AzureError(
'nonce_mismatch',
"Response nonce doesn't match request nonce",
)
}
resolve(idToken)
} catch (e) {
reject(e)
} finally {
window.removeEventListener('message', handleMessage)
clearInterval(timer)
popup.close()
}
}
window.addEventListener('message', handleMessage)
popup.focus()
}),
[baseUrl, clientId],
)
}

export { useAzure as use }
2 changes: 2 additions & 0 deletions catalog/config.json.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"oktaBaseUrl": "${OKTA_BASE_URL}",
"oneLoginClientId": "${ONELOGIN_CLIENT_ID}",
"oneLoginBaseUrl": "${ONELOGIN_BASE_URL}",
"azureClientId": "${AZURE_CLIENT_ID}",
"azureBaseUrl": "${AZURE_BASE_URL}",
"sentryDSN": "${SENTRY_DSN}",
"mixpanelToken": "${MIXPANEL_TOKEN}",
"analyticsBucket": "${ANALYTICS_BUCKET}",
Expand Down

0 comments on commit 542514e

Please sign in to comment.