updates after code review

Signed-off-by: Gasan Guseinov <gasan.guseinov@ing.com>
This commit is contained in:
Gasan Guseinov
2025-01-08 18:36:16 +01:00
parent fe015869b3
commit 0553465f7d
12 changed files with 192 additions and 163 deletions
+14 -2
View File
@@ -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.
+31 -23
View File
@@ -296,11 +296,26 @@ export type AuthApiCreateOptions = {
// @public
export type AuthConnector<AuthSession> = {
createSession(options: CreateSessionOptions): Promise<AuthSession>;
refreshSession(scopes?: Set<string>): Promise<AuthSession>;
createSession(
options: AuthConnectorCreateSessionOptions,
): Promise<AuthSession>;
refreshSession(
options?: AuthConnectorRefreshSessionOptions,
): Promise<AuthSession>;
removeSession(): Promise<void>;
};
// @public (undocumented)
export type AuthConnectorCreateSessionOptions = {
scopes: Set<string>;
instantPopup?: boolean;
};
// @public (undocumented)
export type AuthConnectorRefreshSessionOptions = {
scopes: Set<string>;
};
// @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<string>;
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<OAuth2Session>;
authConnector?: AuthConnector<OAuth2Session>;
};
// @public
@@ -627,6 +625,19 @@ export type OneLoginAuthCreateOptions = {
provider?: AuthProviderInfo;
};
// @public
export function openLoginPopup(
options: OpenLoginPopupOptions,
): Promise<unknown>;
// @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<void>;
}
// @public
export function showLoginPopup(options: LoginPopupOptions): Promise<any>;
// @public
export type SignInPageProps = PropsWithChildren<{
onSignInSuccess(identityApi: IdentityApi): void;
@@ -44,9 +44,7 @@ import { OAuthApiCreateOptions } from '../types';
export type OAuth2CreateOptions = OAuthApiCreateOptions & {
scopeTransform?: (scopes: string[]) => string[];
popupOptions?: PopupOptions;
authConnectorFactory?: (
opts: OAuth2CreateOptions,
) => AuthConnector<OAuth2Session>;
authConnector?: AuthConnector<OAuth2Session>;
};
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,
+7 -4
View File
@@ -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';
@@ -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<any>(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,
@@ -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<AuthSession>
this.popupOptions = popupOptions;
}
async createSession(options: CreateSessionOptions): Promise<AuthSession> {
async createSession(
options: AuthConnectorCreateSessionOptions,
): Promise<AuthSession> {
if (options.instantPopup) {
if (this.enableExperimentalRedirectFlow) {
return this.executeRedirect(options.scopes);
@@ -133,11 +140,13 @@ export class DefaultAuthConnector<AuthSession>
return this.authRequester(options.scopes);
}
async refreshSession(scopes?: Set<string>): Promise<any> {
async refreshSession(
options?: AuthConnectorRefreshSessionOptions,
): Promise<any> {
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<AuthSession>
? 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,
});
@@ -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<DirectAuthResponse> {
async createSession(): Promise<DirectAuthResponse> {
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,
@@ -17,11 +17,18 @@
/**
* @public
*/
export type CreateSessionOptions = {
export type AuthConnectorCreateSessionOptions = {
scopes: Set<string>;
instantPopup?: boolean;
};
/**
* @public
*/
export type AuthConnectorRefreshSessionOptions = {
scopes: Set<string>;
};
/**
* 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<AuthSession> = {
createSession(options: CreateSessionOptions): Promise<AuthSession>;
refreshSession(scopes?: Set<string>): Promise<AuthSession>;
createSession(
options: AuthConnectorCreateSessionOptions,
): Promise<AuthSession>;
refreshSession(
options?: AuthConnectorRefreshSessionOptions,
): Promise<AuthSession>;
removeSession(): Promise<void>;
};
@@ -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<string> }) => 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<string>) => ({
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<string>) => ({
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<string>) => ({
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,
@@ -137,9 +137,9 @@ export class RefreshingAuthSessionManager<T> implements SessionManager<T> {
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;
@@ -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',
+7 -8
View File
@@ -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<any> {
export function openLoginPopup(
options: OpenLoginPopupOptions,
): Promise<unknown> {
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<any> {
if (event.source !== popup) {
return;
}
if (event.origin !== options.origin) {
if (event.origin !== origin) {
return;
}
const { data } = event;