Skip to content

Commit

Permalink
refactor(http): separate auth management from client
Browse files Browse the repository at this point in the history
  • Loading branch information
seth2810 committed Jul 28, 2024
1 parent c8e390a commit 705b9a6
Show file tree
Hide file tree
Showing 2 changed files with 172 additions and 156 deletions.
167 changes: 167 additions & 0 deletions src/lib/http/AuthManager.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import axios, { AxiosInstance } from 'axios';
import { AuthError } from '../../interfaces/Errors';
import { PrivateConfig, SpotifyConfig } from '../../interfaces/Config';

const accountsApiUrl = 'https://accounts.spotify.com/api';

const accessTokenExpireTTL = 60 * 60 * 1_000; // 1hour

export class AuthManager {
protected client: AxiosInstance;

constructor(
// eslint-disable-next-line no-unused-vars
protected config: SpotifyConfig,
// eslint-disable-next-line no-unused-vars
protected privateConfig: PrivateConfig
) {
this.client = axios.create({
baseURL: accountsApiUrl,
auth: {
username: this.config.clientCredentials?.clientId,
password: this.config.clientCredentials?.clientSecret
},
validateStatus: () => true
});
}

/**
* @description Get a refresh token.
* @param {number} retryAmount The amount of retries.
* @returns {string} Returns the refresh token.
*/
private async refreshToken(retryAmount = 0): Promise<string> {
if (
!this.config.clientCredentials.clientId ||
!this.config.clientCredentials.clientSecret ||
!this.config.refreshToken
) {
throw new AuthError(
'Missing information needed to refresh token, required: client id, client secret, refresh token'
);
}

const response = await this.client.post(
'/token',
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.config.refreshToken
})
);

const { status: statusCode } = response;

if (statusCode === 200) {
return response.data.access_token;
}

if (statusCode === 400) {
throw new AuthError('Failed to refresh token: bad request', {
data: response.data
});
}

if (retryAmount === 5) {
if (statusCode >= 500 && statusCode < 600) {
throw new AuthError(`Failed to refresh token: server error (${statusCode})`);
}

throw new AuthError(`Request retry attempts exceeded, failed with status code ${statusCode}`);
}

if (this.config.debug) {
// eslint-disable-next-line no-console
console.log(
`Failed to refresh token: got (${statusCode}) response. Retrying... (${retryAmount + 1})`
);
}

return await this.refreshToken(retryAmount + 1);
}

/**
* Get authorization token with client credentials flow.
* @param {number} retryAmount The amount of retries.
* @returns {string} Returns the authorization token.
*/
private async requestToken(retryAmount = 0): Promise<string> {
const response = await this.client.post(
'/token',
new URLSearchParams({
grant_type: 'client_credentials'
})
);

const { status: statusCode } = response;

if (statusCode === 200) {
return response.data.access_token;
}

if (statusCode === 400) {
throw new AuthError(`Failed to get token: bad request`, {
data: response.data
});
}

if (retryAmount === 5) {
if (statusCode >= 500 && statusCode < 600) {
throw new AuthError(`Failed to get token: server error (${statusCode})`);
}

throw new AuthError(`Request retry attempts exceeded, failed with status code ${statusCode}`);
}

if (typeof this.config.debug === 'boolean' && this.config.debug === true) {
// eslint-disable-next-line no-console
console.log(
`Failed to get token: got (${statusCode}) response. retrying... (${retryAmount + 1})`
);
}

return await this.requestToken(retryAmount + 1);
}

