UNPKG

@j2inn/scram

Version:

TypeScript client SCRAM authentication library

383 lines (374 loc) 16.1 kB
'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var sjcl = require('sjcl'); function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { 'default': e }; } var sjcl__default = /*#__PURE__*/_interopDefaultLegacy(sjcl); /* * Copyright (c) 2025, J2 Innovations. All Rights Reserved */ var __classPrivateFieldSet$1 = (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$1 = (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 _ScramError_options; /** * The stage in the SCRAM authentication process from the client side. */ exports.ScramStage = void 0; (function (ScramStage) { /** * Initialization where client and server agree on the specific SCRAM mechanism to use. */ ScramStage["Hello"] = "hello"; /** * The client sends its username and a client-generated nonce to the server. */ ScramStage["FirstMessage"] = "firstMessage"; /** * The client computes a "Client Proof" then sends this proof to the server. */ ScramStage["LastMessage"] = "lastMessage"; /** * The client validates the Server Signature. */ ScramStage["Validation"] = "validation"; })(exports.ScramStage || (exports.ScramStage = {})); /** * Error returned by the SCRAM authentication process. */ class ScramError extends Error { /** * Constructs a {@link ScramError}. * * @param message Optional message. * @param options Additional options to construct an error. */ constructor(message, options) { super(message); _ScramError_options.set(this, void 0); __classPrivateFieldSet$1(this, _ScramError_options, options, "f"); } get response() { return __classPrivateFieldGet$1(this, _ScramError_options, "f")?.response; } get stage() { return __classPrivateFieldGet$1(this, _ScramError_options, "f")?.stage; } /** * Constructs a {@link ScramError} for the Hello stage. * * @param message Optional message. * @param options Additional options to construct an error. */ static hello(message, options) { return new ScramError(message, { ...options, stage: exports.ScramStage.Hello, }); } /** * Constructs a {@link ScramError} for the First Message stage. * * @param message Optional message. * @param options Additional options to construct an error. */ static firstMessage(message, options) { return new ScramError(message, { ...options, stage: exports.ScramStage.FirstMessage, }); } /** * Constructs a {@link ScramError} for the Last Message stage. * * @param message Optional message. * @param options Additional options to construct an error. */ static lastMessage(message, options) { return new ScramError(message, { ...options, stage: exports.ScramStage.LastMessage, }); } /** * Constructs a {@link ScramError} for the Validation stage. * * @param message Optional message. * @param options Additional options to construct an error. */ static validation(message, options) { return new ScramError(message, { ...options, stage: exports.ScramStage.Validation, }); } } _ScramError_options = new WeakMap(); /* * 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__default["default"]; /** * 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 ScramError.hello('Unsupported authentication type'); } } /** * Sends the 'hello' handshake message * @returns Record<string, string> or error */ async hello() { const response = 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 { headers } = response; const wwwAuthenticate = headers.get('www-authenticate'); if (!wwwAuthenticate) { throw ScramError.hello('Invalid authentication headers', { response, }); } const authHeaders = this.parseAuthHeaders(wwwAuthenticate); const { hash: authHash } = authHeaders; if (!authHash) { throw ScramError.hello('Invalid handshake response', { response, }); } __classPrivateFieldSet(this, _ScramAuth_dkLen, DK_LEN[authHash.toUpperCase()], "f"); if (!__classPrivateFieldGet(this, _ScramAuth_dkLen, "f")) { throw ScramError.hello(`Unsupported hashing scheme: ${authHash}`, { response, }); } 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 ScramError.firstMessage('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 response = await this.fetch(__classPrivateFieldGet(this, _ScramAuth_authUri, "f"), { method: 'GET', headers: { Authorization: `scram handshakeToken=${handshakeToken},data=${data}`, }, }); const { headers: serverRes1 } = response; const firstAuthResponse = serverRes1?.get('www-authenticate'); if (!firstAuthResponse) { throw ScramError.firstMessage('Invalid first response', { response, }); } const authHeaders = this.parseAuthHeaders(firstAuthResponse); const { data: authData, hash: authHash } = authHeaders; if (!authData || !authHash) { throw ScramError.firstMessage('Invalid first response auth headers', { response, }); } 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 ScramError.firstMessage('Invalid first server response', { response, }); } if (serverNonce.length < clientNonce.length || serverNonce.substring(0, clientNonce.length) !== clientNonce) { throw ScramError.firstMessage("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 ScramError.firstMessage(`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 response = await this.fetch(__classPrivateFieldGet(this, _ScramAuth_authUri, "f"), { method: 'GET', headers: { Authorization: `scram handshakeToken=${handshakeToken},data=${data}`, }, }); const { headers: serverRes2, status } = response; if (status !== 200 /*OK*/) { throw ScramError.lastMessage(`Invalid status response: ${status}`, { response, }); } 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 ScramError.lastMessage('Invalid final response', { response, }); } } validateFinalResponse(finalAuthResponse, saltedPassword, authMessage) { const { data: responseData, hash: authHash } = finalAuthResponse; if (!responseData || !authHash) { throw ScramError.validation('Invalid final server response'); } if (DK_LEN[authHash] !== __classPrivateFieldGet(this, _ScramAuth_dkLen, "f")) { throw ScramError.validation('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 ScramError.validation('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 ScramError('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(); } exports.ScramError = ScramError; exports.authenticate = authenticate; exports["default"] = authenticate;