@sourceloop/ctrl-plane-tenant-management-service
Version:
Tenant Management microservice for SaaS control plane
247 lines • 11.3 kB
JavaScript
;
Object.defineProperty(exports, "__esModule", { value: true });
exports.KeycloakIdpProvider = exports.STATUS = void 0;
const tslib_1 = require("tslib");
const axios_1 = tslib_1.__importDefault(require("axios"));
const qs_1 = tslib_1.__importDefault(require("qs"));
const aws_sdk_1 = tslib_1.__importDefault(require("aws-sdk"));
const crypto_1 = require("crypto");
exports.STATUS = {
OK: 200,
NOT_FOUND: 404,
};
class KeycloakIdpProvider {
constructor() {
this.ssm = new aws_sdk_1.default.SSM({ region: process.env.AWS_REGION });
}
value() {
return payload => this.configure(payload);
}
async configure(payload) {
const { tenant, plan } = payload;
try {
const token = await this.authenticateAdmin();
// Fetch the clientId, clientSecret, and realmName from AWS SSM
const clientId = await this.getParameterFromSSM(`/${process.env.NAMESPACE}/${process.env.ENVIRONMENT}/${plan.tier.toLowerCase()}/${tenant.key}/keycloak-client-id`);
const clientSecret = await this.getParameterFromSSM(`/${process.env.NAMESPACE}/${process.env.ENVIRONMENT}/${plan.tier.toLowerCase()}/${tenant.key}/keycloak-client-secret`);
const realmName = await this.getParameterFromSSM(`/${process.env.NAMESPACE}/${process.env.ENVIRONMENT}/${plan.tier.toLowerCase()}/${tenant.key}/keycloak-client-realm`);
// Handling the logic based on tenant tier
if (plan.tier === 'PREMIUM') {
// For PREMIUM: always create a new realm
await this.createRealm(realmName !== null && realmName !== void 0 ? realmName : tenant.key, token);
}
else if (plan.tier === 'STANDARD' || plan.tier === 'BASIC') {
// For STANDARD or BASIC: check if the realm exists
const realmExists = await this.realmExists(realmName !== null && realmName !== void 0 ? realmName : tenant.key, token);
if (!realmExists) {
// If the realm does not exist, create it
await this.createRealm(realmName !== null && realmName !== void 0 ? realmName : tenant.key, token);
}
}
else {
// DO NOTHING
}
// Set up SMTP settings in the realm for AWS SES
await this.setupEmailSettings(realmName !== null && realmName !== void 0 ? realmName : tenant.key, token);
// Create a new client within the realm
await this.createClient(realmName !== null && realmName !== void 0 ? realmName : tenant.key, clientId, token, clientSecret, tenant.key);
// Create a new admin user for the tenant
const adminUsername = tenant.contacts[0].email;
const passwordLength = 20;
const adminPassword = this.generateStrongPassword(passwordLength);
const { firstName, lastName, email } = tenant.contacts[0];
const user = await this.createUser(realmName !== null && realmName !== void 0 ? realmName : tenant.key, adminUsername, adminPassword, firstName, lastName, email, token);
return {
authId: user.id,
};
}
catch (error) {
throw new Error(`Failed to configure Keycloak for tenant: ${tenant.name}`);
}
}
// Method to check if a realm exists
async realmExists(realmName, token) {
try {
const response = await axios_1.default.get(`${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
// If the realm exists, a successful response is returned (status code 200)
return response.status === exports.STATUS.OK;
}
catch (error) {
if (error.response && error.response.status === exports.STATUS.NOT_FOUND) {
// If a 404 is returned, it means the realm doesn't exist
return false;
}
// Rethrow any other errors
throw new Error(`Error checking realm existence: ${error.message}`);
}
}
// Method to authenticate as Keycloak Admin
async authenticateAdmin() {
const response = await axios_1.default.post(`${process.env.KEYCLOAK_HOST}/realms/master/protocol/openid-connect/token`, qs_1.default.stringify({
username: process.env.KEYCLOAK_ADMIN_USERNAME,
password: process.env.KEYCLOAK_ADMIN_PASSWORD,
// eslint-disable-next-line
grant_type: 'password',
// eslint-disable-next-line
client_id: 'admin-cli',
}), {
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
});
return response.data.access_token;
}
async createRealm(realmName, token) {
try {
await axios_1.default.post(`${process.env.KEYCLOAK_HOST}/admin/realms`, {
realm: realmName,
enabled: true,
}, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
catch (error) {
throw new Error(`Failed to create realm '${realmName}': ${error.message}`);
}
}
// Method to set up AWS SES SMTP settings in the realm
async setupEmailSettings(realmName, token) {
try {
await axios_1.default.put(`${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}`, {
smtpServer: {
auth: true,
starttls: true, // Enables TLS
host: process.env.AWS_SES_SMTP_HOST, // Example: email-smtp.us-east-1.amazonaws.com
port: '587', // Use port 587 for TLS
user: process.env.AWS_SES_SMTP_USERNAME, // Your AWS SES SMTP username
password: process.env.AWS_SES_SMTP_PASSWORD, // Your AWS SES SMTP password
from: process.env.SMTP_FROM_EMAIL, // The "from" email address, e.g. 'no-reply@yourdomain.com'
fromDisplayName: process.env.SMTP_FROM_DISPLAY_NAME, // The display name, e.g. 'Your Company Name'
},
}, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
catch (error) {
throw new Error(`Failed to set up email settings for realm '${realmName}': ${error.message}`);
}
}
// Method to create a new Keycloak client
async createClient(realmName, clientId, token, clientSecret, key) {
try {
const redirectUris = [
'http://localhost:3000/*',
`https://${key}.${process.env.DOMAIN_NAME}/authentication-service/*`,
];
await axios_1.default.post(`${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}/clients`, {
clientId: clientId,
publicClient: false, // Must be false for client authentication
secret: clientSecret,
directAccessGrantsEnabled: true,
protocol: 'openid-connect',
enabled: true,
redirectUris: redirectUris,
clientAuthenticatorType: 'client-secret', // Enable client authentication
}, {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
catch (error) {
throw new Error(`Failed to create client '${clientId}' in realm '${realmName}': ${error.message}`);
}
}
// Method to create a new Keycloak user
async createUser(realmName, username, password, firstName, lastName, email, token) {
try {
const createUserResponse = await axios_1.default.post(`${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}/users`, {
username: username,
enabled: true,
firstName: firstName,
lastName: lastName,
email: email,
emailVerified: true,
credentials: [
{
type: 'password',
value: password,
temporary: true, // Set password as temporary
},
],
}, {
headers: {
Authorization: `Bearer ${token}`,
},
});
const locationHeader = createUserResponse.headers['location'];
if (!locationHeader) {
throw new Error("User creation failed, no 'Location' header in response.");
}
const userId = locationHeader.split('/').pop();
if (!userId) {
throw new Error("User creation failed, could not extract user ID from 'Location' header.");
}
// Send the password reset email
await this.sendPasswordResetEmail(realmName, userId, token);
return { id: userId };
}
catch (error) {
throw new Error(`Failed to create user '${username}' in realm '${realmName}': ${error.message}`);
}
}
// Method to send a password reset email
async sendPasswordResetEmail(realmName, userId, token) {
try {
await axios_1.default.put(`${process.env.KEYCLOAK_HOST}/admin/realms/${realmName}/users/${userId}/execute-actions-email`, ['UPDATE_PASSWORD'], {
headers: {
Authorization: `Bearer ${token}`,
},
});
}
catch (error) {
throw new Error(`Failed to send password reset email for user '${userId}' in realm '${realmName}': ${error.message}`);
}
}
// Helper function to fetch parameters from AWS SSM with error handling
async getParameterFromSSM(parameterName) {
var _a, _b;
try {
const response = await this.ssm
.getParameter({ Name: parameterName, WithDecryption: true })
.promise();
return (_b = (_a = response.Parameter) === null || _a === void 0 ? void 0 : _a.Value) !== null && _b !== void 0 ? _b : '';
}
catch (error) {
// Optionally, you can throw the error or return a default value
throw new Error(`Failed to fetch parameter ${parameterName}`);
}
}
generateStrongPassword(length) {
const regex = /[A-Za-z0-9!@#$%^&*()_+~`|}{[\]:;?><,./-=]/; //NOSONAR
const validChars = [];
const ASCII_PRINTABLE_START = 33;
const ASCII_PRINTABLE_END = 126;
for (let i = ASCII_PRINTABLE_START; i <= ASCII_PRINTABLE_END; i++) {
const char = String.fromCharCode(i);
if (regex.test(char)) {
validChars.push(char);
}
}
const randomBytesArray = (0, crypto_1.randomBytes)(length);
const password = Array.from(randomBytesArray)
.map(byte => validChars[byte % validChars.length])
.join('');
return password;
}
}
exports.KeycloakIdpProvider = KeycloakIdpProvider;
//# sourceMappingURL=idp-keycloak.provider.js.map