Skip to content

Commit

Permalink
Merge pull request #1827 from buildkite/triarius/oidc-token-command
Browse files Browse the repository at this point in the history
Add `buildkite-agent oidc request-token` command
  • Loading branch information
triarius authored Nov 15, 2022
2 parents 35ce452 + 15bc08d commit 7022d6f
Show file tree
Hide file tree
Showing 5 changed files with 432 additions and 3 deletions.
7 changes: 4 additions & 3 deletions api/client_test.go
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package api
package api_test

import (
"fmt"
Expand All @@ -7,6 +7,7 @@ import (
"strings"
"testing"

"github.com/buildkite/agent/v3/api"
"github.com/buildkite/agent/v3/logger"
)

Expand Down Expand Up @@ -36,13 +37,13 @@ func TestRegisteringAndConnectingClient(t *testing.T) {
defer server.Close()

// Initial client with a registration token
c := NewClient(logger.Discard, Config{
c := api.NewClient(logger.Discard, api.Config{
Endpoint: server.URL,
Token: "llamas",
})

// Check a register works
regResp, _, err := c.Register(&AgentRegisterRequest{})
regResp, _, err := c.Register(&api.AgentRegisterRequest{})
if err != nil {
t.Fatalf("c.Register(&AgentRegisterRequest{}) error = %v", err)
}
Expand Down
36 changes: 36 additions & 0 deletions api/oidc.go
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)
if err != nil {
return nil, resp, err
}

return t, resp, nil
}
237 changes: 237 additions & 0 deletions api/oidc_test.go
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,
)
}
}()
}
}
Loading

0 comments on commit 7022d6f

Please sign in to comment.