I had to integrate 2FA with OTP inside a suite for integration tests in NodeJS. The users for manual testing in the target environment used a regular authenticator, so that was my starting point.
OTP Authenticators work all in the same way: they implement an algorithm that calculates the OTP based on a secret shared between client and server. It is also time-dependent, but this detail is not relevant for this activity. I had to extract the secret from the authenticator and use a library to do the rest of the job.
The complete code is in my GitHub: google-authenticator-commandlin.
Extract the secret from the authenticator
This process depends on the authenticator. For some, like 2FAS, it’s as simple as opening the details of a record. For Google Authenticator, it’s more complex because Google uses a particular format and (at this time at least) there is no easy way to extract the secret.
Extract the secret from Google Authenticator
Open Google Authenticator -> hamburger icon -> transfer accounts -> select only one account -> generate the QR Code -> save the image with the cropped QR code inside a folder.
Run the following script with node decode-qr-code.js qr.jpeg to decode the QR code.
const QrCode = require('qrcode-reader');
async function decodeQRCode(path) {
const jimpModule = await import('jimp');
// The main class is under jimpModule.Jimp
const JimpClass = jimpModule.Jimp;
if (!JimpClass || !JimpClass.read) {
throw new Error('Jimp.read function not found in imported module');
}
const img = await JimpClass.read(path);
return new Promise((resolve, reject) => {
const qr = new QrCode();
qr.callback = (err, value) => {
if (err) reject(err);
else resolve(value.result);
};
qr.decode(img.bitmap);
});
}
const filePath = process.argv[2];
if (!filePath) {
console.error('- Please provide the path to the QR code image.');
process.exit(1);
}
decodeQRCode(filePath)
.then(result => console.log('Decoded QR code data:\n', result))
.catch(err => console.error('Failed to decode QR code:', err));
The output will look like this
Decoded QR code data:
otpauth-migration://offline?data=LONG_STRING
That LONG_STRING needs to be decoded. Call the following script with node otpauth-migration.js "otpauth-migration://offline?data=LONG_STRING". This call will require the presence of otp_migration.proto in the same folder.
const protobuf = require('protobufjs');
const base32 = require('thirty-two'); // for base32 encoding
const url = require('url');
const migrationUrl = process.argv[2];
if (!migrationUrl) {
console.error('- Please provide the otpauth-migration:// URL as the first argument.');
process.exit(1);
}
const parsed = url.parse(migrationUrl, true);
const dataBase64 = parsed.query.data;
if (!dataBase64) {
console.error('- No data field found in URL.');
process.exit(1);
}
protobuf.load('otp_migration.proto').then(root => {
const MigrationPayload = root.lookupType('MigrationPayload');
const buffer = Buffer.from(dataBase64, 'base64');
const message = MigrationPayload.decode(buffer);
const object = MigrationPayload.toObject(message, { longs: String, enums: String, bytes: Buffer });
object.otpParameters.forEach(otp => {
// secret is a Buffer, encode it as base32
const secretBase32 = base32.encode(otp.secret).toString().replace(/=/g, '').replace(/\n/g, '');
console.log('Account:', otp.name);
console.log('Issuer:', otp.issuer);
console.log('Secret (base32):', secretBase32);
console.log('Algorithm:', otp.algorithm);
console.log('Digits:', otp.digits);
console.log('Type:', otp.type);
console.log('---------------------------');
});
}).catch(err => {
console.error('Failed to decode protobuf:', err);
});
otp_migration.proto
syntax = "proto3";
message MigrationPayload {
repeated OTPParameters otp_parameters = 1;
int32 version = 2;
int32 batch_size = 3;
int32 batch_index = 4;
int32 batch_id = 5;
}
message OTPParameters {
bytes secret = 1;
string name = 2;
string issuer = 3;
Algorithm algorithm = 4;
DigitCount digits = 5;
OtpType type = 6;
}
enum Algorithm {
ALGORITHM_UNSPECIFIED = 0;
ALGORITHM_SHA1 = 1;
ALGORITHM_SHA256 = 2;
ALGORITHM_SHA512 = 3;
ALGORITHM_MD5 = 4;
}
enum DigitCount {
DIGIT_COUNT_UNSPECIFIED = 0;
DIGIT_COUNT_SIX = 1;
DIGIT_COUNT_EIGHT = 2;
}
enum OtpType {
OTP_TYPE_UNSPECIFIED = 0;
HOTP = 1;
TOTP = 2;
}
The output will look like this:
Account: foo@bar.com
Issuer: FOO
Secret (base32): SECRET_STRING
Algorithm: ALGORITHM_SHA1
Digits: DIGIT_COUNT_SIX
Type: TOTP
That SECRET_STRING is the secret we need.
Generate the secret
Call the script with node generate-otp.js SECRET_STRING
const otplib = require('otplib');
// Get secret from command line
const secret = process.argv[2];
if (!secret) {
console.error('- Please provide the secret as the first argument.');
process.exit(1);
}
// Explicit TOTP configuration
otplib.authenticator.options = {
algorithm: 'sha1',
step: 30, // 30-second time window
digits: 6 // 6-digit OTP
};
// Generate TOTP
const otp = otplib.authenticator.generate(secret);
console.log(`- Secret: ${secret}`);
console.log(`- OTP: ${otp}`);
This will output the OTP. Once the secret is known, only this last step is required.
The last script can be easily integrated into any JavaScript-based integration test.
Final note
All this code was generated by AI “vibe-coding”-style. The result is not perfect, but considering that it took me about 10 minutes to do the whole thing, it’s pretty impressive.