Skip to content

Commit

Permalink
Fix client credentials grant flow (#8)
Browse files Browse the repository at this point in the history
* Fix client credentials grant flow

* Remove duplication

Co-authored-by: Flavio Prado <[email protected]>
  • Loading branch information
Bakrog and Flavio Prado authored Sep 22, 2021
1 parent 619d18b commit 3263351
Show file tree
Hide file tree
Showing 8 changed files with 78 additions and 37 deletions.
3 changes: 3 additions & 0 deletions .github/workflows/github-actions-paxful.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ jobs:
- run: npm ci
- run: npm run build --if-present
- run: npm test
env:
PAXFUL_CLIENT_ID: ${{ secrets.PAXFUL_CLIENT_ID }}
PAXFUL_CLIENT_SECRET: ${{ secrets.PAXFUL_CLIENT_SECRET }}
- run: echo "🎉 Tests completed."
deploy:
name: Publish
Expand Down
5 changes: 2 additions & 3 deletions src/PaxfulApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export class PaxfulApi {
*/
public async impersonatedCredentials(code: string): Promise<Credentials> {
return this.saveToken(
retrieveImpersonatedCredentials(code, this.apiConfiguration)
retrieveImpersonatedCredentials(this.apiConfiguration, code)
);
}

Expand Down Expand Up @@ -63,8 +63,7 @@ export class PaxfulApi {
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
public invoke(url: string, payload?: Record<string, unknown> | []): Promise<any> {
if (!this.credentialStorage) throw Error("No credentials storage defined.");
return invoke(url, this.credentialStorage, this.apiConfiguration, payload);
return invoke(url, this.apiConfiguration, this.credentialStorage, payload);
}

private validateAndSetDefaultParameters(configuration: ApiConfiguration) {
Expand Down
17 changes: 16 additions & 1 deletion src/__tests__/paxfulapi_test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const expectedTokenAnswer = {
refreshToken: UUID(),
}

// noinspection HttpUrlsUsage
const proxyAgent = new ProxyAgent("http://proxy_url:proxy_port");

const picture = "https://paxful.com/2/images/avatar.png";
Expand Down Expand Up @@ -62,7 +63,7 @@ describe("With the Paxful API SDK", function () {
expires_in: ttl
})
}, {
sendAsJson: false
sendAsJson: false,
});
});

Expand Down Expand Up @@ -349,6 +350,7 @@ describe("With the Paxful API SDK", function () {
}
};

// noinspection JSUnusedGlobalSymbols
(fetch as unknown as FetchMockSandbox).once({
url: /https:\/\/api\.paxful\.com\/paxful\/v1\/offer\/create/,
method: "POST",
Expand All @@ -370,4 +372,17 @@ describe("With the Paxful API SDK", function () {

expect(trades).toMatchObject(expectedTrades);
});

it('I can see my offers in client grant flow', async function (){
(fetch as unknown as FetchMockSandbox).reset();
const credentials = {
clientId: process.env.PAXFUL_CLIENT_ID || "",
clientSecret: process.env.PAXFUL_CLIENT_SECRET || "",
};
const paxfulApi = usePaxful(credentials);
const offers = await paxfulApi.invoke('/paxful/v1/offer/all', {
offer_type: "buy"
});
expect(offers.data.offers.length).toBeGreaterThan(0);
});
});
13 changes: 11 additions & 2 deletions src/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import dotenv from "dotenv";
import fetchMock from "fetch-mock";

dotenv.config();

jest.mock('node-fetch', () => fetchMock.sandbox())
jest.mock('node-fetch', () => {
const nodeFetch = jest.requireActual('node-fetch');
// eslint-disable-next-line
const sandbox = require('fetch-mock').sandbox();
Object.assign(sandbox.config, {
fetch: nodeFetch,
fallbackToNetwork: true,
warnOnFallback: false
});
return sandbox;
});
2 changes: 1 addition & 1 deletion src/commands/GetProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,6 @@ export default function retrieveProfile(credentialStorage: CredentialStorage, co
if(!token) throw Error("Token not provided, please review if token was generated!");
const request = createRequest(token, config);
return fetch(request)
.then(response => validateAndRefresh(request, response, credentialStorage, config))
.then(response => validateAndRefresh(request, response, config, credentialStorage))
.then(response => response.json() as Promise<Profile>);
}
28 changes: 17 additions & 11 deletions src/commands/ImpersonateCredentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import { URLSearchParams } from "url";
import { AccountServiceTokenResponse, Credentials } from "../oauth";
import { ApiConfiguration } from "../ApiConfiguration";

