Skip to content

Commit

Permalink
Add /register route with tests
Browse files Browse the repository at this point in the history
  • Loading branch information
humphd committed Apr 16, 2021
1 parent 2eb90e0 commit 7925bb8
Show file tree
Hide file tree
Showing 10 changed files with 212 additions and 33 deletions.
1 change: 0 additions & 1 deletion src/api/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,6 @@
"celebrate": "^14.0.0",
"connect-redis": "^5.1.0",
"express-session": "^1.17.1",
"http-errors": "^1.8.0",
"jsonwebtoken": "^8.5.1",
"minimatch": "^3.0.4",
"node-fetch": "^2.6.1",
Expand Down
1 change: 0 additions & 1 deletion src/api/auth/src/authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ const strategy = new SamlStrategy(
logger.debug({ senecaProfile, telescopeProfile }, 'Telescope user authenticated');
done(null, new User(senecaProfile, telescopeProfile));
} catch (err) {
console.log({ err });
logger.error({ err });
done(err, false);
}
Expand Down
65 changes: 64 additions & 1 deletion src/api/auth/src/middleware.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
const { logger, createError } = require('@senecacdot/satellite');
const { celebrate, Segments, Joi } = require('celebrate');
const fetch = require('node-fetch');

const { matchOrigin } = require('./util');
const { matchOrigin, getUserId } = require('./util');

const { USERS_URL } = process.env;

// Space-separated list of App origins that we know about and will allow
// to be used as part of login redirects. You only need to specify
Expand Down Expand Up @@ -82,3 +85,63 @@ module.exports.captureAuthDetailsOnSession = function captureAuthDetailsOnSessio
next();
};
};

// Forward a request to create a new user to the Users service.
module.exports.createTelescopeUser = function createTelescopeUser() {
return async (req, res, next) => {
try {
const id = getUserId(req);
const response = await fetch(`${USERS_URL}/${id}`, {
method: 'POST',
headers: {
// re-use the user's authorization header and token
Authorization: req.get('Authorization'),
'Content-Type': 'application/json',
},
body: JSON.stringify(req.body),
});

if (response.status !== 201) {
logger.warn(
{ status: response.status, id, data: req.body },
'unable to create user with Users service'
);
next(createError(response.status, 'unable to create user'));
return;
}

next();
} catch (err) {
logger.error({ err }, 'error creating Telescope user');
next(createError(500, 'unable to create Telescope user'));
}
};
};

// Get user's Telescope profile info from the Users service
module.exports.getTelescopeProfile = function getTelescopeProfile() {
return async (req, res, next) => {
try {
const id = getUserId(req);
const response = await fetch(`${USERS_URL}/${id}`, {
headers: {
// re-use the user's authorization header and token
Authorization: req.get('Authorization'),
},
});

if (!response.ok) {
logger.warn({ status: response.status, id }, 'unable to get user info from Users service');
next(createError(response.status, 'unable to get updated user info'));
return;
}

// Otherwise, it worked. Pass along the user's details
res.locals.telescopeProfile = await response.json();
next();
} catch (err) {
logger.error({ err }, 'error getting Telescope profile for user');
next(createError(500, 'unable to get Telescope user profile'));
}
};
};
4 changes: 4 additions & 0 deletions src/api/auth/src/roles.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
// Define various roles for our tokens
module.exports.seneca = () => ['seneca'];
module.exports.telescope = () => ['seneca', 'telescope'];
module.exports.admin = () => ['seneca', 'telescope', 'admin'];
1 change: 1 addition & 0 deletions src/api/auth/src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ const router = Router();

router.use('/login', require('./login'));
router.use('/logout', require('./logout'));
router.use('/register', require('./register'));
router.use('/sp', require('./saml-metadata'));

// Let Celebrate handle validation errors
Expand Down
39 changes: 39 additions & 0 deletions src/api/auth/src/routes/register.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
const { Router, isAuthenticated, isAuthorized } = require('@senecacdot/satellite');

const { createTelescopeUser, getTelescopeProfile } = require('../middleware');
const { createToken } = require('../token');
const roles = require('../roles');

