Merge pull request #2393 from spotify/rugvip/oauth

core-api: refactor most auth implementations to use the OAuth2 one
This commit is contained in:
Fredrik Adelöw
2020-09-10 11:25:16 +02:00
committed by GitHub
21 changed files with 329 additions and 956 deletions
@@ -1,36 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 Auth0Auth from './Auth0Auth';
describe('Auth0Auth', () => {
it('should normalize scope', () => {
const tests = [
{
arguments: ['read_user api write_repository'],
expect: new Set(['read_user', 'api', 'write_repository']),
},
{
arguments: ['read_repository sudo'],
expect: new Set(['read_repository', 'sudo']),
},
];
for (const test of tests) {
expect(Auth0Auth.normalizeScopes(...test.arguments)).toEqual(test.expect);
}
});
});
@@ -15,26 +15,13 @@
*/
import Auth0Icon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { Auth0Session } from './types';
import {
OpenIdConnectApi,
ProfileInfoApi,
ProfileInfo,
SessionStateApi,
SessionState,
BackstageIdentityApi,
AuthRequestOptions,
BackstageIdentity,
} from '../../../definitions/auth';
import { auth0AuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
@@ -44,106 +31,27 @@ type CreateOptions = {
provider?: AuthProvider & { id: string };
};
export type Auth0AuthResponse = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'auth0',
title: 'Auth0',
icon: Auth0Icon,
};
class Auth0Auth
implements
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
class Auth0Auth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
}: CreateOptions): typeof auth0AuthApiRef.T {
return OAuth2.create({
discoveryApi,
environment,
oauthRequestApi,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: Auth0AuthResponse): Auth0Session {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: Auth0Auth.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
environment,
defaultScopes: ['openid', `email`, `profile`],
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set(['openid', `email`, `profile`]),
sessionScopes: (session: Auth0Session) => session.providerInfo.scopes,
sessionShouldRefresh: (session: Auth0Session) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
return new Auth0Auth(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<Auth0Session>) {}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scope?: string | string[]): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope)
? scope
: scope.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
export default Auth0Auth;
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './types';
export { default as Auth0Auth } from './Auth0Auth';
@@ -1,28 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type Auth0Session = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -14,33 +14,37 @@
* limitations under the License.
*/
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
import GitlabAuth from './GitlabAuth';
describe('GitlabAuth', () => {
it('should get access token', async () => {
const getSession = jest
.fn()
.mockResolvedValue({ providerInfo: { accessToken: 'access-token' } });
const gitlabAuth = new GitlabAuth({ getSession } as any);
const getSession = jest.fn();
expect(await gitlabAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('GitlabAuth', () => {
afterEach(() => {
jest.resetAllMocks();
});
it('should normalize scope', () => {
const tests = [
{
arguments: ['read_user api write_repository'],
expect: new Set(['read_user', 'api', 'write_repository']),
},
{
arguments: ['read_repository sudo'],
expect: new Set(['read_repository', 'sudo']),
},
];
it.each([
[
'read_user api write_repository',
['read_user', 'api', 'write_repository'],
],
['read_repository sudo', ['read_repository', 'sudo']],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
const googleAuth = GitlabAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
for (const test of tests) {
expect(GitlabAuth.normalizeScope(...test.arguments)).toEqual(test.expect);
}
googleAuth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -15,24 +15,13 @@
*/
import GitlabIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GitlabSession } from './types';
import {
OAuthApi,
SessionStateApi,
SessionState,
ProfileInfo,
BackstageIdentity,
AuthRequestOptions,
} from '../../../definitions/auth';
import { gitlabAuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { StaticAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
@@ -42,94 +31,26 @@ type CreateOptions = {
provider?: AuthProvider & { id: string };
};
export type GitlabAuthResponse = {
providerInfo: {
accessToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'gitlab',
title: 'Gitlab',
icon: GitlabIcon,
};
class GitlabAuth implements OAuthApi, SessionStateApi {
class GitlabAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
}: CreateOptions): typeof gitlabAuthApiRef.T {
return OAuth2.create({
discoveryApi,
environment,
provider,
oauthRequestApi,
sessionTransform(res: GitlabAuthResponse): GitlabSession {
return {
...res,
providerInfo: {
accessToken: res.providerInfo.accessToken,
scopes: GitlabAuth.normalizeScope(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
provider,
environment,
defaultScopes: ['read_user'],
});
const sessionManager = new StaticAuthSessionManager({
connector,
defaultScopes: new Set(['read_user']),
sessionScopes: (session: GitlabSession) => session.providerInfo.scopes,
});
return new GitlabAuth(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<GitlabSession>) {}
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
const session = await this.sessionManager.getSession({
...options,
scopes: GitlabAuth.normalizeScope(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
async logout() {
await this.sessionManager.removeSession();
}
static normalizeScope(scope?: string): Set<string> {
if (!scope) {
return new Set();
}
const scopeList = Array.isArray(scope) ? scope : scope.split(' ');
return new Set(scopeList);
}
}
export default GitlabAuth;
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './types';
export { default as GitlabAuth } from './GitlabAuth';
@@ -1,27 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type GitlabSession = {
providerInfo: {
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -15,101 +15,23 @@
*/
import GoogleAuth from './GoogleAuth';
const theFuture = new Date(Date.now() + 3600000);
const thePast = new Date(Date.now() - 10);
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
const PREFIX = 'https://www.googleapis.com/auth/';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('GoogleAuth', () => {
it('should get refreshed access token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get refreshed id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getIdToken()).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get optional id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const googleAuth = new GoogleAuth({ getSession } as any);
expect(await googleAuth.getIdToken({ optional: true })).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should share popup closed errors', async () => {
const error = new Error('NOPE');
error.name = 'RejectedError';
const getSession = jest
.fn()
.mockResolvedValueOnce({
providerInfo: {
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`${PREFIX}not-enough`]),
},
})
.mockRejectedValue(error);
const googleAuth = new GoogleAuth({ getSession } as any);
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
await expect(googleAuth.getAccessToken()).resolves.toBe('access-token');
const promise1 = googleAuth.getAccessToken('more');
const promise2 = googleAuth.getAccessToken('more');
await expect(promise1).rejects.toBe(error);
await expect(promise2).rejects.toBe(error);
expect(getSession).toBeCalledTimes(3);
});
it('should wait for all session refreshes', async () => {
const initialSession = {
providerInfo: {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
},
};
const getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
providerInfo: {
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
},
});
const googleAuth = new GoogleAuth({ getSession } as any);
// Grab the expired session first
await expect(googleAuth.getIdToken()).resolves.toBe('token1');
expect(getSession).toBeCalledTimes(1);
initialSession.providerInfo.expiresAt = thePast;
const promise1 = googleAuth.getIdToken();
const promise2 = googleAuth.getIdToken();
const promise3 = googleAuth.getIdToken();
await expect(promise1).resolves.toBe('token2');
await expect(promise2).resolves.toBe('token2');
await expect(promise3).resolves.toBe('token2');
expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client
afterEach(() => {
jest.resetAllMocks();
});
it.each([
@@ -136,6 +58,12 @@ describe('GoogleAuth', () => {
[`${PREFIX}profile`, [`${PREFIX}profile`]],
[`${PREFIX}openid`, [`${PREFIX}openid`]],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
expect(GoogleAuth.normalizeScopes(scope)).toEqual(new Set(scopes));
const googleAuth = GoogleAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
googleAuth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -15,27 +15,13 @@
*/
import GoogleIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { GoogleSession } from './types';
import {
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
ProfileInfo,
SessionStateApi,
SessionState,
BackstageIdentityApi,
AuthRequestOptions,
BackstageIdentity,
} from '../../../definitions/auth';
import { googleAuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
@@ -45,140 +31,49 @@ type CreateOptions = {
provider?: AuthProvider & { id: string };
};
export type GoogleAuthResponse = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'google',
title: 'Google',
icon: GoogleIcon,
};
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
class GoogleAuth
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
class GoogleAuth {
static create({
discoveryApi,
oauthRequestApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
environment,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: GoogleAuthResponse): GoogleSession {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: GoogleAuth.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
}: CreateOptions): typeof googleAuthApiRef.T {
const SCOPE_PREFIX = 'https://www.googleapis.com/auth/';
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set([
return OAuth2.create({
discoveryApi,
oauthRequestApi,
provider,
environment,
defaultScopes: [
'openid',
`${SCOPE_PREFIX}userinfo.email`,
`${SCOPE_PREFIX}userinfo.profile`,
]),
sessionScopes: (session: GoogleSession) => session.providerInfo.scopes,
sessionShouldRefresh: (session: GoogleSession) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
],
scopeTransform(scopes: string[]) {
return scopes.map(scope => {
if (scope === 'openid') {
return scope;
}
if (scope === 'profile' || scope === 'email') {
return `${SCOPE_PREFIX}userinfo.${scope}`;
}
if (scope.startsWith(SCOPE_PREFIX)) {
return scope;
}
return `${SCOPE_PREFIX}${scope}`;
});
},
});
return new GoogleAuth(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<GoogleSession>) {}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const session = await this.sessionManager.getSession({
...options,
scopes: GoogleAuth.normalizeScopes(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s]/).filter(Boolean);
const normalizedScopes = scopeList.map(scope => {
if (scope === 'openid') {
return scope;
}
if (scope === 'profile' || scope === 'email') {
return `${SCOPE_PREFIX}userinfo.${scope}`;
}
if (scope.startsWith(SCOPE_PREFIX)) {
return scope;
}
return `${SCOPE_PREFIX}${scope}`;
});
return new Set(normalizedScopes);
}
}
export default GoogleAuth;
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './types';
export { default as GoogleAuth } from './GoogleAuth';
@@ -1,28 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type GoogleSession = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -15,29 +15,14 @@
*/
import MicrosoftIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { MicrosoftSession } from './types';
import {
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
ProfileInfo,
SessionStateApi,
SessionState,
BackstageIdentityApi,
AuthRequestOptions,
BackstageIdentity,
} from '../../../definitions/auth';
import { microsoftAuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
@@ -47,126 +32,33 @@ type CreateOptions = {
provider?: AuthProvider & { id: string };
};
export type MicrosoftAuthResponse = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'microsoft',
title: 'Microsoft',
icon: MicrosoftIcon,
};
class MicrosoftAuth
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
class MicrosoftAuth {
static create({
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
discoveryApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
}: CreateOptions): typeof microsoftAuthApiRef.T {
return OAuth2.create({
discoveryApi,
environment,
oauthRequestApi,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: MicrosoftAuthResponse): MicrosoftSession {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: MicrosoftAuth.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
},
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set([
environment,
defaultScopes: [
'openid',
'offline_access',
'profile',
'email',
'User.Read',
]),
sessionScopes: (session: MicrosoftSession) => session.providerInfo.scopes,
sessionShouldRefresh: (session: MicrosoftSession) => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
],
});
return new MicrosoftAuth(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(
private readonly sessionManager: SessionManager<MicrosoftSession>,
) {}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const session = await this.sessionManager.getSession({
...options,
scopes: MicrosoftAuth.normalizeScopes(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
}
}
export default MicrosoftAuth;
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './types';
export { default as MicrosoftAuth } from './MicrosoftAuth';
@@ -1,28 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type MicrosoftSession = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
@@ -0,0 +1,152 @@
/*
* Copyright 2020 Spotify AB
*
* 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';
const theFuture = new Date(Date.now() + 3600000);
const thePast = new Date(Date.now() - 10);
const PREFIX = 'https://www.googleapis.com/auth/';
const scopeTransform = (x: string[]) => x;
describe('OAuth2', () => {
it('should get refreshed access token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getAccessToken('my-scope my-scope2')).toBe(
'access-token',
);
expect(getSession).toBeCalledTimes(1);
expect(getSession.mock.calls[0][0].scopes).toEqual(
new Set(['my-scope', 'my-scope2']),
);
});
it('should transform scopes', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform: scopes => scopes.map(scope => `my-prefix/${scope}`),
});
expect(await oauth2.getAccessToken('my-scope')).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
expect(getSession.mock.calls[0][0].scopes).toEqual(
new Set(['my-prefix/my-scope']),
);
});
it('should get refreshed id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getIdToken()).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get optional id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
expect(await oauth2.getIdToken({ optional: true })).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should share popup closed errors', async () => {
const error = new Error('NOPE');
error.name = 'RejectedError';
const getSession = jest
.fn()
.mockResolvedValueOnce({
providerInfo: {
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`${PREFIX}not-enough`]),
},
})
.mockRejectedValue(error);
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
await expect(oauth2.getAccessToken()).resolves.toBe('access-token');
const promise1 = oauth2.getAccessToken('more');
const promise2 = oauth2.getAccessToken('more');
await expect(promise1).rejects.toBe(error);
await expect(promise2).rejects.toBe(error);
expect(getSession).toBeCalledTimes(3);
});
it('should wait for all session refreshes', async () => {
const initialSession = {
providerInfo: {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
},
};
const getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
providerInfo: {
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
},
});
const oauth2 = new OAuth2({
sessionManager: { getSession } as any,
scopeTransform,
});
// Grab the expired session first
await expect(oauth2.getIdToken()).resolves.toBe('token1');
expect(getSession).toBeCalledTimes(1);
initialSession.providerInfo.expiresAt = thePast;
const promise1 = oauth2.getIdToken();
const promise2 = oauth2.getIdToken();
const promise3 = oauth2.getIdToken();
await expect(promise1).resolves.toBe('token2');
await expect(promise2).resolves.toBe('token2');
await expect(promise3).resolves.toBe('token2');
expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client
});
});
@@ -33,9 +33,15 @@ import {
ProfileInfoApi,
SessionState,
SessionStateApi,
BackstageIdentityApi,
} from '../../../definitions/auth';
import { OAuth2Session } from './types';
type Options = {
sessionManager: SessionManager<OAuth2Session>;
scopeTransform: (scopes: string[]) => string[];
};
type CreateOptions = {
discoveryApi: DiscoveryApi;
oauthRequestApi: OAuthRequestApi;
@@ -43,6 +49,7 @@ type CreateOptions = {
environment?: string;
provider?: AuthProvider & { id: string };
defaultScopes?: string[];
scopeTransform?: (scopes: string[]) => string[];
};
export type OAuth2Response = {
@@ -63,13 +70,19 @@ const DEFAULT_PROVIDER = {
};
class OAuth2
implements OAuthApi, OpenIdConnectApi, ProfileInfoApi, SessionStateApi {
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
defaultScopes = [],
scopeTransform = x => x,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
discoveryApi,
@@ -82,7 +95,10 @@ class OAuth2
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: OAuth2.normalizeScopes(res.providerInfo.scope),
scopes: OAuth2.normalizeScopes(
scopeTransform,
res.providerInfo.scope,
),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
@@ -102,20 +118,26 @@ class OAuth2
},
});
return new OAuth2(sessionManager);
return new OAuth2({ sessionManager, scopeTransform });
}
private readonly sessionManager: SessionManager<OAuth2Session>;
private readonly scopeTransform: (scopes: string[]) => string[];
constructor(options: Options) {
this.sessionManager = options.sessionManager;
this.scopeTransform = options.scopeTransform;
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<OAuth2Session>) {}
async getAccessToken(
scope?: string | string[],
options?: AuthRequestOptions,
) {
const normalizedScopes = OAuth2.normalizeScopes(scope);
const normalizedScopes = OAuth2.normalizeScopes(this.scopeTransform, scope);
const session = await this.sessionManager.getSession({
...options,
scopes: normalizedScopes,
@@ -144,16 +166,19 @@ class OAuth2
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
private static normalizeScopes(
scopeTransform: (scopes: string[]) => string[],
scopes?: string | string[],
): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s]/).filter(Boolean);
: scopes.split(/[\s|,]/).filter(Boolean);
return new Set(scopeList);
return new Set(scopeTransform(scopeList));
}
}
@@ -13,102 +13,25 @@
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import OktaAuth from './OktaAuth';
const theFuture = new Date(Date.now() + 3600000);
const thePast = new Date(Date.now() - 10);
import OktaAuth from './OktaAuth';
import MockOAuthApi from '../../OAuthRequestApi/MockOAuthApi';
import { UrlPatternDiscovery } from '../../DiscoveryApi';
const PREFIX = 'okta.';
const getSession = jest.fn();
jest.mock('../../../../lib/AuthSessionManager', () => ({
...(jest.requireActual('../../../../lib/AuthSessionManager') as any),
RefreshingAuthSessionManager: class {
getSession = getSession;
},
}));
describe('OktaAuth', () => {
it('should get refreshed access token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { accessToken: 'access-token', expiresAt: theFuture },
});
const oktaAuth = new OktaAuth({ getSession } as any);
expect(await oktaAuth.getAccessToken()).toBe('access-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get refreshed id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oktaAuth = new OktaAuth({ getSession } as any);
expect(await oktaAuth.getIdToken()).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should get optional id token', async () => {
const getSession = jest.fn().mockResolvedValue({
providerInfo: { idToken: 'id-token', expiresAt: theFuture },
});
const oktaAuth = new OktaAuth({ getSession } as any);
expect(await oktaAuth.getIdToken({ optional: true })).toBe('id-token');
expect(getSession).toBeCalledTimes(1);
});
it('should share popup closed errors', async () => {
const error = new Error('NOPE');
error.name = 'RejectedError';
const getSession = jest
.fn()
.mockResolvedValueOnce({
providerInfo: {
accessToken: 'access-token',
expiresAt: theFuture,
scopes: new Set([`not-a-scope`]),
},
})
.mockRejectedValue(error);
const oktaAuth = new OktaAuth({ getSession } as any);
// Make sure we have a session before we do the double request, so that we get past the !this.currentSession check
await expect(oktaAuth.getAccessToken()).resolves.toBe('access-token');
const promise1 = oktaAuth.getAccessToken('more');
const promise2 = oktaAuth.getAccessToken('more');
await expect(promise1).rejects.toBe(error);
await expect(promise2).rejects.toBe(error);
expect(getSession).toBeCalledTimes(3);
});
it('should wait for all session refreshes', async () => {
const initialSession = {
providerInfo: {
idToken: 'token1',
expiresAt: theFuture,
scopes: new Set(),
},
};
const getSession = jest
.fn()
.mockResolvedValueOnce(initialSession)
.mockResolvedValue({
providerInfo: {
idToken: 'token2',
expiresAt: theFuture,
scopes: new Set(),
},
});
const oktaAuth = new OktaAuth({ getSession } as any);
// Grab the expired session first
await expect(oktaAuth.getIdToken()).resolves.toBe('token1');
expect(getSession).toBeCalledTimes(1);
initialSession.providerInfo.expiresAt = thePast;
const promise1 = oktaAuth.getIdToken();
const promise2 = oktaAuth.getIdToken();
const promise3 = oktaAuth.getIdToken();
await expect(promise1).resolves.toBe('token2');
await expect(promise2).resolves.toBe('token2');
await expect(promise3).resolves.toBe('token2');
expect(getSession).toBeCalledTimes(4); // De-duping of session requests happens in client
afterEach(() => {
jest.resetAllMocks();
});
it.each([
@@ -127,6 +50,12 @@ describe('OktaAuth', () => {
[`${PREFIX}profile`, [`${PREFIX}profile`]],
[`${PREFIX}openid`, [`${PREFIX}openid`]],
])(`should normalize scopes correctly - %p`, (scope, scopes) => {
expect(OktaAuth.normalizeScopes(scope)).toEqual(new Set(scopes));
const auth = OktaAuth.create({
oauthRequestApi: new MockOAuthApi(),
discoveryApi: UrlPatternDiscovery.compile('http://example.com'),
});
auth.getAccessToken(scope);
expect(getSession).toHaveBeenCalledWith({ scopes: new Set(scopes) });
});
});
@@ -15,27 +15,13 @@
*/
import OktaIcon from '@material-ui/icons/AcUnit';
import { DefaultAuthConnector } from '../../../../lib/AuthConnector';
import { OktaSession } from './types';
import {
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
ProfileInfo,
SessionStateApi,
SessionState,
BackstageIdentityApi,
AuthRequestOptions,
BackstageIdentity,
} from '../../../definitions/auth';
import { oktaAuthApiRef } from '../../../definitions/auth';
import {
OAuthRequestApi,
AuthProvider,
DiscoveryApi,
} from '../../../definitions';
import { SessionManager } from '../../../../lib/AuthSessionManager/types';
import { RefreshingAuthSessionManager } from '../../../../lib/AuthSessionManager';
import { Observable } from '../../../../types';
import { OAuth2 } from '../oauth2';
type CreateOptions = {
discoveryApi: DiscoveryApi;
@@ -45,17 +31,6 @@ type CreateOptions = {
provider?: AuthProvider & { id: string };
};
export type OktaAuthResponse = {
providerInfo: {
accessToken: string;
idToken: string;
scope: string;
expiresInSeconds: number;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};
const DEFAULT_PROVIDER = {
id: 'okta',
title: 'Okta',
@@ -74,110 +49,33 @@ const OKTA_OIDC_SCOPES: Set<String> = new Set([
const OKTA_SCOPE_PREFIX: string = 'okta.';
class OktaAuth
implements
OAuthApi,
OpenIdConnectApi,
ProfileInfoApi,
BackstageIdentityApi,
SessionStateApi {
class OktaAuth {
static create({
discoveryApi,
environment = 'development',
provider = DEFAULT_PROVIDER,
oauthRequestApi,
}: CreateOptions) {
const connector = new DefaultAuthConnector({
}: CreateOptions): typeof oktaAuthApiRef.T {
return OAuth2.create({
discoveryApi,
environment,
oauthRequestApi,
provider,
oauthRequestApi: oauthRequestApi,
sessionTransform(res: OktaAuthResponse): OktaSession {
return {
...res,
providerInfo: {
idToken: res.providerInfo.idToken,
accessToken: res.providerInfo.accessToken,
scopes: OktaAuth.normalizeScopes(res.providerInfo.scope),
expiresAt: new Date(
Date.now() + res.providerInfo.expiresInSeconds * 1000,
),
},
};
environment,
defaultScopes: ['openid', 'email', 'profile', 'offline_access'],
scopeTransform(scopes) {
return scopes.map(scope => {
if (OKTA_OIDC_SCOPES.has(scope)) {
return scope;
}
if (scope.startsWith(OKTA_SCOPE_PREFIX)) {
return scope;
}
return `${OKTA_SCOPE_PREFIX}${scope}`;
});
},
});
const sessionManager = new RefreshingAuthSessionManager({
connector,
defaultScopes: new Set(['openid', 'email', 'profile', 'offline_access']),
sessionScopes: session => session.scopes,
sessionShouldRefresh: session => {
const expiresInSec =
(session.providerInfo.expiresAt.getTime() - Date.now()) / 1000;
return expiresInSec < 60 * 5;
},
});
return new OktaAuth(sessionManager);
}
sessionState$(): Observable<SessionState> {
return this.sessionManager.sessionState$();
}
constructor(private readonly sessionManager: SessionManager<OktaSession>) {}
async getAccessToken(scope?: string, options?: AuthRequestOptions) {
const session = await this.sessionManager.getSession({
...options,
scopes: OktaAuth.normalizeScopes(scope),
});
return session?.providerInfo.accessToken ?? '';
}
async getIdToken(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.providerInfo.idToken ?? '';
}
async logout() {
await this.sessionManager.removeSession();
}
async getBackstageIdentity(
options: AuthRequestOptions = {},
): Promise<BackstageIdentity | undefined> {
const session = await this.sessionManager.getSession(options);
return session?.backstageIdentity;
}
async getProfile(options: AuthRequestOptions = {}) {
const session = await this.sessionManager.getSession(options);
return session?.profile;
}
static normalizeScopes(scopes?: string | string[]): Set<string> {
if (!scopes) {
return new Set();
}
const scopeList = Array.isArray(scopes)
? scopes
: scopes.split(/[\s|,]/).filter(Boolean);
const normalizedScopes = scopeList.map(scope => {
if (OKTA_OIDC_SCOPES.has(scope)) {
return scope;
}
if (scope.startsWith(OKTA_SCOPE_PREFIX)) {
return scope;
}
return `${OKTA_SCOPE_PREFIX}${scope}`;
});
return new Set(normalizedScopes);
}
}
@@ -14,5 +14,4 @@
* limitations under the License.
*/
export * from './types';
export { default as OktaAuth } from './OktaAuth';
@@ -1,27 +0,0 @@
/*
* Copyright 2020 Spotify AB
*
* 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 { ProfileInfo, BackstageIdentity } from '../../../definitions';
export type OktaSession = {
providerInfo: {
idToken: string;
accessToken: string;
scopes: Set<string>;
expiresAt: Date;
};
profile: ProfileInfo;
backstageIdentity: BackstageIdentity;
};