const createOAuthRequestTokenUrl = (code: string, config: ApiConfiguration): Request => {
const createOAuthRequestTokenUrl = (config: ApiConfiguration, code?: string): Request => {
const form = new URLSearchParams();
form.append("grant_type", "authorization_code");
form.append("code", code);
form.append("redirect_uri", config.redirectUri || "");
if(code) {
form.append("grant_type", "authorization_code");
form.append("code", code);
form.append("redirect_uri", config.redirectUri || "");
} else {
form.append("grant_type", "client_credentials");
}
form.append("client_id", config.clientId);
form.append("client_secret", config.clientSecret);

Expand All @@ -31,12 +35,14 @@ const createOAuthRequestTokenUrl = (code: string, config: ApiConfiguration): Req
* @param code
* @param config
*/
export default function retrieveImpersonatedCredentials(code: string, config: ApiConfiguration): Promise<Credentials> {
return fetch(createOAuthRequestTokenUrl(code, config))
export default function retrieveImpersonatedCredentials(config: ApiConfiguration, code?: string): Promise<Credentials> {
return fetch(createOAuthRequestTokenUrl(config, code))
.then(response => response.json() as Promise<AccountServiceTokenResponse>)
.then((tokenResponse: AccountServiceTokenResponse) => ({
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
expiresAt: new Date(Date.now() + (tokenResponse.expires_in * 1000))
}));
.then((tokenResponse: AccountServiceTokenResponse) => {
return ({
accessToken: tokenResponse.access_token,
refreshToken: tokenResponse.refresh_token,
expiresAt: new Date(Date.now() + (tokenResponse.expires_in * 1000))
});
});
}
21 changes: 12 additions & 9 deletions src/commands/Invoke.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import fetch, { Request } from "node-fetch";

import validateAndRefresh from "./RefreshIfNeeded";
import retrieveImpersonatedCredentials from "./ImpersonateCredentials";

import { Credentials, CredentialStorage } from "../oauth";
import { ApiConfiguration } from "../ApiConfiguration";
import queryString from 'query-string';
import { flatten } from 'q-flat';

// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
const createRequest = (url: string, credentials: Credentials, config: ApiConfiguration, payload?: Record<string, unknown> | []): Request => {
const createRequest = (url: string, config: ApiConfiguration, credentials: Credentials, payload?: Record<string, unknown> | []): Request => {
const headers = {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": `Bearer ${credentials.accessToken}`
};
return new Request({
href: `${process.env.PAXFUL_DATA_HOST}${url}`
}, {
method: "POST",
headers: {
"Accept": "application/json",
"Content-Type": "application/x-www-form-urlencoded",
"Authorization": `Bearer ${credentials.accessToken}`
},
headers,
agent: config.proxyAgent,
body: queryString.stringify(flatten(payload), { encode:false })
});
Expand All @@ -32,10 +34,11 @@ const createRequest = (url: string, credentials: Credentials, config: ApiConfigu
* @param config
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/explicit-module-boundary-types
export default function invoke(url: string, credentialStorage: CredentialStorage, config: ApiConfiguration, payload?: Record<string, unknown> | []): Promise<any> {
const request = createRequest(url, credentialStorage.getCredentials(), config, payload);
export default async function invoke(url: string, config: ApiConfiguration, credentialStorage?: CredentialStorage, payload?: Record<string, unknown> | []): Promise<any> {
const credentials = credentialStorage?.getCredentials() || await retrieveImpersonatedCredentials(config);
const request = createRequest(url, config, credentials, payload);
return fetch(request)
.then(response => validateAndRefresh(request, response, credentialStorage, config))
.then(response => validateAndRefresh(request, response, config, credentialStorage))
// eslint-disable-next-line @typescript-eslint/no-explicit-any
.then(response => response.json() as Promise<any>);
}
26 changes: 16 additions & 10 deletions src/commands/RefreshIfNeeded.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import fetch, { Request, Response } from "node-fetch";
import { URLSearchParams } from "url";

import retrieveImpersonatedCredentials from "./ImpersonateCredentials";

import { AccountServiceTokenResponse, Credentials, CredentialStorage } from "../oauth";
import { ApiConfiguration } from "../ApiConfiguration";

Expand Down Expand Up @@ -31,21 +33,25 @@ const refreshAccessToken = async (credentials: Credentials, config: ApiConfigura
}));
}

const createRequest = async (request: Request, credentialStorage: CredentialStorage, config: ApiConfiguration): Promise<Request> => {
let credentials = credentialStorage.getCredentials()
credentials = await refreshAccessToken(credentials, config);
credentialStorage.saveCredentials(credentials);

request.headers["Authorization"] = credentials.accessToken;
const createRequest = async (request: Request, config: ApiConfiguration, credentialStorage?: CredentialStorage): Promise<Request> => {
let credentials: Credentials;
if(credentialStorage){
credentials = credentialStorage.getCredentials()
credentials = await refreshAccessToken(credentials, config);
credentialStorage.saveCredentials(credentials);
} else {
credentials = await retrieveImpersonatedCredentials(config);
}
request.headers["Authorization"] = `Bearer ${credentials.accessToken}`;

return Promise.resolve(request);
}

const validateIfTokenIsExpired = async (request: Request, response: Response, credentialStorage: CredentialStorage, config: ApiConfiguration): Promise<Response> => {
if (response.status === 401) return await fetch(await createRequest(request, credentialStorage, config));
const validateIfTokenIsExpired = async (request: Request, response: Response, config: ApiConfiguration, credentialStorage?: CredentialStorage): Promise<Response> => {
if (response.status === 401) return await fetch(await createRequest(request, config, credentialStorage));
return Promise.resolve(response);
}

export default function validateAndRefresh(request: Request, response: Response, credentialStorage: CredentialStorage, config: ApiConfiguration): Promise<Response> {
return validateIfTokenIsExpired(request, response, credentialStorage, config);
export default function validateAndRefresh(request: Request, response: Response, config: ApiConfiguration, credentialStorage?: CredentialStorage): Promise<Response> {
return validateIfTokenIsExpired(request, response, config, credentialStorage);
}

0 comments on commit 3263351

Please sign in to comment.