зеркало из https://github.com/mozilla/fxa.git
feat(gql-api): add auth to graphql-api server
This patch enables graphql-api server to accept an OAuth2 access token on the graphql route. It: 1. validates the format of the authorization header value in middleware 2. looks up the token in Redis 3. ensures the client id of the token is in the allowed clients list 4. includes the user's id and email in the 'authUser' property in Apollo's context 5. includes the authorization header value as 'token' in Apollo's context
This commit is contained in:
Родитель
70e822f768
Коммит
7ed4037665
|
@ -272,7 +272,8 @@ If you're using `npm start`, the following ports are used for `--inspect`:
|
|||
| 9160 | auth-server |
|
||||
| 9170 | payments-server |
|
||||
| 9180 | event-broker |
|
||||
| 9190 | graphql-api |
|
||||
| 9190 | support-panel |
|
||||
| 9200 | graphql-api |
|
||||
|
||||
#### Debugging tests
|
||||
|
||||
|
|
|
@ -729,6 +729,15 @@
|
|||
"resolved": "https://registry.npmjs.org/@types/http-assert/-/http-assert-1.5.1.tgz",
|
||||
"integrity": "sha512-PGAK759pxyfXE78NbKxyfRcWYA/KwW17X290cNev/qAsn9eQIxkH4shoNBafH37wewhDG/0p1cHPbK6+SzZjWQ=="
|
||||
},
|
||||
"@types/ioredis": {
|
||||
"version": "4.16.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/ioredis/-/ioredis-4.16.0.tgz",
|
||||
"integrity": "sha512-fxR2oHLj0NIqdM9OT8/hwPmlHI05i77UVfP9deys8+ZutZuo0SneA7FvXm2Kage6drQyl8F5gHWiTGK0lXaCCA==",
|
||||
"dev": true,
|
||||
"requires": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"@types/keygrip": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/keygrip/-/keygrip-1.0.2.tgz",
|
||||
|
@ -1912,6 +1921,11 @@
|
|||
"mimic-response": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"cluster-key-slot": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.0.tgz",
|
||||
"integrity": "sha512-2Nii8p3RwAPiFwsnZvukotvow2rIHM+yQ6ZcBXGHdniadkYGZYiGmkHJIbZPIV9nfv7m/U1IPMVVcAhoWFeklw=="
|
||||
},
|
||||
"co": {
|
||||
"version": "4.6.0",
|
||||
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
|
||||
|
@ -2249,6 +2263,11 @@
|
|||
"resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
|
||||
"integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk="
|
||||
},
|
||||
"denque": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-1.4.1.tgz",
|
||||
"integrity": "sha512-OfzPuSZKGcgr96rf1oODnfjqBFmr1DVoc/TrItj3Ohe0Ah1C5WX5Baquw/9U9KovnQ88EqmJbD66rKYUQYN1tQ=="
|
||||
},
|
||||
"depd": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-1.1.2.tgz",
|
||||
|
@ -3441,6 +3460,32 @@
|
|||
"resolved": "https://registry.npmjs.org/interpret/-/interpret-2.0.0.tgz",
|
||||
"integrity": "sha512-e0/LknJ8wpMMhTiWcjivB+ESwIuvHnBSlBbmP/pSb8CQJldoj1p2qv7xGZ/+BtbTziYRFSz8OsvdbiX45LtYQA=="
|
||||
},
|
||||
"ioredis": {
|
||||
"version": "4.16.3",
|
||||
"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-4.16.3.tgz",
|
||||
"integrity": "sha512-Ejvcs2yW19Vq8AipvbtfcX3Ig8XG9EAyFOvGbhI/Q1QoVOK9ZdgY092kdOyOWIYBnPHjfjMJhU9qhsnp0i0K1w==",
|
||||
"requires": {
|
||||
"cluster-key-slot": "^1.1.0",
|
||||
"debug": "^4.1.1",
|
||||
"denque": "^1.1.0",
|
||||
"lodash.defaults": "^4.2.0",
|
||||
"lodash.flatten": "^4.4.0",
|
||||
"redis-commands": "1.5.0",
|
||||
"redis-errors": "^1.2.0",
|
||||
"redis-parser": "^3.0.0",
|
||||
"standard-as-callback": "^2.0.1"
|
||||
},
|
||||
"dependencies": {
|
||||
"debug": {
|
||||
"version": "4.1.1",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz",
|
||||
"integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==",
|
||||
"requires": {
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ip": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/ip/-/ip-1.1.5.tgz",
|
||||
|
@ -3881,6 +3926,16 @@
|
|||
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||
"integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8="
|
||||
},
|
||||
"lodash.defaults": {
|
||||
"version": "4.2.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
|
||||
"integrity": "sha1-0JF4cW/+pN3p5ft7N/bwgCJ0WAw="
|
||||
},
|
||||
"lodash.flatten": {
|
||||
"version": "4.4.0",
|
||||
"resolved": "https://registry.npmjs.org/lodash.flatten/-/lodash.flatten-4.4.0.tgz",
|
||||
"integrity": "sha1-8xwiIlqWMtK7+OSt2+8kCqdlph8="
|
||||
},
|
||||
"lodash.get": {
|
||||
"version": "4.4.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz",
|
||||
|
@ -5404,6 +5459,24 @@
|
|||
"resolve": "^1.1.6"
|
||||
}
|
||||
},
|
||||
"redis-commands": {
|
||||
"version": "1.5.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-commands/-/redis-commands-1.5.0.tgz",
|
||||
"integrity": "sha512-6KxamqpZ468MeQC3bkWmCB1fp56XL64D4Kf0zJSwDZbVLLm7KFkoIcHrgRvQ+sk8dnhySs7+yBg94yIkAK7aJg=="
|
||||
},
|
||||
"redis-errors": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
|
||||
"integrity": "sha1-62LSrbFeTq9GEMBK/hUpOEJQq60="
|
||||
},
|
||||
"redis-parser": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
|
||||
"integrity": "sha1-tm2CjNyv5rS4pCin3vTGvKwxyLQ=",
|
||||
"requires": {
|
||||
"redis-errors": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"reflect-metadata": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.1.13.tgz",
|
||||
|
@ -5990,6 +6063,11 @@
|
|||
"resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz",
|
||||
"integrity": "sha1-VHxws0fo0ytOEI6hoqFZ5f3eGcA="
|
||||
},
|
||||
"standard-as-callback": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.0.1.tgz",
|
||||
"integrity": "sha512-NQOxSeB8gOI5WjSaxjBgog2QFw55FV8TkS6Y07BiB3VJ8xNTvUYm0wl0s8ObgQ5NhdpnNfigMIKjgPESzgr4tg=="
|
||||
},
|
||||
"static-extend": {
|
||||
"version": "0.1.2",
|
||||
"resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz",
|
||||
|
|
|
@ -11,7 +11,7 @@
|
|||
"start": "pm2 start pm2.config.js",
|
||||
"stop": "pm2 stop pm2.config.js",
|
||||
"restart": "pm2 restart pm2.config.js",
|
||||
"test": "./node_modules/mocha/bin/mocha -r ts-node/register src/test/**/*.spec.ts src/test/**/**/*.spec.ts src/test/**/**/**/*.spec.ts",
|
||||
"test": "./node_modules/mocha/bin/mocha -r ts-node/register 'src/test/**/*.spec.ts'",
|
||||
"email-bounce": "ts-node ./scripts/email-bounce.ts"
|
||||
},
|
||||
"repository": {
|
||||
|
@ -35,6 +35,7 @@
|
|||
"apollo-server-express": "^2.12.0",
|
||||
"convict": "^5.2.0",
|
||||
"graphql": "^14.6.0",
|
||||
"ioredis": "^4.16.3",
|
||||
"knex": "^0.21.0",
|
||||
"mozlog": "^2.2.0",
|
||||
"mysql": "^2.18.1",
|
||||
|
@ -49,6 +50,7 @@
|
|||
"@types/chance": "^1.0.10",
|
||||
"@types/convict": "^4.2.1",
|
||||
"@types/graphql": "^14.5.0",
|
||||
"@types/ioredis": "^4.16.0",
|
||||
"@types/mocha": "^7.0.2",
|
||||
"@types/node": "^12.12.6",
|
||||
"@types/proxyquire": "^1.3.28",
|
||||
|
|
|
@ -12,7 +12,7 @@ module.exports = {
|
|||
min_uptime: '2m',
|
||||
env: {
|
||||
NODE_ENV: 'development',
|
||||
NODE_OPTIONS: '--inspect=9190',
|
||||
NODE_OPTIONS: '--inspect=9200',
|
||||
TS_NODE_TRANSPILE_ONLY: 'true',
|
||||
TS_NODE_FILES: 'true',
|
||||
PORT: '8290', // TODO: this needs to get added to src/config.ts
|
||||
|
|
|
@ -3,24 +3,48 @@
|
|||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import 'reflect-metadata';
|
||||
import { Container } from 'typedi';
|
||||
|
||||
import express from 'express';
|
||||
import mozlog from 'mozlog';
|
||||
import Redis from 'ioredis';
|
||||
|
||||
import {
|
||||
configContainerToken,
|
||||
loggerContainerToken,
|
||||
userLookupFnContainerToken,
|
||||
redisContainerToken,
|
||||
} from '../lib/constants';
|
||||
|
||||
import Config from '../config';
|
||||
Container.set(configContainerToken, Config);
|
||||
const config = Config.getProperties();
|
||||
|
||||
const logger = mozlog(config.logging)('graphql-api');
|
||||
Container.set(loggerContainerToken, logger);
|
||||
const redis = new Redis({
|
||||
...config.redis.accessTokens,
|
||||
keyPrefix: config.redis.accessTokens.prefix,
|
||||
});
|
||||
Container.set(redisContainerToken, redis);
|
||||
import fetchUserByToken from '../lib/user';
|
||||
Container.set(userLookupFnContainerToken, fetchUserByToken);
|
||||
|
||||
import { dbHealthCheck } from '../lib/db';
|
||||
import { loadBalancerRoutes } from '../lib/middleware';
|
||||
import { configureSentry } from '../lib/sentry';
|
||||
import { createServer } from '../lib/server';
|
||||
import { version } from '../lib/version';
|
||||
import { oauthBearerTokenValidator } from '../lib/oauth';
|
||||
|
||||
const logger = mozlog(Config.get('logging'))('supportPanel');
|
||||
configureSentry({ dsn: Config.getProperties().sentryDsn, release: version.version });
|
||||
configureSentry({ dsn: config.sentryDsn, release: version.version });
|
||||
|
||||
async function run() {
|
||||
const app = express();
|
||||
const server = await createServer(Config.getProperties(), logger);
|
||||
const server = await createServer(config, logger);
|
||||
app.use(server.graphqlPath, oauthBearerTokenValidator);
|
||||
server.applyMiddleware({ app });
|
||||
|
||||
app.use(loadBalancerRoutes(dbHealthCheck));
|
||||
app.listen({ port: 8290 }, () => {
|
||||
logger.info('startup', {
|
||||
|
|
|
@ -8,7 +8,7 @@ import path from 'path';
|
|||
|
||||
const conf = convict({
|
||||
authHeader: {
|
||||
default: 'oidc-claim-id-token-email',
|
||||
default: 'authorization',
|
||||
doc: 'Authentication header that should be logged for the user',
|
||||
env: 'AUTH_HEADER',
|
||||
format: String,
|
||||
|
@ -52,7 +52,7 @@ const conf = convict({
|
|||
format: ['development', 'test', 'stage', 'production'],
|
||||
},
|
||||
logging: {
|
||||
app: { default: 'fxa-user-admin-server' },
|
||||
app: { default: 'fxa-graphql-api-server' },
|
||||
fmt: {
|
||||
default: 'heka',
|
||||
env: 'LOGGING_FORMAT',
|
||||
|
@ -74,6 +74,42 @@ const conf = convict({
|
|||
},
|
||||
},
|
||||
},
|
||||
oauth: {
|
||||
accessToken: {
|
||||
hexLength: {
|
||||
default: 64,
|
||||
doc: 'Number of characters in an access token as a hex string',
|
||||
env: 'OAUTH_ACCESS_TOKEN_LENGTH',
|
||||
format: 'int',
|
||||
},
|
||||
},
|
||||
allowedClients: {
|
||||
default: [],
|
||||
doc: 'A list of OAuth client ids that are allowed to use this GraphQL api',
|
||||
env: 'OAUTH_ALLOWED_CLIENTS',
|
||||
format: Array,
|
||||
},
|
||||
},
|
||||
redis: {
|
||||
accessTokens: {
|
||||
host: {
|
||||
default: 'localhost',
|
||||
env: 'ACCESS_TOKEN_REDIS_HOST',
|
||||
format: String,
|
||||
},
|
||||
port: {
|
||||
default: 6379,
|
||||
env: 'ACCESS_TOKEN_REDIS_PORT',
|
||||
format: 'port',
|
||||
},
|
||||
prefix: {
|
||||
default: 'at:',
|
||||
env: 'ACCESS_TOKEN_REDIS_KEY_PREFIX',
|
||||
format: String,
|
||||
doc: 'Key prefix for access tokens in Redis',
|
||||
},
|
||||
},
|
||||
},
|
||||
sentryDsn: {
|
||||
default: '',
|
||||
doc: 'Sentry DSN for error and log reporting',
|
||||
|
@ -95,4 +131,5 @@ conf.loadFile(files);
|
|||
conf.validate({ allowed: 'strict' });
|
||||
const Config = conf;
|
||||
|
||||
export type AppConfig = typeof Config;
|
||||
export default Config;
|
||||
|
|
|
@ -0,0 +1,14 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Token } from 'typedi';
|
||||
import { AppConfig } from '../config';
|
||||
import { Redis } from 'ioredis';
|
||||
import { Logger } from 'mozlog';
|
||||
import { UserLookupFn } from './user';
|
||||
|
||||
export const configContainerToken = new Token<AppConfig>();
|
||||
export const redisContainerToken = new Token<Redis>();
|
||||
export const loggerContainerToken = new Token<Logger>();
|
||||
export const userLookupFnContainerToken = new Token<UserLookupFn>();
|
|
@ -0,0 +1,35 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Container } from 'typedi';
|
||||
import crypto from 'crypto';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { configContainerToken, loggerContainerToken } from '../constants';
|
||||
|
||||
const Config = Container.get(configContainerToken);
|
||||
const logger = Container.get(loggerContainerToken);
|
||||
const authHeaderKey = Config.get('authHeader').toLowerCase();
|
||||
const TOKEN_LENGTH = Config.get('oauth.accessToken.hexLength');
|
||||
const tokenPattern = new RegExp(`^Bearer [0-9a-f]{${TOKEN_LENGTH}}$`);
|
||||
|
||||
/**
|
||||
* Check the format of the authorization header value. Used as Express
|
||||
* middleware.
|
||||
*/
|
||||
export function oauthBearerTokenValidator(req: Request, res: Response, next: NextFunction) {
|
||||
if (!req.headers[authHeaderKey] || !tokenPattern.test(req.headers[authHeaderKey] as string)) {
|
||||
logger.debug('invalidToken', req.headers);
|
||||
return res.status(401).send('Invalid Token');
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a SHA-256 hash of the token.
|
||||
*/
|
||||
export function getTokenId(token: string) {
|
||||
const sha256 = crypto.createHash('sha256');
|
||||
sha256.update(Buffer.from(token, 'hex'));
|
||||
return sha256.digest('hex');
|
||||
}
|
|
@ -2,14 +2,15 @@
|
|||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { ApolloServer } from 'apollo-server-express';
|
||||
import { Container } from 'typedi';
|
||||
import { ApolloServer, AuthenticationError } from 'apollo-server-express';
|
||||
import { Logger } from 'mozlog';
|
||||
import * as TypeGraphQL from 'type-graphql';
|
||||
import { Container } from 'typedi';
|
||||
|
||||
import { DatabaseConfig, setupDatabase } from './db';
|
||||
import { AccountResolver } from './resolvers/account-resolver';
|
||||
import { reportGraphQLError } from './sentry';
|
||||
import { userLookupFnContainerToken } from './constants';
|
||||
|
||||
type ServerConfig = {
|
||||
authHeader: string;
|
||||
|
@ -32,19 +33,24 @@ export async function createServer(
|
|||
context?: () => object
|
||||
): Promise<ApolloServer> {
|
||||
setupDatabase(config.database);
|
||||
const fetchUserFn = Container.get(userLookupFnContainerToken);
|
||||
const schema = await TypeGraphQL.buildSchema({
|
||||
container: Container,
|
||||
resolvers: [AccountResolver],
|
||||
});
|
||||
const debugMode = config.env !== 'production';
|
||||
const defaultContext = ({ req }: any) => {
|
||||
const authUser = req.headers[config.authHeader.toLowerCase()];
|
||||
const defaultContext = async ({ req }: any) => {
|
||||
const bearerToken = req.headers[config.authHeader.toLowerCase()];
|
||||
const authUser = await fetchUserFn(bearerToken);
|
||||
if (!authUser) throw new AuthenticationError('You must be logged in');
|
||||
|
||||
return {
|
||||
authUser,
|
||||
logAction: (action: string, options?: object): void => {
|
||||
logger.info(action, { authUser, ...options });
|
||||
logger.info(action, { uid: authUser.userId, ...options });
|
||||
},
|
||||
logger,
|
||||
token: bearerToken,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -0,0 +1,34 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Container } from 'typedi';
|
||||
import Redis from 'ioredis';
|
||||
import { OAuthUser } from './types';
|
||||
import { getTokenId } from '../oauth';
|
||||
import { redisContainerToken, configContainerToken } from '../constants';
|
||||
import { AppConfig } from '../../config';
|
||||
|
||||
const Config = Container.get(configContainerToken);
|
||||
const redis = Container.get(redisContainerToken);
|
||||
const allowedClients = Config.get('oauth.allowedClients') as string[];
|
||||
|
||||
export default async function fetchUserByToken(
|
||||
authorizationHeader: string
|
||||
): Promise<OAuthUser | null> {
|
||||
// The value of the header has already been validated.
|
||||
const token: string = authorizationHeader.split(' ', 2)[1];
|
||||
const tokenId = getTokenId(token);
|
||||
const tokenString = await redis.get(`${tokenId}`);
|
||||
if (tokenString) {
|
||||
const tokenInfo = JSON.parse(tokenString);
|
||||
|
||||
if (allowedClients.includes(tokenInfo.clientId)) {
|
||||
return { userId: tokenInfo.userId, email: tokenInfo.email };
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export type UserLookupFn = typeof fetchUserByToken;
|
|
@ -0,0 +1,8 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
export type OAuthUser = {
|
||||
userId: string;
|
||||
email: string;
|
||||
};
|
|
@ -0,0 +1,91 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Container } from 'typedi';
|
||||
import { Request, Response } from 'express';
|
||||
import { assert } from 'chai';
|
||||
import 'mocha';
|
||||
import sinon from 'sinon';
|
||||
|
||||
import { loggerContainerToken, configContainerToken } from '../../../lib/constants';
|
||||
|
||||
const sandbox = sinon.createSandbox();
|
||||
// tslint:disable-next-line: no-empty
|
||||
const mockLogger = { debug: sandbox.stub() };
|
||||
Container.set(loggerContainerToken, mockLogger);
|
||||
const mockConfig = {
|
||||
get: (k: string): string | number => {
|
||||
const fakeConfigs: { [key: string]: string | number } = {
|
||||
authHeader: 'authorization',
|
||||
'oauth.accessToken.hexLength': 64,
|
||||
};
|
||||
return fakeConfigs[k];
|
||||
},
|
||||
};
|
||||
Container.set(configContainerToken, mockConfig);
|
||||
|
||||
import { getTokenId, oauthBearerTokenValidator } from '../../../lib/oauth';
|
||||
import { AppConfig } from '../../../config';
|
||||
|
||||
describe('oauthBearerTokenValidator', () => {
|
||||
const sendStub = sandbox.stub();
|
||||
const statusStub = sandbox.stub().returns({ send: sendStub });
|
||||
const res = ({ status: statusStub } as unknown) as Response;
|
||||
const nextFunc = sandbox.stub();
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox.resetHistory();
|
||||
});
|
||||
|
||||
it('should 401 when the authorization header is missing', () => {
|
||||
const req = { headers: {} } as Request;
|
||||
oauthBearerTokenValidator(req, res, nextFunc);
|
||||
|
||||
sinon.assert.calledOnceWithExactly(statusStub, 401);
|
||||
sinon.assert.calledOnce(sendStub);
|
||||
sinon.assert.calledOnce(mockLogger.debug);
|
||||
assert.isFalse(nextFunc.called);
|
||||
});
|
||||
|
||||
it('should 401 when the authorization header is empty', () => {
|
||||
const req = ({ headers: { authorization: '' } } as unknown) as Request;
|
||||
oauthBearerTokenValidator(req, res, nextFunc);
|
||||
|
||||
sinon.assert.calledOnceWithExactly(statusStub, 401);
|
||||
sinon.assert.calledOnce(sendStub);
|
||||
sinon.assert.calledOnce(mockLogger.debug);
|
||||
assert.isFalse(nextFunc.called);
|
||||
});
|
||||
|
||||
it('should 401 when the token is not in the correct format', () => {
|
||||
const req = ({ headers: { authorization: 'this is no HEX!' } } as unknown) as Request;
|
||||
oauthBearerTokenValidator(req, res, nextFunc);
|
||||
|
||||
sinon.assert.calledOnceWithExactly(statusStub, 401);
|
||||
sinon.assert.calledOnce(sendStub);
|
||||
sinon.assert.calledOnce(mockLogger.debug);
|
||||
assert.isFalse(nextFunc.called);
|
||||
});
|
||||
|
||||
it('should call next() when validation is successful', () => {
|
||||
const req = ({
|
||||
headers: {
|
||||
authorization: 'Bearer fc8d07fbbe179b7d75e73172884158053a357692f491cf678540558744f2e4a5',
|
||||
},
|
||||
} as unknown) as Request;
|
||||
oauthBearerTokenValidator(req, res, nextFunc);
|
||||
|
||||
assert.isFalse(statusStub.called);
|
||||
assert.isFalse(sendStub.called);
|
||||
assert.isTrue(nextFunc.called);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTokenId', () => {
|
||||
it('should return the hex string of a SHA-256 hash', () => {
|
||||
const expected = 'bfc1e6a89fd9ecca18d8da13cb2676b623cfd4d8c694e07018cbd90bc11097e2';
|
||||
const actual = getTokenId('fc8d07fbbe179b7d75e73172884158053a357692f491cf678540558744f2e4a5');
|
||||
assert.equal(actual, expected);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,55 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Container } from 'typedi';
|
||||
import { assert } from 'chai';
|
||||
import 'mocha';
|
||||
import sinon from 'sinon';
|
||||
import { Logger } from 'mozlog';
|
||||
import { createServer } from '../../lib/server';
|
||||
import { userLookupFnContainerToken } from '../../lib/constants';
|
||||
import { AuthenticationError } from 'apollo-server';
|
||||
import Config from '../../config';
|
||||
|
||||
const sandbox = sinon.createSandbox();
|
||||
|
||||
const userFetchFn = sandbox.stub();
|
||||
Container.set(userLookupFnContainerToken, userFetchFn);
|
||||
|
||||
// tslint:disable-next-line: no-empty
|
||||
const mockLogger = ({ info: () => {} } as unknown) as Logger;
|
||||
|
||||
describe('createServer', () => {
|
||||
describe('the default context', async () => {
|
||||
const server = await createServer(Config.getProperties(), mockLogger);
|
||||
|
||||
beforeEach(() => {
|
||||
sandbox.resetBehavior();
|
||||
sandbox.resetHistory();
|
||||
});
|
||||
|
||||
it('should throw an AuthenticationError when user is not found', async () => {
|
||||
userFetchFn.returns(null);
|
||||
try {
|
||||
await (server as any).context({ req: { headers: {} } });
|
||||
assert.fail('Should have thrown an exception');
|
||||
} catch (e) {
|
||||
assert.instanceOf(e, AuthenticationError);
|
||||
}
|
||||
});
|
||||
|
||||
it('should return a user and the bearer token', async () => {
|
||||
userFetchFn.returns({ userId: '9001xyz', email: 'testo@example.com' });
|
||||
try {
|
||||
const context = await (server as any).context({
|
||||
req: { headers: { authorization: 'Bearer lolcatz' } },
|
||||
});
|
||||
assert.deepEqual(context.authUser, { userId: '9001xyz', email: 'testo@example.com' });
|
||||
assert.equal(context.token, 'Bearer lolcatz');
|
||||
} catch (e) {
|
||||
assert.fail('Should have thrown an exception');
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
|
@ -0,0 +1,49 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
|
||||
import { Container } from 'typedi';
|
||||
import { assert } from 'chai';
|
||||
import 'mocha';
|
||||
import sinon from 'sinon';
|
||||
const sandbox = sinon.createSandbox();
|
||||
|
||||
import { configContainerToken, redisContainerToken } from '../../../lib/constants';
|
||||
const mockConfig = {
|
||||
get: (k: string) => {
|
||||
return k === 'oauth.allowedClients' ? ['wibble'] : '';
|
||||
},
|
||||
};
|
||||
Container.set(configContainerToken, mockConfig);
|
||||
const bearerToken = 'Bearer thisissomesecret';
|
||||
const mockToken = { clientId: 'wibble', userId: '9001xyz', email: 'testo@example.com' };
|
||||
const mockRedis = { get: sandbox.stub() };
|
||||
Container.set(redisContainerToken, mockRedis);
|
||||
|
||||
import fetchUserByToken from '../../../lib/user';
|
||||
|
||||
describe('fetchUserByToken', () => {
|
||||
beforeEach(() => {
|
||||
sandbox.resetBehavior();
|
||||
sandbox.resetHistory();
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
it('should return null when token is not found', async () => {
|
||||
mockRedis.get.returns('');
|
||||
const acutal = await fetchUserByToken(bearerToken);
|
||||
assert.isNull(acutal);
|
||||
});
|
||||
|
||||
it('should return null when the client is not in the allowed list', async () => {
|
||||
mockRedis.get.returns(JSON.stringify({ ...mockToken, clientId: 'nope' }));
|
||||
const acutal = await fetchUserByToken(bearerToken);
|
||||
assert.isNull(acutal);
|
||||
});
|
||||
|
||||
it('should return an OAuthUser when token is found', async () => {
|
||||
mockRedis.get.returns(JSON.stringify(mockToken));
|
||||
const acutal = await fetchUserByToken(bearerToken);
|
||||
assert.deepEqual(acutal, { userId: mockToken.userId, email: mockToken.email });
|
||||
});
|
||||
});
|
Загрузка…
Ссылка в новой задаче