Merge pull request #28004 from gusega/custom_auth_connector
custom AuthConnector for OAuth2
This commit is contained in:
@@ -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.
|
||||
@@ -294,6 +294,28 @@ export type AuthApiCreateOptions = {
|
||||
configApi?: ConfigApi;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type AuthConnector<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[];
|
||||
@@ -523,7 +545,9 @@ export class OAuth2
|
||||
SessionApi
|
||||
{
|
||||
// (undocumented)
|
||||
static create(options: OAuth2CreateOptions): OAuth2;
|
||||
static create(
|
||||
options: OAuth2CreateOptions | OAuth2CreateOptionsWithAuthConnector,
|
||||
): OAuth2;
|
||||
// (undocumented)
|
||||
getAccessToken(
|
||||
scope?: string | string[],
|
||||
@@ -538,6 +562,13 @@ export class OAuth2
|
||||
// (undocumented)
|
||||
getProfile(options?: AuthRequestOptions): Promise<ProfileInfo | undefined>;
|
||||
// (undocumented)
|
||||
static normalizeScopes(
|
||||
scopes?: string | string[],
|
||||
options?: {
|
||||
scopeTransform: (scopes: string[]) => string[];
|
||||
},
|
||||
): Set<string>;
|
||||
// (undocumented)
|
||||
sessionState$(): Observable<SessionState>;
|
||||
// (undocumented)
|
||||
signIn(): Promise<void>;
|
||||
@@ -551,6 +582,13 @@ export type OAuth2CreateOptions = OAuthApiCreateOptions & {
|
||||
popupOptions?: PopupOptions;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type OAuth2CreateOptionsWithAuthConnector = {
|
||||
scopeTransform?: (scopes: string[]) => string[];
|
||||
defaultScopes?: string[];
|
||||
authConnector: AuthConnector<OAuth2Session>;
|
||||
};
|
||||
|
||||
// @public
|
||||
export type OAuth2Session = {
|
||||
providerInfo: {
|
||||
@@ -600,6 +638,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?:
|
||||
|
||||
@@ -18,6 +18,13 @@ import OAuth2 from './OAuth2';
|
||||
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
|
||||
import { UrlPatternDiscovery } from '../../DiscoveryApi';
|
||||
import { mockApis } from '@backstage/test-utils';
|
||||
import {
|
||||
OAuth2Session,
|
||||
AuthConnector,
|
||||
AuthConnectorRefreshSessionOptions,
|
||||
openLoginPopup,
|
||||
OAuth2CreateOptionsWithAuthConnector,
|
||||
} from '../../../../index';
|
||||
|
||||
const theFuture = new Date(Date.now() + 3600000);
|
||||
const thePast = new Date(Date.now() - 10);
|
||||
@@ -37,6 +44,25 @@ jest.mock('../../../../lib/AuthSessionManager', () => ({
|
||||
|
||||
const configApi = mockApis.config();
|
||||
|
||||
class CustomAuthConnector implements AuthConnector<OAuth2Session> {
|
||||
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<any> {}
|
||||
|
||||
async removeSession(): Promise<void> {}
|
||||
}
|
||||
|
||||
describe('OAuth2', () => {
|
||||
it('should get refreshed access token', async () => {
|
||||
getSession = jest.fn().mockResolvedValue({
|
||||
@@ -215,4 +241,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']) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -14,35 +14,32 @@
|
||||
* 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,
|
||||
BackstageUserIdentity,
|
||||
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';
|
||||
import {
|
||||
OAuth2CreateOptions,
|
||||
OAuth2CreateOptionsWithAuthConnector,
|
||||
OAuth2Session,
|
||||
} from './types';
|
||||
|
||||
/**
|
||||
* OAuth2 create options.
|
||||
* @public
|
||||
*/
|
||||
export type OAuth2CreateOptions = OAuthApiCreateOptions & {
|
||||
scopeTransform?: (scopes: string[]) => string[];
|
||||
popupOptions?: PopupOptions;
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'oauth2',
|
||||
title: 'Your Identity Provider',
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
export type OAuth2Response = {
|
||||
@@ -60,12 +57,6 @@ export type OAuth2Response = {
|
||||
};
|
||||
};
|
||||
|
||||
const DEFAULT_PROVIDER = {
|
||||
id: 'oauth2',
|
||||
title: 'Your Identity Provider',
|
||||
icon: () => null,
|
||||
};
|
||||
|
||||
/**
|
||||
* Implements a generic OAuth2 flow for auth.
|
||||
*
|
||||
@@ -79,19 +70,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,
|
||||
@@ -106,10 +101,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,
|
||||
@@ -128,6 +122,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,
|
||||
@@ -182,7 +184,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,
|
||||
@@ -210,9 +214,12 @@ export default class OAuth2
|
||||
return session?.profile;
|
||||
}
|
||||
|
||||
private static normalizeScopes(
|
||||
scopeTransform: (scopes: string[]) => string[],
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
public static normalizeScopes(
|
||||
scopes?: string | string[],
|
||||
options?: { scopeTransform: (scopes: string[]) => string[] },
|
||||
): Set<string> {
|
||||
if (!scopes) {
|
||||
return new Set();
|
||||
@@ -222,6 +229,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);
|
||||
}
|
||||
}
|
||||
|
||||
+141
@@ -0,0 +1,141 @@
|
||||
/*
|
||||
* 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,
|
||||
} from '../../../../index';
|
||||
import { waitFor } from '@testing-library/react';
|
||||
import { BackstageUserIdentity, ProfileInfo } from '@backstage/core-plugin-api';
|
||||
|
||||
const scopeTransform = (x: string[]) => x;
|
||||
|
||||
type Options = {
|
||||
/**
|
||||
* Function used to transform an auth response into the session type.
|
||||
*/
|
||||
sessionTransform?(response: any): OAuth2Session | Promise<OAuth2Session>;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<OAuth2Session> {
|
||||
private readonly sessionTransform: (response: any) => Promise<OAuth2Session>;
|
||||
|
||||
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<any> {}
|
||||
|
||||
async removeSession(): Promise<void> {}
|
||||
}
|
||||
|
||||
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(res.providerInfo.scope, {
|
||||
scopeTransform,
|
||||
}),
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -15,11 +15,12 @@
|
||||
*/
|
||||
|
||||
import {
|
||||
ProfileInfo,
|
||||
BackstageIdentityResponse,
|
||||
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 +37,22 @@ export type OAuth2Session = {
|
||||
profile: ProfileInfo;
|
||||
backstageIdentity?: BackstageIdentityResponse;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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<OAuth2Session>;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,18 +14,34 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type CreateSessionOptions = {
|
||||
/**
|
||||
* @public
|
||||
*/
|
||||
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.
|
||||
*
|
||||
* @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>;
|
||||
};
|
||||
|
||||
|
||||
+22
-15
@@ -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',
|
||||
|
||||
@@ -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<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,
|
||||
@@ -92,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;
|
||||
|
||||
Reference in New Issue
Block a user