Skip to content

Commit

Permalink
Adding authenticator class and tests (#6)
Browse files Browse the repository at this point in the history
* Adding authenticator class and tests
  • Loading branch information
erdimaden authored May 13, 2024
1 parent 29e89ba commit 7ca5e2e
Show file tree
Hide file tree
Showing 6 changed files with 3,394 additions and 1 deletion.
1 change: 1 addition & 0 deletions .eslintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"sourceType": "module"
},
"rules": {
"multiline-comment-style": ["error", "starred-block"],
"prettier/prettier": "error"
},
"ignorePatterns": ["src/**/__tests__/**", "src/**/*.test.ts"]
Expand Down
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",
"test": "npx jest --no-cache",
"clean": "rm -rf dist/*",
"build": "tsc",
"prepack": "tsc",
Expand Down Expand Up @@ -40,6 +40,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
121 changes: 121 additions & 0 deletions src/coinbase/authenticator.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { JWK, JWS } from "node-jose";
import { InternalError, InvalidAPIKeyFormat } from "./errors";
import { InternalAxiosRequestConfig } from "axios";

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.
* @throws {InternalError} If there is an issue with the private key.
*/
async authenticateRequest(
config: InternalAxiosRequestConfig,
): Promise<InternalAxiosRequestConfig> {
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 JWT if successful or throws an error.
* @throws {InternalError} If there is an issue with the private key.
*/
async buildJWT(url: string, method = "GET"): Promise<string> {
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.
* @throws {InvalidAPIKeyFormat} If the private key string is not in the correct format.
*/
private extractPemKey(privateKeyString: string): string {
// Remove all newline characters
privateKeyString = privateKeyString.replace(/\n/g, "");

if (privateKeyString.startsWith(pemHeader) && privateKeyString.endsWith(pemFooter)) {
return privateKeyString;
}

throw InvalidAPIKeyFormat;
}

/**
* Generates a random nonce for the JWT.
* @returns {string}
*/
private nonce(): string {
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`);
43 changes: 43 additions & 0 deletions src/coinbase/tests/authenticator_test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
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");
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

0 comments on commit 7ca5e2e

Please sign in to comment.