Skip to content

Commit

Permalink
Merge pull request #3796 from uselagoon/keycloak-upgrade
Browse files Browse the repository at this point in the history
Upgrade keycloak to version 24
  • Loading branch information
tobybellwood authored Sep 4, 2024
2 parents 85f3698 + d7a076f commit 43b005e
Show file tree
Hide file tree
Showing 25 changed files with 1,058 additions and 1,060 deletions.
1 change: 0 additions & 1 deletion docker-compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,6 @@ services:
# - NEW_RELIC_LICENSE_KEY=
# - NEW_RELIC_APP_NAME=keycloak-local
volumes:
- "./services/keycloak/profile.properties:/opt/keycloak/standalone/configuration/profile.properties"
- "./services/keycloak/startup-scripts:/opt/keycloak/startup-scripts"
- "./services/keycloak/themes/lagoon:/opt/keycloak/themes/lagoon"
- "./local-dev/keycloak:/lagoon/keycloak"
Expand Down
118 changes: 58 additions & 60 deletions node-packages/commons/src/util/func.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
import { unless, is, isNil, isEmpty, partialRight, complement } from 'ramda';
import http from 'http';
import querystring from 'querystring';
import { getConfigFromEnv } from './config';

export const isNumber = is(Number);
Expand All @@ -13,85 +11,85 @@ export const notArray = complement(isArray);
export const isNotNil = complement(isNil);
export const isNotEmpty = complement(isEmpty);

export const asyncPipe = (...functions) => input =>
functions.reduce((chain, func) => chain.then(func), Promise.resolve(input));
export const asyncPipe =
(...functions) =>
(input) =>
functions.reduce((chain, func) => chain.then(func), Promise.resolve(input));

