Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Keycloak changes for UI #699

Merged
merged 5 commits into from
Dec 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading