Version Matrix

CircleCI codecov Scala Steward badge License: Unlicense

fsclient

Maven Central

// circe codecs
libraryDependencies += "io.bartholomews" %% "fsclient-circe" % "0.1.2"
// play-json codecs
libraryDependencies += "io.bartholomews" %% "fsclient-play" % "0.1.2"
// no codecs
libraryDependencies += "io.bartholomews" %% "fsclient-core" % "0.1.2"

http client wrapping sttp and providing OAuth signatures and other utils

  import io.bartholomews.fsclient.core._
  import io.bartholomews.fsclient.core.oauth.Signer
  import sttp.client3._

  val token: Signer = ???

  /*
    Sign the sttp request with `Signer`, which might be one of:
    - an OAuth v1 signature
    - an OAuth v2 basic / bearer
    - a custom `Authorization` header
   */
  emptyRequest
    .get(uri"https://some-server/authenticated-endpoint")
    .sign(token)

OAuth 1.0

Token Credentials

  import io.bartholomews.fsclient.core.config.UserAgent
  import io.bartholomews.fsclient.core.oauth.v1.OAuthV1.{Consumer, SignatureMethod}
  import io.bartholomews.fsclient.core.oauth.v1.TemporaryCredentials
  import io.bartholomews.fsclient.core.oauth.{
    RedirectUri,
    RequestTokenCredentials,
    ResourceOwnerAuthorizationUri,
    TemporaryCredentialsRequest
  }
  import sttp.client3.{
    emptyRequest,
    HttpURLConnectionBackend,
    Identity,
    Response,
    ResponseException,
    SttpBackend,
    UriContext
  }
  import sttp.model.Method

  // Choose your effect / sttp backend
  type F[X] = Identity[X]

  val backend: SttpBackend[F, Any] = HttpURLConnectionBackend()

  val userAgent = UserAgent(
    appName = "SAMPLE_APP_NAME",
    appVersion = Some("SAMPLE_APP_VERSION"),
    appUrl = Some("https://bartholomews.io/sample-app-url")
  )

  // you probably want to load this from config
  val myConsumer: Consumer = Consumer(
    key = "CONSUMER_KEY",
    secret = "CONSUMER_SECRET"
  )

  val myRedirectUri = RedirectUri(uri"https://my-app/callback")

  // 1. Prepare a temporary credentials request
  val temporaryCredentialsRequest = TemporaryCredentialsRequest(
    myConsumer,
    myRedirectUri,
    SignatureMethod.SHA1
  )

  // 2. Retrieve temporary credentials
  val maybeTemporaryCredentials: F[Response[Either[ResponseException[String, Exception], TemporaryCredentials]]] =
    temporaryCredentialsRequest.send(
      Method.POST,
      // https://tools.ietf.org/html/rfc5849#section-2.1
      serverUri = uri"https://some-authorization-server/oauth/request-token",
      userAgent,
      // https://tools.ietf.org/html/rfc5849#section-2.2
      ResourceOwnerAuthorizationUri(uri"https://some-server/oauth/authorize")
    )(backend)

  // a successful `resourceOwnerAuthorizationUriResponse` will have the token in the query parameters:
  val resourceOwnerAuthorizationUriResponse =
    myRedirectUri.value.withParams(("oauth_token", "AAA"), ("oauth_verifier", "ZZZ"))

  // 3. Extract the Token Credentials
  val maybeRequestTokenCredentials: Either[ResponseException[String, Exception], RequestTokenCredentials] =
    maybeTemporaryCredentials.body.flatMap { temporaryCredentials =>
      RequestTokenCredentials.fetchRequestTokenCredentials(
        resourceOwnerAuthorizationUriResponse,
        temporaryCredentials,
        SignatureMethod.PLAINTEXT
      )
    }

  maybeRequestTokenCredentials.map { token =>
    // import `FsClientSttpExtensions` in http package to use `sign`
    import io.bartholomews.fsclient.core._

    // 4. Use the Token Credentials
    emptyRequest
      .get(uri"https://some-server/authenticated-endpoint")
      .sign(token)
  }

OAuth 2.0

