summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorDJ O'Leary <dijitol@proton.me>2026-09-03 02:35:45 +0200
committerDJ O'Leary <dijitol@proton.me>2026-09-03 02:35:45 +0200
commit1fe1e04c3b413109bd3d7fddea975513c455b098 (patch)
treeba2198542be5305b9c2eccac5c24503fbf306aaa
parentf5440cff6298e4a29c5af42ef38f690b10c853bd (diff)
feat(oauth): create oauth1 client
-rw-r--r--src/oauth/auth_header.go181
-rw-r--r--src/oauth/auth_header_test.go186
-rw-r--r--src/oauth/client.go112
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
+}