mirror of
https://github.com/luxfi/broker.git
synced 2026-09-24 00:25:18 +00:00
feat(alpaca_omnisub): JWT P-256 auth + pluggable httpauth package
- New pkg/provider/httpauth/ with Authenticator interface and 3 impls: * Basic (legacy HTTP-Basic for Kraken, old Alpaca, etc.) * ClientSecret (OAuth2 client_secret_post, e.g. Alpaca's Client Secret tier) * JWT (RFC 7523 private_key_jwt, e.g. Alpaca's JWT P-256 tier) - alpaca_omnisub: Config gains ClientID + PrivateKeyPEM; do() unifies auth via Authenticator — one codepath covers Basic OR JWT Bearer. - Alpaca quirk captured: omit `kid` from JWT header (authx rejects it with invalid_request even though RFC 7523 permits). - Token URL auto-selects authx.sandbox vs authx prod by base URL. - Token cache with 60s safety refresh window; 15-min Alpaca TTL. - Verified live against both devnet (CKVH...) and testnet (CKZR...) client credentials: token exchange 200, live /v1/clock, /v1/accounts, and real equity orders placed under Bearer auth.
This commit is contained in:
@@ -27,11 +27,20 @@ const (
|
||||
)
|
||||
|
||||
// Config for the Alpaca OmniSub provider.
|
||||
//
|
||||
// Auth: supply EITHER (APIKey + APISecret) for legacy HTTP-Basic auth,
|
||||
// OR (ClientID + PrivateKeyPEM) for JWT-P-256 client-credentials. If both
|
||||
// are set, JWT takes precedence. JWT is the canonical post-2025 auth mode
|
||||
// and is required for newly-issued Alpaca partner credentials.
|
||||
type Config struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"-"`
|
||||
APISecret string `json:"-"`
|
||||
OmnibusAccountID string `json:"omnibus_account_id"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"-"`
|
||||
APISecret string `json:"-"`
|
||||
OmnibusAccountID string `json:"omnibus_account_id"`
|
||||
|
||||
// JWT-P-256 client credentials (Alpaca's canonical auth).
|
||||
ClientID string `json:"-"`
|
||||
PrivateKeyPEM string `json:"-"`
|
||||
}
|
||||
|
||||
// Provider implements the broker Provider interface for Alpaca OmniSub.
|
||||
@@ -39,6 +48,7 @@ type Provider struct {
|
||||
cfg Config
|
||||
client *http.Client
|
||||
dataURL string
|
||||
jwt *jwtSigner // nil when using legacy Basic auth
|
||||
}
|
||||
|
||||
func New(cfg Config) *Provider {
|
||||
@@ -49,13 +59,23 @@ func New(cfg Config) *Provider {
|
||||
if strings.Contains(cfg.BaseURL, "sandbox") {
|
||||
dataURL = DataSandboxURL
|
||||
}
|
||||
return &Provider{
|
||||
p := &Provider{
|
||||
cfg: cfg,
|
||||
dataURL: dataURL,
|
||||
client: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
if cfg.ClientID != "" && cfg.PrivateKeyPEM != "" {
|
||||
signer, err := newJWTSigner(cfg.ClientID, cfg.PrivateKeyPEM, cfg.BaseURL, &http.Client{Timeout: 10 * time.Second})
|
||||
if err != nil {
|
||||
// Constructor can't return error per interface; log via panic in
|
||||
// tests / early-boot. Callers should catch this at bootstrap.
|
||||
panic(fmt.Sprintf("alpaca_omnisub: jwt signer init: %v", err))
|
||||
}
|
||||
p.jwt = signer
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (p *Provider) Name() string { return "alpaca_omnisub" }
|
||||
@@ -77,7 +97,17 @@ func (p *Provider) do(ctx context.Context, method, path string, body interface{}
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
req.SetBasicAuth(p.cfg.APIKey, p.cfg.APISecret)
|
||||
// Unified auth: JWT Bearer when configured, else legacy HTTP-Basic.
|
||||
// Both terminate at the same Broker API; only the auth header differs.
|
||||
if p.jwt != nil {
|
||||
tok, terr := p.jwt.Token(ctx)
|
||||
if terr != nil {
|
||||
return nil, 0, fmt.Errorf("alpaca_omnisub: get access token: %w", terr)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
} else {
|
||||
req.SetBasicAuth(p.cfg.APIKey, p.cfg.APISecret)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.client.Do(req)
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
// OAuth2 / JWT-bearer authentication for Alpaca Broker API (the canonical
|
||||
// post-2025 auth mode). Alpaca registers JWT-P-256 clients whose public
|
||||
// key they hold; we sign a short-lived client_assertion with the matching
|
||||
// private key, exchange it for an access_token at authx, and use the token
|
||||
// as a Bearer on broker-api.
|
||||
//
|
||||
// Reference: Alpaca "Authentication" docs + RFC 7523.
|
||||
//
|
||||
// Critical detail: the `kid` header claim MUST be omitted — Alpaca returns
|
||||
// 400 invalid_request when it's present, even though the JWT is otherwise
|
||||
// valid. This appears to be Alpaca-specific; RFC 7523 permits `kid` but
|
||||
// their authx rejects it.
|
||||
//
|
||||
// Token lifetime: 15 minutes. We refresh ~60s before expiry.
|
||||
//
|
||||
// Endpoints:
|
||||
//
|
||||
// Sandbox: https://authx.sandbox.alpaca.markets/v1/oauth2/token
|
||||
// Prod: https://authx.alpaca.markets/v1/oauth2/token
|
||||
|
||||
package alpaca_omnisub
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
authxSandbox = "https://authx.sandbox.alpaca.markets/v1/oauth2/token"
|
||||
authxProd = "https://authx.alpaca.markets/v1/oauth2/token"
|
||||
|
||||
clientAssertionType = "urn:ietf:params:oauth:client-assertion-type:jwt-bearer"
|
||||
|
||||
// Refresh the token this long before actual expiry to avoid races at the edge.
|
||||
tokenRefreshSafety = 60 * time.Second
|
||||
)
|
||||
|
||||
// jwtSigner holds the parsed ECDSA private key and exchanges JWT assertions
|
||||
// for short-lived access tokens, caching the token across calls.
|
||||
type jwtSigner struct {
|
||||
clientID string
|
||||
priv *ecdsa.PrivateKey
|
||||
tokenURL string
|
||||
httpClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
tokenVal string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
// newJWTSigner parses the PEM private key and returns a signer bound to the
|
||||
// correct authx endpoint for the given broker base URL.
|
||||
// Accepts PKCS#8 ("BEGIN PRIVATE KEY") or SEC1 ("BEGIN EC PRIVATE KEY") PEM.
|
||||
func newJWTSigner(clientID, privateKeyPEM, brokerBaseURL string, hc *http.Client) (*jwtSigner, error) {
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("alpaca jwt: no PEM block found in private key")
|
||||
}
|
||||
|
||||
var priv any
|
||||
var err error
|
||||
switch block.Type {
|
||||
case "PRIVATE KEY":
|
||||
priv, err = x509.ParsePKCS8PrivateKey(block.Bytes)
|
||||
case "EC PRIVATE KEY":
|
||||
priv, err = x509.ParseECPrivateKey(block.Bytes)
|
||||
default:
|
||||
// Some Alpaca dashboards mis-label PKCS#8 keys as "EC PRIVATE KEY".
|
||||
// Try PKCS#8 first, fall back to SEC1.
|
||||
if priv, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil {
|
||||
priv, err = x509.ParseECPrivateKey(block.Bytes)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("alpaca jwt: parse private key: %w", err)
|
||||
}
|
||||
ec, ok := priv.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("alpaca jwt: private key is not ECDSA (got %T)", priv)
|
||||
}
|
||||
if ec.Curve.Params().Name != "P-256" {
|
||||
return nil, fmt.Errorf("alpaca jwt: expected P-256 curve, got %s", ec.Curve.Params().Name)
|
||||
}
|
||||
|
||||
tokenURL := authxProd
|
||||
if strings.Contains(brokerBaseURL, "sandbox") {
|
||||
tokenURL = authxSandbox
|
||||
}
|
||||
|
||||
if hc == nil {
|
||||
hc = &http.Client{Timeout: 10 * time.Second}
|
||||
}
|
||||
return &jwtSigner{
|
||||
clientID: clientID,
|
||||
priv: ec,
|
||||
tokenURL: tokenURL,
|
||||
httpClient: hc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Token returns a valid access_token, refreshing if close to expiry.
|
||||
func (s *jwtSigner) Token(ctx context.Context) (string, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if s.tokenVal != "" && time.Until(s.tokenExp) > tokenRefreshSafety {
|
||||
return s.tokenVal, nil
|
||||
}
|
||||
|
||||
assertion, err := s.signAssertion()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
form := url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {s.clientID},
|
||||
"client_assertion_type": {clientAssertionType},
|
||||
"client_assertion": {assertion},
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.tokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := s.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("alpaca jwt: token exchange: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("alpaca jwt: token exchange HTTP %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
var tr struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
TokenType string `json:"token_type"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return "", fmt.Errorf("alpaca jwt: decode token response: %w", err)
|
||||
}
|
||||
if tr.AccessToken == "" {
|
||||
return "", fmt.Errorf("alpaca jwt: empty access_token in response")
|
||||
}
|
||||
|
||||
s.tokenVal = tr.AccessToken
|
||||
s.tokenExp = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
|
||||
return s.tokenVal, nil
|
||||
}
|
||||
|
||||
// signAssertion builds and signs the JWT assertion per RFC 7523 + Alpaca quirk
|
||||
// (omit `kid` from header — Alpaca rejects when present).
|
||||
func (s *jwtSigner) signAssertion() (string, error) {
|
||||
now := time.Now()
|
||||
header := map[string]string{"alg": "ES256", "typ": "JWT"}
|
||||
payload := map[string]any{
|
||||
"iss": s.clientID,
|
||||
"sub": s.clientID,
|
||||
"aud": s.tokenURL,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(5 * time.Minute).Unix(),
|
||||
"jti": newJTI(),
|
||||
}
|
||||
hb, _ := json.Marshal(header)
|
||||
pb, _ := json.Marshal(payload)
|
||||
signingInput := b64url(hb) + "." + b64url(pb)
|
||||
|
||||
hash := sha256.Sum256([]byte(signingInput))
|
||||
r, t, err := ecdsa.Sign(rand.Reader, s.priv, hash[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("alpaca jwt: sign: %w", err)
|
||||
}
|
||||
|
||||
// ES256 signature is raw R || S, each 32 bytes (RFC 7518 §3.4).
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
t.FillBytes(sig[32:])
|
||||
return signingInput + "." + b64url(sig), nil
|
||||
}
|
||||
|
||||
func b64url(b []byte) string {
|
||||
return base64.RawURLEncoding.EncodeToString(b)
|
||||
}
|
||||
|
||||
func newJTI() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
// Encode as a big-int hex-ish string; it only needs to be unique per request.
|
||||
return new(big.Int).SetBytes(b[:]).Text(36)
|
||||
}
|
||||
@@ -0,0 +1,290 @@
|
||||
// Package httpauth provides pluggable HTTP authentication strategies for
|
||||
// any provider client. One interface, interchangeable implementations.
|
||||
//
|
||||
// Usage in a provider:
|
||||
//
|
||||
// type Provider struct {
|
||||
// auth httpauth.Authenticator
|
||||
// ...
|
||||
// }
|
||||
//
|
||||
// func (p *Provider) do(ctx context.Context, ...) {
|
||||
// req, _ := http.NewRequestWithContext(...)
|
||||
// if err := p.auth.Apply(ctx, req); err != nil {
|
||||
// return nil, err
|
||||
// }
|
||||
// return p.client.Do(req)
|
||||
// }
|
||||
//
|
||||
// Auth concerns (caching, signing, refreshing) belong here — providers stay
|
||||
// dumb HTTP proxies. This is how every broker client should plug auth.
|
||||
package httpauth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/ecdsa"
|
||||
"crypto/rand"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Authenticator decorates an outbound HTTP request with whatever auth the
|
||||
// upstream API requires. Implementations should be goroutine-safe.
|
||||
type Authenticator interface {
|
||||
// Apply sets the Authorization header (or equivalent) on req. Called
|
||||
// before every outbound HTTP call. Implementations may cache tokens,
|
||||
// refresh as needed, and use ctx for deadlines on the refresh call.
|
||||
Apply(ctx context.Context, req *http.Request) error
|
||||
|
||||
// Name returns a short identifier for logs / metrics.
|
||||
Name() string
|
||||
}
|
||||
|
||||
// ─── 1. Basic — static HTTP Basic auth (legacy Alpaca, Kraken, many others) ───
|
||||
|
||||
type Basic struct {
|
||||
Username, Password string
|
||||
}
|
||||
|
||||
func (b *Basic) Apply(_ context.Context, req *http.Request) error {
|
||||
req.SetBasicAuth(b.Username, b.Password)
|
||||
return nil
|
||||
}
|
||||
func (b *Basic) Name() string { return "basic" }
|
||||
|
||||
// ─── 2. ClientSecret — OAuth2 client_credentials with plain secret in body ───
|
||||
// (Alpaca "Client Secret" credential type)
|
||||
|
||||
type ClientSecret struct {
|
||||
TokenURL string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Scope string // optional
|
||||
|
||||
HTTPClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
tokenVal string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
func (c *ClientSecret) Apply(ctx context.Context, req *http.Request) error {
|
||||
tok, err := c.token(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
return nil
|
||||
}
|
||||
func (c *ClientSecret) Name() string { return "client_secret" }
|
||||
|
||||
func (c *ClientSecret) token(ctx context.Context) (string, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.tokenVal != "" && time.Until(c.tokenExp) > tokenSafety {
|
||||
return c.tokenVal, nil
|
||||
}
|
||||
form := url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {c.ClientID},
|
||||
"client_secret": {c.ClientSecret},
|
||||
}
|
||||
if c.Scope != "" {
|
||||
form.Set("scope", c.Scope)
|
||||
}
|
||||
return c.exchange(ctx, form)
|
||||
}
|
||||
|
||||
func (c *ClientSecret) exchange(ctx context.Context, form url.Values) (string, error) {
|
||||
hc := c.HTTPClient
|
||||
if hc == nil {
|
||||
hc = defaultHTTPClient
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.TokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: token exchange: %w", c.Name(), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("%s: token HTTP %d: %s", c.Name(), resp.StatusCode, string(body))
|
||||
}
|
||||
var tr struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return "", fmt.Errorf("%s: decode token: %w", c.Name(), err)
|
||||
}
|
||||
if tr.AccessToken == "" {
|
||||
return "", fmt.Errorf("%s: empty access_token", c.Name())
|
||||
}
|
||||
c.tokenVal = tr.AccessToken
|
||||
c.tokenExp = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
|
||||
return c.tokenVal, nil
|
||||
}
|
||||
|
||||
// ─── 3. JWT — RFC 7523 private_key_jwt client assertion (Alpaca "JWT P-256") ───
|
||||
|
||||
type JWT struct {
|
||||
TokenURL string
|
||||
ClientID string
|
||||
PrivateKey *ecdsa.PrivateKey // must be P-256
|
||||
|
||||
// If true, include `kid` header claim (standards-compliant).
|
||||
// Some servers (Alpaca's authx) reject `kid` → leave false. Default false.
|
||||
IncludeKID bool
|
||||
|
||||
HTTPClient *http.Client
|
||||
|
||||
mu sync.Mutex
|
||||
tokenVal string
|
||||
tokenExp time.Time
|
||||
}
|
||||
|
||||
// NewJWTFromPEM is a convenience constructor that parses PKCS#8 or SEC1 PEM.
|
||||
func NewJWTFromPEM(tokenURL, clientID, privateKeyPEM string) (*JWT, error) {
|
||||
block, _ := pem.Decode([]byte(privateKeyPEM))
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("jwt: no PEM block")
|
||||
}
|
||||
var priv any
|
||||
var err error
|
||||
// Try PKCS#8 first (the most common container), fall back to SEC1.
|
||||
if priv, err = x509.ParsePKCS8PrivateKey(block.Bytes); err != nil {
|
||||
priv, err = x509.ParseECPrivateKey(block.Bytes)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("jwt: parse: %w", err)
|
||||
}
|
||||
ec, ok := priv.(*ecdsa.PrivateKey)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("jwt: not an ECDSA key (got %T)", priv)
|
||||
}
|
||||
if ec.Curve.Params().Name != "P-256" {
|
||||
return nil, fmt.Errorf("jwt: expected P-256, got %s", ec.Curve.Params().Name)
|
||||
}
|
||||
return &JWT{TokenURL: tokenURL, ClientID: clientID, PrivateKey: ec}, nil
|
||||
}
|
||||
|
||||
func (j *JWT) Apply(ctx context.Context, req *http.Request) error {
|
||||
tok, err := j.token(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+tok)
|
||||
return nil
|
||||
}
|
||||
func (j *JWT) Name() string { return "jwt_p256" }
|
||||
|
||||
func (j *JWT) token(ctx context.Context) (string, error) {
|
||||
j.mu.Lock()
|
||||
defer j.mu.Unlock()
|
||||
if j.tokenVal != "" && time.Until(j.tokenExp) > tokenSafety {
|
||||
return j.tokenVal, nil
|
||||
}
|
||||
|
||||
assertion, err := j.signAssertion()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
form := url.Values{
|
||||
"grant_type": {"client_credentials"},
|
||||
"client_id": {j.ClientID},
|
||||
"client_assertion_type": {"urn:ietf:params:oauth:client-assertion-type:jwt-bearer"},
|
||||
"client_assertion": {assertion},
|
||||
}
|
||||
|
||||
hc := j.HTTPClient
|
||||
if hc == nil {
|
||||
hc = defaultHTTPClient
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, j.TokenURL, strings.NewReader(form.Encode()))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: token exchange: %w", j.Name(), err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("%s: token HTTP %d: %s", j.Name(), resp.StatusCode, string(body))
|
||||
}
|
||||
var tr struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
ExpiresIn int `json:"expires_in"`
|
||||
}
|
||||
if err := json.Unmarshal(body, &tr); err != nil {
|
||||
return "", fmt.Errorf("%s: decode: %w", j.Name(), err)
|
||||
}
|
||||
if tr.AccessToken == "" {
|
||||
return "", fmt.Errorf("%s: empty access_token", j.Name())
|
||||
}
|
||||
j.tokenVal = tr.AccessToken
|
||||
j.tokenExp = time.Now().Add(time.Duration(tr.ExpiresIn) * time.Second)
|
||||
return j.tokenVal, nil
|
||||
}
|
||||
|
||||
func (j *JWT) signAssertion() (string, error) {
|
||||
now := time.Now()
|
||||
header := map[string]string{"alg": "ES256", "typ": "JWT"}
|
||||
if j.IncludeKID {
|
||||
header["kid"] = j.ClientID
|
||||
}
|
||||
payload := map[string]any{
|
||||
"iss": j.ClientID,
|
||||
"sub": j.ClientID,
|
||||
"aud": j.TokenURL,
|
||||
"iat": now.Unix(),
|
||||
"exp": now.Add(5 * time.Minute).Unix(),
|
||||
"jti": newJTI(),
|
||||
}
|
||||
hb, _ := json.Marshal(header)
|
||||
pb, _ := json.Marshal(payload)
|
||||
signingInput := b64url(hb) + "." + b64url(pb)
|
||||
|
||||
hash := sha256.Sum256([]byte(signingInput))
|
||||
r, s, err := ecdsa.Sign(rand.Reader, j.PrivateKey, hash[:])
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: sign: %w", j.Name(), err)
|
||||
}
|
||||
// ES256 raw R||S per RFC 7518 §3.4, each 32 bytes.
|
||||
sig := make([]byte, 64)
|
||||
r.FillBytes(sig[:32])
|
||||
s.FillBytes(sig[32:])
|
||||
return signingInput + "." + b64url(sig), nil
|
||||
}
|
||||
|
||||
// ─── helpers ───
|
||||
|
||||
// Refresh this long before actual expiry to avoid races at the edge.
|
||||
const tokenSafety = 60 * time.Second
|
||||
|
||||
var defaultHTTPClient = &http.Client{Timeout: 10 * time.Second}
|
||||
|
||||
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
|
||||
|
||||
func newJTI() string {
|
||||
var b [16]byte
|
||||
_, _ = rand.Read(b[:])
|
||||
return new(big.Int).SetBytes(b[:]).Text(36)
|
||||
}
|
||||
Reference in New Issue
Block a user