|
| 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