Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Adding authenticator class and tests #6

Merged
merged 4 commits into from
May 13, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
"format": "prettier -c .prettierrc --write \"**/*.{ts,js,cjs,json,md}\"",
"format-check": "prettier -c .prettierrc --check \"**/*.{ts,js,cjs,json,md}\"",
"check": "tsc --noEmit",
"test": "NODE_OPTIONS=--experimental-vm-modules npx jest --no-cache",
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this prefix prevents to run jest so removing it

"test": "npx jest --no-cache",
"clean": "rm -rf dist/*",
"build": "tsc",
"prepack": "tsc"
Expand All @@ -39,6 +39,7 @@
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-prettier": "^5.1.3",
"jest": "^29.7.0",
"mock-fs": "^5.2.0",
"prettier": "^3.2.5",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.2",
Expand Down
126 changes: 126 additions & 0 deletions src/coinbase/authenticator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { JWK, JWS } from "node-jose";
import { InternalError, InvalidAPIKeyFormat } from "./errors";
import { InternalAxiosRequestConfig } from "axios";

const legacyPemHeader = "-----BEGIN ECDSA Private Key-----";
const legacyPemFooter = "-----END ECDSA Private Key-----";
const pemHeader = "-----BEGIN EC PRIVATE KEY-----";
const pemFooter = "-----END EC PRIVATE KEY-----";

// A class that builds JWTs for authenticating with the Coinbase Platform APIs.
export class CoinbaseAuthenticator {
private apiKey: string;
private privateKey: string;

/*
Initializes the Authenticator.
@constructor
@param {string} apiKey - The API key name.
@param {string} privateKey - The private key associated with the API key.
*/
constructor(apiKey: string, privateKey: string) {
this.apiKey = apiKey;
this.privateKey = privateKey;
}

/*
Middleware to intercept requests and add JWT to the Authorization header for AxiosInterceptor
@param {MiddlewareRequestType} config - The request configuration.
@returns {MiddlewareRequestType} The request configuration with the Authorization header added.
*/
async authenticateRequest(config: InternalAxiosRequestConfig) {
erdimaden marked this conversation as resolved.
Show resolved Hide resolved
const method = config.method?.toString().toUpperCase();
const token = await this.buildJWT(config.url || "", method);

config.headers["Authorization"] = `Bearer ${token}`;
config.headers["Content-Type"] = "application/json";
return config;
}

/*
Builds the JWT for the given API endpoint URI. The JWT is signed with the API key's private key.
@param {string} url - The URI of the API endpoint.
@param {string} method - The HTTP method of the request.
@returns {string} The signed JWT.
*/
async buildJWT(url: string, method = "GET"): Promise<string> {
erdimaden marked this conversation as resolved.
Show resolved Hide resolved
const pemPrivateKey = this.extractPemKey(this.privateKey);
let privateKey: JWK.Key;

try {
privateKey = await JWK.asKey(pemPrivateKey, "pem");
if (privateKey.kty !== "EC") {
throw InternalError;
}
} catch (error) {
throw InternalError;
}

const header = {
alg: "ES256",
kid: this.apiKey,
typ: "JWT",
nonce: this.nonce(),
};

const uri = `${method} ${url.substring(8)}`;
const claims = {
sub: this.apiKey,
iss: "coinbase-cloud",
aud: ["cdp_service"],
nbf: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + 60, // +1 minute
uri,
};

const payload = Buffer.from(JSON.stringify(claims)).toString("utf8");
try {
const result = await JWS.createSign({ format: "compact", fields: header }, privateKey)
.update(payload)
.final();

return result as unknown as string;
} catch (err) {
throw InternalError;
}
}

/*
Extracts the PEM key from the given private key string.
@param {string} privateKeyString - The private key string.
@returns {string} The PEM key.
*/
extractPemKey(privateKeyString: string): string {
erdimaden marked this conversation as resolved.
Show resolved Hide resolved
// Remove all newline characters
privateKeyString = privateKeyString.replace(/\n/g, "");

// If the string starts with the standard PEM header and footer, return as is.
if (privateKeyString.startsWith(pemHeader) && privateKeyString.endsWith(pemFooter)) {
return privateKeyString;
}

// If the string starts with the legacy header and footer, replace them.
const regex = new RegExp(`^${legacyPemHeader}([\\s\\S]+?)${legacyPemFooter}$`);

const match = privateKeyString.match(regex);

if (match && match[1]) {
return pemHeader + match[1].trim() + pemFooter;
}

// The string does not match any of the expected formats.
throw InvalidAPIKeyFormat;
}

// Generates a random nonce for the JWT.
erdimaden marked this conversation as resolved.
Show resolved Hide resolved
nonce(): string {
erdimaden marked this conversation as resolved.
Show resolved Hide resolved
const range = "0123456789";
let result = "";

for (let i = 0; i < 16; i++) {
result += range.charAt(Math.floor(Math.random() * range.length));
}

return result;
}
}
3 changes: 3 additions & 0 deletions src/coinbase/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export const InvalidConfiguration = new Error("Invalid configuration");
export const InvalidAPIKeyFormat = new Error("Invalid format of API private key");
export const InternalError = new Error(`Internal Error`);
42 changes: 42 additions & 0 deletions src/coinbase/tests/authenticator_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { AxiosHeaders } from "axios";
import { CoinbaseAuthenticator } from "../authenticator";

const VALID_KEY =
"organizations/0c3bbe72-ac81-46ec-946a-7cd019d6d86b/apiKeys/db813705-bf33-4e33-816c-4c3f1f54672b";
const VALID_PRIVATE_KEY =
"-----BEGIN EC PRIVATE KEY-----\nMHcCAQEEIBPl8LBKrDw2Is+bxQEXa2eHhDmvIgArOhSAdmYpYQrCoAoGCCqGSM49\nAwEHoUQDQgAEQSoVSr8ImpS18thpGe3KuL9efy+L+AFdFFfCVwGgCsKvTYVDKaGo\nVmN5Bl6EJkeIQjyarEtWbmY6komwEOdnHA==\n-----END EC PRIVATE KEY-----\n";

const VALID_CONFIG = {
method: "GET",
url: "https://api.cdp.coinbase.com/platform/v1/users/me",
headers: {} as AxiosHeaders,
};

describe("Authenticator tests", () => {
const authenticator = new CoinbaseAuthenticator(VALID_KEY, VALID_PRIVATE_KEY);
it("should raise InvalidConfiguration error", async () => {
const invalidConfig = {
method: "GET",
url: "https://api.cdp.coinbase.com/platform/v1/users/me",
headers: {} as AxiosHeaders,
};
const authenticator = new CoinbaseAuthenticator("api_key", "private_key");
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we use the authenticator with the valid api key and private key here to ensures that the error being thrown on authenticateRequest is coming from the invalid config and not prom the invalid private key?

await expect(authenticator.authenticateRequest(invalidConfig)).rejects.toThrow();
});

it("should return a valid signature", async () => {
const config = await authenticator.authenticateRequest(VALID_CONFIG);
const token = config.headers?.Authorization as string;
expect(token).toContain("Bearer ");
// length of the token should be greater than 100
expect(token?.length).toBeGreaterThan(100);
});

it("invalid pem key should raise an error", () => {
const invalidAuthenticator = new CoinbaseAuthenticator(
"test-key",
"-----BEGIN EC PRIVATE KEY-----+L+==\n-----END EC PRIVATE KEY-----\n",
);
expect(invalidAuthenticator.authenticateRequest(VALID_CONFIG)).rejects.toThrow();
});
});
Loading
Loading