Merge pull request #2393 from spotify/rugvip/oauth
core-api: refactor most auth implementations to use the OAuth2 one
This commit is contained in:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user