feat(graphql): Update jwt tokens, add tests.

This commit is contained in:
SamTolmay 2021-03-04 13:27:40 +02:00
parent 872ef2e387
commit f5ea705074
7 changed files with 512 additions and 21 deletions

View File

@ -25,8 +25,8 @@ function createContext(config) {
const bootstrapContext = {
CONFIGURATION_BASE_PATH,
development,
logger,
getSecrets,
logger,
};
// lambda context function signature is ({ event }),
// but express is ({ req })

View File

@ -25,7 +25,6 @@ const logger = {
log: mockLog,
};
const mockGetHeadersFromInput = jest.fn((input) => input.headers);
const mockGetSecrets = jest.fn(() => ({}));
const config = {
@ -34,6 +33,14 @@ const config = {
getSecrets: mockGetSecrets,
};
const input = {
req: {
headers: {
host: 'host',
},
},
};
/* TODO:
- secrets can only be accessed where they should be
- CONFIGURATION_BASE_PATH is mapped to loaders
@ -46,7 +53,7 @@ test('create context function', () => {
test('context function returns context object with getController and logger', async () => {
const contextFn = createContext(config);
const context = await contextFn();
const context = await contextFn(input);
expect(context).toBeInstanceOf(Object);
expect(context.logger).toBe(logger);
expect(context.getController).toBeInstanceOf(Function);
@ -55,7 +62,7 @@ test('context function returns context object with getController and logger', as
test('context function returns context object with getController and logger', async () => {
const contextFn = createContext(config);
const context = await contextFn();
const context = await contextFn(input);
expect(context).toBeInstanceOf(Object);
expect(context.logger).toBe(logger);
expect(context.getController).toBeInstanceOf(Function);
@ -63,7 +70,7 @@ test('context function returns context object with getController and logger', as
test('getController returns the correct controllers', async () => {
const contextFn = createContext(config);
const context = await contextFn();
const context = await contextFn(input);
const pageController = context.getController('page');
expect(pageController).toBeInstanceOf(PageController);
const componentController = context.getController('component');
@ -72,14 +79,54 @@ test('getController returns the correct controllers', async () => {
test('logger is mapped through', async () => {
const contextFn = createContext(config);
const context = await contextFn();
const context = await contextFn(input);
context.logger.log('test');
expect(mockLog.mock.calls).toEqual([['test']]);
});
test('request controller has getSecrets', async () => {
const contextFn = createContext(config);
const context = await contextFn();
const context = await contextFn(input);
const requestController = context.getController('request');
expect(requestController.getSecrets).toBe(mockGetSecrets);
});
test('request controller get host from req', async () => {
const contextFn = createContext(config);
let context = await contextFn({
req: {
headers: {
host: 'host',
},
},
});
let openIDController = context.getController('openId');
expect(openIDController.host).toBe('host');
context = await contextFn({
req: {
headers: {
Host: 'host',
},
},
});
openIDController = context.getController('openId');
expect(openIDController.host).toBe('host');
context = await contextFn({
event: {
headers: {
host: 'host',
},
},
});
openIDController = context.getController('openId');
expect(openIDController.host).toBe('host');
context = await contextFn({
event: {
headers: {
Host: 'host',
},
},
});
openIDController = context.getController('openId');
expect(openIDController.host).toBe('host');
});

View File

@ -85,7 +85,9 @@ class OpenIdController {
async callback({ code, state }) {
try {
const config = await this.getOpenIdConfig();
if (!config) return null;
if (!config) {
throw new Error('OpenID Connect is not configured.');
}
const { claims, idToken } = await this.openIdCallback({ code, config });

View File

@ -0,0 +1,231 @@
/*
Copyright 2020-2021 Lowdefy, Inc
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 { Issuer } from 'openid-client';
import { testBootstrapContext } from '../test/testContext';
import createOpenIdController from './openIdController';
import { AuthenticationError, ConfigurationError } from '../context/errors';
jest.mock('openid-client');
const mockopenIdAuthorizationUrl = jest.fn(
// eslint-disable-next-line camelcase
({ redirect_uri, response_type, scope, state }) =>
`${redirect_uri}:${response_type}:${scope}:${state}`
);
const mockClient = jest.fn(() => ({
authorizationUrl: mockopenIdAuthorizationUrl,
}));
// eslint-disable-next-line no-undef
Issuer.discover = jest.fn(() => ({
Client: mockClient,
}));
const secrets = {
OPENID_CLIENT_ID: 'OPENID_CLIENT_ID',
OPENID_CLIENT_SECRET: 'OPENID_CLIENT_SECRET',
OPENID_DOMAIN: 'OPENID_DOMAIN',
JWT_SECRET: 'JWT_SECRET',
};
const mockLoadComponent = jest.fn();
const loaders = {
component: {
load: mockLoadComponent,
},
};
const getSecrets = jest.fn();
const context = testBootstrapContext({ getSecrets, host: 'host', loaders });
const authorizationUrlInput = { input: { i: true }, pageId: 'pageId', urlQuery: { u: true } };
const callbackInput = { code: 'code', state: 'state' };
const RealDate = Date.now;
const mockNow = jest.fn();
mockNow.mockImplementation(() => 1000);
beforeEach(() => {
mockLoadComponent.mockReset();
getSecrets.mockReset();
global.Date.now = mockNow;
});
afterAll(() => {
global.Date.now = RealDate;
});
describe('getOpenIdConfig', () => {
test('getOpenIdConfig, no optional config', async () => {
getSecrets.mockImplementation(() => secrets);
const openIdController = createOpenIdController(context);
const config = await openIdController.getOpenIdConfig();
expect(config).toEqual({
clientId: 'OPENID_CLIENT_ID',
clientSecret: 'OPENID_CLIENT_SECRET',
domain: 'OPENID_DOMAIN',
});
});
test('getOpenIdConfig, all optional config', async () => {
getSecrets.mockImplementation(() => secrets);
mockLoadComponent.mockImplementation(() => ({
auth: {
openId: {
logoutFromProvider: true,
logoutRedirectUri: 'logoutRedirectUri',
scope: 'scope',
},
},
}));
const openIdController = createOpenIdController(context);
const config = await openIdController.getOpenIdConfig();
expect(config).toEqual({
clientId: 'OPENID_CLIENT_ID',
clientSecret: 'OPENID_CLIENT_SECRET',
domain: 'OPENID_DOMAIN',
logoutFromProvider: true,
logoutRedirectUri: 'logoutRedirectUri',
scope: 'scope',
});
});
test('getOpenIdConfig, no openId config', async () => {
getSecrets.mockImplementation(() => ({}));
const openIdController = createOpenIdController(context);
const config = await openIdController.getOpenIdConfig();
expect(config).toEqual(null);
});
test('getOpenIdConfig, some openId config', async () => {
const openIdController = createOpenIdController(context);
getSecrets.mockImplementationOnce(() => ({ OPENID_CLIENT_ID: 'OPENID_CLIENT_ID' }));
await expect(openIdController.getOpenIdConfig()).rejects.toThrow(
'Invalid OpenID Connect configuration.'
);
getSecrets.mockImplementationOnce(() => ({ OPENID_CLIENT_SECRET: 'OPENID_CLIENT_SECRET' }));
await expect(openIdController.getOpenIdConfig()).rejects.toThrow(
'Invalid OpenID Connect configuration.'
);
getSecrets.mockImplementationOnce(() => ({ OPENID_DOMAIN: 'OPENID_DOMAIN' }));
await expect(openIdController.getOpenIdConfig()).rejects.toThrow(
'Invalid OpenID Connect configuration.'
);
getSecrets.mockImplementationOnce(() => ({
OPENID_CLIENT_SECRET: 'OPENID_CLIENT_SECRET',
OPENID_DOMAIN: 'OPENID_DOMAIN',
}));
await expect(openIdController.getOpenIdConfig()).rejects.toThrow(
'Invalid OpenID Connect configuration.'
);
getSecrets.mockImplementationOnce(() => ({
OPENID_CLIENT_ID: 'OPENID_CLIENT_ID',
OPENID_DOMAIN: 'OPENID_DOMAIN',
}));
await expect(openIdController.getOpenIdConfig()).rejects.toThrow(
'Invalid OpenID Connect configuration.'
);
getSecrets.mockImplementationOnce(() => ({
OPENID_CLIENT_ID: 'OPENID_CLIENT_ID',
OPENID_CLIENT_SECRET: 'OPENID_CLIENT_SECRET',
}));
await expect(openIdController.getOpenIdConfig()).rejects.toThrow(
'Invalid OpenID Connect configuration.'
);
});
});
describe('authorizationUrl', () => {
test('authorizationUrl, no openId config', async () => {
getSecrets.mockImplementation(() => ({}));
const openIdController = createOpenIdController(context);
const url = await openIdController.authorizationUrl(authorizationUrlInput);
expect(url).toEqual(null);
});
test('authorizationUrl, no optional config', async () => {
getSecrets.mockImplementation(() => secrets);
const openIdController = createOpenIdController(context);
const url = await openIdController.authorizationUrl(authorizationUrlInput);
expect(mockClient.mock.calls).toEqual([
[
{
client_id: 'OPENID_CLIENT_ID',
client_secret: 'OPENID_CLIENT_SECRET',
redirect_uris: ['https://host/auth/openid-callback'],
},
],
]);
expect(url).toEqual(
'https://host/auth/openid-callback:code:openid profile email:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnB1dCI6eyJpIjp0cnVlfSwibG93ZGVmeV9vcGVuaWRfc3RhdGVfdG9rZW4iOnRydWUsInBhZ2VJZCI6InBhZ2VJZCIsInVybFF1ZXJ5Ijp7InUiOnRydWV9LCJpYXQiOjEsImV4cCI6MzAxLCJhdWQiOiJob3N0IiwiaXNzIjoiaG9zdCJ9.-GLdtCspyagMhdx9z1VootZXXbIdLY3cbzpn5UK8eGI'
);
});
test('authorizationUrl, set scope', async () => {
getSecrets.mockImplementation(() => secrets);
mockLoadComponent.mockImplementation(() => ({
auth: {
openId: {
scope: 'custom scope',
},
},
}));
const openIdController = createOpenIdController(context);
const url = await openIdController.authorizationUrl(authorizationUrlInput);
expect(url).toEqual(
'https://host/auth/openid-callback:code:custom scope:eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpbnB1dCI6eyJpIjp0cnVlfSwibG93ZGVmeV9vcGVuaWRfc3RhdGVfdG9rZW4iOnRydWUsInBhZ2VJZCI6InBhZ2VJZCIsInVybFF1ZXJ5Ijp7InUiOnRydWV9LCJpYXQiOjEsImV4cCI6MzAxLCJhdWQiOiJob3N0IiwiaXNzIjoiaG9zdCJ9.-GLdtCspyagMhdx9z1VootZXXbIdLY3cbzpn5UK8eGI'
);
});
test('redirect Uris', async () => {
const openIdController = createOpenIdController(context);
expect(openIdController.redirectUri).toEqual('https://host/auth/openid-callback');
const devOpenIdController = createOpenIdController(
testBootstrapContext({ getSecrets, host: 'host', loaders, development: true })
);
expect(devOpenIdController.redirectUri).toEqual('http://host/auth/openid-callback');
});
test('authorizationUrl, jwt config error', async () => {
getSecrets.mockImplementation(() => ({
OPENID_CLIENT_ID: 'OPENID_CLIENT_ID',
OPENID_CLIENT_SECRET: 'OPENID_CLIENT_SECRET',
OPENID_DOMAIN: 'OPENID_DOMAIN',
}));
const openIdController = createOpenIdController(context);
await expect(openIdController.authorizationUrl(authorizationUrlInput)).rejects.toThrow(
ConfigurationError
);
});
});
describe('callback', () => {
test('callback, no openId config', async () => {
getSecrets.mockImplementation(() => ({}));
const openIdController = createOpenIdController(context);
await expect(openIdController.callback(callbackInput)).rejects.toThrow(AuthenticationError);
await expect(openIdController.callback(callbackInput)).rejects.toThrow(
'OpenID Connect is not configured.'
);
});
});

View File

@ -18,19 +18,17 @@ import jwt from 'jsonwebtoken';
import { AuthenticationError, TokenExpiredError } from '../context/errors';
class TokenController {
constructor({ getLoader, getSecrets, host }) {
this.componentLoader = getLoader('component');
constructor({ getSecrets, host }) {
this.host = host;
this.getSecrets = getSecrets;
}
async issueAccessToken({ sub, groups = [] }) {
async issueAccessToken(claims) {
const { JWT_SECRET } = await this.getSecrets();
return jwt.sign(
{
sub,
type: 'lowdefy_access',
groups,
...claims,
lowdefy_access_token: true,
},
JWT_SECRET,
{
@ -50,7 +48,7 @@ class TokenController {
issuer: this.host,
});
if (claims.type !== 'lowdefy_access') {
if (claims.lowdefy_access_token !== true) {
throw new AuthenticationError('Invalid token');
}
if (!claims.sub) {
@ -70,8 +68,8 @@ class TokenController {
const { JWT_SECRET } = await this.getSecrets();
return jwt.sign(
{
type: 'openid_state',
input,
lowdefy_openid_state_token: true,
pageId,
urlQuery,
},
@ -93,7 +91,7 @@ class TokenController {
issuer: this.host,
});
if (claims.type !== 'openid_state') {
if (claims.lowdefy_openid_state_token !== true) {
throw new AuthenticationError('Invalid token');
}
return claims;

View File

@ -0,0 +1,209 @@
/*
Copyright 2020-2021 Lowdefy, Inc
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 jwt from 'jsonwebtoken';
import { testBootstrapContext } from '../test/testContext';
import createTokenController from './tokenController';
import { AuthenticationError, TokenExpiredError } from '../context/errors';
const secrets = {
JWT_SECRET: 'JWT_SECRET',
};
const getSecrets = () => secrets;
const context = testBootstrapContext({ getSecrets, host: 'host' });
const RealDate = Date.now;
const mockNow = jest.fn();
mockNow.mockImplementation(() => 1000);
const openIdClaims = { sub: 'sub', email: 'email' };
const openIdStateLocation = { input: { i: true }, pageId: 'pageId', urlQuery: { u: true } };
beforeEach(() => {
global.Date.now = mockNow;
});
afterAll(() => {
global.Date.now = RealDate;
});
describe('access tokens', () => {
test('issueAccessToken', async () => {
const tokenController = createTokenController(context);
const accessToken = await tokenController.issueAccessToken(openIdClaims);
const claims = jwt.verify(accessToken, 'JWT_SECRET', {
algorithms: ['HS256'],
audience: 'host',
issuer: 'host',
});
expect(claims).toEqual({
aud: 'host',
email: 'email',
exp: 43201, // 12 hours
iat: 1,
iss: 'host',
sub: 'sub',
lowdefy_access_token: true,
});
});
test('verifyAccessToken', async () => {
const tokenController = createTokenController(context);
const accessToken = await tokenController.issueAccessToken(openIdClaims);
const claims = await tokenController.verifyAccessToken(accessToken);
expect(claims).toEqual({
aud: 'host',
email: 'email',
exp: 43201, // 12 hours
iat: 1,
iss: 'host',
sub: 'sub',
lowdefy_access_token: true,
});
});
test('verifyAccessToken openIdState token invalid', async () => {
const tokenController = createTokenController(context);
const token = await tokenController.issueOpenIdStateToken(openIdStateLocation);
await expect(tokenController.verifyAccessToken(token)).rejects.toThrow(AuthenticationError);
});
test('verifyAccessToken invalid token', async () => {
const tokenController = createTokenController(context);
await expect(tokenController.verifyAccessToken('not a token')).rejects.toThrow(
AuthenticationError
);
});
test('verifyAccessToken no sub invalid token', async () => {
const tokenController = createTokenController(context);
const accessToken = await tokenController.issueAccessToken({
email: 'email',
});
await expect(tokenController.verifyAccessToken(accessToken)).rejects.toThrow(
AuthenticationError
);
});
test('verifyAccessToken token expired', async () => {
const tokenController = createTokenController(context);
const token = jwt.sign(
{
sub: 'sub',
lowdefy_access_token: true,
exp: -10000,
},
'JWT_SECRET',
{
audience: 'host',
issuer: 'host',
}
);
await expect(tokenController.verifyAccessToken(token)).rejects.toThrow(TokenExpiredError);
});
});
describe('openId state tokens', () => {
test('issueOpenIdStateToken', async () => {
const tokenController = createTokenController(context);
const accessToken = await tokenController.issueOpenIdStateToken(openIdStateLocation);
const claims = jwt.verify(accessToken, 'JWT_SECRET', {
algorithms: ['HS256'],
audience: 'host',
issuer: 'host',
});
expect(claims).toEqual({
aud: 'host',
exp: 301, // 5min
iat: 1,
input: { i: true },
iss: 'host',
lowdefy_openid_state_token: true,
pageId: 'pageId',
urlQuery: { u: true },
});
});
test('issueOpenIdStateToken, no location data', async () => {
const tokenController = createTokenController(context);
const accessToken = await tokenController.issueOpenIdStateToken({});
const claims = jwt.verify(accessToken, 'JWT_SECRET', {
algorithms: ['HS256'],
audience: 'host',
issuer: 'host',
});
expect(claims).toEqual({
aud: 'host',
exp: 301, // 5min
iat: 1,
iss: 'host',
lowdefy_openid_state_token: true,
});
});
test('verifyOpenIdStateToken', async () => {
const tokenController = createTokenController(context);
const accessToken = await tokenController.issueOpenIdStateToken(openIdStateLocation);
const claims = await tokenController.verifyOpenIdStateToken(accessToken);
expect(claims).toEqual({
aud: 'host',
exp: 301, // 5min
iat: 1,
input: { i: true },
iss: 'host',
lowdefy_openid_state_token: true,
pageId: 'pageId',
urlQuery: { u: true },
});
});
test('verifyOpenIdStateToken access token invalid', async () => {
const tokenController = createTokenController(context);
const token = await tokenController.issueAccessToken(openIdClaims);
await expect(tokenController.verifyOpenIdStateToken(token)).rejects.toThrow(
AuthenticationError
);
});
test('verifyOpenIdStateToken invalid token', async () => {
const tokenController = createTokenController(context);
expect(tokenController.verifyOpenIdStateToken('not a token')).rejects.toThrow(
AuthenticationError
);
});
test('verifyOpenIdStateToken token expired', async () => {
const tokenController = createTokenController(context);
const token = jwt.sign(
{
lowdefy_openid_state_token: true,
exp: -10000,
},
'JWT_SECRET',
{
audience: 'host',
issuer: 'host',
}
);
await expect(tokenController.verifyOpenIdStateToken(token)).rejects.toThrow(
AuthenticationError
);
});
});

View File

@ -14,24 +14,28 @@
limitations under the License.
*/
import createGetController from '../context/getController';
import createGetController from '../controllers/getController';
function testBootstrapContext({ loaders, getSecrets } = {}) {
function testBootstrapContext({ development, getSecrets, host, loaders } = {}) {
const bootstrapContext = {
CONFIGURATION_BASE_PATH: 'CONFIGURATION_BASE_PATH',
getLoader: loaders ? (name) => loaders[name] : () => {},
development,
getController: () => {},
getLoader: loaders ? (name) => loaders[name] : () => {},
getSecrets: getSecrets || (() => {}),
host: host || 'host',
logger: { log: () => {} },
};
bootstrapContext.getController = createGetController(bootstrapContext);
return bootstrapContext;
}
function testContext({ loaders, getSecrets } = {}) {
function testContext({ development, getSecrets, host, loaders } = {}) {
const bootstrapContext = {
development,
getLoader: (name) => loaders[name],
getSecrets: getSecrets || (() => {}),
host: host || 'host',
logger: { log: () => {} },
};
bootstrapContext.getController = createGetController(bootstrapContext);