Skip to content

Commit

Permalink
Merge pull request #699 from bcgov/educ-dev-keycloak
Browse files Browse the repository at this point in the history
Keycloak changes for UI
  • Loading branch information
michaeltangbcgov authored Dec 13, 2024
2 parents f2e15da + 516b628 commit 040f2ad
Show file tree
Hide file tree
Showing 3 changed files with 207 additions and 123 deletions.
4 changes: 3 additions & 1 deletion backend/src/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,8 @@ utils.getOidcDiscovery().then(discovery => {
callbackURL: config.get('server:frontend') + '/api/auth/callback',
scope: discovery.scopes_supported,
kc_idp_hint: config.get('server:idirIDPHint')
}, (_issuer, _sub, profile, accessToken, refreshToken, done) => {
}, (_issuer, _sub, profile, accessToken, refreshToken, params, done) => {
const idToken = params.id_token;
if ((typeof (accessToken) === 'undefined') || (accessToken === null) ||
(typeof (refreshToken) === 'undefined') || (refreshToken === null)) {
return done('No access token', null);
Expand All @@ -116,6 +117,7 @@ utils.getOidcDiscovery().then(discovery => {
profile.jwtFrontend = auth.generateUiToken();
profile.jwt = accessToken;
profile.refreshToken = refreshToken;
profile.idToken = idToken;
return done(null, profile);
}));
//JWT strategy is used for authorization
Expand Down
246 changes: 155 additions & 91 deletions backend/src/components/auth.js
Original file line number Diff line number Diff line change
@@ -1,52 +1,82 @@
'use strict';

const axios = require('axios');
const config = require('../config/index');
const log = require('./logger');
const jsonwebtoken = require('jsonwebtoken');
const qs = require('querystring');
const utils = require('./utils');
const safeStringify = require('fast-safe-stringify');
const userRoles = require('./roles');
const {partial, fromPairs} = require('lodash');
const HttpStatus = require('http-status-codes');
const {pick} = require('lodash');
const {ApiError} = require('./error');
"use strict";

const axios = require("axios");
const config = require("../config/index");
const log = require("./logger");
const jsonwebtoken = require("jsonwebtoken");
const qs = require("querystring");
const utils = require("./utils");
const safeStringify = require("fast-safe-stringify");
const userRoles = require("./roles");
const { partial, fromPairs } = require("lodash");
const HttpStatus = require("http-status-codes");
const { pick } = require("lodash");
const { ApiError } = require("./error");

/**
* Create help functions for authorization: isValidGMPUserToken, isValidGMPUser, isValidGMPAdmin, etc
* @param {*} roles
*/
function createRoleHelpers(roles) {
const userTokenHelpers = Object.entries(roles.User).map(([roleType, roleNames]) => [
`isValid${roleType}UserToken`, isValidUiToken(isUserHasRoles, roleType, roleNames)
]);
const userTokenHelpers = Object.entries(roles.User).map(
([roleType, roleNames]) => [
`isValid${roleType}UserToken`,
isValidUiToken(isUserHasRoles, roleType, roleNames),
]
);
// userHelpers is called to generate object { isValidGMPUser: ture, isValidUMPUser: true, isValidStudentSearchUser: false, ...} which will be passed to the frontend.
const userHelpers = Object.entries(roles.User).map(([roleType, roleNames]) => [
`isValid${roleType}User`, isValidUser(isUserHasRoles, roleType, roleNames)
]);
const adminHelpers = Object.entries(roles.Admin).map(([roleType, roleName]) => [
`isValid${roleType}Admin`, isValidUiToken(isUserHasAdminRole, roleType, roleName)
]);
const adminHelpersFE = Object.entries(roles.Admin).map(([roleType, roleName]) => [
`isValid${roleType}Admin`, isValidUser(isUserHasAdminRole, roleType, roleName)
]);
const userHelpers = Object.entries(roles.User).map(
([roleType, roleNames]) => [
`isValid${roleType}User`,
isValidUser(isUserHasRoles, roleType, roleNames),
]
);
const adminHelpers = Object.entries(roles.Admin).map(
([roleType, roleName]) => [
`isValid${roleType}Admin`,
isValidUiToken(isUserHasAdminRole, roleType, roleName),
]
);
const adminHelpersFE = Object.entries(roles.Admin).map(
([roleType, roleName]) => [
`isValid${roleType}Admin`,
isValidUser(isUserHasAdminRole, roleType, roleName),
]
);
// create object { isValidGMPUser: ture, isValidUMPUser: true, isValidStudentSearchUser: false, ...}
const isValidUsers = (req) => fromPairs(userHelpers.map(([roleType, verifyRole]) => [roleType, verifyRole(req)]));
const isValidAdminUsers = (req) => fromPairs(adminHelpersFE.map(([roleType, verifyRole]) => [roleType, verifyRole(req)]));
return ({...fromPairs([...userTokenHelpers, ...userHelpers, ...adminHelpers]), isValidUsers, isValidAdminUsers});
const isValidUsers = (req) =>
fromPairs(
userHelpers.map(([roleType, verifyRole]) => [roleType, verifyRole(req)])
);
const isValidAdminUsers = (req) =>
fromPairs(
adminHelpersFE.map(([roleType, verifyRole]) => [
roleType,
verifyRole(req),
])
);
return {
...fromPairs([...userTokenHelpers, ...userHelpers, ...adminHelpers]),
isValidUsers,
isValidAdminUsers,
};
}

function isUserHasAdminRole(roleType, roleName, roles) {
const adminRole = roleName || '';
const adminRole = roleName || "";
log.silly(`valid ${roleType} from environment variable is ${adminRole}`);
return !!(Array.isArray(roles) && roles.includes(adminRole));
}

function isUserHasRoles(roleType, roleNames, roles) {
const validRoles = roleNames || [];
log.silly(`valid ${roleType} Roles from environment variable are ${safeStringify(validRoles)}`);
const isValidUserRole = (element) => Array.isArray(validRoles) ? validRoles.includes(element) : false;
log.silly(
`valid ${roleType} Roles from environment variable are ${safeStringify(
validRoles
)}`
);
const isValidUserRole = (element) =>
Array.isArray(validRoles) ? validRoles.includes(element) : false;
return !!(Array.isArray(roles) && roles.some(isValidUserRole));
}

Expand All @@ -56,40 +86,53 @@ function isValidUiToken(isUserHasRole, roleType, roleNames) {
const jwtToken = utils.getBackendToken(req);
if (!jwtToken) {
return res.status(HttpStatus.UNAUTHORIZED).json({
message: 'Unauthorized user'
message: "Unauthorized user",
});
}
let userToken;
try {
userToken = jsonwebtoken.verify(jwtToken, config.get('oidc:publicKey'));
userToken = jsonwebtoken.verify(jwtToken, config.get("oidc:publicKey"));
} catch (e) {
log.debug('error is from verify', e);
log.debug("error is from verify", e);
return res.status(HttpStatus.UNAUTHORIZED).json();
}
if (userToken['realm_access'] && userToken['realm_access'].roles
&& isUserHasRole(roleType, roleNames, userToken['realm_access'].roles)) {
if (
userToken["realm_access"] &&
userToken["realm_access"].roles &&
isUserHasRole(roleType, roleNames, userToken["realm_access"].roles)
) {
return next();
}
return res.status(HttpStatus.FORBIDDEN).json({
message: 'user is missing role'
message: "user is missing role",
});
} catch (e) {
log.error(e);
return res.status(HttpStatus.INTERNAL_SERVER_ERROR).json();
}

};
}

function isValidUser(isUserHasRole, roleType, roleNames) {
return function isValidUserHandler(req) {

try {
const thisSession = req['session'];
if (thisSession && thisSession['passport'] && thisSession['passport'].user && thisSession['passport'].user.jwt) {
const userToken = jsonwebtoken.verify(thisSession['passport'].user.jwt, config.get('oidc:publicKey'));
if (userToken && userToken.realm_access && userToken.realm_access.roles
&& (isUserHasRole(roleType, roleNames, userToken.realm_access.roles))) {
const thisSession = req["session"];
if (
thisSession &&
thisSession["passport"] &&
thisSession["passport"].user &&
thisSession["passport"].user.jwt
) {
const userToken = jsonwebtoken.verify(
thisSession["passport"].user.jwt,
config.get("oidc:publicKey")
);
if (
userToken &&
userToken.realm_access &&
userToken.realm_access.roles &&
isUserHasRole(roleType, roleNames, userToken.realm_access.roles)
) {
return true;
}
}
Expand All @@ -109,7 +152,7 @@ const auth = {
const now = Date.now().valueOf() / 1000;
const payload = jsonwebtoken.decode(token);

return (!!payload['exp'] && payload['exp'] < (now + 30)); // Add 30 seconds to make sure , edge case is avoided and token is refreshed.
return !!payload["exp"] && payload["exp"] < now + 30; // Add 30 seconds to make sure , edge case is avoided and token is refreshed.
},

// Check if JWT Refresh Token has expired
Expand All @@ -118,11 +161,14 @@ const auth = {
const payload = jsonwebtoken.decode(token);

// Check if expiration exists, or lacks expiration
if (typeof (payload['exp']) !== 'undefined' && payload['exp'] !== null) {
return payload['exp'] === 0 || payload['exp'] > now;
} else if (typeof (payload['iat']) !== 'undefined' && payload['iat'] !== null) {
const expiresIn = config.get('tokenGenerate:expiresIn') || '1800'
return payload['iat'] + parseInt(expiresIn) > now;
if (typeof payload["exp"] !== "undefined" && payload["exp"] !== null) {
return payload["exp"] === 0 || payload["exp"] > now;
} else if (
typeof payload["iat"] !== "undefined" &&
payload["iat"] !== null
) {
const expiresIn = config.get("tokenGenerate:expiresIn") || "1800";
return payload["iat"] + parseInt(expiresIn) > now;
} else {
return false;
}
Expand All @@ -134,25 +180,27 @@ const auth = {

try {
const discovery = await utils.getOidcDiscovery();
const response = await axios.post(discovery.token_endpoint,
const response = await axios.post(
discovery.token_endpoint,
qs.stringify({
client_id: config.get('oidc:clientId'),
client_secret: config.get('oidc:clientSecret'),
grant_type: 'refresh_token',
client_id: config.get("oidc:clientId"),
client_secret: config.get("oidc:clientSecret"),
grant_type: "refresh_token",
refresh_token: refreshToken,
scope: discovery.scopes_supported
}), {
scope: "openid profile",
}),
{
headers: {
Accept: 'application/json',
'Cache-Control': 'no-cache',
'Content-Type': 'application/x-www-form-urlencoded',
}
Accept: "application/json",
"Cache-Control": "no-cache",
"Content-Type": "application/x-www-form-urlencoded",
},
}
);
result.jwt = response.data.access_token;
result.refreshToken = response.data.refresh_token;
} catch (error) {
log.error('renew', error.message);
log.error("renew", error.message);
result = error.response.data;
}

Expand All @@ -163,47 +211,50 @@ const auth = {
async refreshJWT(req, _res, next) {
try {
if (req?.user?.jwt) {
log.verbose('refreshJWT', 'User & JWT exists');
log.verbose("refreshJWT", "User & JWT exists");

if (auth.isTokenExpired(req.user.jwt)) {
log.verbose('refreshJWT', 'JWT has expired');
log.verbose("refreshJWT", "JWT has expired");

if (req?.user?.refreshToken && auth.isRenewable(req.user.refreshToken)) {
log.verbose('refreshJWT', 'Can refresh JWT token');
if (
req?.user?.refreshToken &&
auth.isRenewable(req.user.refreshToken)
) {
log.verbose("refreshJWT", "Can refresh JWT token");
// Get new JWT and Refresh Tokens and update the request
const result = await auth.renew(req.user.refreshToken);
req.user.jwt = result.jwt; // eslint-disable-line require-atomic-updates
req.user.refreshToken = result.refreshToken; // eslint-disable-line require-atomic-updates
} else {
log.verbose('refreshJWT', 'Cannot refresh JWT token');
log.verbose("refreshJWT", "Cannot refresh JWT token");
delete req.user;
}
}
} else {
log.verbose('refreshJWT', 'No existing User or JWT');
log.verbose("refreshJWT", "No existing User or JWT");
delete req.user;
}
} catch (error) {
log.error('refreshJWT', error.message);
log.error("refreshJWT", error.message);
}
next();
},

generateUiToken() {
const i = config.get('tokenGenerate:issuer');
const s = '[email protected]';
const a = config.get('server:frontend');
const i = config.get("tokenGenerate:issuer");
const s = "[email protected]";
const a = config.get("server:frontend");
const signOptions = {
issuer: i,
subject: s,
audience: a,
expiresIn: '30m',
algorithm: 'RS256'
expiresIn: "30m",
algorithm: "RS256",
};

const privateKey = config.get('tokenGenerate:privateKey');
const privateKey = config.get("tokenGenerate:privateKey");
const uiToken = jsonwebtoken.sign({}, privateKey, signOptions);

return uiToken;
},
isValidUiTokenWithRoles: partial(isValidUiToken, isUserHasRoles),
Expand All @@ -213,33 +264,46 @@ const auth = {
async getApiCredentials() {
try {
const discovery = await utils.getOidcDiscovery();
const response = await axios.post(discovery.token_endpoint,
const response = await axios.post(
discovery.token_endpoint,
qs.stringify({
client_id: config.get('oidc:clientId'),
client_secret: config.get('oidc:clientSecret'),
grant_type: 'client_credentials',
scope: discovery.scopes_supported
}), {
client_id: config.get("oidc:clientId"),
client_secret: config.get("oidc:clientSecret"),
grant_type: "client_credentials",
scope: discovery.scopes_supported,
}),
{
headers: {
Accept: 'application/json',
'Cache-Control': 'no-cache',
'Content-Type': 'application/x-www-form-urlencoded',
}
Accept: "application/json",
"Cache-Control": "no-cache",
"Content-Type": "application/x-www-form-urlencoded",
},
}
);

log.verbose('getApiCredentials Res', safeStringify(response.data));
log.verbose("getApiCredentials Res", safeStringify(response.data));

let result = {};
result.accessToken = response.data.access_token;
result.refreshToken = response.data.refresh_token;
return result;
} catch (error) {
log.error('getApiCredentials Error', error.response ? pick(error.response, ['status', 'statusText', 'data']) : error.message);
const status = error.response ? error.response.status : HttpStatus.INTERNAL_SERVER_ERROR;
throw new ApiError(status, {message: 'Get getApiCredentials error'}, error);
log.error(
"getApiCredentials Error",
error.response
? pick(error.response, ["status", "statusText", "data"])
: error.message
);
const status = error.response
? error.response.status
: HttpStatus.INTERNAL_SERVER_ERROR;
throw new ApiError(
status,
{ message: "Get getApiCredentials error" },
error
);
}
}
},
};

module.exports = auth;
Loading

0 comments on commit 040f2ad

Please sign in to comment.