From 515d64e40a657ca0120a7b97a2db010b787dab8f Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Wed, 24 Jun 2020 22:55:14 -0400 Subject: [PATCH 1/6] Add okta integration --- plugins/auth-backend/package.json | 2 + .../auth-backend/src/providers/factories.ts | 2 + .../auth-backend/src/providers/okta/index.ts | 16 ++ .../src/providers/okta/provider.ts | 208 ++++++++++++++++++ .../src/providers/okta/types.d.ts | 22 ++ plugins/auth-backend/src/providers/types.ts | 4 + plugins/auth-backend/src/service/router.ts | 9 + 7 files changed, 263 insertions(+) create mode 100644 plugins/auth-backend/src/providers/okta/index.ts create mode 100644 plugins/auth-backend/src/providers/okta/provider.ts create mode 100644 plugins/auth-backend/src/providers/okta/types.d.ts diff --git a/plugins/auth-backend/package.json b/plugins/auth-backend/package.json index 2de098c7eb..d137357d60 100644 --- a/plugins/auth-backend/package.json +++ b/plugins/auth-backend/package.json @@ -40,6 +40,8 @@ "passport": "^0.4.1", "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", + "passport-oauth2": "^1.5.0", + "passport-okta-oauth": "^0.0.1", "passport-saml": "^1.3.3", "uuid": "^8.0.0", "winston": "^3.2.1", diff --git a/plugins/auth-backend/src/providers/factories.ts b/plugins/auth-backend/src/providers/factories.ts index 5096b0698a..9feefdac9f 100644 --- a/plugins/auth-backend/src/providers/factories.ts +++ b/plugins/auth-backend/src/providers/factories.ts @@ -18,6 +18,7 @@ import Router from 'express-promise-router'; import { createGithubProvider } from './github'; import { createGoogleProvider } from './google'; import { createSamlProvider } from './saml'; +import { createOktaProvider } from './okta'; import { AuthProviderFactory, AuthProviderConfig } from './types'; import { Logger } from 'winston'; import { TokenIssuer } from '../identity'; @@ -26,6 +27,7 @@ const factories: { [providerId: string]: AuthProviderFactory } = { google: createGoogleProvider, github: createGithubProvider, saml: createSamlProvider, + okta: createOktaProvider, }; export const createAuthProviderRouter = ( diff --git a/plugins/auth-backend/src/providers/okta/index.ts b/plugins/auth-backend/src/providers/okta/index.ts new file mode 100644 index 0000000000..bc32601ac2 --- /dev/null +++ b/plugins/auth-backend/src/providers/okta/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ +export { createOktaProvider } from './provider'; diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts new file mode 100644 index 0000000000..7b3d5770d7 --- /dev/null +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -0,0 +1,208 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 express, { Request } from 'express'; +import { OAuthProvider } from '../../lib/OAuthProvider'; +import { Strategy as OktaStrategy } from 'passport-okta-oauth'; +import passport from 'passport'; +import { + executeFrameHandlerStrategy, + executeRedirectStrategy, + executeRefreshTokenStrategy, + makeProfileInfo, + executeFetchUserProfileStrategy, +} from '../../lib/PassportStrategyHelper'; +import { + OAuthProviderHandlers, + RedirectInfo, + AuthProviderConfig, + EnvironmentProviderConfig, + OAuthProviderOptions, + OAuthProviderConfig, + OAuthResponse, + PassportDoneCallback, +} from '../types'; +import { + EnvironmentHandler, + EnvironmentHandlers, +} from '../../lib/EnvironmentHandler'; +import { Logger } from 'winston'; +import { StateStore } from 'passport-oauth2'; +import { TokenIssuer } from '../../identity'; + +type PrivateInfo = { + refreshToken: string; +}; + +export class OktaAuthProvider implements OAuthProviderHandlers { + + private readonly _strategy: any; + + /** + * Due to passport-okta-oauth forcing options.state = true, + * passport-oauth2 requires express-session to be installed + * so that the 'state' parameter of the oauth2 flow can be stored. + * This implementation of StateStore matches the NullStore found within + * passport-oauth2, which is the StateStore implementation used when options.state = false, + * allowing us to avoid using express-session in order to integrate with Okta. + */ + private _store: StateStore = { + store(req: Request, cb: any) { + cb(null, null); + }, + verify(req: Request, state: string, cb: any) { + cb(null, true); + }, + } + + constructor(options: OAuthProviderOptions) { + this._strategy = new OktaStrategy({ + passReqToCallback: false as true, + ...options, + store: this._store, + response_type: 'code', + }, ( + accessToken: any, + refreshToken: any, + params: any, + rawProfile: passport.Profile, + done: PassportDoneCallback, + ) => { + const profile = makeProfileInfo(rawProfile, params.id_token); + + done( + undefined, + { + providerInfo: { + idToken: params.id_token, + accessToken, + scope: params.scope, + expiresInSeconds: params.expires_in, + }, + profile, + }, + { + refreshToken, + }, + ) + }); + } + + async start( + req: express.Request, + options: Record + ): Promise { + return await executeRedirectStrategy(req, this._strategy, options); + } + + async handler( + req: express.Request + ): Promise<{ response: OAuthResponse; refreshToken: string }> { + const { response, privateInfo } = await executeFrameHandlerStrategy< + OAuthResponse, + PrivateInfo + >(req, this._strategy); + + return { + response: await this.populateIdentity(response), + refreshToken: privateInfo.refreshToken, + }; + } + + async refresh(refreshToken: string, scope: string): Promise { + const { accessToken, params } = await executeRefreshTokenStrategy( + this._strategy, + refreshToken, + scope, + ); + + const profile = await executeFetchUserProfileStrategy( + this._strategy, + accessToken, + params.id_token, + ); + + return this.populateIdentity({ + providerInfo: { + accessToken, + idToken: params.id_token, + expiresInSeconds: params.expires_in, + scope: params.scope, + }, + profile, + }); + + } + + private async populateIdentity( + response: OAuthResponse, + ): Promise { + const { profile } = response; + + if (!profile.email) { + throw new Error('Okta profile contained no email'); + } + + // TODO(Rugvip): Hardcoded to the local part of the email for now + const id = profile.email.split('@')[0]; + + return { ...response, backstageIdentity: { id } }; + } +} + +export function createOktaProvider( + { baseUrl }: AuthProviderConfig, + providerConfig: EnvironmentProviderConfig, + logger: Logger, + tokenIssuer: TokenIssuer, +) { + const envProviders: EnvironmentHandlers = {}; + + for (const [env, envConfig] of Object.entries(providerConfig)) { + const config = (envConfig as unknown) as OAuthProviderConfig; + const { secure, appOrigin } = config; + const callbackURLParam = `?env=${env}`; + const opts = { + audience: config.audience, + clientID: config.clientId, + clientSecret: config.clientSecret, + callbackURL: `${baseUrl}/okta/handler/frame${callbackURLParam}`, + }; + + if (!opts.clientID || !opts.clientSecret || !opts.audience) { + if (process.env.NODE_ENV !== 'development') { + throw new Error( + 'Failed to initialize Okta auth provider, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars', + ); + } + + logger.warn( + 'Okta auth provider disabled, set AUTH_OKTA_CLIENT_ID, AUTH_OKTA_CLIENT_SECRET, and AUTH_OKTA_AUDIENCE env vars to enable', + ); + continue; + } + + envProviders[env] = new OAuthProvider(new OktaAuthProvider(opts), { + disableRefresh: false, + providerId: 'okta', + secure, + baseUrl, + appOrigin, + tokenIssuer, + }); + } + + return new EnvironmentHandler(envProviders); +} diff --git a/plugins/auth-backend/src/providers/okta/types.d.ts b/plugins/auth-backend/src/providers/okta/types.d.ts new file mode 100644 index 0000000000..bed6d24043 --- /dev/null +++ b/plugins/auth-backend/src/providers/okta/types.d.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ +declare module 'passport-okta-oauth' { + + export class Strategy { + constructor(options: any, verify: any) + } +} + \ No newline at end of file diff --git a/plugins/auth-backend/src/providers/types.ts b/plugins/auth-backend/src/providers/types.ts index 56d4db79fa..aec4f9dfd0 100644 --- a/plugins/auth-backend/src/providers/types.ts +++ b/plugins/auth-backend/src/providers/types.ts @@ -55,6 +55,10 @@ export type OAuthProviderConfig = { * Client Secret of the auth provider. */ clientSecret: string; + /** + * The location of the OAuth Authorization Server + */ + audience?: string; }; export type EnvironmentProviderConfig = { diff --git a/plugins/auth-backend/src/service/router.ts b/plugins/auth-backend/src/service/router.ts index 28718e85a1..b9c0ff4496 100644 --- a/plugins/auth-backend/src/service/router.ts +++ b/plugins/auth-backend/src/service/router.ts @@ -82,6 +82,15 @@ export async function createRouter( issuer: 'passport-saml', }, }, + okta: { + development: { + appOrigin: 'http://localhost:3000', + secure: false, + clientId: process.env.AUTH_OKTA_CLIENT_ID!, + clientSecret: process.env.AUTH_OKTA_CLIENT_SECRET!, + audience: process.env.AUTH_OKTA_AUDIENCE, + } + } }, }, }; From 907371ffe09e9695397298238ae72f9298106582 Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Wed, 24 Jun 2020 22:56:10 -0400 Subject: [PATCH 2/6] Add ui implementation for okta sso integration --- packages/app/src/App.tsx | 2 +- packages/app/src/apis.ts | 11 ++ .../core-api/src/apis/definitions/auth.ts | 17 ++ .../src/apis/implementations/auth/index.ts | 1 + .../auth/okta/OktaAuth.test.ts | 111 ++++++++++++ .../implementations/auth/okta/OktaAuth.ts | 168 ++++++++++++++++++ .../apis/implementations/auth/okta/index.ts | 18 ++ .../apis/implementations/auth/okta/types.ts | 27 +++ .../core/src/layout/Sidebar/UserSettings.tsx | 6 + .../src/layout/SignInPage/oktaProvider.tsx | 93 ++++++++++ .../core/src/layout/SignInPage/providers.tsx | 4 +- yarn.lock | 35 +++- 12 files changed, 489 insertions(+), 4 deletions(-) create mode 100644 packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts create mode 100644 packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts create mode 100644 packages/core-api/src/apis/implementations/auth/okta/index.ts create mode 100644 packages/core-api/src/apis/implementations/auth/okta/types.ts create mode 100644 packages/core/src/layout/SignInPage/oktaProvider.tsx diff --git a/packages/app/src/App.tsx b/packages/app/src/App.tsx index f4515cbf01..ccaeb2828b 100644 --- a/packages/app/src/App.tsx +++ b/packages/app/src/App.tsx @@ -31,7 +31,7 @@ const app = createApp({ plugins: Object.values(plugins), components: { SignInPage: props => ( - + ), }, }); diff --git a/packages/app/src/apis.ts b/packages/app/src/apis.ts index 5011b16eaf..d2b2706464 100644 --- a/packages/app/src/apis.ts +++ b/packages/app/src/apis.ts @@ -26,10 +26,12 @@ import { FeatureFlags, GoogleAuth, GithubAuth, + OktaAuth, oauthRequestApiRef, OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, + oktaAuthApiRef, storageApiRef, WebStorage, } from '@backstage/core'; @@ -91,6 +93,15 @@ export const apis = (config: ConfigApi) => { }), ); + builder.add( + oktaAuthApiRef, + OktaAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), + ); + builder.add( techRadarApiRef, new TechRadar({ diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index d5c0e9c0a4..f82352cbc8 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -233,3 +233,20 @@ export const githubAuthApiRef = createApiRef< id: 'core.auth.github', description: 'Provides authentication towards Github APIs', }); + +/** + * Provides authentication towards Okta APIs. + * + * See https://developer.okta.com/docs/reference/api/oidc/ + * for a full list of supported scopes. + */ +export const oktaAuthApiRef = createApiRef< + OAuthApi & + OpenIdConnectApi & + ProfileInfoApi & + BackstageIdentityApi & + SessionStateApi +>({ + id: 'core.auth.okta', + description: 'Provides authentication towards Okta APIs', +}); diff --git a/packages/core-api/src/apis/implementations/auth/index.ts b/packages/core-api/src/apis/implementations/auth/index.ts index f13368b5c4..4fa992dfb5 100644 --- a/packages/core-api/src/apis/implementations/auth/index.ts +++ b/packages/core-api/src/apis/implementations/auth/index.ts @@ -16,3 +16,4 @@ export * from './google'; export * from './github'; +export * from './okta'; diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts new file mode 100644 index 0000000000..7f2bd5d6bc --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts @@ -0,0 +1,111 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 OktaAuth from './OktaAuth'; + +const theFuture = new Date(Date.now() + 3600000); +const thePast = new Date(Date.now() - 10); + +describe('OktaAuth', () => { + it('should get refreshed access token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + expect(await oktaAuth.getAccessToken()).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should get refreshed id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + expect(await oktaAuth.getIdToken()).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should get optional id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + expect(await oktaAuth.getIdToken({ optional: true })).toBe('id-token'); + expect(getSession).toBeCalledTimes(1); + }); + + it('should share popup closed errors', async () => { + const error = new Error('NOPE'); + error.name = 'RejectedError'; + const getSession = jest + .fn() + .mockResolvedValueOnce({ + providerInfo: { + accessToken: 'access-token', + expiresAt: theFuture, + scopes: new Set([`not-a-scope`]), + }, + }) + .mockRejectedValue(error); + const oktaAuth = new OktaAuth({ getSession } as any); + + // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check + await expect(oktaAuth.getAccessToken()).resolves.toBe('access-token'); + + const promise1 = oktaAuth.getAccessToken('more'); + const promise2 = oktaAuth.getAccessToken('more'); + await expect(promise1).rejects.toBe(error); + await expect(promise2).rejects.toBe(error); + expect(getSession).toBeCalledTimes(3); + }); + + it('should wait for all session refreshes', async () => { + const initialSession = { + providerInfo: { + idToken: 'token1', + expiresAt: theFuture, + scopes: new Set(), + }, + }; + const getSession = jest + .fn() + .mockResolvedValueOnce(initialSession) + .mockResolvedValue({ + providerInfo: { + idToken: 'token2', + expiresAt: theFuture, + scopes: new Set(), + }, + }); + const oktaAuth = new OktaAuth({ getSession } as any); + + // Grab the expired session first + await expect(oktaAuth.getIdToken()).resolves.toBe('token1'); + expect(getSession).toBeCalledTimes(1); + + initialSession.providerInfo.expiresAt = thePast; + + const promise1 = oktaAuth.getIdToken(); + const promise2 = oktaAuth.getIdToken(); + const promise3 = oktaAuth.getIdToken(); + await expect(promise1).resolves.toBe('token2'); + await expect(promise2).resolves.toBe('token2'); + await expect(promise3).resolves.toBe('token2'); + expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client + }); +}); diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts new file mode 100644 index 0000000000..55820375f6 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -0,0 +1,168 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 OktaIcon from '@material-ui/icons/AcUnit'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; +import { OktaSession } from './types'; +import { + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + ProfileInfo, + SessionStateApi, + SessionState, + BackstageIdentityApi, + AuthRequestOptions, + BackstageIdentity, +} from '../../../definitions/auth'; +import { OAuthRequestApi, AuthProvider } from '../../../definitions'; +import { SessionManager } from '../../../../lib/AuthSessionManager/types'; +import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; +import { Observable } from '../../../../types'; + +type CreateOptions = { + apiOrigin: string; + basePath: string; + + oauthRequestApi: OAuthRequestApi; + + environment?: string; + provider?: AuthProvider & { id: string }; +}; + +export type OktaAuthResponse = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds: number; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; + +const DEFAULT_PROVIDER = { + id: 'okta', + title: 'Okta', + icon: OktaIcon, +}; + +class OktaAuth implements + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + BackstageIdentityApi, + SessionStateApi +{ + static create({ + apiOrigin, + basePath, + environment = 'development', + provider = DEFAULT_PROVIDER, + oauthRequestApi, + }: CreateOptions) { + const connector = new DefaultAuthConnector({ + apiOrigin, + basePath, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform(res: OktaAuthResponse): OktaSession { + return { + ...res, + providerInfo: { + idToken: res.providerInfo.idToken, + accessToken: res.providerInfo.accessToken, + scopes: OktaAuth.normalizeScopes(res.providerInfo.scope), + expiresAt: new Date( + Date.now() + res.providerInfo.expiresInSeconds * 1000, + ), + }, + }; + }, + }); + + const sessionManager = new RefreshingAuthSessionManager({ + connector, + defaultScopes: new Set([ + 'openid', + 'email', + 'profile', + 'offline_access', + ]), + sessionScopes: session => session.scopes, + sessionShouldRefresh: session => { + const expiresInSec = + (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; + return expiresInSec < 60 * 5; + }, + }); + + return new OktaAuth(sessionManager); + } + + sessionState$(): Observable { + return this.sessionManager.sessionState$(); + } + + constructor(private readonly sessionManager: SessionManager) {} + + async getAccessToken( + scope?: string, + options?: AuthRequestOptions + ) { + const session = await this.sessionManager.getSession({ + ...options, + scopes: OktaAuth.normalizeScopes(scope), + }); + return session?.providerInfo.accessToken ?? ''; + } + + async getIdToken(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.providerInfo.idToken ?? ''; + } + + async logout() { + await this.sessionManager.removeSession(); + } + + async getBackstageIdentity( + options: AuthRequestOptions = {}, + ): Promise { + const session = await this.sessionManager.getSession(options); + return session?.backstageIdentity; + } + + async getProfile(options: AuthRequestOptions = {}) { + const session = await this.sessionManager.getSession(options); + return session?.profile; + } + + static normalizeScopes(scope?: string): Set { + if (!scope) { + return new Set(); + } + + const scopeList = Array.isArray(scope) + ? scope + : scope.split(/[\s|,]/).filter(Boolean); + + return new Set(scopeList); + } +} + +export default OktaAuth; \ No newline at end of file diff --git a/packages/core-api/src/apis/implementations/auth/okta/index.ts b/packages/core-api/src/apis/implementations/auth/okta/index.ts new file mode 100644 index 0000000000..2bef0ce0db --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/index.ts @@ -0,0 +1,18 @@ +/* + * Copyright 2020 Spotify AB + * + * 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. + */ + +export * from './types'; +export { default as OktaAuth } from './OktaAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/okta/types.ts b/packages/core-api/src/apis/implementations/auth/okta/types.ts new file mode 100644 index 0000000000..e3392ba1d6 --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/okta/types.ts @@ -0,0 +1,27 @@ +/* + * Copyright 2020 Spotify AB + * + * 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 { ProfileInfo, BackstageIdentity } from '../../../definitions'; + +export type OktaSession = { + providerInfo: { + idToken: string; + accessToken: string; + scopes: Set; + expiresAt: Date; + }; + profile: ProfileInfo; + backstageIdentity: BackstageIdentity; +}; diff --git a/packages/core/src/layout/Sidebar/UserSettings.tsx b/packages/core/src/layout/Sidebar/UserSettings.tsx index 36e8e1088b..668cb1354a 100644 --- a/packages/core/src/layout/Sidebar/UserSettings.tsx +++ b/packages/core/src/layout/Sidebar/UserSettings.tsx @@ -23,6 +23,7 @@ import { googleAuthApiRef, githubAuthApiRef, identityApiRef, + oktaAuthApiRef, useApi, } from '@backstage/core-api'; import { @@ -56,6 +57,11 @@ export function SidebarUserSettings() { apiRef={githubAuthApiRef} icon={Star} /> + { + const oktaAuthApi = useApi(oktaAuthApiRef); + const errorApi = useApi(errorApiRef); + + const handleLogin = async () => { + try { + const identity = await oktaAuthApi.getBackstageIdentity({ + instantPopup: true, + }); + + const profile = await oktaAuthApi.getProfile(); + + onResult({ + userId: identity!.id, + profile: profile!, + getIdToken: () => + oktaAuthApi.getBackstageIdentity().then(i => i!.idToken), + logout: async () => { + await oktaAuthApi.logout(); + }, + }); + } catch (error) { + errorApi.post(error); + } + }; + + return ( + + + Sign In + + } + > + Sign In using Okta + + + ); +}; + +const loader: ProviderLoader = async apis => { + const oktaAuthApi = apis.get(oktaAuthApiRef)!; + + const identity = await oktaAuthApi.getBackstageIdentity({ + optional: true, + }); + + if (!identity) { + return undefined; + } + + const profile = await oktaAuthApi.getProfile(); + + return { + userId: identity.id, + profile: profile!, + getIdToken: () => + oktaAuthApi.getBackstageIdentity().then(i => i!.idToken), + logout: async () => { + await oktaAuthApi.logout(); + }, + }; +}; + +export const oktaProvider: SignInProvider = { Component, loader }; \ No newline at end of file diff --git a/packages/core/src/layout/SignInPage/providers.tsx b/packages/core/src/layout/SignInPage/providers.tsx index 96dbc30a89..a195b2042a 100644 --- a/packages/core/src/layout/SignInPage/providers.tsx +++ b/packages/core/src/layout/SignInPage/providers.tsx @@ -18,6 +18,7 @@ import React, { useLayoutEffect, useState, useMemo, useCallback } from 'react'; import { guestProvider } from './guestProvider'; import { googleProvider } from './googleProvider'; import { customProvider } from './customProvider'; +import { oktaProvider } from './oktaProvider'; import { SignInPageProps, SignInResult, @@ -30,12 +31,13 @@ import { SignInProvider } from './types'; const PROVIDER_STORAGE_KEY = '@backstage/core:SignInPage:provider'; // Separate list here to avoid exporting internal types -export type SignInProviderId = 'guest' | 'google' | 'custom'; +export type SignInProviderId = 'guest' | 'google' | 'custom' | 'okta'; const signInProviders: { [id in SignInProviderId]: SignInProvider } = { guest: guestProvider, google: googleProvider, custom: customProvider, + okta: oktaProvider, }; export const useSignInProviders = ( diff --git a/yarn.lock b/yarn.lock index dc34848cba..873cbb3582 100644 --- a/yarn.lock +++ b/yarn.lock @@ -14239,7 +14239,16 @@ passport-google-oauth20@^2.0.0: dependencies: passport-oauth2 "1.x.x" -passport-oauth2@1.x.x: +passport-oauth1@1.x.x: + version "1.1.0" + resolved "https://registry.npmjs.org/passport-oauth1/-/passport-oauth1-1.1.0.tgz#a7de988a211f9cf4687377130ea74df32730c918" + integrity sha1-p96YiiEfnPRoc3cTDqdN8ycwyRg= + dependencies: + oauth "0.9.x" + passport-strategy "1.x.x" + utils-merge "1.x.x" + +passport-oauth2@1.x.x, passport-oauth2@^1.5.0: version "1.5.0" resolved "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.5.0.tgz#64babbb54ac46a4dcab35e7f266ed5294e3c4108" integrity sha512-kqBt6vR/5VlCK8iCx1/KpY42kQ+NEHZwsSyt4Y6STiNjU+wWICG1i8ucc1FapXDGO15C5O5VZz7+7vRzrDPXXQ== @@ -14250,6 +14259,23 @@ passport-oauth2@1.x.x: uid2 "0.0.x" utils-merge "1.x.x" +passport-oauth@1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/passport-oauth/-/passport-oauth-1.0.0.tgz#90aff63387540f02089af28cdad39ea7f80d77df" + integrity sha1-kK/2M4dUDwIImvKM2tOep/gNd98= + dependencies: + passport-oauth1 "1.x.x" + passport-oauth2 "1.x.x" + +passport-okta-oauth@^0.0.1: + version "0.0.1" + resolved "https://registry.npmjs.org/passport-okta-oauth/-/passport-okta-oauth-0.0.1.tgz#c8bcee02af3d56ca79d3cca776f2df7cf15a5748" + integrity sha1-yLzuAq89Vsp508yndvLffPFaV0g= + dependencies: + passport-oauth "1.0.0" + pkginfo "0.2.x" + uid2 "0.0.3" + passport-saml@^1.3.3: version "1.3.3" resolved "https://registry.npmjs.org/passport-saml/-/passport-saml-1.3.3.tgz#cbea1a2b21ff32b3bc4bfd84dc39c3a370df9935" @@ -14498,6 +14524,11 @@ pkg-up@3.1.0, pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +pkginfo@0.2.x: + version "0.2.3" + resolved "https://registry.npmjs.org/pkginfo/-/pkginfo-0.2.3.tgz#7239c42a5ef6c30b8f328439d9b9ff71042490f8" + integrity sha1-cjnEKl72wwuPMoQ52bn/cQQkkPg= + please-upgrade-node@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/please-upgrade-node/-/please-upgrade-node-3.2.0.tgz#aeddd3f994c933e4ad98b99d9a556efa0e2fe942" @@ -18517,7 +18548,7 @@ uid-number@0.0.6: resolved "https://registry.npmjs.org/uid-number/-/uid-number-0.0.6.tgz#0ea10e8035e8eb5b8e4449f06da1c730663baa81" integrity sha1-DqEOgDXo61uOREnwbaHHMGY7qoE= -uid2@0.0.x: +uid2@0.0.3, uid2@0.0.x: version "0.0.3" resolved "https://registry.npmjs.org/uid2/-/uid2-0.0.3.tgz#483126e11774df2f71b8b639dcd799c376162b82" integrity sha1-SDEm4Rd03y9xuLY53NeZw3YWK4I= From 686018f81161fb016ae89dc4c589c5f73748f546 Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Thu, 25 Jun 2020 08:44:55 -0400 Subject: [PATCH 3/6] Add correct link for okta resouce scopes --- packages/core-api/src/apis/definitions/auth.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core-api/src/apis/definitions/auth.ts b/packages/core-api/src/apis/definitions/auth.ts index f82352cbc8..a944e85a7e 100644 --- a/packages/core-api/src/apis/definitions/auth.ts +++ b/packages/core-api/src/apis/definitions/auth.ts @@ -237,7 +237,7 @@ export const githubAuthApiRef = createApiRef< /** * Provides authentication towards Okta APIs. * - * See https://developer.okta.com/docs/reference/api/oidc/ + * See https://developer.okta.com/docs/guides/implement-oauth-for-okta/scopes/ * for a full list of supported scopes. */ export const oktaAuthApiRef = createApiRef< From 144a3b9294c42c4a601696c280e1aa0461b70bd9 Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Thu, 25 Jun 2020 18:20:13 -0400 Subject: [PATCH 4/6] Fix build and add okta oauth2 scope normalization --- .../auth/okta/OktaAuth.test.ts | 18 +++++++++++++++ .../implementations/auth/okta/OktaAuth.ts | 22 +++++++++++++++++-- packages/storybook/.storybook/apis.js | 11 ++++++++++ .../src/providers/okta/provider.ts | 4 ++-- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts index 7f2bd5d6bc..ab6c46c9b4 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts @@ -18,6 +18,8 @@ import OktaAuth from './OktaAuth'; const theFuture = new Date(Date.now() + 3600000); const thePast = new Date(Date.now() - 10); +const PREFIX = 'okta.'; + describe('OktaAuth', () => { it('should get refreshed access token', async () => { const getSession = jest.fn().mockResolvedValue({ @@ -108,4 +110,20 @@ describe('OktaAuth', () => { await expect(promise3).resolves.toBe('token2'); expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client }); + + it.each([ + ['openid', ['openid']], + ['profile email', ['profile', 'email']], + [`${PREFIX}groups.manage`, [`${PREFIX}groups.manage`]], + ['groups.read', [`${PREFIX}groups.read`]], + [`${PREFIX}groups.manage groups.read, openid`, [`${PREFIX}groups.manage`, `${PREFIX}groups.read`, 'openid']], + [`email\t ${PREFIX}groups.read`, ['email', `${PREFIX}groups.read`]], + + // Some incorrect scopes that we don't try to fix + [`${PREFIX}email`, [`${PREFIX}email`]], + [`${PREFIX}profile`, [`${PREFIX}profile`]], + [`${PREFIX}openid`, [`${PREFIX}openid`]], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + expect(OktaAuth.normalizeScopes(scope)).toEqual(new Set(scopes)); + }); }); diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts index 55820375f6..5631648016 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -60,6 +60,12 @@ const DEFAULT_PROVIDER = { icon: OktaIcon, }; +const OKTA_OIDC_SCOPES: Set = new Set( + ['openid', 'profile', 'email', 'phone', 'address', 'groups', 'offline_access'] +) + +const OKTA_SCOPE_PREFIX: string = 'okta.' + class OktaAuth implements OAuthApi, OpenIdConnectApi, @@ -152,7 +158,7 @@ class OktaAuth implements return session?.profile; } - static normalizeScopes(scope?: string): Set { + static normalizeScopes(scope?: string | string[]): Set { if (!scope) { return new Set(); } @@ -161,7 +167,19 @@ class OktaAuth implements ? scope : scope.split(/[\s|,]/).filter(Boolean); - return new Set(scopeList); + const normalizedScopes = scopeList.map(scope => { + if (OKTA_OIDC_SCOPES.has(scope)) { + return scope; + } + + if (scope.startsWith(OKTA_SCOPE_PREFIX)) { + return scope; + } + + return `${OKTA_SCOPE_PREFIX}${scope}` + }); + + return new Set(normalizedScopes); } } diff --git a/packages/storybook/.storybook/apis.js b/packages/storybook/.storybook/apis.js index 3b61ae7af8..0d0cf74e8f 100644 --- a/packages/storybook/.storybook/apis.js +++ b/packages/storybook/.storybook/apis.js @@ -6,11 +6,13 @@ import { OAuthRequestManager, googleAuthApiRef, githubAuthApiRef, + oktaAuthApiRef, AlertApiForwarder, ErrorApiForwarder, ErrorAlerter, GoogleAuth, GithubAuth, + OktaAuth, identityApiRef, } from '@backstage/core'; @@ -50,4 +52,13 @@ builder.add( }), ); +builder.add( + oktaAuthApiRef, + OktaAuth.create({ + apiOrigin: 'http://localhost:7000', + basePath: '/auth/', + oauthRequestApi, + }), +); + export const apis = builder.build(); diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 7b3d5770d7..788af9c4bd 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -59,10 +59,10 @@ export class OktaAuthProvider implements OAuthProviderHandlers { * allowing us to avoid using express-session in order to integrate with Okta. */ private _store: StateStore = { - store(req: Request, cb: any) { + store({}, cb: any) { cb(null, null); }, - verify(req: Request, state: string, cb: any) { + verify({}, {}, cb: any) { cb(null, true); }, } From 72387eb720ba4c9c94c0f7bdd404774f146b0d3a Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Thu, 25 Jun 2020 18:28:51 -0400 Subject: [PATCH 5/6] Fix linting and tsc issues --- .../src/apis/implementations/auth/okta/OktaAuth.ts | 10 +++++----- plugins/auth-backend/src/providers/okta/provider.ts | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts index 5631648016..f10ec4ebce 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -158,14 +158,14 @@ class OktaAuth implements return session?.profile; } - static normalizeScopes(scope?: string | string[]): Set { - if (!scope) { + static normalizeScopes(scopes?: string | string[]): Set { + if (!scopes) { return new Set(); } - const scopeList = Array.isArray(scope) - ? scope - : scope.split(/[\s|,]/).filter(Boolean); + const scopeList = Array.isArray(scopes) + ? scopes + : scopes.split(/[\s|,]/).filter(Boolean); const normalizedScopes = scopeList.map(scope => { if (OKTA_OIDC_SCOPES.has(scope)) { diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 788af9c4bd..2dd3b57137 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -13,7 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import express, { Request } from 'express'; +import express from 'express'; import { OAuthProvider } from '../../lib/OAuthProvider'; import { Strategy as OktaStrategy } from 'passport-okta-oauth'; import passport from 'passport'; From d3a990ac0492ace10cb7d0f72a4900ee0c7b5d57 Mon Sep 17 00:00:00 2001 From: Nicholas Pirrello Date: Thu, 25 Jun 2020 19:42:32 -0400 Subject: [PATCH 6/6] Add prompt: consent. Adjust unused types for StateStore --- plugins/auth-backend/src/providers/okta/provider.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/plugins/auth-backend/src/providers/okta/provider.ts b/plugins/auth-backend/src/providers/okta/provider.ts index 2dd3b57137..e73843bb0c 100644 --- a/plugins/auth-backend/src/providers/okta/provider.ts +++ b/plugins/auth-backend/src/providers/okta/provider.ts @@ -59,10 +59,10 @@ export class OktaAuthProvider implements OAuthProviderHandlers { * allowing us to avoid using express-session in order to integrate with Okta. */ private _store: StateStore = { - store({}, cb: any) { + store(_req: express.Request, cb: any) { cb(null, null); }, - verify({}, {}, cb: any) { + verify(_req: express.Request, _state: string, cb: any) { cb(null, true); }, } @@ -104,7 +104,12 @@ export class OktaAuthProvider implements OAuthProviderHandlers { req: express.Request, options: Record ): Promise { - return await executeRedirectStrategy(req, this._strategy, options); + const providerOptions = { + ...options, + accessType: 'offline', + prompt: 'consent', + }; + return await executeRedirectStrategy(req, this._strategy, providerOptions); } async handler(