Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: oauth support in APIX when embedded in another app #1198

Merged
merged 18 commits into from
Oct 28, 2022
Merged
2 changes: 1 addition & 1 deletion packages/api-explorer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@
"style-loader": "^1.1.3",
"webpack-bundle-analyzer": "^4.2.0",
"webpack-cli": "^4.6.0",
"webpack-dev-server": "^3.11.2",
"webpack-dev-server": "^4.8.1",
"webpack-merge": "^5.7.3"
},
"dependencies": {
Expand Down
3 changes: 2 additions & 1 deletion packages/api-explorer/src/state/specs/sagas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ function* initSaga(action: PayloadAction<InitSpecsAction>) {
try {
const specs: SpecList = yield* call([adaptor, 'fetchSpecList'])
let currentSpecKey = action.payload.specKey
if (!currentSpecKey) {
if (!currentSpecKey || !specs[currentSpecKey]) {
// if current spec key is invalid or not assigned, default to the first "current" spec
currentSpecKey = Object.values(specs).find(
(spec) => spec.status === 'current'
)!.key
Expand Down
2 changes: 1 addition & 1 deletion packages/api-explorer/src/utils/adaptorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export const fullify = (uri: string, baseUrl: string): string => {
return url.toString()
}
/**
* parse spec url into version and name for api_spec cccall
* parse spec url into version and name for api_spec call
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🙀

* @param spec to parse
*/
const apiSpecBits = (spec: SpecItem): string[] =>
Expand Down
3 changes: 2 additions & 1 deletion packages/api-explorer/src/utils/path.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ export const getSpecKey = (location: HLocation | Location): string | null => {
} else {
match = pathname.match(/\/(?<specKey>\w+\.\w+).*/)
}
return match?.groups?.specKey || null
const result = match?.groups?.specKey || null
return result
}

/**
Expand Down
1 change: 1 addition & 0 deletions packages/extension-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
"@looker/components-test-utils": "^1.5.26",
"@testing-library/react": "^12.1.5",
"@types/react-router": "^5.1.18",
"@types/react-router-dom": "^5.3.3",
"@types/redux": "^3.6.0",
"webpack-bundle-analyzer": "^4.2.0",
"webpack-cli": "^4.6.0",
Expand Down
11 changes: 9 additions & 2 deletions packages/extension-utils/src/OAuthScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
ProgressCircular,
} from '@looker/components'
import { useHistory } from 'react-router'

import { useLocation } from 'react-router-dom'
import type { IEnvironmentAdaptor } from './adaptorUtils'

interface OAuthSceneProps {
Expand All @@ -48,13 +48,20 @@ interface OAuthSceneProps {
*/
export const OAuthScene: FC<OAuthSceneProps> = ({ adaptor }) => {
const history = useHistory()
const location = useLocation()
const reactPath = location.pathname
const fullPath = (window as any).location.pathname
const extraPath = fullPath.substr(0, fullPath.indexOf(reactPath))
const authSession = adaptor.sdk.authSession as BrowserSession
const oldUrl = authSession.returnUrl || `/`
const retPath = authSession.returnUrl ?? '/'
/** If this is a nested React app, remove extraPath to prevent recursive return pathing */
const oldUrl = retPath.replace(extraPath, '')

useEffect(() => {
const maybeLogin = async () => {
const token = await adaptor.login()
if (token) {
console.error({ push: oldUrl, retPath, extraPath, location })
history.push(oldUrl)
}
}
Expand Down
22 changes: 22 additions & 0 deletions packages/extension-utils/src/adaptorUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@

import type { ThemeCustomizations } from '@looker/design-tokens'
import type { IAPIMethods } from '@looker/sdk-rtl'
import type { Location as HLocation } from 'history'
import { BrowserAdaptor } from './browserAdaptor'

export interface IAuthAdaptor {
Expand Down Expand Up @@ -146,3 +147,24 @@ export const registerTestEnvAdaptor = <T extends IEnvironmentAdaptor>(
const mockSdk = {} as unknown as IAPIMethods
registerEnvAdaptor(adaptor || new BrowserAdaptor(mockSdk))
}

/**
* Get new application-level base path for react application
* This function compares the react-based location with the browser window location
* pathname to ensure that the newPath variable is assigned at the root of the
* React app path rather than potentially recursive nesting
*
* @param location which is usually from useLocation()
* @param newPath new path to assign, like `/oauth`
*/
export const appPath = (location: HLocation, newPath: string) => {
const reactPath = location.pathname
const wloc = (window as any).location
const base = wloc.origin
const wpath = wloc.pathname
const result = `${base}${wpath.substring(
0,
wpath.indexOf(reactPath)
)}${newPath}`
return result
}
15 changes: 11 additions & 4 deletions packages/extension-utils/src/authUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,12 @@ export class OAuthConfigProvider extends ApiSettings {

getStoredConfig() {
const storage = this.getStorage(this.configKey)
let config = { base_url: '', looker_url: '' }
let config = {
base_url: '',
looker_url: '',
client_id: '',
redirect_uri: '',
}
if (storage.value) {
config = JSON.parse(storage.value)
}
Expand All @@ -97,19 +102,21 @@ export class OAuthConfigProvider extends ApiSettings {
config = {
base_url: this.base_url,
looker_url: `${authServer}:9999`,
client_id: 'looker.api-explorer',
redirect_uri: `${window.location.origin}/oauth`,
}
}

const { base_url, looker_url } = config
const { base_url, looker_url, client_id, redirect_uri } = config
/* update base_url to the dynamically determined value for standard transport requests */
this.base_url = base_url
return {
...super.readConfig(_section),
...{
base_url,
looker_url,
client_id: 'looker.api-explorer',
redirect_uri: `${window.location.origin}/oauth`,
client_id,
redirect_uri,
},
}
}
Expand Down
19 changes: 11 additions & 8 deletions packages/run-it/src/RunIt.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ import { api, testTextResponse } from './test-data'
import { initRunItSdk } from './utils'
import { RunItProvider } from './RunItProvider'

const mockedConfig = {
base_url: 'https://foo:19999',
looker_url: 'https://foo:9999',
client_id: 'looker.api-explorer',
redirect_uri: 'https://localhost:8080/oauth',
}

describe('RunIt', () => {
const run = 'Run'
const sdk = initRunItSdk()
Expand All @@ -57,10 +64,7 @@ describe('RunIt', () => {
jest.spyOn(sdk.authSession, 'isAuthenticated').mockReturnValue(true)
jest
.spyOn(OAuthConfigProvider.prototype, 'getStoredConfig')
.mockReturnValue({
base_url: 'https://foo:19999',
looker_url: 'https://foo:9999',
})
.mockReturnValue(mockedConfig)
})
afterEach(() => {
jest.clearAllMocks()
Expand Down Expand Up @@ -132,6 +136,8 @@ describe('RunIt', () => {
.mockReturnValue({
base_url: '',
looker_url: '',
client_id: '',
redirect_uri: '',
})
})

Expand All @@ -154,10 +160,7 @@ describe('RunIt', () => {
jest.spyOn(sdk.authSession, 'isAuthenticated').mockReturnValue(false)
jest
.spyOn(OAuthConfigProvider.prototype, 'getStoredConfig')
.mockReturnValue({
base_url: 'https://foo:19999',
looker_url: 'https://foo:9999',
})
.mockReturnValue(mockedConfig)
})

test('it has Login button', () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/run-it/src/components/ConfigForm/ConfigForm.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,17 @@ import { BrowserAdaptor, registerTestEnvAdaptor } from '@looker/extension-utils'
import { initRunItSdk } from '../..'
import { ConfigForm, RunItConfigKey } from '.'

jest.mock('react-router-dom', () => {
const ReactRouterDOM = jest.requireActual('react-router-dom')
return {
...ReactRouterDOM,
useLocation: () => ({
pathname: '/4.0/methods/Dashboard/dashboard',
}),
useHistory: jest.fn().mockReturnValue({ push: jest.fn(), location }),
}
})

describe('ConfigForm', () => {
const adaptor = new BrowserAdaptor(initRunItSdk())
registerTestEnvAdaptor(adaptor)
Expand Down
79 changes: 56 additions & 23 deletions packages/run-it/src/components/ConfigForm/ConfigForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,10 @@ import {
Tooltip,
} from '@looker/components'
import { CodeCopy } from '@looker/code-editor'
import { getEnvAdaptor } from '@looker/extension-utils'
import { appPath, getEnvAdaptor } from '@looker/extension-utils'
import type { ILookerVersions } from '@looker/sdk-codegen'

import { useLocation } from 'react-router-dom'
import type { RunItValues } from '../..'
import { CollapserCard, RunItHeading, DarkSpan, readyToLogin } from '../..'
import {
Expand Down Expand Up @@ -76,7 +77,7 @@ interface ConfigFormProps {
requestContent: RunItValues
/** Title for the config form */
title?: string
/** A set state callback which if present allows for editing, setting or clearing OAuth configuration parameters */
/** A set state callback which allows for editing, setting or clearing OAuth configuration parameters if present */
setHasConfig?: Dispatch<boolean>
}

Expand All @@ -85,14 +86,17 @@ export const ConfigForm: FC<ConfigFormProps> = ({
requestContent,
setHasConfig,
}) => {
const location = useLocation()
const redirect_uri = appPath(location, '/oauth')
const client_id = 'looker.api-explorer' // TODO make this configurable
const BASE_URL = 'baseUrl'
const WEB_URL = 'webUrl'
const FETCH_INTENT = 'fetchIntent'
const FETCH_RESULT = 'fetchResult'
const CRITICAL: MessageBarIntent = 'critical'
const appConfig = `{
"client_guid": "looker.api-explorer",
"redirect_uri": "${(window as any).location.origin}/oauth",
const appConfig = `// client_guid=${client_id}
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like Prism can correctly highlight this comment, so that's a bonus

{
"redirect_uri": "${redirect_uri}",
"display_name": "CORS API Explorer",
"description": "Looker API Explorer using CORS",
"enabled": true
Expand Down Expand Up @@ -163,24 +167,37 @@ export const ConfigForm: FC<ConfigFormProps> = ({
updateMessage(CRITICAL, message)
}

const saveConfig = (baseUrl: string, webUrl: string) => {
const data = {
base_url: baseUrl,
looker_url: webUrl,
client_id,
redirect_uri,
}
updateFields({
[BASE_URL]: baseUrl,
[WEB_URL]: webUrl,
})
// TODO: replace when redux is introduced to run it
localStorage.setItem(RunItConfigKey, JSON.stringify(data))
if (setHasConfig) setHasConfig(true)
setSaved(data)
updateMessage(POSITIVE, `Saved ${webUrl} as OAuth server`)
}

const updateForm = async (_e: BaseSyntheticEvent, save: boolean) => {
updateMessage('inform', '')
const versionsUrl = `${fields.baseUrl}/versions`
try {
const { web_server_url: webUrl, api_server_url: baseUrl } =
(await getVersions(versionsUrl)) as ILookerVersions
updateMessage(POSITIVE, 'Configuration is valid')
updateFields({
[BASE_URL]: baseUrl,
[WEB_URL]: webUrl,
})
updateMessage(POSITIVE, 'Configuration is valid')
if (save) {
const data = { base_url: baseUrl, looker_url: webUrl }
// TODO: replace when redux is introduced to run it
localStorage.setItem(RunItConfigKey, JSON.stringify(data))
if (setHasConfig) setHasConfig(true)
setSaved(data)
updateMessage(POSITIVE, `Saved ${webUrl} as OAuth server`)
saveConfig(baseUrl, webUrl)
}
} catch (e: any) {
fetchError(e.message)
Expand Down Expand Up @@ -299,17 +316,33 @@ export const ConfigForm: FC<ConfigFormProps> = ({
/>
</Fieldset>
</Form>
<Paragraph fontSize="small">
The following configuration can be used to create a{' '}
<Link
href="https://github.com/looker-open-source/sdk-codegen/blob/main/docs/cors.md#reference-implementation"
target="_blank"
>
Looker OAuth client
</Link>
.
</Paragraph>
<CodeCopy key="appConfig" language="json" code={appConfig} />
{!!fields.webUrl && (
<>
<Paragraph fontSize="small">
On {fields.webUrl}, enable API Explorer as a{' '}
<Link
href="https://github.com/looker-open-source/sdk-codegen/blob/main/docs/cors.md#reference-implementation"
target="_blank"
>
Looker OAuth client
</Link>{' '}
by adding "{(window as any).location.origin}" to the{' '}
<Link href={`${fields.webUrl}/admin/embed`} target="_blank">
Embedded Domain Allowlist
</Link>
. If API Explorer is also installed, the configuration below can
be used to{' '}
<Link
href={`${fields.webUrl}/extensions/marketplace_extension_api_explorer::api-explorer/4.0/methods/Auth/register_oauth_client_app`}
target="_blank"
>
register this API Explorer instance
</Link>{' '}
as an OAuth client.
</Paragraph>
<CodeCopy key="appConfig" language="json" code={appConfig} />
</>
)}
<Space>
<Tooltip content="Clear the configuration values">
<ButtonTransparent
Expand Down
Loading