-
Notifications
You must be signed in to change notification settings - Fork 301
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
Conversation
@@ -1,4 +1,4 @@ | |||
package api | |||
package api_test |
There was a problem hiding this comment.
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.
9a8b6b8
to
3c55028
Compare
3c55028
to
d9f9fb1
Compare
c54ee95
to
23f0451
Compare
23f0451
to
52995c2
Compare
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.
api/oidc.go
Outdated
switch len(audience) { | ||
case 0: | ||
case 1: | ||
m.Audience = audience[0] |
There was a problem hiding this comment.
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?)
There was a problem hiding this comment.
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.
Co-authored-by: Paul Annesley <paul@annesley.cc>
This is more extensible.
…ent into triarius/oidc-token-command
There was a problem hiding this 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.
} | ||
|
||
t := &OidcToken{} | ||
resp, err := c.doRequest(httpReq, t) |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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
Lines 60 to 65 in 1a9d87e
resp, err := c.doRequest(req, e) | |
if err != nil { | |
return nil, resp, err | |
} | |
return e, resp, err |
agent/clicommand/meta_data_get.go
Lines 107 to 119 in 1a9d87e
).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.
There was a problem hiding this comment.
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() |
There was a problem hiding this comment.
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
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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( |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
clicommand/odic_request_token.go
Outdated
ExperimentsFlag, | ||
ProfileFlag, | ||
}, | ||
Action: func(c *cli.Context) error { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
// Create the API client | ||
client := api.NewClient(l, loadAPIClientConfig(cfg, "AgentAccessToken")) | ||
|
||
// Find the meta data value |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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.
buildkite-agent oidc request-token
command
It's better to ensure that all conditions are checked by using t.Error instead of t.Fatal. Similarly, else if would exclude certain checks
1191bd5
to
15bc08d
Compare
There was a problem hiding this 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.
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 seenaud
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 valuedaud
if there is only a single audience as some JWT libraries don't easily support validating JWTs with an array valuedaud
: dgrijalva/jwt-go#445 and so many implementations may break when using them.