From 1e0230e30494d1a63be9efa2b0b45eb08916d465 Mon Sep 17 00:00:00 2001 From: Gasan Guseinov Date: Thu, 5 Dec 2024 19:15:43 +0100 Subject: [PATCH 01/11] custom AuthConnector for OAuth2 Signed-off-by: Gasan Guseinov --- .changeset/brave-pandas-beam.md | 5 ++ .../implementations/auth/oauth2/OAuth2.ts | 47 ++++++++++++++----- packages/core-app-api/src/index.ts | 4 ++ 3 files changed, 43 insertions(+), 13 deletions(-) create mode 100644 .changeset/brave-pandas-beam.md diff --git a/.changeset/brave-pandas-beam.md b/.changeset/brave-pandas-beam.md new file mode 100644 index 0000000000..3defff8aa6 --- /dev/null +++ b/.changeset/brave-pandas-beam.md @@ -0,0 +1,5 @@ +--- +'@backstage/core-app-api': patch +--- + +custom AuthConnector for OAuth2 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 4963330724..9162896ebe 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 @@ -15,6 +15,7 @@ */ import { + AuthConnector, DefaultAuthConnector, PopupOptions, } from '../../../../lib/AuthConnector'; @@ -43,6 +44,9 @@ import { OAuthApiCreateOptions } from '../types'; export type OAuth2CreateOptions = OAuthApiCreateOptions & { scopeTransform?: (scopes: string[]) => string[]; popupOptions?: PopupOptions; + authConnectorFactory?: ( + opts: OAuth2CreateOptions, + ) => AuthConnector; }; export type OAuth2Response = { @@ -79,23 +83,22 @@ export default class OAuth2 BackstageIdentityApi, SessionApi { - static create(options: OAuth2CreateOptions) { + private static createDefaultAuthConnector(options: OAuth2CreateOptions) { const { - configApi, - discoveryApi, - environment = 'development', - provider = DEFAULT_PROVIDER, - oauthRequestApi, - defaultScopes = [], - scopeTransform = x => x, - popupOptions, - } = options; - - const connector = new DefaultAuthConnector({ configApi, discoveryApi, environment, provider, + oauthRequestApi, + scopeTransform, + popupOptions, + } = options; + + return new DefaultAuthConnector({ + configApi, + discoveryApi, + environment: environment!, + provider: provider!, oauthRequestApi: oauthRequestApi, sessionTransform({ backstageIdentity, @@ -107,7 +110,7 @@ export default class OAuth2 idToken: res.providerInfo.idToken, accessToken: res.providerInfo.accessToken, scopes: OAuth2.normalizeScopes( - scopeTransform, + scopeTransform!, res.providerInfo.scope, ), expiresAt: res.providerInfo.expiresInSeconds @@ -128,6 +131,24 @@ export default class OAuth2 }, popupOptions, }); + } + + static create(options: OAuth2CreateOptions) { + const { + environment = 'development', + provider = DEFAULT_PROVIDER, + defaultScopes = [], + scopeTransform = x => x, + authConnectorFactory = (opts: OAuth2CreateOptions) => + OAuth2.createDefaultAuthConnector(opts), + } = options; + + const connector = authConnectorFactory({ + ...options, + scopeTransform, + environment, + provider, + }); const sessionManager = new RefreshingAuthSessionManager({ connector, diff --git a/packages/core-app-api/src/index.ts b/packages/core-app-api/src/index.ts index a5e6b649e9..9033622b9d 100644 --- a/packages/core-app-api/src/index.ts +++ b/packages/core-app-api/src/index.ts @@ -23,3 +23,7 @@ export * from './apis'; export * from './app'; export * from './routing'; +export type { AuthConnector } from './lib'; +export type { CreateSessionOptions } from './lib'; +export { showLoginPopup } from './lib'; +export type { LoginPopupOptions } from './lib'; From 976b4fedd829ef97313116a2775c2415fe2996c8 Mon Sep 17 00:00:00 2001 From: Gasan Guseinov Date: Mon, 9 Dec 2024 15:33:44 +0100 Subject: [PATCH 02/11] update api specification Signed-off-by: Gasan Guseinov --- packages/core-app-api/report.api.md | 3 +++ packages/core-app-api/src/lib/AuthConnector/types.ts | 5 +++++ packages/core-app-api/src/lib/loginPopup.ts | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/packages/core-app-api/report.api.md b/packages/core-app-api/report.api.md index 3d2c44a148..0cec898bfd 100644 --- a/packages/core-app-api/report.api.md +++ b/packages/core-app-api/report.api.md @@ -551,6 +551,9 @@ export class OAuth2 export type OAuth2CreateOptions = OAuthApiCreateOptions & { scopeTransform?: (scopes: string[]) => string[]; popupOptions?: PopupOptions; + authConnectorFactory?: ( + opts: OAuth2CreateOptions, + ) => AuthConnector; }; // @public diff --git a/packages/core-app-api/src/lib/AuthConnector/types.ts b/packages/core-app-api/src/lib/AuthConnector/types.ts index 3c01bb254f..1ba96c6cb8 100644 --- a/packages/core-app-api/src/lib/AuthConnector/types.ts +++ b/packages/core-app-api/src/lib/AuthConnector/types.ts @@ -14,6 +14,9 @@ * limitations under the License. */ +/** + * @public + */ export type CreateSessionOptions = { scopes: Set; instantPopup?: boolean; @@ -22,6 +25,8 @@ export type CreateSessionOptions = { /** * An AuthConnector is responsible for realizing auth session actions * by for example communicating with a backend or interacting with the user. + * + * @public */ export type AuthConnector = { createSession(options: CreateSessionOptions): Promise; diff --git a/packages/core-app-api/src/lib/loginPopup.ts b/packages/core-app-api/src/lib/loginPopup.ts index b6c14d60c9..9ab75501b0 100644 --- a/packages/core-app-api/src/lib/loginPopup.ts +++ b/packages/core-app-api/src/lib/loginPopup.ts @@ -16,6 +16,8 @@ /** * Options used to open a login popup. + * + * @public */ export type LoginPopupOptions = { /** @@ -65,6 +67,8 @@ type AuthResult = * to the app window. The message posted to the app must match the AuthResult type. * * The returned promise resolves to the response of the message that was posted from the auth popup. + * + * @public */ export function showLoginPopup(options: LoginPopupOptions): Promise { return new Promise((resolve, reject) => { From fe015869b39fbf6ac1c416a8132fff95ecef0409 Mon Sep 17 00:00:00 2001 From: Gasan Guseinov Date: Mon, 9 Dec 2024 16:07:37 +0100 Subject: [PATCH 03/11] update api specification Signed-off-by: Gasan Guseinov --- packages/core-app-api/report.api.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/core-app-api/report.api.md b/packages/core-app-api/report.api.md index 0cec898bfd..73d940d1c4 100644 --- a/packages/core-app-api/report.api.md +++ b/packages/core-app-api/report.api.md @@ -294,6 +294,13 @@ export type AuthApiCreateOptions = { configApi?: ConfigApi; }; +// @public +export type AuthConnector = { + createSession(options: CreateSessionOptions): Promise; + refreshSession(scopes?: Set): Promise; + removeSession(): Promise; +}; + // @public export type BackstageApp = { getPlugins(): BackstagePlugin[]; @@ -353,6 +360,12 @@ export function createFetchApi(options: { middleware?: FetchMiddleware | FetchMiddleware[] | undefined; }): FetchApi; +// @public (undocumented) +export type CreateSessionOptions = { + scopes: Set; + instantPopup?: boolean; +}; + // @public export function createSpecializedApp(options: AppOptions): BackstageApp; @@ -478,6 +491,15 @@ export class LocalStorageFeatureFlags implements FeatureFlagsApi { save(options: FeatureFlagsSaveOptions): void; } +// @public +export type LoginPopupOptions = { + url: string; + name: string; + origin: string; + width?: number; + height?: number; +}; + // @public export class MicrosoftAuth { // (undocumented) @@ -640,6 +662,9 @@ export class SamlAuth signOut(): Promise; } +// @public +export function showLoginPopup(options: LoginPopupOptions): Promise; + // @public export type SignInPageProps = PropsWithChildren<{ onSignInSuccess(identityApi: IdentityApi): void; From 0553465f7d6e9d60af5884368adfe23b9a43b9b2 Mon Sep 17 00:00:00 2001 From: Gasan Guseinov Date: Wed, 8 Jan 2025 18:36:16 +0100 Subject: [PATCH 04/11] updates after code review Signed-off-by: Gasan Guseinov --- .changeset/brave-pandas-beam.md | 16 ++- packages/core-app-api/report.api.md | 54 +++++---- .../implementations/auth/oauth2/OAuth2.ts | 109 ++++++++---------- packages/core-app-api/src/index.ts | 11 +- .../DefaultAuthConnector.test.ts | 19 ++- .../lib/AuthConnector/DefaultAuthConnector.ts | 22 ++-- .../lib/AuthConnector/DirectAuthConnector.ts | 7 +- .../src/lib/AuthConnector/types.ts | 17 ++- .../RefreshingAuthSessionManager.test.ts | 37 +++--- .../RefreshingAuthSessionManager.ts | 6 +- .../core-app-api/src/lib/loginPopup.test.ts | 42 +++---- packages/core-app-api/src/lib/loginPopup.ts | 15 ++- 12 files changed, 192 insertions(+), 163 deletions(-) diff --git a/.changeset/brave-pandas-beam.md b/.changeset/brave-pandas-beam.md index 3defff8aa6..8dd525d749 100644 --- a/.changeset/brave-pandas-beam.md +++ b/.changeset/brave-pandas-beam.md @@ -1,5 +1,17 @@ --- -'@backstage/core-app-api': patch +'@backstage/core-app-api': minor --- -custom AuthConnector for OAuth2 +Support custom `AuthConnector` for `OAuth2`. + +A user can pass their own `AuthConnector` implementation in `OAuth2` constructor. +In which case the session manager will use that instead of the `DefaultAuthConnector` to interact with the +authentication provider. + +A custom `AuthConnector` may call the authentication provider from the front-end, store and retrieve tokens +in the session storage, for example, and otherwise send custom requests to the authentication provider and +handle its responses. + +Note, that if the custom `AuthConnector` transforms scopes returned from the authentication provider, +the transformation must be the same as `OAuth2CreateOptions#scopeTransform` passed to `OAuth2` constructor. +See creating `DefaultAuthConnector` in `OAuth2#create(...)` for an example. diff --git a/packages/core-app-api/report.api.md b/packages/core-app-api/report.api.md index 73d940d1c4..ccec5ca4ce 100644 --- a/packages/core-app-api/report.api.md +++ b/packages/core-app-api/report.api.md @@ -296,11 +296,26 @@ export type AuthApiCreateOptions = { // @public export type AuthConnector = { - createSession(options: CreateSessionOptions): Promise; - refreshSession(scopes?: Set): Promise; + createSession( + options: AuthConnectorCreateSessionOptions, + ): Promise; + refreshSession( + options?: AuthConnectorRefreshSessionOptions, + ): Promise; removeSession(): Promise; }; +// @public (undocumented) +export type AuthConnectorCreateSessionOptions = { + scopes: Set; + instantPopup?: boolean; +}; + +// @public (undocumented) +export type AuthConnectorRefreshSessionOptions = { + scopes: Set; +}; + // @public export type BackstageApp = { getPlugins(): BackstagePlugin[]; @@ -360,12 +375,6 @@ export function createFetchApi(options: { middleware?: FetchMiddleware | FetchMiddleware[] | undefined; }): FetchApi; -// @public (undocumented) -export type CreateSessionOptions = { - scopes: Set; - instantPopup?: boolean; -}; - // @public export function createSpecializedApp(options: AppOptions): BackstageApp; @@ -491,15 +500,6 @@ export class LocalStorageFeatureFlags implements FeatureFlagsApi { save(options: FeatureFlagsSaveOptions): void; } -// @public -export type LoginPopupOptions = { - url: string; - name: string; - origin: string; - width?: number; - height?: number; -}; - // @public export class MicrosoftAuth { // (undocumented) @@ -573,9 +573,7 @@ export class OAuth2 export type OAuth2CreateOptions = OAuthApiCreateOptions & { scopeTransform?: (scopes: string[]) => string[]; popupOptions?: PopupOptions; - authConnectorFactory?: ( - opts: OAuth2CreateOptions, - ) => AuthConnector; + authConnector?: AuthConnector; }; // @public @@ -627,6 +625,19 @@ export type OneLoginAuthCreateOptions = { provider?: AuthProviderInfo; }; +// @public +export function openLoginPopup( + options: OpenLoginPopupOptions, +): Promise; + +// @public +export type OpenLoginPopupOptions = { + url: string; + name: string; + width?: number; + height?: number; +}; + // @public export type PopupOptions = { size?: @@ -662,9 +673,6 @@ export class SamlAuth signOut(): Promise; } -// @public -export function showLoginPopup(options: LoginPopupOptions): Promise; - // @public export type SignInPageProps = PropsWithChildren<{ onSignInSuccess(identityApi: IdentityApi): void; 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 9162896ebe..fb3aead36c 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 @@ -44,9 +44,7 @@ import { OAuthApiCreateOptions } from '../types'; export type OAuth2CreateOptions = OAuthApiCreateOptions & { scopeTransform?: (scopes: string[]) => string[]; popupOptions?: PopupOptions; - authConnectorFactory?: ( - opts: OAuth2CreateOptions, - ) => AuthConnector; + authConnector?: AuthConnector; }; export type OAuth2Response = { @@ -83,72 +81,61 @@ export default class OAuth2 BackstageIdentityApi, SessionApi { - private static createDefaultAuthConnector(options: OAuth2CreateOptions) { - const { - configApi, - discoveryApi, - environment, - provider, - oauthRequestApi, - scopeTransform, - popupOptions, - } = options; - - return new DefaultAuthConnector({ - configApi, - discoveryApi, - environment: environment!, - provider: 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, - }; - } - return session; - }, - popupOptions, - }); - } - static create(options: OAuth2CreateOptions) { const { + configApi, + discoveryApi, environment = 'development', provider = DEFAULT_PROVIDER, + oauthRequestApi, defaultScopes = [], scopeTransform = x => x, - authConnectorFactory = (opts: OAuth2CreateOptions) => - OAuth2.createDefaultAuthConnector(opts), + popupOptions, } = options; - const connector = authConnectorFactory({ - ...options, - scopeTransform, - environment, - provider, - }); + 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, + }, + }; + if (backstageIdentity) { + session.backstageIdentity = { + token: backstageIdentity.token, + identity: backstageIdentity.identity, + expiresAt: backstageIdentity.expiresInSeconds + ? new Date( + Date.now() + backstageIdentity.expiresInSeconds * 1000, + ) + : undefined, + }; + } + return session; + }, + popupOptions, + }); const sessionManager = new RefreshingAuthSessionManager({ connector, diff --git a/packages/core-app-api/src/index.ts b/packages/core-app-api/src/index.ts index 9033622b9d..1dc8ddec19 100644 --- a/packages/core-app-api/src/index.ts +++ b/packages/core-app-api/src/index.ts @@ -23,7 +23,10 @@ export * from './apis'; export * from './app'; export * from './routing'; -export type { AuthConnector } from './lib'; -export type { CreateSessionOptions } from './lib'; -export { showLoginPopup } from './lib'; -export type { LoginPopupOptions } from './lib'; +export type { + AuthConnector, + AuthConnectorCreateSessionOptions, + AuthConnectorRefreshSessionOptions, + openLoginPopup, + OpenLoginPopupOptions, +} from './lib'; diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts index 60c4a3b8d9..192b1b7ac0 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.test.ts @@ -26,7 +26,7 @@ import { ConfigApi } from '@backstage/core-plugin-api'; jest.mock('../loginPopup', () => { return { - showLoginPopup: jest.fn(), + openLoginPopup: jest.fn(), }; }); @@ -74,7 +74,9 @@ describe('DefaultAuthConnector', () => { ); const connector = new DefaultAuthConnector(defaultOptions); - const session = await connector.refreshSession(new Set(['a', 'b', 'c'])); + const session = await connector.refreshSession({ + scopes: new Set(['a', 'b', 'c']), + }); expect(session.idToken).toBe('mock-id-token'); expect(session.accessToken).toBe('mock-access-token'); expect(session.scopes).toEqual(new Set(['a', 'b', 'c'])); @@ -118,7 +120,7 @@ describe('DefaultAuthConnector', () => { it('should create a session', async () => { const mockOauth = new MockOAuthApi(); const popupSpy = jest - .spyOn(loginPopup, 'showLoginPopup') + .spyOn(loginPopup, 'openLoginPopup') .mockResolvedValue({ idToken: 'my-id-token', accessToken: 'my-access-token', @@ -151,7 +153,7 @@ describe('DefaultAuthConnector', () => { it('should instantly show popup if option is set', async () => { const popupSpy = jest - .spyOn(loginPopup, 'showLoginPopup') + .spyOn(loginPopup, 'openLoginPopup') .mockResolvedValue('my-session'); const connector = new DefaultAuthConnector({ ...defaultOptions, @@ -169,7 +171,6 @@ describe('DefaultAuthConnector', () => { expect(popupSpy).toHaveBeenCalledTimes(1); expect(popupSpy).toHaveBeenCalledWith({ name: 'My Provider Login', - origin: 'http://my-host', url: 'http://my-host/api/auth/my-provider/start?scope=&origin=http%3A%2F%2Flocalhost&flow=popup&env=production', width: 450, height: 730, @@ -178,7 +179,7 @@ describe('DefaultAuthConnector', () => { it('should show popup fullscreen', async () => { const popupSpy = jest - .spyOn(loginPopup, 'showLoginPopup') + .spyOn(loginPopup, 'openLoginPopup') .mockResolvedValue('my-session'); jest.spyOn(window.screen, 'width', 'get').mockReturnValue(1000); @@ -205,7 +206,6 @@ describe('DefaultAuthConnector', () => { expect(popupSpy).toHaveBeenCalledWith({ height: 1000, name: 'My Provider Login', - origin: 'http://my-host', url: 'http://my-host/api/auth/my-provider/start?scope=&origin=http%3A%2F%2Flocalhost&flow=popup&env=production', width: 1000, }); @@ -213,7 +213,7 @@ describe('DefaultAuthConnector', () => { it('should show popup with special width and height', async () => { const popupSpy = jest - .spyOn(loginPopup, 'showLoginPopup') + .spyOn(loginPopup, 'openLoginPopup') .mockResolvedValue('my-session'); const connector = new DefaultAuthConnector({ ...defaultOptions, @@ -236,7 +236,6 @@ describe('DefaultAuthConnector', () => { expect(popupSpy).toHaveBeenCalledWith({ name: 'My Provider Login', - origin: 'http://my-host', url: 'http://my-host/api/auth/my-provider/start?scope=&origin=http%3A%2F%2Flocalhost&flow=popup&env=production', width: 500, height: 1000, @@ -246,7 +245,7 @@ describe('DefaultAuthConnector', () => { it('should use join func to join scopes', async () => { const mockOauth = new MockOAuthApi(); const popupSpy = jest - .spyOn(loginPopup, 'showLoginPopup') + .spyOn(loginPopup, 'openLoginPopup') .mockResolvedValue({ scopes: '' }); const connector = new DefaultAuthConnector({ ...defaultOptions, diff --git a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts index ad1b25619d..3671ddc6e6 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DefaultAuthConnector.ts @@ -20,8 +20,13 @@ import { OAuthRequestApi, OAuthRequester, } from '@backstage/core-plugin-api'; -import { showLoginPopup } from '../loginPopup'; -import { AuthConnector, CreateSessionOptions, PopupOptions } from './types'; +import { openLoginPopup } from '../loginPopup'; +import { + AuthConnector, + AuthConnectorCreateSessionOptions, + PopupOptions, + AuthConnectorRefreshSessionOptions, +} from './types'; let warned = false; @@ -123,7 +128,9 @@ export class DefaultAuthConnector this.popupOptions = popupOptions; } - async createSession(options: CreateSessionOptions): Promise { + async createSession( + options: AuthConnectorCreateSessionOptions, + ): Promise { if (options.instantPopup) { if (this.enableExperimentalRedirectFlow) { return this.executeRedirect(options.scopes); @@ -133,11 +140,13 @@ export class DefaultAuthConnector return this.authRequester(options.scopes); } - async refreshSession(scopes?: Set): Promise { + async refreshSession( + options?: AuthConnectorRefreshSessionOptions, + ): Promise { const res = await fetch( await this.buildUrl('/refresh', { optional: true, - ...(scopes && { scope: this.joinScopesFunc(scopes) }), + ...(options && { scope: this.joinScopesFunc(options.scopes) }), }), { headers: { @@ -203,10 +212,9 @@ export class DefaultAuthConnector ? window.screen.height : this.popupOptions?.size?.height || 730; - const payload = await showLoginPopup({ + const payload = await openLoginPopup({ url: popupUrl, name: `${this.provider.title} Login`, - origin: new URL(popupUrl).origin, width, height, }); diff --git a/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts index 200ba755ac..a45bfa0af9 100644 --- a/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts +++ b/packages/core-app-api/src/lib/AuthConnector/DirectAuthConnector.ts @@ -14,7 +14,7 @@ * limitations under the License. */ import { AuthProviderInfo, DiscoveryApi } from '@backstage/core-plugin-api'; -import { showLoginPopup } from '../loginPopup'; +import { openLoginPopup } from '../loginPopup'; type Options = { discoveryApi: DiscoveryApi; @@ -36,13 +36,12 @@ export class DirectAuthConnector { async createSession(): Promise { const popupUrl = await this.buildUrl('/start'); - const payload = await showLoginPopup({ + const payload = (await openLoginPopup({ url: popupUrl, name: `${this.provider.title} Login`, - origin: new URL(popupUrl).origin, width: 450, height: 730, - }); + })) as any; return { ...payload, diff --git a/packages/core-app-api/src/lib/AuthConnector/types.ts b/packages/core-app-api/src/lib/AuthConnector/types.ts index 1ba96c6cb8..035760d0aa 100644 --- a/packages/core-app-api/src/lib/AuthConnector/types.ts +++ b/packages/core-app-api/src/lib/AuthConnector/types.ts @@ -17,11 +17,18 @@ /** * @public */ -export type CreateSessionOptions = { +export type AuthConnectorCreateSessionOptions = { scopes: Set; instantPopup?: boolean; }; +/** + * @public + */ +export type AuthConnectorRefreshSessionOptions = { + scopes: Set; +}; + /** * An AuthConnector is responsible for realizing auth session actions * by for example communicating with a backend or interacting with the user. @@ -29,8 +36,12 @@ export type CreateSessionOptions = { * @public */ export type AuthConnector = { - createSession(options: CreateSessionOptions): Promise; - refreshSession(scopes?: Set): Promise; + createSession( + options: AuthConnectorCreateSessionOptions, + ): Promise; + refreshSession( + options?: AuthConnectorRefreshSessionOptions, + ): Promise; removeSession(): Promise; }; diff --git a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts index 88000638e6..9afc710bdd 100644 --- a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts +++ b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.test.ts @@ -16,6 +16,7 @@ import { RefreshingAuthSessionManager } from './RefreshingAuthSessionManager'; import { SessionState } from '@backstage/core-plugin-api'; +import { AuthConnectorRefreshSessionOptions } from '../AuthConnector'; const defaultOptions = { sessionScopes: (session: { scopes: Set }) => session.scopes, @@ -47,7 +48,7 @@ describe('RefreshingAuthSessionManager', () => { await manager.getSession({}); expect(createSession).toHaveBeenCalledTimes(1); - expect(refreshSession).toHaveBeenCalledWith(new Set()); + expect(refreshSession).toHaveBeenCalledWith({ scopes: new Set() }); expect(stateSubscriber.mock.calls).toEqual([ [SessionState.SignedOut], [SessionState.SignedIn], @@ -103,7 +104,7 @@ describe('RefreshingAuthSessionManager', () => { await manager.getSession({ scopes: new Set(['a']) }); expect(createSession).toHaveBeenCalledTimes(1); - expect(refreshSession).toHaveBeenCalledWith(new Set(['a'])); + expect(refreshSession).toHaveBeenCalledWith({ scopes: new Set(['a']) }); await manager.getSession({ scopes: new Set(['a']) }); expect(createSession).toHaveBeenCalledTimes(1); @@ -134,7 +135,7 @@ describe('RefreshingAuthSessionManager', () => { expect(await manager.getSession({ optional: true })).toBe(undefined); expect(createSession).toHaveBeenCalledTimes(0); - expect(refreshSession).toHaveBeenCalledWith(new Set()); + expect(refreshSession).toHaveBeenCalledWith({ scopes: new Set() }); }); it('should forward option to instantly show auth popup and not attempt refresh', async () => { @@ -168,10 +169,12 @@ describe('RefreshingAuthSessionManager', () => { it('should handle two simultaneous session refreshes with same scopes', async () => { const createSession = jest.fn(); - const refreshSession = jest.fn(async (scopes?: Set) => ({ - scopes: scopes ?? new Set(), - expired: false, - })); + const refreshSession = jest.fn( + async (options?: AuthConnectorRefreshSessionOptions) => ({ + scopes: options?.scopes ?? new Set(), + expired: false, + }), + ); const manager = new RefreshingAuthSessionManager({ connector: { createSession, refreshSession }, ...defaultOptions, @@ -192,10 +195,12 @@ describe('RefreshingAuthSessionManager', () => { it('should handle two simultaneous session refreshes with different scopes', async () => { const createSession = jest.fn(); - const refreshSession = jest.fn(async (scopes?: Set) => ({ - scopes: scopes ?? new Set(), - expired: false, - })); + const refreshSession = jest.fn( + async (options?: AuthConnectorRefreshSessionOptions) => ({ + scopes: options?.scopes ?? new Set(), + expired: false, + }), + ); const manager = new RefreshingAuthSessionManager({ connector: { createSession, refreshSession }, ...defaultOptions, @@ -216,10 +221,12 @@ describe('RefreshingAuthSessionManager', () => { it('should handle multiple simultaneous session refreshes with different scopes', async () => { const createSession = jest.fn(); - const refreshSession = jest.fn(async (scopes?: Set) => ({ - scopes: scopes ?? new Set(), - expired: false, - })); + const refreshSession = jest.fn( + async (options?: AuthConnectorRefreshSessionOptions) => ({ + scopes: options?.scopes ?? new Set(), + expired: false, + }), + ); const manager = new RefreshingAuthSessionManager({ connector: { createSession, refreshSession }, ...defaultOptions, diff --git a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts index 414743ba9d..216409a528 100644 --- a/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts +++ b/packages/core-app-api/src/lib/AuthSessionManager/RefreshingAuthSessionManager.ts @@ -137,9 +137,9 @@ export class RefreshingAuthSessionManager implements SessionManager { return this.refreshPromise; } - this.refreshPromise = this.connector.refreshSession( - this.helper.getExtendedScope(this.currentSession, scopes), - ); + this.refreshPromise = this.connector.refreshSession({ + scopes: this.helper.getExtendedScope(this.currentSession, scopes), + }); try { const session = await this.refreshPromise; diff --git a/packages/core-app-api/src/lib/loginPopup.test.ts b/packages/core-app-api/src/lib/loginPopup.test.ts index 50193cbdd6..448f4f727c 100644 --- a/packages/core-app-api/src/lib/loginPopup.test.ts +++ b/packages/core-app-api/src/lib/loginPopup.test.ts @@ -14,14 +14,14 @@ * limitations under the License. */ -import { showLoginPopup } from './loginPopup'; +import { openLoginPopup } from './loginPopup'; -describe('showLoginPopup', () => { +describe('openLoginPopup', () => { afterEach(() => { jest.resetAllMocks(); }); - it('should show an auth popup', async () => { + it('should open an auth popup', async () => { const popupMock = { closed: false }; const openSpy = jest .spyOn(window, 'open') @@ -29,15 +29,14 @@ describe('showLoginPopup', () => { const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - const payloadPromise = showLoginPopup({ - url: 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + const payloadPromise = openLoginPopup({ + url: 'http://my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', name: 'test-popup', - origin: 'my-origin', }); expect(openSpy).toHaveBeenCalledTimes(1); expect(openSpy.mock.calls[0][0]).toBe( - 'my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', + 'http://my-origin/api/backend/auth/start?scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fa%20https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fb', ); expect(openSpy.mock.calls[0][1]).toBe('test-popup'); expect(addEventListenerSpy).toHaveBeenCalledTimes(1); @@ -57,16 +56,16 @@ describe('showLoginPopup', () => { // None of these should be accepted listener({ source: popupMock } as MessageEvent); - listener({ origin: 'my-origin' } as MessageEvent); + listener({ origin: 'http://my-origin' } as MessageEvent); listener({ data: { type: 'authorization_response' } } as MessageEvent); listener({ source: popupMock, - origin: 'my-origin', + origin: 'http://my-origin', data: {}, } as MessageEvent); listener({ source: popupMock, - origin: 'my-origin', + origin: 'http://my-origin', data: { type: 'not-auth-result', response: {} }, } as MessageEvent); @@ -79,7 +78,7 @@ describe('showLoginPopup', () => { // This should be accepted as a valid sessions response listener({ source: popupMock, - origin: 'my-origin', + origin: 'http://my-origin', data: { type: 'authorization_response', response: myResponse, @@ -101,10 +100,9 @@ describe('showLoginPopup', () => { const addEventListenerSpy = jest.spyOn(window, 'addEventListener'); const removeEventListenerSpy = jest.spyOn(window, 'removeEventListener'); - const payloadPromise = showLoginPopup({ - url: 'url', + const payloadPromise = openLoginPopup({ + url: 'http://my-origin', name: 'name', - origin: 'my-origin', }); expect(openSpy).toHaveBeenCalledTimes(1); @@ -115,7 +113,7 @@ describe('showLoginPopup', () => { listener({ source: popupMock, - origin: 'my-origin', + origin: 'http://my-origin', data: { type: 'authorization_response', error: { @@ -145,10 +143,9 @@ describe('showLoginPopup', () => { openSpy.mockReturnValue(popupMock as Window); - const payloadPromise = showLoginPopup({ - url: 'url', + const payloadPromise = openLoginPopup({ + url: 'http://origin', name: 'name', - origin: 'origin', }); expect(openSpy).toHaveBeenCalledTimes(1); @@ -158,7 +155,7 @@ describe('showLoginPopup', () => { const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; listener({ source: popupMock, - origin: 'origin', + origin: 'http://origin', data: { type: 'config_info', targetOrigin: 'http://localhost', @@ -187,16 +184,15 @@ describe('showLoginPopup', () => { openSpy.mockReturnValue(popupMock as Window); - const payloadPromise = showLoginPopup({ - url: 'url', + const payloadPromise = openLoginPopup({ + url: 'http://origin', name: 'name', - origin: 'origin', }); const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; listener({ source: popupMock, - origin: 'origin', + origin: 'http://origin', data: { type: 'config_info', targetOrigin: 'http://differenthost', diff --git a/packages/core-app-api/src/lib/loginPopup.ts b/packages/core-app-api/src/lib/loginPopup.ts index 9ab75501b0..e7ed84b0cf 100644 --- a/packages/core-app-api/src/lib/loginPopup.ts +++ b/packages/core-app-api/src/lib/loginPopup.ts @@ -19,7 +19,7 @@ * * @public */ -export type LoginPopupOptions = { +export type OpenLoginPopupOptions = { /** * The URL that the auth popup should point to */ @@ -30,11 +30,6 @@ export type LoginPopupOptions = { */ name: string; - /** - * The origin of the final popup page that will post a message to this window. - */ - origin: string; - /** * The width of the popup in pixels, defaults to 500 */ @@ -70,13 +65,17 @@ type AuthResult = * * @public */ -export function showLoginPopup(options: LoginPopupOptions): Promise { +export function openLoginPopup( + options: OpenLoginPopupOptions, +): Promise { return new Promise((resolve, reject) => { const width = options.width || 500; const height = options.height || 700; const left = window.screen.width / 2 - width / 2; const top = window.screen.height / 2 - height / 2; + const origin = new URL(options.url).origin; + const popup = window.open( options.url, options.name, @@ -96,7 +95,7 @@ export function showLoginPopup(options: LoginPopupOptions): Promise { if (event.source !== popup) { return; } - if (event.origin !== options.origin) { + if (event.origin !== origin) { return; } const { data } = event; From 8a61795308eadcdd10f757559fdbad8555edef06 Mon Sep 17 00:00:00 2001 From: Gasan Guseinov Date: Thu, 23 Jan 2025 18:37:54 +0100 Subject: [PATCH 05/11] 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'; From 144f7629891f7d9fd742f96f92d2bcdbdf30792e Mon Sep 17 00:00:00 2001 From: Gasan Guseinov Date: Thu, 23 Jan 2025 18:39:27 +0100 Subject: [PATCH 06/11] remove unwanted changes Signed-off-by: Gasan Guseinov --- packages/cli/src/commands/repo/test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/cli/src/commands/repo/test.ts b/packages/cli/src/commands/repo/test.ts index b32f3caaa7..6b3478b5d9 100644 --- a/packages/cli/src/commands/repo/test.ts +++ b/packages/cli/src/commands/repo/test.ts @@ -422,8 +422,5 @@ export async function command(opts: OptionValues, cmd: Command): Promise { }; } - console.log('retry'); - console.log(`args: ${JSON.stringify(args)}`); - await runJest(args); } From af4f756a1d9970829018773d19d8322eb7d19337 Mon Sep 17 00:00:00 2001 From: Gasan Guseinov Date: Fri, 24 Jan 2025 14:04:33 +0100 Subject: [PATCH 07/11] update test name Signed-off-by: Gasan Guseinov --- .../auth/oauth2/OAuth2CustomAuthConnector.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 508d3c7274..e5ef3cbc0d 100644 --- 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 @@ -53,7 +53,7 @@ class CustomAuthConnector implements AuthConnector { async removeSession(): Promise {} } -describe('OAuth2', () => { +describe('OAuth2CustomAuthConnector', () => { it('should use provided auth provider', async () => { const popupMock = { closed: false }; From f6b38b06a2bdc4cb3674796101dd209e5ed35eac Mon Sep 17 00:00:00 2001 From: Gasan Guseinov Date: Fri, 31 Jan 2025 15:40:12 +0100 Subject: [PATCH 08/11] update custom auth connector test Signed-off-by: Gasan Guseinov --- .../auth/oauth2/OAuth2CustomAuthConnector.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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 index e5ef3cbc0d..cfed4d4714 100644 --- 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 @@ -23,6 +23,7 @@ import { OAuth2CreateOptionsWithAuthConnector, OAuth2Response, } from '../../../../index'; +import { waitFor } from '@testing-library/react'; const scopeTransform = (x: string[]) => x; @@ -54,7 +55,7 @@ class CustomAuthConnector implements AuthConnector { } describe('OAuth2CustomAuthConnector', () => { - it('should use provided auth provider', async () => { + it('should use custom auth connector', async () => { const popupMock = { closed: false }; jest.spyOn(window, 'open').mockReturnValue(popupMock as Window); @@ -88,11 +89,10 @@ describe('OAuth2CustomAuthConnector', () => { }; 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 accessToken = oauth2.getAccessToken('myScope'); + + // wait until `openLoginPopup` has been called + await waitFor(() => expect(addEventListenerSpy).toHaveBeenCalled()); const listener = addEventListenerSpy.mock.calls[0][1] as EventListener; From 398f4b32ee267fcaaf3e849afdacfdef3b526103 Mon Sep 17 00:00:00 2001 From: Gasan Guseinov <465806+gusega@users.noreply.github.com> Date: Tue, 11 Feb 2025 16:22:07 +0100 Subject: [PATCH 09/11] Apply suggestions from code review remove commented code Co-authored-by: Patrik Oldsberg Signed-off-by: Gasan Guseinov <465806+gusega@users.noreply.github.com> --- .../src/apis/implementations/auth/oauth2/OAuth2.test.ts | 1 - 1 file changed, 1 deletion(-) 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 f662c47bda..018f068a62 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 @@ -23,7 +23,6 @@ import { AuthConnector, AuthConnectorRefreshSessionOptions, openLoginPopup, - // OAuth2Response, OAuth2CreateOptionsWithAuthConnector, } from '../../../../index'; From 75e0c40abbe929b715a557d2195f15aa1ed4dfc5 Mon Sep 17 00:00:00 2001 From: "Gasan.Guseinov" <465806+gusega@users.noreply.github.com> Date: Tue, 11 Feb 2025 18:18:15 +0100 Subject: [PATCH 10/11] update normalizeScopes signature Signed-off-by: Gasan.Guseinov <465806+gusega@users.noreply.github.com> --- packages/core-app-api/report.api.md | 4 +++- .../apis/implementations/auth/oauth2/OAuth2.ts | 17 +++++++++++------ .../oauth2/OAuth2CustomAuthConnector.test.ts | 5 ++--- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/packages/core-app-api/report.api.md b/packages/core-app-api/report.api.md index 6af171ea4d..09119d8f8d 100644 --- a/packages/core-app-api/report.api.md +++ b/packages/core-app-api/report.api.md @@ -566,8 +566,10 @@ export class OAuth2 getProfile(options?: AuthRequestOptions): Promise; // (undocumented) static normalizeScopes( - scopeTransform: (scopes: string[]) => string[], scopes?: string | string[], + options?: { + scopeTransform: (scopes: string[]) => string[]; + }, ): Set; // (undocumented) sessionState$(): Observable; 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 7318d30382..c03176f8b6 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 @@ -85,10 +85,9 @@ export default class OAuth2 providerInfo: { idToken: res.providerInfo.idToken, accessToken: res.providerInfo.accessToken, - scopes: OAuth2.normalizeScopes( + scopes: OAuth2.normalizeScopes(res.providerInfo.scope, { scopeTransform, - res.providerInfo.scope, - ), + }), expiresAt: res.providerInfo.expiresInSeconds ? new Date(Date.now() + res.providerInfo.expiresInSeconds * 1000) : undefined, @@ -169,7 +168,9 @@ export default class OAuth2 scope?: string | string[], options?: AuthRequestOptions, ) { - const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope); + const normalizedScopes = OAuth2.normalizeScopes(scope, { + scopeTransform: this.scopeTransform, + }); const session = await this.sessionManager.getSession({ ...options, scopes: normalizedScopes, @@ -201,8 +202,8 @@ export default class OAuth2 * @public */ public static normalizeScopes( - scopeTransform: (scopes: string[]) => string[], scopes?: string | string[], + options?: { scopeTransform: (scopes: string[]) => string[] }, ): Set { if (!scopes) { return new Set(); @@ -212,6 +213,10 @@ export default class OAuth2 ? scopes : scopes.split(/[\s|,]/).filter(Boolean); - return new Set(scopeTransform(scopeList)); + const transformedScopes = options + ? options.scopeTransform(scopeList) + : scopeList; + + return new Set(transformedScopes); } } 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 index cfed4d4714..82578f835e 100644 --- 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 @@ -70,10 +70,9 @@ describe('OAuth2CustomAuthConnector', () => { providerInfo: { idToken: res.providerInfo.idToken, accessToken: res.providerInfo.accessToken, - scopes: OAuth2.normalizeScopes( + scopes: OAuth2.normalizeScopes(res.providerInfo.scope, { scopeTransform, - res.providerInfo.scope, - ), + }), expiresAt: res.providerInfo.expiresInSeconds ? new Date(Date.now() + res.providerInfo.expiresInSeconds * 1000) : undefined, From 50e2d6fb6ce7c4ee56b0db17ba46e06f51eb12da Mon Sep 17 00:00:00 2001 From: "Gasan.Guseinov" <465806+gusega@users.noreply.github.com> Date: Tue, 25 Mar 2025 17:11:23 +0100 Subject: [PATCH 11/11] unexport OAuth2Response Signed-off-by: Gasan.Guseinov <465806+gusega@users.noreply.github.com> --- packages/core-app-api/report.api.md | 17 -------------- .../implementations/auth/oauth2/OAuth2.ts | 18 ++++++++++++++- .../oauth2/OAuth2CustomAuthConnector.test.ts | 23 ++++++++++++++++++- .../apis/implementations/auth/oauth2/types.ts | 19 --------------- 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/packages/core-app-api/report.api.md b/packages/core-app-api/report.api.md index 09119d8f8d..68dd2f05e8 100644 --- a/packages/core-app-api/report.api.md +++ b/packages/core-app-api/report.api.md @@ -21,7 +21,6 @@ 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'; @@ -592,22 +591,6 @@ export type OAuth2CreateOptionsWithAuthConnector = { 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 export type OAuth2Session = { providerInfo: { 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 c03176f8b6..92747a1707 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 @@ -21,8 +21,10 @@ import { AuthRequestOptions, BackstageIdentityApi, BackstageIdentityResponse, + BackstageUserIdentity, OAuthApi, OpenIdConnectApi, + ProfileInfo, ProfileInfoApi, SessionApi, SessionState, @@ -31,7 +33,6 @@ import { Observable } from '@backstage/types'; import { OAuth2CreateOptions, OAuth2CreateOptionsWithAuthConnector, - OAuth2Response, OAuth2Session, } from './types'; @@ -41,6 +42,21 @@ const DEFAULT_PROVIDER = { icon: () => null, }; +export type OAuth2Response = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds?: number; + }; + profile: ProfileInfo; + backstageIdentity: { + token: string; + expiresInSeconds?: number; + identity: BackstageUserIdentity; + }; +}; + /** * Implements a generic OAuth2 flow for auth. * 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 index 82578f835e..a3188c9a80 100644 --- 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 @@ -21,9 +21,9 @@ import { AuthConnectorRefreshSessionOptions, openLoginPopup, OAuth2CreateOptionsWithAuthConnector, - OAuth2Response, } from '../../../../index'; import { waitFor } from '@testing-library/react'; +import { BackstageUserIdentity, ProfileInfo } from '@backstage/core-plugin-api'; const scopeTransform = (x: string[]) => x; @@ -34,6 +34,27 @@ type Options = { sessionTransform?(response: any): OAuth2Session | Promise; }; +/** + * A replica of private `OAuth2Response` from OAuth2.ts. + * `OAuth2Response` represents raw OAuth2 response from the `auth-backend` plugin. + * If custom auth connector calls `auth-backend` plugin, it will have to transform `OAuth2Response` into + * `OAuth2Session`. + */ +type OAuth2Response = { + providerInfo: { + accessToken: string; + idToken: string; + scope: string; + expiresInSeconds?: number; + }; + profile: ProfileInfo; + backstageIdentity: { + token: string; + expiresInSeconds?: number; + identity: BackstageUserIdentity; + }; +}; + class CustomAuthConnector implements AuthConnector { private readonly sessionTransform: (response: any) => Promise; 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 0f320e97e6..651113fd4d 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 @@ -16,7 +16,6 @@ import { BackstageIdentityResponse, - BackstageUserIdentity, ProfileInfo, } from '@backstage/core-plugin-api'; import { OAuthApiCreateOptions } from '../types.ts'; @@ -39,24 +38,6 @@ export type OAuth2Session = { 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