├── .gitignore
├── .travis.yml
├── LICENSE
├── README.md
├── build.sbt
├── build
├── build.sh
├── credentials.sbt.enc
├── deploy_key.pem.enc
├── pubring.gpg.enc
└── secring.gpg.enc
├── project
├── build.properties
└── plugins.sbt
├── src
├── main
│ └── scala
│ │ └── com
│ │ └── twitter
│ │ └── finagle
│ │ ├── OAuth2.scala
│ │ └── oauth2
│ │ ├── AccessToken.scala
│ │ ├── AuthInfo.scala
│ │ ├── ClientCredential.scala
│ │ ├── DataHandler.scala
│ │ ├── GrantHandler.scala
│ │ ├── GrantResult.scala
│ │ ├── OAuthError.scala
│ │ └── Request.scala
└── test
│ └── scala
│ └── com
│ └── twitter
│ └── finagle
│ └── oauth2
│ ├── AuthHeaderSpec.scala
│ ├── AuthorizationCodeSpec.scala
│ ├── ClientCredentialFetcherSpec.scala
│ ├── ClientCredentialsSpec.scala
│ ├── EndToEndSpec.scala
│ ├── MockDataHandler.scala
│ ├── PasswordSpec.scala
│ ├── RefreshTokenSpec.scala
│ └── RequestParameterSpec.scala
└── version.sbt
/.gitignore:
--------------------------------------------------------------------------------
1 | *.iml
2 | target/
3 | .idea/
4 | .idea_modules/
5 | .DS_STORE
6 | .cache
7 | .settings
8 | .project
9 | .classpath
10 | local.*
11 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: scala
2 | branches:
3 | except:
4 | - release
5 | sudo: true
6 | jdk:
7 | - openjdk8
8 | before_cache:
9 | - find $HOME/.ivy2 -name "ivydata-*.properties" -delete
10 | - find $HOME/.sbt -name "*.lock" -delete
11 | cache:
12 | directories:
13 | - "$HOME/.ivy2/cache"
14 | - "$HOME/.sbt/boot/"
15 | script:
16 | - "./build/build.sh"
17 | env:
18 | global:
19 | secure: XGe1fOKTjJsqet5Zn3xlCbBtJ9n2uUH9ukgnpojBT65p5ETNr4U/NPNsryL9TZk0DsPKdDNKrb9RmadeMcKjNa0x+p7WXYlogQr+rJgcjAo6POhBzGcPM22XGSNzCmZOKmKT0LM7cuGiQ63QEefdzGxsqE8baljHUWFatvCxPXY=
20 |
--------------------------------------------------------------------------------
/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 {yyyy} {name of copyright owner}
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.
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | [](https://travis-ci.org/finagle/finagle-oauth2)
2 | [](https://maven-badges.herokuapp.com/maven-central/com.github.finagle/finagle-oauth2_2.12)
3 |
4 | OAuth2 Provider for Finagle
5 | ---------------------------
6 |
7 | This is a [Finagle]-friendly version of [scala-oauth2-provider].
8 |
9 | ## User Guide
10 |
11 | 1. Implement `com.twitter.finagle.oauth2.DataHandler` using your own data store (in-memory, DB, etc).
12 | 2. Use `com.twitter.finagle.OAuth2` API to authorize requests and issue access tokens.
13 |
14 | > A service that emits OAuth2 access tokens based on request's credentials.
15 |
16 | ```scala
17 | import com.twitter.finagle.OAuth2
18 | import com.twitter.finagle.oauth2.{OAuthError, DataHandler}
19 |
20 | import com.twitter.finagle.http.{Request, Response, Version, Status}
21 | import com.twitter.finagle.Service
22 | import com.twitter.util.Future
23 |
24 | val dataHandler: DataHandler[?] = ???
25 |
26 | object TokenService extends Service[Request, Response] with OAuth2 {
27 | def apply(req: Request): Future[Response] =
28 | issueAccessToken(req, dataHandler).flatMap { token =>
29 | val rep = Response(Version.Http11, Status.Ok)
30 | rep.setContentString(token.accessToken)
31 | Future.value(rep)
32 | } handle {
33 | case e: OAuthError => e.toResponse
34 | }
35 | }
36 | ```
37 |
38 | > A service that checks whether the request contains a valid token.
39 |
40 | ```scala
41 | import com.twitter.finagle.OAuth2
42 | import com.twitter.finagle.oauth2.{OAuthError, DataHandler}
43 |
44 | import com.twitter.finagle.http.{Request, Response, Version, Status}
45 | import com.twitter.finagle.Service
46 | import com.twitter.util.Future
47 |
48 | object ProtectedService extends Service[Request, Response] with OAuth2 {
49 | def apply(req: Request): Future[Response] =
50 | authorize(req, dataHandler).flatMap { authInfo =>
51 | val rep = Response(Version.Http11, Status.Ok)
52 | rep.setContentString(s"Hello ${authInfo.user}!")
53 | Future.value(rep)
54 | } handle {
55 | case e: OAuthError => e.toResponse
56 | }
57 | }
58 | ```
59 |
60 | [Finagle]: https://github.com/twitter/finagle
61 | [scala-oauth2-provider]: https://github.com/nulab/scala-oauth2-provider
62 |
63 |
--------------------------------------------------------------------------------
/build.sbt:
--------------------------------------------------------------------------------
1 | import ReleaseTransformations._
2 |
3 | lazy val finagleVersion = "19.8.0"
4 |
5 | lazy val buildSettings = Seq(
6 | organization := "com.github.finagle",
7 | scalaVersion := "2.12.7",
8 | crossScalaVersions := Seq("2.11.12", "2.12.7")
9 | )
10 |
11 | val baseSettings = Seq(
12 | libraryDependencies ++= Seq(
13 | "com.twitter" %% "finagle-http" % finagleVersion,
14 | "org.scalacheck" %% "scalacheck" % "1.14.0" % Test,
15 | "org.scalatest" %% "scalatest" % "3.0.8" % Test
16 | )
17 | )
18 |
19 | lazy val publishSettings = Seq(
20 | publishMavenStyle := true,
21 | publishArtifact := true,
22 | publishTo := {
23 | val nexus = "https://oss.sonatype.org/"
24 | if (isSnapshot.value)
25 | Some("snapshots" at nexus + "content/repositories/snapshots")
26 | else
27 | Some("releases" at nexus + "service/local/staging/deploy/maven2")
28 | },
29 | publishArtifact in Test := false,
30 | pgpSecretRing := file("local.secring.gpg"),
31 | pgpPublicRing := file("local.pubring.gpg"),
32 | releasePublishArtifactsAction := PgpKeys.publishSigned.value,
33 | releaseIgnoreUntrackedFiles := true,
34 | licenses := Seq("Apache 2.0" -> url("http://www.apache.org/licenses/LICENSE-2.0")),
35 | homepage := Some(url("https://github.com/finagle/finagle-oauth2")),
36 | autoAPIMappings := true,
37 | scmInfo := Some(
38 | ScmInfo(
39 | url("https://github.com/finagle/finagle-oauth2"),
40 | "scm:git:git@github.com:finagle/finagle-oauth2.git"
41 | )
42 | ),
43 | releaseVersionBump := sbtrelease.Version.Bump.Minor,
44 | releaseProcess := {
45 | Seq[ReleaseStep](
46 | checkSnapshotDependencies,
47 | inquireVersions,
48 | releaseStepCommandAndRemaining("+clean"),
49 | releaseStepCommandAndRemaining("+test"),
50 | setReleaseVersion,
51 | commitReleaseVersion,
52 | tagRelease,
53 | releaseStepCommandAndRemaining("+publishSigned"),
54 | setNextVersion,
55 | commitNextVersion,
56 | releaseStepCommand("sonatypeReleaseAll"),
57 | pushChanges
58 | )
59 | },
60 | pomExtra :=
61 |
62 |
63 | vkostyukov
64 | Vladimir Kostyukov
65 | https://kostyukov.net
66 |
67 |
68 | )
69 |
70 | lazy val allSettings = baseSettings ++ buildSettings ++ publishSettings
71 |
72 | lazy val oauth2 = project.in(file("."))
73 | .settings(moduleName := "finagle-oauth2")
74 | .settings(allSettings)
75 |
--------------------------------------------------------------------------------
/build/build.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 | set -e
3 |
4 | SBT_CMD="sbt +test"
5 |
6 | if [[ "${TRAVIS_PULL_REQUEST}" == "false" ]]; then
7 | SBT_CMD+=" +publish"
8 | openssl aes-256-cbc -pass env:ENCRYPTION_PASSWORD -in ./build/secring.gpg.enc -out local.secring.gpg -d
9 | openssl aes-256-cbc -pass env:ENCRYPTION_PASSWORD -in ./build/pubring.gpg.enc -out local.pubring.gpg -d
10 | openssl aes-256-cbc -pass env:ENCRYPTION_PASSWORD -in ./build/credentials.sbt.enc -out local.credentials.sbt -d
11 | openssl aes-256-cbc -pass env:ENCRYPTION_PASSWORD -in ./build/deploy_key.pem.enc -out local.deploy_key.pem -d
12 |
13 | if [[ "${TRAVIS_BRANCH}" == "master" && $(cat version.sbt) != *"SNAPSHOT"* ]]; then
14 | eval "$(ssh-agent -s)"
15 | chmod 600 local.deploy_key.pem
16 | ssh-add local.deploy_key.pem
17 | git config --global user.name "Finch CI"
18 | git config --global user.email "ci@kostyukov.net"
19 | git remote set-url origin git@github.com:finagle/finagle-oauth2.git
20 | git checkout master || git checkout -b master
21 | git reset --hard origin/master
22 |
23 | echo 'Performing a release'
24 | sbt 'release cross with-defaults'
25 | elif [[ "${TRAVIS_BRANCH}" == "master" ]]; then
26 | echo 'Master build'
27 | ${SBT_CMD}
28 | else
29 | echo 'Branch build'
30 | printf 'version in ThisBuild := "%s-SNAPSHOT"' "${TRAVIS_BRANCH}" > version.sbt
31 | ${SBT_CMD}
32 | fi
33 | else
34 | echo 'PR build'
35 | ${SBT_CMD}
36 | fi
37 |
--------------------------------------------------------------------------------
/build/credentials.sbt.enc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/finagle/finagle-oauth2/c46fc9d860270efdb8383142df7c745e61cae31c/build/credentials.sbt.enc
--------------------------------------------------------------------------------
/build/deploy_key.pem.enc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/finagle/finagle-oauth2/c46fc9d860270efdb8383142df7c745e61cae31c/build/deploy_key.pem.enc
--------------------------------------------------------------------------------
/build/pubring.gpg.enc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/finagle/finagle-oauth2/c46fc9d860270efdb8383142df7c745e61cae31c/build/pubring.gpg.enc
--------------------------------------------------------------------------------
/build/secring.gpg.enc:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/finagle/finagle-oauth2/c46fc9d860270efdb8383142df7c745e61cae31c/build/secring.gpg.enc
--------------------------------------------------------------------------------
/project/build.properties:
--------------------------------------------------------------------------------
1 | sbt.version=1.2.8
2 |
--------------------------------------------------------------------------------
/project/plugins.sbt:
--------------------------------------------------------------------------------
1 | addSbtPlugin("com.github.gseitz" % "sbt-release" % "1.0.11")
2 | addSbtPlugin("org.xerial.sbt" % "sbt-sonatype" % "2.0")
3 | addSbtPlugin("com.jsuereth" % "sbt-pgp" % "1.1.2")
4 |
--------------------------------------------------------------------------------
/src/main/scala/com/twitter/finagle/OAuth2.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle
2 |
3 | import com.twitter.util.Future
4 | import com.twitter.finagle.oauth2._
5 |
6 | /**
7 | * An entry point API to enable OAuth2 in Finagle services (server-side).
8 | *
9 | * Issuing a token:
10 | *
11 | * {{{
12 | * import com.twitter.finagle.OAuth2
13 | * import com.twitter.finagle.oauth2.{DataHandler, Grant}
14 | * import com.twitter.finagle.http.Request
15 | * import com.twitter.util.Future
16 | *
17 | * val dataHandler: DataHandler[?] = ???
18 | * val request: Request = ??? // contains client credentials
19 | *
20 | * val grant: Future[Grant] = OAuth2.issueAccessToken(request, dataHandler)
21 | * }}}
22 | *
23 | * Authorizing a request:
24 | *
25 | * {{{
26 | * import com.twitter.finagle.OAuth2
27 | * import com.twitter.finagle.oauth2.{AuthInfo, DataHandler}
28 | * import com.twitter.finagle.http.Request
29 | * import com.twitter.util.Future
30 | *
31 | * val dataHandler: DataHandler[?] = ???
32 | * val request: Request = ??? // contains token
33 | *
34 | * val authInfo: AuthInfo[?] = OAuth2.authorize(request, dataHandler)
35 | * }}}
36 | *
37 | * Note both `authorize` and `issueAccessToken` may resolve into `Future.exception` containing
38 | * [[OAuthError]], which could be converted into a barebones HTTP response via `.toResponse`:
39 | */
40 | trait OAuth2 {
41 |
42 | def issueAccessToken[U](
43 | request: http.Request,
44 | dataHandler: DataHandler[U]
45 | ): Future[GrantResult] =
46 | issueAccessToken(new Request.Authorization(request.headerMap, request.params), dataHandler)
47 |
48 | def issueAccessToken[U](
49 | request: Request.Authorization,
50 | dataHandler: DataHandler[U]
51 | ): Future[GrantResult] = for {
52 | grantType <- request.grantType match {
53 | case Some(t) => Future.value(t)
54 | case None => Future.exception(new InvalidRequest("grant_type not found"))
55 | }
56 | handler <- GrantHandler.fromGrantType(grantType) match {
57 | case Some(h) => Future.value(h)
58 | case None => Future.exception(new UnsupportedGrantType("the grant_type isn't supported"))
59 | }
60 | credential <- request.clientCredential match {
61 | case Some(c) => Future.value(c)
62 | case None => Future.exception(new InvalidRequest("client credential not found"))
63 | }
64 | validated <- dataHandler.validateClient(credential.clientId, credential.clientSecret, grantType)
65 | result <-
66 | if (validated) handler.handle(request, dataHandler)
67 | else Future.exception(new InvalidClient())
68 | } yield result
69 |
70 | def authorize[U](
71 | request: http.Request,
72 | dataHandler: DataHandler[U]
73 | ): Future[AuthInfo[U]] =
74 | authorize(new Request.ProtectedResource(request.headerMap, request.params), dataHandler)
75 |
76 | def authorize[U](
77 | request: Request.ProtectedResource,
78 | dataHandler: DataHandler[U]
79 | ): Future[AuthInfo[U]] = for {
80 | accessToken <- request.token match {
81 | case Some(f) => Future.value(f)
82 | case None => Future.exception(new InvalidRequest("Access token was not specified"))
83 | }
84 | tokenOption <- dataHandler.findAccessToken(accessToken)
85 | token <- tokenOption match {
86 | case Some(t) =>
87 | if (dataHandler.isAccessTokenExpired(t)) Future.exception(new ExpiredToken())
88 | else Future.value(t)
89 | case None => Future.exception(new InvalidToken("Invalid access token"))
90 | }
91 | infoOption <- dataHandler.findAuthInfoByAccessToken(token)
92 | info <- infoOption match {
93 | case Some(i) => Future.value(i)
94 | case None => Future.exception(new InvalidToken("invalid access token"))
95 | }
96 | } yield info
97 | }
98 |
99 | object OAuth2 extends OAuth2
100 |
--------------------------------------------------------------------------------
/src/main/scala/com/twitter/finagle/oauth2/AccessToken.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import java.util.Date
4 |
5 | /**
6 | * Access token.
7 | *
8 | * @param token Access token is used to authentication.
9 | * @param refreshToken Refresh token is used to re-issue access token.
10 | * @param scope Inform the client of the scope of the access token issued.
11 | * @param expiresIn Expiration date of access token. Unit is seconds.
12 | * @param createdAt Access token is created date.
13 | */
14 | final case class AccessToken(
15 | token: String,
16 | refreshToken: Option[String],
17 | scope: Option[String],
18 | expiresIn: Option[Long],
19 | createdAt: Date
20 | )
21 |
--------------------------------------------------------------------------------
/src/main/scala/com/twitter/finagle/oauth2/AuthInfo.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | /**
4 | * Authorized information.
5 | *
6 | * @param user Authorized user which is registered on system.
7 | * @param clientId Using client id which is registered on system.
8 | * @param scope Inform the client of the scope of the access token issued.
9 | * @param redirectUri This value is used by Authorization Code Grant.
10 | */
11 | final case class AuthInfo[U](
12 | user: U,
13 | clientId: String,
14 | scope: Option[String],
15 | redirectUri: Option[String]
16 | )
17 |
--------------------------------------------------------------------------------
/src/main/scala/com/twitter/finagle/oauth2/ClientCredential.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | final case class ClientCredential(clientId: String, clientSecret: String)
4 |
--------------------------------------------------------------------------------
/src/main/scala/com/twitter/finagle/oauth2/DataHandler.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.util.Future
4 |
5 | /**
6 | * Provide accessing to data storage for using OAuth 2.0.
7 | *
8 | *
[Authorization phases]
9 | *
10 | * Authorization Code Grant
11 | *
12 | * - validateClient(clientId, clientSecret, grantType)
13 | * - findAuthInfoByCode(code)
14 | * - getStoredAccessToken(authInfo)
15 | * - isAccessTokenExpired(token)
16 | * - refreshAccessToken(authInfo, token)
17 | *
- createAccessToken(authInfo)
18 | *
19 | *
20 | * Refresh Token Grant
21 | *
22 | * - validateClient(clientId, clientSecret, grantType)
23 | * - findAuthInfoByRefreshToken(refreshToken)
24 | * - refreshAccessToken(authInfo, refreshToken)
25 | *
26 | *
27 | * Resource Owner Password Credentials Grant
28 | *
29 | * - validateClient(clientId, clientSecret, grantType)
30 | * - findUser(username, password)
31 | * - getStoredAccessToken(authInfo)
32 | * - isAccessTokenExpired(token)
33 | * - refreshAccessToken(authInfo, token)
34 | *
- createAccessToken(authInfo)
35 | *
36 | *
37 | * Client Credentials Grant
38 | *
39 | * - validateClient(clientId, clientSecret, grantType)
40 | * - findClientUser(clientId, clientSecret)
41 | * - getStoredAccessToken(authInfo)
42 | * - isAccessTokenExpired(token)
43 | * - refreshAccessToken(authInfo, token)
44 | *
- createAccessToken(authInfo)
45 | *
46 | *
47 | * [Access to Protected Resource phase]
48 | *
49 | * - findAccessToken(token)
50 | * - isAccessTokenExpired(token)
51 | * - findAuthInfoByAccessToken(token)
52 | *
53 | */
54 | trait DataHandler[U] {
55 |
56 | /**
57 | * Verify proper client with parameters for issue an access token.
58 | *
59 | * @param clientId Client send this value which is registered by application.
60 | * @param clientSecret Client send this value which is registered by application.
61 | * @param grantType Client send this value which is registered by application.
62 | * @return true if request is a regular client, false if request is a illegal client.
63 | */
64 | def validateClient(clientId: String, clientSecret: String, grantType: String): Future[Boolean]
65 |
66 | /**
67 | * Find userId with username and password these are used on your system.
68 | * If you don't support Resource Owner Password Credentials Grant then doesn't need implementing.
69 | *
70 | * @param username Client send this value which is used on your system.
71 | * @param password Client send this value which is used on your system.
72 | * @return Including UserId to Option if could find the user, None if couldn't find.
73 | */
74 | def findUser(username: String, password: String): Future[Option[U]]
75 |
76 | /**
77 | * Creates a new access token by authorized information.
78 | *
79 | * @param authInfo This value is already authorized by system.
80 | * @return Access token returns to client.
81 | */
82 | def createAccessToken(authInfo: AuthInfo[U]): Future[AccessToken]
83 |
84 | /**
85 | * Returns stored access token by authorized information.
86 | *
87 | * If want to create new access token then have to return None
88 | *
89 | * @param authInfo This value is already authorized by system.
90 | * @return Access token returns to client.
91 | */
92 | def getStoredAccessToken(authInfo: AuthInfo[U]): Future[Option[AccessToken]]
93 |
94 | /**
95 | * Creates a new access token by refreshToken.
96 | *
97 | * @param authInfo This value is already authorized by system.
98 | * @return Access token returns to client.
99 | */
100 | def refreshAccessToken(authInfo: AuthInfo[U], refreshToken: String): Future[AccessToken]
101 |
102 | /**
103 | * Find authorized information by authorization code.
104 | *
105 | * If you don't support Authorization Code Grant then doesn't need implementing.
106 | *
107 | * @param code Client send authorization code which is registered by system.
108 | * @return Return authorized information that matched the code.
109 | */
110 | def findAuthInfoByCode(code: String): Future[Option[AuthInfo[U]]]
111 |
112 | /**
113 | * Find authorized information by refresh token.
114 | *
115 | * If you don't support Refresh Token Grant then doesn't need implementing.
116 | *
117 | * @param refreshToken Client send refresh token which is created by system.
118 | * @return Return authorized information that matched the refresh token.
119 | */
120 | def findAuthInfoByRefreshToken(refreshToken: String): Future[Option[AuthInfo[U]]]
121 |
122 | /**
123 | * Find userId by clientId and clientSecret.
124 | *
125 | * If you don't support Client Credentials Grant then doesn't need implementing.
126 | *
127 | * @param clientId Client send this value which is registered by application.
128 | * @param clientSecret Client send this value which is registered by application.
129 | * @return Return user that matched both values.
130 | */
131 | def findClientUser(
132 | clientId: String, clientSecret: String, scope: Option[String]): Future[Option[U]]
133 |
134 | /**
135 | * Find AccessToken object by access token code.
136 | *
137 | * @param token Client send access token which is created by system.
138 | * @return Return access token that matched the token.
139 | */
140 | def findAccessToken(token: String): Future[Option[AccessToken]]
141 |
142 | /**
143 | * Find authorized information by access token.
144 | *
145 | * @param accessToken This value is AccessToken.
146 | * @return Return authorized information if the parameter is available.
147 | */
148 | def findAuthInfoByAccessToken(accessToken: AccessToken): Future[Option[AuthInfo[U]]]
149 |
150 | /**
151 | * Check expiration.
152 | *
153 | * @param accessToken accessToken
154 | * @return true if accessToken expired
155 | */
156 | def isAccessTokenExpired(accessToken: AccessToken): Boolean = {
157 | accessToken.expiresIn.exists { expiresIn =>
158 | val now = System.currentTimeMillis()
159 | accessToken.createdAt.getTime + expiresIn * 1000 <= now
160 | }
161 | }
162 | }
163 |
--------------------------------------------------------------------------------
/src/main/scala/com/twitter/finagle/oauth2/GrantHandler.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.util.Future
4 |
5 | sealed abstract class GrantHandler {
6 | def handle[U](
7 | request: Request.Authorization,
8 | dataHandler: DataHandler[U]
9 | ): Future[GrantResult]
10 |
11 | protected def issueAccessToken[U](
12 | dataHandler: DataHandler[U],
13 | authInfo: AuthInfo[U]
14 | ): Future[GrantResult] = for {
15 | tokenOption <- dataHandler.getStoredAccessToken(authInfo)
16 | token <- tokenOption match {
17 | case Some(t) if dataHandler.isAccessTokenExpired(t) =>
18 | val refreshToken = t.refreshToken map { dataHandler.refreshAccessToken(authInfo, _) }
19 | refreshToken.getOrElse(dataHandler.createAccessToken(authInfo))
20 | case Some(t) => Future.value(t)
21 | case None => dataHandler.createAccessToken(authInfo)
22 | }
23 | } yield GrantResult(
24 | "Bearer",
25 | token.token,
26 | token.expiresIn,
27 | token.refreshToken,
28 | token.scope
29 | )
30 | }
31 |
32 | object GrantHandler {
33 |
34 | def fromGrantType(grantType: String): Option[GrantHandler] = grantType match {
35 | case "authorization_code" => Some(AuthorizationCode)
36 | case "refresh_token" => Some(RefreshToken)
37 | case "client_credentials" => Some(ClientCredentials)
38 | case "password" => Some(Password)
39 | case _ => None
40 | }
41 |
42 | object RefreshToken extends GrantHandler {
43 | def handle[U](
44 | request: Request.Authorization,
45 | dataHandler: DataHandler[U]
46 | ): Future[GrantResult] = {
47 | val clientCredential = request.clientCredential match {
48 | case Some(c) => Future.value(c)
49 | case None => Future.exception(new InvalidRequest("BadRequest"))
50 | }
51 |
52 | val refreshToken = request.requireRefreshToken
53 |
54 | for {
55 | credential <- clientCredential
56 | infoOption <- dataHandler.findAuthInfoByRefreshToken(refreshToken)
57 | info <- infoOption match {
58 | case Some(i) =>
59 | if (i.clientId != credential.clientId) Future.exception(new InvalidClient())
60 | else Future.value(i)
61 | case None => Future.exception(new InvalidGrant("NotFound"))
62 | }
63 | token <- dataHandler.refreshAccessToken(info, refreshToken)
64 | } yield GrantResult(
65 | "Bearer",
66 | token.token,
67 | token.expiresIn,
68 | token.refreshToken,
69 | token.scope
70 | )
71 | }
72 | }
73 |
74 | object Password extends GrantHandler {
75 |
76 | def handle[U](
77 | request: Request.Authorization,
78 | dataHandler: DataHandler[U]
79 | ): Future[GrantResult] = {
80 | val clientCredential = request.clientCredential match {
81 | case Some(c) => Future.value(c)
82 | case None => Future.exception(new InvalidRequest("BadRequest"))
83 | }
84 |
85 | val username = request.requireUsername
86 | val password = request.requirePassword
87 | val scope = request.scope
88 |
89 | for {
90 | credential <- clientCredential
91 | userOption <- dataHandler.findUser(username, password)
92 | user <- userOption match {
93 | case Some(u) => Future.value(u)
94 | case None => Future.exception(new InvalidGrant())
95 | }
96 | token <- issueAccessToken(dataHandler, AuthInfo(user, credential.clientId, scope, None))
97 | } yield token
98 | }
99 | }
100 |
101 | object ClientCredentials extends GrantHandler {
102 |
103 | def handle[U](
104 | request: Request.Authorization,
105 | dataHandler: DataHandler[U]
106 | ): Future[GrantResult] = {
107 | val clientCredential = request.clientCredential match {
108 | case Some(c) => Future.value(c)
109 | case None => Future.exception(new InvalidRequest("BadRequest"))
110 | }
111 |
112 | val scope = request.scope
113 |
114 | for {
115 | credential <- clientCredential
116 | userOption <- dataHandler.findClientUser(credential.clientId, credential.clientSecret, scope)
117 | user <- userOption match {
118 | case Some(u) => Future.value(u)
119 | case None => Future.exception(new InvalidGrant())
120 | }
121 | token <- issueAccessToken(dataHandler, AuthInfo(user, credential.clientId, scope, None))
122 | } yield token
123 | }
124 | }
125 |
126 | object AuthorizationCode extends GrantHandler {
127 |
128 | def handle[U](
129 | request: Request.Authorization,
130 | dataHandler: DataHandler[U]
131 | ): Future[GrantResult] = {
132 | val clientCredential = request.clientCredential match {
133 | case Some(c) => Future.value(c)
134 | case None => Future.exception(new InvalidRequest("BadRequest"))
135 | }
136 |
137 | val code = request.requireCode
138 | val redirectUri = request.redirectUri
139 |
140 | for {
141 | credential <- clientCredential
142 | infoOption <- dataHandler.findAuthInfoByCode(code)
143 | info <- infoOption match {
144 | case Some(i) =>
145 | if (i.clientId != credential.clientId)
146 | Future.exception(new InvalidClient())
147 | else if (i.redirectUri.isDefined && i.redirectUri != redirectUri)
148 | Future.exception(new RedirectUriMismatch())
149 | else Future.value(i)
150 | case None => Future.exception(new InvalidGrant())
151 | }
152 | token <- issueAccessToken(dataHandler, info)
153 | } yield token
154 | }
155 | }
156 | }
157 |
--------------------------------------------------------------------------------
/src/main/scala/com/twitter/finagle/oauth2/GrantResult.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | final case class GrantResult(
4 | tokenType: String,
5 | accessToken: String,
6 | expiresIn: Option[Long],
7 | refreshToken: Option[String],
8 | scope: Option[String]
9 | )
10 |
11 |
--------------------------------------------------------------------------------
/src/main/scala/com/twitter/finagle/oauth2/OAuthError.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.finagle.http.{Response, Version, Status}
4 |
5 | abstract class OAuthError(val statusCode: Int, val description: String) extends Exception {
6 |
7 | def this(description: String) = this(400, description)
8 |
9 | def errorType: String
10 |
11 | final def toResponse: Response = {
12 | val bearer = Seq("error=\"" + errorType + "\"") ++
13 | (if (!description.isEmpty) Seq("error_description=\"" + description + "\"") else Nil)
14 |
15 | val rep = Response(Version.Http11, Status(statusCode))
16 | rep.headerMap.add("WWW-Authenticate", "Bearer " + bearer.mkString(", "))
17 |
18 | rep
19 | }
20 | }
21 |
22 | final class InvalidRequest(description: String = "") extends OAuthError(description) {
23 | def errorType: String = "invalid_request"
24 | }
25 |
26 | final class InvalidClient(description: String = "") extends OAuthError(401, description) {
27 | def errorType: String = "invalid_client"
28 | }
29 |
30 | final class UnauthorizedClient(description: String = "") extends OAuthError(401, description) {
31 | def errorType: String = "unauthorized_client"
32 | }
33 |
34 | final class RedirectUriMismatch(description: String = "") extends OAuthError(401, description) {
35 | def errorType: String = "redirect_uri_mismatch"
36 | }
37 |
38 | final class AccessDenied(description: String = "") extends OAuthError(401, description) {
39 | def errorType: String = "access_denied"
40 | }
41 |
42 | final class UnsupportedResponseType(description: String = "") extends OAuthError(description) {
43 | def errorType: String = "unsupported_response_type"
44 | }
45 |
46 | final class InvalidGrant(description: String = "") extends OAuthError(401, description) {
47 | def errorType: String = "invalid_grant"
48 | }
49 |
50 | final class UnsupportedGrantType(description: String = "") extends OAuthError(description) {
51 | def errorType: String = "unsupported_grant_type"
52 | }
53 |
54 | final class InvalidScope(description: String = "") extends OAuthError(401, description) {
55 | def errorType: String = "invalid_scope"
56 | }
57 |
58 | final class InvalidToken(description: String = "") extends OAuthError(401, description) {
59 | def errorType: String = "invalid_token"
60 | }
61 |
62 | final class ExpiredToken extends OAuthError(401, "The access token expired") {
63 | def errorType: String = "invalid_token"
64 | }
65 |
66 | final class InsufficientScope(description: String = "") extends OAuthError(401, description) {
67 | def errorType: String = "insufficient_scope"
68 | }
69 |
--------------------------------------------------------------------------------
/src/main/scala/com/twitter/finagle/oauth2/Request.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.finagle.http.{HeaderMap, ParamMap}
4 | import java.util.Base64
5 |
6 | sealed abstract class Request(headers: HeaderMap, params: ParamMap) {
7 |
8 | final def header(name: String): Option[String] =
9 | headers.get(name)
10 |
11 | final def requireHeader(name: String): String =
12 | header(name).getOrElse(throw new InvalidRequest("required header: " + name))
13 |
14 | final def param(name: String): Option[String] =
15 | params.get(name)
16 |
17 | final def requireParam(name: String): String =
18 | param(name).getOrElse(throw new InvalidRequest("required parameter: " + name))
19 | }
20 |
21 | object Request {
22 |
23 | private[this] def tryDecode(encoded: String): Option[String] = {
24 | try Some(new String(Base64.getMimeDecoder.decode(encoded), "UTF-8"))
25 | catch {
26 | case _: IllegalArgumentException => None
27 | }
28 | }
29 | private[this] val BasicAuthPattern = "(?i)basic.*".r.pattern
30 | private[this] val WwwAuthorizationPattern = """^\s*(OAuth|Bearer)\s+([^\s\,]*)""".r
31 |
32 | private[this] def isBasicAuthHeader(header: String): Boolean =
33 | BasicAuthPattern.matcher(header).matches
34 |
35 | final class ProtectedResource(
36 | headers: HeaderMap,
37 | params: ParamMap
38 | ) extends Request(headers, params) {
39 |
40 | def oauthToken: Option[String] = param("oauth_token")
41 | def accessToken: Option[String] = param("access_token")
42 | def authorizationToken: Option[String] = for {
43 | authorization <- header("Authorization")
44 | matcher <- WwwAuthorizationPattern.findFirstMatchIn(authorization)
45 | } yield matcher.group(2)
46 |
47 | def token: Option[String] =
48 | oauthToken.orElse(accessToken).orElse(authorizationToken)
49 | }
50 |
51 | final class Authorization(
52 | headers: HeaderMap,
53 | params: ParamMap
54 | ) extends Request(headers, params) {
55 |
56 | def grantType: Option[String] = param("grant_type")
57 | def clientId: Option[String] = param("client_id")
58 | def clientSecret: Option[String] = param("client_secret")
59 | def scope: Option[String] = param("scope")
60 | def redirectUri: Option[String] = param("redirect_uri")
61 | def requireCode: String = requireParam("code")
62 | def requireUsername: String = requireParam("username")
63 | def requirePassword: String = requireParam("password")
64 | def requireRefreshToken: String = requireParam("refresh_token")
65 |
66 | def clientCredential: Option[ClientCredential] = {
67 | header("Authorization") match {
68 | case Some(authHeader) if isBasicAuthHeader(authHeader) =>
69 | val credentials = authHeader.substring(6)
70 | tryDecode(credentials).map(_.split(":", 2)) flatMap {
71 | case Array(clientId, clientSecret) => Some(ClientCredential(clientId, clientSecret))
72 | case Array(clientId) if clientId.nonEmpty => Some(ClientCredential(clientId, ""))
73 | case other => None
74 | }
75 | case _ => clientId.map { clientId =>
76 | ClientCredential(clientId, clientSecret.getOrElse(""))
77 | }
78 | }
79 | }
80 | }
81 | }
82 |
83 |
84 |
--------------------------------------------------------------------------------
/src/test/scala/com/twitter/finagle/oauth2/AuthHeaderSpec.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.finagle.http.{HeaderMap, ParamMap}
4 | import org.scalatest._
5 | import org.scalatest.Matchers._
6 |
7 | class AuthHeaderSpec extends FlatSpec {
8 |
9 | def createRequest(authorization: Option[String]): Request.ProtectedResource = authorization match {
10 | case Some(s) =>
11 | new Request.ProtectedResource(HeaderMap("Authorization" -> s), ParamMap())
12 | case _ =>
13 | new Request.ProtectedResource(HeaderMap(), ParamMap())
14 | }
15 |
16 | it should "match AuthHeader" in {
17 | createRequest(Some("OAuth token1")).token shouldBe Some("token1")
18 | createRequest(Some("Bearer token1")).token shouldBe Some("token1")
19 | }
20 |
21 | it should "doesn't match AuthHeader" in {
22 | createRequest(None).token shouldBe None
23 | createRequest(Some("OAuth")).token shouldBe None
24 | createRequest(Some("OAtu token1")).token shouldBe None
25 | createRequest(Some("oauth token1")).token shouldBe None
26 | createRequest(Some("Bearer")).token shouldBe None
27 | createRequest(Some("Beare token1")).token shouldBe None
28 | createRequest(Some("bearer token1")).token shouldBe None
29 | }
30 |
31 | it should "fetch parameter from OAuth" in {
32 | val req = createRequest(Some("""OAuth access_token_value,algorithm="hmac-sha256",nonce="s8djwd",signature="wOJIO9A2W5mFwDgiDvZbTSMK%2FPY%3D",timestamp="137131200""""))
33 | req.token shouldBe Some("access_token_value")
34 | }
35 |
36 | it should "fetch parameter from Bearer" in {
37 | val req = createRequest(Some("""Bearer access_token_value,algorithm="hmac-sha256",nonce="s8djwd",signature="wOJIO9A2W5mFwDgiDvZbTSMK%2FPY%3D",timestamp="137131200""""))
38 | req.token shouldBe Some("access_token_value")
39 | }
40 | }
41 |
--------------------------------------------------------------------------------
/src/test/scala/com/twitter/finagle/oauth2/AuthorizationCodeSpec.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.finagle.http.{HeaderMap, ParamMap}
4 | import org.scalatest._
5 | import org.scalatest.Matchers._
6 | import com.twitter.util.{Await, Future}
7 |
8 | class AuthorizationCodeSpec extends FlatSpec {
9 |
10 | it should "handle request" in {
11 | val authorizationCode = GrantHandler.AuthorizationCode
12 |
13 | val request = new Request.Authorization(
14 | HeaderMap(),
15 | ParamMap(
16 | "code" -> "code1",
17 | "redirect_uri" -> "http://example.com/",
18 | "client_id" -> "clientId1",
19 | "client_secret" -> "clientSecret1"
20 | )
21 | )
22 |
23 | val grantHandlerResult = Await.result(authorizationCode.handle(request, new MockDataHandler() {
24 | override def findAuthInfoByCode(code: String): Future[Option[AuthInfo[MockUser]]] =
25 | Future.value(Some(AuthInfo(user = MockUser(10000, "username"), clientId = "clientId1", scope = Some("all"), redirectUri = Some("http://example.com/"))))
26 |
27 | override def createAccessToken(authInfo: AuthInfo[MockUser]): Future[AccessToken] =
28 | Future.value(AccessToken("token1", Some("refreshToken1"), Some("all"), Some(3600), new java.util.Date()))
29 | }))
30 | grantHandlerResult.tokenType should be ("Bearer")
31 | grantHandlerResult.accessToken should be ("token1")
32 | grantHandlerResult.expiresIn should be (Some(3600))
33 | grantHandlerResult.refreshToken should be (Some("refreshToken1"))
34 | grantHandlerResult.scope should be (Some("all"))
35 | }
36 |
37 | it should "handle request if redirectUrl is none" in {
38 | val authorizationCode = GrantHandler.AuthorizationCode
39 |
40 | val request = new Request.Authorization(
41 | HeaderMap(),
42 | ParamMap(
43 | "code" -> "code1",
44 | "client_id" -> "clientId1",
45 | "client_secret" -> "clientSecret1"
46 | )
47 | )
48 |
49 | val grantHandlerResult = Await.result(authorizationCode.handle(request, new MockDataHandler() {
50 | override def findAuthInfoByCode(code: String): Future[Option[AuthInfo[MockUser]]] =
51 | Future.value(Some(AuthInfo(user = MockUser(10000, "username"), clientId = "clientId1", scope = Some("all"), redirectUri = None)))
52 |
53 | override def createAccessToken(authInfo: AuthInfo[MockUser]): Future[AccessToken] =
54 | Future.value(AccessToken("token1", Some("refreshToken1"), Some("all"), Some(3600), new java.util.Date()))
55 | }))
56 | grantHandlerResult.tokenType should be ("Bearer")
57 | grantHandlerResult.accessToken should be ("token1")
58 | grantHandlerResult.expiresIn should be (Some(3600))
59 | grantHandlerResult.refreshToken should be (Some("refreshToken1"))
60 | grantHandlerResult.scope should be (Some("all"))
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/test/scala/com/twitter/finagle/oauth2/ClientCredentialFetcherSpec.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.finagle.http.{HeaderMap, ParamMap}
4 | import org.scalatest.FlatSpec
5 | import org.scalatest.Matchers._
6 |
7 | class ClientCredentialFetcherSpec extends FlatSpec {
8 |
9 | it should "fetch Basic64" in {
10 | val request = new Request.Authorization(
11 | HeaderMap("Authorization" -> "Basic Y2xpZW50X2lkX3ZhbHVlOmNsaWVudF9zZWNyZXRfdmFsdWU="),
12 | ParamMap()
13 | )
14 |
15 | val Some(c) = request.clientCredential
16 | c.clientId should be ("client_id_value")
17 | c.clientSecret should be ("client_secret_value")
18 | }
19 |
20 | it should "fetch Basic64 by case insensitive" in {
21 | val request = new Request.Authorization(
22 | HeaderMap("authorization" -> "Basic Y2xpZW50X2lkX3ZhbHVlOmNsaWVudF9zZWNyZXRfdmFsdWU="),
23 | ParamMap()
24 | )
25 |
26 | val Some(c) = request.clientCredential
27 | c.clientId should be ("client_id_value")
28 | c.clientSecret should be ("client_secret_value")
29 | }
30 |
31 | it should "fetch empty client_secret" in {
32 | val request = new Request.Authorization(
33 | HeaderMap("Authorization" -> "Basic Y2xpZW50X2lkX3ZhbHVlOg=="),
34 | ParamMap()
35 | )
36 |
37 | val Some(c) = request.clientCredential
38 | c.clientId should be ("client_id_value")
39 | c.clientSecret should be ("")
40 | }
41 |
42 | it should "not fetch no Authorization key in header" in {
43 | val request = new Request.Authorization(
44 | HeaderMap("authorizatio" -> "Basic Y2xpZW50X2lkX3ZhbHVlOmNsaWVudF9zZWNyZXRfdmFsdWU="),
45 | ParamMap()
46 | )
47 |
48 | request.clientCredential should be (None)
49 | }
50 |
51 | it should "not fetch invalidate Base64" in {
52 | val request = new Request.Authorization(
53 | HeaderMap("Authorization" -> "Basic basic"),
54 | ParamMap()
55 | )
56 |
57 | request.clientCredential should be (None)
58 | }
59 |
60 | it should "fetch parameter" in {
61 | val request = new Request.Authorization(
62 | HeaderMap(),
63 | ParamMap("client_id" -> "client_id_value", "client_secret" -> "client_secret_value")
64 | )
65 |
66 | val Some(c) = request.clientCredential
67 | c.clientId should be ("client_id_value")
68 | c.clientSecret should be ("client_secret_value")
69 | }
70 |
71 | it should "omit client_secret" in {
72 | val request = new Request.Authorization(
73 | HeaderMap(),
74 | ParamMap("client_id" -> "client_id_value")
75 | )
76 |
77 | val Some(c) = request.clientCredential
78 | c.clientId should be ("client_id_value")
79 | c.clientSecret should be ("")
80 | }
81 |
82 | it should "not fetch missing parameter" in {
83 | val request = new Request.Authorization(
84 | HeaderMap(),
85 | ParamMap("client_secret" -> "client_secret_value")
86 | )
87 |
88 | request.clientCredential should be (None)
89 | }
90 |
91 | it should "not fetch invalid parameter" in {
92 | val request = new Request.Authorization(HeaderMap("Authorization" -> ""), ParamMap())
93 | request.clientCredential should be (None)
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/test/scala/com/twitter/finagle/oauth2/ClientCredentialsSpec.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.finagle.http.{HeaderMap, ParamMap}
4 | import org.scalatest._
5 | import org.scalatest.Matchers._
6 | import com.twitter.util.{Await, Future}
7 |
8 | class ClientCredentialsSpec extends FlatSpec {
9 |
10 | it should "handle request" in {
11 | val clientCredentials = GrantHandler.ClientCredentials
12 |
13 | val request = new Request.Authorization(
14 | HeaderMap(),
15 | ParamMap(
16 | "scope" -> "all",
17 | "client_id" -> "clientId1",
18 | "client_secret" -> "clientSecret1"
19 | )
20 | )
21 |
22 | val grantHandlerResult = Await.result(clientCredentials.handle(request, new MockDataHandler() {
23 | override def findClientUser(clientId: String, clientSecret: String, scope: Option[String]): Future[Option[MockUser]] =
24 | Future.value(Some(MockUser(10000, "username")))
25 |
26 | override def createAccessToken(authInfo: AuthInfo[MockUser]): Future[AccessToken] =
27 | Future.value(AccessToken("token1", None, Some("all"), Some(3600), new java.util.Date()))
28 | }))
29 |
30 | grantHandlerResult.tokenType should be ("Bearer")
31 | grantHandlerResult.accessToken should be ("token1")
32 | grantHandlerResult.expiresIn should be (Some(3600))
33 | grantHandlerResult.refreshToken should be (None)
34 | grantHandlerResult.scope should be (Some("all"))
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/src/test/scala/com/twitter/finagle/oauth2/EndToEndSpec.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.finagle.OAuth2
4 | import com.twitter.finagle.http.{HeaderMap, ParamMap}
5 | import com.twitter.util.{Await, Future}
6 | import java.util.Date
7 | import org.scalatest.FlatSpec
8 | import org.scalatest.Matchers._
9 |
10 | class EndToEndSpec extends FlatSpec {
11 |
12 | def successfulAccessTokenHandler(): MockDataHandler = new MockDataHandler() {
13 | override def validateClient(clientId: String, clientSecret: String, grantType: String): Future[Boolean] =
14 | Future.value(true)
15 |
16 | override def findUser(username: String, password: String): Future[Option[MockUser]] =
17 | Future.value(Some(MockUser(10000, "username")))
18 |
19 | override def createAccessToken(authInfo: AuthInfo[MockUser]): Future[AccessToken] =
20 | Future.value(AccessToken("token1", None, Some("all"), Some(3600), new Date()))
21 | }
22 |
23 | def successfulAuthorizeDataHandler(): MockDataHandler = new MockDataHandler() {
24 | override def findAccessToken(token: String): Future[Option[AccessToken]] =
25 | Future.value(Some(AccessToken("token1", Some("refreshToken1"), Some("all"), Some(3600), new Date())))
26 |
27 | override def findAuthInfoByAccessToken(accessToken: AccessToken): Future[Option[AuthInfo[MockUser]]] =
28 | Future.value(Some(AuthInfo(user = MockUser(10000, "username"), clientId = "clientId1", scope = Some("all"), redirectUri = None)))
29 | }
30 |
31 | it should "be handled request with token into header" in {
32 | val request = new Request.ProtectedResource(
33 | HeaderMap("Authorization" -> "OAuth token1"),
34 | ParamMap("username" -> "user", "password" -> "pass", "scope" -> "all")
35 | )
36 |
37 | val dataHandler = successfulAuthorizeDataHandler()
38 | Await.result(OAuth2.authorize(request, dataHandler)) should not be (null)
39 | }
40 |
41 | it should "be handled request with token into body" in {
42 | val request = new Request.ProtectedResource(
43 | HeaderMap(),
44 | ParamMap("access_token" -> "token1", "username" -> "user", "password" -> "pass", "scope" -> "all")
45 | )
46 |
47 | val dataHandler = successfulAuthorizeDataHandler()
48 | Await.result(OAuth2.authorize(request, dataHandler)) should not be (null)
49 | }
50 |
51 | it should "be lost expired" in {
52 | val request = new Request.ProtectedResource(
53 | HeaderMap("Authorization" -> "OAuth token1"),
54 | ParamMap("username" -> "user", "password" -> "pass", "scope" -> "all")
55 | )
56 |
57 | val dataHandler = new MockDataHandler() {
58 | override def findAccessToken(token: String): Future[Option[AccessToken]] =
59 | Future.value(Some(AccessToken("token1", Some("refreshToken1"), Some("all"), Some(3600), new Date(new Date().getTime() - 4000 * 1000))))
60 |
61 | override def findAuthInfoByAccessToken(accessToken: AccessToken): Future[Option[AuthInfo[MockUser]]] =
62 | Future.value(Some(AuthInfo(user = MockUser(10000, "username"), clientId = "clientId1", scope = Some("all"), redirectUri = None)))
63 |
64 | }
65 |
66 | intercept[ExpiredToken] {
67 | Await.result(OAuth2.authorize(request, dataHandler))
68 | }
69 | }
70 |
71 | it should "be invalid request without token" in {
72 | val request = new Request.ProtectedResource(
73 | HeaderMap(),
74 | ParamMap("username" -> "user", "password" -> "pass", "scope" -> "all")
75 | )
76 |
77 | val dataHandler = successfulAuthorizeDataHandler()
78 | intercept[InvalidRequest] {
79 | Await.result(OAuth2.authorize(request, dataHandler))
80 | }
81 | }
82 |
83 | it should "be handled request" in {
84 | val request = new Request.Authorization(
85 | HeaderMap("Authorization" -> "Basic Y2xpZW50X2lkX3ZhbHVlOmNsaWVudF9zZWNyZXRfdmFsdWU="),
86 | ParamMap("grant_type" -> "password", "username" -> "user", "password" -> "pass", "scope" -> "all")
87 | )
88 |
89 | val dataHandler = successfulAccessTokenHandler()
90 | Await.result(OAuth2.issueAccessToken(request, dataHandler)) should not be (null)
91 | }
92 |
93 | it should "be error if grant type doesn't exist" in {
94 | val request = new Request.Authorization(
95 | HeaderMap("Authorization" -> "Basic Y2xpZW50X2lkX3ZhbHVlOmNsaWVudF9zZWNyZXRfdmFsdWU="),
96 | ParamMap("username" -> "user", "password" -> "pass", "scope" -> "all")
97 | )
98 |
99 | val dataHandler = successfulAccessTokenHandler()
100 | intercept[InvalidRequest] {
101 | Await.result(OAuth2.issueAccessToken(request, dataHandler))
102 | }
103 | }
104 |
105 | it should "be error if grant type is wrong" in {
106 | val request = new Request.Authorization(
107 | HeaderMap("Authorization" -> "Basic Y2xpZW50X2lkX3ZhbHVlOmNsaWVudF9zZWNyZXRfdmFsdWU="),
108 | ParamMap("grant_type" -> "test", "username" -> "user", "password" -> "pass", "scope" -> "all")
109 | )
110 |
111 | val dataHandler = successfulAccessTokenHandler()
112 | intercept[UnsupportedGrantType] {
113 | Await.result(OAuth2.issueAccessToken(request, dataHandler))
114 | }
115 | }
116 |
117 | it should "be invalid request without client credential" in {
118 | val request = new Request.Authorization(
119 | HeaderMap(),
120 | ParamMap("grant_type" -> "password", "username" -> "user", "password" -> "pass", "scope" -> "all")
121 | )
122 |
123 | val dataHandler = successfulAccessTokenHandler()
124 | intercept[InvalidRequest] {
125 | Await.result(OAuth2.issueAccessToken(request, dataHandler))
126 | }
127 | }
128 |
129 | it should "be invalid client if client information is wrong" in {
130 | val request = new Request.Authorization(
131 | HeaderMap("Authorization" -> "Basic Y2xpZW50X2lkX3ZhbHVlOmNsaWVudF9zZWNyZXRfdmFsdWU="),
132 | ParamMap("grant_type" -> "password", "username" -> "user", "password" -> "pass", "scope" -> "all")
133 | )
134 |
135 | val dataHandler = new MockDataHandler() {
136 | override def validateClient(clientId: String, clientSecret: String, grantType: String): Future[Boolean] =
137 | Future.value(false)
138 | }
139 |
140 | intercept[InvalidClient] {
141 | Await.result(OAuth2.issueAccessToken(request, dataHandler))
142 | }
143 | }
144 | }
145 |
--------------------------------------------------------------------------------
/src/test/scala/com/twitter/finagle/oauth2/MockDataHandler.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import java.util.Date
4 | import com.twitter.util.Future
5 |
6 | case class MockUser(id: Long, name: String)
7 |
8 | class MockDataHandler extends DataHandler[MockUser] {
9 |
10 | def validateClient(clientId: String, clientSecret: String, grantType: String): Future[Boolean] =
11 | Future.value(false)
12 |
13 | def findUser(username: String, password: String): Future[Option[MockUser]] =
14 | Future.value(None)
15 |
16 | def createAccessToken(authInfo: AuthInfo[MockUser]): Future[AccessToken] =
17 | Future.value(AccessToken("", Some(""), Some(""), Some(0L), new Date()))
18 |
19 | def findAuthInfoByCode(code: String): Future[Option[AuthInfo[MockUser]]] =
20 | Future.value(None)
21 |
22 | def findAuthInfoByRefreshToken(refreshToken: String): Future[Option[AuthInfo[MockUser]]] =
23 | Future.value(None)
24 |
25 | def findClientUser(clientId: String, clientSecret: String, scope: Option[String]): Future[Option[MockUser]] =
26 | Future.value(None)
27 |
28 | def findAccessToken(token: String): Future[Option[AccessToken]] =
29 | Future.value(None)
30 |
31 | def findAuthInfoByAccessToken(accessToken: AccessToken): Future[Option[AuthInfo[MockUser]]] =
32 | Future.value(None)
33 |
34 | def getStoredAccessToken(authInfo: AuthInfo[MockUser]): Future[Option[AccessToken]] =
35 | Future.value(None)
36 |
37 | def refreshAccessToken(authInfo: AuthInfo[MockUser], refreshToken: String): Future[AccessToken] =
38 | Future.value(AccessToken("", Some(""), Some(""), Some(0L), new Date()))
39 | }
40 |
--------------------------------------------------------------------------------
/src/test/scala/com/twitter/finagle/oauth2/PasswordSpec.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.finagle.http.{HeaderMap, ParamMap}
4 | import org.scalatest._
5 | import org.scalatest.Matchers._
6 | import com.twitter.util.{Await, Future}
7 |
8 | class PasswordSpec extends FlatSpec {
9 |
10 | it should "handle request" in {
11 | val password = GrantHandler.Password
12 |
13 | val request = new Request.Authorization(
14 | HeaderMap(),
15 | ParamMap(
16 | "username" -> "user",
17 | "password" -> "pass",
18 | "scope" -> "all",
19 | "client_id" -> "clientId1",
20 | "client_secret" -> "clientSecret1"
21 | )
22 | )
23 |
24 | val grantHandlerResult = Await.result(password.handle(request, new MockDataHandler() {
25 | override def findUser(username: String, password: String): Future[Option[MockUser]] =
26 | Future.value(Some(MockUser(10000, "username")))
27 |
28 | override def createAccessToken(authInfo: AuthInfo[MockUser]): Future[AccessToken] =
29 | Future.value(AccessToken("token1", Some("refreshToken1"), Some("all"), Some(3600), new java.util.Date()))
30 | }))
31 |
32 | grantHandlerResult.tokenType should be ("Bearer")
33 | grantHandlerResult.accessToken should be ("token1")
34 | grantHandlerResult.expiresIn should be (Some(3600))
35 | grantHandlerResult.refreshToken should be (Some("refreshToken1"))
36 | grantHandlerResult.scope should be (Some("all"))
37 | }
38 | }
39 |
--------------------------------------------------------------------------------
/src/test/scala/com/twitter/finagle/oauth2/RefreshTokenSpec.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.finagle.http.{HeaderMap, ParamMap}
4 | import org.scalatest.FlatSpec
5 | import org.scalatest.Matchers._
6 | import com.twitter.util.{Await, Future}
7 |
8 | class RefreshTokenSpec extends FlatSpec {
9 |
10 | it should "handle request" in {
11 | val refreshToken = GrantHandler.RefreshToken
12 |
13 | val request = new Request.Authorization(
14 | HeaderMap(),
15 | ParamMap(
16 | "refresh_token" -> "refreshToken1",
17 | "client_id" -> "clientId1",
18 | "client_secret" -> "clientSecret1"
19 | )
20 | )
21 |
22 | val grantHandlerResult = Await.result(refreshToken.handle(request, new MockDataHandler() {
23 | override def findAuthInfoByRefreshToken(refreshToken: String): Future[Option[AuthInfo[MockUser]]] =
24 | Future.value(Some(AuthInfo(user = MockUser(10000, "username"),
25 | clientId = "clientId1", scope = None, redirectUri = None)))
26 |
27 | override def refreshAccessToken(authInfo: AuthInfo[MockUser], refreshToken: String): Future[AccessToken] =
28 | Future.value(AccessToken("token1", Some(refreshToken), None, Some(3600), new java.util.Date()))
29 | }))
30 |
31 | grantHandlerResult.tokenType should be ("Bearer")
32 | grantHandlerResult.accessToken should be ("token1")
33 | grantHandlerResult.expiresIn should be (Some(3600))
34 | grantHandlerResult.refreshToken should be (Some("refreshToken1"))
35 | grantHandlerResult.scope should be (None)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/src/test/scala/com/twitter/finagle/oauth2/RequestParameterSpec.scala:
--------------------------------------------------------------------------------
1 | package com.twitter.finagle.oauth2
2 |
3 | import com.twitter.finagle.http.{HeaderMap, ParamMap}
4 | import org.scalatest.Matchers._
5 | import org.scalatest.FlatSpec
6 |
7 | class RequestParameterSpec extends FlatSpec {
8 |
9 | def createRequest(
10 | oauthToken: Option[String],
11 | accessToken: Option[String],
12 | another: Seq[(String, String)] = Seq.empty
13 | ): Request.ProtectedResource = {
14 | val params =
15 | oauthToken.fold(Seq.empty[(String, String)])(t => Seq("oauth_token" -> t)) ++
16 | accessToken.fold(Seq.empty[(String, String)])(t => Seq("access_token" -> t)) ++
17 | another
18 |
19 | new Request.ProtectedResource(HeaderMap(), ParamMap(params: _*))
20 | }
21 |
22 | it should "match RequestParameter" in {
23 | createRequest(Some("token1"), None).token shouldBe Some("token1")
24 | createRequest(None, Some("token2")).token shouldBe Some("token2")
25 | createRequest(Some("token1"), Some("token2")).token shouldBe Some("token1")
26 | }
27 |
28 | it should "doesn't match RequestParameter" in {
29 | createRequest(None, None).token shouldBe None
30 | }
31 | }
32 |
--------------------------------------------------------------------------------
/version.sbt:
--------------------------------------------------------------------------------
1 | version in ThisBuild := "19.9.0-SNAPSHOT"
2 |
--------------------------------------------------------------------------------