Skip to content

Commit d9ffd34

Browse files
committed
✨ feat: implement wave authenticator
1 parent 59cda11 commit d9ffd34

File tree

6 files changed

+290
-0
lines changed

6 files changed

+290
-0
lines changed
Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import type { DatabaseTransactionManager, PrismaTransaction } from '../../database';
2+
import { WaveAuthenticatorImpl } from './authenticator-implementation';
3+
import { mockDeep, type DeepMockProxy } from 'vitest-mock-extended';
4+
import type { ApplicationName, AuthenticatorRepository } from './authenticator-repository';
5+
import { describe, vi, it, expect, beforeEach } from 'vitest';
6+
7+
const mockJson = vi.fn();
8+
const mockStatus = vi.fn();
9+
const mockText = vi.fn();
10+
const mockHeaders = vi.fn();
11+
global.fetch = vi.fn(() => Promise.resolve({
12+
status: mockStatus(),
13+
headers: {
14+
get: mockHeaders,
15+
},
16+
json: () => Promise.resolve(mockJson()),
17+
text: () => Promise.resolve(mockText()),
18+
}) as unknown as Promise<Response>);
19+
20+
interface MakeSut {
21+
transactionManager: DeepMockProxy<DatabaseTransactionManager>;
22+
tokenRepository: DeepMockProxy<AuthenticatorRepository>;
23+
sut: WaveAuthenticatorImpl;
24+
}
25+
26+
const makeSut = (): MakeSut => {
27+
const transactionManager = mockDeep<DatabaseTransactionManager>();
28+
const tokenRepository = mockDeep<AuthenticatorRepository>();
29+
const sut = new WaveAuthenticatorImpl(
30+
transactionManager,
31+
tokenRepository,
32+
{
33+
authUrl: 'https://auth.wave.com',
34+
audience: 'https://api.wave.com',
35+
clientId: 'clientId',
36+
clientSecret: 'clientSecret',
37+
},
38+
{
39+
tokenExpiryInSeconds: 3600,
40+
},
41+
);
42+
43+
return { transactionManager, tokenRepository, sut };
44+
};
45+
46+
describe('wave authentication impl', () => {
47+
beforeEach(() => {
48+
vi.clearAllMocks();
49+
});
50+
51+
describe('authenticate', () => {
52+
it('should return cached token if not expired', async () => {
53+
const { sut, tokenRepository } = makeSut();
54+
const futureTimestamp = Date.now() + 1000000;
55+
const mockToken = { token: 'cached-token', expiresAt: futureTimestamp, application: 'WAVE_MVNO_APPLICATION' as ApplicationName };
56+
57+
tokenRepository.get.mockResolvedValueOnce(mockToken);
58+
mockStatus.mockReturnValueOnce(200);
59+
const result = await sut.authenticate('WAVE_MVNO_APPLICATION');
60+
61+
expect(result).toBe(mockToken.token);
62+
expect(tokenRepository.get).toHaveBeenCalledWith('WAVE_MVNO_APPLICATION');
63+
expect(global.fetch).not.toHaveBeenCalled();
64+
});
65+
66+
it('should fetch new token if cached token is expired', async () => {
67+
const { sut, tokenRepository, transactionManager } = makeSut();
68+
const pastTimestamp = Date.now() - 1000;
69+
const mockToken = { token: 'expired-token', expiresAt: pastTimestamp, application: 'WAVE_MVNO_APPLICATION' as ApplicationName };
70+
const newToken = { access_token: 'new-token', expires_in: 3600, application: 'WAVE_MVNO_APPLICATION' as ApplicationName };
71+
72+
tokenRepository.get.mockResolvedValueOnce(mockToken);
73+
mockJson.mockReturnValueOnce(newToken);
74+
mockStatus.mockReturnValueOnce(200);
75+
transactionManager.execute.mockImplementationOnce(
76+
(callback) => callback({} as PrismaTransaction)
77+
);
78+
79+
const result = await sut.authenticate('WAVE_MVNO_APPLICATION');
80+
81+
expect(result).toBe(newToken.access_token);
82+
expect(tokenRepository.get).toHaveBeenCalledWith('WAVE_MVNO_APPLICATION');
83+
expect(global.fetch).toHaveBeenCalledWith(
84+
'https://auth.wave.com/oauth/token',
85+
expect.objectContaining({
86+
method: 'POST',
87+
headers: {
88+
'Content-Type': 'application/x-www-form-urlencoded',
89+
'Authorization': 'Basic Y2xpZW50SWQ6Y2xpZW50U2VjcmV0',
90+
},
91+
})
92+
);
93+
expect(tokenRepository.save).toHaveBeenCalledWith(
94+
expect.objectContaining({
95+
application: 'WAVE_MVNO_APPLICATION',
96+
token: newToken.access_token,
97+
}),
98+
expect.anything()
99+
);
100+
});
101+
102+
it('should handle non-JSON error responses with status 401', async () => {
103+
const { sut, tokenRepository, transactionManager } = makeSut();
104+
105+
tokenRepository.get.mockResolvedValueOnce(undefined);
106+
mockStatus.mockReturnValueOnce(401);
107+
mockText.mockReturnValueOnce('Unauthorized');
108+
mockHeaders.mockReturnValueOnce('text/plain');
109+
transactionManager.execute.mockImplementationOnce(
110+
(callback) => callback({} as PrismaTransaction)
111+
);
112+
113+
await expect(sut.authenticate('WAVE_MVNO_APPLICATION')).rejects.toThrow('Unauthorized');
114+
});
115+
116+
it('should handle JSON error responses with status 400', async () => {
117+
const { sut, tokenRepository, transactionManager } = makeSut();
118+
119+
tokenRepository.get.mockResolvedValueOnce(undefined);
120+
mockStatus.mockReturnValueOnce(400);
121+
mockJson.mockReturnValueOnce({ error: 'invalid_request' });
122+
mockHeaders.mockReturnValueOnce('application/json');
123+
transactionManager.execute.mockImplementationOnce(
124+
(callback) => callback({} as PrismaTransaction)
125+
);
126+
127+
await expect(sut.authenticate('WAVE_MVNO_APPLICATION')).rejects.toThrow('{"error":"invalid_request"}');
128+
});
129+
130+
it('should use default expiry when token response has no expiresIn', async () => {
131+
const { sut, tokenRepository, transactionManager } = makeSut();
132+
const pastTimestamp = Date.now() - 1000;
133+
const mockToken = { token: 'expired-token', expiresAt: pastTimestamp, application: 'WAVE_MVNO_APPLICATION' as ApplicationName };
134+
const newToken = { access_token: 'new-token', expires_in: 3600, application: 'WAVE_MVNO_APPLICATION' as ApplicationName }; // No expiresIn
135+
136+
tokenRepository.get.mockResolvedValueOnce(mockToken);
137+
mockJson.mockReturnValueOnce(newToken);
138+
mockStatus.mockReturnValueOnce(200);
139+
transactionManager.execute.mockImplementationOnce(
140+
(callback) => callback({} as PrismaTransaction)
141+
);
142+
143+
await sut.authenticate('WAVE_MVNO_APPLICATION');
144+
145+
expect(tokenRepository.save).toHaveBeenCalledWith(
146+
expect.objectContaining({
147+
application: 'WAVE_MVNO_APPLICATION',
148+
token: newToken.access_token,
149+
expiresAt: expect.any(Number),
150+
}),
151+
expect.anything()
152+
);
153+
});
154+
});
155+
});
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import type { WaveAuthenticator, WaveAuthenticatorConfig, WaveAuthenticatorParams, WaveAuthenticatorResult } from './authenticator-types';
2+
import type { ApplicationName, AuthenticatorRepository } from './authenticator-repository';
3+
import type { DatabaseTransactionManager } from '../../database';
4+
import { secondsToMillis } from '../../utils';
5+
import { Logger } from '../../core';
6+
7+
export class WaveAuthenticatorImpl implements WaveAuthenticator<ApplicationName> {
8+
private applicationTokens: Record<ApplicationName, WaveAuthenticatorResult> = {
9+
'WAVE_MVNO_APPLICATION': { token: '', expiresIn: 0 },
10+
'WAVE_PROVISIONING_APPLICATION': { token: '', expiresIn: 0 },
11+
'WAVE_TENANT_APPLICATION': { token: '', expiresIn: 0 },
12+
};
13+
14+
constructor(
15+
private readonly transactionManager: DatabaseTransactionManager,
16+
private readonly tokenRepository: AuthenticatorRepository,
17+
private readonly params: WaveAuthenticatorParams,
18+
private readonly config: WaveAuthenticatorConfig,
19+
) { }
20+
21+
private isTokenExpired(expiresIn: number): boolean {
22+
return Date.now() > expiresIn;
23+
}
24+
25+
private async getToken(): Promise<WaveAuthenticatorResult> {
26+
try {
27+
const credentials = `${this.params.clientId}:${this.params.clientSecret}`;
28+
const encodedCredentials = Buffer.from(credentials).toString('base64');
29+
30+
const response = await fetch(`${this.params.authUrl}/oauth/token`, {
31+
method: 'POST',
32+
headers: {
33+
'Content-Type': 'application/x-www-form-urlencoded',
34+
'Authorization': `Basic ${encodedCredentials}`,
35+
},
36+
body: new URLSearchParams({
37+
grant_type: 'client_credentials',
38+
audience: this.params.audience,
39+
}),
40+
});
41+
42+
if (response.status < 200 || response.status >= 400) {
43+
const contentType = response.headers.get('Content-Type');
44+
const errorMessage = contentType?.includes('json') ?
45+
JSON.stringify(await response.json()) :
46+
await response.text();
47+
throw new Error(errorMessage);
48+
}
49+
50+
const data = await response.json();
51+
52+
return {
53+
token: data.access_token,
54+
expiresIn: data.expires_in,
55+
};
56+
} catch (error) {
57+
Logger.error('[Authenticator Get Token] An error occurred when trying to authenticate to wave application', { clientId: this.params.clientId, audience: this.params.audience, authUrl: this.params.authUrl }, error);
58+
throw error;
59+
}
60+
}
61+
62+
async authenticate(application: ApplicationName): Promise<string> {
63+
const { token, expiresIn } = this.applicationTokens[application];
64+
65+
if (!token || this.isTokenExpired(expiresIn)) {
66+
const result = await this.tokenRepository.get(application) ?? { token: '', expiresAt: 0 };
67+
this.applicationTokens[application] = { token: result.token, expiresIn: result.expiresAt };
68+
Logger.info(`[Authenticator Get Token] Token refreshed for application ${application}`, { application });
69+
}
70+
71+
const tokenExpiresAt = this.applicationTokens[application].expiresIn;
72+
if (!this.isTokenExpired(tokenExpiresAt)) {
73+
return this.applicationTokens[application].token;
74+
}
75+
76+
await this.transactionManager.execute(async (tx) => {
77+
Logger.info(`[Authenticator Get Token] Refreshing token for application ${application}`, { application });
78+
79+
const { token, expiresIn } = await this.getToken();
80+
81+
const tokenExpiracy = !expiresIn ? this.config.tokenExpiryInSeconds : expiresIn;
82+
const expiresAt = Date.now() + secondsToMillis(tokenExpiracy);
83+
84+
this.applicationTokens[application] = { token, expiresIn: expiresAt };
85+
await this.tokenRepository.save({ application, token, expiresAt }, tx);
86+
87+
Logger.info(`[Authenticator Get Token] Token refreshed for application ${application}`, { application });
88+
});
89+
90+
return this.applicationTokens[application].token;
91+
}
92+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import type { PrismaTransaction } from '../../database';
2+
3+
export type ApplicationName = 'WAVE_MVNO_APPLICATION' | 'WAVE_PROVISIONING_APPLICATION' | 'WAVE_TENANT_APPLICATION';
4+
5+
export interface WaveToken {
6+
token: string;
7+
application: ApplicationName;
8+
expiresAt: number;
9+
}
10+
11+
export interface SaveTokenParams {
12+
application: ApplicationName;
13+
token: string;
14+
expiresAt: number;
15+
}
16+
17+
export interface AuthenticatorRepository {
18+
get: (application: ApplicationName) => Promise<WaveToken | undefined>;
19+
save: (params: SaveTokenParams, tx?: PrismaTransaction) => Promise<void>;
20+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
export interface WaveAuthenticatorResult {
2+
token: string;
3+
expiresIn: number;
4+
}
5+
6+
export interface WaveAuthenticator<T> {
7+
authenticate: (tokenIdentifier: T) => Promise<string>;
8+
}
9+
10+
export interface WaveAuthenticatorParams {
11+
authUrl: string;
12+
audience: string;
13+
clientId: string;
14+
clientSecret: string;
15+
}
16+
17+
export interface WaveAuthenticatorConfig {
18+
tokenExpiryInSeconds: number;
19+
}

src/core/authenticator/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export type * from './authenticator-types';
2+
export type * from './authenticator-repository';
3+
export * from './authenticator-implementation';

src/core/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,4 @@ export * from './result'
33
export type * from './use-case'
44
export * from './uuid'
55
export * from './hooks'
6+
export * from './authenticator'

0 commit comments

Comments
 (0)