From e3ab3814a30a6e797499eca946f518dcdee04a42 Mon Sep 17 00:00:00 2001 From: Patrik Oldsberg Date: Thu, 10 Sep 2020 10:38:12 +0200 Subject: [PATCH] core-api: refactor most auth implementations to use the OAuth2 one --- .../auth/auth0/Auth0Auth.test.ts | 36 ---- .../implementations/auth/auth0/Auth0Auth.ts | 110 +----------- .../apis/implementations/auth/auth0/index.ts | 1 - .../apis/implementations/auth/auth0/types.ts | 28 --- .../auth/gitlab/GitlabAuth.test.ts | 48 +++--- .../implementations/auth/gitlab/GitlabAuth.ts | 95 +--------- .../apis/implementations/auth/gitlab/index.ts | 1 - .../apis/implementations/auth/gitlab/types.ts | 27 --- .../auth/google/GoogleAuth.test.ts | 112 +++--------- .../implementations/auth/google/GoogleAuth.ts | 163 ++++-------------- .../apis/implementations/auth/google/index.ts | 1 - .../apis/implementations/auth/google/types.ts | 28 --- .../auth/microsoft/MicrosoftAuth.ts | 128 ++------------ .../implementations/auth/microsoft/index.ts | 1 - .../implementations/auth/microsoft/types.ts | 28 --- .../auth/oauth2/OAuth2.test.ts | 152 ++++++++++++++++ .../implementations/auth/oauth2/OAuth2.ts | 43 ++++- .../auth/okta/OktaAuth.test.ts | 113 +++--------- .../implementations/auth/okta/OktaAuth.ts | 142 +++------------ .../apis/implementations/auth/okta/index.ts | 1 - .../apis/implementations/auth/okta/types.ts | 27 --- 21 files changed, 329 insertions(+), 956 deletions(-) delete mode 100644 packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.test.ts delete mode 100644 packages/core-api/src/apis/implementations/auth/auth0/types.ts delete mode 100644 packages/core-api/src/apis/implementations/auth/gitlab/types.ts delete mode 100644 packages/core-api/src/apis/implementations/auth/google/types.ts delete mode 100644 packages/core-api/src/apis/implementations/auth/microsoft/types.ts create mode 100644 packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts delete mode 100644 packages/core-api/src/apis/implementations/auth/okta/types.ts diff --git a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.test.ts b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.test.ts deleted file mode 100644 index 5912c2a364..0000000000 --- a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.test.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * 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 Auth0Auth from './Auth0Auth'; - -describe('Auth0Auth', () => { - it('should normalize scope', () => { - const tests = [ - { - arguments: ['read_user api write_repository'], - expect: new Set(['read_user', 'api', 'write_repository']), - }, - { - arguments: ['read_repository sudo'], - expect: new Set(['read_repository', 'sudo']), - }, - ]; - - for (const test of tests) { - expect(Auth0Auth.normalizeScopes(...test.arguments)).toEqual(test.expect); - } - }); -}); diff --git a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts index 505c283b71..40e537169c 100644 --- a/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts +++ b/packages/core-api/src/apis/implementations/auth/auth0/Auth0Auth.ts @@ -15,26 +15,13 @@ */ import Auth0Icon from '@material-ui/icons/AcUnit'; -import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; -import { Auth0Session } from './types'; -import { - OpenIdConnectApi, - ProfileInfoApi, - ProfileInfo, - SessionStateApi, - SessionState, - BackstageIdentityApi, - AuthRequestOptions, - BackstageIdentity, -} from '../../../definitions/auth'; +import { auth0AuthApiRef } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider, DiscoveryApi, } from '../../../definitions'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; -import { Observable } from '../../../../types'; +import { OAuth2 } from '../oauth2'; type CreateOptions = { discoveryApi: DiscoveryApi; @@ -44,106 +31,27 @@ type CreateOptions = { provider?: AuthProvider & { id: string }; }; -export type Auth0AuthResponse = { - providerInfo: { - accessToken: string; - idToken: string; - scope: string; - expiresInSeconds: number; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; - const DEFAULT_PROVIDER = { id: 'auth0', title: 'Auth0', icon: Auth0Icon, }; -class Auth0Auth - implements - OpenIdConnectApi, - ProfileInfoApi, - BackstageIdentityApi, - SessionStateApi { +class Auth0Auth { static create({ discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions) { - const connector = new DefaultAuthConnector({ + }: CreateOptions): typeof auth0AuthApiRef.T { + return OAuth2.create({ discoveryApi, - environment, + oauthRequestApi, provider, - oauthRequestApi: oauthRequestApi, - sessionTransform(res: Auth0AuthResponse): Auth0Session { - return { - ...res, - providerInfo: { - idToken: res.providerInfo.idToken, - accessToken: res.providerInfo.accessToken, - scopes: Auth0Auth.normalizeScopes(res.providerInfo.scope), - expiresAt: new Date( - Date.now() + res.providerInfo.expiresInSeconds * 1000, - ), - }, - }; - }, + environment, + defaultScopes: ['openid', `email`, `profile`], }); - - const sessionManager = new RefreshingAuthSessionManager({ - connector, - defaultScopes: new Set(['openid', `email`, `profile`]), - sessionScopes: (session: Auth0Session) => session.providerInfo.scopes, - sessionShouldRefresh: (session: Auth0Session) => { - const expiresInSec = - (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; - return expiresInSec < 60 * 5; - }, - }); - - return new Auth0Auth(sessionManager); - } - - sessionState$(): Observable { - return this.sessionManager.sessionState$(); - } - - constructor(private readonly sessionManager: SessionManager) {} - - 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 | string[]): Set { - if (!scope) { - return new Set(); - } - - const scopeList = Array.isArray(scope) - ? scope - : scope.split(/[\s|,]/).filter(Boolean); - - return new Set(scopeList); } } + export default Auth0Auth; diff --git a/packages/core-api/src/apis/implementations/auth/auth0/index.ts b/packages/core-api/src/apis/implementations/auth/auth0/index.ts index fb5d432642..dda27d0fa3 100644 --- a/packages/core-api/src/apis/implementations/auth/auth0/index.ts +++ b/packages/core-api/src/apis/implementations/auth/auth0/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export * from './types'; export { default as Auth0Auth } from './Auth0Auth'; diff --git a/packages/core-api/src/apis/implementations/auth/auth0/types.ts b/packages/core-api/src/apis/implementations/auth/auth0/types.ts deleted file mode 100644 index 203ccd9fd9..0000000000 --- a/packages/core-api/src/apis/implementations/auth/auth0/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 Auth0Session = { - providerInfo: { - idToken: string; - accessToken: string; - scopes: Set; - expiresAt: Date; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts index 18346b3208..44b9aeb14e 100644 --- a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.test.ts @@ -14,33 +14,37 @@ * limitations under the License. */ +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; import GitlabAuth from './GitlabAuth'; -describe('GitlabAuth', () => { - it('should get access token', async () => { - const getSession = jest - .fn() - .mockResolvedValue({ providerInfo: { accessToken: 'access-token' } }); - const gitlabAuth = new GitlabAuth({ getSession } as any); +const getSession = jest.fn(); - expect(await gitlabAuth.getAccessToken()).toBe('access-token'); - expect(getSession).toBeCalledTimes(1); +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + +describe('GitlabAuth', () => { + afterEach(() => { + jest.resetAllMocks(); }); - it('should normalize scope', () => { - const tests = [ - { - arguments: ['read_user api write_repository'], - expect: new Set(['read_user', 'api', 'write_repository']), - }, - { - arguments: ['read_repository sudo'], - expect: new Set(['read_repository', 'sudo']), - }, - ]; + it.each([ + [ + 'read_user api write_repository', + ['read_user', 'api', 'write_repository'], + ], + ['read_repository sudo', ['read_repository', 'sudo']], + ])(`should normalize scopes correctly - %p`, (scope, scopes) => { + const googleAuth = GitlabAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); - for (const test of tests) { - expect(GitlabAuth.normalizeScope(...test.arguments)).toEqual(test.expect); - } + googleAuth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); }); }); diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts index 1734e930fa..9e2acd4537 100644 --- a/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/gitlab/GitlabAuth.ts @@ -15,24 +15,13 @@ */ import GitlabIcon from '@material-ui/icons/AcUnit'; -import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; -import { GitlabSession } from './types'; -import { - OAuthApi, - SessionStateApi, - SessionState, - ProfileInfo, - BackstageIdentity, - AuthRequestOptions, -} from '../../../definitions/auth'; +import { gitlabAuthApiRef } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider, DiscoveryApi, } from '../../../definitions'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager'; -import { Observable } from '../../../../types'; +import { OAuth2 } from '../oauth2'; type CreateOptions = { discoveryApi: DiscoveryApi; @@ -42,94 +31,26 @@ type CreateOptions = { provider?: AuthProvider & { id: string }; }; -export type GitlabAuthResponse = { - providerInfo: { - accessToken: string; - scope: string; - expiresInSeconds: number; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; - const DEFAULT_PROVIDER = { id: 'gitlab', title: 'Gitlab', icon: GitlabIcon, }; -class GitlabAuth implements OAuthApi, SessionStateApi { +class GitlabAuth { static create({ discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions) { - const connector = new DefaultAuthConnector({ + }: CreateOptions): typeof gitlabAuthApiRef.T { + return OAuth2.create({ discoveryApi, - environment, - provider, oauthRequestApi, - sessionTransform(res: GitlabAuthResponse): GitlabSession { - return { - ...res, - providerInfo: { - accessToken: res.providerInfo.accessToken, - scopes: GitlabAuth.normalizeScope(res.providerInfo.scope), - expiresAt: new Date( - Date.now() + res.providerInfo.expiresInSeconds * 1000, - ), - }, - }; - }, + provider, + environment, + defaultScopes: ['read_user'], }); - - const sessionManager = new StaticAuthSessionManager({ - connector, - defaultScopes: new Set(['read_user']), - sessionScopes: (session: GitlabSession) => session.providerInfo.scopes, - }); - - return new GitlabAuth(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: GitlabAuth.normalizeScope(scope), - }); - return session?.providerInfo.accessToken ?? ''; - } - - 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; - } - - async logout() { - await this.sessionManager.removeSession(); - } - - static normalizeScope(scope?: string): Set { - if (!scope) { - return new Set(); - } - - const scopeList = Array.isArray(scope) ? scope : scope.split(' '); - return new Set(scopeList); } } export default GitlabAuth; diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/index.ts b/packages/core-api/src/apis/implementations/auth/gitlab/index.ts index ee17b7de06..42d7210551 100644 --- a/packages/core-api/src/apis/implementations/auth/gitlab/index.ts +++ b/packages/core-api/src/apis/implementations/auth/gitlab/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export * from './types'; export { default as GitlabAuth } from './GitlabAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/gitlab/types.ts b/packages/core-api/src/apis/implementations/auth/gitlab/types.ts deleted file mode 100644 index b03dfec084..0000000000 --- a/packages/core-api/src/apis/implementations/auth/gitlab/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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 GitlabSession = { - providerInfo: { - accessToken: string; - scopes: Set; - expiresAt: Date; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts index 5290ee798b..9e8569c5cf 100644 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.test.ts @@ -15,101 +15,23 @@ */ import GoogleAuth from './GoogleAuth'; - -const theFuture = new Date(Date.now() + 3600000); -const thePast = new Date(Date.now() - 10); +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; const PREFIX = 'https://www.googleapis.com/auth/'; +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + describe('GoogleAuth', () => { - it('should get refreshed access token', async () => { - const getSession = jest.fn().mockResolvedValue({ - providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, - }); - const googleAuth = new GoogleAuth({ getSession } as any); - - expect(await googleAuth.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 googleAuth = new GoogleAuth({ getSession } as any); - - expect(await googleAuth.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 googleAuth = new GoogleAuth({ getSession } as any); - - expect(await googleAuth.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([`${PREFIX}not-enough`]), - }, - }) - .mockRejectedValue(error); - const googleAuth = new GoogleAuth({ 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(googleAuth.getAccessToken()).resolves.toBe('access-token'); - - const promise1 = googleAuth.getAccessToken('more'); - const promise2 = googleAuth.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 googleAuth = new GoogleAuth({ getSession } as any); - - // Grab the expired session first - await expect(googleAuth.getIdToken()).resolves.toBe('token1'); - expect(getSession).toBeCalledTimes(1); - - initialSession.providerInfo.expiresAt = thePast; - - const promise1 = googleAuth.getIdToken(); - const promise2 = googleAuth.getIdToken(); - const promise3 = googleAuth.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 + afterEach(() => { + jest.resetAllMocks(); }); it.each([ @@ -136,6 +58,12 @@ describe('GoogleAuth', () => { [`${PREFIX}profile`, [`${PREFIX}profile`]], [`${PREFIX}openid`, [`${PREFIX}openid`]], ])(`should normalize scopes correctly - %p`, (scope, scopes) => { - expect(GoogleAuth.normalizeScopes(scope)).toEqual(new Set(scopes)); + const googleAuth = GoogleAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + googleAuth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) }); }); }); diff --git a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts index fdf9d46ba8..7e84226508 100644 --- a/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/google/GoogleAuth.ts @@ -15,27 +15,13 @@ */ import GoogleIcon from '@material-ui/icons/AcUnit'; -import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; -import { GoogleSession } from './types'; -import { - OAuthApi, - OpenIdConnectApi, - ProfileInfoApi, - ProfileInfo, - SessionStateApi, - SessionState, - BackstageIdentityApi, - AuthRequestOptions, - BackstageIdentity, -} from '../../../definitions/auth'; +import { googleAuthApiRef } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider, DiscoveryApi, } from '../../../definitions'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; -import { Observable } from '../../../../types'; +import { OAuth2 } from '../oauth2'; type CreateOptions = { discoveryApi: DiscoveryApi; @@ -45,140 +31,49 @@ type CreateOptions = { provider?: AuthProvider & { id: string }; }; -export type GoogleAuthResponse = { - providerInfo: { - accessToken: string; - idToken: string; - scope: string; - expiresInSeconds: number; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; - const DEFAULT_PROVIDER = { id: 'google', title: 'Google', icon: GoogleIcon, }; -const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; - -class GoogleAuth - implements - OAuthApi, - OpenIdConnectApi, - ProfileInfoApi, - BackstageIdentityApi, - SessionStateApi { +class GoogleAuth { static create({ discoveryApi, + oauthRequestApi, environment = 'development', provider = DEFAULT_PROVIDER, - oauthRequestApi, - }: CreateOptions) { - const connector = new DefaultAuthConnector({ - discoveryApi, - environment, - provider, - oauthRequestApi: oauthRequestApi, - sessionTransform(res: GoogleAuthResponse): GoogleSession { - return { - ...res, - providerInfo: { - idToken: res.providerInfo.idToken, - accessToken: res.providerInfo.accessToken, - scopes: GoogleAuth.normalizeScopes(res.providerInfo.scope), - expiresAt: new Date( - Date.now() + res.providerInfo.expiresInSeconds * 1000, - ), - }, - }; - }, - }); + }: CreateOptions): typeof googleAuthApiRef.T { + const SCOPE_PREFIX = 'https://www.googleapis.com/auth/'; - const sessionManager = new RefreshingAuthSessionManager({ - connector, - defaultScopes: new Set([ + return OAuth2.create({ + discoveryApi, + oauthRequestApi, + provider, + environment, + defaultScopes: [ 'openid', `${SCOPE_PREFIX}userinfo.email`, `${SCOPE_PREFIX}userinfo.profile`, - ]), - sessionScopes: (session: GoogleSession) => session.providerInfo.scopes, - sessionShouldRefresh: (session: GoogleSession) => { - const expiresInSec = - (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; - return expiresInSec < 60 * 5; + ], + scopeTransform(scopes: string[]) { + return scopes.map(scope => { + if (scope === 'openid') { + return scope; + } + + if (scope === 'profile' || scope === 'email') { + return `${SCOPE_PREFIX}userinfo.${scope}`; + } + + if (scope.startsWith(SCOPE_PREFIX)) { + return scope; + } + + return `${SCOPE_PREFIX}${scope}`; + }); }, }); - - return new GoogleAuth(sessionManager); - } - - sessionState$(): Observable { - return this.sessionManager.sessionState$(); - } - - constructor(private readonly sessionManager: SessionManager) {} - - async getAccessToken( - scope?: string | string[], - options?: AuthRequestOptions, - ) { - const session = await this.sessionManager.getSession({ - ...options, - scopes: GoogleAuth.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 (scope === 'openid') { - return scope; - } - - if (scope === 'profile' || scope === 'email') { - return `${SCOPE_PREFIX}userinfo.${scope}`; - } - - if (scope.startsWith(SCOPE_PREFIX)) { - return scope; - } - - return `${SCOPE_PREFIX}${scope}`; - }); - - return new Set(normalizedScopes); } } export default GoogleAuth; diff --git a/packages/core-api/src/apis/implementations/auth/google/index.ts b/packages/core-api/src/apis/implementations/auth/google/index.ts index 78e8a97c31..2521d46046 100644 --- a/packages/core-api/src/apis/implementations/auth/google/index.ts +++ b/packages/core-api/src/apis/implementations/auth/google/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export * from './types'; export { default as GoogleAuth } from './GoogleAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/google/types.ts b/packages/core-api/src/apis/implementations/auth/google/types.ts deleted file mode 100644 index 3b40932980..0000000000 --- a/packages/core-api/src/apis/implementations/auth/google/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 GoogleSession = { - providerInfo: { - idToken: string; - accessToken: string; - scopes: Set; - expiresAt: Date; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts b/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts index 5800308c26..241ac7b802 100644 --- a/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/microsoft/MicrosoftAuth.ts @@ -15,29 +15,14 @@ */ import MicrosoftIcon from '@material-ui/icons/AcUnit'; -import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; -import { MicrosoftSession } from './types'; - -import { - OAuthApi, - OpenIdConnectApi, - ProfileInfoApi, - ProfileInfo, - SessionStateApi, - SessionState, - BackstageIdentityApi, - AuthRequestOptions, - BackstageIdentity, -} from '../../../definitions/auth'; +import { microsoftAuthApiRef } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider, DiscoveryApi, } from '../../../definitions'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; -import { Observable } from '../../../../types'; +import { OAuth2 } from '../oauth2'; type CreateOptions = { discoveryApi: DiscoveryApi; @@ -47,126 +32,33 @@ type CreateOptions = { provider?: AuthProvider & { id: string }; }; -export type MicrosoftAuthResponse = { - providerInfo: { - accessToken: string; - idToken: string; - scope: string; - expiresInSeconds: number; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; - const DEFAULT_PROVIDER = { id: 'microsoft', title: 'Microsoft', icon: MicrosoftIcon, }; -class MicrosoftAuth - implements - OAuthApi, - OpenIdConnectApi, - ProfileInfoApi, - BackstageIdentityApi, - SessionStateApi { +class MicrosoftAuth { static create({ environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, discoveryApi, - }: CreateOptions) { - const connector = new DefaultAuthConnector({ + }: CreateOptions): typeof microsoftAuthApiRef.T { + return OAuth2.create({ discoveryApi, - environment, + oauthRequestApi, provider, - oauthRequestApi: oauthRequestApi, - sessionTransform(res: MicrosoftAuthResponse): MicrosoftSession { - return { - ...res, - providerInfo: { - idToken: res.providerInfo.idToken, - accessToken: res.providerInfo.accessToken, - scopes: MicrosoftAuth.normalizeScopes(res.providerInfo.scope), - expiresAt: new Date( - Date.now() + res.providerInfo.expiresInSeconds * 1000, - ), - }, - }; - }, - }); - - const sessionManager = new RefreshingAuthSessionManager({ - connector, - defaultScopes: new Set([ + environment, + defaultScopes: [ 'openid', 'offline_access', 'profile', 'email', 'User.Read', - ]), - sessionScopes: (session: MicrosoftSession) => session.providerInfo.scopes, - sessionShouldRefresh: (session: MicrosoftSession) => { - const expiresInSec = - (session.providerInfo.expiresAt.getTime() - Date.now()) / 1000; - return expiresInSec < 60 * 5; - }, + ], }); - - return new MicrosoftAuth(sessionManager); - } - - sessionState$(): Observable { - return this.sessionManager.sessionState$(); - } - - constructor( - private readonly sessionManager: SessionManager, - ) {} - - async getAccessToken( - scope?: string | string[], - options?: AuthRequestOptions, - ) { - const session = await this.sessionManager.getSession({ - ...options, - scopes: MicrosoftAuth.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); - - return new Set(scopeList); } } + export default MicrosoftAuth; diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/index.ts b/packages/core-api/src/apis/implementations/auth/microsoft/index.ts index e3ae4ee4f1..77328d8557 100644 --- a/packages/core-api/src/apis/implementations/auth/microsoft/index.ts +++ b/packages/core-api/src/apis/implementations/auth/microsoft/index.ts @@ -14,5 +14,4 @@ * limitations under the License. */ -export * from './types'; export { default as MicrosoftAuth } from './MicrosoftAuth'; diff --git a/packages/core-api/src/apis/implementations/auth/microsoft/types.ts b/packages/core-api/src/apis/implementations/auth/microsoft/types.ts deleted file mode 100644 index 6eaf92808a..0000000000 --- a/packages/core-api/src/apis/implementations/auth/microsoft/types.ts +++ /dev/null @@ -1,28 +0,0 @@ -/* - * 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 MicrosoftSession = { - providerInfo: { - idToken: string; - accessToken: string; - scopes: Set; - expiresAt: Date; - }; - profile: ProfileInfo; - backstageIdentity: BackstageIdentity; -}; diff --git a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts new file mode 100644 index 0000000000..93db2c732d --- /dev/null +++ b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts @@ -0,0 +1,152 @@ +/* + * 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 OAuth2 from './OAuth2'; + +const theFuture = new Date(Date.now() + 3600000); +const thePast = new Date(Date.now() - 10); + +const PREFIX = 'https://www.googleapis.com/auth/'; + +const scopeTransform = (x: string[]) => x; + +describe('OAuth2', () => { + it('should get refreshed access token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe( + 'access-token', + ); + expect(getSession).toBeCalledTimes(1); + expect(getSession.mock.calls[0][0].scopes).toEqual( + new Set(['my-scope', 'my-scope2']), + ); + }); + + it('should transform scopes', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`), + }); + + expect(await oauth2.getAccessToken('my-scope')).toBe('access-token'); + expect(getSession).toBeCalledTimes(1); + expect(getSession.mock.calls[0][0].scopes).toEqual( + new Set(['my-prefix/my-scope']), + ); + }); + + it('should get refreshed id token', async () => { + const getSession = jest.fn().mockResolvedValue({ + providerInfo: { idToken: 'id-token', expiresAt: theFuture }, + }); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.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 oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + expect(await oauth2.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([`${PREFIX}not-enough`]), + }, + }) + .mockRejectedValue(error); + const oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + // Make sure we have a session before we do the double request, so that we get past the !this.currentSession check + await expect(oauth2.getAccessToken()).resolves.toBe('access-token'); + + const promise1 = oauth2.getAccessToken('more'); + const promise2 = oauth2.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 oauth2 = new OAuth2({ + sessionManager: { getSession } as any, + scopeTransform, + }); + + // Grab the expired session first + await expect(oauth2.getIdToken()).resolves.toBe('token1'); + expect(getSession).toBeCalledTimes(1); + + initialSession.providerInfo.expiresAt = thePast; + + const promise1 = oauth2.getIdToken(); + const promise2 = oauth2.getIdToken(); + const promise3 = oauth2.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/oauth2/OAuth2.ts b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts index 3f8ebce5ba..27626aac5a 100644 --- a/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -33,9 +33,15 @@ import { ProfileInfoApi, SessionState, SessionStateApi, + BackstageIdentityApi, } from '../../../definitions/auth'; import { OAuth2Session } from './types'; +type Options = { + sessionManager: SessionManager; + scopeTransform: (scopes: string[]) => string[]; +}; + type CreateOptions = { discoveryApi: DiscoveryApi; oauthRequestApi: OAuthRequestApi; @@ -43,6 +49,7 @@ type CreateOptions = { environment?: string; provider?: AuthProvider & { id: string }; defaultScopes?: string[]; + scopeTransform?: (scopes: string[]) => string[]; }; export type OAuth2Response = { @@ -63,13 +70,19 @@ const DEFAULT_PROVIDER = { }; class OAuth2 - implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi { + implements + OAuthApi, + OpenIdConnectApi, + ProfileInfoApi, + BackstageIdentityApi, + SessionStateApi { static create({ discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, defaultScopes = [], + scopeTransform = x => x, }: CreateOptions) { const connector = new DefaultAuthConnector({ discoveryApi, @@ -82,7 +95,10 @@ class OAuth2 providerInfo: { idToken: res.providerInfo.idToken, accessToken: res.providerInfo.accessToken, - scopes: OAuth2.normalizeScopes(res.providerInfo.scope), + scopes: OAuth2.normalizeScopes( + scopeTransform, + res.providerInfo.scope, + ), expiresAt: new Date( Date.now() + res.providerInfo.expiresInSeconds * 1000, ), @@ -102,20 +118,26 @@ class OAuth2 }, }); - return new OAuth2(sessionManager); + return new OAuth2({ sessionManager, scopeTransform }); + } + + private readonly sessionManager: SessionManager; + private readonly scopeTransform: (scopes: string[]) => string[]; + + constructor(options: Options) { + this.sessionManager = options.sessionManager; + this.scopeTransform = options.scopeTransform; } sessionState$(): Observable { return this.sessionManager.sessionState$(); } - constructor(private readonly sessionManager: SessionManager) {} - async getAccessToken( scope?: string | string[], options?: AuthRequestOptions, ) { - const normalizedScopes = OAuth2.normalizeScopes(scope); + const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope); const session = await this.sessionManager.getSession({ ...options, scopes: normalizedScopes, @@ -144,16 +166,19 @@ class OAuth2 return session?.profile; } - static normalizeScopes(scopes?: string | string[]): Set { + private static normalizeScopes( + scopeTransform: (scopes: string[]) => string[], + scopes?: string | string[], + ): Set { if (!scopes) { return new Set(); } const scopeList = Array.isArray(scopes) ? scopes - : scopes.split(/[\s]/).filter(Boolean); + : scopes.split(/[\s|,]/).filter(Boolean); - return new Set(scopeList); + return new Set(scopeTransform(scopeList)); } } 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 40e6f78909..d6b1a07d9d 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 @@ -13,102 +13,25 @@ * 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); +import OktaAuth from './OktaAuth'; +import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; +import { UrlPatternDiscovery } from '../../DiscoveryApi'; const PREFIX = 'okta.'; +const getSession = jest.fn(); + +jest.mock('../../../../lib/AuthSessionManager', () => ({ + ...(jest.requireActual('../../../../lib/AuthSessionManager') as any), + RefreshingAuthSessionManager: class { + getSession = getSession; + }, +})); + 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 + afterEach(() => { + jest.resetAllMocks(); }); it.each([ @@ -127,6 +50,12 @@ describe('OktaAuth', () => { [`${PREFIX}profile`, [`${PREFIX}profile`]], [`${PREFIX}openid`, [`${PREFIX}openid`]], ])(`should normalize scopes correctly - %p`, (scope, scopes) => { - expect(OktaAuth.normalizeScopes(scope)).toEqual(new Set(scopes)); + const auth = OktaAuth.create({ + oauthRequestApi: new MockOAuthApi(), + discoveryApi: UrlPatternDiscovery.compile('http://example.com'), + }); + + auth.getAccessToken(scope); + expect(getSession).toHaveBeenCalledWith({ scopes: 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 1f804748f0..7e9ff77678 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/OktaAuth.ts @@ -15,27 +15,13 @@ */ 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 { oktaAuthApiRef } from '../../../definitions/auth'; import { OAuthRequestApi, AuthProvider, DiscoveryApi, } from '../../../definitions'; -import { SessionManager } from '../../../../lib/AuthSessionManager/types'; -import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; -import { Observable } from '../../../../types'; +import { OAuth2 } from '../oauth2'; type CreateOptions = { discoveryApi: DiscoveryApi; @@ -45,17 +31,6 @@ type CreateOptions = { 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', @@ -74,110 +49,33 @@ const OKTA_OIDC_SCOPES: Set = new Set([ const OKTA_SCOPE_PREFIX: string = 'okta.'; -class OktaAuth - implements - OAuthApi, - OpenIdConnectApi, - ProfileInfoApi, - BackstageIdentityApi, - SessionStateApi { +class OktaAuth { static create({ discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - }: CreateOptions) { - const connector = new DefaultAuthConnector({ + }: CreateOptions): typeof oktaAuthApiRef.T { + return OAuth2.create({ discoveryApi, - environment, + oauthRequestApi, 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, - ), - }, - }; + environment, + defaultScopes: ['openid', 'email', 'profile', 'offline_access'], + scopeTransform(scopes) { + return scopes.map(scope => { + if (OKTA_OIDC_SCOPES.has(scope)) { + return scope; + } + + if (scope.startsWith(OKTA_SCOPE_PREFIX)) { + return scope; + } + + return `${OKTA_SCOPE_PREFIX}${scope}`; + }); }, }); - - 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); } } diff --git a/packages/core-api/src/apis/implementations/auth/okta/index.ts b/packages/core-api/src/apis/implementations/auth/okta/index.ts index 5d6d382c45..4cc774b26b 100644 --- a/packages/core-api/src/apis/implementations/auth/okta/index.ts +++ b/packages/core-api/src/apis/implementations/auth/okta/index.ts @@ -14,5 +14,4 @@ * 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 deleted file mode 100644 index e3392ba1d6..0000000000 --- a/packages/core-api/src/apis/implementations/auth/okta/types.ts +++ /dev/null @@ -1,27 +0,0 @@ -/* - * 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; -};