-
Notifications
You must be signed in to change notification settings - Fork 40
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Adding authenticator class and tests (#6)
* Adding authenticator class and tests
- Loading branch information
Showing
6 changed files
with
3,394 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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`); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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(); | ||
}); | ||
}); |
Oops, something went wrong.