Merge branch 'master' of github.com:spotify/backstage into shmidt-i/location-update-results
This commit is contained in:
@@ -27,14 +27,10 @@
|
||||
"yn": "^4.0.0",
|
||||
"passport": "^0.4.1",
|
||||
"passport-google-oauth20": "^2.0.0",
|
||||
"passport-oauth2-refresh": "^2.0.0",
|
||||
"passport-oauth2": "^1.5.0",
|
||||
"cookie-parser": "^1.4.5",
|
||||
"@types/passport-oauth2-refresh": "^1.1.1",
|
||||
"@types/passport": "^1.0.3",
|
||||
"@types/passport-google-oauth20": "^2.0.3",
|
||||
"@types/cookie-parser": "^1.4.2",
|
||||
"@types/passport-oauth2": "^1.4.9"
|
||||
"@types/cookie-parser": "^1.4.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@backstage/cli": "^0.1.1-alpha.6",
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
/*
|
||||
* 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 express, { CookieOptions } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import { AuthProviderRouteHandlers, OAuthProviderHandlers } from './types';
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
import { postMessageResponse, ensuresXRequestedWith } from './utils';
|
||||
|
||||
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
|
||||
export const TEN_MINUTES_MS = 600 * 1000;
|
||||
|
||||
export const verifyNonce = (req: express.Request, provider: string) => {
|
||||
const cookieNonce = req.cookies[`${provider}-nonce`];
|
||||
const stateNonce = req.query.state;
|
||||
|
||||
if (!cookieNonce || !stateNonce) {
|
||||
throw new Error('Missing nonce');
|
||||
}
|
||||
|
||||
if (cookieNonce !== stateNonce) {
|
||||
throw new Error('Invalid nonce');
|
||||
}
|
||||
};
|
||||
|
||||
export const setNonceCookie = (res: express.Response, provider: string) => {
|
||||
const nonce = crypto.randomBytes(16).toString('base64');
|
||||
|
||||
const options: CookieOptions = {
|
||||
maxAge: TEN_MINUTES_MS,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${provider}/handler`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(`${provider}-nonce`, nonce, options);
|
||||
|
||||
return nonce;
|
||||
};
|
||||
|
||||
export const setRefreshTokenCookie = (
|
||||
res: express.Response,
|
||||
provider: string,
|
||||
refreshToken: string,
|
||||
) => {
|
||||
const options: CookieOptions = {
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${provider}`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(`${provider}-refresh-token`, refreshToken, options);
|
||||
};
|
||||
|
||||
export const removeRefreshTokenCookie = (
|
||||
res: express.Response,
|
||||
provider: string,
|
||||
) => {
|
||||
const options: CookieOptions = {
|
||||
maxAge: 0,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${provider}`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(`${provider}-refresh-token`, '', options);
|
||||
};
|
||||
|
||||
export class OAuthProvider implements AuthProviderRouteHandlers {
|
||||
private readonly provider: string;
|
||||
private readonly providerHandlers: OAuthProviderHandlers;
|
||||
constructor(providerHandlers: OAuthProviderHandlers, provider: string) {
|
||||
this.provider = provider;
|
||||
this.providerHandlers = providerHandlers;
|
||||
}
|
||||
|
||||
async start(req: express.Request, res: express.Response): Promise<any> {
|
||||
// retrieve scopes from request
|
||||
const scope = req.query.scope?.toString() ?? '';
|
||||
|
||||
if (!scope) {
|
||||
throw new InputError('missing scope parameter');
|
||||
}
|
||||
|
||||
// set a nonce cookie before redirecting to oauth provider
|
||||
const nonce = setNonceCookie(res, this.provider);
|
||||
|
||||
const options = {
|
||||
scope,
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
state: nonce,
|
||||
};
|
||||
const { url, status } = await this.providerHandlers.start(req, options);
|
||||
|
||||
res.statusCode = status || 302;
|
||||
res.setHeader('Location', url);
|
||||
res.setHeader('Content-Length', '0');
|
||||
res.end();
|
||||
}
|
||||
|
||||
async frameHandler(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
): Promise<any> {
|
||||
try {
|
||||
// verify nonce cookie and state cookie on callback
|
||||
verifyNonce(req, this.provider);
|
||||
|
||||
const { user, info } = await this.providerHandlers.handler(req);
|
||||
|
||||
// throw error if missing refresh token
|
||||
const { refreshToken } = info;
|
||||
if (!refreshToken) {
|
||||
throw new Error('Missing refresh token');
|
||||
}
|
||||
|
||||
// set new refresh token
|
||||
setRefreshTokenCookie(res, this.provider, refreshToken);
|
||||
|
||||
// post message back to popup if successful
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
payload: user,
|
||||
});
|
||||
} catch (error) {
|
||||
// post error message back to popup if failure
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
error: {
|
||||
name: error.name,
|
||||
message: error.message,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async logout(req: express.Request, res: express.Response): Promise<any> {
|
||||
if (!ensuresXRequestedWith(req)) {
|
||||
return res.status(401).send('Invalid X-Requested-With header');
|
||||
}
|
||||
|
||||
// remove refresh token cookie before logout
|
||||
removeRefreshTokenCookie(res, this.provider);
|
||||
return res.send('logout!');
|
||||
}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response): Promise<any> {
|
||||
if (!ensuresXRequestedWith(req)) {
|
||||
return res.status(401).send('Invalid X-Requested-With header');
|
||||
}
|
||||
|
||||
try {
|
||||
const refreshToken = req.cookies[`${this.provider}-refresh-token`];
|
||||
|
||||
// throw error if refresh token is missing in the request
|
||||
if (!refreshToken) {
|
||||
throw new Error('Missing session cookie');
|
||||
}
|
||||
|
||||
const scope = req.query.scope?.toString() ?? '';
|
||||
|
||||
// get new access_token
|
||||
const refreshInfo = await this.providerHandlers.refresh(
|
||||
refreshToken,
|
||||
scope,
|
||||
);
|
||||
return res.send(refreshInfo);
|
||||
} catch (error) {
|
||||
return res.status(401).send(`${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
/*
|
||||
* 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 express from 'express';
|
||||
import passport from 'passport';
|
||||
import { RedirectInfo, RefreshTokenResponse } from './types';
|
||||
|
||||
export const executeRedirectStrategy = async (
|
||||
req: express.Request,
|
||||
providerStrategy: passport.Strategy,
|
||||
options: any,
|
||||
): Promise<RedirectInfo> => {
|
||||
return new Promise(resolve => {
|
||||
const strategy = Object.create(providerStrategy);
|
||||
strategy.redirect = (url: string, status?: number) => {
|
||||
resolve({ url, status: status ?? undefined });
|
||||
};
|
||||
|
||||
strategy.authenticate(req, { ...options });
|
||||
});
|
||||
};
|
||||
|
||||
export const executeFrameHandlerStrategy = async (
|
||||
req: express.Request,
|
||||
providerStrategy: passport.Strategy,
|
||||
) => {
|
||||
return new Promise<{ user: any; info: any }>((resolve, reject) => {
|
||||
const strategy = Object.create(providerStrategy);
|
||||
strategy.success = (user: any, info: any) => {
|
||||
resolve({ user, info });
|
||||
};
|
||||
strategy.fail = (
|
||||
info: { type: 'success' | 'error'; message?: string },
|
||||
// _status: number,
|
||||
) => {
|
||||
reject(new Error(`Authentication rejected, ${info.message ?? ''}`));
|
||||
};
|
||||
strategy.error = (error: Error) => {
|
||||
reject(new Error(`Authentication failed, ${error}`));
|
||||
};
|
||||
strategy.redirect = () => {
|
||||
reject(new Error('Unexpected redirect'));
|
||||
};
|
||||
|
||||
strategy.authenticate(req);
|
||||
});
|
||||
};
|
||||
|
||||
export const executeRefreshTokenStrategy = async (
|
||||
providerstrategy: passport.Strategy,
|
||||
refreshToken: string,
|
||||
scope: string,
|
||||
): Promise<RefreshTokenResponse> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
const anyStrategy = providerstrategy as any;
|
||||
const OAuth2 = anyStrategy._oauth2.constructor;
|
||||
const oauth2 = new OAuth2(
|
||||
anyStrategy._oauth2._clientId,
|
||||
anyStrategy._oauth2._clientSecret,
|
||||
anyStrategy._oauth2._baseSite,
|
||||
anyStrategy._oauth2._authorizeUrl,
|
||||
anyStrategy._refreshURL || anyStrategy._oauth2._accessTokenUrl,
|
||||
anyStrategy._oauth2._customHeaders,
|
||||
);
|
||||
|
||||
oauth2.getOAuthAccessToken(
|
||||
refreshToken,
|
||||
{
|
||||
scope,
|
||||
grant_type: 'refresh_token',
|
||||
},
|
||||
(
|
||||
err: Error | null,
|
||||
accessToken: string,
|
||||
_refreshToken: string,
|
||||
params: any,
|
||||
) => {
|
||||
if (err) {
|
||||
reject(new Error(`Failed to refresh access token ${err}`));
|
||||
}
|
||||
if (!accessToken) {
|
||||
reject(
|
||||
new Error(
|
||||
`Failed to refresh access token, no access token received`,
|
||||
),
|
||||
);
|
||||
}
|
||||
resolve({
|
||||
accessToken,
|
||||
params,
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
@@ -1,51 +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 express from 'express';
|
||||
import passport from 'passport';
|
||||
import { AuthProvider, AuthProviderRouteHandlers } from './types';
|
||||
import { ProviderFactories } from './factories';
|
||||
|
||||
class MyAuthProvider implements AuthProvider, AuthProviderRouteHandlers {
|
||||
strategy(): passport.Strategy {
|
||||
return new passport.Strategy();
|
||||
}
|
||||
async start(_: express.Request, res: express.Response): Promise<any> {
|
||||
res.send('start');
|
||||
}
|
||||
async frameHandler(_: express.Request, res: express.Response): Promise<any> {
|
||||
res.send('frameHandler');
|
||||
}
|
||||
async logout(_: express.Request, res: express.Response): Promise<any> {
|
||||
res.send('logout');
|
||||
}
|
||||
}
|
||||
|
||||
describe('getProviderFactory', () => {
|
||||
it('makes a provider for MyAuthProvider', () => {
|
||||
jest
|
||||
.spyOn(ProviderFactories, 'getProviderFactory')
|
||||
.mockReturnValueOnce(MyAuthProvider);
|
||||
const provider = ProviderFactories.getProviderFactory('a');
|
||||
expect(provider).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws an error when provider implementation does not exist', () => {
|
||||
expect(() => {
|
||||
ProviderFactories.getProviderFactory('b');
|
||||
}).toThrow('Provider Implementation missing for : b auth provider');
|
||||
});
|
||||
});
|
||||
@@ -15,7 +15,7 @@
|
||||
*/
|
||||
|
||||
import { AuthProviderFactories, AuthProviderFactory } from './types';
|
||||
import { GoogleAuthProvider } from './google/provider';
|
||||
import { GoogleAuthProvider } from './google';
|
||||
|
||||
export class ProviderFactories {
|
||||
private static readonly providerFactories: AuthProviderFactories = {
|
||||
|
||||
+1
-5
@@ -14,8 +14,4 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
describe('test', () => {
|
||||
it('unbreaks the test runner', () => {
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
});
|
||||
export { GoogleAuthProvider } from './provider';
|
||||
@@ -1,522 +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 {
|
||||
GoogleAuthProvider,
|
||||
THOUSAND_DAYS_MS,
|
||||
TEN_MINUTES_MS,
|
||||
} from './provider';
|
||||
import passport from 'passport';
|
||||
import express from 'express';
|
||||
import * as utils from './../utils';
|
||||
import refresh from 'passport-oauth2-refresh';
|
||||
|
||||
const googleAuthProviderConfig = {
|
||||
provider: 'google',
|
||||
options: {
|
||||
clientID: 'a',
|
||||
clientSecret: 'b',
|
||||
callbackURL: 'c',
|
||||
},
|
||||
};
|
||||
|
||||
const googleAuthProviderConfigInvalidOptions = {
|
||||
provider: 'google',
|
||||
options: {},
|
||||
};
|
||||
|
||||
describe('GoogleAuthProvider', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
describe('create a new provider', () => {
|
||||
it('should succeed with valid config', () => {
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
expect(googleAuthProvider).toBeDefined();
|
||||
expect(googleAuthProvider.start).toBeDefined();
|
||||
expect(googleAuthProvider.logout).toBeDefined();
|
||||
expect(googleAuthProvider.frameHandler).toBeDefined();
|
||||
expect(googleAuthProvider.strategy).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('start authentication handler', () => {
|
||||
const mockResponse = ({
|
||||
send: jest.fn().mockReturnThis(),
|
||||
status: jest.fn().mockReturnThis(),
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
const mockNext: express.NextFunction = jest.fn();
|
||||
|
||||
it('should initiate authenticate request with provided scopes', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
query: {
|
||||
scope: 'a,b',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const spyPassport = jest
|
||||
.spyOn(passport, 'authenticate')
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
googleAuthProvider.start(mockRequest, mockResponse, mockNext);
|
||||
expect(spyPassport).toBeCalledTimes(1);
|
||||
expect(spyPassport).toBeCalledWith('google', {
|
||||
scope: 'a,b',
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
state: expect.any(String),
|
||||
});
|
||||
});
|
||||
|
||||
it('should set a nonce cookie', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
query: {
|
||||
scope: 'a,b',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
googleAuthProvider.start(mockRequest, mockResponse, mockNext);
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'google-nonce',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
maxAge: TEN_MINUTES_MS,
|
||||
path: `/auth/${googleAuthProviderConfig.provider}/handler`,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should throw error if no scopes provided', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
query: {},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
expect(() => {
|
||||
googleAuthProvider.start(mockRequest, mockResponse, mockNext);
|
||||
}).toThrowError('missing scope parameter');
|
||||
});
|
||||
});
|
||||
|
||||
describe('logout handler', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
} as unknown) as express.Request;
|
||||
|
||||
it('should perform logout and respond with 200', () => {
|
||||
const mockResponse: any = ({
|
||||
send: jest.fn(),
|
||||
cookie: jest.fn(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
const spyResponse = jest
|
||||
.spyOn(mockResponse, 'send')
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
googleAuthProvider.logout(mockRequest, mockResponse);
|
||||
expect(spyResponse).toBeCalledTimes(1);
|
||||
expect(spyResponse).toBeCalledWith('logout!');
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'google-refresh-token',
|
||||
'',
|
||||
expect.objectContaining({ maxAge: 0 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redirect frame handler', () => {
|
||||
const mockResponse: any = ({
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
cookie: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
const mockNext: express.NextFunction = jest.fn();
|
||||
|
||||
it('should call authenticate and post a response', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-nonce': 'NONCE' },
|
||||
query: {
|
||||
state: 'NONCE',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const spyPostMessage = jest
|
||||
.spyOn(utils, 'postMessageResponse')
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
const spyPassport = jest
|
||||
.spyOn(passport, 'authenticate')
|
||||
.mockImplementation((_x, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(null, { refreshToken: 'REFRESH_TOKEN' });
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(spyPassport).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledTimes(1);
|
||||
expect(mockResponse.cookie).toBeCalledWith(
|
||||
'google-refresh-token',
|
||||
'REFRESH_TOKEN',
|
||||
expect.objectContaining({
|
||||
path: '/auth/google',
|
||||
sameSite: 'none',
|
||||
httpOnly: true,
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should respond with a error message if no refresh token returned', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-nonce': 'NONCE' },
|
||||
query: {
|
||||
state: 'NONCE',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const spyPassport = jest
|
||||
.spyOn(passport, 'authenticate')
|
||||
.mockImplementation((_x, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(null, {});
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
const spyPostMessage = jest
|
||||
.spyOn(utils, 'postMessageResponse')
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(spyPassport).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledWith(mockResponse, {
|
||||
type: 'auth-result',
|
||||
error: new Error('Missing refresh token'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with a error message if auth failed', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-nonce': 'NONCE' },
|
||||
query: {
|
||||
state: 'NONCE',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const spyPassport = jest
|
||||
.spyOn(passport, 'authenticate')
|
||||
.mockImplementation((_x, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(new Error('TokenError'), null);
|
||||
return jest.fn();
|
||||
});
|
||||
|
||||
const spyPostMessage = jest
|
||||
.spyOn(utils, 'postMessageResponse')
|
||||
.mockImplementation(() => jest.fn());
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(spyPassport).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledTimes(1);
|
||||
expect(spyPostMessage).toBeCalledWith(mockResponse, {
|
||||
type: 'auth-result',
|
||||
error: new Error('Google auth failed, Error: TokenError'),
|
||||
});
|
||||
});
|
||||
|
||||
it('should respond with a error message if cookie nonce is missing', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: {},
|
||||
query: { state: 'NONCE' },
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith('Missing nonce');
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
});
|
||||
|
||||
it('should respond with a error message if state nonce is missing', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-nonce': 'NONCE' },
|
||||
query: {},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith('Missing nonce');
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
});
|
||||
|
||||
it('should respond with a error message if nonce mismatch', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-nonce': 'NONCA' },
|
||||
query: { state: 'NONCEB' },
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.frameHandler(mockRequest, mockResponse, mockNext);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith('Invalid nonce');
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('strategy handler', () => {
|
||||
it('should return a valid passport strategy', () => {
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
expect(googleAuthProvider.strategy()).toBeInstanceOf(passport.Strategy);
|
||||
});
|
||||
|
||||
it('should throw an error for invalid options', () => {
|
||||
expect(() => {
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfigInvalidOptions,
|
||||
);
|
||||
googleAuthProvider.strategy();
|
||||
}).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh token handler', () => {
|
||||
const mockResponse = ({
|
||||
status: jest.fn().mockReturnThis(),
|
||||
send: jest.fn().mockReturnThis(),
|
||||
} as unknown) as express.Response;
|
||||
|
||||
describe('no refresh token cookie', () => {
|
||||
it('should respond with a 401', () => {
|
||||
const mockRequest = ({
|
||||
cookies: jest.fn(),
|
||||
header: () => 'XMLHttpRequest',
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith('Missing session cookie');
|
||||
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh token cookie, no scope', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
|
||||
query: {},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
it('should request for a new access token and fail if no access token returned', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(undefined, undefined, undefined, {});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith(
|
||||
'Failed to refresh access token',
|
||||
);
|
||||
});
|
||||
|
||||
it('should request for a new access token and return 401 if any error', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb({ error: 'ERROR' }, undefined, undefined, {});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith(
|
||||
'Failed to refresh access token',
|
||||
);
|
||||
});
|
||||
|
||||
it('should fetch and return a new access token', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(undefined, 'ACCESS_TOKEN', undefined, {
|
||||
expires_in: 'EXPIRES_IN',
|
||||
id_token: 'ID_TOKEN',
|
||||
});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{},
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith({
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 'EXPIRES_IN',
|
||||
scope: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('refresh token cookie and scope', () => {
|
||||
const mockRequest = ({
|
||||
header: () => 'XMLHttpRequest',
|
||||
cookies: { 'google-refresh-token': 'REFRESH_TOKEN' },
|
||||
query: {
|
||||
scope: 'a,b',
|
||||
},
|
||||
} as unknown) as express.Request;
|
||||
|
||||
const googleAuthProvider = new GoogleAuthProvider(
|
||||
googleAuthProviderConfig,
|
||||
);
|
||||
|
||||
it('should fetch and return a new access token with scopes', () => {
|
||||
const spyRefresh = jest
|
||||
.spyOn(refresh, 'requestNewAccessToken')
|
||||
.mockImplementation((_x, _y, _z, callbackFunc) => {
|
||||
const cb = callbackFunc as Function;
|
||||
cb(undefined, 'ACCESS_TOKEN', undefined, {
|
||||
expires_in: 'EXPIRES_IN',
|
||||
id_token: 'ID_TOKEN',
|
||||
scope: 'a,b',
|
||||
});
|
||||
});
|
||||
|
||||
googleAuthProvider.refresh(mockRequest, mockResponse);
|
||||
expect(spyRefresh).toBeCalledTimes(1);
|
||||
expect(spyRefresh).toBeCalledWith(
|
||||
'google',
|
||||
'REFRESH_TOKEN',
|
||||
{ scope: 'a,b' },
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith({
|
||||
accessToken: 'ACCESS_TOKEN',
|
||||
idToken: 'ID_TOKEN',
|
||||
expiresInSeconds: 'EXPIRES_IN',
|
||||
scope: 'a,b',
|
||||
});
|
||||
});
|
||||
|
||||
it('ensures x-requested-with header', () => {
|
||||
const mockHeaderRequest = ({
|
||||
header: () => 'TEST',
|
||||
} as unknown) as express.Request;
|
||||
|
||||
googleAuthProvider.refresh(mockHeaderRequest, mockResponse);
|
||||
expect(mockResponse.send).toBeCalledTimes(1);
|
||||
expect(mockResponse.send).toBeCalledWith(
|
||||
'Invalid X-Requested-With header',
|
||||
);
|
||||
expect(mockResponse.status).toBeCalledTimes(1);
|
||||
expect(mockResponse.status).toBeCalledWith(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -14,168 +14,29 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import passport from 'passport';
|
||||
import express, { CookieOptions } from 'express';
|
||||
import crypto from 'crypto';
|
||||
import express from 'express';
|
||||
import { Strategy as GoogleStrategy } from 'passport-google-oauth20';
|
||||
import refresh from 'passport-oauth2-refresh';
|
||||
import {
|
||||
AuthProvider,
|
||||
AuthProviderRouteHandlers,
|
||||
executeFrameHandlerStrategy,
|
||||
executeRedirectStrategy,
|
||||
executeRefreshTokenStrategy,
|
||||
} from '../PassportStrategyHelper';
|
||||
import {
|
||||
OAuthProviderHandlers,
|
||||
AuthInfoBase,
|
||||
AuthInfoPrivate,
|
||||
RedirectInfo,
|
||||
AuthProviderConfig,
|
||||
} from './../types';
|
||||
import { postMessageResponse, ensuresXRequestedWith } from './../utils';
|
||||
import { InputError } from '@backstage/backend-common';
|
||||
} from '../types';
|
||||
|
||||
export const THOUSAND_DAYS_MS = 1000 * 24 * 60 * 60 * 1000;
|
||||
export const TEN_MINUTES_MS = 600 * 1000;
|
||||
export class GoogleAuthProvider
|
||||
implements AuthProvider, AuthProviderRouteHandlers {
|
||||
export class GoogleAuthProvider implements OAuthProviderHandlers {
|
||||
private readonly providerConfig: AuthProviderConfig;
|
||||
private readonly _strategy: GoogleStrategy;
|
||||
|
||||
constructor(providerConfig: AuthProviderConfig) {
|
||||
this.providerConfig = providerConfig;
|
||||
}
|
||||
|
||||
start(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) {
|
||||
const nonce = crypto.randomBytes(16).toString('base64');
|
||||
|
||||
const options: CookieOptions = {
|
||||
maxAge: TEN_MINUTES_MS,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${this.providerConfig.provider}/handler`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(`${this.providerConfig.provider}-nonce`, nonce, options);
|
||||
|
||||
const scope = req.query.scope?.toString() ?? '';
|
||||
if (!scope) {
|
||||
throw new InputError('missing scope parameter');
|
||||
}
|
||||
return passport.authenticate('google', {
|
||||
scope,
|
||||
accessType: 'offline',
|
||||
prompt: 'consent',
|
||||
state: nonce,
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
frameHandler(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
) {
|
||||
const cookieNonce = req.cookies[`${this.providerConfig.provider}-nonce`];
|
||||
const stateNonce = req.query.state;
|
||||
|
||||
if (!cookieNonce || !stateNonce) {
|
||||
return res.status(401).send('Missing nonce');
|
||||
}
|
||||
|
||||
if (cookieNonce !== stateNonce) {
|
||||
return res.status(401).send('Invalid nonce');
|
||||
}
|
||||
|
||||
return passport.authenticate('google', (err, user) => {
|
||||
if (err) {
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
error: new Error(`Google auth failed, ${err}`),
|
||||
});
|
||||
}
|
||||
|
||||
const { refreshToken } = user;
|
||||
|
||||
if (!refreshToken) {
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
error: new Error('Missing refresh token'),
|
||||
});
|
||||
}
|
||||
|
||||
delete user.refreshToken;
|
||||
|
||||
const options: CookieOptions = {
|
||||
maxAge: THOUSAND_DAYS_MS,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${this.providerConfig.provider}`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(
|
||||
`${this.providerConfig.provider}-refresh-token`,
|
||||
refreshToken,
|
||||
options,
|
||||
);
|
||||
return postMessageResponse(res, {
|
||||
type: 'auth-result',
|
||||
payload: user,
|
||||
});
|
||||
})(req, res, next);
|
||||
}
|
||||
|
||||
async logout(req: express.Request, res: express.Response) {
|
||||
if (!ensuresXRequestedWith(req)) {
|
||||
return res.status(401).send('Invalid X-Requested-With header');
|
||||
}
|
||||
|
||||
const options: CookieOptions = {
|
||||
maxAge: 0,
|
||||
secure: false,
|
||||
sameSite: 'none',
|
||||
domain: 'localhost',
|
||||
path: `/auth/${this.providerConfig.provider}`,
|
||||
httpOnly: true,
|
||||
};
|
||||
|
||||
res.cookie(`${this.providerConfig.provider}-refresh-token`, '', options);
|
||||
return res.send('logout!');
|
||||
}
|
||||
|
||||
async refresh(req: express.Request, res: express.Response) {
|
||||
if (!ensuresXRequestedWith(req)) {
|
||||
return res.status(401).send('Invalid X-Requested-With header');
|
||||
}
|
||||
|
||||
const refreshToken =
|
||||
req.cookies[`${this.providerConfig.provider}-refresh-token`];
|
||||
|
||||
if (!refreshToken) {
|
||||
return res.status(401).send('Missing session cookie');
|
||||
}
|
||||
|
||||
const scope = req.query.scope?.toString() ?? '';
|
||||
const refreshTokenRequestParams = scope ? { scope } : {};
|
||||
|
||||
return refresh.requestNewAccessToken(
|
||||
this.providerConfig.provider,
|
||||
refreshToken,
|
||||
refreshTokenRequestParams,
|
||||
(err, accessToken, _refreshToken, params) => {
|
||||
if (err || !accessToken) {
|
||||
return res.status(401).send('Failed to refresh access token');
|
||||
}
|
||||
return res.send({
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
strategy(): passport.Strategy {
|
||||
// TODO: throw error if env variables not set?
|
||||
return new GoogleStrategy(
|
||||
this._strategy = new GoogleStrategy(
|
||||
{ ...this.providerConfig.options },
|
||||
(
|
||||
accessToken: any,
|
||||
@@ -184,15 +45,45 @@ export class GoogleAuthProvider
|
||||
profile: any,
|
||||
done: any,
|
||||
) => {
|
||||
done(undefined, {
|
||||
profile,
|
||||
idToken: params.id_token,
|
||||
accessToken,
|
||||
refreshToken,
|
||||
scope: params.scope,
|
||||
expiresInSeconds: params.expires_in,
|
||||
});
|
||||
done(
|
||||
undefined,
|
||||
{
|
||||
profile,
|
||||
idToken: params.id_token,
|
||||
accessToken,
|
||||
scope: params.scope,
|
||||
expiresInSeconds: params.expires_in,
|
||||
},
|
||||
{
|
||||
refreshToken,
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async start(req: express.Request, options: any): Promise<RedirectInfo> {
|
||||
return await executeRedirectStrategy(req, this._strategy, options);
|
||||
}
|
||||
|
||||
async handler(
|
||||
req: express.Request,
|
||||
): Promise<{ user: AuthInfoBase; info: AuthInfoPrivate }> {
|
||||
return await executeFrameHandlerStrategy(req, this._strategy);
|
||||
}
|
||||
|
||||
async refresh(refreshToken: string, scope: string): Promise<AuthInfoBase> {
|
||||
const { accessToken, params } = await executeRefreshTokenStrategy(
|
||||
this._strategy,
|
||||
refreshToken,
|
||||
scope,
|
||||
);
|
||||
|
||||
return {
|
||||
accessToken,
|
||||
idToken: params.id_token,
|
||||
expiresInSeconds: params.expires_in,
|
||||
scope: params.scope,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,90 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import passport from 'passport';
|
||||
import express from 'express';
|
||||
import { makeProvider, defaultRouter } from '.';
|
||||
import {
|
||||
AuthProvider,
|
||||
AuthProviderRouteHandlers,
|
||||
AuthProviderConfig,
|
||||
} from './types';
|
||||
import * as passportGoogleOAuth20 from 'passport-google-oauth20';
|
||||
import { ProviderFactories } from './factories';
|
||||
|
||||
class MyAuthProvider implements AuthProvider, AuthProviderRouteHandlers {
|
||||
private readonly providerConfig: AuthProviderConfig;
|
||||
constructor(providerConfig: AuthProviderConfig) {
|
||||
this.providerConfig = providerConfig;
|
||||
}
|
||||
|
||||
strategy(): passport.Strategy {
|
||||
return new passportGoogleOAuth20.Strategy(
|
||||
this.providerConfig.options,
|
||||
() => {},
|
||||
);
|
||||
}
|
||||
async start(_: express.Request, res: express.Response): Promise<any> {
|
||||
res.send('start');
|
||||
}
|
||||
async frameHandler(_: express.Request, res: express.Response): Promise<any> {
|
||||
res.send('frameHandler');
|
||||
}
|
||||
async logout(_: express.Request, res: express.Response): Promise<any> {
|
||||
res.send('logout');
|
||||
}
|
||||
}
|
||||
|
||||
class MyAuthProviderWithRefresh extends MyAuthProvider {
|
||||
async refresh(_: express.Request, res: express.Response): Promise<any> {
|
||||
res.send('logout');
|
||||
}
|
||||
}
|
||||
|
||||
const providerConfig = {
|
||||
provider: 'a',
|
||||
options: {
|
||||
clientID: 'somevalue',
|
||||
},
|
||||
};
|
||||
|
||||
const providerConfigInvalid = {
|
||||
provider: 'b',
|
||||
options: {
|
||||
clientID: 'somevalue',
|
||||
},
|
||||
};
|
||||
|
||||
describe('makeProvider', () => {
|
||||
it('makes a provider for Myauthprovider', () => {
|
||||
jest
|
||||
.spyOn(ProviderFactories, 'getProviderFactory')
|
||||
.mockReturnValueOnce(MyAuthProvider);
|
||||
const provider = makeProvider(providerConfig);
|
||||
expect(provider.providerId).toEqual('a');
|
||||
expect(provider.strategy).toBeDefined();
|
||||
expect(provider.providerRouter).toBeDefined();
|
||||
});
|
||||
|
||||
it('throws an error when provider implementation does not exist', () => {
|
||||
expect(() => {
|
||||
makeProvider(providerConfigInvalid);
|
||||
}).toThrow('Provider Implementation missing for : b auth provider');
|
||||
});
|
||||
});
|
||||
|
||||
describe('defaultRouter', () => {
|
||||
it('make router for auth provider without refresh', () => {
|
||||
expect(
|
||||
defaultRouter(new MyAuthProvider({ provider: 'a', options: {} })),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('make router for auth provider with refresh', () => {
|
||||
expect(
|
||||
defaultRouter(
|
||||
new MyAuthProviderWithRefresh({ provider: 'b', options: {} }),
|
||||
),
|
||||
).toBeDefined();
|
||||
describe('test', () => {
|
||||
it('unbreaks the test runner', () => {
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
import Router from 'express-promise-router';
|
||||
import { AuthProviderRouteHandlers, AuthProviderConfig } from './types';
|
||||
import { ProviderFactories } from './factories';
|
||||
import { OAuthProvider } from './OAuthProvider';
|
||||
|
||||
export const defaultRouter = (provider: AuthProviderRouteHandlers) => {
|
||||
const router = Router();
|
||||
@@ -33,7 +34,8 @@ export const makeProvider = (config: AuthProviderConfig) => {
|
||||
const providerId = config.provider;
|
||||
const ProviderImpl = ProviderFactories.getProviderFactory(providerId);
|
||||
const providerInstance = new ProviderImpl(config);
|
||||
const strategy = providerInstance.strategy();
|
||||
const providerRouter = defaultRouter(providerInstance);
|
||||
return { providerId, strategy, providerRouter };
|
||||
|
||||
const oauthProvider = new OAuthProvider(providerInstance, providerId);
|
||||
const providerRouter = defaultRouter(oauthProvider);
|
||||
return { providerId, providerRouter };
|
||||
};
|
||||
|
||||
@@ -22,32 +22,18 @@ export type AuthProviderConfig = {
|
||||
options: any;
|
||||
};
|
||||
|
||||
export interface AuthProvider {
|
||||
strategy(): passport.Strategy;
|
||||
router?(): express.Router;
|
||||
export interface OAuthProviderHandlers {
|
||||
start(req: express.Request, options: any): Promise<any>;
|
||||
handler(req: express.Request): Promise<any>;
|
||||
refresh(refreshToken: string, scope: string): Promise<any>;
|
||||
logout?(): Promise<any>;
|
||||
}
|
||||
|
||||
export interface AuthProviderRouteHandlers {
|
||||
start(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
): Promise<any>;
|
||||
frameHandler(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
): Promise<any>;
|
||||
refresh?(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
): Promise<any>;
|
||||
logout(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
next: express.NextFunction,
|
||||
): Promise<any>;
|
||||
start(req: express.Request, res: express.Response): Promise<any>;
|
||||
frameHandler(req: express.Request, res: express.Response): Promise<any>;
|
||||
refresh?(req: express.Request, res: express.Response): Promise<any>;
|
||||
logout(req: express.Request, res: express.Response): Promise<any>;
|
||||
}
|
||||
|
||||
export type AuthProviderFactories = {
|
||||
@@ -55,7 +41,7 @@ export type AuthProviderFactories = {
|
||||
};
|
||||
|
||||
export type AuthProviderFactory = {
|
||||
new (providerConfig: any): AuthProvider & AuthProviderRouteHandlers;
|
||||
new (providerConfig: any): OAuthProviderHandlers;
|
||||
};
|
||||
|
||||
export type AuthInfoBase = {
|
||||
@@ -69,7 +55,7 @@ export type AuthInfoWithProfile = AuthInfoBase & {
|
||||
profile: passport.Profile;
|
||||
};
|
||||
|
||||
export type AuthInfoPrivate = AuthInfoWithProfile & {
|
||||
export type AuthInfoPrivate = {
|
||||
refreshToken: string;
|
||||
};
|
||||
|
||||
@@ -82,3 +68,13 @@ export type AuthResponse =
|
||||
type: 'auth-result';
|
||||
error: Error;
|
||||
};
|
||||
|
||||
export type RedirectInfo = {
|
||||
url: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
export type RefreshTokenResponse = {
|
||||
accessToken: string;
|
||||
params: any;
|
||||
};
|
||||
|
||||
@@ -16,10 +16,7 @@
|
||||
|
||||
import express from 'express';
|
||||
import Router from 'express-promise-router';
|
||||
import passport from 'passport';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import refresh from 'passport-oauth2-refresh';
|
||||
import OAuth2Strategy from 'passport-oauth2';
|
||||
import { Logger } from 'winston';
|
||||
import { providers } from './../providers/config';
|
||||
import { makeProvider } from '../providers';
|
||||
@@ -33,38 +30,14 @@ export async function createRouter(
|
||||
): Promise<express.Router> {
|
||||
const router = Router();
|
||||
const logger = options.logger.child({ plugin: 'auth' });
|
||||
const providerRouters: { [key: string]: express.Router } = {};
|
||||
|
||||
router.use(cookieParser());
|
||||
|
||||
// configure all the providers
|
||||
for (const providerConfig of providers) {
|
||||
const { providerId, strategy, providerRouter } = makeProvider(
|
||||
providerConfig,
|
||||
);
|
||||
logger.info(`Configuring provider: ${providerId}`);
|
||||
passport.use(strategy);
|
||||
if (strategy instanceof OAuth2Strategy) {
|
||||
refresh.use(strategy);
|
||||
}
|
||||
providerRouters[providerId] = providerRouter;
|
||||
}
|
||||
|
||||
passport.serializeUser((user, done) => {
|
||||
done(null, user);
|
||||
});
|
||||
|
||||
passport.deserializeUser((user, done) => {
|
||||
done(null, user);
|
||||
});
|
||||
|
||||
router.use(passport.initialize());
|
||||
router.use(passport.session());
|
||||
router.use(cookieParser());
|
||||
|
||||
for (const providerId in providerRouters) {
|
||||
if (providerRouters.hasOwnProperty(providerId)) {
|
||||
const providerRouter = providerRouters[providerId];
|
||||
router.use(`/${providerId}`, providerRouter);
|
||||
}
|
||||
const { providerId, providerRouter } = makeProvider(providerConfig);
|
||||
logger.info(`Configuring provider, ${providerId}`);
|
||||
router.use(`/${providerId}`, providerRouter);
|
||||
}
|
||||
|
||||
return router;
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
export {};
|
||||
|
||||
@@ -31,7 +31,7 @@ export class StaticEntitiesCatalog implements EntitiesCatalog {
|
||||
}
|
||||
|
||||
async entityByUid(uid: string): Promise<Entity | undefined> {
|
||||
const item = this._entities.find(e => uid === e.metadata?.uid);
|
||||
const item = this._entities.find(e => uid === e.metadata.uid);
|
||||
if (!item) {
|
||||
throw new NotFoundError('Entity cannot be found');
|
||||
}
|
||||
@@ -46,8 +46,8 @@ export class StaticEntitiesCatalog implements EntitiesCatalog {
|
||||
const item = this._entities.find(
|
||||
e =>
|
||||
kind === e.kind &&
|
||||
name === e.metadata?.name &&
|
||||
namespace === e.metadata?.namespace,
|
||||
name === e.metadata.name &&
|
||||
namespace === e.metadata.namespace,
|
||||
);
|
||||
if (!item) {
|
||||
throw new NotFoundError('Entity cannot be found');
|
||||
|
||||
@@ -135,7 +135,7 @@ describe('Database', () => {
|
||||
catalog.addEntity(tx, entityRequest),
|
||||
);
|
||||
expect(added).toStrictEqual(entityResponse);
|
||||
expect(added.entity.metadata!.generation).toBe(1);
|
||||
expect(added.entity.metadata.generation).toBe(1);
|
||||
});
|
||||
|
||||
it('rejects adding the same-named entity twice', async () => {
|
||||
@@ -148,9 +148,9 @@ describe('Database', () => {
|
||||
|
||||
it('accepts adding the same-named entity twice if on different namespaces', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
entityRequest.entity.metadata!.namespace = 'namespace1';
|
||||
entityRequest.entity.metadata.namespace = 'namespace1';
|
||||
await catalog.transaction(tx => catalog.addEntity(tx, entityRequest));
|
||||
entityRequest.entity.metadata!.namespace = 'namespace2';
|
||||
entityRequest.entity.metadata.namespace = 'namespace2';
|
||||
await expect(
|
||||
catalog.transaction(tx => catalog.addEntity(tx, entityRequest)),
|
||||
).resolves.toBeDefined();
|
||||
@@ -207,17 +207,15 @@ describe('Database', () => {
|
||||
);
|
||||
expect(updated.entity.apiVersion).toEqual(added.entity.apiVersion);
|
||||
expect(updated.entity.kind).toEqual(added.entity.kind);
|
||||
expect(updated.entity.metadata!.etag).not.toEqual(
|
||||
added.entity.metadata!.etag,
|
||||
expect(updated.entity.metadata.etag).not.toEqual(
|
||||
added.entity.metadata.etag,
|
||||
);
|
||||
expect(updated.entity.metadata!.generation).toEqual(
|
||||
added.entity.metadata!.generation,
|
||||
expect(updated.entity.metadata.generation).toEqual(
|
||||
added.entity.metadata.generation,
|
||||
);
|
||||
expect(updated.entity.metadata!.name).toEqual(
|
||||
added.entity.metadata!.name,
|
||||
);
|
||||
expect(updated.entity.metadata!.namespace).toEqual(
|
||||
added.entity.metadata!.namespace,
|
||||
expect(updated.entity.metadata.name).toEqual(added.entity.metadata.name);
|
||||
expect(updated.entity.metadata.namespace).toEqual(
|
||||
added.entity.metadata.namespace,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -226,11 +224,11 @@ describe('Database', () => {
|
||||
const added = await catalog.transaction(tx =>
|
||||
catalog.addEntity(tx, entityRequest),
|
||||
);
|
||||
added.entity.metadata!.name! = 'new!';
|
||||
added.entity.metadata.name! = 'new!';
|
||||
const updated = await catalog.transaction(tx =>
|
||||
catalog.updateEntity(tx, { entity: added.entity }),
|
||||
);
|
||||
expect(updated.entity.metadata!.name).toEqual('new!');
|
||||
expect(updated.entity.metadata.name).toEqual('new!');
|
||||
});
|
||||
|
||||
it('can update fields if kind, name, and namespace match', async () => {
|
||||
@@ -239,8 +237,8 @@ describe('Database', () => {
|
||||
catalog.addEntity(tx, entityRequest),
|
||||
);
|
||||
added.entity.apiVersion = 'something.new';
|
||||
delete added.entity.metadata!.uid;
|
||||
delete added.entity.metadata!.generation;
|
||||
delete added.entity.metadata.uid;
|
||||
delete added.entity.metadata.generation;
|
||||
const updated = await catalog.transaction(tx =>
|
||||
catalog.updateEntity(tx, { entity: added.entity }),
|
||||
);
|
||||
@@ -253,9 +251,9 @@ describe('Database', () => {
|
||||
catalog.addEntity(tx, entityRequest),
|
||||
);
|
||||
added.entity.apiVersion = 'something.new';
|
||||
delete added.entity.metadata!.uid;
|
||||
delete added.entity.metadata!.generation;
|
||||
added.entity.metadata!.namespace = 'something.wrong';
|
||||
delete added.entity.metadata.uid;
|
||||
delete added.entity.metadata.generation;
|
||||
added.entity.metadata.namespace = 'something.wrong';
|
||||
await expect(
|
||||
catalog.transaction(tx =>
|
||||
catalog.updateEntity(tx, { entity: added.entity }),
|
||||
@@ -268,7 +266,7 @@ describe('Database', () => {
|
||||
const added = await catalog.transaction(tx =>
|
||||
catalog.addEntity(tx, entityRequest),
|
||||
);
|
||||
added.entity.metadata!.etag = 'garbage';
|
||||
added.entity.metadata.etag = 'garbage';
|
||||
await expect(
|
||||
catalog.transaction(tx =>
|
||||
catalog.updateEntity(tx, { entity: added.entity }),
|
||||
@@ -281,7 +279,7 @@ describe('Database', () => {
|
||||
const added = await catalog.transaction(tx =>
|
||||
catalog.addEntity(tx, entityRequest),
|
||||
);
|
||||
added.entity.metadata!.generation! += 100;
|
||||
added.entity.metadata.generation! += 100;
|
||||
await expect(
|
||||
catalog.transaction(tx =>
|
||||
catalog.updateEntity(tx, { entity: added.entity }),
|
||||
@@ -293,10 +291,15 @@ describe('Database', () => {
|
||||
describe('entities', () => {
|
||||
it('can get all entities with empty filters list', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const e1: Entity = { apiVersion: 'a', kind: 'b' };
|
||||
const e2: Entity = {
|
||||
const e1: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
kind: 'k1',
|
||||
metadata: { name: 'n' },
|
||||
};
|
||||
const e2: Entity = {
|
||||
apiVersion: 'c',
|
||||
kind: 'k2',
|
||||
metadata: { name: 'n' },
|
||||
spec: { c: null },
|
||||
};
|
||||
await catalog.transaction(async tx => {
|
||||
@@ -309,8 +312,14 @@ describe('Database', () => {
|
||||
expect(result.length).toEqual(2);
|
||||
expect(result).toEqual(
|
||||
expect.arrayContaining([
|
||||
{ locationId: undefined, entity: expect.objectContaining(e1) },
|
||||
{ locationId: undefined, entity: expect.objectContaining(e2) },
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining({ kind: 'k1' }),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining({ kind: 'k2' }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
});
|
||||
@@ -318,15 +327,17 @@ describe('Database', () => {
|
||||
it('can get all specific entities for matching filters (naive case)', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const entities: Entity[] = [
|
||||
{ apiVersion: 'a', kind: 'b' },
|
||||
{ apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
kind: 'k2',
|
||||
metadata: { name: 'n' },
|
||||
spec: { c: 'some' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
kind: 'k3',
|
||||
metadata: { name: 'n' },
|
||||
spec: { c: null },
|
||||
},
|
||||
];
|
||||
@@ -340,27 +351,32 @@ describe('Database', () => {
|
||||
await expect(
|
||||
catalog.transaction(async tx =>
|
||||
catalog.entities(tx, [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'kind', values: ['k2'] },
|
||||
{ key: 'spec.c', values: ['some'] },
|
||||
]),
|
||||
),
|
||||
).resolves.toEqual([
|
||||
{ locationId: undefined, entity: expect.objectContaining(entities[1]) },
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining({ kind: 'k2' }),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('can get all specific entities for matching filters with nulls (both missing and literal null value)', async () => {
|
||||
const catalog = new Database(database, getVoidLogger());
|
||||
const entities: Entity[] = [
|
||||
{ apiVersion: 'a', kind: 'b' },
|
||||
{ apiVersion: 'a', kind: 'k1', metadata: { name: 'n' } },
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
kind: 'k2',
|
||||
metadata: { name: 'n' },
|
||||
spec: { c: 'some' },
|
||||
},
|
||||
{
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
kind: 'k3',
|
||||
metadata: { name: 'n' },
|
||||
spec: { c: null },
|
||||
},
|
||||
];
|
||||
@@ -373,7 +389,7 @@ describe('Database', () => {
|
||||
|
||||
const rows = await catalog.transaction(async tx =>
|
||||
catalog.entities(tx, [
|
||||
{ key: 'kind', values: ['b'] },
|
||||
{ key: 'apiVersion', values: ['a'] },
|
||||
{ key: 'spec.c', values: [null, 'some'] },
|
||||
]),
|
||||
);
|
||||
@@ -383,15 +399,15 @@ describe('Database', () => {
|
||||
expect.arrayContaining([
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[0]),
|
||||
entity: expect.objectContaining({ kind: 'k1' }),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[1]),
|
||||
entity: expect.objectContaining({ kind: 'k2' }),
|
||||
},
|
||||
{
|
||||
locationId: undefined,
|
||||
entity: expect.objectContaining(entities[2]),
|
||||
entity: expect.objectContaining({ kind: 'k3' }),
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -47,11 +47,7 @@ function getStrippedMetadata(metadata: EntityMeta): EntityMeta {
|
||||
return output;
|
||||
}
|
||||
|
||||
function serializeMetadata(metadata: EntityMeta | undefined): string | null {
|
||||
if (!metadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function serializeMetadata(metadata: EntityMeta): string {
|
||||
return JSON.stringify(getStrippedMetadata(metadata));
|
||||
}
|
||||
|
||||
@@ -68,14 +64,14 @@ function toEntityRow(
|
||||
entity: Entity,
|
||||
): DbEntitiesRow {
|
||||
return {
|
||||
id: entity.metadata!.uid!,
|
||||
id: entity.metadata.uid!,
|
||||
location_id: locationId || null,
|
||||
etag: entity.metadata!.etag!,
|
||||
generation: entity.metadata!.generation!,
|
||||
etag: entity.metadata.etag!,
|
||||
generation: entity.metadata.generation!,
|
||||
api_version: entity.apiVersion,
|
||||
kind: entity.kind,
|
||||
name: entity.metadata!.name || null,
|
||||
namespace: entity.metadata!.namespace || null,
|
||||
name: entity.metadata.name || null,
|
||||
namespace: entity.metadata.namespace || null,
|
||||
metadata: serializeMetadata(entity.metadata),
|
||||
spec: serializeSpec(entity.spec),
|
||||
};
|
||||
@@ -86,17 +82,13 @@ function toEntityResponse(row: DbEntitiesRow): DbEntityResponse {
|
||||
apiVersion: row.api_version,
|
||||
kind: row.kind,
|
||||
metadata: {
|
||||
...(JSON.parse(row.metadata) as Entity['metadata']),
|
||||
uid: row.id,
|
||||
etag: row.etag,
|
||||
generation: Number(row.generation), // cast because of sqlite
|
||||
},
|
||||
};
|
||||
|
||||
if (row.metadata) {
|
||||
const metadata = JSON.parse(row.metadata) as Entity['metadata'];
|
||||
entity.metadata = { ...entity.metadata, ...metadata };
|
||||
}
|
||||
|
||||
if (row.spec) {
|
||||
const spec = JSON.parse(row.spec);
|
||||
entity.spec = spec;
|
||||
@@ -177,11 +169,11 @@ export class Database {
|
||||
tx: Knex.Transaction<any, any>,
|
||||
request: DbEntityRequest,
|
||||
): Promise<DbEntityResponse> {
|
||||
if (request.entity.metadata?.uid !== undefined) {
|
||||
if (request.entity.metadata.uid !== undefined) {
|
||||
throw new InputError('May not specify uid for new entities');
|
||||
} else if (request.entity.metadata?.etag !== undefined) {
|
||||
} else if (request.entity.metadata.etag !== undefined) {
|
||||
throw new InputError('May not specify etag for new entities');
|
||||
} else if (request.entity.metadata?.generation !== undefined) {
|
||||
} else if (request.entity.metadata.generation !== undefined) {
|
||||
throw new InputError('May not specify generation for new entities');
|
||||
}
|
||||
|
||||
@@ -294,9 +286,9 @@ export class Database {
|
||||
if (oldRow.metadata) {
|
||||
const oldMetadata = JSON.parse(oldRow.metadata) as EntityMeta;
|
||||
if (oldMetadata.annotations) {
|
||||
newEntity.metadata!.annotations = {
|
||||
newEntity.metadata.annotations = {
|
||||
...oldMetadata.annotations,
|
||||
...newEntity.metadata!.annotations,
|
||||
...newEntity.metadata.annotations,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,14 +36,14 @@ describe('DatabaseManager', () => {
|
||||
readLocation: jest.fn(),
|
||||
};
|
||||
const policy: EntityPolicy = {
|
||||
apply: jest.fn(),
|
||||
enforce: jest.fn(),
|
||||
};
|
||||
|
||||
await expect(
|
||||
DatabaseManager.refreshLocations(db, reader, policy, getVoidLogger()),
|
||||
).resolves.toBeUndefined();
|
||||
expect(reader.readLocation).not.toHaveBeenCalled();
|
||||
expect(policy.apply).not.toHaveBeenCalled();
|
||||
expect(policy.enforce).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('can update a single location', async () => {
|
||||
@@ -78,7 +78,7 @@ describe('DatabaseManager', () => {
|
||||
),
|
||||
};
|
||||
const policy: EntityPolicy = {
|
||||
apply: jest.fn(() => Promise.resolve(desc)),
|
||||
enforce: jest.fn(() => Promise.resolve(desc)),
|
||||
};
|
||||
|
||||
await expect(
|
||||
@@ -126,7 +126,7 @@ describe('DatabaseManager', () => {
|
||||
),
|
||||
};
|
||||
const policy: EntityPolicy = {
|
||||
apply: jest.fn(() => Promise.resolve(desc)),
|
||||
enforce: jest.fn(() => Promise.resolve(desc)),
|
||||
};
|
||||
|
||||
await expect(
|
||||
@@ -178,7 +178,9 @@ describe('DatabaseManager', () => {
|
||||
),
|
||||
};
|
||||
const policy: EntityPolicy = {
|
||||
apply: jest.fn(() => Promise.reject(new Error('parser error message'))),
|
||||
enforce: jest.fn(() =>
|
||||
Promise.reject(new Error('parser error message')),
|
||||
),
|
||||
};
|
||||
|
||||
await expect(
|
||||
@@ -225,7 +227,9 @@ describe('DatabaseManager', () => {
|
||||
),
|
||||
};
|
||||
const policy: EntityPolicy = {
|
||||
apply: jest.fn(() => Promise.reject(new Error('parser error message'))),
|
||||
enforce: jest.fn(() =>
|
||||
Promise.reject(new Error('parser error message')),
|
||||
),
|
||||
};
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -19,9 +19,9 @@ import Knex from 'knex';
|
||||
import lodash from 'lodash';
|
||||
import path from 'path';
|
||||
import { Logger } from 'winston';
|
||||
import { IngestionModel } from '../ingestion/types';
|
||||
import { Database } from './Database';
|
||||
import { DatabaseLocationUpdateLogStatus, DbEntityRequest } from './types';
|
||||
import { IngestionModel } from '../ingestion/types';
|
||||
|
||||
export class DatabaseManager {
|
||||
public static async createDatabase(
|
||||
@@ -86,7 +86,7 @@ export class DatabaseManager {
|
||||
}
|
||||
|
||||
try {
|
||||
const entity = await entityPolicy.apply(readerItem.data);
|
||||
const entity = await entityPolicy.enforce(readerItem.data);
|
||||
await DatabaseManager.refreshSingleEntity(
|
||||
database,
|
||||
location.id,
|
||||
@@ -96,14 +96,14 @@ export class DatabaseManager {
|
||||
await DatabaseManager.logUpdateSuccess(
|
||||
database,
|
||||
location.id,
|
||||
entity.metadata!.name,
|
||||
entity.metadata.name,
|
||||
);
|
||||
} catch (error) {
|
||||
await DatabaseManager.logUpdateFailure(
|
||||
database,
|
||||
location.id,
|
||||
error,
|
||||
readerItem.data.metadata?.name,
|
||||
readerItem.data.metadata.name,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ export async function up(knex: Knex): Promise<any> {
|
||||
.comment('The metadata.namespace field of the entity');
|
||||
table
|
||||
.string('metadata')
|
||||
.nullable()
|
||||
.notNullable()
|
||||
.comment('The entire metadata JSON blob of the entity');
|
||||
table
|
||||
.string('spec')
|
||||
|
||||
@@ -102,14 +102,15 @@ describe('search', () => {
|
||||
const input: Entity = {
|
||||
apiVersion: 'a',
|
||||
kind: 'b',
|
||||
metadata: { name: 'n' },
|
||||
};
|
||||
expect(buildEntitySearch('eid', input)).toEqual([
|
||||
{ entity_id: 'eid', key: 'metadata.name', value: null },
|
||||
{ entity_id: 'eid', key: 'metadata.name', value: 'n' },
|
||||
{ entity_id: 'eid', key: 'metadata.namespace', value: null },
|
||||
{ entity_id: 'eid', key: 'metadata.uid', value: null },
|
||||
{ entity_id: 'eid', key: 'apiVersion', value: 'a' },
|
||||
{ entity_id: 'eid', key: 'kind', value: 'b' },
|
||||
{ entity_id: 'eid', key: 'name', value: null },
|
||||
{ entity_id: 'eid', key: 'name', value: 'n' },
|
||||
{ entity_id: 'eid', key: 'namespace', value: null },
|
||||
{ entity_id: 'eid', key: 'uid', value: null },
|
||||
]);
|
||||
|
||||
@@ -127,17 +127,17 @@ export function buildEntitySearch(
|
||||
{
|
||||
entity_id: entityId,
|
||||
key: 'metadata.name',
|
||||
value: toValue(entity.metadata?.name),
|
||||
value: toValue(entity.metadata.name),
|
||||
},
|
||||
{
|
||||
entity_id: entityId,
|
||||
key: 'metadata.namespace',
|
||||
value: toValue(entity.metadata?.namespace),
|
||||
value: toValue(entity.metadata.namespace),
|
||||
},
|
||||
{
|
||||
entity_id: entityId,
|
||||
key: 'metadata.uid',
|
||||
value: toValue(entity.metadata?.uid),
|
||||
value: toValue(entity.metadata.uid),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ export type DbEntitiesRow = {
|
||||
namespace: string | null;
|
||||
etag: string;
|
||||
generation: number;
|
||||
metadata: string | null;
|
||||
metadata: string;
|
||||
spec: string | null;
|
||||
};
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ export class IngestionModels implements IngestionModel {
|
||||
result.push(item);
|
||||
} else {
|
||||
try {
|
||||
const output = await this.entityPolicy.apply(item.data);
|
||||
const output = await this.entityPolicy.enforce(item.data);
|
||||
result.push({ type: 'data', data: output });
|
||||
} catch (e) {
|
||||
result.push({ type: 'error', error: e });
|
||||
|
||||
@@ -17,4 +17,4 @@
|
||||
export { LocationReaders } from './LocationReaders';
|
||||
export { FileLocationReader } from './readers/FileLocationReader';
|
||||
export { GitHubLocationReader } from './readers/GitHubLocationReader';
|
||||
export { LocationReader } from './readers/types';
|
||||
export type { LocationReader } from './readers/types';
|
||||
|
||||
@@ -38,7 +38,9 @@ class MockLocationsCatalog implements LocationsCatalog {
|
||||
describe('createRouter', () => {
|
||||
describe('entities', () => {
|
||||
it('happy path: lists entities', async () => {
|
||||
const entities: Entity[] = [{ apiVersion: 'a', kind: 'b' }];
|
||||
const entities: Entity[] = [
|
||||
{ apiVersion: 'a', kind: 'b', metadata: { name: 'n' } },
|
||||
];
|
||||
|
||||
const catalog = new MockEntitiesCatalog();
|
||||
catalog.entities.mockResolvedValueOnce(entities);
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
export {};
|
||||
|
||||
@@ -28,7 +28,8 @@ import {
|
||||
} from 'circleci-api';
|
||||
import { createApiRef } from '@backstage/core';
|
||||
|
||||
export { BuildWithSteps, BuildStepAction, BuildSummary, GitType };
|
||||
export { GitType };
|
||||
export type { BuildWithSteps, BuildStepAction, BuildSummary };
|
||||
|
||||
export const circleCIApiRef = createApiRef<CircleCIApi>({
|
||||
id: 'plugin.circleci.service',
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import '@backstage/backend-common';
|
||||
|
||||
describe('test', () => {
|
||||
it('unbreaks the test runner', () => {
|
||||
expect(true).toBeTruthy();
|
||||
|
||||
@@ -15,3 +15,4 @@
|
||||
*/
|
||||
|
||||
require('jest-fetch-mock').enableMocks();
|
||||
export {};
|
||||
|
||||
Reference in New Issue
Block a user