-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #15 from asteurer/actions
Actions
- Loading branch information
Showing
7 changed files
with
345 additions
and
142 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,3 @@ | ||
.PHONY: test | ||
test: | ||
go test -v ./azure |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
package azure | ||
|
||
import ( | ||
"bytes" | ||
"crypto/hmac" | ||
"crypto/sha256" | ||
"encoding/base64" | ||
"fmt" | ||
"net/http" | ||
"net/url" | ||
"sort" | ||
"strings" | ||
) | ||
|
||
type AZCredentials struct { | ||
AccountName string | ||
AccountKey []byte | ||
Service string | ||
} | ||
|
||
func ParseAZCredentials(accountName, accountKey, service string) (*AZCredentials, error) { | ||
decodedKey, err := base64.StdEncoding.DecodeString(accountKey) | ||
if err != nil { | ||
return nil, fmt.Errorf("decode account key: %v", err) | ||
} | ||
return &AZCredentials{AccountName: accountName, AccountKey: decodedKey, Service: service}, nil | ||
} | ||
|
||
func ComputeHMACSHA256(c *AZCredentials, message string) (string, error) { | ||
h := hmac.New(sha256.New, c.AccountKey) | ||
_, err := h.Write([]byte(message)) | ||
return base64.StdEncoding.EncodeToString(h.Sum(nil)), err | ||
} | ||
|
||
func BuildStringToSign(c *AZCredentials, req *http.Request) (string, error) { | ||
// Returns a blank value if the header value doesn't exist | ||
getHeader := func(key string, headers http.Header) string { | ||
if headers == nil { | ||
return "" | ||
} | ||
if v, ok := headers[key]; ok { | ||
if len(v) > 0 { | ||
return v[0] | ||
} | ||
} | ||
return "" | ||
} | ||
|
||
headers := req.Header | ||
|
||
// Per the documentation, the Content-Length field must be an empty string if the content length of the request is zero. | ||
contentLength := getHeader("Content-Length", headers) | ||
if contentLength == "0" { | ||
contentLength = "" | ||
} | ||
|
||
canonicalizedResource, err := buildCanonicalizedResource(c, req.URL) | ||
if err != nil { | ||
return "", err | ||
} | ||
|
||
stringToSign := strings.Join([]string{ | ||
req.Method, | ||
getHeader("Content-Encoding", headers), | ||
getHeader("Content-Language", headers), | ||
contentLength, | ||
getHeader("Content-MD5", headers), | ||
getHeader("Content-Type", headers), | ||
"", // Empty date because x-ms-date is expected | ||
getHeader("If-Modified-Since", headers), | ||
getHeader("If-Match", headers), | ||
getHeader("If-None-Match", headers), | ||
getHeader("If-Unmodified-Since", headers), | ||
getHeader("Range", headers), | ||
buildCanonicalizedHeader(headers), | ||
canonicalizedResource, | ||
}, "\n") | ||
|
||
return stringToSign, nil | ||
} | ||
|
||
// buildCanonicalizedHeader retrieves all headers which start with 'x-ms-' and creates a lexicographically sorted string: | ||
// x-ms-header-a:foo,\nx-ms-header-b:bar,\nx-ms-header-0:baz\n | ||
func buildCanonicalizedHeader(headers http.Header) string { | ||
cm := map[string][]string{} | ||
for k, v := range headers { | ||
headerName := strings.TrimSpace(strings.ToLower(k)) | ||
if strings.HasPrefix(headerName, "x-ms-") { | ||
cm[headerName] = v | ||
} | ||
} | ||
if len(cm) == 0 { | ||
return "" | ||
} | ||
|
||
keys := make([]string, 0, len(cm)) | ||
for key := range cm { | ||
keys = append(keys, key) | ||
} | ||
sort.Strings(keys) // Canonicalized headers must be in lexicographical order | ||
|
||
ch := bytes.NewBufferString("") | ||
for i, key := range keys { | ||
if i > 0 { | ||
ch.WriteRune('\n') | ||
} | ||
ch.WriteString(key) | ||
ch.WriteRune(':') | ||
ch.WriteString(strings.Join(cm[key], ",")) | ||
} | ||
return ch.String() | ||
} | ||
|
||
func buildCanonicalizedResource(c *AZCredentials, u *url.URL) (string, error) { | ||
cr := bytes.NewBufferString("/") | ||
cr.WriteString(c.AccountName) | ||
|
||
if len(u.Path) > 0 { | ||
cr.WriteString(u.EscapedPath()) | ||
} else { | ||
cr.WriteString("/") | ||
} | ||
|
||
params, err := url.ParseQuery(u.RawQuery) | ||
if err != nil { | ||
return "", fmt.Errorf("failed to parse query params: %v", err) | ||
} | ||
|
||
if len(params) > 0 { | ||
var paramNames []string | ||
for paramName := range params { | ||
paramNames = append(paramNames, paramName) | ||
} | ||
sort.Strings(paramNames) | ||
|
||
for _, paramName := range paramNames { | ||
paramValues := params[paramName] | ||
sort.Strings(paramValues) | ||
cr.WriteString("\n" + strings.ToLower(paramName) + ":" + strings.Join(paramValues, ",")) | ||
} | ||
} | ||
|
||
return cr.String(), nil | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,160 @@ | ||
package azure | ||
|
||
import ( | ||
"io" | ||
"net/http" | ||
"testing" | ||
) | ||
|
||
func TestParseAZCredentials(t *testing.T) { | ||
goodCreds := AZCredentials{ | ||
AccountName: "testaccount", | ||
AccountKey: []byte("testkey"), | ||
Service: "testservice", | ||
} | ||
|
||
tests := []struct { | ||
name string | ||
accountName string | ||
accountKey string | ||
service string | ||
expectedCreds *AZCredentials | ||
expectedError bool | ||
}{{ | ||
name: "properly base64 encoded account key", | ||
accountName: "testaccount", | ||
accountKey: "dGVzdGtleQ==", | ||
service: "testservice", | ||
expectedCreds: &goodCreds, | ||
expectedError: false, | ||
}, { | ||
name: "non-base64 encoded account key", | ||
accountName: "testaccount", | ||
accountKey: "T3=$stK3y@", // Contains characters not in (A-Z, a-z, 0-9, +, /), and the length of the string is not a multiple of four, and there's a misplaced padding (=) character. | ||
service: "testservice", | ||
expectedCreds: nil, | ||
expectedError: true, | ||
}} | ||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
creds, err := ParseAZCredentials(tt.accountName, tt.accountKey, tt.service) | ||
|
||
if (tt.expectedError && err == nil) || (!tt.expectedError && err != nil) { | ||
t.Errorf("got: %v, want: %v", err, tt.expectedError) | ||
} else if !tt.expectedError { | ||
if string(creds.AccountKey) != string(tt.expectedCreds.AccountKey) { | ||
t.Errorf("got: %v, want: %v", string(creds.AccountKey), string(tt.expectedCreds.AccountKey)) | ||
} | ||
|
||
if creds.AccountName != tt.expectedCreds.AccountName { | ||
t.Errorf("got: %v, want: %v", creds.AccountName, tt.expectedCreds.AccountName) | ||
} | ||
|
||
if creds.Service != tt.expectedCreds.Service { | ||
t.Errorf("got: %v, want: %v", creds.Service, tt.expectedCreds.Service) | ||
} | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestComputeHMACSHA256(t *testing.T) { | ||
testCreds := AZCredentials{ | ||
AccountKey: []byte("testkey"), | ||
} | ||
|
||
tests := []struct { | ||
name string | ||
creds *AZCredentials | ||
message string | ||
expectedString string | ||
expectedError bool | ||
}{{ | ||
name: "initial test", | ||
creds: &testCreds, | ||
message: "Hello, world!", | ||
expectedString: "JcFgXrIZPVbHKy1GWjQzi/KN+3hFaA/g2+Dn9JV7UAM=", | ||
expectedError: false, | ||
}} | ||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
str, err := ComputeHMACSHA256(tt.creds, tt.message) | ||
|
||
if str != tt.expectedString { | ||
t.Errorf("got: %v, want: %v", str, tt.expectedString) | ||
} | ||
|
||
if (tt.expectedError && err == nil) || (!tt.expectedError && err != nil) { | ||
t.Errorf("got: %v, want: %v", err, tt.expectedError) | ||
} | ||
}) | ||
} | ||
} | ||
|
||
func TestBuildStringToSign(t *testing.T) { | ||
buildReq := func(method, endpoint string, payload io.Reader, headers map[string]string) (*http.Request, error) { | ||
req, err := http.NewRequest(method, endpoint, payload) | ||
if err != nil { | ||
return nil, err | ||
} | ||
|
||
for key, value := range headers { | ||
req.Header.Set(key, value) | ||
} | ||
|
||
return req, nil | ||
} | ||
|
||
testCreds := AZCredentials{ | ||
AccountName: "testaccount", | ||
AccountKey: []byte("testkey"), | ||
Service: "testservice", | ||
} | ||
|
||
goodReq, err := buildReq("GET", "https://localhost:3000/testpath?foo=bar&bar=baz", nil, map[string]string{ | ||
"x-ms-header-a": "foo", | ||
"x-ms-header-b": "bar", | ||
"x-ms-header-0": "baz", | ||
"Content-Type": "test/type", | ||
}) | ||
if err != nil { | ||
t.Errorf("unexpected error: %v", err) | ||
} | ||
|
||
emptyReq, err := buildReq("GET", "https://localhost:3000", nil, map[string]string{}) | ||
if err != nil { | ||
t.Errorf("unexpected error: %v", err) | ||
} | ||
|
||
tests := []struct { | ||
name string | ||
creds *AZCredentials | ||
req *http.Request | ||
expectedString string | ||
expectedError bool | ||
}{{ | ||
name: "normal request", | ||
creds: &testCreds, | ||
req: goodReq, | ||
expectedString: "GET\n\n\n\n\ntest/type\n\n\n\n\n\n\nx-ms-header-0:baz\nx-ms-header-a:foo\nx-ms-header-b:bar\n/testaccount/testpath\nbar:baz\nfoo:bar", | ||
expectedError: false, | ||
}, { | ||
name: "empty headers", | ||
creds: &testCreds, | ||
req: emptyReq, | ||
expectedString: "GET\n\n\n\n\n\n\n\n\n\n\n\n\n/testaccount/", | ||
expectedError: false, | ||
}} | ||
for _, tt := range tests { | ||
t.Run(tt.name, func(t *testing.T) { | ||
str, err := BuildStringToSign(tt.creds, tt.req) | ||
if str != tt.expectedString { | ||
t.Errorf("got: %v, want: %v", str, tt.expectedString) | ||
} | ||
|
||
if (tt.expectedError && err == nil) || (!tt.expectedError && err != nil) { | ||
t.Errorf("got: %v, want: %v", err, tt.expectedError) | ||
} | ||
}) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,4 +1,4 @@ | ||
module example | ||
module github.com/fermyon/experimental-azure-client | ||
|
||
go 1.22 | ||
|
||
|
Oops, something went wrong.