├── .github
└── workflows
│ └── ci.yml
├── .gitignore
├── .scalafmt.conf
├── LICENSE
├── README.md
├── buid.sbt
├── project
├── build.properties
└── plugins.sbt
└── src
├── main
└── scala
│ └── scalaoauth2
│ └── provider
│ └── OAuth2Provider.scala
└── test
└── scala
└── scalaoauth2
└── provider
├── MockDataHandler.scala
└── OAuth2ProviderSpec.scala
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 | on:
3 | pull_request:
4 | push:
5 | jobs:
6 | test:
7 | strategy:
8 | fail-fast: false
9 | matrix:
10 | include:
11 | - os: ubuntu-latest
12 | java: 8
13 | - os: ubuntu-latest
14 | java: 11
15 | runs-on: ${{ matrix.os }}
16 | steps:
17 | - name: Checkout
18 | uses: actions/checkout@v2
19 | - name: Setup
20 | uses: olafurpg/setup-scala@v10
21 | with:
22 | java-version: "adopt@1.${{ matrix.java }}"
23 | - name: Coursier cache
24 | uses: coursier/cache-action@v5
25 | - name: Build and test
26 | run: sbt scalafmtSbtCheck scalafmtCheck test:scalafmtCheck +test
27 | - run: rm -rf "$HOME/.ivy2/local" || true
28 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .bsp/
2 | .idea/
3 | .idea_modules/
4 | target/
5 |
--------------------------------------------------------------------------------
/.scalafmt.conf:
--------------------------------------------------------------------------------
1 | version = 2.7.5
2 | project.git = true
3 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2013 Nulab Inc.
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of
6 | this software and associated documentation files (the "Software"), to deal in
7 | the Software without restriction, including without limitation the rights to
8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9 | the Software, and to permit persons to whom the Software is furnished to do so,
10 | subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
21 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # akka-http-oauth2-provider [](https://github.com/nulab/akka-http-oauth2-provider/actions/workflows/ci.yml)
2 |
3 | This library is enabled using [scala-oauth2-provider](https://github.com/nulab/scala-oauth2-provider) in Akka HTTP.
4 |
5 | ## Setup
6 |
7 | Add "akka-http-oauth2-provider" to library dependencies of your project.
8 |
9 | ```scala
10 | libraryDependencies ++= Seq(
11 | "com.nulab-inc" %% "scala-oauth2-core" % "1.5.0",
12 | "com.nulab-inc" %% "akka-http-oauth2-provider" % "1.4.0"
13 | )
14 | ```
15 |
16 | Library version | Akka HTTP version
17 | --------------- | ------------
18 | 1.4.0 | 10.1.x
19 | 1.3.0 | 2.4.x
20 |
--------------------------------------------------------------------------------
/buid.sbt:
--------------------------------------------------------------------------------
1 | val akkaHttpVersion = "10.1.9"
2 | val akkaVersion = "2.5.23"
3 |
4 | val commonDependenciesInTestScope = Seq(
5 | "org.scalatest" %% "scalatest" % "3.2.0" % "test",
6 | "ch.qos.logback" % "logback-classic" % "1.2.6" % "test"
7 | )
8 |
9 | lazy val scalaOAuth2ProviderSettings =
10 | Defaults.coreDefaultSettings ++
11 | Seq(
12 | organization := "com.nulab-inc",
13 | scalaVersion := "2.13.6",
14 | crossScalaVersions := Seq("2.13.6", "2.12.14", "2.11.12"),
15 | scalacOptions ++= Seq("-deprecation", "-unchecked", "-feature"),
16 | publishTo := {
17 | val v = version.value
18 | val nexus = "https://oss.sonatype.org/"
19 | if (v.trim.endsWith("SNAPSHOT"))
20 | Some("snapshots" at nexus + "content/repositories/snapshots")
21 | else Some("releases" at nexus + "service/local/staging/deploy/maven2")
22 | },
23 | publishMavenStyle := true,
24 | Test / publishArtifact := false,
25 | pomIncludeRepository := { x =>
26 | false
27 | },
28 | pomExtra := https://github.com/nulab/akka-http-oauth2-provider
29 |
30 |
31 | MIT License
32 | http://www.opensource.org/licenses/mit-license.php
33 | repo
34 |
35 |
36 |
37 | https://github.com/nulab/akka-http-oauth2-provider
38 | scm:git:git@github.com:nulab/akka-http-oauth2-provider.git
39 |
40 |
41 |
42 | tsuyoshizawa
43 | Tsuyoshi Yoshizawa
44 | https://github.com/tsuyoshizawa
45 |
46 |
47 | )
48 |
49 | lazy val root = (project in file("."))
50 | .settings(
51 | scalaOAuth2ProviderSettings,
52 | name := "akka-http-oauth2-provider",
53 | description := "Support scala-oauth2-core library on akka-http",
54 | version := "1.4.1-SNAPSHOT",
55 | libraryDependencies ++= Seq(
56 | "com.nulab-inc" %% "scala-oauth2-core" % "1.4.0" % "provided",
57 | "com.typesafe.akka" %% "akka-http-core" % akkaHttpVersion % "provided",
58 | "com.typesafe.akka" %% "akka-http" % akkaHttpVersion % "provided",
59 | "com.typesafe.akka" %% "akka-stream" % akkaVersion % "provided",
60 | "com.typesafe.akka" %% "akka-http-testkit" % akkaHttpVersion % "provided",
61 | "com.typesafe.akka" %% "akka-http-spray-json" % akkaHttpVersion % "provided",
62 | "com.typesafe.akka" %% "akka-stream-testkit" % akkaVersion % "provided"
63 | ) ++ commonDependenciesInTestScope
64 | )
65 |
--------------------------------------------------------------------------------
/project/build.properties:
--------------------------------------------------------------------------------
1 | sbt.version=1.5.5
2 |
--------------------------------------------------------------------------------
/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | addSbtPlugin("org.scalameta" % "sbt-scalafmt" % "2.4.3")
2 |
--------------------------------------------------------------------------------
/src/main/scala/scalaoauth2/provider/OAuth2Provider.scala:
--------------------------------------------------------------------------------
1 | package scalaoauth2.provider
2 |
3 | import akka.http.scaladsl.model.StatusCodes._
4 | import akka.http.scaladsl.server.Directives
5 | import akka.http.scaladsl.server.directives.Credentials
6 | import scalaoauth2.provider.OAuth2Provider.TokenResponse
7 | import spray.json.{JsValue, DefaultJsonProtocol}
8 |
9 | import scala.concurrent.ExecutionContext.Implicits.global
10 | import scala.concurrent.Future
11 | import scala.util.{Failure, Success}
12 |
13 | trait OAuth2Provider[U] extends Directives with DefaultJsonProtocol {
14 | import akka.http.scaladsl.marshallers.sprayjson.SprayJsonSupport._
15 |
16 | val oauth2DataHandler: DataHandler[U]
17 |
18 | val tokenEndpoint: TokenEndpoint
19 |
20 | def grantResultToTokenResponse(grantResult: GrantHandlerResult[U]): JsValue =
21 | OAuth2Provider.tokenResponseFormat.write(
22 | TokenResponse(
23 | grantResult.tokenType,
24 | grantResult.accessToken,
25 | grantResult.expiresIn.getOrElse(1L),
26 | grantResult.refreshToken.getOrElse("")
27 | )
28 | )
29 |
30 | def oauth2Authenticator(
31 | credentials: Credentials
32 | ): Future[Option[AuthInfo[U]]] =
33 | credentials match {
34 | case Credentials.Provided(token) =>
35 | oauth2DataHandler.findAccessToken(token).flatMap {
36 | case Some(token) => oauth2DataHandler.findAuthInfoByAccessToken(token)
37 | case None => Future.successful(None)
38 | }
39 | case _ => Future.successful(None)
40 | }
41 |
42 | def accessTokenRoute = pathPrefix("oauth") {
43 | path("access_token") {
44 | post {
45 | formFieldMap { fields =>
46 | onComplete(
47 | tokenEndpoint.handleRequest(
48 | new AuthorizationRequest(
49 | Map(),
50 | fields.map(m => m._1 -> Seq(m._2))
51 | ),
52 | oauth2DataHandler
53 | )
54 | ) {
55 | case Success(maybeGrantResponse) =>
56 | maybeGrantResponse.fold(
57 | oauthError => complete(Unauthorized),
58 | grantResult => complete(grantResultToTokenResponse(grantResult))
59 | )
60 | case Failure(ex) =>
61 | complete(
62 | InternalServerError,
63 | s"An error occurred: ${ex.getMessage}"
64 | )
65 | }
66 | }
67 | }
68 | }
69 | }
70 |
71 | }
72 |
73 | object OAuth2Provider extends DefaultJsonProtocol {
74 | case class TokenResponse(
75 | token_type: String,
76 | access_token: String,
77 | expires_in: Long,
78 | refresh_token: String
79 | )
80 | implicit val tokenResponseFormat = jsonFormat4(TokenResponse)
81 | }
82 |
--------------------------------------------------------------------------------
/src/test/scala/scalaoauth2/provider/MockDataHandler.scala:
--------------------------------------------------------------------------------
1 | package scalaoauth2.provider
2 |
3 | import java.util.Date
4 |
5 | import scala.concurrent.Future
6 |
7 | class MockDataHandler extends DataHandler[User] {
8 |
9 | override def validateClient(
10 | maybeClientCredential: Option[ClientCredential],
11 | request: AuthorizationRequest
12 | ): Future[Boolean] = Future.successful(false)
13 |
14 | override def findUser(
15 | maybeClientCredential: Option[ClientCredential],
16 | request: AuthorizationRequest
17 | ): Future[Option[User]] = Future.successful(None)
18 |
19 | override def createAccessToken(
20 | authInfo: AuthInfo[User]
21 | ): Future[AccessToken] =
22 | Future.successful(AccessToken("", Some(""), Some(""), Some(0L), new Date()))
23 |
24 | override def findAuthInfoByCode(
25 | code: String
26 | ): Future[Option[AuthInfo[User]]] = Future.successful(None)
27 |
28 | override def findAuthInfoByRefreshToken(
29 | refreshToken: String
30 | ): Future[Option[AuthInfo[User]]] = Future.successful(None)
31 |
32 | override def findAccessToken(token: String): Future[Option[AccessToken]] =
33 | Future.successful(None)
34 |
35 | override def findAuthInfoByAccessToken(
36 | accessToken: AccessToken
37 | ): Future[Option[AuthInfo[User]]] = Future.successful(None)
38 |
39 | override def getStoredAccessToken(
40 | authInfo: AuthInfo[User]
41 | ): Future[Option[AccessToken]] = Future.successful(None)
42 |
43 | override def refreshAccessToken(
44 | authInfo: AuthInfo[User],
45 | refreshToken: String
46 | ): Future[AccessToken] =
47 | Future.successful(AccessToken("", Some(""), Some(""), Some(0L), new Date()))
48 |
49 | override def deleteAuthCode(code: String): Future[Unit] =
50 | Future.successful(())
51 | }
52 |
53 | trait User {
54 | def id: Long
55 | def name: String
56 | }
57 |
58 | case class MockUser(id: Long, name: String) extends User
59 |
--------------------------------------------------------------------------------
/src/test/scala/scalaoauth2/provider/OAuth2ProviderSpec.scala:
--------------------------------------------------------------------------------
1 | package scalaoauth2.provider
2 |
3 | import java.util.Date
4 | import akka.http.scaladsl.model.headers.OAuth2BearerToken
5 | import akka.http.scaladsl.server.directives.Credentials
6 | import akka.http.scaladsl.model.StatusCodes._
7 | import akka.http.scaladsl.model.FormData
8 | import akka.http.scaladsl.testkit.ScalatestRouteTest
9 | import org.scalatest.concurrent.ScalaFutures
10 | import org.scalatest.matchers.should.Matchers
11 | import org.scalatest.wordspec.AnyWordSpec
12 |
13 | import scala.concurrent.Future
14 |
15 | class OAuth2ProviderSpec
16 | extends AnyWordSpec
17 | with Matchers
18 | with ScalaFutures
19 | with ScalatestRouteTest {
20 |
21 | val tokenEndpointCredentials = new TokenEndpoint {
22 | override val handlers = Map(
23 | OAuthGrantType.CLIENT_CREDENTIALS -> new ClientCredentials
24 | )
25 | }
26 |
27 | val oauth2ProviderFail = new OAuth2Provider[User] {
28 | override val oauth2DataHandler = new MockDataHandler()
29 | override val tokenEndpoint = tokenEndpointCredentials
30 | }
31 |
32 | val user = MockUser(1, "user")
33 | val someAuthInfo = Some(AuthInfo(user, Some("clientId"), None, None))
34 | val accessToken =
35 | AccessToken("token", Some("refresh token"), None, Some(3600), new Date)
36 |
37 | val oauth2ProviderSuccess = new OAuth2Provider[User] {
38 | override val tokenEndpoint = tokenEndpointCredentials
39 | override val oauth2DataHandler = new MockDataHandler() {
40 | override def findAccessToken(token: String): Future[Option[AccessToken]] =
41 | Future.successful(Some(accessToken))
42 | override def findAuthInfoByAccessToken(
43 | accessToken: AccessToken
44 | ): Future[Option[AuthInfo[User]]] =
45 | Future.successful(someAuthInfo)
46 | override def findUser(
47 | maybeClientCredential: Option[ClientCredential],
48 | request: AuthorizationRequest
49 | ): Future[Option[User]] =
50 | Future.successful(Some(user))
51 | override def validateClient(
52 | maybeClientCredential: Option[ClientCredential],
53 | request: AuthorizationRequest
54 | ): Future[Boolean] =
55 | Future.successful(true)
56 | override def getStoredAccessToken(
57 | authInfo: AuthInfo[User]
58 | ): Future[Option[AccessToken]] =
59 | Future.successful(Some(accessToken))
60 | override def createAccessToken(
61 | authInfo: AuthInfo[User]
62 | ): Future[AccessToken] =
63 | Future.successful(accessToken)
64 | }
65 | }
66 |
67 | "oauth2Authenticator" should {
68 |
69 | "return none when data handler cannot find access token" in {
70 | val r = oauth2ProviderFail.oauth2Authenticator(
71 | Credentials(Some(OAuth2BearerToken("token")))
72 | )
73 | whenReady(r) { result =>
74 | result should be(None)
75 | }
76 | }
77 |
78 | "return none when there is not a bearer token in request" in {
79 | val r = oauth2ProviderSuccess.oauth2Authenticator(Credentials(None))
80 | whenReady(r) { result =>
81 | result should be(None)
82 | }
83 | }
84 |
85 | "return some authinfo when there is a token match" in {
86 | val r = oauth2ProviderSuccess.oauth2Authenticator(
87 | Credentials(Some(OAuth2BearerToken("token")))
88 | )
89 | whenReady(r) { result =>
90 | result should be(someAuthInfo)
91 | }
92 | }
93 |
94 | }
95 |
96 | "access token route" should {
97 |
98 | "return Unauthorized when there is an error on authorization" in {
99 | Post(
100 | "/oauth/access_token",
101 | FormData(
102 | "client_id" -> "bob_client_id",
103 | "client_secret" -> "bob_client_secret",
104 | "grant_type" -> "client_credentials"
105 | )
106 | ) ~> oauth2ProviderFail.accessTokenRoute ~> check {
107 | handled shouldEqual true
108 | status shouldEqual Unauthorized
109 | }
110 | }
111 |
112 | "return Ok with token respons when there is a valid authorization" in {
113 | Post(
114 | "/oauth/access_token",
115 | FormData(
116 | "client_id" -> "bob_client_id",
117 | "client_secret" -> "bob_client_secret",
118 | "grant_type" -> "client_credentials"
119 | )
120 | ) ~> oauth2ProviderSuccess.accessTokenRoute ~> check {
121 | handled shouldEqual true
122 | status shouldEqual OK
123 | }
124 | }
125 |
126 | }
127 |
128 | }
129 |
--------------------------------------------------------------------------------