Skip to content
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

Add buildkite-agent oidc request-token command #1827

Merged
merged 38 commits into from
Nov 15, 2022

Conversation

triarius
Copy link
Contributor

@triarius triarius commented Nov 10, 2022

I decided to have just 1 method abstracting the API call for when there is and is not an audience. The way I chose to implement the method gives room to extend it if we decided Buildkite's API should allow multiple audiences.

Per the spec, aud can be EITHER an array or a string: https://openid.net/specs/openid-connect-core-1_0.html#IDToken. So it is certainly possible for us to make that choice in the future, even if we do not want to do that now. Indeed, there may be services that would require multiple audience claims in the single JWT that we may want to allow integration with. Moreover, I have seen aud be an array in the wild: https://community.auth0.com/t/audience-value-in-returned-bearer-token-jwt-is-a-list/6418.

However, as a note for if we do enable such support, we should ensure that the API issues a JWT with a string valued aud if there is only a single audience as some JWT libraries don't easily support validating JWTs with an array valued aud: dgrijalva/jwt-go#445 and so many implementations may break when using them.

@@ -1,4 +1,4 @@
package api
package api_test
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wanted to use a private method in this file in a black box test, so I made this file a black box test too.

The ticket seemed to indicate that if there is no audience specified, then
the request was to have an empty body. However, that leads to a 500 from
the API. Reading the associated plugin show that an empty object is
expected in that case.
@triarius triarius requested a review from a team November 10, 2022 21:21
api/oidc.go Outdated Show resolved Hide resolved
api/oidc.go Outdated
Comment on lines 20 to 23
switch len(audience) {
case 0:
case 1:
m.Audience = audience[0]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I initially read this as C-style switch fall-through, where cases 0 and 1 are handled the same, and that left me wondering why it didn't panic on audience[0] for an empty slice.

Maybe it's worth making it clearer that case 0: is a no-op?

(Alternatively, maybe the complexity of handling multiple audiences is YAGNI since we may never support that?)

Copy link
Contributor Author

@triarius triarius Nov 11, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I didn't consider it to be YAGNI as it's in the spec, and we don't control whether 3rd parties would read the spec and think it would be a good idea to require multiple audiences on a OIDC token they consume. Then our implementation would not work with such a service.

But this it not really the right place to have a debate about it, bk/bk needs to support it too. So I'll implement it with at most one audience in the agent for now.

clicommand/oidc_token.go Outdated Show resolved Hide resolved
main.go Outdated Show resolved Hide resolved
@triarius triarius requested a review from pda November 11, 2022 07:16
Copy link
Contributor

@DrJosh9000 DrJosh9000 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's looking pretty good! Still, have some comments.

api/oidc.go Outdated Show resolved Hide resolved
}

t := &OidcToken{}
resp, err := c.doRequest(httpReq, t)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are the token and response ever useful if err != nil? If so that would make using this method more complicated.
Otherwise to avoid potential confusion ("I called OIDCToken and got both an error and some other stuff?") I would insert an if err != nil { return nil, nil, err } in here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

doRequest returns the response in cases where err != nil. The rationale seems to be that it allows inspection of the status code and retrying if necessary. See

agent/api/client.go

Lines 260 to 261 in 1a9d87e

// even though there was an error, we still return the response
// in case the caller wants to inspect it further

The practice in this codebase seems to be to do the retrying in the clicommand method and not the api one, and inspecting the status code from the bubbled up response. See

agent/api/meta_data.go

Lines 60 to 65 in 1a9d87e

resp, err := c.doRequest(req, e)
if err != nil {
return nil, resp, err
}
return e, resp, err
and
).Do(func(r *roko.Retrier) error {
metaData, resp, err = client.GetMetaData(cfg.Job, cfg.Key)
// Don't bother retrying if the response was one of these statuses
if resp != nil && (resp.StatusCode == 401 || resp.StatusCode == 404 || resp.StatusCode == 400) {
r.Break()
return err
}
if err != nil {
l.Warn("%s (%s)", err, r)
}
return err
})

So the respone SHOULD be passed up or the retry won't work as expected. The token is not needed not however, so I have changed it to pass a nil pointer on error now.

I don't really think this is a good practice though (see below). However, I do think it's better to consistently follow a bad practice than to mix different practices. So I think we should follow this for now, and have a piece of work to unravel http.doRequest. I haven't thought too deeply about what that should be, but here are some initial thoughts:

I think there is no good reason for api.doRequest to return an http.Response. Instead, I think the relavant parts of the response that are needed for retry handling or otherwise should be returned as part of a structured error. Indeed, there already is a error type that purports to do this: api.ErrorReponse, although it contains a pointer to http.Response 🤷‍♂️. Either way returning a pointer to http.Response is potentially misleading as the body of the http.Response is closed in api.doRequest, so it will look like there is no body if we try to use it outside the api.doRequest method.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's fair. net/http itself does this sort of thing.

api/oidc_test.go Outdated
accessToken, oidcToken, path string,
expectedBody []byte,
) *httptest.Server {
t.Helper()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Breaking function declarations across multiple lines feels weird to me. It's the sort of thing that leads to indentation-level confusion.
And many other Go folks think there's no problem with long lines anyway:
https://github.com/golang/go/wiki/CodeReviewComments#line-length

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this line would be “uncomfortably long” if the function declaration were not wrapped. It's a lot easier to read a function declaration that has been intentionally wrapped for each argument than one that has been arbitrarily wrapped by the code editor. So I prefer to do it this way.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay - I won't hold you up with a debate around wrapping. It's not unreadable. I'll go away and think about it.

api/oidc_test.go Outdated
switch req.URL.Path {
case path:
if got, want := authToken(req), accessToken; got != want {
http.Error(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly here, and below - is it so uncomfortably long to put it on one line?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that it is already quite indented, I do think that this line would be uncomfortably long if it were not wrapped.

api/oidc_test.go Outdated Show resolved Hide resolved
api/oidc_test.go Outdated Show resolved Hide resolved
api/oidc_test.go Outdated Show resolved Hide resolved
ExperimentsFlag,
ProfileFlag,
},
Action: func(c *cli.Context) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Something that all the other commands do, that I would like to undo in most of them, is to change these Action closures into named top-level functions. A few of them "feel" too big and complicated to be an inline func, so it would pay to reduce the indentation level even by one tab.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. However, I prefer to consistently follow a bad practice until we decide to explicitly adopt a better one.

clicommand/odic_request_token.go Outdated Show resolved Hide resolved
// Create the API client
client := api.NewClient(l, loadAPIClientConfig(cfg, "AgentAccessToken"))

// Find the meta data value
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be a bit shorter to print the token within the retry func as soon as the request succeeded. (You could declare token, resp, err := client.OIDCToken(req) on 121.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's nicer to think of token as the value that the retrier produces. I removed resp as there is no reason for it to escape the retrier.

@pda pda changed the title Add oidc token command to agent Add buildkite-agent oidc request-token command Nov 14, 2022
Copy link
Contributor

@DrJosh9000 DrJosh9000 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🥳

This is good. I think anything else I could comment would be nitpicking.

@triarius triarius merged commit 7022d6f into main Nov 15, 2022
@triarius triarius deleted the triarius/oidc-token-command branch November 15, 2022 02:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants