Add ui implementation for okta sso integration

This commit is contained in:
Nicholas Pirrello
2020-06-24 22:56:10 -04:00
parent 515d64e40a
commit 907371ffe0
12 changed files with 489 additions and 4 deletions
+1 -1
View File
@@ -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']} />
),
},
});
+11
View File
@@ -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/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',
});
@@ -16,3 +16,4 @@
export * from './google';
export * from './github';
export * from './okta';
@@ -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
});
});
@@ -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<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(scope?: string): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope)
? scope
: scope.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
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 = (