├── .gitignore ├── .scalafmt.conf ├── .travis.yml ├── LICENSE ├── README.md ├── build.sbt ├── project ├── build.properties └── plugins.sbt ├── src ├── main │ └── scala │ │ └── com │ │ └── github │ │ └── dakatsuka │ │ └── akka │ │ └── http │ │ └── oauth2 │ │ └── client │ │ ├── AccessToken.scala │ │ ├── Client.scala │ │ ├── ClientLike.scala │ │ ├── Config.scala │ │ ├── ConfigLike.scala │ │ ├── Error.scala │ │ ├── GrantType.scala │ │ ├── strategy │ │ ├── AuthorizationCodeStrategy.scala │ │ ├── ClientCredentialsStrategy.scala │ │ ├── ImplicitStrategy.scala │ │ ├── PasswordCredentialsStrategy.scala │ │ ├── RefreshTokenStrategy.scala │ │ ├── Strategy.scala │ │ └── package.scala │ │ └── utils │ │ └── JsonUnmarshaller.scala └── test │ └── scala │ └── com │ └── github │ └── dakatsuka │ └── akka │ └── http │ └── oauth2 │ └── client │ ├── AccessTokenSpec.scala │ └── ClientSpec.scala └── version.sbt /.gitignore: -------------------------------------------------------------------------------- 1 | /project/project 2 | /project/target 3 | /target 4 | /native 5 | /tmp 6 | .history 7 | *.pyc 8 | /dist 9 | /.idea 10 | /*.iml 11 | /out 12 | /.idea_modules 13 | /.classpath 14 | /.project 15 | /RUNNING_PID 16 | /.settings 17 | -------------------------------------------------------------------------------- /.scalafmt.conf: -------------------------------------------------------------------------------- 1 | style = defaultWithAlign 2 | danglingParentheses = true 3 | indentOperator = spray 4 | includeCurlyBraceInSelectChains = true 5 | maxColumn = 140 6 | rewrite.rules = [RedundantParens, SortImports, PreferCurlyFors] 7 | spaces.inImportCurlyBraces = true 8 | binPack.literalArgumentLists = false 9 | unindentTopLevelOperators = true 10 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: scala 2 | 3 | scala: 4 | - 2.11.11 5 | - 2.12.3 6 | 7 | cache: 8 | directories: 9 | - $HOME/.ivy2 10 | - $HOME/.sbt 11 | 12 | script: 13 | - sbt ++$TRAVIS_SCALA_VERSION clean test 14 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2017 Dai Akatsuka 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # akka-http-oauth2-client 2 | [![Build Status](https://travis-ci.org/dakatsuka/akka-http-oauth2-client.svg?branch=master)](https://travis-ci.org/dakatsuka/akka-http-oauth2-client) [![Maven Central](https://img.shields.io/maven-central/v/com.github.dakatsuka/akka-http-oauth2-client_2.12.svg)](https://search.maven.org/#search%7Cga%7C1%7Ca%3A%22akka-http-oauth2-client_2.12%22) 3 | 4 | A Scala wrapper for OAuth 2.0 with Akka HTTP. 5 | 6 | ## Getting akka-http-oauth2-client 7 | 8 | akka-http-oauth2-client is available in sonatype repository and it targets Akka HTTP 10.0.x. There are scala 2.11 and 2.12 compatible jars available. 9 | 10 | ```sbt 11 | libraryDependencies += "com.github.dakatsuka" %% "akka-http-oauth2-client" % "0.1.0" 12 | ``` 13 | 14 | ## Usage 15 | 16 | ```scala 17 | import java.net.URI 18 | 19 | import akka.actor.ActorSystem 20 | import akka.http.scaladsl.model.Uri 21 | import akka.stream.{ ActorMaterializer, Materializer } 22 | import com.github.dakatsuka.akka.http.oauth2.client.{ Client, Config } 23 | import com.github.dakatsuka.akka.http.oauth2.client.Error.UnauthorizedException 24 | import com.github.dakatsuka.akka.http.oauth2.client.strategy._ 25 | 26 | import scala.concurrent.{ ExecutionContext, Future } 27 | 28 | implicit val system: ActorSystem = ActorSystem() 29 | implicit val ec: ExecutionContext = system.dispatcher 30 | implicit val mat: Materializer = ActorMaterializer() 31 | 32 | val config = Config( 33 | clientId = "xxxxxxxxx", 34 | clientSecret = "xxxxxxxxx", 35 | site = URI.create("https://api.example.com") 36 | ) 37 | 38 | val client = Client(config) 39 | 40 | // Some(https://api.example.com/oauth/authorize?redirect_uri=https://example.com/oauth2/callback&response_type=code&client_id=xxxxxxxxx) 41 | val authorizeUrl: Option[Uri] = 42 | client.getAuthorizeUrl(GrantType.AuthorizationCode, Map("redirect_uri" -> "https://example.com/oauth2/callback")) 43 | 44 | val accessToken: Future[Either[Throwable, AccessToken]] = 45 | client.getAccessToken(GrantType.AuthorizationCode, Map("code" -> "yyyyyy", "redirect_uri" -> "https://example.com")) 46 | 47 | accessToken.foreach { 48 | case Right(t) => 49 | t.accessToken // String 50 | t.tokenType // String 51 | t.expiresIn // Int 52 | t.refreshToken // Option[String] 53 | case Left(ex: UnauthorizedException) => 54 | ex.code // Code 55 | ex.description // String 56 | ex.response // HttpResponse 57 | } 58 | 59 | val newAccessToken: Future[Either[Throwable, AccessToken]] = 60 | client.getAccessToken(GrantType.RefreshToken, Map("refresh_token" -> "zzzzzzzz")) 61 | ``` 62 | 63 | ## Testing 64 | 65 | `Client` can pass mock connection into constructor. 66 | 67 | ```scala 68 | val mock = Flow[HttpRequest].map { _ => 69 | HttpResponse( 70 | status = StatusCodes.OK, 71 | headers = Nil, 72 | entity = HttpEntity( 73 | `application/json`, 74 | s""" 75 | |{ 76 | | "access_token": "dummy", 77 | | "token_type": "bearer", 78 | | "expires_in": 86400, 79 | | "refresh_token": "dummy" 80 | |} 81 | """.stripMargin 82 | ) 83 | ) 84 | } 85 | 86 | val client = Client(config, mock) 87 | ``` 88 | 89 | ## Authors 90 | 91 | * Dai Akatsuka 92 | -------------------------------------------------------------------------------- /build.sbt: -------------------------------------------------------------------------------- 1 | import ReleaseTransformations._ 2 | 3 | organization := "com.github.dakatsuka" 4 | 5 | name := "akka-http-oauth2-client" 6 | 7 | scalaVersion := "2.12.3" 8 | 9 | crossScalaVersions := Seq("2.11.11", "2.12.3") 10 | 11 | lazy val akkaHttpVersion = "10.0.10" 12 | lazy val circeVersion = "0.8.0" 13 | 14 | libraryDependencies ++= Seq( 15 | "com.typesafe.akka" %% "akka-http" % akkaHttpVersion, 16 | "io.circe" %% "circe-generic" % circeVersion, 17 | "io.circe" %% "circe-parser" % circeVersion, 18 | "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVersion % "test", 19 | "org.scalatest" %% "scalatest" % "3.0.3" % "test", 20 | "org.scalamock" %% "scalamock-scalatest-support" % "3.4.2" % "test" 21 | ) 22 | 23 | scalacOptions ++= Seq( 24 | "-deprecation", 25 | "-encoding", 26 | "utf-8", 27 | "-feature", 28 | "-language:existentials", 29 | "-language:experimental.macros", 30 | "-language:higherKinds", 31 | "-language:implicitConversions", 32 | "-unchecked", 33 | "-Xcheckinit", 34 | "-Xfatal-warnings", 35 | "-Xfuture", 36 | "-Xlint" 37 | ) 38 | 39 | enablePlugins(ScalafmtPlugin) 40 | 41 | scalafmtOnCompile := true 42 | 43 | scalafmtTestOnCompile := true 44 | 45 | licenses := Seq("The Apache Software License, Version 2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0.txt")) 46 | 47 | homepage := Some(url("https://github.com/dakatsuka/akka-http-oauth2-client")) 48 | 49 | publishMavenStyle := true 50 | 51 | publishArtifact in Test := false 52 | 53 | pomIncludeRepository := { _ => 54 | false 55 | } 56 | 57 | releaseCrossBuild := true 58 | 59 | publishTo := Some( 60 | if (isSnapshot.value) Opts.resolver.sonatypeSnapshots 61 | else Opts.resolver.sonatypeStaging 62 | ) 63 | 64 | scmInfo := Some( 65 | ScmInfo( 66 | url("https://github.com/dakatsuka/akka-http-oauth2-client"), 67 | "scm:git@github.com:dakatsuka/akka-http-oauth2-client.git" 68 | ) 69 | ) 70 | 71 | developers := List( 72 | Developer( 73 | id = "dakatsuka", 74 | name = "Dai Akatsuka", 75 | email = "d.akatsuka@gmail.com", 76 | url = url("https://github.com/dakatsuka") 77 | ) 78 | ) 79 | 80 | releaseProcess := Seq[ReleaseStep]( 81 | checkSnapshotDependencies, 82 | inquireVersions, 83 | runClean, 84 | runTest, 85 | setReleaseVersion, 86 | commitReleaseVersion, 87 | tagRelease, 88 | ReleaseStep(action = Command.process("+publishSigned", _)), 89 | setNextVersion, 90 | commitNextVersion, 91 | ReleaseStep(action = Command.process("+sonatypeRelease", _)), 92 | pushChanges 93 | ) 94 | -------------------------------------------------------------------------------- /project/build.properties: -------------------------------------------------------------------------------- 1 | sbt.version=0.13.16 2 | -------------------------------------------------------------------------------- /project/plugins.sbt: -------------------------------------------------------------------------------- 1 | addSbtPlugin("com.lucidchart" % "sbt-scalafmt" % "1.8") 2 | 3 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "1.1") 4 | 5 | addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.5") 6 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/AccessToken.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client 2 | 3 | import akka.http.scaladsl.model.HttpResponse 4 | import akka.http.scaladsl.unmarshalling.Unmarshal 5 | import akka.stream.Materializer 6 | import com.github.dakatsuka.akka.http.oauth2.client.utils.JsonUnmarshaller 7 | import io.circe.Decoder 8 | 9 | import scala.concurrent.Future 10 | 11 | case class AccessToken( 12 | accessToken: String, 13 | tokenType: String, 14 | expiresIn: Int, 15 | refreshToken: Option[String] 16 | ) 17 | 18 | object AccessToken extends JsonUnmarshaller { 19 | implicit def decoder: Decoder[AccessToken] = Decoder.instance { c => 20 | for { 21 | accessToken <- c.downField("access_token").as[String].right 22 | tokenType <- c.downField("token_type").as[String].right 23 | expiresIn <- c.downField("expires_in").as[Int].right 24 | refreshToken <- c.downField("refresh_token").as[Option[String]].right 25 | } yield AccessToken(accessToken, tokenType, expiresIn, refreshToken) 26 | } 27 | 28 | def apply(response: HttpResponse)(implicit mat: Materializer): Future[AccessToken] = { 29 | Unmarshal(response).to[AccessToken] 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/Client.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client 2 | 3 | import akka.actor.ActorSystem 4 | import akka.http.scaladsl.Http 5 | import akka.http.scaladsl.model.headers.OAuth2BearerToken 6 | import akka.http.scaladsl.model.{ HttpRequest, HttpResponse, Uri } 7 | import akka.stream.Materializer 8 | import akka.stream.scaladsl.{ Flow, Sink } 9 | import com.github.dakatsuka.akka.http.oauth2.client.Error.UnauthorizedException 10 | import com.github.dakatsuka.akka.http.oauth2.client.strategy.Strategy 11 | 12 | import scala.concurrent.{ ExecutionContext, Future } 13 | 14 | class Client(config: ConfigLike, connection: Option[Flow[HttpRequest, HttpResponse, _]] = None)(implicit system: ActorSystem) 15 | extends ClientLike { 16 | def getAuthorizeUrl[A <: GrantType](grant: A, params: Map[String, String] = Map.empty)(implicit s: Strategy[A]): Option[Uri] = 17 | s.getAuthorizeUrl(config, params) 18 | 19 | def getAccessToken[A <: GrantType]( 20 | grant: A, 21 | params: Map[String, String] = Map.empty 22 | )(implicit s: Strategy[A], ec: ExecutionContext, mat: Materializer): Future[Either[Throwable, AccessToken]] = { 23 | val source = s.getAccessTokenSource(config, params) 24 | 25 | source 26 | .via(connection.getOrElse(defaultConnection)) 27 | .mapAsync(1)(handleError) 28 | .mapAsync(1)(AccessToken.apply) 29 | .runWith(Sink.head) 30 | .map(Right.apply) 31 | .recover { 32 | case ex => Left(ex) 33 | } 34 | } 35 | 36 | def getConnectionWithAccessToken(accessToken: AccessToken): Flow[HttpRequest, HttpResponse, _] = 37 | Flow[HttpRequest] 38 | .map(_.addCredentials(OAuth2BearerToken(accessToken.accessToken))) 39 | .via(connection.getOrElse(defaultConnection)) 40 | 41 | private def defaultConnection: Flow[HttpRequest, HttpResponse, _] = 42 | config.site.getScheme match { 43 | case "http" => Http().outgoingConnection(config.getHost, config.getPort) 44 | case "https" => Http().outgoingConnectionHttps(config.getHost, config.getPort) 45 | } 46 | 47 | private def handleError(response: HttpResponse)(implicit ec: ExecutionContext, mat: Materializer): Future[HttpResponse] = { 48 | if (response.status.isFailure()) UnauthorizedException.fromHttpResponse(response).flatMap(Future.failed(_)) 49 | else Future.successful(response) 50 | } 51 | } 52 | 53 | object Client { 54 | def apply(config: ConfigLike)(implicit system: ActorSystem): Client = 55 | new Client(config) 56 | 57 | def apply(config: ConfigLike, connection: Flow[HttpRequest, HttpResponse, _])(implicit system: ActorSystem): Client = 58 | new Client(config, Some(connection)) 59 | } 60 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/ClientLike.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client 2 | 3 | import akka.http.scaladsl.model.{ HttpRequest, HttpResponse, Uri } 4 | import akka.stream.Materializer 5 | import akka.stream.scaladsl.Flow 6 | import com.github.dakatsuka.akka.http.oauth2.client.strategy.Strategy 7 | 8 | import scala.concurrent.{ ExecutionContext, Future } 9 | 10 | trait ClientLike { 11 | def getAuthorizeUrl[A <: GrantType](grant: A, params: Map[String, String] = Map.empty)(implicit s: Strategy[A]): Option[Uri] 12 | 13 | def getAccessToken[A <: GrantType]( 14 | grant: A, 15 | params: Map[String, String] = Map.empty 16 | )(implicit s: Strategy[A], ec: ExecutionContext, mat: Materializer): Future[Either[Throwable, AccessToken]] 17 | 18 | def getConnectionWithAccessToken(accessToken: AccessToken): Flow[HttpRequest, HttpResponse, _] 19 | } 20 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/Config.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client 2 | 3 | import java.net.URI 4 | 5 | import akka.http.scaladsl.model.{ HttpMethod, HttpMethods } 6 | 7 | case class Config( 8 | clientId: String, 9 | clientSecret: String, 10 | site: URI, 11 | authorizeUrl: String = "/oauth/authorize", 12 | tokenUrl: String = "/oauth/token", 13 | tokenMethod: HttpMethod = HttpMethods.POST 14 | ) extends ConfigLike { 15 | def getHost: String = site.getHost 16 | def getPort: Int = site.getScheme match { 17 | case "http" => if (site.getPort == -1) 80 else site.getPort 18 | case "https" => if (site.getPort == -1) 443 else site.getPort 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/ConfigLike.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client 2 | 3 | import java.net.URI 4 | 5 | import akka.http.scaladsl.model.HttpMethod 6 | 7 | trait ConfigLike { 8 | def clientId: String 9 | def clientSecret: String 10 | def site: URI 11 | def authorizeUrl: String 12 | def tokenUrl: String 13 | def tokenMethod: HttpMethod 14 | def getHost: String 15 | def getPort: Int 16 | } 17 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/Error.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client 2 | 3 | import akka.http.scaladsl.model.HttpResponse 4 | import akka.http.scaladsl.unmarshalling.Unmarshal 5 | import akka.stream.Materializer 6 | import com.github.dakatsuka.akka.http.oauth2.client.utils.JsonUnmarshaller 7 | import io.circe.Decoder 8 | 9 | import scala.concurrent.{ ExecutionContext, Future } 10 | 11 | object Error { 12 | sealed abstract class Code(val value: String) 13 | case object InvalidRequest extends Code("invalid_request") 14 | case object InvalidClient extends Code("invalid_client") 15 | case object InvalidToken extends Code("invalid_token") 16 | case object InvalidGrant extends Code("invalid_grant") 17 | case object InvalidScope extends Code("invalid_scope") 18 | case object UnsupportedGrantType extends Code("unsupported_grant_type") 19 | case object Unknown extends Code("unknown") 20 | 21 | object Code { 22 | def fromString(code: String): Code = code match { 23 | case "invalid_request" => InvalidRequest 24 | case "invalid_client" => InvalidClient 25 | case "invalid_token" => InvalidToken 26 | case "invalid_grant" => InvalidGrant 27 | case "invalid_scope" => InvalidScope 28 | case "unsupported_grant_type" => UnsupportedGrantType 29 | case _ => Unknown 30 | } 31 | } 32 | 33 | class UnauthorizedException(val code: Code, val description: String, val response: HttpResponse) 34 | extends RuntimeException(s"$code: $description") 35 | 36 | object UnauthorizedException extends JsonUnmarshaller { 37 | case class UnauthorizedResponse(error: String, errorDescription: String) 38 | 39 | implicit def decoder: Decoder[UnauthorizedResponse] = Decoder.instance { c => 40 | for { 41 | error <- c.downField("error").as[String].right 42 | description <- c.downField("error_description").as[String].right 43 | } yield UnauthorizedResponse(error, description) 44 | } 45 | 46 | def fromHttpResponse(response: HttpResponse)(implicit ec: ExecutionContext, mat: Materializer): Future[UnauthorizedException] = { 47 | Unmarshal(response).to[UnauthorizedResponse].map { r => 48 | new UnauthorizedException(Code.fromString(r.error), r.errorDescription, response) 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/GrantType.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client 2 | 3 | sealed abstract class GrantType(val value: String) 4 | 5 | object GrantType { 6 | case object AuthorizationCode extends GrantType("authorization_code") 7 | case object ClientCredentials extends GrantType("client_credentials") 8 | case object PasswordCredentials extends GrantType("password") 9 | case object Implicit extends GrantType("implicit") 10 | case object RefreshToken extends GrantType("refresh_token") 11 | } 12 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/strategy/AuthorizationCodeStrategy.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client.strategy 2 | 3 | import akka.NotUsed 4 | import akka.http.scaladsl.model.headers.RawHeader 5 | import akka.http.scaladsl.model._ 6 | import akka.stream.scaladsl.Source 7 | import com.github.dakatsuka.akka.http.oauth2.client.{ ConfigLike, GrantType } 8 | 9 | class AuthorizationCodeStrategy extends Strategy(GrantType.AuthorizationCode) { 10 | override def getAuthorizeUrl(config: ConfigLike, params: Map[String, String] = Map.empty): Option[Uri] = { 11 | val uri = Uri 12 | .apply(config.site.toASCIIString) 13 | .withPath(Uri.Path(config.authorizeUrl)) 14 | .withQuery(Uri.Query(params ++ Map("response_type" -> "code", "client_id" -> config.clientId))) 15 | 16 | Option(uri) 17 | } 18 | 19 | override def getAccessTokenSource(config: ConfigLike, params: Map[String, String] = Map.empty): Source[HttpRequest, NotUsed] = { 20 | require(params.contains("code")) 21 | require(params.contains("redirect_uri")) 22 | 23 | val uri = Uri 24 | .apply(config.site.toASCIIString) 25 | .withPath(Uri.Path(config.tokenUrl)) 26 | 27 | val request = HttpRequest( 28 | method = config.tokenMethod, 29 | uri = uri, 30 | headers = List( 31 | RawHeader("Accept", "*/*") 32 | ), 33 | FormData( 34 | params ++ Map( 35 | "grant_type" -> grant.value, 36 | "client_id" -> config.clientId, 37 | "client_secret" -> config.clientSecret 38 | ) 39 | ).toEntity(HttpCharsets.`UTF-8`) 40 | ) 41 | 42 | Source.single(request) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/strategy/ClientCredentialsStrategy.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client.strategy 2 | 3 | import akka.NotUsed 4 | import akka.http.scaladsl.model.headers.RawHeader 5 | import akka.http.scaladsl.model._ 6 | import akka.stream.scaladsl.Source 7 | import com.github.dakatsuka.akka.http.oauth2.client.{ ConfigLike, GrantType } 8 | 9 | class ClientCredentialsStrategy extends Strategy(GrantType.ClientCredentials) { 10 | override def getAuthorizeUrl(config: ConfigLike, params: Map[String, String] = Map.empty): Option[Uri] = None 11 | 12 | override def getAccessTokenSource(config: ConfigLike, params: Map[String, String] = Map.empty): Source[HttpRequest, NotUsed] = { 13 | val uri = Uri 14 | .apply(config.site.toASCIIString) 15 | .withPath(Uri.Path(config.tokenUrl)) 16 | 17 | val request = HttpRequest( 18 | method = config.tokenMethod, 19 | uri = uri, 20 | headers = List( 21 | RawHeader("Accept", "*/*") 22 | ), 23 | FormData( 24 | params ++ Map( 25 | "grant_type" -> grant.value, 26 | "client_id" -> config.clientId, 27 | "client_secret" -> config.clientSecret 28 | ) 29 | ).toEntity(HttpCharsets.`UTF-8`) 30 | ) 31 | 32 | Source.single(request) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/strategy/ImplicitStrategy.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client.strategy 2 | 3 | import akka.NotUsed 4 | import akka.http.scaladsl.model.{ HttpRequest, Uri } 5 | import akka.stream.scaladsl.Source 6 | import com.github.dakatsuka.akka.http.oauth2.client.{ ConfigLike, GrantType } 7 | 8 | class ImplicitStrategy extends Strategy(GrantType.Implicit) { 9 | override def getAuthorizeUrl(config: ConfigLike, params: Map[String, String] = Map.empty): Option[Uri] = { 10 | val uri = Uri 11 | .apply(config.site.toASCIIString) 12 | .withPath(Uri.Path(config.authorizeUrl)) 13 | .withQuery(Uri.Query(params ++ Map("response_type" -> "token", "client_id" -> config.clientId))) 14 | 15 | Option(uri) 16 | } 17 | 18 | override def getAccessTokenSource(config: ConfigLike, params: Map[String, String] = Map.empty): Source[HttpRequest, NotUsed] = 19 | Source.empty 20 | } 21 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/strategy/PasswordCredentialsStrategy.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client.strategy 2 | 3 | import akka.NotUsed 4 | import akka.http.scaladsl.model.headers.RawHeader 5 | import akka.http.scaladsl.model._ 6 | import akka.stream.scaladsl.Source 7 | import com.github.dakatsuka.akka.http.oauth2.client.{ ConfigLike, GrantType } 8 | 9 | class PasswordCredentialsStrategy extends Strategy(GrantType.PasswordCredentials) { 10 | override def getAuthorizeUrl(config: ConfigLike, params: Map[String, String] = Map.empty): Option[Uri] = None 11 | 12 | override def getAccessTokenSource(config: ConfigLike, params: Map[String, String] = Map.empty): Source[HttpRequest, NotUsed] = { 13 | require(params.contains("username")) 14 | require(params.contains("password")) 15 | 16 | val uri = Uri 17 | .apply(config.site.toASCIIString) 18 | .withPath(Uri.Path(config.tokenUrl)) 19 | 20 | val request = HttpRequest( 21 | method = config.tokenMethod, 22 | uri = uri, 23 | headers = List( 24 | RawHeader("Accept", "*/*") 25 | ), 26 | FormData( 27 | params ++ Map( 28 | "grant_type" -> grant.value, 29 | "client_id" -> config.clientId, 30 | "client_secret" -> config.clientSecret 31 | ) 32 | ).toEntity(HttpCharsets.`UTF-8`) 33 | ) 34 | 35 | Source.single(request) 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/strategy/RefreshTokenStrategy.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client.strategy 2 | 3 | import akka.NotUsed 4 | import akka.http.scaladsl.model.headers.RawHeader 5 | import akka.http.scaladsl.model.{ FormData, HttpCharsets, HttpRequest, Uri } 6 | import akka.stream.scaladsl.Source 7 | import com.github.dakatsuka.akka.http.oauth2.client.{ ConfigLike, GrantType } 8 | 9 | class RefreshTokenStrategy extends Strategy(GrantType.RefreshToken) { 10 | override def getAuthorizeUrl(config: ConfigLike, params: Map[String, String] = Map.empty): Option[Uri] = None 11 | 12 | override def getAccessTokenSource(config: ConfigLike, params: Map[String, String] = Map.empty): Source[HttpRequest, NotUsed] = { 13 | require(params.contains("refresh_token")) 14 | 15 | val uri = Uri 16 | .apply(config.site.toASCIIString) 17 | .withPath(Uri.Path(config.tokenUrl)) 18 | 19 | val request = HttpRequest( 20 | method = config.tokenMethod, 21 | uri = uri, 22 | headers = List( 23 | RawHeader("Accept", "*/*") 24 | ), 25 | FormData( 26 | params ++ Map( 27 | "grant_type" -> grant.value, 28 | "client_id" -> config.clientId, 29 | "client_secret" -> config.clientSecret 30 | ) 31 | ).toEntity(HttpCharsets.`UTF-8`) 32 | ) 33 | 34 | Source.single(request) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/strategy/Strategy.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client.strategy 2 | 3 | import akka.NotUsed 4 | import akka.http.scaladsl.model.{ HttpRequest, Uri } 5 | import akka.stream.scaladsl.Source 6 | import com.github.dakatsuka.akka.http.oauth2.client.{ ConfigLike, GrantType } 7 | 8 | abstract class Strategy[A <: GrantType](val grant: A) { 9 | def getAuthorizeUrl(config: ConfigLike, params: Map[String, String]): Option[Uri] 10 | def getAccessTokenSource(config: ConfigLike, params: Map[String, String]): Source[HttpRequest, NotUsed] 11 | } 12 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/strategy/package.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client 2 | 3 | package object strategy { 4 | implicit val authorizationCodeStrategy: AuthorizationCodeStrategy = new AuthorizationCodeStrategy 5 | implicit val clientCredentialsStrategy: ClientCredentialsStrategy = new ClientCredentialsStrategy 6 | implicit val implicitStrategy: ImplicitStrategy = new ImplicitStrategy 7 | implicit val passwordCredentialsStrategy: PasswordCredentialsStrategy = new PasswordCredentialsStrategy 8 | implicit val refreshTokenStrategy: RefreshTokenStrategy = new RefreshTokenStrategy 9 | } 10 | -------------------------------------------------------------------------------- /src/main/scala/com/github/dakatsuka/akka/http/oauth2/client/utils/JsonUnmarshaller.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client.utils 2 | 3 | import akka.http.scaladsl.model.ContentTypeRange 4 | import akka.http.scaladsl.model.MediaTypes.`application/json` 5 | import akka.http.scaladsl.unmarshalling.{ FromEntityUnmarshaller, Unmarshaller } 6 | import akka.util.ByteString 7 | import io.circe.{ jawn, Decoder, Json } 8 | 9 | trait JsonUnmarshaller { 10 | def unmarshallerContentTypes: Seq[ContentTypeRange] = 11 | List(`application/json`) 12 | 13 | implicit def jsonUnmarshaller: FromEntityUnmarshaller[Json] = 14 | Unmarshaller.byteStringUnmarshaller 15 | .forContentTypes(unmarshallerContentTypes: _*) 16 | .map { 17 | case ByteString.empty => throw Unmarshaller.NoContentException 18 | case data => jawn.parseByteBuffer(data.asByteBuffer).fold(throw _, identity) 19 | } 20 | 21 | implicit def unmarshaller[A: Decoder]: FromEntityUnmarshaller[A] = { 22 | def decode(json: Json) = implicitly[Decoder[A]].decodeJson(json).fold(throw _, identity) 23 | jsonUnmarshaller.map(decode) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/test/scala/com/github/dakatsuka/akka/http/oauth2/client/AccessTokenSpec.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client 2 | 3 | import akka.actor.ActorSystem 4 | import akka.http.scaladsl.model.{ HttpEntity, HttpResponse, StatusCodes } 5 | import akka.http.scaladsl.model.ContentTypes.`application/json` 6 | import akka.stream.{ ActorMaterializer, Materializer } 7 | import org.scalatest.concurrent.ScalaFutures 8 | import org.scalatest.time.{ Millis, Seconds, Span } 9 | import org.scalatest.{ BeforeAndAfterAll, DiagrammedAssertions, FlatSpec } 10 | 11 | import scala.concurrent.{ Await, ExecutionContext } 12 | import scala.concurrent.duration.Duration 13 | 14 | class AccessTokenSpec extends FlatSpec with DiagrammedAssertions with ScalaFutures with BeforeAndAfterAll { 15 | implicit val system: ActorSystem = ActorSystem() 16 | implicit val ec: ExecutionContext = system.dispatcher 17 | implicit val materializer: Materializer = ActorMaterializer() 18 | implicit val defaultPatience: PatienceConfig = 19 | PatienceConfig(timeout = Span(5, Seconds), interval = Span(700, Millis)) 20 | 21 | override def afterAll(): Unit = { 22 | Await.ready(system.terminate(), Duration.Inf) 23 | } 24 | 25 | behavior of "AccessToken" 26 | 27 | it should "apply from HttpResponse" in { 28 | val accessToken = "xxx" 29 | val tokenType = "bearer" 30 | val expiresIn = 86400 31 | val refreshToken = "yyy" 32 | 33 | val httpResponse = HttpResponse( 34 | status = StatusCodes.OK, 35 | headers = Nil, 36 | entity = HttpEntity( 37 | `application/json`, 38 | s""" 39 | |{ 40 | | "access_token": "$accessToken", 41 | | "token_type": "$tokenType", 42 | | "expires_in": $expiresIn, 43 | | "refresh_token": "$refreshToken" 44 | |} 45 | """.stripMargin 46 | ) 47 | ) 48 | 49 | val result = AccessToken(httpResponse) 50 | 51 | whenReady(result) { token => 52 | assert(token.accessToken == accessToken) 53 | assert(token.tokenType == tokenType) 54 | assert(token.expiresIn == expiresIn) 55 | assert(token.refreshToken.contains(refreshToken)) 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/test/scala/com/github/dakatsuka/akka/http/oauth2/client/ClientSpec.scala: -------------------------------------------------------------------------------- 1 | package com.github.dakatsuka.akka.http.oauth2.client 2 | 3 | import java.net.URI 4 | 5 | import akka.actor.ActorSystem 6 | import akka.http.scaladsl.model._ 7 | import akka.http.scaladsl.model.ContentTypes.`application/json` 8 | import akka.stream.scaladsl.{ Flow, Sink, Source } 9 | import akka.stream.{ ActorMaterializer, Materializer } 10 | import com.github.dakatsuka.akka.http.oauth2.client.Error.UnauthorizedException 11 | import org.scalatest.concurrent.ScalaFutures 12 | import org.scalatest.time.{ Millis, Seconds, Span } 13 | import org.scalatest.{ BeforeAndAfterAll, DiagrammedAssertions, FlatSpec } 14 | 15 | import scala.concurrent.duration.Duration 16 | import scala.concurrent.{ Await, ExecutionContext } 17 | 18 | class ClientSpec extends FlatSpec with DiagrammedAssertions with ScalaFutures with BeforeAndAfterAll { 19 | implicit val system: ActorSystem = ActorSystem() 20 | implicit val ec: ExecutionContext = system.dispatcher 21 | implicit val materializer: Materializer = ActorMaterializer() 22 | implicit val defaultPatience: PatienceConfig = 23 | PatienceConfig(timeout = Span(5, Seconds), interval = Span(700, Millis)) 24 | 25 | override def afterAll(): Unit = { 26 | Await.ready(system.terminate(), Duration.Inf) 27 | } 28 | 29 | behavior of "Client" 30 | 31 | "#getAuthorizeUrl" should "delegate processing to strategy" in { 32 | import strategy._ 33 | 34 | val config = Config("xxx", "yyy", site = URI.create("https://example.com"), authorizeUrl = "/oauth/custom_authorize") 35 | val client = Client(config) 36 | val result = client.getAuthorizeUrl(GrantType.AuthorizationCode, Map("redirect_uri" -> "https://example.com/callback")) 37 | val actual = result.get.toString 38 | val expect = "https://example.com/oauth/custom_authorize?redirect_uri=https://example.com/callback&response_type=code&client_id=xxx" 39 | assert(actual == expect) 40 | } 41 | 42 | "#getAccessToken" should "return Right[AccessToken] when oauth provider approves" in { 43 | import strategy._ 44 | 45 | val response = HttpResponse( 46 | status = StatusCodes.OK, 47 | headers = Nil, 48 | entity = HttpEntity( 49 | `application/json`, 50 | s""" 51 | |{ 52 | | "access_token": "xxx", 53 | | "token_type": "bearer", 54 | | "expires_in": 86400, 55 | | "refresh_token": "yyy" 56 | |} 57 | """.stripMargin 58 | ) 59 | ) 60 | 61 | val mockConnection = Flow[HttpRequest].map(_ => response) 62 | val config = Config("xxx", "yyy", URI.create("https://example.com")) 63 | val client = Client(config, mockConnection) 64 | val result = client.getAccessToken(GrantType.AuthorizationCode, Map("code" -> "zzz", "redirect_uri" -> "https://example.com")) 65 | 66 | whenReady(result) { r => 67 | assert(r.isRight) 68 | } 69 | } 70 | 71 | it should "return Left[UnauthorizedException] when oauth provider rejects" in { 72 | import strategy._ 73 | 74 | val response = HttpResponse( 75 | status = StatusCodes.Unauthorized, 76 | headers = Nil, 77 | entity = HttpEntity( 78 | `application/json`, 79 | s""" 80 | |{ 81 | | "error": "invalid_client", 82 | | "error_description": "description" 83 | |} 84 | """.stripMargin 85 | ) 86 | ) 87 | 88 | val mockConnection = Flow[HttpRequest].map(_ => response) 89 | val config = Config("xxx", "yyy", URI.create("https://example.com")) 90 | val client = Client(config, mockConnection) 91 | val result = client.getAccessToken(GrantType.AuthorizationCode, Map("code" -> "zzz", "redirect_uri" -> "https://example.com")) 92 | 93 | whenReady(result) { r => 94 | assert(r.isLeft) 95 | assert(r.left.exists(_.isInstanceOf[UnauthorizedException])) 96 | } 97 | } 98 | 99 | "#getConnectionWithAccessToken" should "return outgoing connection flow with access token" in { 100 | val accessToken = AccessToken( 101 | accessToken = "xxx", 102 | tokenType = "bearer", 103 | expiresIn = 86400, 104 | refreshToken = Some("yyy") 105 | ) 106 | 107 | val request = HttpRequest(HttpMethods.GET, "/v1/foo/bar") 108 | val response = HttpResponse( 109 | status = StatusCodes.OK, 110 | headers = Nil, 111 | entity = HttpEntity( 112 | `application/json`, 113 | s""" 114 | |{ 115 | | "key": "value" 116 | |} 117 | """.stripMargin 118 | ) 119 | ) 120 | 121 | val mockConnection = Flow[HttpRequest] 122 | .filter { req => 123 | req.headers.exists(_.is("authorization")) && req.headers.exists(_.value() == s"Bearer ${accessToken.accessToken}") 124 | } 125 | .map(_ => response) 126 | 127 | val config = Config("xxx", "yyy", URI.create("https://example.com")) 128 | val client = Client(config, mockConnection) 129 | val result = Source.single(request).via(client.getConnectionWithAccessToken(accessToken)).runWith(Sink.head) 130 | 131 | whenReady(result) { r => 132 | assert(r.status.isSuccess()) 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /version.sbt: -------------------------------------------------------------------------------- 1 | version in ThisBuild := "0.2.1-SNAPSHOT" 2 | --------------------------------------------------------------------------------