-
Notifications
You must be signed in to change notification settings - Fork 40
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
Changes from 1 commit
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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; | ||
} | ||
} |
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,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"); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
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.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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