Merge pull request #1455 from nspirrello/add-okta-sso
Add okta sso integration
This commit is contained in:
@@ -31,7 +31,7 @@ const app = createApp({
|
||||
plugins: Object.values(plugins),
|
||||
components: {
|
||||
SignInPage: props => (
|
||||
<SignInPage {...props} providers={['guest', 'google', 'custom']} />
|
||||
<SignInPage {...props} providers={['guest', 'google', 'custom', 'okta']} />
|
||||
),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
@@ -16,3 +16,4 @@
|
||||
|
||||
export * from './google';
|
||||
export * from './github';
|
||||
export * from './okta';
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
});
|
||||
@@ -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<String> = 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<SessionState> {
|
||||
return this.sessionManager.sessionState$();
|
||||
}
|
||||
|
||||
constructor(private readonly sessionManager: SessionManager<OktaSession>) {}
|
||||
|
||||
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<BackstageIdentity | undefined> {
|
||||
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<string> {
|
||||
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;
|
||||
@@ -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';
|
||||
@@ -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<string>;
|
||||
expiresAt: Date;
|
||||
};
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity: BackstageIdentity;
|
||||
};
|
||||
@@ -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}
|
||||
/>
|
||||
<OIDCProviderSettings
|
||||
title="Okta"
|
||||
apiRef={oktaAuthApiRef}
|
||||
icon={Star}
|
||||
/>
|
||||
<SidebarItem
|
||||
icon={SignOutIcon}
|
||||
text="Sign Out"
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* 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 React from 'react';
|
||||
import { Grid, Typography, Button } from '@material-ui/core';
|
||||
import { InfoCard } from '../InfoCard/InfoCard';
|
||||
import { ProviderComponent, ProviderLoader, SignInProvider } from './types';
|
||||
import {
|
||||
useApi,
|
||||
oktaAuthApiRef,
|
||||
errorApiRef,
|
||||
} from '@backstage/core-api';
|
||||
|
||||
const Component: ProviderComponent = ({ onResult }) => {
|
||||
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 (
|
||||
<Grid item>
|
||||
<InfoCard
|
||||
title="Okta"
|
||||
actions={
|
||||
<Button color="primary" variant="outlined" onClick={handleLogin}>
|
||||
Sign In
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Typography variant="body1">Sign In using Okta</Typography>
|
||||
</InfoCard>
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
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 };
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 = (
|
||||
|
||||
@@ -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';
|
||||
@@ -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<OAuthResponse, PrivateInfo>,
|
||||
) => {
|
||||
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<string, string>
|
||||
): Promise<RedirectInfo> {
|
||||
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<OAuthResponse> {
|
||||
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<OAuthResponse> {
|
||||
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);
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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=
|
||||
|
||||
Reference in New Issue
Block a user