Skip to content

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse files

Import from open source

  • Loading branch information...
commit 98c6882f0f9827923a1ecdd8bdabe183066a8949 1 parent b47df8f
Steven Liu xsl authored
Showing with 3,919 additions and 0 deletions.
  1. +10 −0 .gitignore
  2. +1 −0  GROUPS
  3. +202 −0 LICENSE
  4. +3 −0  OWNERS
  5. +213 −0 README.markdown
  6. +180 −0 README.md
  7. +210 −0 pom.xml
  8. +8 −0 project/build.properties
  9. +16 −0 project/build.properties.BACKUP.16626.properties
  10. +8 −0 project/build.properties.LOCAL.16626.properties
  11. +8 −0 project/build.properties.REMOTE.16626.properties
  12. +24 −0 project/build/Project.scala
  13. +25 −0 project/plugins/Plugins.scala
  14. +4 −0 project/release.properties
  15. +6 −0 project/versions.properties
  16. +20 −0 src/main/java/com/twitter/joauth/VerifierResult.java
  17. +119 −0 src/main/scala/com/twitter/joauth/Base64Util.scala
  18. +40 −0 src/main/scala/com/twitter/joauth/NonceValidator.scala
  19. +183 −0 src/main/scala/com/twitter/joauth/Normalizer.scala
  20. +254 −0 src/main/scala/com/twitter/joauth/OAuthParams.scala
  21. +42 −0 src/main/scala/com/twitter/joauth/Request.scala
  22. +82 −0 src/main/scala/com/twitter/joauth/Signer.scala
  23. +146 −0 src/main/scala/com/twitter/joauth/UnpackedRequest.scala
  24. +222 −0 src/main/scala/com/twitter/joauth/Unpacker.scala
  25. +25 −0 src/main/scala/com/twitter/joauth/UnpackerException.scala
  26. +123 −0 src/main/scala/com/twitter/joauth/UrlEncoder.scala
  27. +90 −0 src/main/scala/com/twitter/joauth/Verifier.scala
  28. +142 −0 src/main/scala/com/twitter/joauth/keyvalue/KeyValueHandler.scala
  29. +66 −0 src/main/scala/com/twitter/joauth/keyvalue/KeyValueParser.scala
  30. +38 −0 src/main/scala/com/twitter/joauth/keyvalue/Transformer.scala
  31. +56 −0 src/test/scala/com/twitter/joauth/NormalizerSpec.scala
  32. +182 −0 src/test/scala/com/twitter/joauth/OAuth1ParamsSpec.scala
  33. +73 −0 src/test/scala/com/twitter/joauth/OAuth1RequestSpec.scala
  34. +38 −0 src/test/scala/com/twitter/joauth/SignerSpec.scala
  35. +211 −0 src/test/scala/com/twitter/joauth/UnpackerSpec.scala
  36. +121 −0 src/test/scala/com/twitter/joauth/VerifierSpec.scala
  37. +73 −0 src/test/scala/com/twitter/joauth/keyvalue/KeyValueHandlerSpec.scala
  38. +55 −0 src/test/scala/com/twitter/joauth/keyvalue/KeyValueParserSpec.scala
  39. +43 −0 src/test/scala/com/twitter/joauth/testhelpers/MockRequest.scala
  40. +100 −0 src/test/scala/com/twitter/joauth/testhelpers/MockRequestFactory.scala
  41. +425 −0 src/test/scala/com/twitter/joauth/testhelpers/OAuth1TestCase.scala
  42. +32 −0 src/test/scala/com/twitter/joauth/testhelpers/ParamHelper.scala
