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..a944e85a7e 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/guides/implement-oauth-for-okta/scopes/
+ * 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..ab6c46c9b4
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.test.ts
@@ -0,0 +1,129 @@
+/*
+ * 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);
+
+const PREFIX = 'okta.';
+
+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
+ });
+
+ 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
new file mode 100644
index 0000000000..f10ec4ebce
--- /dev/null
+++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts
@@ -0,0 +1,186 @@
+/*
+ * 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,
+};
+
+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,
+ 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(scopes?: string | string[]): Set {
+ if (!scopes) {
+ return new Set();
+ }
+
+ const scopeList = Array.isArray(scopes)
+ ? scopes
+ : scopes.split(/[\s|,]/).filter(Boolean);
+
+ 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);
+ }
+}
+
+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/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/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..e73843bb0c
--- /dev/null
+++ b/plugins/auth-backend/src/providers/okta/provider.ts
@@ -0,0 +1,213 @@
+/*
+ * 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 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: express.Request, cb: any) {
+ cb(null, null);
+ },
+ verify(_req: express.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 {
+ const providerOptions = {
+ ...options,
+ accessType: 'offline',
+ prompt: 'consent',
+ };
+ return await executeRedirectStrategy(req, this._strategy, providerOptions);
+ }
+
+ 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,
+ }
+ }
},
},
};
diff --git a/yarn.lock b/yarn.lock
index 0dd8044248..f4fa34b993 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -14026,7 +14026,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==
@@ -14037,6 +14046,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"
@@ -14285,6 +14311,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"
@@ -18280,7 +18311,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=