diff --git a/.gitignore b/.gitignore index 9607671..232de76 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /deps erl_crash.dump *.ez + +/doc diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7ec269d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Gary Fleshman + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 86abe60..4363532 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,65 @@ -JsonWebToken -============ +# JSON Web Token -** TODO: Add description ** +## A JSON Web Token implementation for Elixir + +### Description +An Elixir implementation of the JSON Web Token (JWT) Standards Track [RFC 7519][rfc7519] + +### Philosophy & design goals +* Minimal API surface area +* Clear separation and conformance to underlying standards + - JSON Web Signature (JWS) Standards Track [RFC 7515][rfc7515] + - JSON Web Algorithms (JWA) Standards Track [RFC 7518][rfc7518] +* Thorough test coverage +* Modularity for comprehension and extensibility +* Fail fast and hard, with maximally strict validation + - Inspired by [The Harmful Consequences of Postel's Maxim][thomson-postel] +* Implement only the REQUIRED elements of the JWT standard (initially) + +## Usage + +### JsonWebToken.sign(claims, options) + +Returns a JSON Web Token string + +`claims` (required) map + +`options` (required) map + +* **alg** (optional, default: `HS256`) +* **key** (required unless alg is 'none') + +### JsonWebToken.verify(jwt, options) + +Returns either: +* a JWT claims set map, if the Message Authentication Code (MAC), or signature, is verified +* a string, 'Invalid', otherwise + +`jwt` (required) is a JSON web token string + +`options` (required) map + +* **alg** (optional, default: `HS256`) +* **key** (required unless alg is 'none') + +### Supported encryption algorithms +The 2 REQUIRED JWT algorithms + +- HMAC using SHA-256 per [RFC 2104][rfc2104] +- none (unsecured) + +### Supported Elixir versions +Elixir 1.0.5 and up + +### Limitations +Future implementation may include these features: + +- Representation of a JWT as a JSON Web Encryption (JWE) [RFC 7516][rfc7516] +- RECOMMENDED or OPTIONAL encryption algorithms +- OPTIONAL nested JWTs + +[rfc2104]: http://tools.ietf.org/html/rfc2104 +[rfc7515]: http://tools.ietf.org/html/rfc7515 +[rfc7516]: http://tools.ietf.org/html/rfc7516 +[rfc7518]: http://tools.ietf.org/html/rfc7518 +[rfc7519]: http://tools.ietf.org/html/rfc7519 diff --git a/lib/json_web_token/format/base_64_url.ex b/lib/json_web_token/format/base_64_url.ex new file mode 100644 index 0000000..6056f27 --- /dev/null +++ b/lib/json_web_token/format/base_64_url.ex @@ -0,0 +1,49 @@ +defmodule JsonWebToken.Format.Base64Url do + @moduledoc """ + Provide base64url encoding and decoding functions without padding, based upon standard base64 encoding + and decoding functions that do use padding + + see http://tools.ietf.org/html/rfc7515#appendix-C + """ + + @doc """ + Given a string, return a url_encode64 string with all trailing "=" padding removed + + ## Example + iex> JsonWebToken.Format.Base64Url.encode("foo") + "Zm9v" + """ + def encode(string) do + string + |> Base.url_encode64 + |> base64_padding_removed + end + + defp base64_padding_removed(encoded), do: String.rstrip(encoded, ?=) + + @doc """ + Given a string encoded as url_encode64, add trailing "=" padding and return a decoded string + + ## Example + iex> JsonWebToken.Format.Base64Url.decode("YmFy") + "bar" + + The number of "=" padding characters that need to be added to the end of a url_encode64-encoded + string without padding to turn it into one with padding is a deterministic function of the length + of the encoded string. + """ + def decode(string) do + string + |> base64_padding_added + |> Base.url_decode64! + end + + defp base64_padding_added(str) do + mod = rem(String.length(str), 4) + str <> padding(mod) + end + + defp padding(0), do: "" + defp padding(1), do: raise "Invalid base64 string" + defp padding(mod), do: String.duplicate("=", (4 - mod)) +end diff --git a/mix.exs b/mix.exs index a60dbe7..e60c9be 100644 --- a/mix.exs +++ b/mix.exs @@ -2,19 +2,26 @@ defmodule JsonWebToken.Mixfile do use Mix.Project def project do - [app: :json_web_token, - version: "0.0.1", - elixir: "~> 1.0", - build_embedded: Mix.env == :prod, - start_permanent: Mix.env == :prod, - deps: deps] + [ + app: :json_web_token, + version: "0.0.1", + elixir: "~> 1.0", + build_embedded: Mix.env == :prod, + start_permanent: Mix.env == :prod, + deps: deps, + test_coverage: [tool: ExCoveralls] + ] end # Configuration for the OTP application # # Type `mix help compile.app` for more information def application do - [applications: [:logger]] + [ + applications: [ + :logger + ] + ] end # Dependencies can be Hex packages: @@ -27,6 +34,10 @@ defmodule JsonWebToken.Mixfile do # # Type `mix help deps` for more examples and options defp deps do - [] + [ + {:earmark, "~> 0.1", only: :dev}, + {:ex_doc, "~> 0.7", only: :dev}, + {:excoveralls, "~> 0.3", only: :test} + ] end end diff --git a/mix.lock b/mix.lock new file mode 100644 index 0000000..a9ddd43 --- /dev/null +++ b/mix.lock @@ -0,0 +1,8 @@ +%{"earmark": {:hex, :earmark, "0.1.17"}, + "ex_doc": {:hex, :ex_doc, "0.7.3"}, + "excoveralls": {:hex, :excoveralls, "0.3.11"}, + "exjsx": {:hex, :exjsx, "3.2.0"}, + "hackney": {:hex, :hackney, "1.2.0"}, + "idna": {:hex, :idna, "1.0.2"}, + "jsx": {:hex, :jsx, "2.6.2"}, + "ssl_verify_hostname": {:hex, :ssl_verify_hostname, "1.0.5"}} diff --git a/test/json_web_token/format/base64_url_test.exs b/test/json_web_token/format/base64_url_test.exs new file mode 100644 index 0000000..b69b46b --- /dev/null +++ b/test/json_web_token/format/base64_url_test.exs @@ -0,0 +1,66 @@ +defmodule JsonWebToken.Format.Base64UrlTest do + use ExUnit.Case + + alias JsonWebToken.Format.Base64Url + + doctest Base64Url + + defp decode_encoded_matches?(str) do + encoded = Base64Url.encode(str) + str == Base64Url.decode(encoded) + end + + test "decode/1 encode/1 typical" do + assert decode_encoded_matches?("{\"typ\":\"JWT\", \"alg\":\"HS256\"}") + end + + test "decode/1 encode/1 w whitespace" do + assert decode_encoded_matches?("{\"typ\":\"JWT\" , \"alg\":\"HS256\" }") + end + + test "decode/1 encode/1 w line feed and carriage return" do + assert decode_encoded_matches?("{\"typ\":\"JWT\",/n \"a/rlg\":\"HS256\"}") + end + + defp given_encoded_matches?(str, encoded) do + Base64Url.encode(str) == encoded && + Base64Url.decode(encoded) == str + end + + test "decode/1 w no padding char" do + str = "{\"typ\":\"JWT\", \"alg\":\"none\"}" + encoded = "eyJ0eXAiOiJKV1QiLCAiYWxnIjoibm9uZSJ9" + assert given_encoded_matches?(str, encoded) + end + + test "decode/1 w 1 padding char present" do + str = "{\"typ\":\"JWT\", \"alg\":\"algorithm\"}" + encoded = "eyJ0eXAiOiJKV1QiLCAiYWxnIjoiYWxnb3JpdGhtIn0=" + assert Base64Url.decode(encoded) == str + end + + test "decode/1 w 1 padding char removed" do + str = "{\"typ\":\"JWT\", \"alg\":\"algorithm\"}" + encoded = "eyJ0eXAiOiJKV1QiLCAiYWxnIjoiYWxnb3JpdGhtIn0" + assert given_encoded_matches?(str, encoded) + end + + test "decode/1 w 2 padding char present" do + str = "{\"typ\":\"JWT\", \"alg\":\"HS256\"}" + encoded = "eyJ0eXAiOiJKV1QiLCAiYWxnIjoiSFMyNTYifQ==" + assert Base64Url.decode(encoded) == str + end + + test "decode/1 w 2 padding char removed" do + str = "{\"typ\":\"JWT\", \"alg\":\"HS256\"}" + encoded = "eyJ0eXAiOiJKV1QiLCAiYWxnIjoiSFMyNTYifQ" + assert given_encoded_matches?(str, encoded) + end + + test "decode/1 w invalid encoding" do + message = "Invalid base64 string" + assert_raise RuntimeError, message, fn -> + Base64Url.decode("InR5cCI6IkpXVCIsICJhbGciOiJub25lI") + end + end +end