-
Notifications
You must be signed in to change notification settings - Fork 27
feat(agent): add Conjur JWT authentication client #821
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
roeezis
wants to merge
4
commits into
jetstack:master
Choose a base branch
from
roeezis:split/04-conjur-client
base: master
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
ffa53d1
CP-21164: add jwtsource package for reading projected SA tokens
33e6b50
CP-21164: split legacy username/password login into its own file
0bd93b5
CP-21164: resolve secrets_manager alongside identity_administration
b59e43d
CP-21164: add Conjur JWT authentication client
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| 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" | ||
| ) | ||
|
|
||
| // defaultTokenTTL is the fallback cache lifetime used when a token's own | ||
| // `exp` claim can't be read — Conjur access tokens default to an 8-minute | ||
| // lifetime. It's a Client field, not a const, so tests can shrink it. | ||
| const defaultTokenTTL = 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 | ||
| tokenTTL time.Duration | ||
|
|
||
| mu sync.Mutex | ||
| token string | ||
| identity string | ||
| expiry 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, tokenTTL: defaultTokenTTL} | ||
| } | ||
|
|
||
| // Invalidate clears the cached token, forcing the next AuthenticateRequest | ||
| // call to exchange a fresh one. Callers should call this after a 401 from | ||
| // the resource server the token was used against — the cache's own expiry | ||
| // tracking only catches a token aging out, not one rejected early (e.g. a | ||
| // Conjur restart or a toggled authenticator). | ||
| func (c *Client) Invalidate() { | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
| c.token, c.identity, c.expiry = "", "", time.Time{} | ||
| } | ||
|
|
||
| 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 { | ||
| // Conjur returns a JSON error body with the actual reason; include a | ||
| // bounded prefix so the operator doesn't have to go read Conjur's own | ||
| // audit log to find out why. 401 here most often means the SA token | ||
| // audience != authenticator audience=conjur. | ||
| errBody, _ := io.ReadAll(io.LimitReader(resp.Body, 4*1024)) | ||
| // Drain the rest so the connection can be reused, bounded so a | ||
| // misbehaving server can't make this read unboundedly. | ||
| _, _ = io.Copy(io.Discard, io.LimitReader(resp.Body, 1024*1024)) | ||
| return "", fmt.Errorf("authn-jwt exchange rejected (%d): %s; verify service_id, the authenticator is enabled, and the SA token audience is 'conjur'", | ||
| resp.StatusCode, strings.TrimSpace(string(errBody))) | ||
| } | ||
| 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 | ||
| } | ||
|
|
||
| // tokenClaims is the subset of a Conjur access token's payload this client | ||
| // reads: `sub` (the caller's identity, used for audit tagging) and `exp` | ||
| // (unix seconds, used to drive the token cache off its real expiry instead | ||
| // of a guessed TTL). | ||
| type tokenClaims struct { | ||
| Sub string `json:"sub"` | ||
| Exp int64 `json:"exp"` | ||
| } | ||
|
|
||
| // claimsFromToken extracts the payload claims from a Conjur access token. | ||
| // The payload segment is url-safe base64 without padding. Returns | ||
| // (zero value, false) if the token doesn't parse. | ||
| func claimsFromToken(token string) (tokenClaims, bool) { | ||
| obj, ok := conjurTokenObject(token) | ||
| if !ok { | ||
| return tokenClaims{}, false | ||
| } | ||
| payloadJSON, err := base64.URLEncoding.DecodeString(padBase64(obj.Payload)) | ||
| if err != nil { | ||
| return tokenClaims{}, false | ||
| } | ||
| var claims tokenClaims | ||
| if err := json.Unmarshal(payloadJSON, &claims); err != nil { | ||
| return tokenClaims{}, false | ||
| } | ||
| return claims, 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. | ||
| // | ||
| // The mutex is held across the exchange's network round-trip so concurrent | ||
| // callers share one exchange instead of a thundering herd; they're | ||
| // effectively serial at the current call sites. Whichever caller wins the | ||
| // race also controls the exchange's deadline via its own req.Context(), so | ||
| // an unrelated cancellation can fail a waiting caller — acceptable for now | ||
| // given the current call pattern. | ||
| func (c *Client) AuthenticateRequest(req *http.Request) (string, error) { | ||
| c.mu.Lock() | ||
| defer c.mu.Unlock() | ||
| if c.token == "" || !time.Now().Before(c.expiry) { | ||
| tok, err := c.exchange(req.Context()) | ||
| if err != nil { | ||
| return "", err | ||
| } | ||
| claims, ok := claimsFromToken(tok) | ||
| identity, expiry := c.serviceID, time.Now().Add(c.tokenTTL) | ||
| if !ok { | ||
| klog.FromContext(req.Context()).V(2).Info("could not parse Conjur access token; falling back to service ID as identity and a guessed expiry") | ||
| } else { | ||
| if claims.Sub != "" { | ||
| identity = claims.Sub | ||
| } | ||
| if claims.Exp > 0 { | ||
| expiry = time.Unix(claims.Exp, 0) | ||
| } | ||
| } | ||
| c.token, c.identity, c.expiry = tok, identity, expiry | ||
| } | ||
| req.Header.Set("Authorization", "Bearer "+c.token) | ||
| return c.identity, nil | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.