Skip to content

Commit

Permalink
Initial commit for moving from Keycloak custom realm to BC Gov standa…
Browse files Browse the repository at this point in the history
…rd realm

Signed-off-by: Jason Sherman <[email protected]>
  • Loading branch information
usingtechnology committed Feb 12, 2024
1 parent 89fa641 commit da2e16e
Show file tree
Hide file tree
Showing 18 changed files with 324 additions and 124 deletions.
22 changes: 16 additions & 6 deletions app/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const path = require('path');
const Problem = require('api-problem');
const querystring = require('querystring');

const keycloak = require('./src/components/keycloak');
//const keycloak = require('./src/components/keycloak');
const log = require('./src/components/log')(module.filename);
const httpLogger = require('./src/components/log').httpLogger;
const middleware = require('./src/forms/common/middleware');
Expand Down Expand Up @@ -41,7 +41,7 @@ if (process.env.NODE_ENV !== 'test') {
}

// Use Keycloak OIDC Middleware
app.use(keycloak.middleware());
//app.use(keycloak.middleware());

// Block requests until service is ready
app.use((_req, res, next) => {
Expand Down Expand Up @@ -178,11 +178,16 @@ function initializeConnections() {
.then((results) => {
state.connections.data = results[0];

if (state.connections.data) log.info('DataConnection Reachable', { function: 'initializeConnections' });
if (state.connections.data)
log.info('DataConnection Reachable', {
function: 'initializeConnections',
});
})
.catch((error) => {
log.error(`Initialization failed: Database OK = ${state.connections.data}`, { function: 'initializeConnections' });
log.error('Connection initialization failure', error.message, { function: 'initializeConnections' });
log.error('Connection initialization failure', error.message, {
function: 'initializeConnections',
});
if (!state.ready) {
process.exitCode = 1;
shutdown();
Expand All @@ -191,7 +196,9 @@ function initializeConnections() {
.finally(() => {
state.ready = Object.values(state.connections).every((x) => x);
if (state.ready) {
log.info('Service ready to accept traffic', { function: 'initializeConnections' });
log.info('Service ready to accept traffic', {
function: 'initializeConnections',
});
// Start periodic 10 second connection probe check
probeId = setInterval(checkConnections, 10000);
}
Expand All @@ -211,7 +218,10 @@ function checkConnections() {
Promise.all(tasks).then((results) => {
state.connections.data = results[0];
state.ready = Object.values(state.connections).every((x) => x);
if (!wasReady && state.ready) log.info('Service ready to accept traffic', { function: 'checkConnections' });
if (!wasReady && state.ready)
log.info('Service ready to accept traffic', {
function: 'checkConnections',
});
log.verbose(state);
if (!state.ready) {
process.exitCode = 1;
Expand Down
9 changes: 5 additions & 4 deletions app/config/custom-environment-variables.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,11 +43,12 @@
"basePath": "SERVER_BASEPATH",
"bodyLimit": "SERVER_BODYLIMIT",
"keycloak": {
"clientId": "SERVER_KC_CLIENTID",
"clientSecret": "SERVER_KC_CLIENTSECRET",
"publicKey": "SERVER_KC_PUBLICKEY",
"realm": "SERVER_KC_REALM",
"serverUrl": "SERVER_KC_SERVERURL"
"serverUrl": "SERVER_KC_SERVERURL",
"jwksUri": "SERVER_KC_JWKSURI",
"issuer": "SERVER_KC_ISSUER",
"audience": "SERVER_KC_AUDIENCE",
"maxTokenAge": "SERVER_KC_MAXTOKENAGE"
},
"logFile": "SERVER_LOGFILE",
"logLevel": "SERVER_LOGLEVEL",
Expand Down
11 changes: 7 additions & 4 deletions app/config/default.json
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
"basePath": "/app",
"keycloak": {
"clientId": "chefs-frontend",
"realm": "chefs",
"realm": "standard",
"serverUrl": "https://dev.loginproxy.gov.bc.ca/auth"
}
},
Expand All @@ -41,9 +41,12 @@
"basePath": "/app",
"bodyLimit": "30mb",
"keycloak": {
"clientId": "chefs",
"realm": "chefs",
"serverUrl": "https://dev.loginproxy.gov.bc.ca/auth"
"realm": "standard",
"serverUrl": "https://dev.loginproxy.gov.bc.ca/auth",
"jwksUri": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard/protocol/openid-connect/certs",
"issuer": "https://dev.loginproxy.gov.bc.ca/auth/realms/standard",
"audience": "chefs-frontend",
"maxTokenAge": "300"
},
"logLevel": "http",
"port": "8080",
Expand Down
2 changes: 1 addition & 1 deletion app/frontend/src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ function loadKeycloak(config) {
};

const options = Object.assign({}, defaultParams, {
init: { onLoad: 'check-sso' },
init: { pkceMethod: 'S256', checkLoginIframe: false, onLoad: 'check-sso' },
config: {
clientId: config.keycloak.clientId,
realm: config.keycloak.realm,
Expand Down
1 change: 1 addition & 0 deletions app/frontend/src/store/auth.js
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export const useAuthStore = defineStore('auth', {

if (options.idpHint) {
// Redirect to Keycloak if idpHint is available
//this.keycloak.login(options);
window.location.replace(this.createLoginUrl(options));
} else {
// Navigate to internal login page if no idpHint specified
Expand Down
14 changes: 14 additions & 0 deletions app/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
"form-data": "^4.0.0",
"fs-extra": "^10.1.0",
"handlebars": "^4.7.8",
"jose": "^5.2.1",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.0",
"keycloak-connect": "^21.1.1",
Expand Down
89 changes: 89 additions & 0 deletions app/src/components/jwtService.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
const jose = require('jose');
const config = require('config');
const errorToProblem = require('./errorToProblem');

const SERVICE = 'JwtService';

const jwksUri = config.get('server.keycloak.jwksUri');

// Create a remote JWK set that fetches the JWK set from server with caching
const JWKS = jose.createRemoteJWKSet(new URL(jwksUri));

class JwtService {
constructor({ issuer, audience, maxTokenAge }) {
if (!issuer || !audience || !maxTokenAge) {
throw new Error('JwtService is not configured. Check configuration.');
}

this.audience = audience;
this.issuer = issuer;
this.maxTokenAge = maxTokenAge;
}

getBearerToken(req) {
if (req.headers && req.headers.authorization && req.headers.authorization.startsWith('Bearer ')) {
return req.headers.authorization.substring(7);
}
// do we want to throw errors?
return null;
}

async getTokenPayload(req) {
const bear = this.getBearerToken(req);
if (bear) {
return await this._verify(bear);
}
return null;
}

async _verify(token) {
// could throw JWTClaimValidationFailed
const { payload } = await jose.jwtVerify(token, JWKS, {
issuer: this.issuer,
audience: this.audience,
maxTokenAge: parseInt(this.maxTokenAge),
});
return payload;
}

async validateAccessToken(token) {
try {
await this._verify(token);
// these claims passed, just return true.
return true;
} catch (e) {
if (e instanceof jose.errors.JWTClaimValidationFailed) {
return false;
} else {
errorToProblem(SERVICE, e);
}
}
}

protect(spec) {
// actual middleware
return async (req, res, next) => {
// get token, check if valid
const token = this.getBearerToken(req);
if (token) {
const payload = await this._verify(token);
if (spec && !payload.roles.includes(spec)) {
// todo: fix logic to prevent access
next();
}
}
next();
};
}
}

const audience = config.get('server.keycloak.audience');
const issuer = config.get('server.keycloak.issuer');
const maxTokenAge = config.get('server.keycloak.maxTokenAge');

let jwtService = new JwtService({
issuer: issuer,
audience: audience,
maxTokenAge: maxTokenAge,
});
module.exports = jwtService;
18 changes: 1 addition & 17 deletions app/src/components/keycloak.js
Original file line number Diff line number Diff line change
@@ -1,19 +1,3 @@
const config = require('config');
const Keycloak = require('keycloak-connect');

module.exports = new Keycloak(
{},
{
bearerOnly: true,
'confidential-port': 0,
clientId: config.get('server.keycloak.clientId'),
'policy-enforcer': {},
realm: config.get('server.keycloak.realm'),
realmPublicKey: config.has('server.keycloak.publicKey') ? config.get('server.keycloak.publicKey') : undefined,
secret: config.get('server.keycloak.clientSecret'),
serverUrl: config.get('server.keycloak.serverUrl'),
'ssl-required': 'external',
'use-resource-role-mappings': true,
'verify-token-audience': false,
}
);
module.exports = new Keycloak({}, {});
5 changes: 2 additions & 3 deletions app/src/forms/admin/routes.js
Original file line number Diff line number Diff line change
@@ -1,14 +1,13 @@
const config = require('config');
const routes = require('express').Router();

const currentUser = require('../auth/middleware/userAccess').currentUser;

const controller = require('./controller');
const userController = require('../user/controller');
const keycloak = require('../../components/keycloak');
const jwtService = require('../../components/jwtService');

// Always have this applied to all routes here
routes.use(keycloak.protect(`${config.get('server.keycloak.clientId')}:admin`));
routes.use(jwtService.protect('admin'));
routes.use(currentUser);

// Routes under the /admin pathing will fetch data without doing Form permission checks in the database
Expand Down
Loading

0 comments on commit da2e16e

Please sign in to comment.