From 06603cfef453d6545a62b4213ea7e8a73cef8e8e Mon Sep 17 00:00:00 2001 From: Scott Minor Date: Mon, 19 Aug 2024 15:04:20 -0400 Subject: [PATCH] DeviceCode: Send `engflow_auth` version in requests 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 --- internal/auth/BUILD | 3 +- internal/auth/authenticator.go | 32 ++++++++++++++----- internal/auth/authenticator_test.go | 32 ++++++++++++++++++- internal/httputil/BUILD | 8 +++++ internal/httputil/header_transport.go | 44 +++++++++++++++++++++++++++ 5 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 internal/httputil/BUILD create mode 100644 internal/httputil/header_transport.go diff --git a/internal/auth/BUILD b/internal/auth/BUILD index 0ee4c63..fea1d47 100644 --- a/internal/auth/BUILD +++ b/internal/auth/BUILD @@ -8,6 +8,8 @@ go_library( deps = [ "//internal/autherr", "//internal/browser", + "//internal/buildstamp", + "//internal/httputil", "@org_golang_x_oauth2//:oauth2", ], ) @@ -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", diff --git a/internal/auth/authenticator.go b/internal/auth/authenticator.go index c402f92..e36f706 100644 --- a/internal/auth/authenticator.go +++ b/internal/auth/authenticator.go @@ -17,6 +17,7 @@ package auth import ( "context" "errors" + "fmt" "net/http" "net/url" "strings" @@ -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") @@ -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, diff --git a/internal/auth/authenticator_test.go b/internal/auth/authenticator_test.go index 4cc996f..2de43c5 100644 --- a/internal/auth/authenticator_test.go +++ b/internal/auth/authenticator_test.go @@ -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" @@ -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 }) } @@ -77,6 +81,7 @@ func TestDeviceCode(t *testing.T) { codeFetchErr error tokenFetchResponse *http.Response tokenFetchErr error + stampInfo *buildstamp.Vars wantToken *oauth2.Token wantErr string @@ -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"} @@ -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) diff --git a/internal/httputil/BUILD b/internal/httputil/BUILD new file mode 100644 index 0000000..a4725f1 --- /dev/null +++ b/internal/httputil/BUILD @@ -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__"], +) diff --git a/internal/httputil/header_transport.go b/internal/httputil/header_transport.go new file mode 100644 index 0000000..f6e9250 --- /dev/null +++ b/internal/httputil/header_transport.go @@ -0,0 +1,44 @@ +// 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) { + // Clone the request so the original is not modified + req = req.Clone(req.Context()) + + 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) +}