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 all commits
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
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",
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
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");
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