Skip to content

Commit

Permalink
Add support for API key authentication
Browse files Browse the repository at this point in the history
  • Loading branch information
ammario committed Nov 7, 2024
1 parent 6535bb5 commit 3ed6259
Show file tree
Hide file tree
Showing 5 changed files with 111 additions and 21 deletions.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
secrets/**
secrets.env
5 changes: 4 additions & 1 deletion auth.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package kalshi

import "context"
import (
"context"
)

// LoginRequest is described here:
// https://trading-api.readme.io/reference/login.
Expand Down Expand Up @@ -32,6 +34,7 @@ func (c *Client) Login(ctx context.Context, req LoginRequest) (*LoginResponse, e
if err != nil {
return nil, err
}

return &resp, nil
}

Expand Down
99 changes: 90 additions & 9 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ package kalshi
import (
"bytes"
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"fmt"
"io"
"net/http"
Expand All @@ -30,6 +36,30 @@ func (c Cents) String() string {
return fmt.Sprintf("$%.2f", dollars)
}

type APIKey struct {
ID string
Key *rsa.PrivateKey
}

func LoadAPIKey(apiKeyID, path string) (*APIKey, error) {
key, err := os.ReadFile(path)
if err != nil {
return nil, err
}
// Parse PEM encoded RSA private key
block, _ := pem.Decode(key)
if block == nil {
return nil, fmt.Errorf("failed to decode PEM block")
}

rsaKey, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("failed to parse private key: %w", err)
}

return &APIKey{ID: apiKeyID, Key: rsaKey}, nil
}

// Client must be instantiated via New.
type Client struct {
// BaseURL is one of APIDemoURL or APIProdURL.
Expand All @@ -39,6 +69,11 @@ type Client struct {
WriteRatelimit *rate.Limiter
ReadRateLimit *rate.Limiter

// APIKey is optional if you use the Login method.
// As of 2024-11-07, Login-based auth is not working and returning
// 403 Forbidden?
APIKey *APIKey

httpClient *http.Client
}

Expand All @@ -60,27 +95,72 @@ type request struct {
JSONResponse any
}

func jsonRequestHeaders(
func (c *Client) signRequest(req *http.Request) error {
if c.APIKey == nil {
return nil
}

timestamp := time.Now().UnixMilli()
payload := fmt.Sprintf("%d%s%s", timestamp, req.Method, req.URL.Path)

hashed := crypto.SHA256.New()
hashed.Write([]byte(payload))
signature, err := rsa.SignPSS(
rand.Reader,
c.APIKey.Key,
crypto.SHA256,
hashed.Sum(nil),
&rsa.PSSOptions{
SaltLength: rsa.PSSSaltLengthEqualsHash,
})
if err != nil {
return fmt.Errorf("failed to sign request: %w", err)
}

req.Header.Set("KALSHI-ACCESS-KEY", c.APIKey.ID)
req.Header.Set("KALSHI-ACCESS-TIMESTAMP", strconv.FormatInt(timestamp, 10))
req.Header.Set("KALSHI-ACCESS-SIGNATURE", base64.StdEncoding.EncodeToString(signature))

return nil
}

func (c *Client) jsonRequestHeaders(
ctx context.Context,
client *http.Client,
headers http.Header,
method string, reqURL string,
jsonReq any, jsonResp any,
) error {
reqBodyByt, err := json.Marshal(jsonReq)
if err != nil {
return err
var (
reqBodyReader io.Reader
reqBodyBytes []byte
)
if jsonReq != nil {
var err error
reqBodyBytes, err = json.Marshal(jsonReq)
if err != nil {
return err
}
reqBodyReader = bytes.NewReader(reqBodyBytes)
}

req, err := http.NewRequest(method, reqURL, bytes.NewReader(reqBodyByt))
req, err := http.NewRequestWithContext(ctx, method, reqURL, reqBodyReader)
if err != nil {
return err
}
if headers != nil {
req.Header = headers
}
req.Header.Set("Content-Type", "application/json")

if err := c.signRequest(req); err != nil {
return fmt.Errorf("sign request: %w", err)
}

if req.Method == "POST" || req.Method == "PUT" {
req.Header.Set("Content-Type", "application/json")
}
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "ammario/kalshi-go")

resp, err := client.Do(req)
if err != nil {
Expand All @@ -102,9 +182,10 @@ func jsonRequestHeaders(
if err != nil {
return fmt.Errorf("dump: %w", err)
}
dumpErr := fmt.Sprintf("Request\n%s%s\nResponse\n%s%s",
dumpErr := fmt.Sprintf("Request %s\n%s%s\nResponse\n%s%s",
reqURL,
reqDump,
reqBodyByt,
reqBodyBytes,
respDump,
respBodyByt,
)
Expand Down Expand Up @@ -164,7 +245,7 @@ func (c *Client) request(
}
}

return jsonRequestHeaders(
return c.jsonRequestHeaders(
ctx,
c.httpClient,
nil,
Expand Down
22 changes: 12 additions & 10 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,27 +14,29 @@ var rateLimit = rate.NewLimiter(rate.Every(time.Second), 10-1)

func testClient(t *testing.T) *Client {
const (
emailEnv = "KALSHI_EMAIL"
passEnv = "KALSHI_PASSWORD"
apiKeyIDEnv = "KALSHI_API_KEY_ID"
apiKeyPathEnv = "KALSHI_API_KEY_PATH"
)

ctx := context.Background()

email, ok := os.LookupEnv(emailEnv)
apiKeyID, ok := os.LookupEnv(apiKeyIDEnv)
if !ok {
t.Fatalf("no $%s provided", emailEnv)
t.Fatalf("no $%s provided", apiKeyIDEnv)
}
password, ok := os.LookupEnv(passEnv)

apiKeyPath, ok := os.LookupEnv(apiKeyPathEnv)
if !ok {
t.Fatalf("no $%s provided", passEnv)
t.Fatalf("no $%s provided", apiKeyPathEnv)
}

apiKey, err := LoadAPIKey(apiKeyID, apiKeyPath)
require.NoError(t, err)

c := New(APIDemoURL)
c.WriteRatelimit = rateLimit
_, err := c.Login(ctx, LoginRequest{
Email: email,
Password: password,
})
c.APIKey = apiKey

require.NoError(t, err)
t.Cleanup(func() {
// Logout will fail during the Logout test.
Expand Down
4 changes: 3 additions & 1 deletion exchange_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ func TestExchangeStatus(t *testing.T) {

client := testClient(t)

// ExchangeStatus is not authenticated
client.APIKey = nil

s, err := client.ExchangeStatus(context.Background())
require.NoError(t, err)
// The Demo API never sleeps.
Expand All @@ -27,5 +30,4 @@ func TestExchangeSchedule(t *testing.T) {
_, err := client.ExchangeSchedule(context.Background())

require.NoError(t, err)

}

0 comments on commit 3ed6259

Please sign in to comment.