const router = Router();

/**
* /register allows an authenticated Seneca user to create a new Telescope
* user account with the Users service. We do no validation on the user data,
* which is up to the Users service. If successful, we return an upgraded
* JWT token, which includes more user info and upgrade roles.
*/
router.post(
'/',
isAuthenticated(),
isAuthorized(
// A Seneca user can create a new Telescope user, but an existing Telescope
// user cannot, since they must already have one.
(req, user) => user.roles.includes('seneca') && !user.roles.includes('telescope')
),
createTelescopeUser(),
getTelescopeProfile(),
(req, res) => {
const user = res.locals.telescopeProfile;
const token = createToken(
user.email,
user.firstName,
user.lastName,
user.displayName,
user.isAdmin === true ? roles.admin() : roles.telescope(),
user.github?.avatarUrl
);
res.status(201).json({ token });
}
);

module.exports = router;
10 changes: 6 additions & 4 deletions src/api/auth/src/user.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
const { hash } = require('@senecacdot/satellite');

const roles = require('./roles');

// A User represents a Seneca SSO Authenticated user who might also be a Telescope user.
class User {
constructor(senecaProfile, telescopeProfile) {
Expand Down Expand Up @@ -95,14 +97,14 @@ class User {

// Get a list of roles for this user
get roles() {
const roles = ['seneca'];
if (this.telescope) {
roles.push('telescope');
if (this.telescope.isAdmin === true) {
roles.push('admin');
return roles.admin();
}
return roles.telescope();
}
return roles;
// Default to only Seneca
return roles.seneca();
}

// Serialize the user data into the two main parts
Expand Down
3 changes: 3 additions & 0 deletions src/api/auth/src/util.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,6 @@ module.exports.matchOrigin = (origin, allowedOrigins) =>
// Otherwise, do a full comparison
return origin === allowedOrigin;
});

// Get the sub claim from the authenticated user's JWT payload
module.exports.getUserId = (req) => req.user.sub;
106 changes: 80 additions & 26 deletions src/api/auth/test/e2e/signup-flow.test.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
// NOTE: you need to run the auth and login services in docker for these to work
const { createServiceToken, hash } = require('@senecacdot/satellite');
const { decode } = require('jsonwebtoken');
const fetch = require('node-fetch');

// We need to get the URL to the auth service running in docker, and the list
// of allowed origins, to compare with assumptions in the tests below.
const { login, logout, USERS_URL, cleanupTelescopeUsers } = require('./utils');

const { AUTH_URL, FEED_DISCOVERY_URL } = process.env;

// The user info we'll use to register. We have the following user in our login
// SSO already:
//
Expand All @@ -30,16 +33,13 @@ const galileoGalilei = {
const users = [galileoGalilei];

describe('Signup Flow', () => {
beforeAll(async () => {
// Make sure the user account we want to use for signup isn't already there.
await cleanupTelescopeUsers(users);
});
afterAll(async () => {
await browser.close();
await cleanupTelescopeUsers(users);
});

beforeEach(async () => {
await cleanupTelescopeUsers(users);
context = await browser.newContext();
page = await browser.newPage();
await page.goto(`http://localhost:8888/auth.html`);
Expand Down Expand Up @@ -91,7 +91,7 @@ describe('Signup Flow', () => {
// to access it via the test-web-content domain internally vs. localhost.
const blogUrl = 'http://test-web-content/blog.html';

const res = await fetch('http://localhost/v1/feed-discovery', {
const res = await fetch(FEED_DISCOVERY_URL, {
method: 'POST',
headers: {
Authorization: `bearer ${accessToken}`,
Expand All @@ -106,10 +106,10 @@ describe('Signup Flow', () => {
}

// Part 3: use the access token to POST to the Users service in order to
// register with Telescope.
async function partThree(feedUrls, accessToken, jwt) {
// register a new Telescope user..
async function partThree(feedUrls, accessToken) {
const user = { ...galileoGalilei, feeds: [...feedUrls] };
const res = await fetch(`${USERS_URL}/${jwt.sub}`, {
const res = await fetch(`${AUTH_URL}/register`, {
method: 'POST',
headers: {
Authorization: `bearer ${accessToken}`,
Expand All @@ -118,19 +118,10 @@ describe('Signup Flow', () => {
body: JSON.stringify(user),
});
expect(res.status).toBe(201);
}

// Part 4: logout so we can try logging in again as a registered user.
// Confirm that the token payload matches our upgraded user status.
// Confirm that the data in the Users service for this user
// matches what we expect, and that our token allows us to access it.
async function partFour() {
// Logout
await logout(page);

// Login again
const { accessToken, jwt } = await login(page, 'user2', 'user2pass');

// We should get back an upgraded token for this user
const { token } = await res.json();
const jwt = decode(token);
// Check token payload, make sure it matches what we expect
expect(jwt.sub).toEqual(hash(galileoGalilei.email));
expect(jwt.email).toEqual(galileoGalilei.email);
Expand All @@ -140,10 +131,21 @@ describe('Signup Flow', () => {
expect(jwt.roles).toEqual(['seneca', 'telescope']);
expect(jwt.picture).toEqual(galileoGalilei.github.avatarUrl);

// See if we can use this token to talk to the Users service, confirm our data.
const res = await fetch(`${USERS_URL}/${jwt.sub}`, {
return { id: jwt.sub, token };
}

// Part 4: logout so we can try logging in again as a registered user.
// Confirm that the token payload matches our upgraded user status.
// Confirm that the data in the Users service for this user
// matches what we expect, and that our token allows us to access it.
async function partFour(id, token) {
// Logout
await logout(page);

// Use this upgraded token to get our user profile info and confirm.
const res = await fetch(`${USERS_URL}/${id}`, {
headers: {
Authorization: `bearer ${accessToken}`,
Authorization: `bearer ${token}`,
'Content-Type': 'application/json',
},
});
Expand All @@ -152,9 +154,61 @@ describe('Signup Flow', () => {
expect(data).toEqual(galileoGalilei);
}

const { accessToken, jwt } = await partOne();
const { accessToken } = await partOne();
const feedUrls = await partTwo(accessToken);
await partThree(feedUrls, accessToken, jwt);
await partFour();
const { id, token } = await partThree(feedUrls, accessToken);
await partFour(id, token);
});

it('signup flow fails if user is not authenticated', async () => {
const invalidUser = { ...galileoGalilei, email: '[email protected]' };
const res = await fetch(`${AUTH_URL}/register`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(invalidUser),
});
expect(res.status).toBe(401);
});

it('signup flow fails if user data is missing required properties', async () => {
const { accessToken } = await login(page, 'user2', 'user2pass');
const invalidUser = { ...galileoGalilei };
// Delete the firstName, which is required
delete invalidUser.firstName;
const res = await fetch(`${AUTH_URL}/register`, {
method: 'POST',
headers: {
Authorization: `bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(invalidUser),
});
expect(res.status).toBe(400);
});

it('signup flow fails if user is already a Telescope user', async () => {
const { accessToken } = await login(page, 'user2', 'user2pass');
const res = await fetch(`${AUTH_URL}/register`, {
method: 'POST',
headers: {
Authorization: `bearer ${accessToken}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(galileoGalilei),
});
expect(res.status).toBe(201);
const { token } = await res.json();

const res2 = await fetch(`${AUTH_URL}/register`, {
method: 'POST',
headers: {
Authorization: `bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(galileoGalilei),
});
expect(res2.status).toBe(403);
});
});
15 changes: 15 additions & 0 deletions src/api/auth/test/roles.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
const roles = require('../src/roles');

describe('roles', () => {
it('should return correct roles for Seneca user', () => {
expect(roles.seneca()).toEqual(['seneca']);
});

it('should return correct roles for Telescope user', () => {
expect(roles.telescope()).toEqual(['seneca', 'telescope']);
});

it('should return correct roles for Admin user', () => {
expect(roles.admin()).toEqual(['seneca', 'telescope', 'admin']);
});
});

0 comments on commit 7925bb8

Please sign in to comment.