Skip to content
Open
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
161 changes: 161 additions & 0 deletions internal/cyberark/conjur/conjur.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
package conjur

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"sync"
"time"

"k8s.io/klog/v2"

"github.com/jetstack/preflight/internal/cyberark/jwtsource"
)

const tokenTTL = 8 * time.Minute

// Client exchanges a JWT for a Conjur access token and authenticates requests with it.
type Client struct {
httpClient *http.Client
baseURL string
serviceID string
account string
src jwtsource.Source

mu sync.Mutex
token string
identity string
tokenTime time.Time
}

func New(httpClient *http.Client, baseURL, serviceID, account string, src jwtsource.Source) *Client {
return &Client{httpClient: httpClient, baseURL: baseURL, serviceID: serviceID, account: account, src: src}
}

func (c *Client) exchange(ctx context.Context) (string, error) {
jwt, err := c.src.Read(ctx)
if err != nil {
return "", err
}
endpoint, err := url.JoinPath(c.baseURL, "authn-jwt", c.serviceID, c.account, "authenticate")
if err != nil {
return "", err
}
form := url.Values{"jwt": {jwt}}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
// Request the base64-encoded access token — Conjur's canonical wire form
// for the token, and the encoding this client's own decoding below
// expects.
req.Header.Set("Accept-Encoding", "base64")
resp, err := c.httpClient.Do(req)
if err != nil {
return "", fmt.Errorf("authn-jwt exchange transport error: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
// 401 here most often means the SA token audience != authenticator audience=conjur
return "", fmt.Errorf("authn-jwt exchange rejected (%d): verify service_id, the authenticator is enabled, and the SA token audience is 'conjur'", resp.StatusCode)
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
if err != nil {
return "", err
}
return strings.TrimSpace(string(body)), nil
}

// padBase64 adds the '=' padding base64.StdEncoding/URLEncoding require,
// for inputs that arrived without it.
func padBase64(s string) string {
return s + strings.Repeat("=", (4-len(s)%4)%4)
}

// flattenedJWSJSON is the wire shape of a Conjur access token: a Flattened
// JWS JSON Serialization object, optionally base64-encoded on top (Conjur's
// `Accept-Encoding: base64`, which this client requests).
type flattenedJWSJSON struct {
Protected string `json:"protected"`
Payload string `json:"payload"`
Signature string `json:"signature"`
}

// conjurTokenObject parses a Conjur access token into its Flattened-JWS-JSON
// object, tolerating the token being raw JSON, standard base64, or
// url-safe base64 (Conjur may return any of these depending on encoding).
func conjurTokenObject(token string) (*flattenedJWSJSON, bool) {
candidates := []string{token}
padded := padBase64(token)
if decoded, err := base64.StdEncoding.DecodeString(padded); err == nil {
candidates = append(candidates, string(decoded))
}
if decoded, err := base64.URLEncoding.DecodeString(padded); err == nil {
candidates = append(candidates, string(decoded))
}
for _, candidate := range candidates {
var obj flattenedJWSJSON
if err := json.Unmarshal([]byte(candidate), &obj); err != nil {
continue
}
if obj.Protected != "" && obj.Payload != "" && obj.Signature != "" {
return &obj, true
}
}
return nil, false
}

// identityFromToken extracts the `sub` claim from a Conjur access token's
// payload. The payload segment is url-safe base64 without padding. Returns
// ("", false) if the token doesn't parse or has no `sub` claim.
func identityFromToken(token string) (string, bool) {
obj, ok := conjurTokenObject(token)
if !ok {
return "", false
}
payloadJSON, err := base64.URLEncoding.DecodeString(padBase64(obj.Payload))
if err != nil {
return "", false
}
var payload struct {
Sub string `json:"sub"`
}
if err := json.Unmarshal(payloadJSON, &payload); err != nil {
return "", false
}
if payload.Sub == "" {
return "", false
}
return payload.Sub, true
}

// AuthenticateRequest implements identity.RequestAuthenticator.
// It exchanges the JWT for a Conjur access token, sets the Authorization
// header, and returns an identity string for audit tagging. The identity is
// the token's own `sub` claim when it can be extracted; otherwise it falls
// back to the configured service ID so a token in an unexpected shape never
// fails the request.
func (c *Client) AuthenticateRequest(req *http.Request) (string, error) {
c.mu.Lock()
defer c.mu.Unlock()
if c.token == "" || time.Since(c.tokenTime) >= tokenTTL {
tok, err := c.exchange(req.Context())
if err != nil {
return "", err
}
identity, ok := identityFromToken(tok)
if !ok {
klog.FromContext(req.Context()).V(2).Info("could not extract sub claim from Conjur access token; falling back to service ID as identity")
identity = c.serviceID
}
c.token, c.identity, c.tokenTime = tok, identity, time.Now()
}
req.Header.Set("Authorization", "Bearer "+c.token)
return c.identity, nil
}
116 changes: 116 additions & 0 deletions internal/cyberark/conjur/conjur_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
package conjur

import (
"context"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"

"github.com/stretchr/testify/require"
)

type staticSource struct{ tok string }

func (s staticSource) Read(context.Context) (string, error) { return s.tok, nil }

// mockConjurExchangeServerCountingExchanges is like MockConjurExchangeServer but
// also counts how many times the exchange endpoint was hit, to verify
// token/identity caching doesn't re-exchange on every AuthenticateRequest call.
func mockConjurExchangeServerCountingExchanges(t testing.TB, token string, count *int) *httptest.Server {
t.Helper()
return httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.FormValue("jwt") == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
*count++
_, _ = w.Write([]byte(token))
}))
}

