Skip to content

Commit

Permalink
Add proper roles and User service to JWT authorization
Browse files Browse the repository at this point in the history
  • Loading branch information
humphd committed Apr 8, 2021
1 parent c389260 commit 1ec0155
Show file tree
Hide file tree
Showing 16 changed files with 685 additions and 192 deletions.
6 changes: 6 additions & 0 deletions docker/development.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,9 @@ services:
users:
environment:
- FIRESTORE_EMULATOR_HOST

auth:
environment:
# We development and testing, the Auth service needs to contact the users
# service directly via Docker vs through the http://localhost domain.
- USERS_URL=http://users:6666
1 change: 1 addition & 0 deletions docker/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ services:
- ALLOWED_APP_ORIGINS
- JWT_ISSUER
- JWT_EXPIRES_IN
- USERS_URL
ports:
- ${AUTH_PORT}
depends_on:
Expand Down
49 changes: 49 additions & 0 deletions src/api/auth/env.local
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# Environment variables needed if you want to run `npm run dev`
# and run the server locally, outside of Docker. For all the various
# ways we run this using Docker, the env is defined by one of the
# config/env.* files in the root. This requires that the other
# services are running, particularly the login and users services.
LOG_LEVEL=debug

AUTH_PORT=7777

USERS_URL=http://localhost/v1/users

# The Single Sign On (SSO) login service URL
SSO_LOGIN_URL=http://localhost:8081/simplesaml/saml2/idp/SSOService.php

# The callback URL endpoint to be used by the SSO login service (see the /auth route)
SSO_LOGIN_CALLBACK_URL=http://localhost:7777/login/callback

# The Single Logout (SLO) service URL
SLO_LOGOUT_URL=http://localhost:8081/simplesaml/saml2/idp/SingleLogoutService.php

# The callback URL endpoint to be used by the SLO logout service (see the /auth route)
SLO_LOGOUT_CALLBACK_URL=http://localhost:7777/logout/callback

# The SSO Identity Provider's public key certificate. NOTE: this is the public
# key cert of the test login IdP docker container. Update for staging and prod.
SSO_IDP_PUBLIC_KEY_CERT=MIIDXTCCAkWgAwIBAgIJALmVVuDWu4NYMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBXaWRnaXRzIFB0eSBMdGQwHhcNMTYxMjMxMTQzNDQ3WhcNNDgwNjI1MTQzNDQ3WjBFMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAzUCFozgNb1h1M0jzNRSCjhOBnR+uVbVpaWfXYIR+AhWDdEe5ryY+CgavOg8bfLybyzFdehlYdDRgkedEB/GjG8aJw06l0qF4jDOAw0kEygWCu2mcH7XOxRt+YAH3TVHa/Hu1W3WjzkobqqqLQ8gkKWWM27fOgAZ6GieaJBN6VBSMMcPey3HWLBmc+TYJmv1dbaO2jHhKh8pfKw0W12VM8P1PIO8gv4Phu/uuJYieBWKixBEyy0lHjyixYFCR12xdh4CA47q958ZRGnnDUGFVE1QhgRacJCOZ9bd5t9mr8KLaVBYTCJo5ERE8jymab5dPqe5qKfJsCZiqWglbjUo9twIDAQABo1AwTjAdBgNVHQ4EFgQUxpuwcs/CYQOyui+r1G+3KxBNhxkwHwYDVR0jBBgwFoAUxpuwcs/CYQOyui+r1G+3KxBNhxkwDAYDVR0TBAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAAiWUKs/2x/viNCKi3Y6blEuCtAGhzOOZ9EjrvJ8+COH3Rag3tVBWrcBZ3/uhhPq5gy9lqw4OkvEws99/5jFsX1FJ6MKBgqfuy7yh5s1YfM0ANHYczMmYpZeAcQf2CGAaVfwTTfSlzNLsF2lW/ly7yapFzlYSJLGoVE+OHEu8g5SlNACUEfkXw+5Eghh+KzlIN7R6Q7r2ixWNFBC/jWf7NKUfJyX8qIG5md1YUeT6GBW9Bm2/1/RiO24JTaYlfLdKK9TYb8sG5B+OLab2DImG99CJ25RkAcSobWNF5zD0O6lgOo3cEdB/ksCq3hmtlC/DlLZ/D8CJ+7VuZnS1rR2naQ==

# Our apps's Entity ID, which is also the URL to our metadata.
SAML_ENTITY_ID=http://localhost:7777/sp

# SECRET = cookie session SECRET. If left empty, one will be set automatically
SECRET=secret-sauce

# ADMINISTRATORS is a list (space delimited) of users who have administrator
# rights. Use the user's nameID ([email protected]) or hashed version of
# nameID (2b3b2b9ce8). Either will work.
[email protected]

