Skip to content

Commit b59e43d

Browse files
author
rzisholz
committed
CP-21164: add Conjur JWT authentication client
Exchanges a projected ServiceAccount JWT (via jwtsource) for a Conjur access token through the authn-jwt endpoint, and authenticates requests with it as identity.RequestAuthenticator. Nothing wires this in yet — that's the next PR, once both this and the legacy identity client exist side by side. The identity returned for audit tagging is the token's own sub claim when it can be extracted, falling back to the configured service ID otherwise. The cache expiry is driven by the token's own exp claim when present, falling back to a guessed TTL only when it isn't — a fixed TTL stamped after the exchange returns would otherwise serve a token past its real expiry under latency or clock skew. Exposes Invalidate() so a caller that gets a 401 from the resource server can force a fresh exchange instead of waiting out the cache.
1 parent 0bd93b5 commit b59e43d

3 files changed

Lines changed: 443 additions & 0 deletions

File tree

internal/cyberark/conjur/conjur.go

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
package conjur
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"strings"
12+
"sync"
13+
"time"
14+
15+
"k8s.io/klog/v2"
16+
17+
"github.com/jetstack/preflight/internal/cyberark/jwtsource"
18+
)
19+
20+
// defaultTokenTTL is the fallback cache lifetime used when a token's own
21+
// `exp` claim can't be read — Conjur access tokens default to an 8-minute
22+
// lifetime. It's a Client field, not a const, so tests can shrink it.
23+
const defaultTokenTTL = 8 * time.Minute
24+
25+
// Client exchanges a JWT for a Conjur access token and authenticates requests with it.
26+
type Client struct {
27+
httpClient *http.Client
28+
baseURL string
29+
serviceID string
30+
account string
31+
src jwtsource.Source
32+
tokenTTL time.Duration
33+
34+
mu sync.Mutex
35+
token string
36+
identity string
37+
expiry time.Time
38+
}
39+
40+
func New(httpClient *http.Client, baseURL, serviceID, account string, src jwtsource.Source) *Client {
41+
return &Client{httpClient: httpClient, baseURL: baseURL, serviceID: serviceID, account: account, src: src, tokenTTL: defaultTokenTTL}
42+
}
43+
44+
// Invalidate clears the cached token, forcing the next AuthenticateRequest
45+
// call to exchange a fresh one. Callers should call this after a 401 from
46+
// the resource server the token was used against — the cache's own expiry
47+
// tracking only catches a token aging out, not one rejected early (e.g. a
48+
// Conjur restart or a toggled authenticator).
49+
func (c *Client) Invalidate() {
50+
c.mu.Lock()
51+
defer c.mu.Unlock()
52+
c.token, c.identity, c.expiry = "", "", time.Time{}
53+
}
54+
55+
func (c *Client) exchange(ctx context.Context) (string, error) {
56+
jwt, err := c.src.Read(ctx)
57+
if err != nil {
58+
return "", err
59+
}
60+
endpoint, err := url.JoinPath(c.baseURL, "authn-jwt", c.serviceID, c.account, "authenticate")
61+
if err != nil {
62+
return "", err
63+
}
64+
form := url.Values{"jwt": {jwt}}
65+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(form.Encode()))
66+
if err != nil {
67+
return "", err
68+
}
69+
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
70+
// Request the base64-encoded access token — Conjur's canonical wire form
71+
// for the token, and the encoding this client's own decoding below
72+
// expects.
73+
req.Header.Set("Accept-Encoding", "base64")
74+
resp, err := c.httpClient.Do(req)
75+
if err != nil {
76+
return "", fmt.Errorf("authn-jwt exchange transport error: %w", err)
77+
}
78+
defer resp.Body.Close()
79+
if resp.StatusCode != http.StatusOK {
80+
// Conjur returns a JSON error body with the actual reason; include a
81+
// bounded prefix so the operator doesn't have to go read Conjur's own
82+
// audit log to find out why. 401 here most often means the SA token
83+
// audience != authenticator audience=conjur.
84+
errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024))
85+
// Drain the rest so the connection can be reused, bounded so a
86+
// misbehaving server can't make this read unboundedly.
87+
_, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024*1024))
88+
return "", fmt.Errorf("authn-jwt exchange rejected (%d): %s; verify service_id, the authenticator is enabled, and the SA token audience is 'conjur'",
89+
resp.StatusCode, strings.TrimSpace(string(errBody)))
90+
}
91+
body, err := io.ReadAll(io.LimitReader(resp.Body, 64*1024))
92+
if err != nil {
93+
return "", err
94+
}
95+
return strings.TrimSpace(string(body)), nil
96+
}
97+
98+
// padBase64 adds the '=' padding base64.StdEncoding/URLEncoding require,
99+
// for inputs that arrived without it.
100+
func padBase64(s string) string {
101+
return s + strings.Repeat("=", (4-len(s)%4)%4)
102+
}
103+
104+
// flattenedJWSJSON is the wire shape of a Conjur access token: a Flattened
105+
// JWS JSON Serialization object, optionally base64-encoded on top (Conjur's
106+
// `Accept-Encoding: base64`, which this client requests).
107+
type flattenedJWSJSON struct {
108+
Protected string `json:"protected"`
109+
Payload string `json:"payload"`
110+
Signature string `json:"signature"`
111+
}
112+
113+
// conjurTokenObject parses a Conjur access token into its Flattened-JWS-JSON
114+
// object, tolerating the token being raw JSON, standard base64, or
115+
// url-safe base64 (Conjur may return any of these depending on encoding).
116+
func conjurTokenObject(token string) (*flattenedJWSJSON, bool) {
117+
candidates := []string{token}
118+
padded := padBase64(token)
119+
if decoded, err := base64.StdEncoding.DecodeString(padded); err == nil {
120+
candidates = append(candidates, string(decoded))
121+
}
122+
if decoded, err := base64.URLEncoding.DecodeString(padded); err == nil {
123+
candidates = append(candidates, string(decoded))
124+
}
125+
for _, candidate := range candidates {
126+
var obj flattenedJWSJSON
127+
if err := json.Unmarshal([]byte(candidate), &obj); err != nil {
128+
continue
129+
}
130+
if obj.Protected != "" && obj.Payload != "" && obj.Signature != "" {
131+
return &obj, true
132+
}
133+
}
134+
return nil, false
135+
}
136+
137+
// tokenClaims is the subset of a Conjur access token's payload this client
138+
// reads: `sub` (the caller's identity, used for audit tagging) and `exp`
139+
// (unix seconds, used to drive the token cache off its real expiry instead
140+
// of a guessed TTL).
141+
type tokenClaims struct {
142+
Sub string `json:"sub"`
143+
Exp int64 `json:"exp"`
144+
}
145+
146+
// claimsFromToken extracts the payload claims from a Conjur access token.
147+
// The payload segment is url-safe base64 without padding. Returns
148+
// (zero value, false) if the token doesn't parse.
149+
func claimsFromToken(token string) (tokenClaims, bool) {
150+
obj, ok := conjurTokenObject(token)
151+
if !ok {
152+
return tokenClaims{}, false
153+
}
154+
payloadJSON, err := base64.URLEncoding.DecodeString(padBase64(obj.Payload))
155+
if err != nil {
156+
return tokenClaims{}, false
157+
}
158+
var claims tokenClaims
159+
if err := json.Unmarshal(payloadJSON, &claims); err != nil {
160+
return tokenClaims{}, false
161+
}
162+
return claims, true
163+
}
164+
165+
// AuthenticateRequest implements identity.RequestAuthenticator.
166+
//
167+
// It exchanges the JWT for a Conjur access token, sets the Authorization
168+
// header, and returns an identity string for audit tagging. The identity is
169+
// the token's own `sub` claim when it can be extracted; otherwise it falls
170+
// back to the configured service ID so a token in an unexpected shape never
171+
// fails the request.
172+
//
173+
// The mutex is held across the exchange's network round-trip so concurrent
174+
// callers share one exchange instead of a thundering herd; they're
175+
// effectively serial at the current call sites. Whichever caller wins the
176+
// race also controls the exchange's deadline via its own req.Context(), so
177+
// an unrelated cancellation can fail a waiting caller — acceptable for now
178+
// given the current call pattern.
179+
func (c *Client) AuthenticateRequest(req *http.Request) (string, error) {
180+
c.mu.Lock()
181+
defer c.mu.Unlock()
182+
if c.token == "" || !time.Now().Before(c.expiry) {
183+
tok, err := c.exchange(req.Context())
184+
if err != nil {
185+
return "", err
186+
}
187+
claims, ok := claimsFromToken(tok)
188+
identity, expiry := c.serviceID, time.Now().Add(c.tokenTTL)
189+
if !ok {
190+
klog.FromContext(req.Context()).V(2).Info("could not parse Conjur access token; falling back to service ID as identity and a guessed expiry")
191+
} else {
192+
if claims.Sub != "" {
193+
identity = claims.Sub
194+
}
195+
if claims.Exp > 0 {
196+
expiry = time.Unix(claims.Exp, 0)
197+
}
198+
}
199+
c.token, c.identity, c.expiry = tok, identity, expiry
200+
}
201+
req.Header.Set("Authorization", "Bearer "+c.token)
202+
return c.identity, nil
203+
}

0 commit comments

Comments
 (0)