diff options
| author | DJ O'Leary <dijitol@proton.me> | 2026-09-03 02:35:45 +0200 |
|---|---|---|
| committer | DJ O'Leary <dijitol@proton.me> | 2026-09-03 02:35:45 +0200 |
| commit | 1fe1e04c3b413109bd3d7fddea975513c455b098 (patch) | |
| tree | ba2198542be5305b9c2eccac5c24503fbf306aaa /src | |
| parent | f5440cff6298e4a29c5af42ef38f690b10c853bd (diff) | |
feat(oauth): create oauth1 client
Diffstat (limited to 'src')
| -rw-r--r-- | src/oauth/auth_header.go | 181 | ||||
| -rw-r--r-- | src/oauth/auth_header_test.go | 186 | ||||
| -rw-r--r-- | src/oauth/client.go | 112 |
3 files changed, 479 insertions, 0 deletions
diff --git a/src/oauth/auth_header.go b/src/oauth/auth_header.go new file mode 100644 index 0000000..bf30812 --- /dev/null +++ b/src/oauth/auth_header.go @@ -0,0 +1,181 @@ +package oauth + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha1" + "encoding/base64" + "fmt" + "sort" + "strconv" + "strings" + "time" +) + +const ( + oauthRealm = "realm" + oauthConsumerKey = "oauth_consumer_key" + oauthToken = "oauth_token" + oauthNonce = "oauth_nonce" + oauthTimestamp = "oauth_timestamp" + oauthSignatureMethod = "oauth_signature_method" + oauthVersion = "oauth_version" + oauthSignature = "oauth_signature" +) + +const ( + SignatureMethodHMACSHA1 = "HMAC-SHA1" +) + +const Version = "1.0" + +const separator = "&" + +type authHeader struct { + method string + baseURL string + queryParams map[string]string + tokens Tokens + nonce string + timestamp string + signatureMethod string + version string +} + +func newAuthHeader( + method string, + baseURL string, + queryParams map[string]string, + tokens Tokens, +) authHeader { + return authHeader{ + method: method, + baseURL: baseURL, + queryParams: queryParams, + tokens: tokens, + nonce: rand.Text(), + timestamp: strconv.Itoa(int(time.Now().Unix())), + signatureMethod: SignatureMethodHMACSHA1, + version: Version, + } +} + +func (ah *authHeader) String() string { + signature, _ := ah.buildSignature() // TODO: handle ignored err + + subheaders := make(map[string]string, 8) + subheaders[oauthRealm] = ah.baseURL + subheaders[oauthVersion] = ah.version + subheaders[oauthTimestamp] = ah.timestamp + subheaders[oauthNonce] = ah.nonce + subheaders[oauthConsumerKey] = ah.tokens.Consumer.Token + subheaders[oauthToken] = ah.tokens.Access.Token + subheaders[oauthSignatureMethod] = ah.signatureMethod + subheaders[oauthSignature] = percentEncode(signature) + + parts := make([]string, 0, len(subheaders)) + for k, v := range subheaders { + parts = append(parts, fmt.Sprintf(`%s="%s"`, k, v)) + } + + header := "OAuth " + strings.Join(parts, ", ") + + return string(header) +} + +func (ah *authHeader) buildSignature() (string, error) { + baseString := ah.buildBaseString() + baseString += ah.buildParameterString() + + signingKey := fmt.Appendf( + nil, + "%s&%s", + percentEncode(ah.tokens.Consumer.Secret), + percentEncode(ah.tokens.Access.Secret), + ) + + hasher := hmac.New(sha1.New, signingKey) + if _, err := hasher.Write([]byte(baseString)); err != nil { + return "", err + } + + encoded := base64.StdEncoding.EncodeToString(hasher.Sum(nil)) + + return encoded, nil +} + +func (ah *authHeader) buildBaseString() string { + baseString := strings.Join( + []string{ + strings.ToUpper(ah.method), + percentEncode(ah.baseURL), + }, + separator, + ) + baseString += separator + return baseString +} + +func (ah *authHeader) buildParameterString() string { + params := ah.queryParams + if params == nil { + params = make(map[string]string, 6) + } + + params[oauthConsumerKey] = string(ah.tokens.Consumer.Token) + params[oauthNonce] = string(ah.nonce) + params[oauthSignatureMethod] = string(ah.signatureMethod) + params[oauthTimestamp] = string(ah.timestamp) + params[oauthToken] = string(ah.tokens.Access.Token) + params[oauthVersion] = string(ah.version) + + keys := make([]string, 0, len(params)) + for k := range params { + keys = append(keys, k) + } + sort.Strings(keys) + + parts := make([]string, 0, len(keys)) + for _, k := range keys { + parts = append( + parts, + fmt.Sprintf( + `%s=%s`, + k, + params[k], + ), + ) + } + + parameterString := strings.Join(parts, separator) + parameterString = percentEncode(parameterString) + + return parameterString +} + +func percentEncode(s string) string { + var b strings.Builder + for _, c := range s { + if isUnreservedCharacter(c) { + fmt.Fprintf(&b, "%%%02X", c) + continue + } + b.WriteRune(c) + } + return b.String() +} + +// isUnreservedCharacter returns true if the byte belongs in the range of the +// reserved character list as in https://en.wikipedia.org/wiki/Percent-encoding +func isUnreservedCharacter(c rune) bool { + if (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || + c == '.' || + c == '_' || + c == '~' { + return false + } + return true +} diff --git a/src/oauth/auth_header_test.go b/src/oauth/auth_header_test.go new file mode 100644 index 0000000..332b3de --- /dev/null +++ b/src/oauth/auth_header_test.go @@ -0,0 +1,186 @@ +package oauth + +import ( + "net/http" + "strings" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestString_CardmarketDemoValues_Success(t *testing.T) { + t.Parallel() + + // Arrange + method := http.MethodGet + baseURL := "https://apiv2.cardmarket.com/ws/v2.0/account" + consumerToken := "bfaD9xOU0SXBhtBP" + consumerSecret := "pChvrpp6AEOEwxBIIUBOvWcRG3X9xL4Y" + accessToken := "lBY1xptUJ7ZJSK01x4fNwzw8kAe5b10Q" + accessSecret := "hc1wJAOX02pGGJK2uAv1ZOiwS7I9Tpoe" + nonce := "53eb1f44909d6" + timestamp := "1407917892" + + ah := authHeader{ + method: method, + baseURL: baseURL, + queryParams: nil, + tokens: Tokens{ + Consumer: ConsumerToken{ + Token: consumerToken, + Secret: consumerSecret, + }, + Access: AccessToken{ + Token: accessToken, + Secret: accessSecret, + }, + }, + nonce: nonce, + timestamp: timestamp, + signatureMethod: "HMAC-SHA1", + version: "1.0", + } + + // Act + actual := ah.String() + + // Assert + assert.True(t, strings.HasPrefix(actual, "OAuth ")) + + actualParts := strings.Split(strings.TrimPrefix(actual, "OAuth "), ", ") + actualKVs := map[string]string{} + for _, ap := range actualParts { + kv := strings.Split(ap, "=") + actualKVs[kv[0]] = kv[1] + } + + expected := map[string]string{ + "realm": `"https://apiv2.cardmarket.com/ws/v2.0/account"`, + "oauth_consumer_key": `"bfaD9xOU0SXBhtBP"`, + "oauth_token": `"lBY1xptUJ7ZJSK01x4fNwzw8kAe5b10Q"`, + "oauth_nonce": `"53eb1f44909d6"`, + "oauth_timestamp": `"1407917892"`, + "oauth_signature_method": `"HMAC-SHA1"`, + "oauth_version": `"1.0"`, + "oauth_signature": `"mRdSwq4vJcmCByd990zE1NTPWJ0%3D"`, + } + for expectedKey, expectedValue := range expected { + assert.Equal(t, expectedValue, actualKVs[expectedKey]) + } +} + +func TestBuildSignature(t *testing.T) { + t.Skip() +} + +func TestBuildBaseString(t *testing.T) { + t.Parallel() + + // Arrange + method := http.MethodGet + baseURL := "https://apiv2.cardmarket.com/ws/v2.0/account" + consumerToken := "bfaD9xOU0SXBhtBP" + consumerSecret := "pChvrpp6AEOEwxBIIUBOvWcRG3X9xL4Y" + accessToken := "lBY1xptUJ7ZJSK01x4fNwzw8kAe5b10Q" + accessSecret := "hc1wJAOX02pGGJK2uAv1ZOiwS7I9Tpoe" + nonce := "53eb1f44909d6" + timestamp := "1407917892" + + ah := authHeader{ + method: method, + baseURL: baseURL, + queryParams: nil, + tokens: Tokens{ + Consumer: ConsumerToken{ + Token: consumerToken, + Secret: consumerSecret, + }, + Access: AccessToken{ + Token: accessToken, + Secret: accessSecret, + }, + }, + nonce: nonce, + timestamp: timestamp, + signatureMethod: "HMAC-SHA1", + version: "1.0", + } + + // Act + actual := ah.buildBaseString() + + // Assert + expected := `GET&https%3A%2F%2Fapiv2.cardmarket.com%2Fws%2Fv2.0%2Faccount&` + assert.Equal(t, expected, actual) +} + +func TestBuildParameterString(t *testing.T) { + t.Parallel() + + // Arrange + method := http.MethodGet + baseURL := "https://apiv2.cardmarket.com/ws/v2.0/account" + consumerToken := "bfaD9xOU0SXBhtBP" + consumerSecret := "pChvrpp6AEOEwxBIIUBOvWcRG3X9xL4Y" + accessToken := "lBY1xptUJ7ZJSK01x4fNwzw8kAe5b10Q" + accessSecret := "hc1wJAOX02pGGJK2uAv1ZOiwS7I9Tpoe" + nonce := "53eb1f44909d6" + timestamp := "1407917892" + + ah := authHeader{ + method: method, + baseURL: baseURL, + queryParams: nil, + tokens: Tokens{ + Consumer: ConsumerToken{ + Token: consumerToken, + Secret: consumerSecret, + }, + Access: AccessToken{ + Token: accessToken, + Secret: accessSecret, + }, + }, + nonce: nonce, + timestamp: timestamp, + signatureMethod: "HMAC-SHA1", + version: "1.0", + } + + // Act + actual := ah.buildParameterString() + + // Assert + expected := `oauth_consumer_key%3DbfaD9xOU0SXBhtBP%26oauth_nonce%3D53eb1f44909d6%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1407917892%26oauth_token%3DlBY1xptUJ7ZJSK01x4fNwzw8kAe5b10Q%26oauth_version%3D1.0` + assert.Equal(t, expected, actual) +} + +func TestPercentEncode(t *testing.T) { + t.Parallel() + + tcs := []struct { + name string + str string + expected string + }{ + { + name: "space", + str: " ", + expected: "%20", + }, + { + name: "forward-slash", + str: "/", + expected: "%2F", + }, + } + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + // Act + actual := percentEncode(tc.str) + + // Assert + assert.Equal(t, tc.expected, actual) + }) + } +} diff --git a/src/oauth/client.go b/src/oauth/client.go new file mode 100644 index 0000000..684cb20 --- /dev/null +++ b/src/oauth/client.go @@ -0,0 +1,112 @@ +// The oauth package provides a Client that authenticates requests +// automatically with OAuth1. +// +// [Cardmarket OAuth] and [RFC 5849] were used as reference. +// +// [Cardmarket OAuth]: https://apiv2.cardmarket.com/ws/documentation/API:Auth_OAuthHeader +// [RFC 5849]: https://datatracker.ietf.org/doc/html/rfc5849#section-3.1 +package oauth + +import ( + "context" + "fmt" + "io" + "log/slog" + "net/http" + "strings" +) + +const ( + headerAuthorization = "Authorization" + headerContentType = "Content-Type" +) + +type ConsumerToken struct { + Token string + Secret string +} + +type AccessToken struct { + Token string + Secret string +} + +type Tokens struct { + Consumer ConsumerToken + Access AccessToken +} + +type Client struct { + tokens Tokens +} + +func NewClient(t Tokens) *Client { + return &Client{tokens: t} +} + +func (c *Client) GetWithContext( + ctx context.Context, + url string, +) (*http.Response, error) { + return c.request(ctx, http.MethodGet, url, "", nil) +} + +func (c *Client) PostWithContext( + ctx context.Context, + url, contentType string, + body io.Reader, +) (*http.Response, error) { + return c.request(ctx, http.MethodPost, url, contentType, body) +} + +// request hydrates and fulfils a http.Request using information stored in the client +func (c *Client) request( + ctx context.Context, + method string, + url, contentType string, + body io.Reader, +) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, method, url, body) + if err != nil { + return nil, err + } + + if contentType != "" { + req.Header.Set("Content-Type", contentType) + } + + if err = c.authorize(req); err != nil { + return nil, err + } + + slog.DebugContext( + ctx, + "sending request", + "method", req.Method, + "url", req.URL.String(), + "contentType", req.Header.Get(headerContentType), + "authorization", strings.Join(req.Header.Values(headerAuthorization), ", "), + ) + + return http.DefaultClient.Do(req) +} + +func (c *Client) authorize(req *http.Request) error { + baseURL := fmt.Sprintf( + "%s://%s%s", + strings.ToLower(req.URL.Scheme), + strings.ToLower(req.URL.Host), + req.URL.Path, + ) + + ah := newAuthHeader( + req.Method, + baseURL, + nil, + c.tokens, + ) + + req.Header.Set(headerAuthorization, ah.String()) + + return nil +} |
