Merge pull request #28004 from gusega/custom_auth_connector

custom AuthConnector for OAuth2
This commit is contained in:
Patrik Oldsberg
2025-05-07 18:59:09 +02:00
committed by GitHub
15 changed files with 431 additions and 109 deletions
+17
View File
@@ -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.
+52 -1
View File
@@ -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);
}
}
@@ -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>;
};
+7
View File
@@ -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>;
};
@@ -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',
+11 -8
View File
@@ -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;