Skip to content

Commit eed87e6

Browse files
Merge pull request #821 from roeezis/split/04-conjur-client
feat(agent): add Conjur JWT authentication client
2 parents 173ba28 + 987bd4b commit eed87e6

3 files changed

Lines changed: 477 additions & 0 deletions

File tree

‎internal/cyberark/conjur/conjur.go‎

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

0 commit comments

Comments
 (0)