# Origins of web apps that we'll allow for redirects. See src/api/auth/test
ALLOWED_APP_ORIGINS=http://localhost:8000 http://localhost:8888

# The URI of the auth server
JWT_ISSUER=http://localhost:7777

# The microservices origin
JWT_AUDIENCE=http://localhost

# How long should a JWT work before it expires
JWT_EXPIRES_IN=1h
7 changes: 4 additions & 3 deletions src/api/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
"description": "An Authorization Service",
"scripts": {
"test:manual": "http-server test/manual -p 8888",
"dev": "nodemon src/server.js",
"dev": "env-cmd -f env.local nodemon src/server.js",
"start": "node src/server.js"
},
"repository": "Seneca-CDOT/telescope",
Expand All @@ -15,19 +15,20 @@
},
"homepage": "https://github.com/Seneca-CDOT/telescope#readme",
"dependencies": {
"@senecacdot/satellite": "^1.x",
"@senecacdot/satellite": "^1.x.0",
"celebrate": "^14.0.0",
"express-session": "^1.17.1",
"http-errors": "^1.8.0",
"jsonwebtoken": "^8.5.1",
"node-fetch": "^2.6.1",
"passport": "^0.4.1",
"passport-jwt": "^4.0.0",
"passport-saml": "^2.1.0"
},
"engines": {
"node": ">=12.0.0"
},
"devDependencies": {
"env-cmd": "^10.1.0",
"http-server": "^0.12.3",
"nodemon": "^2.0.7"
}
Expand Down
24 changes: 0 additions & 24 deletions src/api/auth/src/admin.js

This file was deleted.

86 changes: 57 additions & 29 deletions src/api/auth/src/authentication.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@
*
* https://blog.humphd.org/not-so-simple-saml/
*/

const passport = require('passport');
const SamlStrategy = require('passport-saml').Strategy;
const { logger, hash } = require('@senecacdot/satellite');
const { createServiceToken, logger, hash } = require('@senecacdot/satellite');
const fetch = require('node-fetch');

const User = require('./user');
const Admin = require('./admin');

const {
SAML_ENTITY_ID,
Expand All @@ -17,6 +17,7 @@ const {
SSO_LOGIN_CALLBACK_URL,
SLO_LOGOUT_URL,
SLO_LOGOUT_CALLBACK_URL,
USERS_URL,
} = process.env;

/**
Expand All @@ -29,7 +30,8 @@ if (
SSO_LOGIN_URL &&
SSO_LOGIN_CALLBACK_URL &&
SLO_LOGOUT_URL &&
SLO_LOGOUT_CALLBACK_URL
SLO_LOGOUT_CALLBACK_URL &&
USERS_URL
)
) {
logger.error(
Expand All @@ -41,17 +43,7 @@ passport.serializeUser((user, done) => {
done(null, user);
});
passport.deserializeUser((user, done) => {
/**
* We need to rehydrate a full user Object, using one of User or Admin. To do
* so we determine this based on the current user's id (i.e. nameID in SAML)
* matching what we have set in the env for ADMINISTRATORS. There can be
* more than one admin user.
*/
if (Admin.isAdmin(user.id)) {
done(null, new Admin(user.name, user.email, user.id, user.nameID, user.nameIDFormat));
} else {
done(null, new User(user.name, user.email, user.id, user.nameID, user.nameIDFormat));
}
done(null, User.parse(user));
});

