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

Upgrade keycloak to version 24 #3796

Merged
merged 6 commits into from
Sep 4, 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
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