/**
* @description Handles the auth tokens.
* @returns {string} Returns a auth token.
*/
async getToken(): Promise<string> {
if (this.config.accessToken) {
// check if token is expired
if (Date.now() < this.privateConfig.tokenExpireAt) {
// return already defined access token
return this.config.accessToken;
}

// reset token to force trigger refresh
this.config.accessToken = undefined;
}

// refresh token
if (
this.config?.clientCredentials?.clientId &&
this.config?.clientCredentials?.clientSecret &&
this.config?.refreshToken
) {
const accessToken = await this.refreshToken();

this.config.accessToken = accessToken;
this.privateConfig.tokenExpireAt = Date.now() + accessTokenExpireTTL;

return accessToken;
}

// add credentials flow
if (this.config?.clientCredentials?.clientId && this.config?.clientCredentials?.clientSecret) {
const accessToken = await this.requestToken();

this.config.accessToken = accessToken;
this.privateConfig.tokenExpireAt = Date.now() + accessTokenExpireTTL;

return accessToken;
}

throw new AuthError('auth failed: missing information to handle auth');
}
}
161 changes: 5 additions & 156 deletions src/lib/http/HttpManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@ import * as https from 'https';
import { ClientRequest } from 'http';
import axiosBetterStacktrace from 'axios-better-stacktrace';
import {
AuthError,
BadRequestError,
ForbiddenError,
NotFoundError,
Expand All @@ -18,34 +17,23 @@ import {
} from '../../interfaces/Errors';
import { PrivateConfig, SpotifyConfig } from '../../interfaces/Config';
import { sleep } from '../../util/sleep';

const accountsApiUrl = 'https://accounts.spotify.com/api';

const accessTokenExpireTTL = 60 * 60 * 1_000; // 1hour
import { AuthManager } from './AuthManager';

export class HttpClient {
protected baseURL = 'https://api.spotify.com';

protected auth: AuthManager;
protected client: AxiosInstance;
protected authClient: AxiosInstance;

constructor(
protected config: SpotifyConfig,
// eslint-disable-next-line no-unused-vars
protected privateConfig: PrivateConfig
privateConfig: PrivateConfig
) {
if (config.http?.baseURL) {
this.baseURL = config.http.baseURL;
}

this.authClient = axios.create({
baseURL: accountsApiUrl,
auth: {
username: this.config.clientCredentials?.clientId,
password: this.config.clientCredentials?.clientSecret
},
validateStatus: () => true
});
this.auth = new AuthManager(config, privateConfig);

this.client = this.create();
this.client.interceptors.response.use(
Expand All @@ -68,145 +56,6 @@ export class HttpClient {
return url.toString();
}

/**
* @description Get a refresh token.
* @param {number} retryAmount The amount of retries.
* @returns {string} Returns the refresh token.
*/
private async refreshToken(retryAmount = 0): Promise<string> {
if (
!this.config.clientCredentials.clientId ||
!this.config.clientCredentials.clientSecret ||
!this.config.refreshToken
) {
throw new AuthError(
'Missing information needed to refresh token, required: client id, client secret, refresh token'
);
}

const response = await this.authClient.post(
'/token',
new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: this.config.refreshToken
})
);

const { status: statusCode } = response;

if (statusCode === 200) {
return response.data.access_token;
}

if (statusCode === 400) {
throw new AuthError('Failed to refresh token: bad request', {
data: response.data
});
}

if (retryAmount === 5) {
if (statusCode >= 500 && statusCode < 600) {
throw new AuthError(`Failed to refresh token: server error (${statusCode})`);
}

throw new AuthError(`Request retry attempts exceeded, failed with status code ${statusCode}`);
}

if (this.config.debug) {
console.log(
`Failed to refresh token: got (${statusCode}) response. Retrying... (${retryAmount + 1})`
);
}

return await this.refreshToken(retryAmount + 1);
}

/**
* Get authorization token with client credentials flow.
* @param {number} retryAmount The amount of retries.
* @returns {string} Returns the authorization token.
*/
private async getToken(retryAmount = 0): Promise<string> {
const response = await this.authClient.post(
'/token',
new URLSearchParams({
grant_type: 'client_credentials'
})
);

const { status: statusCode } = response;

if (statusCode === 200) {
return response.data.access_token;
}

if (statusCode === 400) {
throw new AuthError(`Failed to get token: bad request`, {
data: response.data
});
}

if (retryAmount === 5) {
if (statusCode >= 500 && statusCode < 600) {
throw new AuthError(`Failed to get token: server error (${statusCode})`);
}

throw new AuthError(`Request retry attempts exceeded, failed with status code ${statusCode}`);
}

if (typeof this.config.debug === 'boolean' && this.config.debug === true) {
console.log(
`Failed to get token: got (${statusCode}) response. retrying... (${retryAmount + 1})`
);
}

return await this.getToken(retryAmount + 1);
}

/**
* @description Handles the auth tokens.
* @returns {string} Returns a auth token.
*/
private async handleAuth(): Promise<string> {
if (this.config.accessToken) {
// check if token is expired
if (this.privateConfig.tokenExpireAt < Date.now()) {
this.config.accessToken = undefined;

return await this.handleAuth();
}

// return already defined access token
return this.config.accessToken;
}

// refresh token
if (
this.config?.clientCredentials?.clientId &&
this.config?.clientCredentials?.clientSecret &&
this.config?.refreshToken
) {
const accessToken = await this.refreshToken();

this.config.accessToken = accessToken;
this.privateConfig.tokenExpireAt = Date.now() + accessTokenExpireTTL;

return accessToken;
}

// add credentials flow
if (this.config?.clientCredentials?.clientId && this.config?.clientCredentials?.clientSecret) {
const accessToken = await this.getToken();

this.config.accessToken = accessToken;
this.privateConfig.tokenExpireAt = Date.now() + accessTokenExpireTTL;

return accessToken;
}

throw new AuthError('auth failed: missing information to handle auth');
}

/**
* Create an axios instance, set interceptors, handle errors & auth.
*/
Expand Down Expand Up @@ -241,7 +90,7 @@ export class HttpClient {

// request interceptor
client.interceptors.request.use(async (config) => {
const accessToken = await this.handleAuth();
const accessToken = await this.auth.getToken();

config.headers.Authorization = `Bearer ${accessToken}`;

Expand Down

0 comments on commit 705b9a6

Please sign in to comment.