-
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
Changes from all commits
7c9e4dd
2946a0f
1c1b516
3766723
53b4098
892baee
f947ad1
7f7cf5d
b11ea30
d9f9fb1
3a7e5dd
52995c2
e6d7ff4
fcbff6f
301bd19
8753f8a
2c5170b
778010d
f2ebe2b
918ca9b
5a10ef1
b265e4a
49065c0
705e40f
863e572
3ec96cc
1a9d87e
9395a9e
5ddef48
fb2dca5
82dc5fe
e22417c
a3418bb
0a03238
0e4e15a
bc55667
5b860fb
15bc08d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||
---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
@@ -0,0 +1,36 @@ | ||||||||||||||||||||||||||||||||||||||||||||
package api | ||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
import ( | ||||||||||||||||||||||||||||||||||||||||||||
"fmt" | ||||||||||||||||||||||||||||||||||||||||||||
) | ||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
type OIDCToken struct { | ||||||||||||||||||||||||||||||||||||||||||||
Token string `json:"token"` | ||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
type OIDCTokenRequest struct { | ||||||||||||||||||||||||||||||||||||||||||||
Job string | ||||||||||||||||||||||||||||||||||||||||||||
Audience string | ||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
func (c *Client) OIDCToken(methodReq *OIDCTokenRequest) (*OIDCToken, *Response, error) { | ||||||||||||||||||||||||||||||||||||||||||||
m := &struct { | ||||||||||||||||||||||||||||||||||||||||||||
Audience string `json:"audience,omitempty"` | ||||||||||||||||||||||||||||||||||||||||||||
}{ | ||||||||||||||||||||||||||||||||||||||||||||
Audience: methodReq.Audience, | ||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
u := fmt.Sprintf("jobs/%s/oidc/tokens", methodReq.Job) | ||||||||||||||||||||||||||||||||||||||||||||
httpReq, err := c.newRequest("POST", u, m) | ||||||||||||||||||||||||||||||||||||||||||||
if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
return nil, nil, err | ||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
t := &OIDCToken{} | ||||||||||||||||||||||||||||||||||||||||||||
resp, err := c.doRequest(httpReq, t) | ||||||||||||||||||||||||||||||||||||||||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Lines 260 to 261 in 1a9d87e
The practice in this codebase seems to be to do the retrying in the Lines 60 to 65 in 1a9d87e
agent/clicommand/meta_data_get.go Lines 107 to 119 in 1a9d87e
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 I think there is no good reason for There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That's fair. |
||||||||||||||||||||||||||||||||||||||||||||
if err != nil { | ||||||||||||||||||||||||||||||||||||||||||||
return nil, resp, err | ||||||||||||||||||||||||||||||||||||||||||||
} | ||||||||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||||||||
return t, resp, nil | ||||||||||||||||||||||||||||||||||||||||||||
} |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,237 @@ | ||
package api_test | ||
|
||
import ( | ||
"bytes" | ||
"fmt" | ||
"io" | ||
"net/http" | ||
"net/http/httptest" | ||
"testing" | ||
|
||
"github.com/buildkite/agent/v3/api" | ||
"github.com/buildkite/agent/v3/logger" | ||
"github.com/google/go-cmp/cmp" | ||
) | ||
|
||
type testOIDCTokenServer struct { | ||
accessToken string | ||
oidcToken string | ||
jobID string | ||
forbiddenJobID string | ||
expectedBody []byte | ||
} | ||
|
||
func (s *testOIDCTokenServer) New(t *testing.T) *httptest.Server { | ||
t.Helper() | ||
path := fmt.Sprintf("/jobs/%s/oidc/tokens", s.jobID) | ||
forbiddenPath := fmt.Sprintf("/jobs/%s/oidc/tokens", s.forbiddenJobID) | ||
return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { | ||
if got, want := authToken(req), s.accessToken; got != want { | ||
http.Error( | ||
rw, | ||
fmt.Sprintf("authToken(req) = %q, want %q", got, want), | ||
http.StatusUnauthorized, | ||
) | ||
return | ||
} | ||
|
||
switch req.URL.Path { | ||
case path: | ||
body, err := io.ReadAll(req.Body) | ||
if err != nil { | ||
http.Error( | ||
rw, | ||
fmt.Sprintf(`{"message:"Internal Server Error: %s"}`, err), | ||
http.StatusInternalServerError, | ||
) | ||
return | ||
} | ||
|
||
if !bytes.Equal(body, s.expectedBody) { | ||
t.Errorf("wanted = %q, got = %q", s.expectedBody, body) | ||
http.Error( | ||
rw, | ||
fmt.Sprintf(`{"message:"Bad Request: wanted = %q, got = %q"}`, s.expectedBody, body), | ||
http.StatusBadRequest, | ||
) | ||
return | ||
} | ||
|
||
io.WriteString(rw, fmt.Sprintf(`{"token":"%s"}`, s.oidcToken)) | ||
|
||
case forbiddenPath: | ||
http.Error( | ||
rw, | ||
fmt.Sprintf(`{"message":"Forbidden; method = %q, path = %q"}`, req.Method, req.URL.Path), | ||
http.StatusForbidden, | ||
) | ||
|
||
default: | ||
http.Error( | ||
rw, | ||
fmt.Sprintf( | ||
`{"message":"Not Found; method = %q, path = %q"}`, | ||
req.Method, | ||
req.URL.Path, | ||
), | ||
http.StatusNotFound, | ||
) | ||
} | ||
})) | ||
} | ||
|
||
func TestOIDCToken(t *testing.T) { | ||
const jobID = "b078e2d2-86e9-4c12-bf3b-612a8058d0a4" | ||
const unauthorizedJobID = "a078e2d2-86e9-4c12-bf3b-612a8058d0a4" | ||
const oidcToken = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiYWRtaW4iOnRydWUsImlhdCI6MTUxNjIzOTAyMn0.NHVaYe26MbtOYhSKkoKYdFVomg4i8ZJd8_-RU8VNbftc4TSMb4bXP3l3YlNWACwyXPGffz5aXHc6lty1Y2t4SWRqGteragsVdZufDn5BlnJl9pdR_kdVFUsra2rWKEofkZeIC4yWytE58sMIihvo9H1ScmmVwBcQP6XETqYd0aSHp1gOa9RdUPDvoXQ5oqygTqVtxaDr6wUFKrKItgBMzWIdNZ6y7O9E0DhEPTbE9rfBo6KTFsHAZnMg4k68CDp2woYIaXbmYTWcvbzIuHO7_37GT79XdIwkm95QJ7hYC9RiwrV7mesbY4PAahERJawntho0my942XheVLmGwLMBkQ" | ||
const accessToken = "llamas" | ||
const audience = "sts.amazonaws.com" | ||
|
||
tests := []struct { | ||
OIDCTokenRequest *api.OIDCTokenRequest | ||
AccessToken string | ||
ExpectedBody []byte | ||
OIDCToken *api.OIDCToken | ||
}{ | ||
{ | ||
AccessToken: accessToken, | ||
OIDCTokenRequest: &api.OIDCTokenRequest{ | ||
Job: jobID, | ||
}, | ||
ExpectedBody: []byte("{}\n"), | ||
OIDCToken: &api.OIDCToken{Token: oidcToken}, | ||
}, | ||
{ | ||
AccessToken: accessToken, | ||
OIDCTokenRequest: &api.OIDCTokenRequest{ | ||
Job: jobID, | ||
Audience: audience, | ||
}, | ||
ExpectedBody: []byte(fmt.Sprintf(`{"audience":%q}`+"\n", audience)), | ||
OIDCToken: &api.OIDCToken{Token: oidcToken}, | ||
}, | ||
} | ||
|
||
for _, test := range tests { | ||
func() { // this exists to allow closing the server on each iteration | ||
server := (&testOIDCTokenServer{ | ||
accessToken: test.AccessToken, | ||
oidcToken: test.OIDCToken.Token, | ||
jobID: jobID, | ||
forbiddenJobID: unauthorizedJobID, | ||
expectedBody: test.ExpectedBody, | ||
}).New(t) | ||
defer server.Close() | ||
|
||
// Initial client with a registration token | ||
client := api.NewClient(logger.Discard, api.Config{ | ||
UserAgent: "Test", | ||
Endpoint: server.URL, | ||
Token: accessToken, | ||
DebugHTTP: true, | ||
}) | ||
|
||
token, resp, err := client.OIDCToken(test.OIDCTokenRequest) | ||
if err != nil { | ||
t.Errorf( | ||
"OIDCToken(%v) got error = %v", | ||
test.OIDCTokenRequest, | ||
err, | ||
) | ||
return | ||
} | ||
|
||
if !cmp.Equal(token, test.OIDCToken) { | ||
t.Errorf( | ||
"OIDCToken(%v) got token = %v, want %v", | ||
test.OIDCTokenRequest, | ||
token, | ||
test.OIDCToken, | ||
) | ||
} | ||
|
||
if resp.StatusCode != http.StatusOK { | ||
t.Errorf( | ||
"OIDCToken(%v) got StatusCode = %v, want %v", | ||
test.OIDCTokenRequest, | ||
resp.StatusCode, | ||
http.StatusOK, | ||
) | ||
} | ||
}() | ||
} | ||
} | ||
|
||
func TestOIDCTokenError(t *testing.T) { | ||
const jobID = "b078e2d2-86e9-4c12-bf3b-612a8058d0a4" | ||
const unauthorizedJobID = "a078e2d2-86e9-4c12-bf3b-612a8058d0a4" | ||
const accessToken = "llamas" | ||
const audience = "sts.amazonaws.com" | ||
|
||
tests := []struct { | ||
OIDCTokenRequest *api.OIDCTokenRequest | ||
AccessToken string | ||
ExpectedStatus int | ||
// TODO: make api.ErrorReponse a serializable type and populate this field | ||
// ExpectedErr error | ||
}{ | ||
{ | ||
AccessToken: "camels", | ||
OIDCTokenRequest: &api.OIDCTokenRequest{ | ||
Job: jobID, | ||
Audience: audience, | ||
}, | ||
ExpectedStatus: http.StatusUnauthorized, | ||
}, | ||
{ | ||
AccessToken: accessToken, | ||
OIDCTokenRequest: &api.OIDCTokenRequest{ | ||
Job: unauthorizedJobID, | ||
Audience: audience, | ||
}, | ||
ExpectedStatus: http.StatusForbidden, | ||
}, | ||
{ | ||
AccessToken: accessToken, | ||
OIDCTokenRequest: &api.OIDCTokenRequest{ | ||
Job: "2", | ||
Audience: audience, | ||
}, | ||
ExpectedStatus: http.StatusNotFound, | ||
}, | ||
} | ||
|
||
for _, test := range tests { | ||
func() { // this exists to allow closing the server on each iteration | ||
server := (&testOIDCTokenServer{ | ||
accessToken: test.AccessToken, | ||
jobID: jobID, | ||
forbiddenJobID: unauthorizedJobID, | ||
}).New(t) | ||
defer server.Close() | ||
|
||
// Initial client with a registration token | ||
client := api.NewClient(logger.Discard, api.Config{ | ||
UserAgent: "Test", | ||
Endpoint: server.URL, | ||
Token: accessToken, | ||
DebugHTTP: true, | ||
}) | ||
|
||
_, resp, err := client.OIDCToken(test.OIDCTokenRequest) | ||
// TODO: make api.ErrorReponse a serializable type and test that the right error type is returned here | ||
if err == nil { | ||
t.Errorf("OIDCToken(%v) did not return an error as expected", test.OIDCTokenRequest) | ||
} | ||
|
||
if resp.StatusCode != test.ExpectedStatus { | ||
t.Errorf( | ||
"OIDCToken(%v) got StatusCode = %v, want %v", | ||
test.OIDCTokenRequest, | ||
resp.StatusCode, | ||
test.ExpectedStatus, | ||
) | ||
} | ||
}() | ||
} | ||
} |
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.