Client credentials

  import io.bartholomews.fsclient.core.oauth.NonRefreshableTokenSigner
  import io.bartholomews.fsclient.core.oauth.v2.OAuthV2.ClientCredentialsGrant
  import io.bartholomews.fsclient.core.oauth.v2.{ClientId, ClientPassword, ClientSecret}
  import io.circe
  import sttp.client3.{HttpURLConnectionBackend, Identity, Response, ResponseException, SttpBackend, UriContext}

  type F[X] = Identity[X]

  val backend: SttpBackend[F, Any] = HttpURLConnectionBackend()

  // using fsclient-circe codecs, you could also use play-json or provide your own
  import io.bartholomews.fsclient.circe.codecs._

  // you probably want to load this from config
  val myClientPassword = ClientPassword(
    clientId = ClientId("APP_CLIENT_ID"),
    clientSecret = ClientSecret("APP_CLIENT_SECRET")
  )

  val accessTokenRequest: F[Response[Either[ResponseException[String, circe.Error], NonRefreshableTokenSigner]]] =
    backend.send(
      ClientCredentialsGrant
        .accessTokenRequest(
          serverUri = uri"https://some-authorization-server/token",
          myClientPassword
        )
    )

Implicit grant

  import io.bartholomews.fsclient.core.FsClient
  import io.bartholomews.fsclient.core.config.UserAgent
  import io.bartholomews.fsclient.core.oauth.v2.OAuthV2.ImplicitGrant
  import io.bartholomews.fsclient.core.oauth.v2.{AuthorizationTokenRequest, ClientId, ClientPassword, ClientSecret}
  import io.bartholomews.fsclient.core.oauth.{ClientPasswordAuthentication, NonRefreshableTokenSigner, RedirectUri}
  import sttp.client3.{emptyRequest, HttpURLConnectionBackend, Identity, SttpBackend, UriContext}
  import sttp.model.Uri

  val backend: SttpBackend[Identity, Any] = HttpURLConnectionBackend()

  val userAgent: UserAgent = UserAgent(
    appName = "SAMPLE_APP_NAME",
    appVersion = Some("SAMPLE_APP_VERSION"),
    appUrl = Some("https://bartholomews.io/sample-app-url")
  )

  // you probably want to load this from config
  val myClientPassword: ClientPassword = ClientPassword(
    clientId = ClientId("APP_CLIENT_ID"),
    clientSecret = ClientSecret("APP_CLIENT_SECRET")
  )

  val myRedirectUri = RedirectUri(uri"https://my-app/callback")

  val client = FsClient.v2.clientPassword(userAgent, ClientPasswordAuthentication(myClientPassword))(backend)

  // 1. Prepare an authorization token request
  val authorizationTokenRequest = AuthorizationTokenRequest(
    clientId = client.signer.clientPassword.clientId,
    redirectUri = myRedirectUri,
    state = Some("some-state"), // see https://tools.ietf.org/html/rfc6749#section-10.12
    scopes = List.empty // see https://tools.ietf.org/html/rfc6749#section-3.3
  )

  /*
     2. Send the user to `authorizationRequestUri`,
     where they will accept/deny permissions for our client app to access their data;
     they will be then redirected to `AuthorizationTokenRequest.redirectUri`
   */
  val authorizationRequestUri: Uri = ImplicitGrant.authorizationRequestUri(
    request = authorizationTokenRequest,
    serverUri = uri"https://some-authorization-server/authorize"
  )

  // a successful `redirectionUriResponse` will have the token in the query parameters:
  val redirectionUriResponseApproved =
    uri"https://my-app/callback?access_token=some-token-verifier&token_type=bearer&state=some-state"

  // 4. Get an access token
  val maybeToken: Either[String, NonRefreshableTokenSigner] = ImplicitGrant
    .accessTokenResponse(
      request = authorizationTokenRequest,
      redirectionUriResponse = redirectionUriResponseApproved
    )

  maybeToken.map { token =>
    // import `FsClientSttpExtensions` in http package to use `sign`
    import io.bartholomews.fsclient.core._

    // 5. Use the access token
    emptyRequest
      .get(uri"https://some-server/authenticated-endpoint")
      .sign(token) // sign with the token provided
  }

