├── .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 | [![Build Status](https://img.shields.io/travis/finagle/finagle-oauth2/master.svg)](https://travis-ci.org/finagle/finagle-oauth2) 2 | [![Maven Central](https://img.shields.io/maven-central/v/com.github.finagle/finagle-oauth2_2.12.svg)](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 | * 19 | * 20 | *

Refresh Token Grant

21 | * 26 | * 27 | *

Resource Owner Password Credentials Grant

28 | * 36 | * 37 | *

Client Credentials Grant

38 | * 46 | * 47 | *

[Access to Protected Resource phase]

48 | * 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 | --------------------------------------------------------------------------------