Adavis
Adavis

Reputation: 1

How to authenticate to Adobe Commerce rest APIs with OAuth 1.0a using integration secrets?

I've created an integration on adobe admin. I have taken these secrets to postman and would like to now auth with OAuth 1.0 in a Kotlin application on the JVM. Any standard java libraries don't seem to put the Auth in the Authorization header like Adobe documentation dictates.

How do I do this without writing my own signer and OAuth Authorization builder?

I've created an integration on adobe admin, and I've worked with it postman. JavaScribe and Apache Signpost adds the OAuth parameters to the URL, and both are out of date by time of writing by 2 and 5 years respectively.

Upvotes: 0

Views: 35

Answers (1)

Adavis
Adavis

Reputation: 1

Alright, so this answer took me on quite the adventure. While I will be sharing some honourable mentions about some gotchas while using adobe commerce rest APIs, this answer will primarily just be about the OAuth 1.0a portion.

For anyone that couldn't find the official documentation:

Honourable Mentions (Problems I faced along the way)

  • Integration permissions in the admin portal appear to be a tree of subset permissions. However, the parent nodes also have permissions that are not fully encapsulated by the child permissions.
  • If you get an error message along the lines of "message": "Specified request cannot be processed." check your API URL. The official documentation says you need to use a "Store ID" or use the "default store" in your URL.
    • Example: {domain}/rest/{storeID}/V1/{theEndpoint} or {domain}/rest/default/V1/{theEndpoint}. This is not necessary for all stores (or perhaps my company's store is just weird like that). My URL ended up looking like {domain}/rest/V1/{theEndpoint}.
  • I'll be mentioning this some below, but make sure that you're using HMAC-SHA256 for your Signature Method and that you're adding your authorization as an Authorization header not in URL params or the request body.

Libraries?

Dependencies

<groupId>com.google.oauth-client</groupId>
<artifactId>google-oauth-client</artifactId>
<version>1.38.0</version>

Someone Somewhere (probably): "But this has many transitive vulnerabilities!" Correct! Luckily I was able to remove those by excluding certain child dependencies and updating the version of one dependency. Mine is in Maven format and works for my uses, but your mileage may vary.

<!-- Updated version of the excluded guava -->
<dependency>
    <groupId>com.google.guava</groupId>
    <artifactId>guava</artifactId>
    <version>33.4.0-jre</version>
</dependency>

<dependency>
    <groupId>com.google.oauth-client</groupId>
    <artifactId>google-oauth-client</artifactId>
    <version>1.38.0</version>

    <exclusions>
        <exclusion>
            <groupId>com.google.guava</groupId>
            <artifactId>guava</artifactId>
        </exclusion>
        <!-- Removed seemingly without consequence -->
        <exclusion>
            <groupId>commons-codec</groupId>
            <artifactId>commons-codec</artifactId>
        </exclusion>
    </exclusions>
</dependency>

I'm also using OkHttp3 for this as HTTP client.

<dependency>
  <groupId>com.squareup.okhttp3</groupId>
  <artifactId>okhttp</artifactId>
  <version>4.12.0</version>
</dependency>

For (de)serialization I'm using Jackson.

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.18.2</version>
</dependency>
<dependency>
    <artifactId>jackson-core</artifactId>
    <groupId>com.fasterxml.jackson.core</groupId>
    <version>2.18.2</version>
</dependency>

The actual implementation!

All of this code is in Kotlin (2.1.0) but should be pretty easily transferrable to Java as necessary.

Some handy extension functions.

// LogManager comes from Log4J
val <T : Any> T.logger: Logger get() = LogManager.getLogger()

// Request is from OkHttp3
fun Request.newBuilder(
    builder: Request.Builder.(Request) -> Request.Builder
) = builder(newBuilder(), this).build()

I've truncated the names since all of them live in a singleton called OAuth1, but they would work separated with more qualified names just as easily.

import com.google.api.client.auth.oauth.OAuthHmacSha256Signer
import com.google.api.client.auth.oauth.OAuthParameters
import com.google.api.client.http.GenericUrl
import okhttp3.Request
import okhttp3.Response

object OAuth1 {
    // I pull secrets from a remote secrets manager so these are
    // just an easy way to standardize input usage. This uses the
    // nomenclature from Postman to make more straightforward to
    // go from Postman to this implementation.
    interface Secrets {
        val consumerKey: String
        val consumerSecret: String
        val accessToken: String
        val tokenSecret: String
    }

    // A default implementation data class (not necessary to use).
    data class DefaultSecrets(
        override val consumerKey: String = "consumerKey",
        override val consumerSecret: String = "consumerSecret",
        override val accessToken: String = "accessToken",
        override val tokenSecret: String = "tokenSecret"
    ) : Secrets

    // Implementing this interface on a class should make it very easy to 
    // integrate OAuth1 in the class for usage.
    interface UsesOAuth1 {
        val oAuthSecrets: Secrets

        // This is where the HMAC-SHA256 comes into play. 
        // Change this for different encryption types.
        val signer
            get() = OAuthHmacSha256Signer(oAuthSecrets.consumerSecret)
                .apply { this.setTokenSecret(oAuthSecrets.tokenSecret) }

        val oauthParams: OAuthParameters
            get() = OAuthParameters().apply {
                [email protected] = oAuthSecrets.consumerKey
                [email protected] = oAuthSecrets.accessToken
                [email protected] = [email protected]
            }
    }

    // An OkHttp3 Interceptor that adds the Authorization header to each call
    // created with a client it is added to.
    class Interceptor(
        private val oauthParams: OAuthParameters
    ) : okhttp3.Interceptor {
        private fun Request.computeOAuth() {
            oauthParams.run {
                // Nonce, Timestamp, and signature need to be unique for each 
                // call and is therefore generated per intercept.
                computeTimestamp()
                computeNonce()
                computeSignature(method, GenericUrl(url.toString()))
            }
        }

        override fun intercept(
            chain: okhttp3.Interceptor.Chain
        ): Response = chain.request().newBuilder {
            logger.info("Calling with OAuth1: ${it.method}: ${it.url}")

            it.computeOAuth()
            addHeader("Authorization", oauthParams.getAuthorizationHeader())
        }.let {
            chain.proceed(it)
        }
    }
}

So that's all the OAuth stuff at a high level. However, I'll go ahead and share the prototype implementation I have with he HTTP client for anyone that just wants a copy, paste, and go.

Some more handy extension functions.

// HttpUrl is from OkHttp3
fun HttpUrl.newBuilder(
    builder: HttpUrl.Builder.() -> HttpUrl.Builder
) = builder(newBuilder()).build()

inline fun <reified T> String.tryParseAsJson(): T? =
    runCatching { parseAsJson<T>() }.onFailure {
        val type = T::class.simpleName?.let { " ($it)" } ?: ""
        logger.warn("Failed to parse JSON$type. String: $this")
    }.getOrNull()

inline fun <reified T> String.parseAsJson(): T =
    jacksonObjectMapper.readValue(this, T::class.java)

val jacksonObjectMapper: ObjectMapper by lazy {
    ObjectMapper()
        .registerModule(jacksonKotlinModule)
        .findAndRegisterModules()
}

private val jacksonKotlinModule by lazy {
    KotlinModule.Builder()
        .withReflectionCacheSize(512)
        .configure(KotlinFeature.NullToEmptyCollection, false)
        .configure(KotlinFeature.NullToEmptyMap, false)
        .configure(KotlinFeature.NullIsSameAsDefault, false)
        .configure(KotlinFeature.SingletonSupport, false)
        .configure(KotlinFeature.StrictNullChecks, false)
        .build()
}

Below is the abstract base API client.

import okhttp3.HttpUrl
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response

abstract class BaseApiClient {
    abstract val baseUrl: HttpUrl

    abstract val httpClient: OkHttpClient

    abstract fun handler(response: Response)

    inline fun <reified T> Request.executeWithHandling(): T? {
        val response = httpClient.newCall(this).execute()

        if (!response.isSuccessful) handler(response)

        // For now this will just parse JSON. 
        // However, in the future this could use
        // body.mediaType to dynamically parse the T type.
        return response.body?.string()?.tryParseAsJson<T>()
    }

    fun String.get(): Request = Request.Builder().apply {
        [email protected]()
        url(baseUrl.newBuilder { addPathSegments(this@get) })
    }.build()
}

Below is an implementation of the BaseApiClient into a "MagentoApiClient" specific to adobe commerce. Note, this only has a single endpoint and the baseUrl has been scrubbed for confidentiality reasons.

class MagentoApiClient(
    override val oAuthSecrets: OAuth1.Secrets,
    override val baseUrl: HttpUrl = "https://#{yourStoreDomain}/rest/V1/".toHttpUrl(),
) : BaseApiClient(), OAuth1.UsesOAuth1 {
    override val httpClient: OkHttpClient = OkHttpClient.Builder()
        .addInterceptor(OAuth1.Interceptor(oauthParams))
        .build()

    override fun handler(response: Response) {
        // The only reason this function would be called is if the response code is not 2xx or 3xx.
        response.body?.string()?.let { error ->
            MagentoApiExceptionHandler.handle(error)
        }
    }

    fun customerById(id: Int): MagentoCustomer? =
        "customers/$id".get().executeWithHandling()
}

Usage:

fun main() {
    // Please don't commit your secrets to source code,
    // and please make sure to pull them from a secure source.
    val magentoApi = MagentoApiClient(OAuth1.DefaultSecrets())

    val response = magentoApi.customerById(15)
    println(response)
}

Alas I'm unable to share the data model for MagentoCustomer or the exception handler code. However, this should be able to get you up and running from almost nothing. Feel free to throw any questions in my general direction, and I'll answer as much as I can.

Upvotes: 0

Related Questions