Skip to content

Commit

Permalink
DeviceCode: Send engflow_auth version in requests
Browse files Browse the repository at this point in the history
This change modifies the HTTP client used by the `DeviceCode`
Authenticator implementation to send version information in each
request.

The version info is sent in the `User-Agent` header, using the format:

    engflow_auth vX.Y.Z

The version string is generated by the `buildstamp` library and is a
semver string in a release binary, or a "pseudoversion" in a dev binary,
similar go Go module pseudoversions.

Tested: Added unit tests
Bug: linear/CUS-387
  • Loading branch information
minor-fixes committed Aug 19, 2024
1 parent 10d8b09 commit ce2c427
Show file tree
Hide file tree
Showing 5 changed files with 107 additions and 9 deletions.
3 changes: 2 additions & 1 deletion internal/auth/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ go_library(
deps = [
"//internal/autherr",
"//internal/browser",
"//internal/buildstamp",
"//internal/httputil",
"@org_golang_x_oauth2//:oauth2",
],
)
Expand All @@ -17,7 +19,6 @@ go_test(
srcs = ["authenticator_test.go"],
embed = [":auth"],
deps = [
"//internal/browser",
"@com_github_stretchr_testify//assert",
"@com_github_stretchr_testify//mock",
"@org_golang_x_oauth2//:oauth2",
Expand Down
32 changes: 25 additions & 7 deletions internal/auth/authenticator.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ package auth
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
Expand All @@ -25,6 +26,8 @@ import (

"github.com/EngFlow/auth/internal/autherr"
"github.com/EngFlow/auth/internal/browser"
"github.com/EngFlow/auth/internal/buildstamp"
"github.com/EngFlow/auth/internal/httputil"
)

var errUnexpectedHTML = errors.New("request to JSON API returned HTML unexpectedly")
Expand Down Expand Up @@ -63,15 +66,30 @@ func NewDeviceCode(browserOpener browser.Opener, clientID string, scopes []strin

func (d *DeviceCode) Authenticate(ctx context.Context, host *url.URL) (*oauth2.Token, error) {
// Under tests, the HTTP transport might be set in order to stub out network
// calls. If this is the case, ensure the oauth2 library is using it; the
// library API around this is that it discovers a client via the context, or
// uses some default if none is set (currently http.DefaultTransport).
// calls. If not, use the default HTTP transport (which is currently the
// oauth2 library's default as well).
transport := http.DefaultTransport
if d.httpTransport != nil {
client := &http.Client{
Transport: d.httpTransport,
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, client)
transport = d.httpTransport
}

version, err := buildstamp.Values.GetVersion()
if err != nil {
return nil, fmt.Errorf("failed to get engflow_auth version: %w", err)
}

// The oauth2 library discovers a client via the context, or uses some
// default if none is set. Create a client that (at least) inserts version
// headers.
client := &http.Client{
Transport: &httputil.HeaderInsertingTransport{
Transport: transport,
Headers: map[string][]string{
"User-Agent": {"engflow_auth " + version},
},
},
}
ctx = context.WithValue(ctx, oauth2.HTTPClient, client)

config := &oauth2.Config{
ClientID: d.clientID,
Expand Down
32 changes: 31 additions & 1 deletion internal/auth/authenticator_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import (
"testing"
"time"

"github.com/EngFlow/auth/internal/buildstamp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"golang.org/x/oauth2"
Expand All @@ -49,7 +50,10 @@ func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {

func requestTargetMatches(url string) any {
return mock.MatchedBy(func(req *http.Request) bool {
return req.URL.String() == url
urlMatches := req.URL.String() == url
headerMatches := strings.HasPrefix(req.Header.Get("User-Agent"), "engflow_auth ")

return urlMatches && headerMatches
})
}

Expand Down Expand Up @@ -77,6 +81,7 @@ func TestDeviceCode(t *testing.T) {
codeFetchErr error
tokenFetchResponse *http.Response
tokenFetchErr error
stampInfo *buildstamp.Vars

wantToken *oauth2.Token
wantErr string
Expand Down Expand Up @@ -122,6 +127,13 @@ func TestDeviceCode(t *testing.T) {

wantErr: "oauth2: cannot fetch token",
},
{
desc: "no stamp values",
codeFetchResponse: httpResponse(200, `{"device_code":"75ba408f-fdf0-469a-a56e-b9a3a698f8b3","verification_uri":"https://oauth2.example.com/login?deviceCode\u003d75ba408f-fdf0-469a-a56e-b9a3a698f8b3","verification_uri_complete":"https://oauth2.example.com/login?deviceCode\u003d75ba408f-fdf0-469a-a56e-b9a3a698f8b3\u0026userCode\u003dKLJQ-OQGG","user_code":"KLJQ-OQGG","expires_in":300,"interval":1}`),
stampInfo: &buildstamp.Vars{},

wantErr: "failed to get engflow_auth version",
},
}
for _, tc := range testCases {
testHost := &url.URL{Scheme: "https", Host: "oauth2.example.com"}
Expand All @@ -131,6 +143,24 @@ func TestDeviceCode(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()

// Fake buildstamp values
oldStampVals := buildstamp.Values
defer func() {
buildstamp.Values = oldStampVals
}()
if tc.stampInfo != nil {
buildstamp.Values = *tc.stampInfo
} else {
buildstamp.Values = buildstamp.Vars{
ReleaseVersion: "v0.0.1",
SourceBranch: "main",
SourceRevision: "abcd",
IsClean: true,
IsOfficial: true,
BuildTimestamp: time.Date(2024, 1, 2, 3, 4, 5, 0, time.UTC),
}
}

opener := &mockOpener{}
opener.On("Open", mock.Anything).Return(tc.browserOpenErr)

Expand Down
8 changes: 8 additions & 0 deletions internal/httputil/BUILD
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
load("@rules_go//go:def.bzl", "go_library")

go_library(
name = "httputil",
srcs = ["header_transport.go"],
importpath = "github.com/EngFlow/auth/internal/httputil",
visibility = ["//:__subpackages__"],
)
41 changes: 41 additions & 0 deletions internal/httputil/header_transport.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// Copyright 2024 EngFlow Inc. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

// Package httputil provides additional types/helpers for HTTP communication.
package httputil

import "net/http"

// HeaderInsertingTransport wraps an http.RoundTripper and inserts headers into
// the request before propagating it to the underlying implementation.
type HeaderInsertingTransport struct {
// Transport is the underlying transport actually performing requests.
Transport http.RoundTripper
// Headers specify the headers to add on each request. These headers will
// override existing headers on the request with the same name; headers on
// the request not mentioned here are not modified.
Headers http.Header
}

func (t *HeaderInsertingTransport) RoundTrip(req *http.Request) (*http.Response, error) {
for k := range t.Headers {
req.Header.Del(k)
}
for k, vals := range t.Headers {
for _, val := range vals {
req.Header.Add(k, val)
}
}
return t.Transport.RoundTrip(req)
}

0 comments on commit ce2c427

Please sign in to comment.