diff --git a/.changeset/brave-pandas-beam.md b/.changeset/brave-pandas-beam.md new file mode 100644 index 0000000000000..8dd525d7492b5 --- /dev/null +++ b/.changeset/brave-pandas-beam.md @@ -0,0 +1,17 @@ +--- +'@backstage/core-app-api': minor +--- + +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 3d2c44a148921..6af171ea4d0d4 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'; @@ -294,6 +295,28 @@ export type AuthApiCreateOptions = { configApi?: ConfigApi; }; +// @public +export type AuthConnector = { + 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[]; @@ -525,7 +548,9 @@ export class OAuth2 SessionApi { // (undocumented) - static create(options: OAuth2CreateOptions): OAuth2; + static create( + options: OAuth2CreateOptions | OAuth2CreateOptionsWithAuthConnector, + ): OAuth2; // (undocumented) getAccessToken( scope?: string | string[], @@ -540,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; @@ -553,6 +583,29 @@ export type OAuth2CreateOptions = OAuthApiCreateOptions & { popupOptions?: PopupOptions; }; +// @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 export type OAuth2Session = { providerInfo: { @@ -602,6 +655,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?: 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 a34589d746627..f662c47bda7d0 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 496333072489f..7318d303825e8 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,51 +14,26 @@ * limitations under the License. */ -import { - 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; -}; - -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', @@ -79,19 +54,23 @@ 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 = new DefaultAuthConnector({ + return new DefaultAuthConnector({ configApi, discoveryApi, environment, @@ -128,6 +107,14 @@ export default class OAuth2 }, popupOptions, }); + } + + static create( + options: OAuth2CreateOptions | OAuth2CreateOptionsWithAuthConnector, + ) { + const { defaultScopes = [], scopeTransform = x => x } = options; + + const connector = OAuth2.createAuthConnector(options); const sessionManager = new RefreshingAuthSessionManager({ connector, @@ -210,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 0000000000000..cfed4d4714b81 --- /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'; +import { waitFor } from '@testing-library/react'; + +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('OAuth2CustomAuthConnector', () => { + it('should use custom auth connector', 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); + + 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; + + 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 e51de63dbe948..0f320e97e64c1 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 a5e6b649e903c..a963b0cca91de 100644 --- a/packages/core-app-api/src/index.ts +++ b/packages/core-app-api/src/index.ts @@ -23,3 +23,10 @@ export * from './apis'; export * from './app'; export * from './routing'; +export type { + AuthConnector, + AuthConnectorCreateSessionOptions, + AuthConnectorRefreshSessionOptions, + OpenLoginPopupOptions, +} from './lib'; +export { openLoginPopup } 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 60c4a3b8d923f..192b1b7ac0c8a 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 ad1b25619d0eb..3671ddc6e60cf 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 200ba755acc9e..a45bfa0af98d2 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 3c01bb254fb0c..035760d0aa826 100644 --- a/packages/core-app-api/src/lib/AuthConnector/types.ts +++ b/packages/core-app-api/src/lib/AuthConnector/types.ts @@ -14,18 +14,34 @@ * limitations under the License. */ -export type CreateSessionOptions = { +/** + * @public + */ +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. + * + * @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 88000638e6b57..9afc710bddec6 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 414743ba9db55..216409a528076 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 50193cbdd66ed..448f4f727c8e1 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 b6c14d60c9282..e7ed84b0cfc3c 100644 --- a/packages/core-app-api/src/lib/loginPopup.ts +++ b/packages/core-app-api/src/lib/loginPopup.ts @@ -16,8 +16,10 @@ /** * Options used to open a login popup. + * + * @public */ -export type LoginPopupOptions = { +export type OpenLoginPopupOptions = { /** * The URL that the auth popup should point to */ @@ -28,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 */ @@ -65,14 +62,20 @@ 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 { +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, @@ -92,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;