export const jsonMerge = function(a, b, prop) {
var reduced = a.filter(function(aitem) {
return !b.find(function(bitem) {
export const jsonMerge = function (a, b, prop) {
var reduced = a.filter(function (aitem) {
return !b.find(function (bitem) {
return aitem[prop] === bitem[prop];
});
});
return reduced.concat(b);
}
};

// will return only what is in a1 that isn't in a2
// eg:
// a1 = [1,2,3,4]
// a2 = [1,2,3,5]
// arrayDiff(a1,a2) = [4]
export const arrayDiff = (a:Array<any>, b:Array<any>) => a.filter(e => !b.includes(e));
export const arrayDiff = (a: Array<any>, b: Array<any>) =>
a.filter((e) => !b.includes(e));

interface PublicKeyResponse {
error?: string;
publickey?: string;
type?: string;
value?: string;
sha256fingerprint?: string;
md5fingerprint?: string;
comment?: string;
}

interface PrivateKeyResponse {
error: string;
publickey: string;
publickeypem: string;
sha256fingerprint: string;
md5fingerprint: string;
type: string;
value: string;
privatekeypem: string;
}

// helper that will use the crypto handler service to check if a public or private key is valid or not
export async function validateKey(key, type) {
const data = querystring.stringify({'key': key});
const options = {
hostname: getConfigFromEnv("SIDECAR_HANDLER_HOST", "localhost"),
port: 3333,
path: `/validate/${type}`,
export async function validateKey(
key: string,
type: 'public' | 'private',
): Promise<PublicKeyResponse | PrivateKeyResponse> {
let response = await fetch(
`http://${getConfigFromEnv('SIDECAR_HANDLER_HOST', 'localhost')}:3333/validate/${type}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Content-Length': Buffer.byteLength(data)
},
};
let p = new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
res.setEncoding('utf8');
let responseBody = '';
body: new URLSearchParams({ key: key }).toString(),
},
);

res.on('data', (chunk) => {
responseBody += chunk;
});
if (!response.ok) {
throw new Error(`Error validating key: ${response.status}`);
}

res.on('end', () => {
resolve(JSON.parse(responseBody));
});
});
req.on('error', (err) => {
reject(err);
});
req.write(data)
req.end();
});
return await p;
if (type === 'public') {
return (await response.json()) as PublicKeyResponse;
} else {
return (await response.json()) as PrivateKeyResponse;
}
}

// helper that will use the crypto handler service to generate a private key with associated public key
export async function generatePrivateKey() {
const options = {
hostname: getConfigFromEnv("SIDECAR_HANDLER_HOST", "localhost"),
port: 3333,
path: '/generate/ed25519',
method: 'GET',
};
let p = new Promise((resolve, reject) => {
const req = http.request(options, (res) => {
res.setEncoding('utf8');
let responseBody = '';
export async function generatePrivateKey(): Promise<PrivateKeyResponse> {
let response = await fetch(
`http://${getConfigFromEnv('SIDECAR_HANDLER_HOST', 'localhost')}:3333/generate/ed25519`,
);

res.on('data', (chunk) => {
responseBody += chunk;
});
if (!response.ok) {
throw new Error(`Error generating key: ${response.status}`);
}

res.on('end', () => {
resolve(JSON.parse(responseBody));
});
});
req.on('error', (err) => {
reject(err);
});
req.end();
});
return await p;
}
return (await response.json()) as PrivateKeyResponse;
}
3 changes: 1 addition & 2 deletions services/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
"license": "MIT",
"dependencies": {
"@lagoon/commons": "4.0.0",
"@s3pweb/keycloak-admin-client-cjs": "^25.0.2",
"@supercharge/request-ip": "^1.1.2",
"apollo-server-express": "^2.14.2",
"aws-sdk": "^2.378.0",
Expand All @@ -46,7 +47,6 @@
"graphql-type-json": "^0.3.0",
"graphql-upload": "^12.0.0",
"jsonwebtoken": "^8.0.1",
"keycloak-admin": "https://github.com/amazeeio/keycloak-admin.git#bd015d2e34634f262c0827f00620657427e3c252",
"keycloak-connect": "^5.0.0",
"knex": "^3.0.1",
"mariadb": "^2.5.2",
Expand All @@ -69,7 +69,6 @@
"@types/jest": "^29.5.12",
"@types/ramda": "types/npm-ramda#dist",
"@types/redis": "^2.8.32",
"axios": "^0.21.1",
"jest": "^29.7.0",
"prettier": "^3.2.5",
"prettier-eslint-cli": "^8.0.1",
Expand Down
10 changes: 3 additions & 7 deletions services/api/src/apolloServer.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,10 @@ const { userActivityLogger } = require('./loggers/userActivityLogger');
const typeDefs = require('./typeDefs');
const resolvers = require('./resolvers');
const { keycloakGrantManager } = require('./clients/keycloakClient');
const { getRedisKeycloakCache, saveRedisKeycloakCache } = require('./clients/redisClient');

const User = require('./models/user');
const Group = require('./models/group');
const ProjectModel = require('./models/project');
const EnvironmentModel = require('./models/environment');
const Environment = require('./models/environment');

const schema = makeExecutableSchema({ typeDefs, resolvers });

Expand Down Expand Up @@ -166,8 +164,7 @@ const apolloServer = new ApolloServer({
models: {
UserModel: User.User(modelClients),
GroupModel: Group.Group(modelClients),
ProjectModel: ProjectModel.ProjectModel(modelClients),
EnvironmentModel: EnvironmentModel.EnvironmentModel(modelClients)
EnvironmentModel: Environment.Environment(modelClients)
},
keycloakUsersGroups,
adminScopes: {
Expand Down Expand Up @@ -273,8 +270,7 @@ const apolloServer = new ApolloServer({
models: {
UserModel: User.User(modelClients),
GroupModel: Group.Group(modelClients),
ProjectModel: ProjectModel.ProjectModel(modelClients),
EnvironmentModel: EnvironmentModel.EnvironmentModel(modelClients)
EnvironmentModel: Environment.Environment(modelClients)
},
keycloakUsersGroups,
adminScopes: {
Expand Down
115 changes: 52 additions & 63 deletions services/api/src/clients/keycloak-admin.ts
Original file line number Diff line number Diff line change
@@ -1,78 +1,67 @@
import axios from 'axios';
import { decode } from 'jsonwebtoken';
import { KeycloakAdminClient } from '@s3pweb/keycloak-admin-client-cjs';
import { logger } from '../loggers/logger';
const KeycloakAdmin = require('keycloak-admin').default;
const { Agent: KeycloakAgent } = require('keycloak-admin/lib/resources/agent');
import { config } from './keycloakClient';

/**
* Everytime an API request is made, check if the access_token is (or will soon
* be) expired. If so, get a new token before making the request.
*/
class RetryAgent extends KeycloakAgent {
constructor(args) {
super(args);
}
/// Helper to type check try/catch. Remove when we can stop using the @s3pweb
// commonJS version.
export const isNetworkError = (
error: unknown,
): error is {
response: {
status: number;
};
} => {
return (
typeof error === 'object' &&
'response' in error &&
typeof error.response === 'object'
);
};

request(args) {
const parentRequest = super.request(args);
return async payload => {
const accessToken = this.client.getAccessToken();
const tokenRaw = Buffer.from(accessToken.split('.')[1], 'base64');
const token = JSON.parse(tokenRaw.toString());
const date = new Date();
const now = Math.floor(date.getTime() / 1000);
export const getKeycloakAdminClient = async (): Promise<KeycloakAdminClient> => {
const keycloakAdminClient = new KeycloakAdminClient({
baseUrl: `${config.origin}/auth`,
realmName: config.realm,
});

if (token.exp - 10 <= now) {
logger.debug('keycloakAdminClient: refreshing expired token');
await this.client.auth();
}
/**
* Use a custom token provider that can automatically refresh expired tokens.
*/
keycloakAdminClient.registerTokenProvider({
async getAccessToken() {

return parentRequest(payload);
};
}
}
if (keycloakAdminClient.accessToken) {
const token = decode(keycloakAdminClient.accessToken);
const now = Math.floor(Date.now() / 1000);

/**
* Use our custom RetryAgent and save credentials internally for re-auth tasks.
*/
export class RetryKeycloakAdmin extends KeycloakAdmin {
constructor(connectionConfig, credentials) {
const agent = new RetryAgent({
getUrlParams: client => ({
realm: client.realmName
}),
getBaseUrl: client => client.baseUrl,
axios
});
if (token.exp - 30 > now) {
return keycloakAdminClient.accessToken;
}

super(connectionConfig, agent);
logger.debug('keycloakAdminClient: refreshing expired token');
}

this.credentials = credentials;
}
// Always auth against master realm.
const curRealm = keycloakAdminClient.realmName;
keycloakAdminClient.setConfig({
realmName: 'master',
});

async auth() {
const curRealm = this.realmName;
this.realmName = this.credentials.realmName;
await super.auth(this.credentials);
this.realmName = curRealm;
}
}
await keycloakAdminClient.auth({
username: config.user,
password: config.pass,
grantType: 'password',
clientId: 'admin-cli',
});

export const getKeycloakAdminClient = async () => {
const keycloakAdminClient = new RetryKeycloakAdmin(
{
baseUrl: `${config.origin}/auth`,
realmName: config.realm
keycloakAdminClient.setConfig({
realmName: curRealm,
});

return keycloakAdminClient.accessToken;
},
{
realmName: 'master',
username: config.user,
password: config.pass,
grantType: 'password',
clientId: 'admin-cli'
}
);
await keycloakAdminClient.auth();
});

return keycloakAdminClient;
};
5 changes: 2 additions & 3 deletions services/api/src/clients/keycloakClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,8 @@ const keycloakConfig = new KeycloakConfig({
export const keycloakGrantManager = new KeycloakGrantManager(keycloakConfig);

// Override the library "validateToken" function because it is very strict about
// verifying the ISS, which is the URI of the keycloak server. This will almost
// always fail since the URI will be different for end users authenticated via
// the web console and services communicating via backchannel.
// verifying the ISS, which is the URI of the keycloak server. This fails when
// the URL used in the UI doesn't match what's used in the API.
keycloakGrantManager.validateToken = function validateToken(
token,
expectedType
Expand Down
Loading

0 comments on commit 43b005e

Please sign in to comment.