@j2inn/scram
Version:
TypeScript client SCRAM authentication library
244 lines (243 loc) • 12.2 kB
JavaScript
/*
* Copyright (c) 2022, J2 Innovations. All Rights Reserved
*/
var __awaiter = (this && this.__awaiter) || function (thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
};
var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
if (kind === "m") throw new TypeError("Private method is not writable");
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
};
var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (receiver, state, kind, f) {
if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a getter");
if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
};
var _ScramAuth_username, _ScramAuth_password, _ScramAuth_authUri, _ScramAuth_dkLen;
import { codec, hash, misc, random } from 'sjcl';
/**
* Key length in bits.
*/
const DK_LEN = {
'SHA-256': 32 * 8,
'SHA-384': 48 * 8,
'SHA-512': 64 * 8,
};
/**
* Performs the client authentication steps required by the SCRAM specification.
*/
export class ScramAuth {
/**
* Construct a new authenticator.
*
* @param username The username.
* @param password The password.
* @param uri Server URI to authenticate with.
* @param fetchFunc Alternative fetch function.
*/
constructor(username, password, uri, fetchFunc) {
_ScramAuth_username.set(this, void 0);
_ScramAuth_password.set(this, void 0);
_ScramAuth_authUri.set(this, void 0);
_ScramAuth_dkLen.set(this, void 0);
__classPrivateFieldSet(this, _ScramAuth_username, username, "f");
__classPrivateFieldSet(this, _ScramAuth_password, password, "f");
__classPrivateFieldSet(this, _ScramAuth_authUri, uri, "f");
this.fetch = fetchFunc;
}
/**
* Performs the client authentication
*
* @returns Optional `authToken` in an Record<string, string>
*/
authenticate() {
return __awaiter(this, void 0, void 0, function* () {
const handshakeResponse = yield this.hello();
if ('scram' in handshakeResponse) {
return this.scram(handshakeResponse);
}
else {
throw new Error('Unsupported authentication type');
}
});
}
/**
* Sends the 'hello' handshake message
* @returns Record<string, string> or error
*/
hello() {
return __awaiter(this, void 0, void 0, function* () {
const { headers } = yield this.fetch(__classPrivateFieldGet(this, _ScramAuth_authUri, "f"), {
method: 'GET',
headers: {
'content-Type': 'text/plain',
authorization: 'hello username=' +
codec.base64url.fromBits(codec.utf8String.toBits(__classPrivateFieldGet(this, _ScramAuth_username, "f"))),
},
});
const wwwAuthenticate = headers.get('www-authenticate');
if (!wwwAuthenticate) {
throw new Error('Invalid authentication headers');
}
const authHeaders = this.parseAuthHeaders(wwwAuthenticate);
const { hash: authHash } = authHeaders;
if (!authHash) {
throw new Error('Invalid handshake response');
}
__classPrivateFieldSet(this, _ScramAuth_dkLen, DK_LEN[authHash.toUpperCase()], "f");
if (!__classPrivateFieldGet(this, _ScramAuth_dkLen, "f")) {
throw new Error(`Unsupported hashing scheme: ${authHash}.`);
}
if (wwwAuthenticate.toLowerCase().startsWith('scram ')) {
authHeaders['scram'] = 'scram';
}
return authHeaders;
});
}
/**
* Performs the SCRAM authentication logic
*
* @param headers The HTTP handshake response headers from the server
*/
scram(authHeaders) {
return __awaiter(this, void 0, void 0, function* () {
const { handshakeToken } = authHeaders;
if (!handshakeToken) {
throw new Error('Invalid handshake response');
}
const firstAuthRes = yield this.clientFirstMessage(handshakeToken);
const { clientFinal, saltedPassword, authMessage } = firstAuthRes;
// client second message
const secondAuthResponse = yield this.clientFinalMessage(clientFinal, handshakeToken);
// validate server signature
if (this.validateFinalResponse(secondAuthResponse, saltedPassword, authMessage)) {
return secondAuthResponse;
}
// Should never reach here but TS compiler too dumb to realize that
// and complains about fn not returning a value
throw new Error();
});
}
clientFirstMessage(handshakeToken) {
return __awaiter(this, void 0, void 0, function* () {
const clientNonce = this.generateClientNonce();
const clientFirstBare = `n=${__classPrivateFieldGet(this, _ScramAuth_username, "f")},r=${clientNonce}`;
const clientFirstMsg = `n,,${clientFirstBare}`;
const data = codec.base64url.fromBits(codec.utf8String.toBits(clientFirstMsg));
// send client first message
const { headers: serverRes1 } = yield this.fetch(__classPrivateFieldGet(this, _ScramAuth_authUri, "f"), {
method: 'GET',
headers: {
Authorization: `scram handshakeToken=${handshakeToken},data=${data}`,
},
});
const firstAuthResponse = serverRes1 === null || serverRes1 === void 0 ? void 0 : serverRes1.get('www-authenticate');
if (!firstAuthResponse) {
throw new Error('Invalid first response');
}
const authHeaders = this.parseAuthHeaders(firstAuthResponse);
const { data: authData, hash: authHash } = authHeaders;
if (!authData || !authHash) {
throw new Error('Invalid first response auth headers');
}
const serverFirstMsg = codec.utf8String.fromBits(codec.base64url.toBits(authData));
const serverFirstMap = this.parseAuthHeaders(serverFirstMsg);
const { r: serverNonce, s: salt, i: iterations } = serverFirstMap;
if (!serverNonce || !salt || !iterations) {
throw new Error('Invalid first server response');
}
if (serverNonce.length < clientNonce.length ||
serverNonce.substring(0, clientNonce.length) !== clientNonce) {
throw new Error("Client nonce doesn't match server response");
}
const serverSalt = codec.base64.toBits(salt);
const serverIterations = parseInt(iterations);
const dkLen = DK_LEN[authHash.toUpperCase()];
if (!dkLen || dkLen !== __classPrivateFieldGet(this, _ScramAuth_dkLen, "f")) {
throw new Error(`Unsupported hashing scheme: ${authHash}.`);
}
const saltedPassword = misc.pbkdf2(__classPrivateFieldGet(this, _ScramAuth_password, "f"), serverSalt, serverIterations, dkLen);
const clientFinalNoPf = `c=biws,r=${serverNonce}`;
const authMessage = `${clientFirstBare},${serverFirstMsg},${clientFinalNoPf}`;
const clientKey = new misc.hmac(saltedPassword).encrypt('Client Key');
const storedKey = new hash.sha256().update(clientKey).finalize();
const clientSignature = new misc.hmac(storedKey).encrypt(authMessage);
const clientProof = this.xor(clientKey, clientSignature);
const clientFinal = `${clientFinalNoPf},p=${codec.base64.fromBits(clientProof)}`;
return { clientFinal, saltedPassword, authMessage };
});
}
clientFinalMessage(clientFinal, handshakeToken) {
var _a;
return __awaiter(this, void 0, void 0, function* () {
const data = codec.base64url.fromBits(codec.utf8String.toBits(clientFinal));
// send client second message
const { headers: serverRes2, status } = yield this.fetch(__classPrivateFieldGet(this, _ScramAuth_authUri, "f"), {
method: 'GET',
headers: {
Authorization: `scram handshakeToken=${handshakeToken},data=${data}`,
},
});
if (status !== 200 /*OK*/) {
throw Error(`Invalid status response: ${status}`);
}
const secondAuthResponse = serverRes2.get('authentication-info');
if (secondAuthResponse) {
const result = this.parseAuthHeaders(secondAuthResponse);
result['x-csrf-token'] = (_a = serverRes2.get('x-csrf-token')) !== null && _a !== void 0 ? _a : '';
return result;
}
else {
throw new Error('Invalid second response');
}
});
}
validateFinalResponse(secondAuthResponse, saltedPassword, authMessage) {
const { data: responseData, hash: authHash } = secondAuthResponse;
if (!responseData || !authHash) {
throw new Error('Invalid second server response');
}
if (DK_LEN[authHash] !== __classPrivateFieldGet(this, _ScramAuth_dkLen, "f")) {
throw new Error('Invalid hash algorithm');
}
const data = codec.utf8String.fromBits(codec.base64url.toBits(responseData));
const dataMap = this.parseAuthHeaders(data);
const serverSig = dataMap.v;
const serverKey = new misc.hmac(saltedPassword).encrypt('Server Key');
const clientServerSignature = new misc.hmac(serverKey).encrypt(authMessage);
if (serverSig !== codec.base64.fromBits(clientServerSignature)) {
throw new Error('Authentication failure. Server signature is not valid');
}
return true;
}
generateClientNonce() {
return codec.base64url.fromBits(random.randomWords(8));
}
parseAuthHeaders(header) {
return header
.split(/\s+/)
.map((v) => v.split(','))
.flat()
.map((v) => v.split(/(?!\w+)=(?=\w+)/))
.filter((v) => v.length > 1)
.reduce((authData, keyValuePair) => {
authData[keyValuePair[0]] = keyValuePair[1];
return authData;
}, {});
}
xor(key, sig) {
if (key.length !== sig.length) {
throw new Error('Invalid arguments');
}
return key.map((k, index) => k ^ sig[index]);
}
}
_ScramAuth_username = new WeakMap(), _ScramAuth_password = new WeakMap(), _ScramAuth_authUri = new WeakMap(), _ScramAuth_dkLen = new WeakMap();