@j2inn/scram
Version:
TypeScript client SCRAM authentication library
245 lines (241 loc) • 11.3 kB
JavaScript
import sjcl from 'sjcl';
/*
* Copyright (c) 2022, J2 Innovations. All Rights Reserved
*/
var __classPrivateFieldSet = (undefined && undefined.__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 = (undefined && undefined.__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;
const { codec, hash, misc, random } = 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.
*/
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>
*/
async authenticate() {
const handshakeResponse = await 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
*/
async hello() {
const { headers } = await 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
*/
async scram(headers) {
const { handshakeToken } = headers;
if (!handshakeToken) {
throw new Error('Invalid handshake response');
}
const { clientFinal, saltedPassword, authMessage } = await this.clientFirstMessage(handshakeToken);
const finalAuthResponse = await this.clientFinalMessage(clientFinal, handshakeToken);
this.validateFinalResponse(finalAuthResponse, saltedPassword, authMessage);
return finalAuthResponse;
}
async clientFirstMessage(handshakeToken) {
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 } = await this.fetch(__classPrivateFieldGet(this, _ScramAuth_authUri, "f"), {
method: 'GET',
headers: {
Authorization: `scram handshakeToken=${handshakeToken},data=${data}`,
},
});
const firstAuthResponse = 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 };
}
async clientFinalMessage(clientFinal, handshakeToken) {
const data = codec.base64url.fromBits(codec.utf8String.toBits(clientFinal));
// send client final message
const { headers: serverRes2, status } = await 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 finalAuthResponse = serverRes2.get('authentication-info');
if (finalAuthResponse) {
const result = this.parseAuthHeaders(finalAuthResponse);
result['x-csrf-token'] = serverRes2.get('x-csrf-token') ?? '';
return result;
}
else {
throw new Error('Invalid final response');
}
}
validateFinalResponse(finalAuthResponse, saltedPassword, authMessage) {
const { data: responseData, hash: authHash } = finalAuthResponse;
if (!responseData || !authHash) {
throw new Error('Invalid final 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');
}
}
generateClientNonce() {
return codec.base64url.fromBits(random.randomWords(8));
}
xor(key, sig) {
if (key.length !== sig.length) {
throw new Error('Invalid arguments');
}
return key.map((k, index) => k ^ sig[index]);
}
parseAuthHeaders(header) {
return header
.split(/\s+/)
.map((v) => v.split(','))
.flat()
.map((v) => v.split(/(?!\w+)=(?=[A-Za-z\d\/_+-=]+)/))
.filter((v) => v.length > 1)
.reduce((authData, keyValuePair) => {
authData[keyValuePair[0]] = keyValuePair[1];
return authData;
}, {});
}
}
_ScramAuth_username = new WeakMap(), _ScramAuth_password = new WeakMap(), _ScramAuth_authUri = new WeakMap(), _ScramAuth_dkLen = new WeakMap();
/*
* Copyright (c) 2022, J2 Innovations. All Rights Reserved
*/
/**
* Authenticate with a server using SCRAM.
*
* - https://tools.ietf.org/html/rfc5802
* - http://www.alienfactory.co.uk/articles/skyspark-scram-over-sasl
*
* @param options.username The username.
* @param options.password The password.
* @param options.uri Optional server URI to authenticate with (defaults to /).
* @param options.fetch Optional alternative fetch function.
* @returns If successful, the final client authentication information is returned.
* @throws If the authentication is not successful or if there's an error.
*/
async function authenticate({ username, password, uri = '/', fetch = typeof globalThis?.fetch === 'function'
? globalThis.fetch.bind(globalThis)
: undefined, }) {
if (!fetch) {
throw new Error('Fetch is undefined');
}
return await new ScramAuth(username, password, uri, fetch).authenticate();
}
export { authenticate, authenticate as default };