// Setup SAML authentication strategy
Expand All @@ -67,15 +59,23 @@ const strategy = new SamlStrategy(
disableRequestedAuthnContext: true,
signatureAlgorithm: 'sha256',
},
(profile, done) => {
if (!profile) {
const error = new Error('SAML Strategy verify callback missing user profile');
function (senecaProfile, done) {
if (
!(
senecaProfile &&
senecaProfile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']
)
) {
const error = new Error(
'SAML Strategy verify callback missing user profile and emailaddress claim'
);
logger.error({ error });
return done(error);
done(error);
return;
}

/**
* The object we get back from Seneca takes this form:
* The profile object we get back from Seneca takes this form:
* {
* "issuer": "https://sts.windows.net/...",
* "inResponseTo": "_851650d2472d2921c6ac",
Expand Down Expand Up @@ -107,17 +107,45 @@ const strategy = new SamlStrategy(
* "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress": "[email protected]"
* "email": "[email protected]"
* }
*
* We only really care about the emailaddress claim, which we also use in the Users service.
*/
const email =
senecaProfile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'];

async function lookupTelescopeUser(id) {
// We have the user's id (i.e. their hashed email address). Now
// make a service-to-service request to the Users service in order to
// get this user's full profile information using the user's id. They
// may or may not have a Telescope profile (yet).
try {
const res = await fetch(`${USERS_URL}/${id}`, {
headers: {
Authorization: `bearer ${createServiceToken()}`,
},
});
if (!res.ok) {
if (res.status === 404) {
// No Telescope user profile found, so create a regular Seneca user
logger.debug({ senecaProfile }, `No Telescope account for ${id} with Users service`);
done(null, new User(senecaProfile));
return;
}
// We can't get a response from the Users service, so we don't know what we have.
throw new Error(`unable to get user info from Users service: ${res.status}`);
}
// If we get back profile data from the Users service, parse and use
const telescopeProfile = await res.json();
logger.debug({ senecaProfile, telescopeProfile }, 'Telescope user authenticated');
done(null, new User(senecaProfile, telescopeProfile));
} catch (err) {
console.log({ err });
logger.error({ err });
done(err, false);
}
}

// We only use the displayname, emailaddress, and nameID info (hashed, for use in our db)
return done(null, {
// Include nameID so we can use it for Logout Requests back to the IdP.
nameID: profile.nameID,
nameIDFormat: profile.nameIDFormat,
name: profile['http://schemas.microsoft.com/identity/claims/displayname'],
email: profile['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress'],
id: hash(profile.nameID),
});
lookupTelescopeUser(hash(email));
}
);
passport.use(strategy);
Expand Down
32 changes: 0 additions & 32 deletions src/api/auth/src/authorization.js

This file was deleted.

3 changes: 1 addition & 2 deletions src/api/auth/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,7 @@ const session = require('express-session');

// Setup SAML SSO-based Authentication
require('./authentication');
// Setup JWT-based Authorization
require('./authorization');

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

const service = new Satellite({
Expand Down
16 changes: 3 additions & 13 deletions src/api/auth/src/routes.js
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,9 @@ router.post('/login/callback', passport.authenticate('saml'), (req, res, next) =
delete req.session.authDetails;
logger.debug({ redirectUri, state }, 'processing /login/callback');

// TODO - need actual user subject info...
const token = createToken(req.user.email);
// Create a token for this user, setting their authorization roles
const { user } = req;
const token = createToken(user.email, user.displayName, user.roles, user.avatarUrl);

let url = `${redirectUri}?access_token=${token}`;
// Add the state we received before, if it was given at all
Expand Down Expand Up @@ -187,17 +188,6 @@ router.get(
}
);

/**
* Determine whether a user with the attached bearer token in the Authorization
* header is an authorized user. Services can pass a token to the /authorize
* endpoint, received from a client app, and determine whether or not the token
* is valid, verified, and allowed to proceed.
*/
router.get('/authorize', passport.authenticate('jwt', { session: false }), (req, res) => {
// TODO: send back any info?
res.status(200).end();
});

/**
* Provide SAML Metadata endpoint for our Service Provider's Entity ID.
* The naming is {host}/sp, for example: http://localhost/v1/auth/sp
Expand Down
41 changes: 33 additions & 8 deletions src/api/auth/src/token.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,44 @@ const jwt = require('jsonwebtoken');

const { JWT_ISSUER, JWT_AUDIENCE, SECRET, JWT_EXPIRES_IN } = process.env;

function createToken(subject) {
// TODO - figure out all the various claims we need to use
/**
* Create a JWT token for the user, and add the 'admin' role if requested.
* @param {string} email the email of this user. This will be the sub claim
* @param {string} name a name suitable for display for this user.
* @param {Array<string>} roles an array of roles for this user
* @param {string} picture [optional] a URL to the user's picture
* @returns {string} the JWT for this user
*/
function createToken(email, name, roles, picture) {
// The token we create includes a number of claims in the payload
const payload = {
// The token is issued by us (e.g., this server)
// iss claim: the token is issued by us (e.g., this server)
iss: JWT_ISSUER,
// It is intended for the services running at this api origin
// aud claim: it is intended for the services running at this api origin
aud: JWT_AUDIENCE,
// The subject of this token, the user
sub: subject,
// TODO: role info (e.g., admin)
// sub claim: the subject of this token (e.g., their email address)
sub: email,
// name claim: the display name
name,
// roles claim: an Arry of one or more authorization roles. There are various
// combinations possible. For authenticated users, we currently have the
// following, and/ a user will have one or more, depending on their account type:
// 1. seneca (user was authenticated with Seneca's SSO)
// 2. telescope (user has a Telescope account with the Users service)
// 3. admin (user's Telescope account includes isAdmin=true)
//
// We also have a service token role, for cases where microservices need to
// communicate with one another using protected routes:
// 4. service (a Telescope microservice, see createServiceToken() in Satellite)
roles,
};

const options = { expiresIn: JWT_EXPIRES_IN || '1h' };
// We may or may not have a GitHub Avatar URL to use for the picture
if (picture) {
payload.picture = picture;
}

const options = { expiresIn: JWT_EXPIRES_IN || '7 days' };
return jwt.sign(payload, SECRET, options);
}

Expand Down
Loading

0 comments on commit 1ec0155

Please sign in to comment.