Authorization code grant

  import io.bartholomews.fsclient.core._
  import io.bartholomews.fsclient.core.config.UserAgent
  import io.bartholomews.fsclient.core.oauth.v2.OAuthV2.{AuthorizationCodeGrant, RefreshToken}
  import io.bartholomews.fsclient.core.oauth.v2._
  import io.bartholomews.fsclient.core.oauth.{AccessTokenSigner, ClientPasswordAuthentication, RedirectUri}
  import sttp.client3.{HttpURLConnectionBackend, Identity, SttpBackend, UriContext}
  import sttp.model.Uri

  val backend: SttpBackend[Identity, Any] = HttpURLConnectionBackend()

  val userAgent = UserAgent(
    appName = "SAMPLE_APP_NAME",
    appVersion = Some("SAMPLE_APP_VERSION"),
    appUrl = Some("https://bartholomews.io/sample-app-url")
  )

  // you probably want to load this from config
  val myClientPassword = ClientPassword(
    clientId = ClientId("APP_CLIENT_ID"),
    clientSecret = ClientSecret("APP_CLIENT_SECRET")
  )

  val myRedirectUri = RedirectUri(uri"https://my-app/callback")

  val client = FsClient.v2.clientPassword(userAgent, ClientPasswordAuthentication(myClientPassword))(backend)

  // 1. Prepare an authorization code request
  val authorizationCodeRequest = AuthorizationCodeRequest(
    clientId = client.signer.clientPassword.clientId,
    redirectUri = myRedirectUri,
    state = Some("some-state"), // see https://tools.ietf.org/html/rfc6749#section-10.12
    scopes = List.empty // see https://tools.ietf.org/html/rfc6749#section-3.3
  )

  /*
     2. Send the user to `authorizationRequestUri`,
     where they will accept/deny permissions for our client app to access their data;
     they will be then redirected to `authorizationCodeRequest.redirectUri`
   */
  val authorizationRequestUri: Uri = AuthorizationCodeGrant.authorizationRequestUri(
    request = authorizationCodeRequest,
    serverUri = uri"https://some-authorization-server/authorize"
  )

  // a successful `redirectionUriResponse` will look like this:
  val redirectionUriResponseApproved = uri"https://my-app/callback?code=some-auth-code-verifier&state=some-state"

  // 3. Validate `redirectionUriResponse`
  val maybeAuthorizationCode: Either[String, AuthorizationCode] = AuthorizationCodeGrant.authorizationResponse(
    request = authorizationCodeRequest,
    redirectionUriResponse = redirectionUriResponseApproved
  )

  // using fsclient-circe codecs
  import io.bartholomews.fsclient.circe.codecs._

  // 4. Get an access token
  val maybeToken: Either[String, AccessTokenSigner] =
    maybeAuthorizationCode.flatMap { authorizationCode =>
      backend
        .send(
          AuthorizationCodeGrant
            .accessTokenRequest(
              serverUri = uri"https://some-authorization-server/token",
              code = authorizationCode,
              maybeRedirectUri = Some(myRedirectUri),
              clientPassword = myClientPassword
            )
        )
        .body
        .left
        .map(_.getMessage)
    }

  maybeToken.map { accessTokenSigner =>
    // 5. Use the access token
    baseRequest(userAgent)
      .get(uri"https://some-server/authenticated-endpoint")
      .sign(accessTokenSigner) // sign with the token signer

    // 6. Get a refresh token
    if (accessTokenSigner.isExpired()) {
      backend.send(
        AuthorizationCodeGrant
          .refreshTokenRequest(
            serverUri = uri"https://some-authorization-server/refresh",
            accessTokenSigner.refreshToken.getOrElse(
              RefreshToken(
                "Refresh token is optional: some implementations (e.g. Spotify) only give you a refresh token " +
                  "with the first `accessTokenSigner` authorization response, so you might need to store and use that."
              )
            ),
            scopes = accessTokenSigner.scope.values,
            clientPassword = myClientPassword
          )
      )
    }
  }

CircleCI deployment

Verify local configuration

https://circleci.com/docs/2.0/local-cli/

circleci config validate

CI/CD Pipeline

This project is using sbt-ci-release plugin:

  • Every push to master will trigger a snapshot release.

  • In order to trigger a regular release you need to push a tag:

    ./scripts/release.sh v1.0.0
  • If for some reason you need to replace an older version (e.g. the release stage failed):

    TAG=v1.0.0
    git push --delete origin ${TAG} && git tag --delete ${TAG} \
    && ./scripts/release.sh ${TAG}