Skip to content

Commit b56c45d

Browse files
committed
[client] add JWT auth support
1 parent 3c297c4 commit b56c45d

File tree

2 files changed

+210
-6
lines changed

2 files changed

+210
-6
lines changed

src/client.test.ts

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
ProcessState,
1313
RegisterWebHookRequest,
1414
WebHook,
15-
WebHookEventType
15+
WebHookEventType,
1616
} from './domain';
1717
import { HttpClient } from './http';
1818

@@ -341,6 +341,153 @@ describe('Client', () => {
341341
expect(result).toBe(undefined);
342342
});
343343

344+
// JWT Authentication Tests
345+
describe('Client with JWT Authentication', () => {
346+
let client: Client;
347+
let mockHttpClient: HttpClient;
348+
const jwtToken = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ';
349+
350+
beforeEach(() => {
351+
mockHttpClient = {
352+
get: jest.fn(),
353+
post: jest.fn(),
354+
put: jest.fn(),
355+
patch: jest.fn(),
356+
delete: jest.fn(),
357+
} as unknown as HttpClient;
358+
359+
client = new Client('', jwtToken, mockHttpClient);
360+
});
361+
362+
it('creates client with JWT authentication', () => {
363+
expect(client).toBeDefined();
364+
});
365+
366+
it('sends a message with JWT authentication', async () => {
367+
const message: Message = {
368+
message: 'Hello',
369+
phoneNumbers: ['+1234567890'],
370+
};
371+
const expectedState: MessageState = {
372+
id: '123',
373+
state: ProcessState.Pending,
374+
recipients: [
375+
{
376+
phoneNumber: '+1234567890',
377+
state: ProcessState.Pending,
378+
}
379+
]
380+
};
381+
382+
(mockHttpClient.post as jest.Mock).mockResolvedValue(expectedState);
383+
384+
const result = await client.send(message);
385+
386+
expect(mockHttpClient.post).toHaveBeenCalledWith(
387+
`${BASE_URL}/message`,
388+
message,
389+
{
390+
"Content-Type": "application/json",
391+
"User-Agent": "android-sms-gateway/3.0 (client; js)",
392+
Authorization: `Bearer ${jwtToken}`,
393+
},
394+
);
395+
expect(result).toBe(expectedState);
396+
});
397+
398+
it('gets the state of a message with JWT authentication', async () => {
399+
const messageId = '123';
400+
const expectedState: MessageState = {
401+
id: '123',
402+
state: ProcessState.Pending,
403+
recipients: [
404+
{
405+
phoneNumber: '+1234567890',
406+
state: ProcessState.Pending,
407+
}
408+
]
409+
};
410+
411+
(mockHttpClient.get as jest.Mock).mockResolvedValue(expectedState);
412+
413+
const result = await client.getState(messageId);
414+
415+
expect(mockHttpClient.get).toHaveBeenCalledWith(
416+
`${BASE_URL}/message/${messageId}`,
417+
{
418+
"User-Agent": "android-sms-gateway/3.0 (client; js)",
419+
Authorization: `Bearer ${jwtToken}`,
420+
},
421+
);
422+
expect(result).toBe(expectedState);
423+
});
424+
425+
it('throws error when JWT token is missing', () => {
426+
expect(() => {
427+
new Client('', '', mockHttpClient);
428+
}).toThrow('Token is required for JWT authentication');
429+
});
430+
});
431+
432+
// Backward Compatibility Tests
433+
describe('Client Backward Compatibility', () => {
434+
let client: Client;
435+
let mockHttpClient: HttpClient;
436+
437+
beforeEach(() => {
438+
mockHttpClient = {
439+
get: jest.fn(),
440+
post: jest.fn(),
441+
put: jest.fn(),
442+
patch: jest.fn(),
443+
delete: jest.fn(),
444+
} as unknown as HttpClient;
445+
client = new Client('login', 'password', mockHttpClient);
446+
});
447+
448+
it('creates client with Basic Auth using legacy constructor', () => {
449+
expect(client).toBeDefined();
450+
});
451+
452+
it('sends a message with Basic Auth using legacy constructor', async () => {
453+
const message: Message = {
454+
message: 'Hello',
455+
phoneNumbers: ['+1234567890'],
456+
};
457+
const expectedState: MessageState = {
458+
id: '123',
459+
state: ProcessState.Pending,
460+
recipients: [
461+
{
462+
phoneNumber: '+1234567890',
463+
state: ProcessState.Pending,
464+
}
465+
]
466+
};
467+
468+
(mockHttpClient.post as jest.Mock).mockResolvedValue(expectedState);
469+
470+
const result = await client.send(message);
471+
472+
expect(mockHttpClient.post).toHaveBeenCalledWith(
473+
`${BASE_URL}/message`,
474+
message,
475+
{
476+
"Content-Type": "application/json",
477+
"User-Agent": "android-sms-gateway/3.0 (client; js)",
478+
Authorization: expect.stringMatching(/^Basic /),
479+
},
480+
);
481+
expect(result).toBe(expectedState);
482+
});
483+
484+
it('throws error when password is missing in legacy constructor', () => {
485+
expect(() => {
486+
new Client('login', '', mockHttpClient);
487+
}).toThrow('Password is required when using Basic Auth with login');
488+
});
489+
});
490+
344491
it('patches settings', async () => {
345492
const settings: Partial<DeviceSettings> = {
346493
messages: { limitValue: 200 },

src/client.ts

Lines changed: 62 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,20 +19,77 @@ export class Client {
1919
private defaultHeaders: Record<string, string>;
2020

2121
/**
22-
* @param login The login to use for authentication
23-
* @param password The password to use for authentication
22+
* @param login The login to use for authentication, pass empty string for JWT
23+
* @param password The password or JWT to use for authentication
2424
* @param httpClient The HTTP client to use for requests
2525
* @param baseUrl The base URL to use for requests. Defaults to {@link BASE_URL}.
2626
*/
27-
constructor(login: string, password: string, httpClient: HttpClient, baseUrl = BASE_URL) {
27+
constructor(
28+
login: string,
29+
password: string,
30+
httpClient?: HttpClient,
31+
baseUrl = BASE_URL
32+
) {
2833
this.baseUrl = baseUrl;
29-
this.httpClient = httpClient;
34+
this.httpClient = httpClient || this.getDefaultHttpClient();
3035
this.defaultHeaders = {
3136
"User-Agent": "android-sms-gateway/3.0 (client; js)",
32-
"Authorization": `Basic ${btoa(`${login}:${password}`)}`,
37+
};
38+
39+
if (login === "") {
40+
if (password === "") {
41+
throw new Error("Token is required for JWT authentication");
42+
}
43+
this.defaultHeaders["Authorization"] = `Bearer ${password}`;
44+
} else {
45+
if (password === "") {
46+
throw new Error("Password is required when using Basic Auth with login");
47+
}
48+
this.defaultHeaders["Authorization"] = `Basic ${btoa(`${login}:${password}`)}`;
3349
}
3450
}
3551

52+
/**
53+
* Gets the default HTTP client implementation
54+
*/
55+
private getDefaultHttpClient(): HttpClient {
56+
// This would typically be implemented elsewhere, but we'll provide a basic implementation
57+
return {
58+
get: async <T>(url: string, headers?: Record<string, string>): Promise<T> => {
59+
const response = await fetch(url, { method: 'GET', headers });
60+
return response.json();
61+
},
62+
post: async <T>(url: string, body: any, headers?: Record<string, string>): Promise<T> => {
63+
const response = await fetch(url, {
64+
method: 'POST',
65+
headers,
66+
body: JSON.stringify(body)
67+
});
68+
return response.json();
69+
},
70+
put: async <T>(url: string, body: any, headers?: Record<string, string>): Promise<T> => {
71+
const response = await fetch(url, {
72+
method: 'PUT',
73+
headers,
74+
body: JSON.stringify(body)
75+
});
76+
return response.json();
77+
},
78+
patch: async <T>(url: string, body: any, headers?: Record<string, string>): Promise<T> => {
79+
const response = await fetch(url, {
80+
method: 'PATCH',
81+
headers,
82+
body: JSON.stringify(body)
83+
});
84+
return response.json();
85+
},
86+
delete: async <T>(url: string, headers?: Record<string, string>): Promise<T> => {
87+
const response = await fetch(url, { method: 'DELETE', headers });
88+
return response.json();
89+
},
90+
};
91+
}
92+
3693
/**
3794
* Sends a new message to the API
3895
* @param request - The message to send

0 commit comments

Comments
 (0)