// buildJWSToken builds a base64-encoded Flattened-JWS-JSON token (Conjur's
// wire form) with the given `sub` claim, for test use only.
func buildJWSToken(t testing.TB, sub string) string {
t.Helper()
payload, err := json.Marshal(map[string]string{"sub": sub})
require.NoError(t, err)
obj := map[string]string{
"protected": base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte(`{"alg":"conjur.v2"}`)),
"payload": base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(payload),
"signature": "sig",
}
raw, err := json.Marshal(obj)
require.NoError(t, err)
return base64.StdEncoding.EncodeToString(raw)
}

func TestAuthenticateRequest_ExchangesAndSetsBearer(t *testing.T) {
srv, httpClient := MockConjurExchangeServer(t, "conjur-access-token")
defer srv.Close()

c := New(httpClient, srv.URL, "dev-cluster", "conjur", staticSource{tok: "the-jwt"})
req, _ := http.NewRequest(http.MethodGet, "https://example.com/snapshot-links", nil)
_, err := c.AuthenticateRequest(req)
require.NoError(t, err)
require.Equal(t, `Bearer conjur-access-token`, req.Header.Get("Authorization"))
}

func TestAuthenticateRequest_ExchangeFailsClosed(t *testing.T) {
srv, httpClient := MockConjurExchangeServerStatus(t, http.StatusUnauthorized)
defer srv.Close()
c := New(httpClient, srv.URL, "dev-cluster", "conjur", staticSource{tok: "the-jwt"})
req, _ := http.NewRequest(http.MethodGet, "https://example.com/x", nil)
_, err := c.AuthenticateRequest(req)
require.Error(t, err)
require.Empty(t, req.Header.Get("Authorization"))
}

func TestAuthenticateRequest_ReturnsSubClaimFromToken(t *testing.T) {
const sub = "host/data/k8s/test-cluster-uuid/workloads/system:serviceaccount:test:test-agent"
token := buildJWSToken(t, sub)
srv, httpClient := MockConjurExchangeServer(t, token)
defer srv.Close()

c := New(httpClient, srv.URL, "dev-cluster", "conjur", staticSource{tok: "the-jwt"})
req, _ := http.NewRequest(http.MethodGet, "https://example.com/snapshot-links", nil)
identity, err := c.AuthenticateRequest(req)
require.NoError(t, err)
require.Equal(t, sub, identity)
require.Equal(t, "Bearer "+token, req.Header.Get("Authorization"))
}

func TestAuthenticateRequest_OpaqueTokenFallsBackToServiceID(t *testing.T) {
// Opaque placeholder tokens (as used elsewhere in this repo's tests) are
// not JWS-JSON; extraction must fail gracefully, not error the request.
srv, httpClient := MockConjurExchangeServer(t, "success-token")
defer srv.Close()

c := New(httpClient, srv.URL, "dev-cluster", "conjur", staticSource{tok: "the-jwt"})
req, _ := http.NewRequest(http.MethodGet, "https://example.com/snapshot-links", nil)
identity, err := c.AuthenticateRequest(req)
require.NoError(t, err)
require.Equal(t, "dev-cluster", identity)
}

func TestAuthenticateRequest_CachesIdentityWithToken(t *testing.T) {
const sub = "host/data/k8s/test-cluster-uuid/workloads/system:serviceaccount:test:test-agent"
token := buildJWSToken(t, sub)
var exchanges int
srv := mockConjurExchangeServerCountingExchanges(t, token, &exchanges)
defer srv.Close()

c := New(srv.Client(), srv.URL, "dev-cluster", "conjur", staticSource{tok: "the-jwt"})
req1, _ := http.NewRequest(http.MethodGet, "https://example.com/a", nil)
identity1, err := c.AuthenticateRequest(req1)
require.NoError(t, err)

req2, _ := http.NewRequest(http.MethodGet, "https://example.com/b", nil)
identity2, err := c.AuthenticateRequest(req2)
require.NoError(t, err)

require.Equal(t, sub, identity1)
require.Equal(t, identity1, identity2)
require.Equal(t, 1, exchanges, "expected only one exchange for two AuthenticateRequest calls within the token TTL")
}
28 changes: 28 additions & 0 deletions internal/cyberark/conjur/mock.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package conjur

import (
"net/http"
"net/http/httptest"
"testing"
)

// MockConjurExchangeServer returns a TLS server whose authn-jwt endpoint returns the given token.
func MockConjurExchangeServer(t testing.TB, token string) (*httptest.Server, *http.Client) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost || r.FormValue("jwt") == "" {
w.WriteHeader(http.StatusBadRequest)
return
}
_, _ = w.Write([]byte(token))
}))
return srv, srv.Client()
}

func MockConjurExchangeServerStatus(t testing.TB, status int) (*httptest.Server, *http.Client) {
t.Helper()
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(status)
}))
return srv, srv.Client()
}
Loading