├── .credo.exs ├── .github └── workflows │ └── elixir.yml ├── .gitignore ├── CHANGELOG.md ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── RELEASE_PROCESS.md ├── config └── config.exs ├── lib ├── api_auth.ex └── api_auth │ ├── authorization_header.ex │ ├── content_hash_header.ex │ ├── content_type_header.ex │ ├── date_header.ex │ ├── header_compare.ex │ ├── header_values.ex │ ├── uri_header.ex │ └── utils.ex ├── mix.exs ├── mix.lock └── test ├── api_auth ├── authorization_header_test.exs ├── content_hash_header_test.exs ├── content_type_header_test.exs ├── date_header_test.exs ├── header_compare_test.exs ├── header_values_test.exs ├── uri_header_test.exs └── utils_test.exs ├── api_auth_test.exs └── test_helper.exs /.credo.exs: -------------------------------------------------------------------------------- 1 | # This file contains the configuration for Credo and you are probably reading 2 | # this after creating it with `mix credo.gen.config`. 3 | # 4 | # If you find anything wrong or unclear in this file, please report an 5 | # issue on GitHub: https://github.com/rrrene/credo/issues 6 | # 7 | %{ 8 | # 9 | # You can have as many configs as you like in the `configs:` field. 10 | configs: [ 11 | %{ 12 | # 13 | # Run any exec using `mix credo -C `. If no exec name is given 14 | # "default" is used. 15 | # 16 | name: "default", 17 | # 18 | # These are the files included in the analysis: 19 | files: %{ 20 | # 21 | # You can give explicit globs or simply directories. 22 | # In the latter case `**/*.{ex,exs}` will be used. 23 | # 24 | included: ["lib/", "src/", "web/", "apps/", "test/"], 25 | excluded: [~r"/_build/", ~r"/deps/"] 26 | }, 27 | # 28 | # If you create your own checks, you must specify the source files for 29 | # them here, so they can be loaded by Credo before running the analysis. 30 | # 31 | requires: [], 32 | # 33 | # Credo automatically checks for updates, like e.g. Hex does. 34 | # You can disable this behaviour below: 35 | # 36 | check_for_updates: true, 37 | # 38 | # If you want to enforce a style guide and need a more traditional linting 39 | # experience, you can change `strict` to `true` below: 40 | # 41 | strict: false, 42 | # 43 | # If you want to use uncolored output by default, you can change `color` 44 | # to `false` below: 45 | # 46 | color: true, 47 | # 48 | # You can customize the parameters of any check by adding a second element 49 | # to the tuple. 50 | # 51 | # To disable a check put `false` as second element: 52 | # 53 | # {Credo.Check.Design.DuplicatedCode, false} 54 | # 55 | checks: [ 56 | {Credo.Check.Consistency.ExceptionNames}, 57 | {Credo.Check.Consistency.LineEndings}, 58 | {Credo.Check.Consistency.ParameterPatternMatching}, 59 | {Credo.Check.Consistency.SpaceAroundOperators}, 60 | {Credo.Check.Consistency.SpaceInParentheses}, 61 | {Credo.Check.Consistency.TabsOrSpaces}, 62 | 63 | # For some checks, like AliasUsage, you can only customize the priority 64 | # Priority values are: `low, normal, high, higher` 65 | # 66 | {Credo.Check.Design.AliasUsage, priority: :low}, 67 | 68 | # For others you can set parameters 69 | 70 | # If you don't want the `setup` and `test` macro calls in ExUnit tests 71 | # or the `schema` macro in Ecto schemas to trigger DuplicatedCode, just 72 | # set the `excluded_macros` parameter to `[:schema, :setup, :test]`. 73 | # 74 | {Credo.Check.Design.DuplicatedCode, excluded_macros: []}, 75 | 76 | # You can also customize the exit_status of each check. 77 | # If you don't want TODO comments to cause `mix credo` to fail, just 78 | # set this value to 0 (zero). 79 | # 80 | {Credo.Check.Design.TagTODO, exit_status: 2}, 81 | {Credo.Check.Design.TagFIXME}, 82 | 83 | {Credo.Check.Readability.FunctionNames}, 84 | {Credo.Check.Readability.LargeNumbers}, 85 | {Credo.Check.Readability.MaxLineLength, priority: :low, max_length: 80}, 86 | {Credo.Check.Readability.ModuleAttributeNames}, 87 | {Credo.Check.Readability.ModuleDoc}, 88 | {Credo.Check.Readability.ModuleNames}, 89 | {Credo.Check.Readability.ParenthesesOnZeroArityDefs}, 90 | {Credo.Check.Readability.ParenthesesInCondition}, 91 | {Credo.Check.Readability.PredicateFunctionNames}, 92 | {Credo.Check.Readability.PreferImplicitTry}, 93 | {Credo.Check.Readability.RedundantBlankLines}, 94 | {Credo.Check.Readability.StringSigils}, 95 | {Credo.Check.Readability.TrailingBlankLine}, 96 | {Credo.Check.Readability.TrailingWhiteSpace}, 97 | {Credo.Check.Readability.VariableNames}, 98 | {Credo.Check.Readability.Semicolons}, 99 | {Credo.Check.Readability.SpaceAfterCommas}, 100 | 101 | {Credo.Check.Refactor.DoubleBooleanNegation}, 102 | {Credo.Check.Refactor.CondStatements}, 103 | {Credo.Check.Refactor.CyclomaticComplexity}, 104 | {Credo.Check.Refactor.FunctionArity}, 105 | {Credo.Check.Refactor.LongQuoteBlocks}, 106 | {Credo.Check.Refactor.MatchInCondition}, 107 | {Credo.Check.Refactor.NegatedConditionsInUnless}, 108 | {Credo.Check.Refactor.NegatedConditionsWithElse}, 109 | {Credo.Check.Refactor.Nesting}, 110 | {Credo.Check.Refactor.PipeChainStart}, 111 | {Credo.Check.Refactor.UnlessWithElse}, 112 | 113 | {Credo.Check.Warning.BoolOperationOnSameValues}, 114 | {Credo.Check.Warning.IExPry}, 115 | {Credo.Check.Warning.IoInspect}, 116 | {Credo.Check.Warning.LazyLogging, false}, 117 | {Credo.Check.Warning.OperationOnSameValues}, 118 | {Credo.Check.Warning.OperationWithConstantResult}, 119 | {Credo.Check.Warning.UnusedEnumOperation}, 120 | {Credo.Check.Warning.UnusedFileOperation}, 121 | {Credo.Check.Warning.UnusedKeywordOperation}, 122 | {Credo.Check.Warning.UnusedListOperation}, 123 | {Credo.Check.Warning.UnusedPathOperation}, 124 | {Credo.Check.Warning.UnusedRegexOperation}, 125 | {Credo.Check.Warning.UnusedStringOperation}, 126 | {Credo.Check.Warning.UnusedTupleOperation}, 127 | {Credo.Check.Warning.RaiseInsideRescue}, 128 | 129 | # Controversial and experimental checks (opt-in, just remove `, false`) 130 | # 131 | {Credo.Check.Refactor.ABCSize, false}, 132 | {Credo.Check.Refactor.AppendSingleItem, false}, 133 | {Credo.Check.Refactor.VariableRebinding, false}, 134 | {Credo.Check.Warning.MapGetUnsafePass, false}, 135 | {Credo.Check.Consistency.MultiAliasImportRequireUse, false}, 136 | # Custom checks can be created using `mix credo.gen.check`. 137 | # 138 | ] 139 | } 140 | ] 141 | } 142 | -------------------------------------------------------------------------------- /.github/workflows/elixir.yml: -------------------------------------------------------------------------------- 1 | name: Elixir CI 2 | on: 3 | push: 4 | branches: [ "**" ] 5 | permissions: 6 | contents: read 7 | jobs: 8 | build: 9 | name: Build and test 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v3 13 | - name: Set up Elixir 14 | uses: erlef/setup-beam@v1 15 | with: 16 | elixir-version: 1.14.0 17 | otp-version: 24 18 | - name: Restore dependencies cache 19 | uses: actions/cache@v3 20 | with: 21 | path: deps 22 | key: ${{ runner.os }}-mix-${{ hashFiles('**/mix.lock') }} 23 | restore-keys: ${{ runner.os }}-mix- 24 | - name: Install Mix Dependencies 25 | run: mix deps.get 26 | - name: Check package compiles without warnings 27 | run: mix compile --warnings-as-errors 28 | - name: Check Formatting 29 | run: mix format "lib/**/*.{ex,exs}" "test/**/*.{ex,exs}" --check-formatted 30 | - name: Run credo linting 31 | run: mix credo 32 | - name: Check for security vulnerabilities in deps 33 | run: mix deps.audit 34 | - name: Run Test Suite 35 | run: mix test 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where 3rd-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## Current release (in development) 2 | 3 | ## 0.2.0 4 | 5 | * Include query parameters in authorization header calculation. [#32](https://github.com/TheGnarCo/api_auth_ex/pull/32) 6 | [Jader Correa](https://github.com/jadercorrea) 7 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as 6 | contributors and maintainers pledge to making participation in our project and 7 | our community a harassment-free experience for everyone, regardless of age, body 8 | size, disability, ethnicity, gender identity and expression, level of experience, 9 | nationality, personal appearance, race, religion, or sexual identity and 10 | orientation. 11 | 12 | ## Our Standards 13 | 14 | Examples of behavior that contributes to creating a positive environment 15 | include: 16 | 17 | * Using welcoming and inclusive language 18 | * Being respectful of differing viewpoints and experiences 19 | * Gracefully accepting constructive criticism 20 | * Focusing on what is best for the community 21 | * Showing empathy towards other community members 22 | 23 | Examples of unacceptable behavior by participants include: 24 | 25 | * The use of sexualized language or imagery and unwelcome sexual attention or 26 | advances 27 | * Trolling, insulting/derogatory comments, and personal or political attacks 28 | * Public or private harassment 29 | * Publishing others' private information, such as a physical or electronic 30 | address, without explicit permission 31 | * Other conduct which could reasonably be considered inappropriate in a 32 | professional setting 33 | 34 | ## Our Responsibilities 35 | 36 | Project maintainers are responsible for clarifying the standards of acceptable 37 | behavior and are expected to take appropriate and fair corrective action in 38 | response to any instances of unacceptable behavior. 39 | 40 | Project maintainers have the right and responsibility to remove, edit, or 41 | reject comments, commits, code, wiki edits, issues, and other contributions 42 | that are not aligned to this Code of Conduct, or to ban temporarily or 43 | permanently any contributor for other behaviors that they deem inappropriate, 44 | threatening, offensive, or harmful. 45 | 46 | ## Scope 47 | 48 | This Code of Conduct applies both within project spaces and in public spaces 49 | when an individual is representing the project or its community. Examples of 50 | representing a project or community include using an official project e-mail 51 | address, posting via an official social media account, or acting as an appointed 52 | representative at an online or offline event. Representation of a project may be 53 | further defined and clarified by project maintainers. 54 | 55 | ## Enforcement 56 | 57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be 58 | reported by contacting the project team at conduct@gnar.dog. All 59 | complaints will be reviewed and investigated and will result in a response that 60 | is deemed necessary and appropriate to the circumstances. The project team is 61 | obligated to maintain confidentiality with regard to the reporter of an incident. 62 | Further details of specific enforcement policies may be posted separately. 63 | 64 | Project maintainers who do not follow or enforce the Code of Conduct in good 65 | faith may face temporary or permanent repercussions as determined by other 66 | members of the project's leadership. 67 | 68 | ## Attribution 69 | 70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, 71 | available at [http://contributor-covenant.org/version/1/4][version] 72 | 73 | [homepage]: http://contributor-covenant.org 74 | [version]: http://contributor-covenant.org/version/1/4/ 75 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 The Gnar Company 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ApiAuth 2 | [![Elixir CI](https://github.com/TheGnarCo/api_auth_ex/actions/workflows/elixir.yml/badge.svg?branch=master)](https://github.com/TheGnarCo/api_auth_ex/actions/workflows/elixir.yml) 3 | 4 | Gitmoji 6 | 7 | 8 | HMAC API authentication. 9 | 10 | This is Elixir implementation should be compatible with [https://github.com/mgomes/api_auth](https://github.com/mgomes/api_auth) 11 | 12 | ## Installation 13 | 14 | It is [available in Hex](https://hex.pm/packages/api_auth) and can be installed 15 | by adding `api_auth` to your list of dependencies in `mix.exs`: 16 | 17 | ```elixir 18 | def deps do 19 | [ 20 | {:api_auth, "~> 0.4.0"} 21 | ] 22 | end 23 | ``` 24 | 25 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 26 | and published on [HexDocs](https://hexdocs.pm). The docs can 27 | be found at [https://hexdocs.pm/api_auth](https://hexdocs.pm/api_auth). 28 | 29 | ## Usage 30 | 31 | ### HTTPotion 32 | 33 | To make a GET request: 34 | 35 | ```elixir 36 | headers = ApiAuth.headers([], "/path", client_id, secret_key) 37 | 38 | "http://example.com/path" 39 | |> HTTPotion.get(headers: headers) 40 | ``` 41 | 42 | Or a POST request: 43 | 44 | ```elixir 45 | body = "post body" 46 | headers = ApiAuth.headers([], "/post/path", client_id, secret_key, 47 | method: "POST", content: body) 48 | 49 | "http://example.com/post/path" 50 | |> HTTPotion.post(body: body, headers: headers) 51 | ``` 52 | 53 | ### HTTPoison 54 | 55 | To make a GET request: 56 | 57 | ```elixir 58 | headers = ApiAuth.headers([], "/path", client_id, secret_key) 59 | 60 | "http://example.com/path" 61 | |> HTTPoison.get(headers) 62 | ``` 63 | 64 | Or a POST request: 65 | 66 | ```elixir 67 | body = "{}" 68 | headers = ApiAuth.headers(["Content-Type": "application/json"], "/post/path", 69 | client_id, secret_key, method: "POST", content: body) 70 | 71 | "http://example.com/path" 72 | |> HTTPoison.post(body, headers) 73 | ``` 74 | 75 | ### Phoenix 76 | 77 | To authenticate all requests for a particular pipeline, create a new 78 | plug and configure it to use `ApiAuth`. 79 | 80 | Note that `Plug.Conn.read_body/2` can only be called once. This means that 81 | if you need the body for something else, you have to make sure to save it. 82 | There are also particular issues with JSON APIs due to the way `Plug.Parsers.JSON` 83 | works. 84 | 85 | [This issue](https://github.com/phoenixframework/phoenix/issues/459) 86 | has some discussion about these problems and different workarounds. 87 | The sample code below assumes that the raw body has been saved in `conn.assigns.raw_body`. 88 | 89 | ```elixir 90 | # lib/myapp_web/router.ex 91 | 92 | defmodule MyappWeb.Router do 93 | use MyappWeb, :router 94 | 95 | pipeline :api do 96 | plug(Myapp.AuthenticationPlug) 97 | end 98 | end 99 | ``` 100 | 101 | ```elixir 102 | # lib/myapp_web/plugs/authentication_plug.ex 103 | 104 | defmodule MyappWeb.AuthenticationPlug do 105 | @moduledoc """ 106 | Authentication plug 107 | Using the `api_auth` package (https://github.com/TheGnarCo/api_auth_ex#phoenix) 108 | this plug allows requests to continue through the pipeline only if they 109 | have a valid HMAC signature. 110 | """ 111 | 112 | import Plug.Conn 113 | 114 | def init(default), do: default 115 | 116 | def call(conn, _default) do 117 | conn 118 | |> authorize() 119 | end 120 | 121 | defp authorize(conn) do 122 | client_id = "client id" 123 | secret_key = "secret key" 124 | body = get_body(conn) 125 | 126 | %{ 127 | query_string: query_string, 128 | req_headers: req_headers, 129 | request_path: request_path, 130 | method: method, 131 | } = conn 132 | 133 | full_path = request_path 134 | |> URI.parse() 135 | |> Map.put(:query, query_string) 136 | |> URI.to_string() 137 | 138 | # you may need to add `content_algorithm: :md5` depending on the code signing the request 139 | # see the compatibility section of the README 140 | authentic = ApiAuth.authentic?(req_headers, full_path, client_id, 141 | secret_key, method: method, 142 | content: body) 143 | 144 | if authentic do 145 | conn 146 | else 147 | conn 148 | |> send_resp(:unauthorized, "") 149 | |> halt() 150 | end 151 | end 152 | 153 | # in order for this code to work, `read_body/2` must be called somewhere earlier 154 | # in the pipeline and the result must be stored in `conn.assigns.raw_body` 155 | # (see https://github.com/phoenixframework/phoenix/issues/459) 156 | defp get_body(%{assigns: assigns}) do 157 | case assigns do 158 | %{raw_body: body} -> body 159 | _ -> "" 160 | end 161 | end 162 | end 163 | ``` 164 | 165 | If you have multiple clients, you'll need to look up the secret key by client id. 166 | The plug would look similar to the one above but with a few changes: 167 | 168 | ```elixir 169 | defmodule MyappWeb.AuthenticationPlug do 170 | import Plug.Conn 171 | 172 | defp authorize(conn) do 173 | client_id = ApiAuth.client_id(conn.req_headers) 174 | {:ok, secret_key} = Myapp.Client.get_secret_key(client_id) 175 | 176 | ... 177 | end 178 | 179 | ... 180 | end 181 | ``` 182 | 183 | ### Compatibility 184 | 185 | Using this library with [https://github.com/mgomes/api_auth](https://github.com/mgomes/api_auth) for Ruby/Rails 186 | requires some configuration. 187 | 188 | By default, the Rails library uses `sha1` as the HMAC hash function. 189 | It also uses `md5` as the hash function for hashing content in PUT and POST requests. 190 | This library uses `sha256` by default for both. 191 | 192 | #### Using api_auth_ex as a client 193 | To make a request to a server which is using the Rails library with default configuration: 194 | 195 | ```elixir 196 | headers 197 | |> ApiAuth.headers(path, client_id, secret_key, content_algorithm: :md5, 198 | signature_algorithm: :sha) 199 | ``` 200 | 201 | Or with `sha256` as the HMAC hash function: 202 | 203 | ```elixir 204 | headers 205 | |> ApiAuth.headers(path, client_id, secret_key, content_algorithm: :md5) 206 | ``` 207 | 208 | #### Using api_auth_ex as a server 209 | To tell if a request generated by the Rails library is authentic: 210 | 211 | ```elixir 212 | headers 213 | |> ApiAuth.authentic?(path, client_id, secret_key, content_algorithm: :md5, 214 | signature_algorithm: :sha) 215 | ``` 216 | 217 | Or with `sha256` as the HMAC function: 218 | 219 | ```elixir 220 | headers 221 | |> ApiAuth.authentic?(path, client_id, secret_key, content_algorithm: :md5) 222 | ``` 223 | 224 | ## Running tests 225 | 226 | * `mix deps.get` 227 | * `mix test` 228 | 229 | ## About The Gnar Company 230 | 231 | ![The Gnar Company](https://avatars0.githubusercontent.com/u/17011419?s=100&v=4) 232 | 233 | If you’re ready to dream it, we’re ready to build it. The Gnar is a custom software company ready to tackle your biggest challenges. Visit [The Gnar Company website](https://www.thegnar.com/) to learn more about us or [contact us](https://www.thegnar.com/contact) to see how we can help design and develop your product. 234 | 235 | -------------------------------------------------------------------------------- /RELEASE_PROCESS.md: -------------------------------------------------------------------------------- 1 | ## Publishing a new version 2 | 3 | To publish a new version: 4 | 5 | * Ensure you have access to publish the package (see docs [here](https://hexdocs.pm/hex/Mix.Tasks.Hex.Owner.html)) 6 | * Update the `version` string in `mix.exs` according to [SemVer](https://semver.org/spec/v2.0.0.html) 7 | * Run `mix hex.publish` 8 | -------------------------------------------------------------------------------- /config/config.exs: -------------------------------------------------------------------------------- 1 | # This file is responsible for configuring your application 2 | # and its dependencies with the aid of the Mix.Config module. 3 | import Config 4 | 5 | # This configuration is loaded before any dependency and is restricted 6 | # to this project. If another project depends on this project, this 7 | # file won't be loaded nor affect the parent project. For this reason, 8 | # if you want to provide default values for your application for 9 | # 3rd-party users, it should be done in your "mix.exs" file. 10 | 11 | # You can configure your application as: 12 | # 13 | # config :api_auth, key: :value 14 | # 15 | # and access this configuration in your application as: 16 | # 17 | # Application.get_env(:api_auth, :key) 18 | # 19 | # You can also configure a 3rd-party app: 20 | # 21 | # config :logger, level: :info 22 | # 23 | 24 | # It is also possible to import configuration files, relative to this 25 | # directory. For example, you can emulate configuration per environment 26 | # by uncommenting the line below and defining dev.exs, test.exs and such. 27 | # Configuration from the imported file will override the ones defined 28 | # here (which is why it is important to import them last). 29 | # 30 | # import_config "#{Mix.env}.exs" 31 | -------------------------------------------------------------------------------- /lib/api_auth.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth do 2 | @moduledoc """ 3 | This is the ApiAuth module. 4 | 5 | It provides an HMAC authentication system for APIs. 6 | """ 7 | 8 | alias ApiAuth.HeaderValues 9 | alias ApiAuth.HeaderCompare 10 | alias ApiAuth.Utils 11 | 12 | alias ApiAuth.ContentTypeHeader 13 | alias ApiAuth.DateHeader 14 | alias ApiAuth.UriHeader 15 | alias ApiAuth.ContentHashHeader 16 | alias ApiAuth.ContentHashHeader 17 | alias ApiAuth.AuthorizationHeader 18 | 19 | @doc """ 20 | Takes a keyword list of headers and arguments necessary for generating the 21 | Authorization header and returns an updated keyword list of headers. 22 | 23 | ## Examples 24 | 25 | iex> [DATE: "Sat, 01 Jan 2000 00:00:00 GMT", "Content-Type": "application/json"] 26 | ...> |> ApiAuth.headers("/path", "client_id", "secret_key", 27 | ...> method: "PUT", content: "{\\"foo\\": \\"bar\\"}") 28 | [Authorization: "APIAuth-HMAC-SHA256 client_id:v5+Ooq88txd0cFyfSXYn03EFK/NQW9Gepk5YIdkZ4qM=", 29 | "X-APIAuth-Content-Hash": "Qm/ATwS/j9tYMdw3u7bc9w9jo34FpoxupfY+ha5Xk3Y=", 30 | DATE: "Sat, 01 Jan 2000 00:00:00 GMT", 31 | "Content-Type": "application/json"] 32 | 33 | """ 34 | def headers(request_headers, uri, client_id, secret_key, opts \\ []) do 35 | parsed = parse(opts) 36 | 37 | request_headers 38 | |> Utils.convert() 39 | |> HeaderValues.wrap() 40 | |> ContentTypeHeader.headers() 41 | |> DateHeader.headers() 42 | |> UriHeader.headers(uri) 43 | |> ContentHashHeader.headers(parsed.method, parsed.content, parsed.content_algorithm) 44 | |> AuthorizationHeader.override( 45 | parsed.method, 46 | client_id, 47 | secret_key, 48 | parsed.signature_algorithm 49 | ) 50 | |> HeaderValues.unwrap() 51 | end 52 | 53 | @doc """ 54 | Takes a request header and arguments necessary for validating the Authorization header 55 | and returns true if the request is authentic and false otherwise 56 | 57 | ## Examples 58 | 59 | iex> headers = ApiAuth.headers([], "/path", "client_id", "secret_key") 60 | ...> ApiAuth.authentic?(headers, "/path", "client_id", "secret_key") 61 | true 62 | 63 | iex> headers = ApiAuth.headers([], "/path", "client_id", "secret_key") 64 | ...> ApiAuth.authentic?(headers, "/path", "client_id", "hacker") 65 | false 66 | 67 | """ 68 | def authentic?(request_headers, uri, client_id, secret_key, opts \\ []) do 69 | parsed = parse(opts) 70 | 71 | converted_headers = Utils.convert(request_headers) 72 | 73 | valid = valid_headers(converted_headers, uri, client_id, secret_key, opts) 74 | 75 | valid 76 | |> HeaderCompare.wrap(converted_headers) 77 | |> ContentHashHeader.compare(parsed.method) 78 | |> AuthorizationHeader.compare() 79 | |> DateHeader.compare() 80 | |> HeaderCompare.to_boolean() 81 | end 82 | 83 | @doc """ 84 | Takes a keyword list of headers and pulls the client id from the Authorization header 85 | returns the {:ok, client_id} or {:error} 86 | 87 | ## Examples 88 | 89 | iex> headers = [Authorization: "APIAuth-HMAC-SHA256 client_id:v5+Ooq88txd0cFyfSXYn03EFK/NQW9Gepk5YIdkZ4qM="] 90 | ...> ApiAuth.client_id(headers) 91 | {:ok, "client_id"} 92 | 93 | iex> headers = [] 94 | ...> ApiAuth.client_id(headers) 95 | :error 96 | 97 | """ 98 | def client_id(headers) do 99 | headers 100 | |> Utils.convert() 101 | |> AuthorizationHeader.extract_client_id() 102 | end 103 | 104 | defp valid_headers(request_headers, uri, client_id, secret_key, opts) do 105 | parsed = parse(opts) 106 | 107 | request_headers 108 | |> HeaderValues.wrap() 109 | |> ContentTypeHeader.headers() 110 | |> DateHeader.headers() 111 | |> UriHeader.override(uri) 112 | |> ContentHashHeader.override(parsed.method, parsed.content, parsed.content_algorithm) 113 | |> AuthorizationHeader.override( 114 | parsed.method, 115 | client_id, 116 | secret_key, 117 | parsed.signature_algorithm 118 | ) 119 | |> HeaderValues.unwrap() 120 | end 121 | 122 | defp parse(opts) do 123 | %{ 124 | method: opts |> Keyword.get(:method, "GET") |> String.upcase(), 125 | content: opts |> Keyword.get(:content, ""), 126 | content_algorithm: opts |> Keyword.get(:content_algorithm, :sha256), 127 | signature_algorithm: opts |> Keyword.get(:signature_algorithm, :sha256) 128 | } 129 | end 130 | end 131 | -------------------------------------------------------------------------------- /lib/api_auth/authorization_header.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.AuthorizationHeader do 2 | @moduledoc false 3 | 4 | @keys [:Authorization, :AUTHORIZATION] 5 | @header_key :Authorization 6 | @value_key :authorization 7 | @pattern ~r{\AAPIAuth(?:-HMAC-(?:MD5|SHA(?:1|224|256|384|512)?))? (?[^:]+):(?.+)\z} 8 | 9 | alias ApiAuth.HeaderValues 10 | alias ApiAuth.HeaderCompare 11 | alias ApiAuth.Utils 12 | 13 | def override(hv, method, client_id, secret_key, algorithm) do 14 | canonical = 15 | canonical_string( 16 | method, 17 | hv |> HeaderValues.get(:content_type), 18 | hv |> HeaderValues.get(:content_hash), 19 | hv |> HeaderValues.get(:uri, "/"), 20 | hv |> HeaderValues.get(:timestamp) 21 | ) 22 | 23 | authorization = 24 | canonical 25 | |> signature(secret_key, algorithm) 26 | |> header_string(client_id, algorithm) 27 | 28 | hv |> HeaderValues.put(@keys, @header_key, @value_key, authorization) 29 | end 30 | 31 | def extract_client_id(headers) do 32 | with {:ok, header} <- Utils.find(headers, @keys), 33 | %{"client_id" => client_id} <- Regex.named_captures(@pattern, header) do 34 | {:ok, client_id} 35 | else 36 | _ -> :error 37 | end 38 | end 39 | 40 | defp canonical_string(method, content_type, content_hash, uri, timestamp) do 41 | [method, content_type, content_hash, uri, timestamp] 42 | |> Enum.join(",") 43 | end 44 | 45 | defp signature(canonical_string, secret_key, algorithm) do 46 | :hmac 47 | |> :crypto.mac(algorithm, secret_key, canonical_string) 48 | |> Base.encode64() 49 | end 50 | 51 | def compare(hc) do 52 | hc |> HeaderCompare.compare(@keys) 53 | end 54 | 55 | defp header_string(signature, client_id, :sha256) do 56 | "APIAuth-HMAC-SHA256 #{client_id}:#{signature}" 57 | end 58 | 59 | defp header_string(signature, client_id, _algorithm) do 60 | "APIAuth #{client_id}:#{signature}" 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /lib/api_auth/content_hash_header.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.ContentHashHeader do 2 | @moduledoc false 3 | 4 | @methods ["PUT", "POST"] 5 | @keys [ 6 | :"X-APIAuth-Content-Hash", 7 | :"X-APIAUTH-CONTENT-HASH", 8 | :X_APIAUTH_CONTENT_HASH, 9 | :"Content-MD5", 10 | :"CONTENT-MD5", 11 | :CONTENT_MD5 12 | ] 13 | @header_key :"X-APIAuth-Content-Hash" 14 | @md5_header_key :"Content-MD5" 15 | @value_key :content_hash 16 | 17 | alias ApiAuth.HeaderValues 18 | alias ApiAuth.HeaderCompare 19 | 20 | def headers(hv, method, content, :md5) when method in @methods do 21 | content_hash = hash(:md5, content) 22 | 23 | hv |> HeaderValues.put_new(@keys, @md5_header_key, @value_key, content_hash) 24 | end 25 | 26 | def headers(hv, method, content, algorithm) when method in @methods do 27 | content_hash = hash(algorithm, content) 28 | 29 | hv |> HeaderValues.put_new(@keys, @header_key, @value_key, content_hash) 30 | end 31 | 32 | def headers(hv, _method, _content, _algorithm) do 33 | hv |> HeaderValues.copy(@keys, @value_key) 34 | end 35 | 36 | def override(hv, method, content, :md5) when method in @methods do 37 | content_hash = hash(:md5, content) 38 | 39 | hv |> HeaderValues.put(@keys, @md5_header_key, @value_key, content_hash) 40 | end 41 | 42 | def override(hv, method, content, algorithm) when method in @methods do 43 | content_hash = hash(algorithm, content) 44 | 45 | hv |> HeaderValues.put(@keys, @header_key, @value_key, content_hash) 46 | end 47 | 48 | def override(hv, _method, _content, _algorithm) do 49 | hv |> HeaderValues.copy(@keys, @value_key) 50 | end 51 | 52 | def compare(hc, method) when method in @methods do 53 | hc |> HeaderCompare.compare(@keys) 54 | end 55 | 56 | def compare(hc, _method) do 57 | hc 58 | end 59 | 60 | defp hash(algorithm, content) do 61 | algorithm 62 | |> :crypto.hash(content) 63 | |> Base.encode64() 64 | end 65 | end 66 | -------------------------------------------------------------------------------- /lib/api_auth/content_type_header.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.ContentTypeHeader do 2 | @moduledoc false 3 | 4 | @keys [:"Content-Type", :"CONTENT-TYPE", :CONTENT_TYPE, :HTTP_CONTENT_TYPE] 5 | @value_key :content_type 6 | 7 | alias ApiAuth.HeaderValues 8 | 9 | def headers(hv) do 10 | HeaderValues.copy(hv, @keys, @value_key) 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /lib/api_auth/date_header.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.DateHeader do 2 | @moduledoc false 3 | 4 | @keys [:DATE, :HTTP_DATE] 5 | @header_key :DATE 6 | @value_key :timestamp 7 | @allowed_skew 900 8 | @httpdate_format_string "%a, %d %b %Y %H:%M:%S GMT" 9 | @utc_timezone "Etc/UTC" 10 | 11 | alias ApiAuth.HeaderValues 12 | alias ApiAuth.HeaderCompare 13 | 14 | alias Timex.Parse.DateTime.Parser 15 | 16 | def headers(hv) do 17 | hv |> HeaderValues.put_new(@keys, @header_key, @value_key, timestamp()) 18 | end 19 | 20 | def compare(hc) do 21 | hc |> HeaderCompare.compare(@keys, ×tamp_compare/2) 22 | end 23 | 24 | @doc """ 25 | Takes a DateTime and returns a string with the date-time in RFC 2616 format. 26 | This format is used in the HTTP protocol. Note that the date-time will always be "shifted" to UTC. 27 | """ 28 | def httpdate(dt) do 29 | Calendar.strftime(dt, @httpdate_format_string) 30 | end 31 | 32 | @doc """ 33 | Parses httpdates into Datetime structs 34 | iex> parse_httpdate("Sat, 06 Sep 2014 09:09:08 GMT") 35 | {:ok, 36 | %DateTime{ 37 | year: 2014, 38 | month: 9, 39 | day: 6, 40 | hour: 9, 41 | minute: 9, 42 | second: 8, 43 | time_zone: "Etc/UTC", 44 | zone_abbr: "UTC", 45 | std_offset: 0, 46 | utc_offset: 0, 47 | microsecond: {0, 0} 48 | } 49 | } 50 | """ 51 | def parse_httpdate(dt) do 52 | case Parser.parse(dt, @httpdate_format_string, :strftime) do 53 | {:ok, result} -> DateTime.from_naive(result, @utc_timezone) 54 | {:error, error_msg} -> {:error, error_msg} 55 | end 56 | end 57 | 58 | defp now do 59 | Timex.now(@utc_timezone) 60 | end 61 | 62 | # NOTE: Returns current datetime in RFC 2616 format. 63 | # Uses 'GMT' instead of 'UTC' for timezone. 64 | # e.g. "Mon, 23 Oct 2023 14:45:18 GMT" 65 | defp timestamp do 66 | now() 67 | |> httpdate 68 | end 69 | 70 | defp timestamp_compare(t1, t2) do 71 | t1 == t2 && timestamp_within_skew?(parse_httpdate(t1)) 72 | end 73 | 74 | defp timestamp_within_skew?({:ok, time}) do 75 | case Timex.diff(now(), time, :second) do 76 | seconds when seconds < 0 -> false 77 | seconds when seconds == 0 -> true 78 | seconds when seconds <= @allowed_skew -> true 79 | _ -> false 80 | end 81 | end 82 | 83 | defp timestamp_within_skew?({:error, _}) do 84 | false 85 | end 86 | 87 | defp timestamp_within_skew?(_timestamp) do 88 | false 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/api_auth/header_compare.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.HeaderCompare do 2 | @moduledoc false 3 | 4 | alias ApiAuth.Utils 5 | alias Plug.Crypto 6 | 7 | def wrap(valid, request) do 8 | {:ok, valid, request} 9 | end 10 | 11 | def compare(hc, keys) do 12 | compare(hc, keys, &Crypto.secure_compare/2) 13 | end 14 | 15 | def compare({:ok, valid, request} = hc, keys, fun) do 16 | with {:ok, valid_value} <- Utils.find(valid, keys), 17 | {:ok, request_value} <- Utils.find(request, keys), 18 | true <- fun.(valid_value, request_value) do 19 | hc 20 | else 21 | _ -> :error 22 | end 23 | end 24 | 25 | def compare(_hc, _keys, _fun) do 26 | :error 27 | end 28 | 29 | def to_boolean({:ok, _valid, _request}) do 30 | true 31 | end 32 | 33 | def to_boolean(_hc) do 34 | false 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /lib/api_auth/header_values.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.HeaderValues do 2 | @moduledoc false 3 | 4 | alias ApiAuth.Utils 5 | 6 | def wrap(headers) do 7 | {headers, %{}} 8 | end 9 | 10 | def unwrap({headers, _assigns}) do 11 | headers 12 | end 13 | 14 | def transform({headers, assigns}, key, default, fun) do 15 | {headers, Map.update(assigns, key, default, fun)} 16 | end 17 | 18 | def get({_headers, assigns}, key, default \\ "") do 19 | Map.get(assigns, key, default) 20 | end 21 | 22 | def copy({headers, assigns}, keys, value_key, default \\ "") do 23 | header = Utils.find(headers, keys) 24 | 25 | new_assigns = 26 | case header do 27 | {:ok, v} -> assigns |> Map.put(value_key, v) 28 | _ -> assigns |> Map.put(value_key, default) 29 | end 30 | 31 | {headers, new_assigns} 32 | end 33 | 34 | def put({headers, assigns}, keys, header_key, value_key, default) do 35 | clean_headers = Utils.reject(headers, keys) 36 | 37 | { 38 | clean_headers |> Keyword.put(header_key, default), 39 | assigns |> Map.put(value_key, default) 40 | } 41 | end 42 | 43 | def put_new({headers, assigns}, keys, header_key, value_key, default) do 44 | header = Utils.find(headers, keys) 45 | 46 | new_headers = 47 | case header do 48 | {:ok, _v} -> headers 49 | _ -> headers |> Keyword.put(header_key, default) 50 | end 51 | 52 | new_assigns = 53 | case header do 54 | {:ok, v} -> assigns |> Map.put(value_key, v) 55 | _ -> assigns |> Map.put(value_key, default) 56 | end 57 | 58 | {new_headers, new_assigns} 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/api_auth/uri_header.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.UriHeader do 2 | @moduledoc false 3 | 4 | @keys [:"X-Original-URI", :"X-ORIGINAL-URI", :X_ORIGINAL_URI, :HTTP_X_ORIGINAL_URI] 5 | @header_key :"X-Original-URI" 6 | @value_key :uri 7 | 8 | alias ApiAuth.HeaderValues 9 | 10 | def headers(hv, uri) do 11 | hv 12 | |> HeaderValues.copy(@keys, @value_key, uri) 13 | |> HeaderValues.transform(@value_key, "/", &parse_uri/1) 14 | end 15 | 16 | def override(hv, uri) do 17 | hv 18 | |> HeaderValues.put(@keys, @header_key, @value_key, uri) 19 | |> HeaderValues.transform(@value_key, "/", &parse_uri/1) 20 | end 21 | 22 | def parse_uri(uri) do 23 | %{path: path, query: query} = URI.parse(uri) 24 | 25 | case query do 26 | nil -> value_for(path) 27 | "" -> value_for(path) 28 | _ -> "#{path}?#{query}" 29 | end 30 | end 31 | 32 | defp value_for(path) do 33 | if path && path != "", do: path, else: "/" 34 | end 35 | end 36 | -------------------------------------------------------------------------------- /lib/api_auth/utils.ex: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.Utils do 2 | @moduledoc false 3 | 4 | def find(headers, keys) do 5 | pair = Enum.find(headers, member_fun(keys)) 6 | 7 | case pair do 8 | {_k, v} -> {:ok, v} 9 | _ -> :error 10 | end 11 | end 12 | 13 | def reject(headers, keys) do 14 | Enum.reject(headers, member_fun(keys)) 15 | end 16 | 17 | def convert(headers) do 18 | Enum.map(headers, &convert_key/1) 19 | end 20 | 21 | defp member_fun(keys) do 22 | fn {k, _v} -> Enum.member?(keys, k) end 23 | end 24 | 25 | defp convert_key({key, value}) when is_bitstring(key) do 26 | new_key = 27 | key 28 | |> String.upcase() 29 | |> String.to_atom() 30 | 31 | {new_key, value} 32 | end 33 | 34 | defp convert_key(tuple) do 35 | tuple 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.Mixfile do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :api_auth, 7 | version: "0.4.0", 8 | elixir: "~> 1.14.0", 9 | description: "HMAC API Authentication", 10 | source_url: "https://github.com/TheGnarCo/api_auth_ex/", 11 | start_permanent: Mix.env() == :prod, 12 | deps: deps(), 13 | package: package() 14 | ] 15 | end 16 | 17 | def application do 18 | [ 19 | extra_applications: [:logger] 20 | ] 21 | end 22 | 23 | defp deps do 24 | [ 25 | {:timex, "~> 3.7.9"}, 26 | {:plug_crypto, "~> 1.0"}, 27 | {:credo, "~> 1.6.1", only: [:dev, :test], runtime: false}, 28 | {:ex_doc, "~> 0.19", only: :dev, runtime: false}, 29 | {:mix_audit, "~> 2.0", only: [:dev, :test], runtime: false} 30 | ] 31 | end 32 | 33 | defp package do 34 | [ 35 | name: :api_auth, 36 | maintainers: ["zfletch"], 37 | licenses: ["MIT"], 38 | links: %{"GitHub" => "https://github.com/TheGnarCo/api_auth_ex/"} 39 | ] 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, 3 | "certifi": {:hex, :certifi, "2.8.0", "d4fb0a6bb20b7c9c3643e22507e42f356ac090a1dcea9ab99e27e0376d695eba", [:rebar3], [], "hexpm", "6ac7efc1c6f8600b08d625292d4bbf584e14847ce1b6b5c44d983d273e1097ea"}, 4 | "combine": {:hex, :combine, "0.10.0", "eff8224eeb56498a2af13011d142c5e7997a80c8f5b97c499f84c841032e429f", [:mix], [], "hexpm", "1b1dbc1790073076580d0d1d64e42eae2366583e7aecd455d1215b0d16f2451b"}, 5 | "credo": {:hex, :credo, "1.6.2", "2f82b29a47c0bb7b72f023bf3a34d151624f1cbe1e6c4e52303b05a11166a701", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "ae9dc112bc368e7b145c547bec2ed257ef88955851c15057c7835251a17211c6"}, 6 | "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [:mix], [], "hexpm", "3b1dcad3067985dd8618c38399a8ee9c4e652d52a17a4aae7a6d6fc4fcc24856"}, 7 | "earmark_parser": {:hex, :earmark_parser, "1.4.37", "2ad73550e27c8946648b06905a57e4d454e4d7229c2dafa72a0348c99d8be5f7", [:mix], [], "hexpm", "6b19783f2802f039806f375610faa22da130b8edc21209d0bff47918bb48360e"}, 8 | "ex_doc": {:hex, :ex_doc, "0.30.9", "d691453495c47434c0f2052b08dd91cc32bc4e1a218f86884563448ee2502dd2", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "d7aaaf21e95dc5cddabf89063327e96867d00013963eadf2c6ad135506a8bc10"}, 9 | "expo": {:hex, :expo, "0.4.1", "1c61d18a5df197dfda38861673d392e642649a9cef7694d2f97a587b2cfb319b", [:mix], [], "hexpm", "2ff7ba7a798c8c543c12550fa0e2cbc81b95d4974c65855d8d15ba7b37a1ce47"}, 10 | "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, 11 | "gettext": {:hex, :gettext, "0.23.1", "821e619a240e6000db2fc16a574ef68b3bd7fe0167ccc264a81563cc93e67a31", [:mix], [{:expo, "~> 0.4.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "19d744a36b809d810d610b57c27b934425859d158ebd56561bc41f7eeb8795db"}, 12 | "hackney": {:hex, :hackney, "1.18.0", "c4443d960bb9fba6d01161d01cd81173089686717d9490e5d3606644c48d121f", [:rebar3], [{:certifi, "~>2.8.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~>6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~>1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~>1.1", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.3.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~>1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "9afcda620704d720db8c6a3123e9848d09c87586dc1c10479c42627b905b5c5e"}, 13 | "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, 14 | "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, 15 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 18 | "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, 19 | "mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"}, 20 | "mix_audit": {:hex, :mix_audit, "2.1.1", "653aa6d8f291fc4b017aa82bdb79a4017903902ebba57960ef199cbbc8c008a1", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "541990c3ab3a7bb8c4aaa2ce2732a4ae160ad6237e5dcd5ad1564f4f85354db1"}, 21 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 22 | "parse_trans": {:hex, :parse_trans, "3.3.1", "16328ab840cc09919bd10dab29e431da3af9e9e7e7e6f0089dd5a2d2820011d8", [:rebar3], [], "hexpm", "07cd9577885f56362d414e8c4c4e6bdf10d43a8767abb92d24cbe8b24c54888b"}, 23 | "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, 24 | "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.6", "cf344f5692c82d2cd7554f5ec8fd961548d4fd09e7d22f5b62482e5aeaebd4b0", [:make, :mix, :rebar3], [], "hexpm", "bdb0d2471f453c88ff3908e7686f86f9be327d065cc1ec16fa4540197ea04680"}, 25 | "timex": {:hex, :timex, "3.7.11", "bb95cb4eb1d06e27346325de506bcc6c30f9c6dea40d1ebe390b262fad1862d1", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:gettext, "~> 0.20", [hex: :gettext, repo: "hexpm", optional: false]}, {:tzdata, "~> 1.1", [hex: :tzdata, repo: "hexpm", optional: false]}], "hexpm", "8b9024f7efbabaf9bd7aa04f65cf8dcd7c9818ca5737677c7b76acbc6a94d1aa"}, 26 | "tzdata": {:hex, :tzdata, "1.1.1", "20c8043476dfda8504952d00adac41c6eda23912278add38edc140ae0c5bcc46", [:mix], [{:hackney, "~> 1.17", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "a69cec8352eafcd2e198dea28a34113b60fdc6cb57eb5ad65c10292a6ba89787"}, 27 | "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.0", "bc84380c9ab48177092f43ac89e4dfa2c6d62b40b8bd132b1059ecc7232f9a78", [:rebar3], [], "hexpm", "25eee6d67df61960cf6a794239566599b09e17e668d3700247bc498638152521"}, 28 | "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, 29 | "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"}, 30 | } 31 | -------------------------------------------------------------------------------- /test/api_auth/authorization_header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.AuthorizationHeaderTest do 2 | use ExUnit.Case 3 | 4 | alias ApiAuth.AuthorizationHeader 5 | alias ApiAuth.DateHeader 6 | alias ApiAuth.ContentTypeHeader 7 | alias ApiAuth.ContentHashHeader 8 | alias ApiAuth.UriHeader 9 | alias ApiAuth.HeaderValues 10 | alias ApiAuth.HeaderCompare 11 | 12 | describe "override" do 13 | test "it calculates the signature" do 14 | headers = [DATE: "Sat, 01 Jan 2000 00:00:00 GMT"] 15 | 16 | value = 17 | headers 18 | |> HeaderValues.wrap() 19 | |> DateHeader.headers() 20 | |> AuthorizationHeader.override("GET", "1044", "123", :sha) 21 | |> HeaderValues.get(:authorization) 22 | 23 | assert value == "APIAuth 1044:49FglhLqXWuJqBu5SQOH4F8D1Og=" 24 | end 25 | 26 | test "it calcualtes the signature with query params" do 27 | headers = [DATE: "Sat, 01 Jan 2000 00:00:00 GMT", "Content-Type": "application/json"] 28 | 29 | value = 30 | headers 31 | |> HeaderValues.wrap() 32 | |> DateHeader.headers() 33 | |> ContentTypeHeader.headers() 34 | |> ContentHashHeader.headers("GET", "", :md5) 35 | |> UriHeader.headers("/foo?a=b") 36 | |> AuthorizationHeader.override("GET", "1044", "123", :sha) 37 | |> HeaderValues.get(:authorization) 38 | 39 | assert value == "APIAuth 1044:EJL51vV1iUgkg6Rxvo7IEXYo4Ys=" 40 | end 41 | 42 | test "it calcualtes the signature with a body" do 43 | headers = [DATE: "Sat, 01 Jan 2000 00:00:00 GMT", "Content-Type": "text/plain"] 44 | 45 | value = 46 | headers 47 | |> HeaderValues.wrap() 48 | |> DateHeader.headers() 49 | |> ContentTypeHeader.headers() 50 | |> ContentHashHeader.headers("PUT", "", :md5) 51 | |> UriHeader.headers("/resource.xml?foo=bar&bar=foo") 52 | |> AuthorizationHeader.override("PUT", "1044", "123", :sha256) 53 | |> HeaderValues.get(:authorization) 54 | 55 | assert value == "APIAuth-HMAC-SHA256 1044:5JhErRhsIbN2+O595t/Rkax2n7w/YZ0f92BYgZFN5ds=" 56 | end 57 | 58 | test "it writes the signature to the headers" do 59 | headers = [DATE: "Sat, 01 Jan 2000 00:00:00 GMT"] 60 | 61 | new_headers = 62 | headers 63 | |> HeaderValues.wrap() 64 | |> DateHeader.headers() 65 | |> AuthorizationHeader.override("GET", "1044", "123", :sha) 66 | |> HeaderValues.unwrap() 67 | 68 | assert new_headers == [ 69 | Authorization: "APIAuth 1044:49FglhLqXWuJqBu5SQOH4F8D1Og=", 70 | DATE: "Sat, 01 Jan 2000 00:00:00 GMT" 71 | ] 72 | end 73 | 74 | test "it overwrites the existing header" do 75 | headers = [DATE: "Sat, 01 Jan 2000 00:00:00 GMT", AUTHORIZATION: "foo"] 76 | 77 | new_headers = 78 | headers 79 | |> HeaderValues.wrap() 80 | |> DateHeader.headers() 81 | |> AuthorizationHeader.override("GET", "1044", "123", :sha) 82 | |> HeaderValues.unwrap() 83 | 84 | assert new_headers == [ 85 | Authorization: "APIAuth 1044:49FglhLqXWuJqBu5SQOH4F8D1Og=", 86 | DATE: "Sat, 01 Jan 2000 00:00:00 GMT" 87 | ] 88 | end 89 | end 90 | 91 | describe "compare" do 92 | test "it is true when the values are the same" do 93 | valid_headers = [Authorization: "foo"] 94 | request_headers = [AUTHORIZATION: "foo"] 95 | 96 | valid_headers 97 | |> HeaderCompare.wrap(request_headers) 98 | |> AuthorizationHeader.compare() 99 | |> HeaderCompare.to_boolean() 100 | |> assert() 101 | end 102 | 103 | test "it is false when the values are different" do 104 | valid_headers = [AUTHORIZATION: "foo"] 105 | request_headers = [Authorization: "bar"] 106 | 107 | valid_headers 108 | |> HeaderCompare.wrap(request_headers) 109 | |> AuthorizationHeader.compare() 110 | |> HeaderCompare.to_boolean() 111 | |> refute() 112 | end 113 | 114 | test "it is false when one of the sides is missing" do 115 | valid_headers = [AUTHORIZATION: "foo"] 116 | request_headers = [] 117 | 118 | valid_headers 119 | |> HeaderCompare.wrap(request_headers) 120 | |> AuthorizationHeader.compare() 121 | |> HeaderCompare.to_boolean() 122 | |> refute() 123 | end 124 | end 125 | 126 | describe "extract_client_id" do 127 | test "it extracts the client id" do 128 | headers = [ 129 | Authorization: "APIAuth-HMAC-SHA256 test:v5+Ooq88txd0cFyfSXYn03EFK/NQW9Gepk5YIdkZ4qM=" 130 | ] 131 | 132 | client_id = 133 | headers 134 | |> AuthorizationHeader.extract_client_id() 135 | 136 | assert client_id == {:ok, "test"} 137 | end 138 | 139 | test "it works with different kinds of authorization headers" do 140 | headers = [AUTHORIZATION: "APIAuth 1044:49FglhLqXWuJqBu5SQOH4F8D1Og="] 141 | 142 | client_id = 143 | headers 144 | |> AuthorizationHeader.extract_client_id() 145 | 146 | assert client_id == {:ok, "1044"} 147 | end 148 | 149 | test "it returns an error when there is no client id" do 150 | headers = [ 151 | Authorization: "APIAuth-HMAC-SHA256 :v5+Ooq88txd0cFyfSXYn03EFK/NQW9Gepk5YIdkZ4qM=" 152 | ] 153 | 154 | client_id = 155 | headers 156 | |> AuthorizationHeader.extract_client_id() 157 | 158 | assert client_id == :error 159 | end 160 | 161 | test "it returns an error when the authorization header is nonsense" do 162 | headers = [Authorization: "zoboomafoo"] 163 | 164 | client_id = 165 | headers 166 | |> AuthorizationHeader.extract_client_id() 167 | 168 | assert client_id == :error 169 | end 170 | 171 | test "it returns an error when there is no authorization header" do 172 | headers = [] 173 | 174 | client_id = 175 | headers 176 | |> AuthorizationHeader.extract_client_id() 177 | 178 | assert client_id == :error 179 | end 180 | end 181 | end 182 | -------------------------------------------------------------------------------- /test/api_auth/content_hash_header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.ContentHashHeaderTest do 2 | use ExUnit.Case 3 | 4 | alias ApiAuth.ContentHashHeader 5 | alias ApiAuth.HeaderValues 6 | alias ApiAuth.HeaderCompare 7 | 8 | describe "headers" do 9 | test "this is not a PUT or POST and it doesn't compute the hash" do 10 | headers = [foo: "bar"] 11 | 12 | value = 13 | headers 14 | |> HeaderValues.wrap() 15 | |> ContentHashHeader.headers("GET", "", :sha256) 16 | |> HeaderValues.get(:content_hash) 17 | 18 | assert value == "" 19 | end 20 | 21 | test "it computes the content hash correctly" do 22 | headers = [foo: "bar"] 23 | 24 | value = 25 | headers 26 | |> HeaderValues.wrap() 27 | |> ContentHashHeader.headers("POST", "foo", :sha256) 28 | |> HeaderValues.get(:content_hash) 29 | 30 | assert value == "LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564=" 31 | end 32 | 33 | test "it computes the md5 hash correctly" do 34 | headers = [foo: "bar"] 35 | 36 | value = 37 | headers 38 | |> HeaderValues.wrap() 39 | |> ContentHashHeader.headers("POST", "foo", :md5) 40 | |> HeaderValues.get(:content_hash) 41 | 42 | assert value == "rL0Y20zC+Fzt72VPzMSk2A==" 43 | end 44 | 45 | test "it gets the value from the headers" do 46 | headers = ["X-APIAuth-Content-Hash": "hash", "Content-MD5": "md5-hash", foo: "bar"] 47 | 48 | value = 49 | headers 50 | |> HeaderValues.wrap() 51 | |> ContentHashHeader.headers("POST", "foo", :sha256) 52 | |> HeaderValues.get(:content_hash) 53 | 54 | assert value == "hash" 55 | end 56 | 57 | test "it gets the md5 from the headers" do 58 | headers = ["Content-MD5": "md5-hash", foo: "bar"] 59 | 60 | value = 61 | headers 62 | |> HeaderValues.wrap() 63 | |> ContentHashHeader.headers("POST", "foo", :md5) 64 | |> HeaderValues.get(:content_hash) 65 | 66 | assert value == "md5-hash" 67 | end 68 | 69 | test "it adds to the header if there is no key" do 70 | headers = [foo: "bar"] 71 | 72 | value = 73 | headers 74 | |> HeaderValues.wrap() 75 | |> ContentHashHeader.headers("POST", "foo", :sha256) 76 | |> HeaderValues.unwrap() 77 | |> Keyword.fetch!(:"X-APIAuth-Content-Hash") 78 | 79 | assert value == "LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564=" 80 | end 81 | 82 | test "it adds to the header if there is no md5 key" do 83 | headers = [foo: "bar"] 84 | 85 | value = 86 | headers 87 | |> HeaderValues.wrap() 88 | |> ContentHashHeader.headers("POST", "foo", :md5) 89 | |> HeaderValues.unwrap() 90 | |> Keyword.fetch!(:"Content-MD5") 91 | 92 | assert value == "rL0Y20zC+Fzt72VPzMSk2A==" 93 | end 94 | 95 | test "it does not change an existing header" do 96 | headers = ["X-APIAuth-Content-Hash": "hash", "Content-MD5": "md5-hash", foo: "bar"] 97 | 98 | new_headers = 99 | headers 100 | |> HeaderValues.wrap() 101 | |> ContentHashHeader.headers("POST", "foo", :md5) 102 | |> HeaderValues.unwrap() 103 | 104 | assert new_headers == headers 105 | end 106 | end 107 | 108 | describe "override" do 109 | test "this is not a PUT or POST and it doesn't compute the hash" do 110 | headers = [foo: "bar"] 111 | 112 | value = 113 | headers 114 | |> HeaderValues.wrap() 115 | |> ContentHashHeader.override("GET", "", :sha256) 116 | |> HeaderValues.get(:content_hash) 117 | 118 | assert value == "" 119 | end 120 | 121 | test "it computes the content hash correctly" do 122 | headers = [foo: "bar"] 123 | 124 | value = 125 | headers 126 | |> HeaderValues.wrap() 127 | |> ContentHashHeader.override("POST", "foo", :sha256) 128 | |> HeaderValues.get(:content_hash) 129 | 130 | assert value == "LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564=" 131 | end 132 | 133 | test "it computes the md5 hash correctly" do 134 | headers = [foo: "bar"] 135 | 136 | value = 137 | headers 138 | |> HeaderValues.wrap() 139 | |> ContentHashHeader.override("POST", "foo", :md5) 140 | |> HeaderValues.get(:content_hash) 141 | 142 | assert value == "rL0Y20zC+Fzt72VPzMSk2A==" 143 | end 144 | 145 | test "it overrides the value from the headers" do 146 | headers = ["X-APIAuth-Content-Hash": "hash", "Content-MD5": "md5-hash", foo: "bar"] 147 | 148 | value = 149 | headers 150 | |> HeaderValues.wrap() 151 | |> ContentHashHeader.override("POST", "foo", :sha256) 152 | |> HeaderValues.get(:content_hash) 153 | 154 | assert value == "LCa0a2j/xo/5m0U8HTBBNBNCLXBkg7+g+YpeiGJm564=" 155 | end 156 | 157 | test "it updates an existing header" do 158 | headers = ["X-APIAuth-Content-Hash": "hash", "Content-MD5": "md5-hash", foo: "bar"] 159 | 160 | new_headers = 161 | headers 162 | |> HeaderValues.wrap() 163 | |> ContentHashHeader.override("POST", "foo", :md5) 164 | |> HeaderValues.unwrap() 165 | 166 | assert new_headers == ["Content-MD5": "rL0Y20zC+Fzt72VPzMSk2A==", foo: "bar"] 167 | end 168 | end 169 | 170 | describe "compare" do 171 | test "it is true when it is a PUT or POST and the hashes match" do 172 | valid_headers = ["Content-MD5": "foo"] 173 | request_headers = ["X-APIAuth-Content-Hash": "foo"] 174 | 175 | valid_headers 176 | |> HeaderCompare.wrap(request_headers) 177 | |> ContentHashHeader.compare("POST") 178 | |> HeaderCompare.to_boolean() 179 | |> assert() 180 | end 181 | 182 | test "it is false when it is a PUT or POST request and the hashes don't match" do 183 | valid_headers = ["Content-MD5": "foo"] 184 | request_headers = ["X-APIAuth-Content-Hash": "bar"] 185 | 186 | valid_headers 187 | |> HeaderCompare.wrap(request_headers) 188 | |> ContentHashHeader.compare("PUT") 189 | |> HeaderCompare.to_boolean() 190 | |> refute() 191 | end 192 | 193 | test "it is false when it is a PUT or POST request and one side is missing" do 194 | valid_headers = ["Content-MD5": "foo"] 195 | request_headers = [] 196 | 197 | valid_headers 198 | |> HeaderCompare.wrap(request_headers) 199 | |> ContentHashHeader.compare("PUT") 200 | |> HeaderCompare.to_boolean() 201 | |> refute() 202 | end 203 | 204 | test "it is true when it is not a PUT or POST request" do 205 | valid_headers = ["Content-MD5": "foo"] 206 | request_headers = ["X-APIAuth-Content-Hash": "bar"] 207 | 208 | valid_headers 209 | |> HeaderCompare.wrap(request_headers) 210 | |> ContentHashHeader.compare("GET") 211 | |> HeaderCompare.to_boolean() 212 | |> assert() 213 | end 214 | end 215 | end 216 | -------------------------------------------------------------------------------- /test/api_auth/content_type_header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.ContentTypeHeaderTest do 2 | use ExUnit.Case 3 | 4 | alias ApiAuth.ContentTypeHeader 5 | alias ApiAuth.HeaderValues 6 | 7 | describe "headers" do 8 | test "it gets the value from a content type header" do 9 | headers = [foo: "bar", "Content-Type": "application/json"] 10 | 11 | value = 12 | headers 13 | |> HeaderValues.wrap() 14 | |> ContentTypeHeader.headers() 15 | |> HeaderValues.get(:content_type) 16 | 17 | assert value == "application/json" 18 | end 19 | 20 | test "it sets a default value of empty string" do 21 | headers = [foo: "bar"] 22 | 23 | value = 24 | headers 25 | |> HeaderValues.wrap() 26 | |> ContentTypeHeader.headers() 27 | |> HeaderValues.get(:content_type) 28 | 29 | assert value == "" 30 | end 31 | 32 | test "it does not change the headers" do 33 | headers = [foo: "bar"] 34 | 35 | new_headers = 36 | headers 37 | |> HeaderValues.wrap() 38 | |> ContentTypeHeader.headers() 39 | |> HeaderValues.unwrap() 40 | 41 | assert new_headers == headers 42 | end 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/api_auth/date_header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.DateHeaderTest do 2 | use ExUnit.Case 3 | 4 | alias ApiAuth.DateHeader 5 | alias ApiAuth.HeaderValues 6 | alias ApiAuth.HeaderCompare 7 | 8 | describe "headers" do 9 | test "it gets the value from the headers" do 10 | headers = [HTTP_DATE: "Sat, 01 Jan 2000 00:00:00 GMT"] 11 | 12 | value = 13 | headers 14 | |> HeaderValues.wrap() 15 | |> DateHeader.headers() 16 | |> HeaderValues.get(:timestamp) 17 | 18 | assert value == "Sat, 01 Jan 2000 00:00:00 GMT" 19 | end 20 | 21 | test "it defaults to the current time if not set" do 22 | headers = [] 23 | 24 | value = 25 | headers 26 | |> HeaderValues.wrap() 27 | |> DateHeader.headers() 28 | |> HeaderValues.get(:timestamp) 29 | 30 | {:ok, parsed} = DateHeader.parse_httpdate(value) 31 | diff = Timex.diff(Timex.now(:utc), parsed, :second) 32 | 33 | assert diff == 0 34 | end 35 | 36 | test "it adds to the header if there is no key" do 37 | headers = [] 38 | 39 | value = 40 | headers 41 | |> HeaderValues.wrap() 42 | |> DateHeader.headers() 43 | |> HeaderValues.unwrap() 44 | |> Keyword.fetch!(:DATE) 45 | 46 | {:ok, parsed} = DateHeader.parse_httpdate(value) 47 | diff = Timex.diff(Timex.now(:utc), parsed, :second) 48 | 49 | assert diff == 0 50 | end 51 | 52 | test "it does not change an existing header" do 53 | headers = [HTTP_DATE: "Sat, 01 Jan 2000 00:00:00 GMT"] 54 | 55 | new_headers = 56 | headers 57 | |> HeaderValues.wrap() 58 | |> DateHeader.headers() 59 | |> HeaderValues.unwrap() 60 | 61 | assert new_headers == headers 62 | end 63 | end 64 | 65 | describe "compare" do 66 | test "it is true when the timestamps match" do 67 | timestamp = DateHeader.httpdate(Timex.now(:utc)) 68 | valid_headers = [DATE: timestamp] 69 | request_headers = [HTTP_DATE: timestamp] 70 | 71 | valid_headers 72 | |> HeaderCompare.wrap(request_headers) 73 | |> DateHeader.compare() 74 | |> HeaderCompare.to_boolean() 75 | |> assert() 76 | end 77 | 78 | test "it is false when the timestamps don't match" do 79 | now = Timex.now(:utc) 80 | timestamp1 = DateHeader.httpdate(now) 81 | past = Timex.shift(now, seconds: -1) 82 | timestamp2 = DateHeader.httpdate(past) 83 | 84 | valid_headers = [DATE: timestamp1] 85 | request_headers = [HTTP_DATE: timestamp2] 86 | 87 | valid_headers 88 | |> HeaderCompare.wrap(request_headers) 89 | |> DateHeader.compare() 90 | |> HeaderCompare.to_boolean() 91 | |> refute() 92 | end 93 | 94 | test "it is false if the timestamps are not times" do 95 | valid_headers = [DATE: "foo"] 96 | request_headers = [HTTP_DATE: "foo"] 97 | 98 | valid_headers 99 | |> HeaderCompare.wrap(request_headers) 100 | |> DateHeader.compare() 101 | |> HeaderCompare.to_boolean() 102 | |> refute() 103 | end 104 | 105 | test "it is true if the timestamps are within 15 minutes from now" do 106 | time = Timex.shift(Timex.now(:utc), seconds: -800) 107 | timestamp = DateHeader.httpdate(time) 108 | valid_headers = [DATE: timestamp] 109 | request_headers = [HTTP_DATE: timestamp] 110 | 111 | valid_headers 112 | |> HeaderCompare.wrap(request_headers) 113 | |> DateHeader.compare() 114 | |> HeaderCompare.to_boolean() 115 | |> assert() 116 | end 117 | 118 | test "it is false if the timestamps are older than 15 minutes" do 119 | time = Timex.shift(Timex.now(:utc), seconds: -901) 120 | timestamp = DateHeader.httpdate(time) 121 | 122 | valid_headers = [DATE: timestamp] 123 | request_headers = [HTTP_DATE: timestamp] 124 | 125 | valid_headers 126 | |> HeaderCompare.wrap(request_headers) 127 | |> DateHeader.compare() 128 | |> HeaderCompare.to_boolean() 129 | |> refute() 130 | end 131 | 132 | test "it is false if the timestamps are in the future" do 133 | time = Timex.shift(Timex.now(:utc), seconds: 7) 134 | timestamp = DateHeader.httpdate(time) 135 | valid_headers = [DATE: timestamp] 136 | request_headers = [HTTP_DATE: timestamp] 137 | 138 | valid_headers 139 | |> HeaderCompare.wrap(request_headers) 140 | |> DateHeader.compare() 141 | |> HeaderCompare.to_boolean() 142 | |> refute() 143 | end 144 | end 145 | 146 | describe "httpdate" do 147 | test "it formats the datetime object correctly" do 148 | date = DateTime.new!(~D[2023-10-31], ~T[12:34:16], "Etc/UTC") 149 | 150 | assert DateHeader.httpdate(date) == "Tue, 31 Oct 2023 12:34:16 GMT" 151 | end 152 | end 153 | 154 | describe "parse_httpdate" do 155 | test "it parses httpdate into datetime object" do 156 | {:ok, result} = DateHeader.parse_httpdate("Tue, 31 Oct 2023 04:59:03 GMT") 157 | 158 | assert result == %DateTime{ 159 | year: 2023, 160 | month: 10, 161 | day: 31, 162 | hour: 4, 163 | minute: 59, 164 | second: 3, 165 | time_zone: "Etc/UTC", 166 | zone_abbr: "UTC", 167 | std_offset: 0, 168 | utc_offset: 0, 169 | microsecond: {0, 0} 170 | } 171 | end 172 | end 173 | end 174 | -------------------------------------------------------------------------------- /test/api_auth/header_compare_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.HeaderCompareTest do 2 | use ExUnit.Case 3 | 4 | alias ApiAuth.HeaderCompare 5 | 6 | describe "wrap" do 7 | test "it wraps two headers in a header compare structure" do 8 | valid_headers = [foo: "bar"] 9 | request_headers = [baz: "bat"] 10 | 11 | hc = HeaderCompare.wrap(valid_headers, request_headers) 12 | 13 | assert hc == {:ok, [foo: "bar"], [baz: "bat"]} 14 | end 15 | end 16 | 17 | describe "compare" do 18 | test "it returns the same tuple when the values are the same" do 19 | valid_headers = [foo: "bar", baz: "bat"] 20 | request_headers = [foo: "bar", baz: "cat"] 21 | 22 | hc = HeaderCompare.wrap(valid_headers, request_headers) 23 | new_hc = hc |> HeaderCompare.compare([:foo]) 24 | 25 | assert new_hc == hc 26 | end 27 | 28 | test "it checks all the keys in the list" do 29 | valid_headers = [foo: "bar", baz: "bat"] 30 | request_headers = [a: "bar", baz: "cat"] 31 | 32 | hc = HeaderCompare.wrap(valid_headers, request_headers) 33 | new_hc = hc |> HeaderCompare.compare([:a, :b, :foo]) 34 | 35 | assert new_hc == hc 36 | end 37 | 38 | test "it returns an error when the values are different" do 39 | valid_headers = [foo: "bar", baz: "bat"] 40 | request_headers = [foo: "bar", baz: "cat"] 41 | 42 | hc = HeaderCompare.wrap(valid_headers, request_headers) 43 | new_hc = hc |> HeaderCompare.compare([:baz]) 44 | 45 | assert new_hc == :error 46 | end 47 | 48 | test "it can be chained" do 49 | valid_headers = [foo: "bar", baz: "bat", x: "y"] 50 | request_headers = [foo: "bar", baz: "cat", z: "y"] 51 | 52 | hc = HeaderCompare.wrap(valid_headers, request_headers) 53 | 54 | new_hc = 55 | hc 56 | |> HeaderCompare.compare([:foo]) 57 | |> HeaderCompare.compare([:z, :x]) 58 | 59 | assert new_hc == hc 60 | end 61 | 62 | test "it returns an error if any call in the chain is an error" do 63 | valid_headers = [foo: "bar", baz: "bat", x: "y"] 64 | request_headers = [foo: "bar", baz: "cat", z: "y"] 65 | 66 | hc = HeaderCompare.wrap(valid_headers, request_headers) 67 | 68 | new_hc = 69 | hc 70 | |> HeaderCompare.compare([:foo]) 71 | |> HeaderCompare.compare([:baz]) 72 | |> HeaderCompare.compare([:z, :x]) 73 | 74 | assert new_hc == :error 75 | end 76 | end 77 | 78 | describe "to_boolean" do 79 | test "it is true when it is a tuple with :ok" do 80 | valid_headers = [foo: "bar"] 81 | request_headers = [foo: "bar"] 82 | 83 | valid_headers 84 | |> HeaderCompare.wrap(request_headers) 85 | |> HeaderCompare.compare([:foo]) 86 | |> HeaderCompare.to_boolean() 87 | |> assert() 88 | end 89 | 90 | test "it is false otherwise" do 91 | valid_headers = [foo: "bar"] 92 | request_headers = [foo: "xyz"] 93 | 94 | valid_headers 95 | |> HeaderCompare.wrap(request_headers) 96 | |> HeaderCompare.compare([:foo]) 97 | |> HeaderCompare.to_boolean() 98 | |> refute() 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /test/api_auth/header_values_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.HeaderValuesTest do 2 | use ExUnit.Case 3 | 4 | alias ApiAuth.HeaderValues 5 | 6 | describe "wrap" do 7 | test "it wraps headers in a header values structure" do 8 | header_values = 9 | [hello: "world", a: 1] 10 | |> HeaderValues.wrap() 11 | 12 | assert header_values == {[hello: "world", a: 1], %{}} 13 | end 14 | end 15 | 16 | describe "unwrap" do 17 | test "it returns the header from a header values structure" do 18 | headers = 19 | {[hello: "world", a: 1], %{}} 20 | |> HeaderValues.unwrap() 21 | 22 | assert headers == [hello: "world", a: 1] 23 | end 24 | 25 | test "calling wrap and unwrap returns the original list" do 26 | list = [hello: "world", a: 1] 27 | new_list = list |> HeaderValues.wrap() |> HeaderValues.unwrap() 28 | 29 | assert list == new_list 30 | end 31 | end 32 | 33 | describe "transform" do 34 | test "it transforms the values without changing the headers" do 35 | header_values = {[hello: "world", a: 1], %{a: 1}} 36 | 37 | new_header_values = 38 | header_values 39 | |> HeaderValues.transform(:a, nil, &(&1 + 1)) 40 | 41 | assert new_header_values == {[hello: "world", a: 1], %{a: 2}} 42 | end 43 | 44 | test "it uses the default if there is no matching value" do 45 | header_values = {[hello: "world", a: 1], %{a: 1}} 46 | 47 | new_header_values = 48 | header_values 49 | |> HeaderValues.transform(:b, 15, &(&1 + 1)) 50 | 51 | assert new_header_values == {[hello: "world", a: 1], %{a: 1, b: 15}} 52 | end 53 | end 54 | 55 | describe "get" do 56 | test "it gets the value" do 57 | header_values = {[hello: "world", a: 1], %{a: 1}} 58 | value = HeaderValues.get(header_values, :a) 59 | 60 | assert value == 1 61 | end 62 | 63 | test "it returns the empty string if there is no match" do 64 | header_values = {[hello: "world", a: 1], %{a: 1}} 65 | value = HeaderValues.get(header_values, :hello) 66 | 67 | assert value == "" 68 | end 69 | 70 | test "it returns the default if there is no match and a default is passed in" do 71 | header_values = {[hello: "world", a: 1], %{a: 1}} 72 | value = HeaderValues.get(header_values, :hello, "default") 73 | 74 | assert value == "default" 75 | end 76 | end 77 | 78 | describe "copy" do 79 | test "there is no matching header so it uses the default value" do 80 | header_values = {[hello: "world", a: 1], %{a: 1}} 81 | 82 | new_header_values = 83 | header_values 84 | |> HeaderValues.copy([:Other], :other, "foo") 85 | 86 | assert new_header_values == {[hello: "world", a: 1], %{a: 1, other: "foo"}} 87 | end 88 | 89 | test "it uses the value from the header" do 90 | header_values = {[hello: "world", a: 1], %{a: 1}} 91 | 92 | new_header_values = 93 | header_values 94 | |> HeaderValues.copy([:Other, :hello], :other, "foo") 95 | 96 | assert new_header_values == {[hello: "world", a: 1], %{a: 1, other: "world"}} 97 | end 98 | end 99 | 100 | describe "put" do 101 | test "there is no matching header so it uses the default value" do 102 | header_values = {[hello: "world", a: 1], %{a: 1}} 103 | 104 | new_header_values = 105 | header_values 106 | |> HeaderValues.put([:Other], :Other, :other, "foo") 107 | 108 | assert new_header_values == {[Other: "foo", hello: "world", a: 1], %{a: 1, other: "foo"}} 109 | end 110 | 111 | test "it uses the default value" do 112 | header_values = {[hello: "world", a: 1], %{a: 1}} 113 | 114 | new_header_values = 115 | header_values 116 | |> HeaderValues.put([:Other, :hello], :Other, :other, "foo") 117 | 118 | assert new_header_values == {[Other: "foo", a: 1], %{a: 1, other: "foo"}} 119 | end 120 | end 121 | 122 | describe "put_new" do 123 | test "there is no matching header so it uses the default value" do 124 | header_values = {[hello: "world", a: 1], %{a: 1}} 125 | 126 | new_header_values = 127 | header_values 128 | |> HeaderValues.put_new([:Other], :Other, :other, "foo") 129 | 130 | assert new_header_values == {[Other: "foo", hello: "world", a: 1], %{a: 1, other: "foo"}} 131 | end 132 | 133 | test "it uses the value from the header" do 134 | header_values = {[hello: "world", a: 1], %{a: 1}} 135 | 136 | new_header_values = 137 | header_values 138 | |> HeaderValues.put_new([:Other, :hello], :Other, :other, "foo") 139 | 140 | assert new_header_values == {[hello: "world", a: 1], %{a: 1, other: "world"}} 141 | end 142 | end 143 | end 144 | -------------------------------------------------------------------------------- /test/api_auth/uri_header_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.UriHeaderTest do 2 | use ExUnit.Case 3 | 4 | alias ApiAuth.UriHeader 5 | alias ApiAuth.HeaderValues 6 | 7 | describe "headers" do 8 | test "it gets the value from a content type header" do 9 | headers = [foo: "bar", "X-Original-URI": "/test"] 10 | 11 | value = 12 | headers 13 | |> HeaderValues.wrap() 14 | |> UriHeader.headers("/other") 15 | |> HeaderValues.get(:uri) 16 | 17 | assert value == "/test" 18 | end 19 | 20 | test "it sets puts a header if there isn't one" do 21 | headers = [foo: "bar"] 22 | 23 | value = 24 | headers 25 | |> HeaderValues.wrap() 26 | |> UriHeader.headers("/other") 27 | |> HeaderValues.get(:uri) 28 | 29 | assert value == "/other" 30 | end 31 | 32 | test "it does not change the headers" do 33 | headers = [foo: "bar"] 34 | 35 | new_headers = 36 | headers 37 | |> HeaderValues.wrap() 38 | |> UriHeader.headers("/other") 39 | |> HeaderValues.unwrap() 40 | 41 | assert new_headers == headers 42 | end 43 | 44 | test "it removes the host part of the uri" do 45 | value = 46 | [] 47 | |> HeaderValues.wrap() 48 | |> UriHeader.headers("https://www.example.com/foo") 49 | |> HeaderValues.get(:uri) 50 | 51 | assert value == "/foo" 52 | end 53 | 54 | test "it does not remove the get params from the uri" do 55 | value = 56 | [] 57 | |> HeaderValues.wrap() 58 | |> UriHeader.headers("/foo?a=b") 59 | |> HeaderValues.get(:uri) 60 | 61 | assert value == "/foo?a=b" 62 | end 63 | 64 | test "the default uri is /" do 65 | value = 66 | [] 67 | |> HeaderValues.wrap() 68 | |> UriHeader.headers("https://www.example.com") 69 | |> HeaderValues.get(:uri) 70 | 71 | assert value == "/" 72 | end 73 | end 74 | 75 | describe "override" do 76 | test "it overrides the value in the header" do 77 | headers = [foo: "bar", "X-Original-URI": "/test"] 78 | 79 | value = 80 | headers 81 | |> HeaderValues.wrap() 82 | |> UriHeader.override("/other") 83 | |> HeaderValues.get(:uri) 84 | 85 | assert value == "/other" 86 | end 87 | 88 | test "it changse the headers" do 89 | headers = [foo: "bar"] 90 | 91 | new_headers = 92 | headers 93 | |> HeaderValues.wrap() 94 | |> UriHeader.override("/other") 95 | |> HeaderValues.unwrap() 96 | 97 | assert new_headers == ["X-Original-URI": "/other", foo: "bar"] 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/api_auth/utils_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiAuth.UtilsTest do 2 | use ExUnit.Case 3 | 4 | alias ApiAuth.Utils 5 | 6 | describe "find" do 7 | test "it returns a tuple with :ok if it finds one of the keys" do 8 | headers = [hello: "world", foo: "bar"] 9 | 10 | found = 11 | headers 12 | |> Utils.find([:test, :foo]) 13 | 14 | assert found == {:ok, "bar"} 15 | end 16 | 17 | test "it returns :error if it doesn't find one of the keys" do 18 | headers = [hello: "world", foo: "bar"] 19 | 20 | found = 21 | headers 22 | |> Utils.find([:other]) 23 | 24 | assert found == :error 25 | end 26 | end 27 | 28 | describe "reject" do 29 | test "it returns the list without the given keys" do 30 | headers = [hello: "world", foo: "bar", baz: "xyz"] 31 | 32 | new_headers = 33 | headers 34 | |> Utils.reject([:foo, :baz]) 35 | 36 | assert new_headers == [hello: "world"] 37 | end 38 | end 39 | 40 | describe "convert" do 41 | test "it returns a keyword list unchanged" do 42 | headers = [a: "1", b: "2", c: "3"] 43 | new_headers = Utils.convert(headers) 44 | 45 | assert new_headers == headers 46 | end 47 | 48 | test "give a list of {string,string} tuples it returns a keyword list" do 49 | headers = [{"A", 1}, {"B", 2}] 50 | new_headers = Utils.convert(headers) 51 | 52 | assert new_headers == [A: 1, B: 2] 53 | end 54 | 55 | test "it upcases all the strings in {string,string} lists" do 56 | headers = [{"a", 1}] 57 | new_headers = Utils.convert(headers) 58 | 59 | assert new_headers == [A: 1] 60 | end 61 | end 62 | end 63 | -------------------------------------------------------------------------------- /test/api_auth_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApiAuthTest do 2 | use ExUnit.Case 3 | doctest ApiAuth 4 | 5 | describe "headers" do 6 | test "adds missing headers and signs request" do 7 | headers = 8 | [DATE: "Sat, 01 Jan 2000 00:00:00 GMT"] 9 | |> ApiAuth.headers("/", "1044", "123", method: "POST") 10 | 11 | expected_headers = [ 12 | Authorization: "APIAuth-HMAC-SHA256 1044:0GZ7kEF4vXa5wjyLYsddgW66Vp1i1i8jA+CO9+9umSI=", 13 | "X-APIAuth-Content-Hash": "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=", 14 | DATE: "Sat, 01 Jan 2000 00:00:00 GMT" 15 | ] 16 | 17 | assert headers == expected_headers 18 | end 19 | end 20 | 21 | describe "authentic?" do 22 | test "it is true when the request is authentic" do 23 | headers = ApiAuth.headers([], "/", "1044", "123") 24 | 25 | headers 26 | |> ApiAuth.authentic?("/", "1044", "123") 27 | |> assert() 28 | end 29 | 30 | test "it is true when headers are lowercase and strings (like in Phoenix)" do 31 | headers = ApiAuth.headers([], "/", "1044", "123") 32 | fun = fn {k, v} -> {String.downcase(Atom.to_string(k)), v} end 33 | phoenix_headers = Enum.map(headers, fun) 34 | 35 | phoenix_headers 36 | |> ApiAuth.authentic?("/", "1044", "123") 37 | |> assert() 38 | end 39 | 40 | test "it is false when the secret key is different" do 41 | headers = ApiAuth.headers([], "/", "1044", "123") 42 | 43 | headers 44 | |> ApiAuth.authentic?("/", "1044", "other") 45 | |> refute() 46 | end 47 | 48 | test "it is false when the client id is different" do 49 | headers = ApiAuth.headers([], "/", "1054", "123") 50 | 51 | headers 52 | |> ApiAuth.authentic?("/", "1044", "123") 53 | |> refute() 54 | end 55 | 56 | test "it verifies the content hash when POST or PUT" do 57 | headers = ApiAuth.headers([], "/resource", "1044", "123", method: "POST", content: "foo") 58 | 59 | headers 60 | |> ApiAuth.authentic?("/resource", "1044", "123", method: "POST", content: "foo") 61 | |> assert() 62 | end 63 | 64 | test "it is false when the content differs when POST or PUT" do 65 | headers = ApiAuth.headers([], "/resource", "1044", "123", method: "POST", content: "foo") 66 | 67 | headers 68 | |> ApiAuth.authentic?("/resource", "1044", "123", method: "POST", content: "bar") 69 | |> refute() 70 | end 71 | 72 | test "it is false when the uri in the headers doesn't match the signed uri" do 73 | headers = ApiAuth.headers([], "/resource", "1044", "123") 74 | modified_headers = headers |> Keyword.put(:"X-Original-URI", "/other") 75 | 76 | modified_headers 77 | |> ApiAuth.authentic?("/other", "1044", "123") 78 | |> refute() 79 | end 80 | 81 | test "it is false when the hash in the headers doesn't match the signed uri" do 82 | headers = ApiAuth.headers([], "/resource", "1044", "123", method: "PUT", content: "foo") 83 | 84 | modified_headers = 85 | headers 86 | |> Keyword.put( 87 | :"X-APIAuth-Content-Hash", 88 | "/N4rLtula/QIYB+3If6bXDONEO5CnqBPrlURto+/j7k=" 89 | ) 90 | 91 | modified_headers 92 | |> ApiAuth.authentic?("/resource", "1044", "123", method: "PUT", content: "bar") 93 | |> refute() 94 | end 95 | 96 | test "it is false when more than 15 minutes has passed" do 97 | headers = 98 | [DATE: "Sat, 01 Jan 2000 00:00:00 GMT"] 99 | |> ApiAuth.headers("/", "1044", "123", method: "POST") 100 | 101 | headers 102 | |> ApiAuth.authentic?("/", "1044", "123", method: "POST") 103 | |> refute() 104 | end 105 | end 106 | 107 | describe "client_id" do 108 | test "it gets the client id from the headers" do 109 | headers = [ 110 | Authorization: 111 | "APIAuth-HMAC-SHA256 client_id:v5+Ooq88txd0cFyfSXYn03EFK/NQW9Gepk5YIdkZ4qM=" 112 | ] 113 | 114 | client_id_tuple = ApiAuth.client_id(headers) 115 | 116 | assert client_id_tuple == {:ok, "client_id"} 117 | end 118 | 119 | test "it gets the client id when given a list of string tuples" do 120 | headers = [ 121 | {"authorization", 122 | "APIAuth-HMAC-SHA256 client_id:v5+Ooq88txd0cFyfSXYn03EFK/NQW9Gepk5YIdkZ4qM="} 123 | ] 124 | 125 | client_id_tuple = ApiAuth.client_id(headers) 126 | 127 | assert client_id_tuple == {:ok, "client_id"} 128 | end 129 | 130 | test "it returns an error if there is no client id" do 131 | client_id_tuple = ApiAuth.client_id([]) 132 | 133 | assert client_id_tuple == :error 134 | end 135 | end 136 | end 137 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | --------------------------------------------------------------------------------