Skip to content

Commit 16c0871

Browse files
committed
feat(cli): implement SCRAM enhanced authentication for MQTT 5.0
Add support for SCRAM-SHA-1, SCRAM-SHA-256, and SCRAM-SHA-512 authentication methods with comprehensive test coverage and CLI integration.
1 parent f858039 commit 16c0871

14 files changed

Lines changed: 1153 additions & 12 deletions

File tree

cli/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
"mqtt": "^4.3.7",
4141
"msgpackr": "^1.11.0",
4242
"ora": "^5.4.1",
43+
"pbkdf2": "^3.1.5",
4344
"protobufjs": "^7.2.3",
4445
"pump": "^3.0.0",
4546
"readable-stream": "^3.6.0",
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { expect, describe, jest } from '@jest/globals'
2+
import { createMqttClient } from '../../utils/mqttConnect'
3+
4+
// Mock mqtt.connect
5+
jest.mock('mqtt', () => ({
6+
connect: jest.fn(() => ({
7+
on: jest.fn(),
8+
handleAuth: undefined,
9+
})),
10+
}))
11+
12+
describe('createMqttClient', () => {
13+
test('should create MQTT client successfully', () => {
14+
const connOpts = {
15+
protocolVersion: 5,
16+
hostname: 'localhost',
17+
port: 1883,
18+
username: 'admin',
19+
password: 'public',
20+
}
21+
22+
const client = createMqttClient(connOpts)
23+
24+
expect(client).toBeDefined()
25+
expect(typeof client.on).toBe('function')
26+
})
27+
28+
test('should create MQTT client with hostname broker.emqx.io', () => {
29+
const connOpts = {
30+
protocolVersion: 5,
31+
hostname: 'broker.emqx.io',
32+
port: 1883,
33+
username: 'admin',
34+
password: 'public',
35+
}
36+
37+
const client = createMqttClient(connOpts)
38+
39+
expect(client).toBeDefined()
40+
})
41+
42+
test('should create MQTT client for MQTT 3.1.1', () => {
43+
const connOpts = {
44+
protocolVersion: 4,
45+
hostname: 'localhost',
46+
port: 1883,
47+
username: 'admin',
48+
password: 'public',
49+
}
50+
51+
const client = createMqttClient(connOpts)
52+
53+
expect(client).toBeDefined()
54+
})
55+
56+
test('should create MQTT client without credentials', () => {
57+
const connOpts = {
58+
protocolVersion: 5,
59+
hostname: 'localhost',
60+
port: 1883,
61+
}
62+
63+
const client = createMqttClient(connOpts)
64+
65+
expect(client).toBeDefined()
66+
})
67+
})
Lines changed: 187 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,187 @@
1+
/// <reference types="../../types/global" />
2+
3+
import { ScramAuth, ScramAlgorithm } from '../../utils/scramAuth'
4+
import { expect, describe, it, jest, beforeEach } from '@jest/globals'
5+
import * as crypto from 'crypto'
6+
7+
// Mock crypto.randomBytes to make tests deterministic
8+
jest.mock('crypto', () => ({
9+
...jest.requireActual<typeof crypto>('crypto'),
10+
randomBytes: jest.fn(),
11+
}))
12+
13+
const mockedCrypto = crypto as jest.Mocked<typeof crypto>
14+
15+
describe('ScramAuth', () => {
16+
beforeEach(() => {
17+
// Reset mocks before each test
18+
jest.clearAllMocks()
19+
// Mock randomBytes to return a predictable value that will create the client nonce 'fyko+d2lbbFgONRv9qkxdawL'
20+
mockedCrypto.randomBytes.mockImplementation(() => Buffer.from('fyko+d2lbbFgONRv9qkxdawL', 'base64'))
21+
})
22+
23+
describe('constructor', () => {
24+
it('should create ScramAuth instance with default algorithm', () => {
25+
const scramAuth = new ScramAuth('testuser', 'testpass')
26+
expect(scramAuth).toBeInstanceOf(ScramAuth)
27+
})
28+
29+
it('should create ScramAuth instance with specified algorithm', () => {
30+
const scramAuth = new ScramAuth('testuser', 'testpass', 'SCRAM-SHA-1')
31+
expect(scramAuth).toBeInstanceOf(ScramAuth)
32+
})
33+
34+
it('should generate client nonce on construction', () => {
35+
new ScramAuth('testuser', 'testpass')
36+
expect(mockedCrypto.randomBytes).toHaveBeenCalledWith(32)
37+
})
38+
})
39+
40+
describe('getHashFunction', () => {
41+
it('should return correct hash function for SCRAM-SHA-1', () => {
42+
const scramAuth = new ScramAuth('testuser', 'testpass', 'SCRAM-SHA-1')
43+
const firstMessage = scramAuth.clientFirst()
44+
expect(firstMessage).toBeInstanceOf(Buffer)
45+
})
46+
47+
it('should return correct hash function for SCRAM-SHA-256', () => {
48+
const scramAuth = new ScramAuth('testuser', 'testpass', 'SCRAM-SHA-256')
49+
const firstMessage = scramAuth.clientFirst()
50+
expect(firstMessage).toBeInstanceOf(Buffer)
51+
})
52+
53+
it('should return correct hash function for SCRAM-SHA-512', () => {
54+
const scramAuth = new ScramAuth('testuser', 'testpass', 'SCRAM-SHA-512')
55+
const firstMessage = scramAuth.clientFirst()
56+
expect(firstMessage).toBeInstanceOf(Buffer)
57+
})
58+
59+
it('should throw error for unsupported algorithm', () => {
60+
expect(() => {
61+
const scramAuth = new ScramAuth('testuser', 'testpass', 'INVALID-ALGORITHM' as ScramAlgorithm)
62+
scramAuth.clientFirst()
63+
const serverFirstMessage = Buffer.from('r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096')
64+
scramAuth.clientFinal(serverFirstMessage)
65+
}).toThrow('Unsupported algorithm: INVALID-ALGORITHM')
66+
})
67+
})
68+
69+
describe('saslPrep', () => {
70+
it('should escape = and , characters in username', () => {
71+
const scramAuth = new ScramAuth('test=user,name', 'testpass')
72+
const firstMessage = scramAuth.clientFirst()
73+
const messageStr = firstMessage.toString()
74+
expect(messageStr).toContain('n=test=3Duser=2Cname')
75+
})
76+
})
77+
78+
describe('clientFirst', () => {
79+
it('should generate client first message with correct format', () => {
80+
const scramAuth = new ScramAuth('testuser', 'testpass')
81+
const firstMessage = scramAuth.clientFirst()
82+
const messageStr = firstMessage.toString()
83+
84+
expect(messageStr).toMatch(/^n,,n=testuser,r=.+/)
85+
expect(messageStr).toContain('n=testuser')
86+
})
87+
88+
it('should handle special characters in username', () => {
89+
const scramAuth = new ScramAuth('test=user,name', 'testpass')
90+
const firstMessage = scramAuth.clientFirst()
91+
const messageStr = firstMessage.toString()
92+
93+
expect(messageStr).toContain('n=test=3Duser=2Cname')
94+
})
95+
96+
it('should return buffer', () => {
97+
const scramAuth = new ScramAuth('testuser', 'testpass')
98+
const firstMessage = scramAuth.clientFirst()
99+
expect(firstMessage).toBeInstanceOf(Buffer)
100+
})
101+
})
102+
103+
describe('clientFinal', () => {
104+
let scramAuth: ScramAuth
105+
106+
beforeEach(() => {
107+
scramAuth = new ScramAuth('user', 'pencil', 'SCRAM-SHA-1')
108+
scramAuth.clientFirst()
109+
})
110+
111+
it('should generate client final message for valid server response', () => {
112+
const serverFirstMessage = Buffer.from('r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096')
113+
114+
const clientFinal = scramAuth.clientFinal(serverFirstMessage)
115+
expect(clientFinal).toBeInstanceOf(Buffer)
116+
117+
const finalStr = clientFinal.toString()
118+
expect(finalStr).toMatch(/^c=biws,r=.+,p=.+/)
119+
})
120+
121+
it('should throw error for invalid server nonce', () => {
122+
const serverFirstMessage = Buffer.from('r=invalid-nonce,s=QSXCR+Q6sek8bf92,i=4096')
123+
124+
expect(() => {
125+
scramAuth.clientFinal(serverFirstMessage)
126+
}).toThrow('Invalid server nonce')
127+
})
128+
129+
it('should parse server parameters correctly', () => {
130+
const serverFirstMessage = Buffer.from('r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j,s=QSXCR+Q6sek8bf92,i=4096')
131+
132+
expect(() => {
133+
scramAuth.clientFinal(serverFirstMessage)
134+
}).not.toThrow()
135+
})
136+
137+
it('should handle malformed server message gracefully', () => {
138+
const serverFirstMessage = Buffer.from('invalid=message=format')
139+
140+
expect(() => {
141+
scramAuth.clientFinal(serverFirstMessage)
142+
}).toThrow()
143+
})
144+
})
145+
146+
describe('algorithm-specific behavior', () => {
147+
const testCases: Array<{ algorithm: ScramAlgorithm; expectedLength: number }> = [
148+
{ algorithm: 'SCRAM-SHA-1', expectedLength: 20 },
149+
{ algorithm: 'SCRAM-SHA-256', expectedLength: 32 },
150+
{ algorithm: 'SCRAM-SHA-512', expectedLength: 64 },
151+
]
152+
153+
testCases.forEach(({ algorithm }) => {
154+
it(`should work correctly with ${algorithm}`, () => {
155+
const scramAuth = new ScramAuth('testuser', 'testpass', algorithm)
156+
const firstMessage = scramAuth.clientFirst()
157+
158+
expect(firstMessage).toBeInstanceOf(Buffer)
159+
expect(firstMessage.toString()).toContain('n=testuser')
160+
})
161+
})
162+
})
163+
164+
describe('error handling', () => {
165+
it('should handle empty username', () => {
166+
const scramAuth = new ScramAuth('', 'testpass')
167+
const firstMessage = scramAuth.clientFirst()
168+
expect(firstMessage.toString()).toContain('n=,')
169+
})
170+
171+
it('should handle empty password', () => {
172+
const scramAuth = new ScramAuth('testuser', '')
173+
expect(() => scramAuth.clientFirst()).not.toThrow()
174+
})
175+
176+
it('should handle server message without required parameters', () => {
177+
const scramAuth = new ScramAuth('user', 'pencil')
178+
scramAuth.clientFirst()
179+
180+
const incompleteServerMessage = Buffer.from('r=fyko+d2lbbFgONRv9qkxdawL3rfcNHYJY1ZVvWVs7j')
181+
182+
expect(() => {
183+
scramAuth.clientFinal(incompleteServerMessage)
184+
}).toThrow()
185+
})
186+
})
187+
})

0 commit comments

Comments
 (0)