10 .gitignore
View
@@ -0,0 +1,10 @@
+target/
+dist/
+project/boot/
+project/plugins/project/
+project/plugins/src_managed/
+*.log
+*.tmproj
+lib_managed/
+*.iml
+.idea
1  GROUPS
View
@@ -0,0 +1 @@
+api-services
202 LICENSE
View
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
3  OWNERS
View
@@ -0,0 +1,3 @@
+koliver
+kwuollett
+steven
213 README.markdown
View
@@ -0,0 +1,213 @@
+# JOAuth [![Build Status](https://travis-ci.org/twitter/joauth.png?branch=master)](https://travis-ci.org/twitter/joauth)
+
+A Scala/JVM library for authenticating HTTP Requests using OAuth
+
+## Features
+
+* Supports OAuth 1.0a and 2.0 (draft 25)
+* Unpacks Requests, extracts and verifies OAuth parameters from headers, GET, and POST
+* Incidentally parses Non-OAuth GET and POST parameters and makes them accessible via a callback
+* Custom callbacks to obtain scheme and path from the request in a non-standard way
+* Configurable timestamp checking
+* Correctly works around various weird URLEncoder bugs in the JVM
+* Written in Scala, but should work pretty well with Java
+
+The Github source repository is [here](http://github.com/9len/joauth/). Patches and contributions are welcome.
+
+## Non-Features
+
+* It's not a full OAuth solution; There's nothing here about creating request tokens, access token/secret pairs, or consumer key/secret pairs. This library is primarily for verifying (and potentially signing) requests.
+* There's no framework for looking up access token/secret pairs and consumer key/secret pairs from a backing store. You're on your own there.
+* There's no Nonce-validation, though there's support for adding your own.
+
+## Building
+
+**v.1.1.2 is the last version that can be built using scala 2.7.7, and now resides in the scala27 branch. v1.2 and above require scala > 2.8.1. v3.0.1 and above uses maven instead of sbt, and require scala 2.9.2 **
+
+*Dependencies*: servlet-api, commons-codec, and util-core (specs & mockito-all to run the tests). These dependencies are managed by the build system.
+
+Below v3.0.1 - Use sbt (simple-build-tool) to build:
+
+ % sbt clean update compile
+
+The finished jar will be in `dist/`.
+
+v3.0.1 and higher - Use maven to build:
+
+ % mvn clean install
+
+## Understanding the Implementation
+
+JOAuth consists of five traits, each of which is invoked with an apply method.
+
+* The OAuthRequest trait models the data needed to validate a request. There are two concrete subclasses, OAuth1Request and OAuth2Request. The deprecated OAuth2d11Request class implements OAuth2 Draft 11.
+* The Unpacker trait unpacks the HttpServletRequest into an OAuthRequest, which models the data needed to validate the request
+* The Normalizer trait produces a normalized String representation of the request, used for signature calculation
+* The Signer trait signs a String, using the OAuth token secret and consumer secret.
+* The Verifier trait verifies that a OAuth1Request is valid, checking the timestamp, nonce, and signature
+
+There are "Standard" and "Const" implementations of the Unpacker, Normalizer, Signer, and the Verifier traits, for easy dependency injection. Each trait has a companion object with apply methods for the default instantiation of the corresponding Standard implementations.
+
+## Usage
+
+### Basic Usage
+
+Create an unpacker, and use it to unpack the Request. The Unpacker will either return an OAuth1Request or OAuth2Request object or throw an UnpackerException.
+
+ import com.twitter.joauth.Unpacker
+
+ val unpack = Unpacker()
+ try {
+ unpack(request) match {
+ case req: OAuth1Request => handleOAuth1(req)
+ case req: OAuth2Request => handleOAuth2(req)
+ case _ => // ignore or throw
+ }
+ } catch {
+ case e:UnpackerException => // handle or rethrow
+ case _ => // handle or rethrow
+ }
+
+**WARNING**: The StandardUnpacker will call the HttpRequest.getInputStream method if the method of the request is POST and the Content-Type is "application/x-www-form-urlencoded." *If you need to read the POST yourself, this will cause you problems, since getInputStream/getReader can only be called once.* There are two solutions: (1) Write an HttpServletWrapper to buffer the POST data and allow multiple calls to getInputStream, and pass the HttpServletWrapper into the Unpacker. (2) Pass a KeyValueHandler into the unpacker call (See "Getting Parameter Key/Values" below for more), which will let you get the parameters in the POST as a side effect of unpacking.
+
+Once the request is unpacked, the credentials need to be validated. For an OAuth2Request, the OAuth Access Token must be retrieved and validated by your authentication service. For an OAuth1Request the Access Token, the Consumer Key, and their respective secrets must be retrieved, and then passed to the Verifier for validation.
+
+ import com.twitter.joauth.{Verifier, VerifierResult}
+
+ val verify = Verifier()
+ verify(oAuth1Request, tokenSecret, consumerSecret) match {
+ case VerifierResult.BAD_NONCE => // handle bad nonce
+ case VerifierResult.BAD_SIGNATURE => // handle bad signature
+ case VerifierResult.BAD_TIMESTAMP => // handle bad timestamp
+ case VerifierResult.OK => //success!
+ }
+
+That's it!
+
+### Advanced Usage
+
+#### Overriding Path and Scheme
+
+If you're building an internal authentication service, it may serve multiple endpoints, and need to calculate signatures for all of them. it may also live on a server hosting multiple services on the same port, in which case you'll need a specific endpoint for your authentication service, while simultaneously needing to validate requests as if they had their original endpoints. You can accommodate this by passing in a method for extracting the path from the HttpServletRequest, via the PathGetter trait.
+
+For example, if you have an authentication service that responded to the /auth endpoint, and you are authenticating requests to an external server serving the /foo endpoint, the path of the request the authentication service receives is /auth/foo. This won't do, because the signature of the request depends on the path being /foo. We can construct a PathGetter that strips /auth out of the path.
+
+ import com.twitter.joauth.PathGetter
+
+ object MyPathGetter extends PathGetter {
+ def apply(request: Request): String = {
+ request.getPathInfo.match {
+ case "^/auth/(/*)$".r(realPath) => realPath
+ case => // up to you whether to return path or throw here, depends on your circumstances
+ }
+ }
+ }
+
+If you're running a high throughput authentication service and only using OAuth1, you might want to avoid using SSL, and listen only for HTTP. Unfortunately, the URI Scheme is part of the signature as well, so you need a way to force the Unpacker to treat the request as HTTPS, even though it isn't. One approach would be for your authentication service to take a custom header to indicate the scheme of the originating request. You can then use the UrlSchemeGetter trait to pull this header out of the request.
+
+ import com.twitter.joauth.UriSchemeGetter
+
+ object MySchemeGetter extends UrlSchemeGetter {
+ def apply(request; Request): String = {
+ val header = request.getHeader("X-My-Scheme-Header")
+ if (header == null) request.getScheme
+ else header.toUpperCase
+ }
+ }
+
+You can now construct your unpacker with your Getters.
+
+ val unpack = Unpacker(new MySchemeGetter, new MyPathGetter)
+
+if you only want to pass in one or the other, you can use the StandardSchemeGetter and StandardPathGetter classes when calling the two-argument Unpacker.apply.
+
+#### Getting Parameter Key/Values
+
+There are two apply methods in the Unpacker trait. The one-argument version takes an HttpRequestServlet, and the two-argument version takes an HttpRequestServlet and a Seq[KeyValueHandler]. A KeyValueHandler is a simple trait that the Unpacker uses as a callback for every Key/Value pair encountered in either the query string or POST data (if the Content-Type is application/x-www-form-urlencoded). If there are duplicate keys, the KeyValueHandler will get invoked for each.
+
+The JOAuth library provides a few basic KeyValueHandlers, and it's easy to add your own. For example, suppose you want to get a list of key/values, including duplicates, from the request you're unpacking. You can do this by passing a DuplicateKeyValueHandler to the unpacker.
+
+ val unpack = Unpacker()
+ val handler = new DuplicateKeyValueHandler
+ val unpackedRequest = unpack(request, Seq(handler))
+ doSomethingWith(handler.toList)
+
+The DuplicateKeyValueHandler is invoked for each key/value pair encountered, and a List[(String, String)] can be extracted afterwards.
+
+You can also construct your own KeyValueHandlers, and there are a few useful KeyValueHandlers already defined. There's a FilteredKeyValueHandler, which wraps an underlying KeyValueHandler so that it is invoked only when certain key/values are encountered. There's a TransformingKeyValueHandler, which wraps an underlying KeyValueHandler such that either the key or value or both are transformed before the underlying handler is invoked.
+
+For example, suppose you want to get all values of a single parameter, and you want it UrlDecoded first.
+
+ class ValuesOnlyKeyValueHandler extends KeyValueHandler {
+ val buffer = new ArrayBuffer[String]
+ def apply(k: String, v: String) = buffer += v
+ def toList = buffer.toList
+ }
+
+ object UrlDecodingTransformer extends Transformer {
+ def apply(str: String) = URLDecoder.decode(str)
+ }
+
+ object MyFilter extends KeyValueFilter {
+ def apply(k: String, v: String) = k == "SpecialKey"
+ }
+
+ class UrlDecodedMyValueOnlyKeyValueHandler(underlying: KeyValueHandler)
+ extends FilteredKeyValueHandler(
+ MyFilter,
+ new TransformingKeyValueHandler(UrlDecodingTransformer, underlying)
+
+ val unpack = Unpacker()
+ val handler = new ValuesOnlyKeyValueHandler
+ val wrappedHandler = UrlDecodedMyValueOnlyKeyValueHandler(handler)
+ val unpackedRequest = unpack(request, Seq(wrappedHandler))
+ doSomethingWith(handler.toList)
+
+Obviously it would be a little easier to just call request.getParameterValues("SpecialKey") in this example, but we hope it's not hard to see that passing custom KeyValueHandlers into the unpacker can be a powerful tool. In particular, they're an easy way to get access to POST data after the Unpacker has ruined your HttpServletRequest by calling getInputStream.
+
+KeyValueHandlers are used in the JOAuth source code to collect OAuth and non-OAuth parameters from the GET, POST and Authorization header.
+
+#### Other Unpacker Tricks
+
+You can pass in a custom Normalizer or custom parameter and header KeyValueParsers to the Unpacker apply method if you really want to, but you're on your own.
+
+#### Using Normalizer and Signer
+
+You can use the Normalizer and Signer to sign OAuth 1.0a requests.
+
+ val normalize = Normalizer()
+ val sign = Signer()
+
+ val normalizedRequest = normalize(scheme, host, port, verb, path, params, oAuthParams)
+ val signedRequest = sign(normalizedRequest, tokenSecret, consumerSecret)
+
+The parameters are passed as a List[(String, String)], and the OAuth params are passed in an OAuthParams instance.
+
+## Running Tests
+
+The tests are completely self contained, and can be run using Maven:
+
+ % mvn test
+
+## Reporting problems
+
+The Github issue tracker is [here](http://github.com/9len/joauth/issues).
+
+## Contributors
+
+* Jeremy Cloud
+* Tina Huang
+* Steve Jenson
+* Nick Kallen
+* John Kalucki
+* Raffi Krikorian
+* Mark McBride
+* Marcel Molina
+* Glen Sanford
+* Fiaz Hossain
+
+## License
+
+Copyright 2010-2013 Twitter, Inc.
+
+Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0
180 README.md
View
@@ -0,0 +1,180 @@
+# JOAuth [![Build Status](https://travis-ci.org/twitter/joauth.png?branch=master)](https://travis-ci.org/twitter/joauth)
+
+A Scala/JVM library for authenticating HTTP Requests using OAuth
+
+## Features
+
+* Supports OAuth 1.0a and 2.0 (draft 25)
+* Unpacks Requests, extracts and verifies OAuth parameters from headers, GET, and POST
+* Incidentally parses Non-OAuth GET and POST parameters and makes them accessible via a callback
+* Custom callbacks to obtain scheme and path from the request in a non-standard way
+* Configurable timestamp checking
+* Correctly works around various weird URLEncoder bugs in the JVM
+* Written in Scala, with Java bindings
+
+The Github source repository is [here](http://github.com/9len/joauth/). Patches and contributions are welcome.
+
+## Non-Features
+
+* It's not a full OAuth solution; There's nothing here about creating request tokens, access token/secret pairs, or consumer key/secret pairs. This library is primarily for verifying (and potentially signing) requests.
+* There's no framework for looking up access token/secret pairs and consumer key/secret pairs from a backing store. You're on your own there.
+* There's no Nonce-validation, though there's support for adding your own.
+
+## Building
+
+*Dependencies*: commons-codec (specs & mockito-all to run the tests). These dependencies are managed by the build system.
+
+v3.0.1 and higher - Use maven to build:
+
+ % mvn clean install
+
+**v.1.1.2 is the last version that can be built using scala 2.7.7, and now resides in the scala27 branch. v1.2 and above require scala > 2.8.1. v3.0.1 and above uses maven instead of sbt, and require scala 2.9.2 **
+
+Below v3.0.1 - Use sbt (simple-build-tool) to build:
+
+ % sbt clean update compile
+
+The finished jar will be in `dist/`.
+
+## Understanding the Implementation
+
+JOAuth consists of five traits, each of which is invoked with an apply method.
+
+* The OAuthRequest trait models the data needed to validate a request. There are two concrete subclasses, OAuth1Request and OAuth2Request. The deprecated OAuth2d11Request class implements OAuth2 Draft 11.
+* The Unpacker trait unpacks the HttpServletRequest into an OAuthRequest, which models the data needed to validate the request
+* The Normalizer trait produces a normalized String representation of the request, used for signature calculation
+* The Signer trait signs a String, using the OAuth token secret and consumer secret.
+* The Verifier trait verifies that a OAuth1Request is valid, checking the timestamp, nonce, and signature
+
+There are "Standard" and "Const" implementations of the Unpacker, Normalizer, Signer, and the Verifier traits, for easy dependency injection. Each trait has a companion object with apply methods for the default instantiation of the corresponding Standard implementations.
+
+## Usage
+
+### Basic Usage
+
+Create an unpacker, and use it to unpack the Request. The Unpacker will either return an OAuth1Request or OAuth2Request object or throw an UnpackerException.
+
+ import com.twitter.joauth.Unpacker
+
+ val unpack = Unpacker()
+ try {
+ unpack(request) match {
+ case req: OAuth1Request => handleOAuth1(req)
+ case req: OAuth2Request => handleOAuth2(req)
+ case _ => // ignore or throw
+ }
+ } catch {
+ case e:UnpackerException => // handle or rethrow
+ case _ => // handle or rethrow
+ }
+
+**WARNING**: The StandardUnpacker will call the HttpRequest.getInputStream method if the method of the request is POST and the Content-Type is "application/x-www-form-urlencoded." *If you need to read the POST yourself, this will cause you problems, since getInputStream/getReader can only be called once.* There are two solutions: (1) Write an HttpServletWrapper to buffer the POST data and allow multiple calls to getInputStream, and pass the HttpServletWrapper into the Unpacker. (2) Pass a KeyValueHandler into the unpacker call (See "Getting Parameter Key/Values" below for more), which will let you get the parameters in the POST as a side effect of unpacking.
+
+Once the request is unpacked, the credentials need to be validated. For an OAuth2Request, the OAuth Access Token must be retrieved and validated by your authentication service. For an OAuth1Request the Access Token, the Consumer Key, and their respective secrets must be retrieved, and then passed to the Verifier for validation.
+
+ import com.twitter.joauth.{Verifier, VerifierResult}
+
+ val verify = Verifier()
+ verify(oAuth1Request, tokenSecret, consumerSecret) match {
+ case VerifierResult.BAD_NONCE => // handle bad nonce
+ case VerifierResult.BAD_SIGNATURE => // handle bad signature
+ case VerifierResult.BAD_TIMESTAMP => // handle bad timestamp
+ case VerifierResult.OK => //success!
+ }
+
+That's it!
+
+### Advanced Usage
+
+#### Getting Parameter Key/Values
+
+There are two apply methods in the Unpacker trait. The one-argument version takes an HttpRequestServlet, and the two-argument version takes an HttpRequestServlet and a Seq[KeyValueHandler]. A KeyValueHandler is a simple trait that the Unpacker uses as a callback for every Key/Value pair encountered in either the query string or POST data (if the Content-Type is application/x-www-form-urlencoded). If there are duplicate keys, the KeyValueHandler will get invoked for each.
+
+The JOAuth library provides a few basic KeyValueHandlers, and it's easy to add your own. For example, suppose you want to get a list of key/values, including duplicates, from the request you're unpacking. You can do this by passing a DuplicateKeyValueHandler to the unpacker.
+
+ val unpack = Unpacker()
+ val handler = new DuplicateKeyValueHandler
+ val unpackedRequest = unpack(request, Seq(handler))
+ doSomethingWith(handler.toList)
+
+The DuplicateKeyValueHandler is invoked for each key/value pair encountered, and a List[(String, String)] can be extracted afterwards.
+
+You can also construct your own KeyValueHandlers, and there are a few useful KeyValueHandlers already defined. There's a FilteredKeyValueHandler, which wraps an underlying KeyValueHandler so that it is invoked only when certain key/values are encountered. There's a TransformingKeyValueHandler, which wraps an underlying KeyValueHandler such that either the key or value or both are transformed before the underlying handler is invoked.
+
+For example, suppose you want to get all values of a single parameter, and you want it UrlDecoded first.
+
+ class ValuesOnlyKeyValueHandler extends KeyValueHandler {
+ val buffer = new ArrayBuffer[String]
+ def apply(k: String, v: String) = buffer += v
+ def toList = buffer.toList
+ }
+
+ object UrlDecodingTransformer extends Transformer {
+ def apply(str: String) = URLDecoder.decode(str)
+ }
+
+ object MyFilter extends KeyValueFilter {
+ def apply(k: String, v: String) = k == "SpecialKey"
+ }
+
+ class UrlDecodedMyValueOnlyKeyValueHandler(underlying: KeyValueHandler)
+ extends FilteredKeyValueHandler(
+ MyFilter,
+ new TransformingKeyValueHandler(UrlDecodingTransformer, underlying)
+
+ val unpack = Unpacker()
+ val handler = new ValuesOnlyKeyValueHandler
+ val wrappedHandler = UrlDecodedMyValueOnlyKeyValueHandler(handler)
+ val unpackedRequest = unpack(request, Seq(wrappedHandler))
+ doSomethingWith(handler.toList)
+
+Obviously it would be a little easier to just call request.getParameterValues("SpecialKey") in this example, but we hope it's not hard to see that passing custom KeyValueHandlers into the unpacker can be a powerful tool. In particular, they're an easy way to get access to POST data after the Unpacker has ruined your HttpServletRequest by calling getInputStream.
+
+KeyValueHandlers are used in the JOAuth source code to collect OAuth and non-OAuth parameters from the GET, POST and Authorization header.
+
+#### Other Unpacker Tricks
+
+You can pass in a custom Normalizer or custom parameter and header KeyValueParsers to the Unpacker apply method if you really want to, but you're on your own.
+
+#### Using Normalizer and Signer
+
+You can use the Normalizer and Signer to sign OAuth 1.0a requests.
+
+ val normalize = Normalizer()
+ val sign = Signer()
+
+ val normalizedRequest = normalize(scheme, host, port, verb, path, params, oAuthParams)
+ val signedRequest = sign(normalizedRequest, tokenSecret, consumerSecret)
+
+The parameters are passed as a List[(String, String)], and the OAuth params are passed in an OAuthParams instance.
+
+## Running Tests
+
+The tests are completely self contained, and can be run using Maven:
+
+ % mvn test
+
+## Reporting problems
+
+The Github issue tracker is [here](http://github.com/9len/joauth/issues).
+
+## Contributors
+
+* Jeremy Cloud
+* Tina Huang
+* Steve Jenson
+* Nick Kallen
+* John Kalucki
+* Raffi Krikorian
+* Mark McBride
+* Marcel Molina
+* Glen Sanford
+* Fiaz Hossain
+* Steven Liu
+
+## License
+
+Copyright 2010-2013 Twitter, Inc.
+
+Licensed under the Apache License, Version 2.0: http://www.apache.org/licenses/LICENSE-2.0
+
210 pom.xml
View
@@ -0,0 +1,210 @@
+<?xml version="1.0"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <name>JOAuth</name>
+ <modelVersion>4.0.0</modelVersion>
+ <groupId>com.twitter</groupId>
+ <artifactId>joauth</artifactId>
+ <packaging>jar</packaging>
+ <version>3.1.4-SNAPSHOT</version>
+ <url>http://github.com/twitter/joauth</url>
+ <description>A Scala library for authenticating HTTP requests using OAuth</description>
+ <dependencies>
+ <!-- need scala -->
+ <dependency>
+ <groupId>org.scala-lang</groupId>
+ <artifactId>scala-library</artifactId>
+ <version>2.9.2</version>
+ </dependency>
+ <!-- project dependencies -->
+ <dependency>
+ <groupId>commons-codec</groupId>
+ <artifactId>commons-codec</artifactId>
+ <version>1.5</version>
+ </dependency>
+ <dependency>
+ <groupId>org.scala-tools.testing</groupId>
+ <artifactId>specs_2.9.1</artifactId>
+ <version>1.6.9</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>junit</groupId>
+ <artifactId>junit</artifactId>
+ <version>4.11</version>
+ <scope>test</scope>
+ </dependency>
+ <dependency>
+ <groupId>org.mockito</groupId>
+ <artifactId>mockito-all</artifactId>
+ <version>1.9.5</version>
+ <scope>test</scope>
+ </dependency>
+ </dependencies>
+
+ <scm>
+ <connection>scm:git:git@github.com:twitter/joauth.git</connection>
+ <url>scm:git:git@github.com:twitter/joauth.git</url>
+ <developerConnection>scm:git:git@github.com:twitter/joauth.git</developerConnection>
+ </scm>
+
+ <licenses>
+ <license>
+ <name>The Apache Software License, Version 2.0</name>
+ <url>http://www.apache.org/licenses/LICENSE-2.0.txt</url>
+ </license>
+ </licenses>
+
+ <developers>
+ <developer>
+ <name>Steven Liu</name>
+ <email>steven@twitter.com</email>
+ </developer>
+ <developer>
+ <name>Kevin Oliver</name>
+ <email>koliver@twitter.com</email>
+ </developer>
+ </developers>
+
+ <properties>
+ <maven.compiler.source>1.6</maven.compiler.source>
+ <maven.compiler.target>1.6</maven.compiler.target>
+ <encoding>UTF-8</encoding>
+ <scala.version>2.9.2</scala.version>
+ </properties>
+
+ <distributionManagement>
+ <snapshotRepository>
+ <id>sonatype-nexus-snapshots</id>
+ <name>Sonatype OSS</name>
+ <url>https://oss.sonatype.org/content/repositories/snapshots</url>
+ </snapshotRepository>
+ <repository>
+ <id>sonatype-nexus-staging</id>
+ <name>Nexus Release Repository</name>
+ <url>https://oss.sonatype.org/service/local/staging/deploy/maven2/</url>
+ </repository>
+ </distributionManagement>
+
+ <repositories>
+ <repository>
+ <id>sonatype-nexus-snapshots</id>
+ <url>https://oss.sonatype.org/content/repositories/snapshots</url>
+ <releases>
+ <enabled>false</enabled>
+ </releases>
+ <snapshots>
+ <enabled>true</enabled>
+ </snapshots>
+ </repository>
+ </repositories>
+
+ <build>
+ <pluginManagement>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-release-plugin</artifactId>
+ <version>2.1</version>
+ <configuration>
+ <mavenExecutorId>forked-path</mavenExecutorId>
+ <useReleaseProfile>false</useReleaseProfile>
+ <arguments>-Psonatype-oss-release</arguments>
+ </configuration>
+ </plugin>
+ </plugins>
+ </pluginManagement>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-compiler-plugin</artifactId>
+ <version>2.5.1</version>
+ <configuration>
+ <source>1.6</source>
+ <target>1.6</target>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>net.alchim31.maven</groupId>
+ <artifactId>scala-maven-plugin</artifactId>
+ <version>3.1.0</version>
+ <executions>
+ <execution>
+ <goals>
+ <goal>compile</goal>
+ <goal>testCompile</goal>
+ </goals>
+ </execution>
+ </executions>
+ <configuration>
+ <sourceDir>src/main/scala</sourceDir>
+ <testSourceDir>src/test/scala</testSourceDir>
+ </configuration>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-surefire-plugin</artifactId>
+ <version>2.12</version>
+ <configuration>
+ <argLine>-Xmx1024m</argLine>
+ <redirectTestOutputToFile>false</redirectTestOutputToFile>
+ <includes>
+ <include>**/Test*.java</include>
+ <include>**/*Test.java</include>
+ <include>**/*Spec.java</include>
+ </includes>
+ </configuration>
+ </plugin>
+ </plugins>
+ </build>
+
+ <profiles>
+ <profile>
+ <id>sonatype-oss-release</id>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-source-plugin</artifactId>
+ <version>2.1.2</version>
+ <executions>
+ <execution>
+ <id>attach-sources</id>
+ <goals>
+ <goal>jar-no-fork</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-javadoc-plugin</artifactId>
+ <version>2.7</version>
+ <executions>
+ <execution>
+ <id>attach-javadocs</id>
+ <goals>
+ <goal>jar</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-gpg-plugin</artifactId>
+ <version>1.1</version>
+ <executions>
+ <execution>
+ <id>sign-artifacts</id>
+ <phase>verify</phase>
+ <goals>
+ <goal>sign</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
+ </profile>
+ </profiles>
+
+</project>
8 project/build.properties
View
@@ -0,0 +1,8 @@
+#Project properties
+#Thu Mar 08 15:39:13 PST 2012
+project.organization=com.twitter
+project.name=joauth
+sbt.version=0.7.4
+project.version=2.0.2-SNAPSHOT
+build.scala.versions=2.8.1
+project.initialize=false
16 project/build.properties.BACKUP.16626.properties
View
@@ -0,0 +1,16 @@
+#Project properties
+<<<<<<< HEAD
+#Tue Nov 15 14:56:16 PST 2011
+project.organization=com.twitter
+project.name=joauth
+sbt.version=0.7.4
+project.version=1.9.4-SNAPSHOT
+=======
+#Thu Mar 08 15:39:13 PST 2012
+project.organization=com.twitter
+project.name=joauth
+sbt.version=0.7.4
+project.version=2.0.2-SNAPSHOT
+>>>>>>> birdcage
+build.scala.versions=2.8.1
+project.initialize=false
8 project/build.properties.LOCAL.16626.properties
View
@@ -0,0 +1,8 @@
+#Project properties
+#Tue Nov 15 14:56:16 PST 2011
+project.organization=com.twitter
+project.name=joauth
+sbt.version=0.7.4
+project.version=1.9.4-SNAPSHOT
+build.scala.versions=2.8.1
+project.initialize=false
8 project/build.properties.REMOTE.16626.properties
View
@@ -0,0 +1,8 @@
+#Project properties
+#Thu Mar 08 15:39:13 PST 2012
+project.organization=com.twitter
+project.name=joauth
+sbt.version=0.7.4
+project.version=2.0.2-SNAPSHOT
+build.scala.versions=2.8.1
+project.initialize=false
24 project/build/Project.scala
View
@@ -0,0 +1,24 @@
+import sbt._
+import com.twitter.sbt._
+
+class JoauthProject(info: ProjectInfo)
+ extends StandardProject(info)
+ with DefaultRepos
+ with ProjectDependencies
+ with SubversionPublisher
+ with PublishSite
+{
+ override def managedStyle = ManagedStyle.Maven
+ override def disableCrossPaths = true
+
+ projectDependencies(
+ "util" ~ "util-core",
+ "util" ~ "util-codec"
+ )
+
+ val specs = "org.scala-tools.testing" % "specs_2.8.1" % "1.6.8" % "test" withSources()
+ val junit = "junit" % "junit" % "4.8.1" % "test" withSources()
+ val mockito = "org.mockito" % "mockito-all" % "1.8.5" % "test" withSources()
+
+ override def subversionRepository = Some("https://svn.twitter.biz/maven-public")
+}
25 project/plugins/Plugins.scala
View
@@ -0,0 +1,25 @@
+import sbt._
+
+class Plugins(info: ProjectInfo) extends PluginDefinition(info) {
+ import scala.collection.jcl
+ val environment = jcl.Map(System.getenv())
+ def isSBTOpenTwitter = environment.get("SBT_OPEN_TWITTER").isDefined
+ def isSBTTwitter = environment.get("SBT_TWITTER").isDefined
+ def isInternal = isSBTOpenTwitter || isSBTTwitter
+
+ override def repositories = if (isSBTOpenTwitter) {
+ Set("twitter.artifactory" at "http://artifactory.local.twitter.com/open-source/")
+ } else if (isSBTTwitter) {
+ Set("twitter.artifactory" at "http://artifactory.local.twitter.com/repo/")
+ } else {
+ super.repositories ++ Seq("twitter.com" at "http://maven.twttr.com/")
+ }
+
+ override def ivyRepositories =
+ if (isInternal)
+ Seq(Resolver.defaultLocal(None)) ++ repositories
+ else
+ super.ivyRepositories
+
+ val defaultProject = "com.twitter" % "standard-project" % "1.0.4"
+}
4 project/release.properties
View
@@ -0,0 +1,4 @@
+#Automatically generated by ReleaseManagement
+#Thu Mar 08 15:39:13 PST 2012
+version=2.0.1
+sha1=2ed60323ac280ad7d253875a2a0ac50887d21cf3
6 project/versions.properties
View
@@ -0,0 +1,6 @@
+#Automatically generated by ProjectDependencies
+#Thu Mar 15 09:58:41 PDT 2012
+com.twitter/util-core=3.0.0
+util|util-codec=com.twitter/util-codec
+com.twitter/util-codec=3.0.0
+util|util-core=com.twitter/util-core
20 src/main/java/com/twitter/joauth/VerifierResult.java
View
@@ -0,0 +1,20 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth;
+
+public enum VerifierResult {
+ OK,
+ BAD_NONCE,
+ BAD_SIGNATURE,
+ BAD_TIMESTAMP
+}
119 src/main/scala/com/twitter/joauth/Base64Util.scala
View
@@ -0,0 +1,119 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ * Adapted from org.apache.commons.codec.binary.Base64
+ *
+ */
+package com.twitter.joauth
+
+import java.nio.charset.Charset
+
+object Base64Util {
+ /**
+ * 6 bits per byte, 4 bytes per block
+ */
+ private val BITS_PER_ENCODED_BYTE: Int = 6
+ private val BYTES_PER_ENCODED_BLOCK: Int = 4
+
+ /**
+ * This array is a lookup table that translates Unicode characters drawn from the "Base64 Alphabet" (as specified in
+ * Table 1 of RFC 2045) into their 6-bit positive integer equivalents. Characters that are not in the Base64
+ * alphabet but fall within the bounds of the array are translated to -1.
+ *
+ * Note: '+' and '-' both decode to 62. '/' and '_' both decode to 63. This means decoder seamlessly handles both
+ * URL_SAFE and STANDARD base64. (The encoder, on the other hand, needs to know ahead of time what to emit).
+ *
+ * Thanks to "commons" project in ws.apache.org for this code.
+ * http://svn.apache.org/repos/asf/webservices/commons/trunk/modules/util/
+ */
+ private val DECODE_TABLE: Array[Byte] = Array(-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1,
+ -1, -1, -1, -1, -1, -1, -1, -1, 62, -1, 62, -1, 63, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, -1,
+ -1, -1, -1, -1, -1, -1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
+ 20, 21, 22, 23, 24, 25, -1, -1, -1, -1, 63, -1, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37,
+ 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51)
+
+ private val UTF_8 = Charset.forName("UTF-8")
+ /**
+ * Compare each decoded byte with the passed in byte array. This code could theoretically
+ * suffer from timing attacks. We should consider not returning early and just ultimately
+ * returning a false result if any comparison fails. This is also true of Arrays.equals
+ * and String.equals.
+ */
+ def equals(base64: String, bytes: Array[Byte]): Boolean = {
+ val in = base64.getBytes(UTF_8)
+ val length = in.length
+ var eof = false
+ var bitWorkArea = 0
+ var modulus = 0
+ var pos = 0
+ var i: Int = 0
+ while (i < length && !eof) {
+ val b: Byte = in(i);
+ if (b == '=') {
+ eof = true
+ } else {
+ if (b >= 0 && b < DECODE_TABLE.length) {
+ val result: Int = DECODE_TABLE(b)
+ if (result >= 0) {
+ modulus = (modulus + 1) % BYTES_PER_ENCODED_BLOCK
+ bitWorkArea = (bitWorkArea << BITS_PER_ENCODED_BYTE) + result
+ if (modulus == 0) {
+ if (bytes(pos) != ((bitWorkArea >> 16) & 0xff).asInstanceOf[Byte]) {
+ return false
+ }
+ pos += 1
+ if (bytes(pos) != ((bitWorkArea >> 8) & 0xff).asInstanceOf[Byte]) {
+ return false
+ }
+ pos += 1
+ if (bytes(pos) != (bitWorkArea & 0xff).asInstanceOf[Byte]) {
+ return false
+ }
+ pos += 1
+ }
+ }
+ }
+ }
+ i += 1
+ }
+
+ // Some may be left over at the end, we need to compare that as well
+ if (eof && modulus != 0) {
+ modulus match {
+ case 2 =>
+ bitWorkArea = bitWorkArea >> 4
+ if (bytes(pos) != ((bitWorkArea) & 0xff).asInstanceOf[Byte]) {
+ return false
+ }
+ pos += 1
+ case 3 =>
+ bitWorkArea = bitWorkArea >> 2
+ if (bytes(pos) != ((bitWorkArea >> 8) & 0xff).asInstanceOf[Byte]) {
+ return false
+ }
+ pos += 1
+ if (bytes(pos) != ((bitWorkArea) & 0xff).asInstanceOf[Byte]) {
+ return false
+ }
+ pos += 1
+ }
+ }
+
+ pos == bytes.length
+ }
+
+}
40 src/main/scala/com/twitter/joauth/NonceValidator.scala
View
@@ -0,0 +1,40 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth
+
+/**
+ * a trait for validating a nonce. Nonce-validation is pretty domain-specific,
+ * so we leave it as an exercise for the reader
+ */
+trait NonceValidator extends ((String) => Boolean)
+
+/**
+ * a singleton of the NoopNonceValidator class
+ */
+object NoopNonceValidator extends NoopNonceValidator
+
+/**
+ * the default nonce validator, which always returns true. Though stateless and threadsafe,
+ * this is a class rather than an object to allow easy access from Java. Scala codebases
+ * should use the corresponding NonceValidator object instead.
+ */
+class NoopNonceValidator extends NonceValidator {
+ override def apply(nonce: String): Boolean = true
+}
+
+/**
+ * for testing. always returns the same result.
+ */
+class ConstNonceValidator(result: Boolean) extends NonceValidator {
+ override def apply(nonce: String): Boolean = result
+}
183 src/main/scala/com/twitter/joauth/Normalizer.scala
View
@@ -0,0 +1,183 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth
+
+import scala.collection.JavaConverters.iterableAsScalaIterableConverter
+
+/**
+ * a Normalizer takes the fields that describe an OAuth 1.0a request, and produces
+ * the normalized string that is used for the signature.
+ */
+trait Normalizer {
+ def apply(
+ scheme: String,
+ host: String,
+ port: Int,
+ verb: String,
+ path: String,
+ params: List[(String, String)],
+ oAuth1Params: OAuth1Params): String
+
+ def apply(req: ParsedRequest, oAuth1Params: OAuth1Params): String =
+ apply(req.scheme, req.host, req.port, req.verb, req.path, req.params, oAuth1Params)
+}
+
+/**
+ * ConstNormalizer can be used for testing. It will always return the same String.
+ */
+class ConstNormalizer(const: String) extends Normalizer {
+ override def apply(
+ scheme: String,
+ host: String,
+ port: Int,
+ verb: String,
+ path: String,
+ params: List[(String, String)],
+ oAuth1Params: OAuth1Params): String = const
+}
+
+/**
+ * A convenience factory for a StandardNormalizer
+ */
+object Normalizer {
+ def apply(): Normalizer = StandardNormalizer
+ val HTTP = "HTTP"
+ val HTTPS = "HTTPS"
+ val AND = "&"
+ val COLON = ":"
+ val EQ = "="
+ val COLON_SLASH_SLASH = "://"
+}
+
+/**
+ * a singleton of the StandardNormalizer class
+ */
+object StandardNormalizer extends StandardNormalizer {
+ private[this] def defaultStringBuilder = new StringBuilder(512)
+
+ protected[StandardNormalizer] val builder = new ThreadLocal[StringBuilder]() {
+ override def initialValue() = defaultStringBuilder
+ }
+
+ protected[StandardNormalizer] def resetBuilder() {
+ builder.set(defaultStringBuilder)
+ }
+}
+
+/**
+ * the standard implmenentation of the Normalizer trait. Though stateless and threadsafe,
+ * this is a class rather than an object to allow easy access from Java. Scala codebases
+ * should use the corresponding StandardNormalizer object instead.
+ */
+class StandardNormalizer extends Normalizer {
+ import Normalizer._
+
+ case class ParameterValuePair(param: String, value: String)
+
+ override def apply(
+ scheme: String,
+ host: String,
+ port: Int,
+ verb: String,
+ path: String,
+ params: List[(String, String)],
+ oAuth1Params: OAuth1Params): String = {
+ // We only need the stringbuilder for the duration of this method
+ val builder = StandardNormalizer.builder.get()
+ builder.clear()
+
+ val normalizedParams = {
+ // first, concatenate the params and the oAuth1Params together.
+ // the parameters are already URLEncoded, so we leave them alone
+ val sigParams = params ::: oAuth1Params.toList(false)
+
+ // sort params first by key, then by value
+ val sortedParams = sigParams.sortWith { case ((thisKey, thisValue), (thatKey, thatValue)) =>
+ thisKey < thatKey || (thisKey == thatKey && thisValue < thatValue)
+ }
+
+ // now turn these back into a standard query string, with keys delimited
+ // from values with "=" and pairs delimited from one another by "&"
+ builder.clear()
+ if (sortedParams.nonEmpty) {
+ sortedParams.head match { case (key, value) =>
+ builder.append(key).append('=').append(value)
+ }
+ sortedParams.tail foreach { case (key, value) =>
+ builder.append('&').append(key).append('=').append(value)
+ }
+ }
+
+ builder.toString
+ }
+
+ // the normalized URL is scheme://host[:port]/path, lowercased
+ val requestUrl = {
+ builder.clear()
+ scheme foreach { c =>
+ builder += c.toLower
+ }
+ builder += (':', '/', '/')
+ host foreach { c =>
+ builder += c.toLower
+ }
+ if (includePortString(port, scheme)) {
+ builder.append(':').append(port)
+ }
+ builder.append(path)
+
+ builder.toString
+ }
+
+ // the normalized string is VERB&normalizedParams&requestUrl,
+ // where URL and PARAMS are UrlEncoded
+ builder.clear()
+ verb foreach { c =>
+ builder += c.toUpper
+ }
+ builder.append('&').append(UrlEncoder(requestUrl))
+ builder.append('&').append(UrlEncoder(normalizedParams))
+ if (builder.length > 4096) {
+ // We don't want to keep around very large builders
+ StandardNormalizer.resetBuilder()
+ }
+ builder.toString
+ }
+
+ /**
+ * The OAuth 1.0a spec says that the port should not be included in the normalized string
+ * when (1) it is port 80 and the scheme is HTTP or (2) it is port 443 and the scheme is HTTPS
+ */
+ def includePortString(port: Int, scheme: String): Boolean = {
+ !((port == 80 && HTTP.equalsIgnoreCase(scheme)) || (port == 443 && HTTPS.equalsIgnoreCase(scheme)))
+ }
+
+ /**
+ * Java bindings
+ */
+ def normalize(
+ scheme: String,
+ host: String,
+ port: Int,
+ verb: String,
+ path: String,
+ paramsMap: java.util.List[ParameterValuePair],
+ oAuth1Params: OAuth1Params
+ ): String = {
+ val paramsList = paramsMap.asScala.map { pv =>
+ (pv.param, pv.value)
+ }.toList
+
+ apply(scheme, host, port, verb, path, paramsList, oAuth1Params)
+ }
+}
254 src/main/scala/com/twitter/joauth/OAuthParams.scala
View
@@ -0,0 +1,254 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth
+
+import com.twitter.joauth.keyvalue.{KeyValueHandler, DuplicateKeyValueHandler, SingleKeyValueHandler}
+import scala.collection.mutable.ListBuffer
+
+trait OAuthParamsHelper {
+ /**
+ * allows one to override the default behavior when parsing timestamps,
+ * which is to parse them as integers, and ignore timestamps that are
+ * malformed
+ */
+ def parseTimestamp(str: String): Option[Long]
+
+ /**
+ * allows custom processing of the OAuth 1.0 signature obtained from the request.
+ */
+ def processSignature(str: String): String
+
+ /**
+ * allows custom processing of keys obtained from the request
+ */
+ def processKey(str: String): String
+}
+
+/**
+ * Provides the default implementation of the OAuthParamsHelper trait
+ * Though stateless and threadsafe, this is a class rather than an object to allow easy
+ * access from Java. Scala codebases should use the corresponding StandardOAuthParamsHelper
+ * object instead.
+ */
+class StandardOAuthParamsHelper extends OAuthParamsHelper {
+ override def parseTimestamp(str: String): Option[Long] = try {
+ Some(str.toLong)
+ } catch {
+ case _ => None
+ }
+ override def processKey(str: String) = str
+ override def processSignature(str: String): String = str
+}
+
+/**
+ * the singleton object of StandardOAuthParamsHelper
+ */
+object StandardOAuthParamsHelper extends StandardOAuthParamsHelper
+
+/**
+ * pull all the OAuth parameter string constants into one place,
+ * add a convenience method for determining if a string is an
+ * OAuth 1.0 fieldname.
+ */
+object OAuthParams {
+ val ACCESS_TOKEN = "access_token"
+ val BEARER_TOKEN = "Bearer"
+ val CLIENT_ID = "client_id"
+ val OAUTH_TOKEN = "oauth_token"
+ val OAUTH_CONSUMER_KEY = "oauth_consumer_key"
+ val OAUTH_SIGNATURE = "oauth_signature"
+ val OAUTH_NONCE = "oauth_nonce"
+ val OAUTH_TIMESTAMP = "oauth_timestamp"
+ val OAUTH_SIGNATURE_METHOD = "oauth_signature_method"
+ val OAUTH_VERSION = "oauth_version"
+ val NORMALIZED_REQUEST = "normalized_request"
+ val UNSET = "(unset)"
+ val OAUTH_2D11 = "oauth2d11"
+
+ val OAUTH_PREFIX_REGEX = "^oauth_[a-z_]+$".r
+
+ val HMAC_SHA1 = "HMAC-SHA1"
+ val ONE_DOT_OH = "1.0"
+ val ONE_DOT_OH_A = "1.0a"
+
+ val OAUTH1_HEADER_AUTHTYPE = "oauth"
+ val OAUTH2D11_HEADER_AUTHTYPE = "oauth2"
+ val OAUTH2_HEADER_AUTHTYPE = "bearer"
+
+ def isOAuthParam(field: String): Boolean = {
+ field == ACCESS_TOKEN ||
+ field == OAUTH_TOKEN ||
+ field == OAUTH_CONSUMER_KEY ||
+ field == OAUTH_SIGNATURE ||
+ field == OAUTH_NONCE ||
+ field == OAUTH_TIMESTAMP ||
+ field == OAUTH_SIGNATURE_METHOD ||
+ field == OAUTH_VERSION
+ }
+
+ def valueOrUnset(value: String) = if (value == null) UNSET else value
+}
+
+/**
+ * OAuth1Params is mostly just a container for OAuth 1.0a parameters.
+ */
+case class OAuth1Params(
+ token: String,
+ consumerKey: String,
+ nonce: String,
+ timestampSecs: Long,
+ timestampStr: String,
+ signature: String,
+ signatureMethod: String,
+ version: String) {
+
+ import OAuthParams._
+
+ def toList(includeSig: Boolean): List[(String, String)] = {
+ val buf = new ListBuffer[(String, String)]
+ buf += OAUTH_CONSUMER_KEY -> consumerKey
+ buf += OAUTH_NONCE -> nonce
+ buf += OAUTH_TOKEN -> token
+ if (includeSig) buf += OAUTH_SIGNATURE -> signature
+ buf += OAUTH_SIGNATURE_METHOD -> signatureMethod
+ buf += OAUTH_TIMESTAMP -> timestampStr
+ if (version != null) buf += OAUTH_VERSION -> version
+ buf.toList
+ }
+
+ // we use String.format here, because we're probably not that worried about
+ // effeciency when printing the class for debugging
+ override def toString: String =
+ "%s=%s,%s=%s,%s=%s,%s=%s(->%s),%s=%s,%s=%s,%s=%s".format(
+ OAUTH_TOKEN, valueOrUnset(token),
+ OAUTH_CONSUMER_KEY, valueOrUnset(consumerKey),
+ OAUTH_NONCE, valueOrUnset(nonce),
+ OAUTH_TIMESTAMP, timestampStr, timestampSecs,
+ OAUTH_SIGNATURE, valueOrUnset(signature),
+ OAUTH_SIGNATURE_METHOD, valueOrUnset(signatureMethod),
+ OAUTH_VERSION, valueOrUnset(version))
+}
+
+/**
+ * A collector for OAuth and other params. There are convenience methods for determining
+ * if it has all OAuth parameters set, just the token set, and for obtaining
+ * a list of all params for use in producing the normalized request.
+ */
+
+class OAuthParamsBuilder(helper: OAuthParamsHelper) {
+ import OAuthParams._
+
+ private[joauth] var v2Token: String = null
+ private[joauth] var oauth2d11: Boolean = true
+ private[joauth] var token: String = null
+ private[joauth] var consumerKey: String = null
+ private[joauth] var nonce: String = null
+ private[joauth] var timestampSecs: Long = -1
+ private[joauth] var timestampStr: String = null
+ private[joauth] var signature: String = null
+ private[joauth] var signatureMethod: String = null
+ private[joauth] var version: String = null
+
+ private[joauth] var paramsHandler = new DuplicateKeyValueHandler
+ private[joauth] var otherOAuthParamsHandler = new SingleKeyValueHandler
+
+ val headerHandler: KeyValueHandler = new KeyValueHandler {
+ override def apply(k: String, v: String) = handleKeyValue(k, v, true)
+ }
+
+ val queryHandler: KeyValueHandler = new KeyValueHandler {
+ override def apply(k: String, v: String) = handleKeyValue(k, v, false)
+ }
+
+ private[this] def handleKeyValue(k: String, v: String, fromHeader: Boolean): Unit = {
+ def ifNonEmpty(value: String)(f: => Unit) {
+ if (value != null && value != "") {
+ f
+ }
+ }
+
+ k match {
+ // empty values for these keys are swallowed
+ case ACCESS_TOKEN => ifNonEmpty(v) { v2Token = v }
+ case BEARER_TOKEN => ifNonEmpty(v) {
+ if (fromHeader) {
+ v2Token = v
+ oauth2d11 = false
+ }
+ }
+ case CLIENT_ID => ifNonEmpty(v) { if(fromHeader) consumerKey = v }
+ case OAUTH_TOKEN => ifNonEmpty(v) { token = v.trim }
+ case OAUTH_CONSUMER_KEY => ifNonEmpty(v) { consumerKey = v }
+ case OAUTH_NONCE => ifNonEmpty(v) { nonce = v }
+ case OAUTH_TIMESTAMP => ifNonEmpty(v) {
+ helper.parseTimestamp(v) match {
+ case Some(t: Long) => {
+ timestampSecs = t
+ timestampStr = v
+ }
+ case None => // ignore
+ }
+ }
+ case OAUTH_SIGNATURE => ifNonEmpty(v) { signature = helper.processSignature(v) }
+ case OAUTH_SIGNATURE_METHOD => ifNonEmpty(v) { signatureMethod = v }
+ case OAUTH_VERSION => ifNonEmpty(v) { version = v }
+ // send oauth_prefixed to a uniquekey handler
+ case OAUTH_PREFIX_REGEX() => otherOAuthParamsHandler(k, v)
+ // send other params to the handler, but only if they didn't come from the header
+ case _ => if (!fromHeader) paramsHandler(k, v)
+ }
+ }
+
+ // we use String.format here, because we're probably not that worried about
+ // effeciency when printing the class for debugging
+ override def toString: String =
+ "%s=%s,%s=%s,%s=%s,%s=%s,%s=%s,%s=%s(->%s),%s=%s,%s=%s,%s=%s".format(
+ OAUTH_2D11, oauth2d11,
+ ACCESS_TOKEN, valueOrUnset(v2Token),
+ OAUTH_TOKEN, valueOrUnset(token),
+ OAUTH_CONSUMER_KEY, valueOrUnset(consumerKey),
+ OAUTH_NONCE, valueOrUnset(nonce),
+ OAUTH_TIMESTAMP, timestampStr, timestampSecs,
+ OAUTH_SIGNATURE, valueOrUnset(signature),
+ OAUTH_SIGNATURE_METHOD, valueOrUnset(signatureMethod),
+ OAUTH_VERSION, valueOrUnset(version))
+
+ def valueOrUnset(value: String) = if (value == null) UNSET else value
+
+ def isOAuth2: Boolean = v2Token != null && !oauth2d11
+ def isOAuth2d11: Boolean = v2Token != null && !isOAuth1 && oauth2d11
+
+ def isOAuth1: Boolean =
+ token != null &&
+ consumerKey != null &&
+ nonce != null &&
+ timestampStr != null &&
+ signature != null &&
+ signatureMethod != null
+ // version is optional, so not included here
+
+ def oAuth2Token = v2Token
+
+ def otherParams = paramsHandler.toList ++ otherOAuthParamsHandler.toList
+
+ // make an immutable params instance
+ def oAuth1Params = OAuth1Params(
+ token,
+ consumerKey,
+ nonce,
+ timestampSecs,
+ timestampStr,
+ signature,
+ signatureMethod,
+ version)
+}
42 src/main/scala/com/twitter/joauth/Request.scala
View
@@ -0,0 +1,42 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth
+
+trait Request {
+ def authHeader: Option[String]
+ def body: String
+ def contentType: Option[String]
+ def host: String
+ def method: String
+ def path: String
+ def port: Int
+ def queryString: String
+ def scheme: String
+
+ def parsedRequest(params: List[(String, String)]) =
+ new ParsedRequest(
+ if (scheme ne null) scheme.toUpperCase else null,
+ host,
+ port,
+ if (method ne null) method.toUpperCase else null,
+ path,
+ params)
+}
+
+case class ParsedRequest(
+ scheme: String,
+ host: String,
+ port: Int,
+ verb: String,
+ path: String,
+ params: List[(String, String)]);
82 src/main/scala/com/twitter/joauth/Signer.scala
View
@@ -0,0 +1,82 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth
+
+import javax.crypto.Mac
+import javax.crypto.spec.SecretKeySpec
+import org.apache.commons.codec.binary.Base64
+
+/**
+ * A Signer takes a string, a token secret and a consumer secret, and produces a signed string
+ */
+trait Signer {
+ /**
+ * produce an encoded signature string
+ */
+ def getString(str: String, tokenSecret: String, consumerSecret: String): String
+
+ /**
+ * produce a signature as a byte array
+ */
+ def getBytes(str: String, tokenSecret: String, consumerSecret: String): Array[Byte]
+
+ /**
+ * decode an existing signature to a byte array
+ */
+ def toBytes(signature: String): Array[Byte]
+}
+
+/**
+ * For testing. Always returns the same string
+ */
+class ConstSigner(str: String, bytes: Array[Byte]) extends Signer {
+ override def getBytes(str: String, tokenSecret: String, consumerSecret: String) = bytes
+ override def getString(str: String, tokenSecret: String, consumerSecret: String) = str
+ override def toBytes(signature: String) = bytes
+}
+
+/**
+ * A convenience factory for a StandardSigner
+ */
+object Signer {
+ def apply(): Signer = StandardSigner
+ val HMACSHA1 = "HmacSHA1"
+}
+
+/**
+ * a singleton of the StandardSigner class
+ */
+object StandardSigner extends StandardSigner
+
+/**
+ * the standard implmenentation of the Signer trait. Though stateless and threadsafe,
+ * this is a class rather than an object to allow easy access from Java. Scala codebases
+ * should use the corresponding StandardSigner object instead.
+ */
+class StandardSigner extends Signer {
+ override def getString(str: String, tokenSecret: String, consumerSecret: String): String =
+ UrlEncoder(Base64.encodeBase64String(getBytes(str, tokenSecret, consumerSecret)))
+
+ override def getBytes(str: String, tokenSecret: String, consumerSecret: String): Array[Byte] = {
+ val key = consumerSecret+Normalizer.AND+tokenSecret
+ val signingKey = new SecretKeySpec(key.getBytes, Signer.HMACSHA1)
+
+ // TODO: consider synchronizing this, apparently Mac may not be threadsafe
+ val mac = Mac.getInstance(Signer.HMACSHA1)
+ mac.init(signingKey)
+ mac.doFinal(str.getBytes)
+ }
+
+ override def toBytes(signature: String): Array[Byte] =
+ Base64.decodeBase64(UrlDecoder(signature).trim)
+}
146 src/main/scala/com/twitter/joauth/UnpackedRequest.scala
View
@@ -0,0 +1,146 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth
+
+sealed trait UnpackedRequest {
+ def parsedRequest: ParsedRequest
+}
+
+case class UnknownRequest(parsedRequest: ParsedRequest) extends UnpackedRequest
+
+/**
+ * Both OAuth 1.0a and 2.0 requests have access tokens,
+ * so it's convenient to combine them into a single trait
+ */
+sealed trait OAuthRequest extends UnpackedRequest {
+ def oAuthVersionString: String
+ def token: String
+ def oAuthParamMap: Map[String, String]
+}
+
+/**
+ * models an OAuth 1.0a request. Rather than passing the
+ * scheme, host, port, etc around, we pre-calculate the normalized request,
+ * since that's all we need for signature validation anyway.
+ */
+case class OAuth1Request(
+ token: String,
+ consumerKey: String,
+ nonce: String,
+ timestampSecs: Long,
+ signature: String,
+ signatureMethod: String,
+ version: String,
+ parsedRequest: ParsedRequest,
+ normalizedRequest: String) extends OAuthRequest {
+
+ import OAuthParams._
+
+ override val oAuthVersionString = "oauth1"
+
+ override lazy val oAuthParamMap = Map(
+ OAUTH_TOKEN -> token,
+ OAUTH_CONSUMER_KEY -> consumerKey,
+ OAUTH_NONCE -> nonce,
+ OAUTH_TIMESTAMP -> timestampSecs.toString,
+ OAUTH_SIGNATURE_METHOD -> signatureMethod,
+ OAUTH_SIGNATURE -> signature,
+ OAUTH_VERSION -> (if (version == null) ONE_DOT_OH else version),
+ NORMALIZED_REQUEST -> normalizedRequest)
+}
+
+/**
+ * models an OAuth 2.0 request. Just a wrapper for the token, really.
+ */
+@deprecated("Use OAuth2Request instead")
+case class OAuth2d11Request(token: String, parsedRequest: ParsedRequest) extends OAuthRequest {
+ override val oAuthVersionString = "oauth2d11"
+
+ override lazy val oAuthParamMap = Map(OAuthParams.ACCESS_TOKEN -> token)
+}
+
+/**
+ * models an OAuth 2.0 rev 25 request. Just a wrapper for the token, really.
+ */
+case class OAuth2Request(token: String, parsedRequest: ParsedRequest, clientId: String = "") extends OAuthRequest {
+ override val oAuthVersionString = "oauth2"
+
+ override lazy val oAuthParamMap = Map(OAuthParams.ACCESS_TOKEN -> token,
+ OAuthParams.CLIENT_ID -> clientId)
+}
+
+/**
+ * The companion object's apply method produces an OAuth1Request instance by
+ * passing the request details into a Normalizer to produce the normalized
+ * request. Will throw a MalformedRequest if any required parameter is unset.
+ */
+object OAuth1Request {
+ val NO_VALUE_FOR = "no value for "
+ val SCHEME = "scheme"
+ val HOST = "host"
+ val PORT = "port"
+ val VERB = "verb"
+ val PATH = "path"
+ val UNSUPPORTED_METHOD = "unsupported signature method: "
+ val UNSUPPORTED_VERSION = "unsupported oauth version: "
+ val MALFORMED_TOKEN = "malformed oauth token: "
+ val MaxTokenLength = 50 // This is limited by DB schema
+
+ def nullException(name: String) = new MalformedRequest(NO_VALUE_FOR+name)
+
+ @throws(classOf[MalformedRequest])
+ def verify(
+ parsedRequest: ParsedRequest,
+ oAuth1Params: OAuth1Params) {
+ if (parsedRequest.scheme == null) throw nullException(SCHEME)
+ else if (parsedRequest.host == null) throw nullException(HOST)
+ else if (parsedRequest.port < 0) throw nullException(PORT)
+ else if (parsedRequest.verb == null) throw nullException(VERB)
+ else if (parsedRequest.path == null) throw nullException(PATH)
+ else if (oAuth1Params.signatureMethod != OAuthParams.HMAC_SHA1) {
+ throw new MalformedRequest(UNSUPPORTED_METHOD+oAuth1Params.signatureMethod)
+ }
+ else if (oAuth1Params.version != null &&
+ oAuth1Params.version != OAuthParams.ONE_DOT_OH &&
+ oAuth1Params.version.toLowerCase != OAuthParams.ONE_DOT_OH_A) {
+ throw new MalformedRequest(UNSUPPORTED_VERSION+oAuth1Params.version)
+ }
+ else if (oAuth1Params.token != null &&
+ (oAuth1Params.token.indexOf(' ') > 0 || oAuth1Params.token.length > MaxTokenLength)) {
+ throw new MalformedRequest(MALFORMED_TOKEN+oAuth1Params.token)
+ }
+ // we don't check the validity of the OAuthParams object, because it must be
+ // fully populated in order for the factory to even be called, and we'd like
+ // to save the expense of iterating over all the fields again
+ }
+
+ @throws(classOf[MalformedRequest])
+ def apply(
+ parsedRequest: ParsedRequest,
+ oAuth1Params: OAuth1Params,
+ normalize: Normalizer): OAuth1Request = {
+
+ verify(parsedRequest, oAuth1Params)
+
+ new OAuth1Request(
+ UrlDecoder(oAuth1Params.token),
+ UrlDecoder(oAuth1Params.consumerKey),
+ UrlDecoder(oAuth1Params.nonce),
+ oAuth1Params.timestampSecs,
+ oAuth1Params.signature,
+ oAuth1Params.signatureMethod,
+ oAuth1Params.version,
+ parsedRequest,
+ normalize(parsedRequest, oAuth1Params))
+ }
+}
222 src/main/scala/com/twitter/joauth/Unpacker.scala
View
@@ -0,0 +1,222 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth
+
+import com.twitter.joauth.keyvalue._
+
+/**
+ * An Unpacker takes an Request and optionally a Seq[KeyValueHandler],
+ * and parses the request into an OAuthRequest instance, invoking each KeyValueHandler
+ * for every key/value pair obtained from either the queryString or the POST data.
+ * If no valid request can be obtained, an UnpackerException is thrown.
+ */
+trait Unpacker {
+ @throws(classOf[UnpackerException])
+ def apply(request: Request): UnpackedRequest = apply(request, Seq())
+
+ @throws(classOf[UnpackerException])
+ def apply(request: Request, kvHandler: KeyValueHandler): UnpackedRequest =
+ apply(request, Seq(kvHandler))
+
+ @throws(classOf[UnpackerException])
+ def apply(request: Request, kvHandlers: Seq[KeyValueHandler]): UnpackedRequest
+}
+
+/**
+ * for testing. Always returns the same result.
+ */
+class ConstUnpacker(result: OAuthRequest) extends Unpacker {
+ override def apply(request: Request, kvHandlers: Seq[KeyValueHandler]): OAuthRequest = result
+}
+
+/**
+ * A convenience factory for a StandardUnpacker
+ */
+object Unpacker {
+ def apply(): Unpacker = StandardUnpacker()
+
+ def apply(
+ helper: OAuthParamsHelper,
+ normalizer: Normalizer,
+ queryParser: KeyValueParser,
+ headerParser: KeyValueParser): Unpacker =
+ new StandardUnpacker(helper, normalizer, queryParser, headerParser)
+}
+
+/**
+ * StandardUnpacker constants, and a few more convenience factory methods, for tests
+ * that need to call methods of the StandardUnpacker directly.
+ */
+object StandardUnpacker {
+ val AUTH_HEADER_REGEX = """^(\S+)\s+(.*)$""".r
+ val POST = "POST"
+ val WWW_FORM_URLENCODED = "application/x-www-form-urlencoded"
+ val HTTPS = "HTTPS"
+ val UTF_8 = "UTF-8"
+
+ def apply(): StandardUnpacker = new StandardUnpacker(
+ StandardOAuthParamsHelper, Normalizer(), QueryKeyValueParser, HeaderKeyValueParser)
+
+ def apply(helper: OAuthParamsHelper): StandardUnpacker =
+ new StandardUnpacker(helper, Normalizer(), QueryKeyValueParser, HeaderKeyValueParser)
+}
+
+/**
+ * the standard implmenentation of the Unpacker trait.
+ *
+ * WARNING: The StandardUnpacker will call the HttpRequest.getInputStream method if the method
+ * of the request is POST and the Content-Type is "application/x-www-form-urlencoded." If you
+ * need to read the POST yourself, this will cause you problems, since getInputStream/getReader
+ * can only be called once. There are two solutions: (1) Write an HttpServletWrapper to buffer
+ * the POST data and allow multiple calls to getInputStream, and pass the HttpServletWrapper
+ * into the Unpacker. (2) Pass a KeyValueHandler into the unpacker call, which will let you
+ * get the parameters in the POST as a side effect of unpacking.
+ */
+class StandardUnpacker(
+ helper: OAuthParamsHelper,
+ normalizer: Normalizer,
+ queryParser: KeyValueParser,
+ headerParser: KeyValueParser) extends Unpacker {
+
+ import StandardUnpacker._
+
+ @throws(classOf[UnpackerException])
+ override def apply(request: Request, kvHandlers: Seq[KeyValueHandler]): UnpackedRequest = {
+ try {
+ val oAuthParamsBuilder = parseRequest(request, kvHandlers)
+ val parsedRequest = request.parsedRequest(oAuthParamsBuilder.otherParams)
+
+ if (oAuthParamsBuilder.isOAuth2) {
+ getOAuth2Request(parsedRequest, oAuthParamsBuilder.oAuth2Token)
+ } else if (oAuthParamsBuilder.isOAuth2d11) {
+ getOAuth2d11Request(parsedRequest, oAuthParamsBuilder.oAuth2Token)
+ } else if (oAuthParamsBuilder.isOAuth1) {
+ getOAuth1Request(parsedRequest, oAuthParamsBuilder.oAuth1Params)
+ } else UnknownRequest(parsedRequest)
+
+ } catch {
+ // just rethrow UnpackerExceptions
+ case u: UnpackerException => throw u
+ // wrap other Throwables in an UnpackerException
+ case t: Throwable => t.printStackTrace;
+ throw new UnpackerException("could not unpack request: " + t, t)
+ }
+ }
+
+ @throws(classOf[MalformedRequest])
+ def getOAuth1Request(
+ parsedRequest: ParsedRequest, oAuth1Params: OAuth1Params): OAuth1Request =
+ OAuth1Request(parsedRequest, oAuth1Params, normalizer)
+
+ @throws(classOf[MalformedRequest])
+ def getOAuth2d11Request(parsedRequest: ParsedRequest, token: String): OAuth2d11Request = {
+ // OAuth 2.0 requests are totally insecure with SSL, so depend on HTTPS to provide
+ // protection against replay and man-in-the-middle attacks. If you need to run
+ // an authorization service that can't do HTTPS for some reason, you can define
+ // a custom UriSchemeGetter to make the scheme pretend to be HTTPS for the purposes
+ // of request validation
+ if (parsedRequest.scheme == HTTPS) OAuth2d11Request(UrlDecoder(token), parsedRequest)
+ else throw new MalformedRequest("OAuth 2.0 requests must use HTTPS")
+ }
+
+ @throws(classOf[MalformedRequest])
+ def getOAuth2Request(parsedRequest: ParsedRequest, token: String): OAuth2Request = {
+ // OAuth 2.0 requests are totally insecure without SSL, so depend on HTTPS to provide
+ // protection against replay and man-in-the-middle attacks.
+ if (parsedRequest.scheme == HTTPS) OAuth2Request(UrlDecoder(token), parsedRequest)
+ else throw new MalformedRequest("OAuth 2.0 requests must use HTTPS")
+ }
+
+ protected[this] def transformingKeyValueHandler(kvHandler: KeyValueHandler) = {
+ new KeyTransformingKeyValueHandler(
+ new TrimmingKeyValueHandler(new UrlEncodingNormalizingKeyValueHandler(kvHandler)),
+ helper.processKey _)
+ }
+
+ def parseRequest(request: Request, kvHandlers: Seq[KeyValueHandler]): OAuthParamsBuilder = {
+ // use an oAuthParamsBuilder instance to accumulate key/values from
+ // the query string, the POST (if the appropriate Content-Type), and
+ // the Authorization header, if any.
+ val oAuthParamsBuilder = new OAuthParamsBuilder(helper)
+
+ // parse the header, if present
+ parseHeader(request.authHeader, oAuthParamsBuilder.headerHandler)
+
+ // If it is an oAuth2 we do not need to process any further
+ if (!oAuthParamsBuilder.isOAuth2) {
+ val queryHandler = transformingKeyValueHandler(oAuthParamsBuilder.queryHandler)
+
+ // add our handlers to the passed-in handlers, to which
+ // we'll only send non-oauth key/values.
+ val queryHandlers: Seq[KeyValueHandler] = queryHandler +: kvHandlers
+
+ // parse the GET query string
+ queryParser(request.queryString, queryHandlers)
+
+ // parse the POST if the Content-Type is appropriate. Use the same
+ // set of KeyValueHandlers that we used to parse the query string.
+ if (request.method.toUpperCase == POST &&
+ request.contentType.isDefined &&
+ request.contentType.get.startsWith(WWW_FORM_URLENCODED)) {
+ queryParser(request.body, queryHandlers)
+ }
+ }
+
+ // now we just return the accumulated parameters and OAuthParams
+ oAuthParamsBuilder
+ }
+
+ def parseHeader(header: Option[String], nonTransformingHandler: KeyValueHandler): Unit = {
+ // trim, normalize encodings
+ val handler = transformingKeyValueHandler(nonTransformingHandler)
+
+ // check for OAuth credentials in the header. OAuth 1.0a and 2.0 have
+ // different header schemes, so match first on the auth scheme.
+ header match {
+ case Some(AUTH_HEADER_REGEX(authType, authString)) => {
+ val (shouldParse, oauth2) = authType.toLowerCase match {
+ case OAuthParams.OAUTH2_HEADER_AUTHTYPE => (false, true)
+ case OAuthParams.OAUTH2D11_HEADER_AUTHTYPE => (true, false)
+ case OAuthParams.OAUTH1_HEADER_AUTHTYPE => (true, false)
+ case _ => (false, false)
+ }
+ if (shouldParse) {
+ // if we were able match an appropriate auth header,
+ // we'll wrap that handler with a MaybeQuotedValueKeyValueHandler,
+ // which will strip quotes from quoted values before passing
+ // to the underlying handler
+ val quotedHandler = new MaybeQuotedValueKeyValueHandler(handler)
+
+ // oauth2 allows specification of the access token alone,
+ // without a key, so we pass in a kvHandler that can detect this case
+ val oneKeyOnlyHandler = new OneKeyOnlyKeyValueHandler
+
+ // now we'll pass the handler to the headerParser,
+ // which splits on commas rather than ampersands,
+ // and is more forgiving with whitespace
+ headerParser(authString, Seq(quotedHandler, oneKeyOnlyHandler))
+
+ // if we did encounter exactly one key with an empty value, invoke
+ // the underlying handler as if it were the token
+ oneKeyOnlyHandler.key match {
+ case Some(token) => handler(OAuthParams.ACCESS_TOKEN, token)
+ case None =>
+ }
+ } else if (oauth2) {
+ nonTransformingHandler(OAuthParams.BEARER_TOKEN, authString)
+ }
+ }
+ case _ =>
+ }
+ }
+}
25 src/main/scala/com/twitter/joauth/UnpackerException.scala
View
@@ -0,0 +1,25 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth
+
+/**
+ * If Unpacker encounters an unexpected exception, it will wrap it in an UnpackerException
+ */
+class UnpackerException(val message: String, t: Throwable) extends Exception(message, t) {
+ def this(message: String) = this(message, null)
+}
+
+/**
+ * thrown if intent is clear, but the request is malformed
+ */
+class MalformedRequest(message: String) extends UnpackerException(message)
123 src/main/scala/com/twitter/joauth/UrlEncoder.scala
View
@@ -0,0 +1,123 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth
+
+import java.net.URLDecoder
+import java.nio.charset.Charset
+
+object UrlEncoder {
+ val UTF_8 = "UTF-8"
+ val UTF_8_CHARSET = Charset.forName(UTF_8)
+
+ val PLUS = "+"
+ val ENCODED_PLUS = "%20"
+ val UNDERSCORE = "_"
+ val ENCODED_UNDERSCORE = "%5F"
+ val DASH = "-"
+ val ENCODED_DASH = "%2D"
+ val PERIOD = "."
+ val ENCODED_PERIOD = "%2E"
+ val TILDE = "~"
+ val ENCODED_TILDE = "%7E"
+ val COMMA = ","
+ val ENCODED_COMMA = "%2C"
+ val ENCODED_OPEN_BRACKET = "%5B"
+ val ENCODED_CLOSE_BRACKET = "%5D"
+
+ def apply(s: String): String = {
+ if (s == null) {
+ return null
+ }
+ var sb: StringBuilder = null
+ for (i <- 0 until s.length) {
+ val c = s.charAt(i)
+
+ val shouldAppend =
+ (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') ||
+ (c >= '0' && c <= '9') || c == '.' || c == '-' || c == '_' || c == '~'
+
+ if (shouldAppend) {
+ if (sb != null) {
+ sb.append(c)
+ }
+ } else {
+ if (sb == null) {
+ sb = new StringBuilder(s.length + 40)
+ sb.append(s.substring(0, i))
+ }
+
+ for (b <- c.toString.getBytes(UTF_8_CHARSET)) {
+ sb.append("%").append(b.toInt.toHexString.toUpperCase)
+ }
+ }
+ }
+
+ if (sb == null) s else sb.toString()
+ }
+
+ def normalize(s: String): String = {
+ if (s == null) {
+ return null
+ }
+
+ var sb: StringBuilder = null
+ val length = s.length
+ var i = 0
+
+ while (i < length) {
+ val c = s.charAt(i)
+ if (c == '%' || c == '+' || c == ',' || c == '[' || c == ']') {
+ if (sb == null) {
+ sb = new StringBuilder(s.length + 40)
+ sb.append(s.substring(0, i))
+ }
+ if (c == '%') {
+ val toAppend = s.substring(i, i + 3).toUpperCase match {
+ case ENCODED_UNDERSCORE => UNDERSCORE
+ case ENCODED_DASH => DASH
+ case ENCODED_TILDE => TILDE
+ case ENCODED_PERIOD => PERIOD
+ case o => o
+ }
+
+ sb.append(toAppend)
+ i += 2
+ } else if (c == ',') {
+ sb.append(ENCODED_COMMA)
+ } else if (c == '+') {
+ sb.append(ENCODED_PLUS)
+ } else if (c == '[') {
+ sb.append(ENCODED_OPEN_BRACKET)
+ } else if (c == ']') {
+ sb.append(ENCODED_CLOSE_BRACKET)
+ }
+ } else if (sb != null) {
+ sb.append(c)
+ }
+ i += 1
+ }
+
+ if (sb == null) s else sb.toString()
+ }
+}
+
+trait UrlDecoder {
+ def apply(s: String) = {
+ if (s == null) {
+ null
+ } else {
+ URLDecoder.decode(s, UrlEncoder.UTF_8)
+ }
+ }
+}
+object UrlDecoder extends UrlDecoder
90 src/main/scala/com/twitter/joauth/Verifier.scala
View
@@ -0,0 +1,90 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth
+
+import java.util.{Arrays, Date}
+
+/**
+ * A Validator takes an OAuth1 request, a token secret, and a consumer secret,
+ * and validates the request. It returns a Java enum for compatability
+ */
+trait Verifier {
+ def apply(request: OAuth1Request, tokenSecret: String, consumerSecret: String): VerifierResult
+}
+
+/**
+ * for testing. always returns the same result.
+ */
+class ConstVerifier(result: VerifierResult) extends Verifier {
+ override def apply(request: OAuth1Request, tokenSecret: String, consumerSecret: String): VerifierResult = result
+}
+
+/**
+ * a factory with various convenience constructors for a StandardVerifier
+ */
+object Verifier {
+ val NO_TIMESTAMP_CHECK = -1
+
+ def apply(): Verifier = new StandardVerifier(
+ Signer(), NO_TIMESTAMP_CHECK, NO_TIMESTAMP_CHECK, NoopNonceValidator)
+ def apply(maxClockFloatAheadMins: Int, maxClockFloatBehindMins: Int) = new StandardVerifier(
+ Signer(), maxClockFloatAheadMins, maxClockFloatBehindMins, NoopNonceValidator)
+ def apply(
+ maxClockFloatAheadMins: Int,
+ maxClockFloatBehindMins: Int,
+ validateNonce: NonceValidator) = new StandardVerifier(
+ Signer(), maxClockFloatAheadMins, maxClockFloatBehindMins, validateNonce)
+ def apply(
+ sign: Signer,
+ maxClockFloatAheadMins: Int,
+ maxClockFloatBehindMins: Int,
+ validateNonce: NonceValidator) = new StandardVerifier(
+ sign, maxClockFloatAheadMins, maxClockFloatBehindMins, validateNonce)
+}
+
+/**
+ * The standard implementation of a Verifier. Constructed with a Signer, the maximum clock float
+ * allowed for a timestamp, and a NonceValidator.
+ */
+class StandardVerifier(
+ signer: Signer,
+ maxClockFloatAheadMins: Int,
+ maxClockFloatBehindMins: Int,
+ validateNonce: NonceValidator)
+extends Verifier {
+
+ val maxClockFloatAheadSecs = maxClockFloatAheadMins * 60L
+ val maxClockFloatBehindSecs = maxClockFloatBehindMins * 60L
+
+ override def apply(request: OAuth1Request, tokenSecret: String, consumerSecret: String): VerifierResult = {
+ if (!validateNonce(request.nonce)) VerifierResult.BAD_NONCE
+ else if (!validateTimestampSecs(request.timestampSecs)) VerifierResult.BAD_TIMESTAMP
+ else if (!validateSignature(request, tokenSecret, consumerSecret)) VerifierResult.BAD_SIGNATURE
+ else VerifierResult.OK
+ }
+
+ def validateTimestampSecs(timestampSecs: Long): Boolean = {
+ val nowSecs = System.currentTimeMillis / 1000
+ (maxClockFloatBehindMins < 0 || (timestampSecs >= nowSecs - maxClockFloatBehindSecs)) &&
+ (maxClockFloatAheadMins < 0 || (timestampSecs <= nowSecs + maxClockFloatAheadSecs))
+ }
+
+ def validateSignature(
+ request: OAuth1Request,
+ tokenSecret: String,
+ consumerSecret: String): Boolean = {
+ Base64Util.equals(UrlDecoder(request.signature).trim,
+ signer.getBytes(request.normalizedRequest, tokenSecret, consumerSecret))
+ }
+}
+
142 src/main/scala/com/twitter/joauth/keyvalue/KeyValueHandler.scala
View
@@ -0,0 +1,142 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth.keyvalue
+
+import collection.mutable.{ListBuffer}
+import java.util.LinkedHashMap
+import scala.collection.JavaConversions._
+
+/**
+ * KeyValueHandler is a trait for a callback with a key and a value.
+ * What you do with the key and value are up to you.
+ */
+trait KeyValueHandler extends ((String, String) => Unit)
+
+object NullKeyValueHandler extends NullKeyValueHandler
+class NullKeyValueHandler extends KeyValueHandler {
+ override def apply(k: String, v: String) = ()
+}
+
+/**
+ * DuplicateKeyValueHandler produces a List[(String, String)] of key
+ * value pairs, allowing duplicate values for keys.
+ */
+class DuplicateKeyValueHandler extends KeyValueHandler {
+ private val buffer = new ListBuffer[(String, String)]
+ override def apply(k: String, v: String): Unit = buffer += ((k, v))
+ def toList = buffer.toList
+}
+
+/**
+ * SingleKeyValueHandler produces either a List[(String, String)]
+ * or a Map[String, String] of key/value pairs, and will override
+ * duplicate values for keys, using the last value encountered
+ */
+class SingleKeyValueHandler extends KeyValueHandler {
+ private val kv = new LinkedHashMap[String, String]
+ override def apply(k: String, v: String): Unit = kv += k -> v
+ def toMap = Map(kv.toList: _*)
+ def toList = kv.toList
+}
+
+/**
+ * key is set iff the handler was invoked exactly once with an empty value
+ */
+class OneKeyOnlyKeyValueHandler extends KeyValueHandler {
+ private var invoked = false
+ private var _key: Option[String] = None
+
+ override def apply(k: String, v: String): Unit = {
+ if (invoked) {
+ if (_key.isDefined) _key = None
+ } else {
+ invoked = true
+ if (v == null || v == "") _key = Some(k)
+ }
+ }
+
+ def key = _key
+}
+
+/**
+ * The MaybeQuotedValueKeyValueHandler passes quoted and unquoted values,
+ * removing quotes along the way. Note that this could have been
+ * implemented as a composition of a FilteringKeyValueHandler and a
+ * TransformingKeyValueHandler, but it was easier to do the filter
+ * and transform in a single pass
+ */
+object MaybeQuotedSingleKeyValueHandler {
+ val QUOTED_REGEX = """^\s*\"(.*)\"\s*$""".r
+}
+class MaybeQuotedValueKeyValueHandler(underlying: KeyValueHandler) extends KeyValueHandler {
+ import MaybeQuotedSingleKeyValueHandler._
+ override def apply(k: String, v: String): Unit = {
+ v match {
+ case QUOTED_REGEX(quotedV) => underlying(k, quotedV)
+ case _ => underlying(k, v)
+ }
+ }
+}
+
+/**
+ * PrintlnKeyValueHandler is very nice for debugging!
+ * Pass it in to the Unpacker to see what's going on.
+ */
+class PrintlnKeyValueHandler(prefix: String) extends KeyValueHandler {
+ override def apply(k: String, v: String): Unit = println("%s%s=%s".format(prefix, k, v))
+}
+
+/**
+ * TransformingKeyValueHandler applies the Transformers to
+ * their respective key and value before passing along to the
+ * underlying KeyValueHandler
+ */
+class TransformingKeyValueHandler(
+ underlying: KeyValueHandler,
+ keyTransform: String => String,
+ valueTransform: String => String) extends KeyValueHandler {
+ override def apply(k: String, v: String): Unit = underlying(keyTransform(k), valueTransform(v))
+}
+
+/**
+ * TrimmingKeyValueHandler trims the key and value before
+ * passing them to the underlying KeyValueHandler
+ */
+class TrimmingKeyValueHandler(underlying: KeyValueHandler)
+ extends TransformingKeyValueHandler(underlying, TrimTransformer, TrimTransformer)
+
+/**
+ * KeyTransformingKeyValueHandler applies a Transformer to the key
+ * before passing the key value pair to the underlying KeyValueHandler
+ */
+class KeyTransformingKeyValueHandler(
+ underlying: KeyValueHandler, keyTransform: String => String) extends KeyValueHandler {
+ override def apply(k: String, v: String): Unit = underlying(keyTransform(k), v)
+}
+
+/**
+ * ValueTransformingKeyValueHandler applies a Transformer to the value
+ * before passing the key value pair to the underlying KeyValueHandler
+ */
+class ValueTransformingKeyValueHandler(
+ underlying: KeyValueHandler, valueTransform: String => String) extends KeyValueHandler {
+ override def apply(k: String, v: String): Unit = underlying(k, valueTransform(v))
+}
+
+/**
+ * UrlEncodingNormalizingKeyValueHandler normalizes URLEncoded
+ * keys and values, to properly capitalize them
+ */
+class UrlEncodingNormalizingKeyValueHandler(underlying: KeyValueHandler)
+ extends TransformingKeyValueHandler(
+ underlying, UrlEncodingNormalizingTransformer, UrlEncodingNormalizingTransformer)
66 src/main/scala/com/twitter/joauth/keyvalue/KeyValueParser.scala
View
@@ -0,0 +1,66 @@
+// Copyright 2011 Twitter, Inc.
+//
+// Licensed under the Apache License, Version 2.0 (the "License"); you may not use this
+// file except in compliance with the License. You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software distributed
+// under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+// CONDITIONS OF ANY KIND, either express or implied. See the License for the
+// specific language governing permissions and limitations under the License.
+
+package com.twitter.joauth.keyvalue
+
+/**
+ * The KeyValueParser trait describes a parser that takes a String and a Seq[KeyValueHandler],
+ * and calls each handler for each key/value pair encountered in the parsed String
+ */
+