From 8a61795308eadcdd10f757559fdbad8555edef06 Mon Sep 17 00:00:00 2001 From: Gasan Guseinov Date: Thu, 23 Jan 2025 18:37:54 +0100 Subject: [PATCH] add a unit test Signed-off-by: Gasan Guseinov --- packages/cli/src/commands/repo/test.ts | 3 + packages/core-app-api/report.api.md | 34 +++- .../auth/oauth2/OAuth2.test.ts | 48 ++++++ .../implementations/auth/oauth2/OAuth2.ts | 146 ++++++++---------- .../oauth2/OAuth2CustomAuthConnector.test.ts | 121 +++++++++++++++ .../apis/implementations/auth/oauth2/types.ts | 43 +++++- packages/core-app-api/src/index.ts | 2 +- 7 files changed, 310 insertions(+), 87 deletions(-) create mode 100644 packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2CustomAuthConnector.test.ts diff --git a/packages/cli/src/commands/repo/test.ts b/packages/cli/src/commands/repo/test.ts index 6b3478b5d9..b32f3caaa7 100644 --- a/packages/cli/src/commands/repo/test.ts +++ b/packages/cli/src/commands/repo/test.ts @@ -422,5 +422,8 @@ export async function command(opts: OptionValues, cmd: Command): Promise { }; } + console.log('retry'); + console.log(`args: ${JSON.stringify(args)}`); + await runJest(args); } diff --git a/packages/core-app-api/report.api.md b/packages/core-app-api/report.api.md index ccec5ca4ce..6af171ea4d 100644 --- a/packages/core-app-api/report.api.md +++ b/packages/core-app-api/report.api.md @@ -21,6 +21,7 @@ import { AuthRequestOptions } from '@backstage/core-plugin-api'; import { BackstageIdentityApi } from '@backstage/core-plugin-api'; import { BackstageIdentityResponse } from '@backstage/core-plugin-api'; import { BackstagePlugin } from '@backstage/core-plugin-api'; +import { BackstageUserIdentity } from '@backstage/core-plugin-api'; import { bitbucketAuthApiRef } from '@backstage/core-plugin-api'; import { bitbucketServerAuthApiRef } from '@backstage/core-plugin-api'; import { ComponentType } from 'react'; @@ -547,7 +548,9 @@ export class OAuth2 SessionApi { // (undocumented) - static create(options: OAuth2CreateOptions): OAuth2; + static create( + options: OAuth2CreateOptions | OAuth2CreateOptionsWithAuthConnector, + ): OAuth2; // (undocumented) getAccessToken( scope?: string | string[], @@ -562,6 +565,11 @@ export class OAuth2 // (undocumented) getProfile(options?: AuthRequestOptions): Promise; // (undocumented) + static normalizeScopes( + scopeTransform: (scopes: string[]) => string[], + scopes?: string | string[], + ): Set; + // (undocumented) sessionState$(): Observable; // (undocumented) signIn(): Promise; @@ -573,7 +581,29 @@ export class OAuth2 export type OAuth2CreateOptions = OAuthApiCreateOptions & { scopeTransform?: (scopes: string[]) => string[]; popupOptions?: PopupOptions; - authConnector?: AuthConnector; +}; + +// @public +export type OAuth2CreateOptionsWithAuthConnector = { + scopeTransform?: (scopes: string[]) => string[]; + defaultScopes?: string[]; + authConnector: AuthConnector; +}; + +// @public (undocumented) +export type OAuth2Response = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds?: number; + }; + profile: ProfileInfo; + backstageIdentity: { + token: string; + expiresInSeconds?: number; + identity: BackstageUserIdentity; + }; }; // @public diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts index a34589d746..f662c47bda 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.test.ts @@ -18,6 +18,14 @@ import OAuth2 from './OAuth2'; import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi'; import { UrlPatternDiscovery } from '../../DiscoveryApi'; import { mockApis } from '@backstage/test-utils'; +import { + OAuth2Session, + AuthConnector, + AuthConnectorRefreshSessionOptions, + openLoginPopup, + // OAuth2Response, + OAuth2CreateOptionsWithAuthConnector, +} from '../../../../index'; const theFuture = new Date(Date.now() + 3600000); const thePast = new Date(Date.now() - 10); @@ -37,6 +45,25 @@ jest.mock('../../../../lib/AuthSessionManager', () => ({ const configApi = mockApis.config(); +class CustomAuthConnector implements AuthConnector { + async createSession() { + const s: OAuth2Session = { + providerInfo: { + idToken: '', + accessToken: 'accessToken', + scopes: new Set(['myScope']), + }, + profile: {}, + }; + await openLoginPopup({ url: 'http://localhost', name: 'myPopup' }); + return Promise.resolve(s); + } + + async refreshSession(_?: AuthConnectorRefreshSessionOptions): Promise {} + + async removeSession(): Promise {} +} + describe('OAuth2', () => { it('should get refreshed access token', async () => { getSession = jest.fn().mockResolvedValue({ @@ -215,4 +242,25 @@ describe('OAuth2', () => { await expect(promise3).resolves.toBe('token2'); expect(getSession).toHaveBeenCalledTimes(4); // De-duping of session requests happens in client }); + it('should use provided auth provider', async () => { + getSession = jest.fn().mockResolvedValue({ + providerInfo: { accessToken: 'access-token', expiresAt: theFuture }, + }); + + const customAuthConnector = new CustomAuthConnector(); + + const options: OAuth2CreateOptionsWithAuthConnector = { + scopeTransform, + defaultScopes: ['myScope'], + authConnector: customAuthConnector, + }; + const oauth2 = OAuth2.create(options); + + expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe( + 'access-token', + ); + expect(getSession).toHaveBeenCalledWith( + expect.objectContaining({ scopes: new Set(['my-scope', 'my-scope2']) }), + ); + }); }); diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts index fb3aead36c..7318d30382 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2.ts @@ -14,53 +14,26 @@ * limitations under the License. */ -import { - AuthConnector, - DefaultAuthConnector, - PopupOptions, -} from '../../../../lib/AuthConnector'; +import { DefaultAuthConnector } from '../../../../lib/AuthConnector'; import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager'; import { SessionManager } from '../../../../lib/AuthSessionManager/types'; import { AuthRequestOptions, + BackstageIdentityApi, BackstageIdentityResponse, OAuthApi, OpenIdConnectApi, - ProfileInfo, ProfileInfoApi, - SessionState, SessionApi, - BackstageIdentityApi, - BackstageUserIdentity, + SessionState, } from '@backstage/core-plugin-api'; import { Observable } from '@backstage/types'; -import { OAuth2Session } from './types'; -import { OAuthApiCreateOptions } from '../types'; - -/** - * OAuth2 create options. - * @public - */ -export type OAuth2CreateOptions = OAuthApiCreateOptions & { - scopeTransform?: (scopes: string[]) => string[]; - popupOptions?: PopupOptions; - authConnector?: AuthConnector; -}; - -export type OAuth2Response = { - providerInfo: { - accessToken: string; - idToken: string; - scope: string; - expiresInSeconds?: number; - }; - profile: ProfileInfo; - backstageIdentity: { - token: string; - expiresInSeconds?: number; - identity: BackstageUserIdentity; - }; -}; +import { + OAuth2CreateOptions, + OAuth2CreateOptionsWithAuthConnector, + OAuth2Response, + OAuth2Session, +} from './types'; const DEFAULT_PROVIDER = { id: 'oauth2', @@ -81,61 +54,67 @@ export default class OAuth2 BackstageIdentityApi, SessionApi { - static create(options: OAuth2CreateOptions) { + private static createAuthConnector( + options: OAuth2CreateOptions | OAuth2CreateOptionsWithAuthConnector, + ) { + if ('authConnector' in options) { + return options.authConnector; + } const { + scopeTransform = x => x, configApi, discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, oauthRequestApi, - defaultScopes = [], - scopeTransform = x => x, popupOptions, } = options; - const connector = - options.authConnector ?? - new DefaultAuthConnector({ - configApi, - discoveryApi, - environment, - provider, - oauthRequestApi: oauthRequestApi, - sessionTransform({ - backstageIdentity, - ...res - }: OAuth2Response): OAuth2Session { - const session: OAuth2Session = { - ...res, - providerInfo: { - idToken: res.providerInfo.idToken, - accessToken: res.providerInfo.accessToken, - scopes: OAuth2.normalizeScopes( - scopeTransform, - res.providerInfo.scope, - ), - expiresAt: res.providerInfo.expiresInSeconds - ? new Date( - Date.now() + res.providerInfo.expiresInSeconds * 1000, - ) - : undefined, - }, + return new DefaultAuthConnector({ + configApi, + discoveryApi, + environment, + provider, + oauthRequestApi: oauthRequestApi, + sessionTransform({ + backstageIdentity, + ...res + }: OAuth2Response): OAuth2Session { + const session: OAuth2Session = { + ...res, + providerInfo: { + idToken: res.providerInfo.idToken, + accessToken: res.providerInfo.accessToken, + scopes: OAuth2.normalizeScopes( + scopeTransform, + res.providerInfo.scope, + ), + expiresAt: res.providerInfo.expiresInSeconds + ? new Date(Date.now() + res.providerInfo.expiresInSeconds * 1000) + : undefined, + }, + }; + if (backstageIdentity) { + session.backstageIdentity = { + token: backstageIdentity.token, + identity: backstageIdentity.identity, + expiresAt: backstageIdentity.expiresInSeconds + ? new Date(Date.now() + backstageIdentity.expiresInSeconds * 1000) + : undefined, }; - if (backstageIdentity) { - session.backstageIdentity = { - token: backstageIdentity.token, - identity: backstageIdentity.identity, - expiresAt: backstageIdentity.expiresInSeconds - ? new Date( - Date.now() + backstageIdentity.expiresInSeconds * 1000, - ) - : undefined, - }; - } - return session; - }, - popupOptions, - }); + } + return session; + }, + popupOptions, + }); + } + + static create( + options: OAuth2CreateOptions | OAuth2CreateOptionsWithAuthConnector, + ) { + const { defaultScopes = [], scopeTransform = x => x } = options; + + const connector = OAuth2.createAuthConnector(options); const sessionManager = new RefreshingAuthSessionManager({ connector, @@ -218,7 +197,10 @@ export default class OAuth2 return session?.profile; } - private static normalizeScopes( + /** + * @public + */ + public static normalizeScopes( scopeTransform: (scopes: string[]) => string[], scopes?: string | string[], ): Set { diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2CustomAuthConnector.test.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2CustomAuthConnector.test.ts new file mode 100644 index 0000000000..508d3c7274 --- /dev/null +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/OAuth2CustomAuthConnector.test.ts @@ -0,0 +1,121 @@ +/* + * Copyright 2020 The Backstage Authors + * + * 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'; +import { + OAuth2Session, + AuthConnector, + AuthConnectorRefreshSessionOptions, + openLoginPopup, + OAuth2CreateOptionsWithAuthConnector, + OAuth2Response, +} from '../../../../index'; + +const scopeTransform = (x: string[]) => x; + +type Options = { + /** + * Function used to transform an auth response into the session type. + */ + sessionTransform?(response: any): OAuth2Session | Promise; +}; + +class CustomAuthConnector implements AuthConnector { + private readonly sessionTransform: (response: any) => Promise; + + constructor(options: Options) { + const { sessionTransform = id => id } = options; + + this.sessionTransform = sessionTransform; + } + + async createSession() { + return await this.sessionTransform( + await openLoginPopup({ url: 'http://my-origin', name: 'myPopup' }), + ); + } + + async refreshSession(_?: AuthConnectorRefreshSessionOptions): Promise {} + + async removeSession(): Promise {} +} + +describe('OAuth2', () => { + it('should use provided auth provider', async () => { + const popupMock = { closed: false }; + + jest.spyOn(window, 'open').mockReturnValue(popupMock as Window); + + const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); + jest.spyOn(window, 'removeEventListener'); + + const customAuthConnector = new CustomAuthConnector({ + sessionTransform(res: OAuth2Response): OAuth2Session { + return { + ...res, + providerInfo: { + idToken: res.providerInfo.idToken, + accessToken: res.providerInfo.accessToken, + scopes: OAuth2.normalizeScopes( + scopeTransform, + res.providerInfo.scope, + ), + expiresAt: res.providerInfo.expiresInSeconds + ? new Date(Date.now() + res.providerInfo.expiresInSeconds * 1000) + : undefined, + }, + }; + }, + }); + + const options: OAuth2CreateOptionsWithAuthConnector = { + scopeTransform, + defaultScopes: ['myScope'], + authConnector: customAuthConnector, + }; + const oauth2 = OAuth2.create(options); + + // so that AuthConnector calls openLoginPopup synchronously (not try to refresh the token) + const accessToken = oauth2.getAccessToken('myScope', { + instantPopup: true, + optional: false, + }); + + const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; + + const accessTokenValue = 'myAccessToken'; + const myResponse = { + providerInfo: { + accessToken: accessTokenValue, + scope: 'myScope', + expiresInSeconds: 900, + }, + profile: { displayName: 'John Doe' }, + }; + + // A valid sessions response + listener({ + source: popupMock, + origin: 'http://my-origin', + data: { + type: 'authorization_response', + response: myResponse, + }, + } as MessageEvent); + + return expect(accessToken).resolves.toBe(accessTokenValue); + }); +}); diff --git a/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts index e51de63dbe..0f320e97e6 100644 --- a/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts +++ b/packages/core-app-api/src/apis/implementations/auth/oauth2/types.ts @@ -15,11 +15,13 @@ */ import { - ProfileInfo, BackstageIdentityResponse, + BackstageUserIdentity, + ProfileInfo, } from '@backstage/core-plugin-api'; +import { OAuthApiCreateOptions } from '../types.ts'; +import { AuthConnector, PopupOptions } from '../../../../lib'; -export type { OAuth2CreateOptions } from './OAuth2'; export type { PopupOptions } from '../../../../lib/AuthConnector'; /** * Session information for generic OAuth2 auth. @@ -36,3 +38,40 @@ export type OAuth2Session = { profile: ProfileInfo; backstageIdentity?: BackstageIdentityResponse; }; + +/** + * @public + */ +export type OAuth2Response = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds?: number; + }; + profile: ProfileInfo; + backstageIdentity: { + token: string; + expiresInSeconds?: number; + identity: BackstageUserIdentity; + }; +}; + +/** + * OAuth2 create options. + * @public + */ +export type OAuth2CreateOptions = OAuthApiCreateOptions & { + scopeTransform?: (scopes: string[]) => string[]; + popupOptions?: PopupOptions; +}; + +/** + * OAuth2 create options with custom auth connector. + * @public + */ +export type OAuth2CreateOptionsWithAuthConnector = { + scopeTransform?: (scopes: string[]) => string[]; + defaultScopes?: string[]; + authConnector: AuthConnector; +}; diff --git a/packages/core-app-api/src/index.ts b/packages/core-app-api/src/index.ts index 1dc8ddec19..a963b0cca9 100644 --- a/packages/core-app-api/src/index.ts +++ b/packages/core-app-api/src/index.ts @@ -27,6 +27,6 @@ export type { AuthConnector, AuthConnectorCreateSessionOptions, AuthConnectorRefreshSessionOptions, - openLoginPopup, OpenLoginPopupOptions, } from './lib'; +export { openLoginPopup } from './lib';